Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e5c0d12
fix(sea): files inside symlinks are not resolved correctly (#295)
mpotthoff Aug 20, 2026
3534931
Address review comments
mpotthoff Aug 25, 2026
99e724b
refactor(prelude): bound symlink memo and shorten the resolution walk
robertsLando Sep 4, 2026
875eede
fix(prelude): keep exact symlink entries ahead of their symlinked parent
robertsLando Sep 4, 2026
9fec501
fix(sea): resolve symlinks in realpath, which also unbreaks readlink
robertsLando Sep 4, 2026
8d09411
fix(prelude): match the deepest symlink key and charge cached hops
robertsLando Sep 7, 2026
034a106
fix(prelude): report symlinks as links in readdir withFileTypes
robertsLando Sep 7, 2026
e8263d9
test(#295): build the symlink fixture at test time so Windows runs it
robertsLando Sep 7, 2026
afce27a
docs: sync the two bootstrap-shared.js line counts
robertsLando Sep 7, 2026
961543f
fix(prelude): service symlinks in classic-mode readlink and lstat
robertsLando Sep 7, 2026
71d7070
fix: address review round 1 on symlink resolution
robertsLando Sep 18, 2026
22a9355
fix(sea): report symlinks in lstat, readlink and readdir
robertsLando Sep 18, 2026
e2a33c0
fix: route ELOOP through callbacks, share the readlink contract
robertsLando Sep 18, 2026
6fefaec
chore: require @roberts_lando/vfs ^0.3.4 for the lstat/readlink routing
robertsLando Sep 18, 2026
a40f9cc
fix: close the remaining ELOOP call sites and share the parent walk
robertsLando Sep 18, 2026
58c78c0
refactor(prelude): one error shape, one link-target policy, one ELOOP…
robertsLando Sep 18, 2026
7f3578c
chore: install @roberts_lando/vfs from the PR commit until it releases
robertsLando Sep 18, 2026
10f47ae
fix(prelude): give the identity resolver a .parent
robertsLando Sep 18, 2026
013dcf9
refactor(prelude): narrow #296 to symlink resolution
robertsLando Oct 15, 2026
e079c7e
refactor(prelude): drop the symlink memo and report ELOOP at the call…
robertsLando Oct 30, 2026
202412f
test: cover the ESM workspace import from #295
robertsLando Oct 30, 2026
95ad62a
Merge branch 'main' into 295-resolve-symlinks
robertsLando Oct 30, 2026
d8231fe
fix: address review round 1 on symlink resolver
robertsLando Oct 30, 2026
754be9c
test: always damage prelude code in test-50-corrupt-executable
robertsLando Oct 30, 2026
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ examples/express/node_modules
yarn-error.log
tsconfig.tsbuildinfo
prelude/sea-bootstrap.bundle.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
test/test-99-#295/esm/node_modules
19 changes: 11 additions & 8 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` (1964 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 @@ -338,7 +338,7 @@ Execution flow (main thread):

The VFS polyfill (`@roberts_lando/vfs`) handles all `fs` and `fs/promises` patching automatically when `mount()` is called — intercepting 164+ functions including `readFile`, `readFileSync`, `stat`, `readdir`, `access`, `realpath`, `createReadStream`, `watch`, `open`, and their promise-based equivalents. It also hooks into the Node.js module resolution system for `require()` and `import`.

**Windows path strategy:** The VFS always mounts at `/snapshot` (POSIX). On Windows, `sea-vfs-setup.js` patches `VirtualFileSystem.prototype.shouldHandle` and `resolvePath` to strip drive letters and convert `\` to `/` before the VFS processes them. The `insideSnapshot()` helper checks for `/snapshot`, `V:\snapshot` (sentinel drive used by `@roberts_lando/vfs` module hooks), and `C:\snapshot` (used by dlopen/child_process).
**Windows path strategy:** The VFS always mounts at `/snapshot` (POSIX). On Windows, `sea-vfs-setup.js` patches `VirtualFileSystem.prototype.shouldHandle` and `resolvePath` to strip drive letters and convert `\` to `/` before the VFS processes them, and `VirtualFileSystem.prototype.realpathSync` to convert its result back to `C:\snapshot\...` so it matches `__filename` (#305). The `insideSnapshot()` helper checks for `/snapshot`, `V:\snapshot` (sentinel drive used by `@roberts_lando/vfs` module hooks), and `C:\snapshot` (used by dlopen/child_process).

### VFS Provider Architecture

Expand Down Expand Up @@ -398,10 +398,11 @@ The `SEAProvider` (in `prelude/sea-vfs-setup.js`) implements lazy loading from a
| -------------------------- | ----------------------------------------------------------------------------- |
| `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) |
| `internalModuleStat(path)` | Fast path for module resolution: 0 (file), 1 (dir), or a negative errno |
| `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()` |
| `readlinkSync(path)` | Unused by `fs.readlinkSync`, which vfs answers via `realpathSync` (#310) |
| `realpathSync(path)` | Follow symlinks, return the path if the manifest has it |

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` (~751 lines) contains runtime patches used by both bootstraps:

### Injection Mechanisms

Expand Down Expand Up @@ -502,6 +503,8 @@ 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)`** — The symlink resolver for **both** modes (`findVirtualFileSystemKeyAndFollowLinks` in classic, `SEAProvider._resolveSymlink` in SEA). It follows parent components like POSIX, so a link at `node_modules/@scope/lib` also resolves `node_modules/@scope/lib/package.json` (#295). The longest matching key wins; that agrees with POSIX because the walker stores realpaths as targets (`toNormalizedRealPath`). More than `MAX_SYMLINK_DEPTH` hops, possible only in a hand-edited manifest, raises `ELOOP`.

**`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 @@ -610,11 +613,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` | ~1964 | Traditional runtime bootstrap (fs/module/process patching) |
| `prelude/bootstrap-shared.js` | ~751 | 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` | ~577 | 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
106 changes: 106 additions & 0 deletions prelude/bootstrap-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -632,6 +632,109 @@ function installDiagnostic(snapshotPrefix) {
}
}

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

// Matches the typical Linux SYMLOOP_MAX.
var MAX_SYMLINK_DEPTH = 40;

// libuv's errno numbers differ on Windows (uv/errno.h). Negated at use.
var ERRNO = (function () {
var windows = process.platform === 'win32';
return {
ENOTDIR: windows ? 4052 : 20,
ENOENT: windows ? 4058 : 2,
EISDIR: windows ? 4068 : 21,
ELOOP: windows ? 4067 : 40,
};
})();

// Callers keep their own message: classic ENOENT carries pkg's asset hint.
function makeFsError(message, code, syscall, path_) {
var err = new Error(message);
err.code = code;
err.errno = -ERRNO[code];
if (syscall !== undefined) err.syscall = syscall;
err.path = path_;
err.pkg = true;
return err;
}

// Maps a snapshot key through its symlinks, parent components included:
// a link at `node_modules/@x/y` also resolves `.../@x/y/package.json` (#295).
// Runs before every snapshot fs call. `display` is the caller's path, shown in
// errors instead of the internal key.
function makeSymlinkResolver(symlinks, sep) {
var keys = Object.keys(symlinks);

// A symlink-free binary pays nothing.
if (keys.length === 0) {
return function (p) {
return p;
};
}

// Links sit at a few depths, so the scan below only slices at those.
var depthHasKey = [];
Comment thread
robertsLando marked this conversation as resolved.
Resolved
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;
}

function resolve(p, display, hops) {
// The walker stores realpaths as targets, so only a hand-edited manifest
// chains or loops; the hop bound turns a loop into ELOOP.
if (hops > MAX_SYMLINK_DEPTH) {
throw makeFsError(
"ELOOP: too many symbolic links encountered, '" + display + "'",
'ELOOP',
undefined,
display,
);
}

if (typeof symlinks[p] === 'string') {
return resolve(symlinks[p], display, hops + 1);
}

// Longest prefix wins: `<dir>/lib` and `<dir>/lib/sub` can both be keys.
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: `constructor` and friends are inherited, non-string values.
if (typeof symlinks[prefix] === 'string') {
bestPos = pos;
bestKey = prefix;
}
}
pos = p.indexOf(sep, pos + 1);
depth++;
}

if (bestKey === null) return p;

var target = symlinks[bestKey];
var rest = target.endsWith(sep) ? p.slice(bestPos + 1) : p.slice(bestPos);
return resolve(target + rest, display, hops + 1);
}

return function resolveKey(p, display) {
return resolve(p, display === undefined ? p : display, 0);
};
}

module.exports = {
pkgCacheDir: pkgCacheDir,
patchDlopen: patchDlopen,
Expand All @@ -642,4 +745,7 @@ module.exports = {
COMPRESS_NONE: COMPRESS_NONE,
pickDecompressorSync: pickDecompressorSync,
pickDecompressorAsync: pickDecompressorAsync,
makeSymlinkResolver: makeSymlinkResolver,
ERRNO: ERRNO,
makeFsError: makeFsError,
};
79 changes: 40 additions & 39 deletions prelude/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,25 +231,13 @@ function toOriginal(fShort) {
.join(path.sep);
}

const symlinksEntries = Object.entries(SYMLINKS);

// separator for substitution depends on platform;
const sepsep = DOCOMPRESS ? separator : path.sep;

const resolveSymlink = REQUIRE_SHARED.makeSymlinkResolver(SYMLINKS, sepsep);
Comment thread
robertsLando marked this conversation as resolved.
Resolved

function findVirtualFileSystemKeyAndFollowLinks(path_) {
let vfsKey = findVirtualFileSystemKey(path_, path.sep);
let needToSubstitute = true;
while (needToSubstitute) {
needToSubstitute = false;
for (const [k, v] of symlinksEntries) {
if (vfsKey.startsWith(`${k}${sepsep}`) || vfsKey === k) {
vfsKey = vfsKey.replace(k, v);
needToSubstitute = true;
break;
}
}
}
return vfsKey;
return resolveSymlink(findVirtualFileSystemKey(path_, path.sep), path_);
}

function realpathFromSnapshot(path_) {
Expand Down Expand Up @@ -521,9 +509,9 @@ function payloadFileSync(pointer) {
const windows = process.platform === 'win32';

const docks = {};
const ENOTDIR = windows ? 4052 : 20;
const ENOENT = windows ? 4058 : 2;
const EISDIR = windows ? 4068 : 21;
// One errno table for both preludes — see bootstrap-shared.js.
const makeFsError = REQUIRE_SHARED.makeFsError;
const ENOENT = REQUIRE_SHARED.ERRNO.ENOENT;

function assertEncoding(encoding) {
if (encoding && !Buffer.isEncoding(encoding)) {
Expand All @@ -536,35 +524,36 @@ function payloadFileSync(pointer) {
return typeof cb === 'function' ? cb : rethrow;
}

// The messages stay traditional-mode's own — this one carries pkg's
// "recompile adding it as asset" guidance and test-50-not-found-wording
// asserts it. Only the shape is shared.
function error_ENOENT(fileOrDirectory, path_) {
const error = new Error(
return makeFsError(
`${fileOrDirectory} '${stripSnapshot(path_)}' ` +
`was not included into executable at compilation stage. ` +
`Please recompile adding it as asset or script.`,
'ENOENT',
undefined,
path_,
);
error.errno = -ENOENT;
error.code = 'ENOENT';
error.path = path_;
error.pkg = true;
return error;
}

function error_EISDIR(path_) {
const error = new Error('EISDIR: illegal operation on a directory, read');
error.errno = -EISDIR;
error.code = 'EISDIR';
error.path = path_;
error.pkg = true;
return error;
return makeFsError(
'EISDIR: illegal operation on a directory, read',
'EISDIR',
'read',
path_,
);
}

function error_ENOTDIR(path_) {
const error = new Error(`ENOTDIR: not a directory, scandir '${path_}'`);
error.errno = -ENOTDIR;
error.code = 'ENOTDIR';
error.path = path_;
error.pkg = true;
return error;
return makeFsError(
`ENOTDIR: not a directory, scandir '${path_}'`,
'ENOTDIR',
'scandir',
path_,
);
}

// ///////////////////////////////////////////////////////////////
Expand Down Expand Up @@ -1107,7 +1096,7 @@ function payloadFileSync(pointer) {
Dirent.prototype.isFIFO = noop;

Dirent.prototype.isSymbolicLink = (fileOrFolderName) =>
Boolean(SYMLINKS[fileOrFolderName]);
typeof SYMLINKS[fileOrFolderName] === 'string';

function getFileTypes(path_, entries) {
return entries.map((entry) => {
Expand Down Expand Up @@ -1442,7 +1431,13 @@ function payloadFileSync(pointer) {
}

function existsFromSnapshot(path_) {
const entity = findVirtualFileSystemEntry(path_);
let entity;
try {
entity = findVirtualFileSystemEntry(path_);
} catch {
// Like fs.existsSync: a symlink loop is just "not there".
return false;
}
if (!entity) return findNativeAddonForExists(path_);
return true;
}
Expand Down Expand Up @@ -1652,7 +1647,13 @@ function payloadFileSync(pointer) {
.internalModuleStat(makeLong(translate(path_)));
}

const entity = findVirtualFileSystemEntry(path_);
let entity;
try {
entity = findVirtualFileSystemEntry(path_);
} catch (error) {
if (error.code !== 'ELOOP') throw error;
return -REQUIRE_SHARED.ERRNO.ELOOP;
}

if (!entity) {
return findNativeAddonForInternalModuleStat(path_);
Expand Down
Loading
Loading