Skip to content

@wtf/project-resolve

Documentation / @wtf/project-resolve

A Node resolve hook that runs a repo’s TypeScript straight from its source. Node strips the types itself, as it does from 24.15, the lowest Node version these packages support. The hook resolves the imports Node would not:

  • An import of a workspace package resolves, by its @wtf/source condition, to its TypeScript, even alongside a module.register loader, such as the one Storybook registers. A package from the registry resolves as it ships.
  • A relative import of the project’s own module that Node finds no file for, written the way tsc writes it, ./x.js, or with no extension, ./x, resolves to the .ts file next to it, or to its folder’s index.ts.

The hook knows the packages of two workspaces: the working directory’s, and the workspace of the script Node runs, at the nearest pnpm-workspace.yaml above it. So a dev entry started from a folder outside its repo, as when an MCP client starts /repo/packages/cli/bin/dev.js mcp, still runs its workspace’s source. When both workspaces have a package of the same name, the working directory’s wins.

Each module resolves to the real path of its file, as Node means it, even after a process has read the stat of its stdin pipe, which Node’s own realpath mistakes for a link. The exception is when Node is told to keep links (--preserve-symlinks, or NODE_PRESERVE_SYMLINKS).

It has no dependencies, so an application can use it at run time.

SubpathWhat you get
./registerRegisters the hooks with Node’s registerHooks, once per process. Once they are registered, @wtf/project-resolve/registered resolves to REGISTERED
./conditionSOURCE_CONDITION, @wtf/source: the export condition of a package’s TypeScript source, which the presets resolve by
./hooksThe hooks as functions, sourcesHookOf(packages, realpath) and resolveTypeScript, and sourcesOf(packages)
./workspaceworkspaceAt(root, { platform }) and workspaceOf(folder, { platform }), the workspace a project is in; workspaceRootOf, workspaceRootAbove and packagesOf, with { platform }; filesOf
./nodenodePlatform(): Node’s disk and paths, as the readers of ./workspace take them
./errorsResolveError, the base, with a code; WorkspaceError, for a workspace file that describes no valid workspace; UnreadableWorkspaceError, for a pnpm-workspace.yaml that exists but cannot be read
./envwithoutHook(env): the environment for a process started without the hook
Terminal window
node --import @wtf/project-resolve/register src/main.ts
NODE_OPTIONS="$NODE_OPTIONS --import=@wtf/project-resolve/register" vitest run --configLoader native

An entry point imports it first, before anything it resolves:

// oxlint-disable-next-line import/no-unassigned-import -- it registers the hook
import '@wtf/project-resolve/register'
const { main } = await import('../src/main.ts')

Every process another process starts inherits NODE_OPTIONS, and Node imports the hook on startup from the process’s own folder. So a process started in a folder where the hook is not installed, such as npm packing a copy of a package, fails to start. withoutHook(env) returns the environment with each --import of the hook removed:

import { spawnSync } from 'node:child_process'
import { withoutHook } from '@wtf/project-resolve/env'
declare const copy: string
spawnSync('npm', ['pack'], { cwd: copy, env: withoutHook(process.env) })

Read a workspace

./workspace reads the workspace a project is in, the way its package manager reads it: its root, its package manager, and its packages, by name.

  • pnpm: the packages list in pnpm-workspace.yaml. Each entry is a glob of folders: * and ? match within a path segment, ** matches any number of segments, and a leading ! leaves out what it matches, wherever it is in the list. The rest of the file is not read.
  • npm, yarn and bun: the workspaces of the root’s package.json, as a list, or yarn’s { packages }. The package manager is the one its packageManager or devEngines field names, else the one whose lockfile is at the root, else npm.
  • A folder whose package.json has no name is not a package; it is one of the workspace’s projects, which are all the folders its globs name that have a package.json. Neither is a folder inside node_modules, or one whose name starts with ., unless a glob names it explicitly.

workspaceAt(root) returns the workspace whose root is root. workspaceOf(folder) returns the nearest one, from folder upwards, that contains it. A project folder that the workspace above does not name, such as a git submodule with a repo of its own, is in its own workspace, or in none. Both return undefined when there is no workspace.

They throw a WorkspaceError, from ./errors, when a workspace file is not valid: a package.json that is not a JSON object, packages or workspaces that is not a list, or two packages with the same name. A pnpm-workspace.yaml that exists but cannot be read is not treated as missing: a folder with that name, a file the process may not read, or a link that loops each throws an UnreadableWorkspaceError (UNREADABLE_WORKSPACE), with what the file system said as its reason. packagesOf, which the hook and the Vitest configs use, reads each folder in packages/ and reports nothing.

What it reads with

They use no glob or YAML library, and read only through what the caller passes. workspaceAt and workspaceOf read through the platform you pass:

  • its file system: navigator.storage.getDirectory(), the root that the paths start from, as in the WHATWG File System Standard and a browser’s FileSystemDirectoryHandle. Each absolute path is walked down from it with getDirectoryHandle, getFileHandle, entries() and getFile().text();
  • its paths.

The standard has no real paths. If a handle has a realpath() of its own, it gives the real path of each folder ** walks, so a link to a folder above is walked only once. If not, a folder’s path is the path walked, as given.

filesOf(platform) returns a path reader on the platform’s handles, where each read is a promise: a file’s text or bytes, a folder’s entries, whether a path exists, and a folder’s real path. The package depends on nothing: it declares the shapes it takes. In Node, nodePlatform() of ./node gives the disk as such handles, and node:path.

import { type WorkspaceOptions, workspaceAt } from '@wtf/project-resolve/workspace'
declare const root: string
declare const platform: WorkspaceOptions['platform']
const workspace = await workspaceAt(root, { platform })
for (const [name, folder] of workspace?.packages ?? []) console.log(name, folder)

The workspace a process runs in

The hook and the Vitest configs read the workspace a process runs in with Node’s platform:

  • workspaceRootOf(folder, { platform }) returns the folder of the nearest pnpm-workspace.yaml at or above the working directory. workspaceRootAbove(folder, { platform }) does the same, and returns undefined for a folder in no workspace.
  • packagesOf(root, { platform }) returns each package in packages/ that has an export map and that the globs name.

The hook, ./register, which Node imports first, passes Node’s own platform, nodePlatform() of ./node.

A folder in packages/ that the globs do not name, such as a git submodule, is not a package, and keeps its build. If the globs name no folder at all, every folder in packages/ is a package. The same goes, silently, for a workspace whose file cannot be read, such as workspaces that is not a list or a pnpm-workspace.yaml the file system fails to read, so no process stops on it at startup, for example one run in a folder of a project of its own. workspaceAt and workspaceOf read the same file, and do report what is wrong with it.

Modules