Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ lib-es5/
prelude/sea-bootstrap.bundle.js
# Symlink needed for test
test/test-99-#108/lib/log.js
# Built by test-99-#295/main.js at test time, removed again afterwards
test/test-99-#295/lib
test/test-99-#295/reallib/inner.js
test/test-99-#295/linkinfo.json
51 changes: 38 additions & 13 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Each file is stored with one or more store types:

### Runtime Bootstrap

`prelude/bootstrap.js` (1970 lines) executes before user code. It:
`prelude/bootstrap.js` (2066 lines) executes before user code. It:

1. **Sets up entrypoint** — Reads `DEFAULT_ENTRYPOINT` from injected parameters, sets `process.argv[1]`
2. **Initializes VFS** — Builds in-memory lookup from `VIRTUAL_FILESYSTEM` dictionary with optional path compression via `DICT`
Expand Down Expand Up @@ -394,14 +394,15 @@ ASCII version:

The `SEAProvider` (in `prelude/sea-vfs-setup.js`) implements lazy loading from a single archive blob:

| Method | Behavior |
| -------------------------- | ----------------------------------------------------------------------------- |
| `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` |
| `statSync(path)` | Return metadata from manifest `stats` |
| `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or -2 (not found) |
| `readdirSync(path)` | Return directory entries from manifest `directories` |
| `existsSync(path)` | O(1) check against manifest `stats` |
| `readlinkSync(path)` | Return symlink target from manifest, fall back to `super.readlinkSync()` |
| Method | Behavior |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `readFileSync(path)` | Resolve symlinks, `subarray()` from archive via `offsets` map, cache in `Map` |
| `statSync(path)` | Return metadata from manifest `stats` |
| `internalModuleStat(path)` | Fast path for module resolution: returns 0 (file), 1 (dir), or -2 (not found) |
| `readdirSync(path)` | Return directory entries from manifest `directories` |
| `existsSync(path)` | O(1) check against manifest `stats` |
| `readlinkSync(path)` | Return symlink target from manifest, resolving a symlinked parent first, then fall back to `super.readlinkSync()`. Not reached via `fs.readlinkSync` — the VFS polyfill answers that through `realpathSync` (yao-pkg/pkg#299) |
| `realpathSync(path)` | Follow the symlink chain, then return the path if the manifest has it. Load-bearing: without it every archive path raises `ENOENT`, which also breaks `fs.readlinkSync` |

The entire archive is loaded once via `sea.getRawAsset('__pkg_archive__')` which returns a zero-copy `ArrayBuffer` reference to the executable's memory-mapped region. Individual files are extracted via `Buffer.subarray(offset, offset + length)` using the manifest's `offsets` map, then cached in a `Map` on first access. String results (when `encoding` is specified) are derived directly from the archive view; Buffer results are copied to prevent callers from corrupting the shared archive memory.

Expand Down Expand Up @@ -467,7 +468,7 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a

## Shared Runtime Code

`prelude/bootstrap-shared.js` (~438 lines) contains runtime patches used by both bootstraps:
`prelude/bootstrap-shared.js` (~813 lines) contains runtime patches used by both bootstraps:

### Injection Mechanisms

Expand Down Expand Up @@ -502,6 +503,30 @@ This keeps the VFS setup, shared patches, worker interception, and diagnostics a
- Set `PKG_EXECPATH` env var so child processes can detect they were spawned from a packaged app
- Replace references to `node`, `process.argv[0]`, or the entrypoint with `process.execPath` (the actual executable)

**`makeSymlinkResolver(symlinks, sep)`** — Builds the symlink resolver used by **both** modes: the traditional bootstrap (`findVirtualFileSystemKeyAndFollowLinks`) and the SEA provider (`SEAProvider._resolveSymlink`). It returns a function mapping a virtual path onto what its symlinks point at, walking parent components the way POSIX does — so a link at `node_modules/@scope/lib` also resolves `node_modules/@scope/lib/package.json` (#295).

An empty `symlinks` record yields the identity function, so a symlink-free binary pays nothing. Otherwise the resolver precomputes which path depths can host a symlink key and memoises each key's fully resolved target — the memo is keyed by manifest entry, not by the caller's path, so it stays bounded by the manifest however many paths are looked up. A manifest cycle raises `ELOOP` rather than hanging startup, with libuv's platform errno (`-4067` on Windows, `-40` elsewhere) and the caller's syscall name.

Matching is **longest-prefix-wins**, not first-match-in-insertion-order: when both `<dir>/lib` and `<dir>/lib/sub` are keys, the deeper one describes the whole chain while the shallower one would strand the walk on a path the archive has no entry for. That differs from POSIX's leftmost-first walk, and the two agree only because every target the walker records is already a full realpath (`toNormalizedRealPath`), so no component of a target can itself be a key.

### Symlink semantics in packaged binaries

Both bootstraps resolve symlinks on parent path components, so `require`, `fs.readFile` and friends reach files under a linked directory. Where they differ:

| | Traditional | Enhanced SEA |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `readdir({ withFileTypes: true })` | Reports a snapshot symlink as a link (`isSymbolicLink()` true, `isDirectory()`/`isFile()` false), matching real `readdir` | Listing comes from `manifest.directories`, which holds resolved paths only, so link entries are not surfaced |
| `lstat` | Describes the link itself, agreeing with the dirent above | Follows the link |
| `readlink` | Returns the target from `SYMLINKS`; `EINVAL` for a path that exists but is not a link | Answered by the VFS polyfill through `realpathSync` (yao-pkg/pkg#299) |
| `realpath` | Follows the chain | Follows the chain |

> **Breaking change (traditional mode, since #296).** `readdir({ withFileTypes: true })` previously reported every snapshot entry as a plain file or directory — `Dirent.isSymbolicLink()` took an argument it is never called with, so it always returned `false`. It now reports links as links, which is what Node does outside a packaged binary. Two consequences for packaged apps whose snapshot contains symlinks (pnpm and workspace trees most of all, plus `node_modules/.bin`):
>
> - Recursive walkers that gate descent on `isDirectory()` and skip links by default (glob, fast-glob, readdirp, `fs.cp` with `recursive`) no longer descend into a symlinked directory unless told to follow links.
> - A filter like `entries.filter((e) => e.isFile())` no longer matches a symlinked **file** — `node_modules/.bin/*` is the common case.
>
> Both match unpackaged Node. `fs.readlink` and `fs.lstat` were patched in the same change so that code taking the `isSymbolicLink()` branch is served rather than falling through to the host filesystem.

**`setupProcessPkg(entrypoint)`** — Creates the `process.pkg` compatibility object with `entrypoint`, `defaultEntrypoint`, and `path.resolve()`.

**`installDiagnostic(snapshotPrefix)`** — Installs runtime diagnostics triggered by the `DEBUG_PKG` environment variable. Available in both traditional and SEA modes. The implementation lives in `prelude/bootstrap-shared.js` and is always present in the runtime bootstrap, but it is **only invoked when the binary was built with `--debug` / `-d`** — release builds omit the entrypoint call, so the diagnostic handler never runs and cannot expose the VFS tree contents.
Expand Down Expand Up @@ -619,11 +644,11 @@ With `node:vfs` and `"useVfs": true` in the SEA config, assets will be auto-moun

| File | Lines | Purpose |
| -------------------------------- | ----- | -------------------------------------------------------------------------------------------- |
| `prelude/bootstrap.js` | ~1970 | Traditional runtime bootstrap (fs/module/process patching) |
| `prelude/bootstrap-shared.js` | ~486 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics) |
| `prelude/bootstrap.js` | ~2066 | Traditional runtime bootstrap (fs/module/process patching) |
| `prelude/bootstrap-shared.js` | ~813 | Shared runtime patches (dlopen, child_process, process.pkg, diagnostics, symlink resolution) |
| `prelude/sea-bootstrap.js` | ~74 | CJS wrapper: Module.runMain() (CJS) or vm.Script + USE_MAIN_CONTEXT_DEFAULT_LOADER (ESM/TLA) |
| `prelude/sea-bootstrap-core.js` | ~121 | Shared setup: VFS, patches, worker interception, diagnostics, perf start |
| `prelude/sea-vfs-setup.js` | ~469 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches |
| `prelude/sea-vfs-setup.js` | ~609 | SEA VFS core: SEAProvider, archive loading, VFS mount, Windows patches |
| `prelude/sea-worker-entry.js` | ~11 | Worker thread entry: requires sea-vfs-setup.js for VFS in workers |
| `scripts/build-sea-bootstrap.js` | ~50 | Build script: 2-step esbuild bundling (worker string + CJS main) |
| `lib/index.ts` | ~704 | CLI entry point, mode routing |
Expand Down
179 changes: 179 additions & 0 deletions prelude/bootstrap-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,184 @@ function installDiagnostic(snapshotPrefix) {
}
}

// /////////////////////////////////////////////////////////////////
// SYMLINK PROCESSING //////////////////////////////////////////////
// /////////////////////////////////////////////////////////////////

// Matches the typical Linux SYMLOOP_MAX. Bounds symlink resolution so a
// manifest cycle (or a corrupt manifest) cannot hang startup.
var MAX_SYMLINK_DEPTH = 40;

// libuv gives ELOOP a different number on Windows (uv/errno.h: UV__ELOOP is
// -4067 there, -40 everywhere else). Same positive-constant, negated-at-use
// convention as bootstrap.js's own error codes.
var ELOOP = process.platform === 'win32' ? 4067 : 40;

// Marks a symlink key whose resolution is still on the stack, so a cycle
// (/a -> /b -> /a, or /a -> /a/b) is caught instead of recursing forever.
var RESOLVING = {};

/**
* Build a symlink resolver over a manifest's symlinks record.
*
* The returned function maps a virtual path onto what its symlinks point at,
* following parent components the way POSIX does: `node_modules/@x/y` being a
* link makes `node_modules/@x/y/package.json` resolve too (#295).
*
* This runs before every fs operation inside a packaged binary (~30K times at
* startup on a large project). The empty-manifest case is allocation-free; the
* no-match case costs one `slice` per depth that actually hosts a key, not one
* per path component (see `depthHasKey` below).
*
* The returned resolver keeps hop-accounting state in its closure, so it is not
* reentrant — never call it from inside its own resolution.
*/
function makeSymlinkResolver(symlinks, sep) {
var keys = Object.keys(symlinks || {});

// Nothing to resolve: hand back identity, so no caller needs a guard of its
// own and a symlink-free binary pays nothing.
if (keys.length === 0) {
return function (p) {
return p;
};
}

// Symlink keys sit at a handful of depths — a package manager's links all
// live at the same level of node_modules. Recording which separator counts
// can host a key lets the walk below slice only at those depths and stop
// past the deepest one: for a 15-segment path in a tree whose links live at
// depth 4, that is one probe instead of fifteen.
var depthHasKey = [];
var maxDepth = 0;
for (var i = 0; i < keys.length; i++) {
var depth = 0;
var at = keys[i].indexOf(sep, 1);
while (at > 0) {
depth++;
at = keys[i].indexOf(sep, at + 1);
}
depthHasKey[depth] = true;
if (depth > maxDepth) maxDepth = depth;
}

// Symlink key -> { target, cost }: where the key fully resolves to, and how
// many hops that took. Keyed by manifest entry rather than by the caller's
// path, so the map stays bounded by the manifest no matter how many distinct
// paths are looked up — including ones an application derives from untrusted
// input. It also amortizes across siblings: every file under one linked
// directory reuses a single entry.
var resolved = new Map();

// High-water hop count of the resolution currently in flight. follow() reads
// it to record each key's `cost`, so a cache hit can charge the hops the
// collapsed chain stands for instead of getting them for free — otherwise
// MAX_SYMLINK_DEPTH would depend on which path happened to be looked up
// first.
var deepest = 0;

// Syscall reported by any ELOOP raised by the resolution in flight. Held in
// the closure rather than threaded through resolve()/follow(), which are on
// the startup hot path.
var syscall = 'stat';

function eloop(origin) {
var err = new Error(
'ELOOP: too many symbolic links encountered, ' +
syscall +
" '" +
origin +
"'",
);
err.code = 'ELOOP';
err.errno = -ELOOP;
err.syscall = syscall;
err.path = origin;
return err;
}

function follow(key, origin, hops) {
var cached = resolved.get(key);
if (cached !== undefined) {
if (cached === RESOLVING) throw eloop(origin);
var reached = hops + cached.cost;
if (reached > MAX_SYMLINK_DEPTH) throw eloop(origin);
if (reached > deepest) deepest = reached;
return cached.target;
}
resolved.set(key, RESOLVING);
// Restart the high-water mark at this key's depth so `cost` measures this
// subtree alone, then fold it back into the caller's mark on the way out.
var outer = deepest;
deepest = hops;
var target;
try {
target = resolve(symlinks[key], origin, hops + 1);
} catch (e) {
// Don't leave the sentinel behind, or a caught ELOOP would poison this
// key for every later lookup.
resolved.delete(key);
throw e;
}
resolved.set(key, { target: target, cost: deepest - hops });
if (outer > deepest) deepest = outer;
return target;
}

function resolve(p, origin, hops) {
if (hops > MAX_SYMLINK_DEPTH) throw eloop(origin);
if (hops > deepest) deepest = hops;

// Longest prefix wins. The walker keys every entry on the *unresolved*
// path it walked (`appendSymlink` in lib/walker.ts) and each target is
// already fully realpath'd, so the deepest key describes the whole chain
// while a shallower one would strand the walk on a path the archive has no
// entry for. `<dir>/lib` and `<dir>/lib/sub` can both be keys. An exact
// match is just the deepest case, so it short-circuits the scan below.
//
// Deepest-prefix-first is not POSIX's leftmost-first, and the two agree
// only because every target the walker records is already a full realpath
// (`toNormalizedRealPath` in lib/walker.ts), so no component of a target
// can itself be a key. A hand-written manifest that breaks that invariant
// would resolve differently here than on disk.
if (typeof symlinks[p] === 'string') return follow(p, origin, hops);

var bestPos = -1;
var bestKey = null;
var pos = p.indexOf(sep, 1);
var depth = 0;
while (pos > 0 && depth <= maxDepth) {
if (depthHasKey[depth]) {
var prefix = p.slice(0, pos);
// typeof, not truthiness: the record is JSON-derived and read with a
// bracket index, so `__proto__`/`constructor`/`toString` would
// otherwise match on an inherited, non-string value.
if (typeof symlinks[prefix] === 'string') {
bestPos = pos;
bestKey = prefix;
}
}
pos = p.indexOf(sep, pos + 1);
depth++;
}

if (bestKey === null) return p;

var target = follow(bestKey, origin, hops);
// Drop the remainder's leading separator when the target already ends in
// one, so the join cannot double up.
var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos);
// The remainder may hold links of its own, so walk the result.
return resolve(target + rest, origin, hops + 1);
}

return function (p, forSyscall) {
deepest = 0;
syscall = forSyscall || 'stat';
return resolve(p, p, 0);
};
}

module.exports = {
patchDlopen: patchDlopen,
patchChildProcess: patchChildProcess,
Expand All @@ -631,4 +809,5 @@ module.exports = {
COMPRESS_NONE: COMPRESS_NONE,
pickDecompressorSync: pickDecompressorSync,
pickDecompressorAsync: pickDecompressorAsync,
makeSymlinkResolver: makeSymlinkResolver,
};
Loading