Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
843326b
fix(sea): files inside symlinks are not resolved correctly (#295)
mpotthoff Aug 20, 2026
deeee73
Address review comments
mpotthoff Aug 25, 2026
4fcf2cb
refactor(prelude): bound symlink memo and shorten the resolution walk
robertsLando Sep 4, 2026
b5b44b7
Merge branch 'main' into 295-resolve-symlinks
robertsLando Sep 4, 2026
3f8a1bc
fix(prelude): keep exact symlink entries ahead of their symlinked parent
robertsLando Sep 4, 2026
6df8da1
fix(sea): resolve symlinks in realpath, which also unbreaks readlink
robertsLando Sep 4, 2026
a6b03ec
fix(prelude): match the deepest symlink key and charge cached hops
robertsLando Sep 7, 2026
0c9c07f
fix(prelude): report symlinks as links in readdir withFileTypes
robertsLando Sep 7, 2026
b7df2fd
test(#295): build the symlink fixture at test time so Windows runs it
robertsLando Sep 7, 2026
410e2f7
docs: sync the two bootstrap-shared.js line counts
robertsLando Sep 7, 2026
ffd51e0
fix(prelude): service symlinks in classic-mode readlink and lstat
robertsLando Sep 7, 2026
f62213a
fix: address review round 1 on symlink resolution
robertsLando Sep 18, 2026
f191bed
fix(sea): report symlinks in lstat, readlink and readdir
robertsLando Sep 18, 2026
210a279
fix: route ELOOP through callbacks, share the readlink contract
robertsLando Sep 18, 2026
e961e50
chore: require @roberts_lando/vfs ^0.3.4 for the lstat/readlink routing
robertsLando Sep 18, 2026
030b059
fix: close the remaining ELOOP call sites and share the parent walk
robertsLando Sep 18, 2026
ec8b2b4
refactor(prelude): one error shape, one link-target policy, one ELOOP…
robertsLando Sep 18, 2026
c459a2e
chore: install @roberts_lando/vfs from the PR commit until it releases
robertsLando Sep 18, 2026
205bc25
fix(prelude): give the identity resolver a .parent
robertsLando Sep 18, 2026
85eb435
Merge branch 'main' into 295-resolve-symlinks
robertsLando Sep 28, 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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ 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
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
54 changes: 41 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` (2174 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,16 @@ 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 manifest directory entries directly; with `withFileTypes` builds shared `Dirent`s, marking links from `manifest.symlinks` |
| `existsSync(path)` | O(1) check against manifest `stats` |
| `readlinkSync(path)` | Return the symlink target from `manifest.symlinks`, resolving a symlinked parent first; `EINVAL` for a path that is present but not a link, `ENOENT` otherwise. Reached through `fs.readlinkSync` since #296 — `sea-vfs-setup.js` re-points the patch here, because the VFS's own one answers through `realpathSync` (yao-pkg/pkg#299) |
| `lstatSync(path)` | Describe the link itself: the target's stat with link semantics and `S_IFLNK` type bits. Reached through `fs.lstatSync` since #296, for the same reason as `readlinkSync` above |
| `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 VFS mounts under a POSIX `/snapshot` prefix, so on Windows the result is converted back to `C:\snapshot\...` before it leaves `fs` (yao-pkg/pkg#305) |

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

### Injection Mechanisms

Expand Down Expand Up @@ -502,6 +504,32 @@ 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` | Same — the provider builds `Dirent`s from `manifest.symlinks`, using the shared `Dirent` in `bootstrap-shared.js` |
| `lstat` | Describes the link itself, agreeing with the dirent above | Same — `SEAProvider.lstatSync` gives the target's stat link semantics |
| `readlink` | Returns the target from `SYMLINKS`; `EINVAL` for a path that exists but is not a link | Same, from `manifest.symlinks`, including the shared parent-resolution step |
| `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.

> **How SEA reports links (since #296).** `@roberts_lando/vfs` used to route `fs.lstat` through `findVFSForFsStat`, which calls `statSync` and therefore follows the link, and `fs.readlink` through `findVFSForRealpath`, which never reached the provider — so neither could report a symlink, and identical application code saw `lstatSync(link).isSymbolicLink() === true` in a traditional binary and `false` in a SEA one. Both now reach `SEAProvider`, which answers from `manifest.symlinks` — the same record the resolver walks, so reporting and resolution cannot disagree. The routing fix, and the `probeSync` change that stops the module hooks' `existsSync` gate flattening a provider's `ELOOP` into `ENOENT`, are upstream in [robertsLando/vfs#4](https://github.com/robertsLando/vfs/pull/4); **pkg needs a vfs release containing them**.

**`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 +647,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` | ~2174 | Traditional runtime bootstrap (fs/module/process patching) |
| `prelude/bootstrap-shared.js` | ~969 | 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` | ~754 | 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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
"@babel/parser": "^7.23.0",
"@babel/traverse": "^7.23.0",
"@babel/types": "^7.23.0",
"@roberts_lando/vfs": "^0.3.3",
"@roberts_lando/vfs": "robertsLando/vfs#19a694fc4c8524009aa3e597e456e6e20fe30c31",
"@yao-pkg/pkg-fetch": "3.6.6",
"esbuild": "^0.28.1",
"into-stream": "^9.1.0",
Expand Down
Loading
Loading