@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/sourcecondition, to its TypeScript, even alongside amodule.registerloader, 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.tsfile next to it, or to its folder’sindex.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.
| Subpath | What you get |
|---|---|
./register | Registers the hooks with Node’s registerHooks, once per process. Once they are registered, @wtf/project-resolve/registered resolves to REGISTERED |
./condition | SOURCE_CONDITION, @wtf/source: the export condition of a package’s TypeScript source, which the presets resolve by |
./hooks | The hooks as functions, sourcesHookOf(packages, realpath) and resolveTypeScript, and sourcesOf(packages) |
./workspace | workspaceAt(root, { platform }) and workspaceOf(folder, { platform }), the workspace a project is in; workspaceRootOf, workspaceRootAbove and packagesOf, with { platform }; filesOf |
./node | nodePlatform(): Node’s disk and paths, as the readers of ./workspace take them |
./errors | ResolveError, 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 |
./env | withoutHook(env): the environment for a process started without the hook |
node --import @wtf/project-resolve/register src/main.tsNODE_OPTIONS="$NODE_OPTIONS --import=@wtf/project-resolve/register" vitest run --configLoader nativeAn entry point imports it first, before anything it resolves:
// oxlint-disable-next-line import/no-unassigned-import -- it registers the hookimport '@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
packageslist 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
workspacesof the root’s package.json, as a list, or yarn’s{ packages }. The package manager is the one itspackageManagerordevEnginesfield 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 insidenode_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’sFileSystemDirectoryHandle. Each absolute path is walked down from it withgetDirectoryHandle,getFileHandle,entries()andgetFile().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: stringdeclare 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 returnsundefinedfor a folder in no workspace.packagesOf(root, { platform })returns each package inpackages/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.
