feat: enhanced SEA mode with walker integration and VFS#229
feat: enhanced SEA mode with walker integration and VFS#229robertsLando wants to merge 39 commits intomainfrom
Conversation
Evolves the --sea flag from a simple single-file wrapper into a full packaging pipeline that reuses the walker for dependency discovery, maps files as SEA assets, and provides a runtime VFS bootstrap using @platformatic/vfs for transparent fs/require/import support. - Add seaMode to walker: skips bytecode compilation and ESM-to-CJS transform, but still discovers all dependencies via stepDetect - Add sea-assets.ts: generates SEA asset map and manifest JSON from walker output (directories, stats, symlinks, native addons) - Add sea-bootstrap.js: runtime bootstrap with lazy SEAProvider, native addon extraction, and process.pkg compatibility - Add seaEnhanced() to sea.ts: walker → refiner → asset gen → blob → bake pipeline with Node 25.5+ --build-sea detection - Route --sea to enhanced mode when input has package.json and target Node >= 22; falls back to simple mode otherwise - Add 4 test suites: multi-file project, asset access, ESM (skipped until node:vfs lands), and VFS fs operations Closes #204 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…mpat @platformatic/vfs requires Node >= 22 but CI runs on Node 20. Since the package is only needed at build time (esbuild bundles it into the sea-bootstrap) and the bundle step is skipped on Node < 22, making it optional prevents yarn install failures on older Node versions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Node.js 20 reached EOL in April 2026. This simplifies the project by removing the Node 20 CI matrix, test scripts, and engine check. It also allows @platformatic/vfs to be a regular dependency (requires Node 22+) and removes the conditional build script for sea-bootstrap. - Update engines to >=22.0.0 in package.json - Remove node20 from CI matrix (ci.yml, test.yml) - Update update-dep.yml to use Node 22 - Move @platformatic/vfs from optionalDependencies to dependencies - Simplify build:sea-bootstrap script (no Node version check) - Update README examples and SEA requirements - Update @types/node to ^22.0.0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Simple SEA mode was introduced in Node 20, not Node 22. The guard should reflect when the Node.js feature was added, not the project minimum. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The shared assertSeaNodeVersion() is used by both simple and enhanced SEA modes. Simple SEA was introduced in Node 20 — the check should reflect that. Enhanced mode's Node 22 requirement is enforced separately by the routing logic in index.ts. Also fixes README --sea description to explain both modes. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR upgrades pkg’s experimental --sea flag from a single-file wrapper into an “enhanced SEA” pipeline that reuses the existing walker/refiner and embeds a VFS-backed runtime bootstrap, while also raising the project’s minimum supported Node.js version to 22.
Changes:
- Add enhanced SEA orchestration (
seaEnhanced) that walks dependencies, generates SEA assets + a manifest, builds a SEA blob, and bakes it into target Node executables. - Introduce SEA VFS runtime bootstrap (
prelude/sea-bootstrap.js) bundled via esbuild, and add@platformatic/vfsas a dependency. - Drop Node 20 from engines/CI/scripts and add new SEA-focused tests.
Reviewed changes
Copilot reviewed 32 out of 34 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| yarn.lock | Adds lock entry for @platformatic/vfs. |
| package.json | Adds @platformatic/vfs, bumps @types/node to 22, builds SEA bootstrap bundle, removes Node 20 test script, bumps engines to Node >= 22. |
| lib/types.ts | Centralizes Marker/WalkerParams types and adds SeaEnhancedOptions. |
| lib/walker.ts | Adds seaMode behavior to skip bytecode/ESM transforms and keep dependency detection. |
| lib/sea-assets.ts | New: converts walker/refiner output into SEA asset map + manifest JSON. |
| lib/sea.ts | Adds seaEnhanced(), shared tempdir helper, execFile usage, and shared macOS signing helper. |
| lib/index.ts | Routes --sea to enhanced mode when appropriate; factors buildMarker(); reuses macOS signing helper. |
| prelude/sea-bootstrap.js | New: SEA runtime bootstrap that mounts a VFS from SEA assets and supports native addon extraction. |
| eslint.config.js | Ignores generated prelude/sea-bootstrap.bundle.js. |
| .gitignore | Ignores generated prelude/sea-bootstrap.bundle.js. |
| README.md | Updates SEA docs and examples to reflect enhanced mode and Node >= 22 focus. |
| .github/workflows/ci.yml | Removes Node 20 from matrix; updates lint run condition to Node 22. |
| .github/workflows/test.yml | Removes Node 20 from test matrix. |
| .github/workflows/update-dep.yml | Updates workflow to use Node 22. |
| test/utils.js | Adds assertSeaOutput() helper for SEA tests. |
| test/test-85-sea-enhanced/* | New enhanced SEA “multi-file project” test fixture. |
| test/test-86-sea-assets/* | New enhanced SEA “fs.readFileSync assets” test fixture. |
| test/test-87-sea-esm/* | New enhanced SEA ESM test fixture (currently skipped). |
| test/test-89-sea-fs-ops/* | New enhanced SEA filesystem ops test fixture. |
| plans/SEA_VFS_IMPLEMENTATION_PLAN.md | Adds design/analysis and implementation plan document. |
Move dlopen patching, child_process patching, and process.pkg setup into a shared module used by both the traditional and SEA bootstraps. This eliminates duplication and ensures both modes handle native addons, subprocess spawning, and process.pkg identically. - Replace copyFolderRecursiveSync with fs.cpSync (Node >= 22) - Add REQUIRE_SHARED parameter to packer wrapper for traditional mode - SEA bootstrap consumes shared module via esbuild bundling - Remove dead code (ARGV0, homedir import, copyFolderRecursiveSync) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Handle non-numeric nodeRange (e.g. "latest") in enhanced SEA gating - Bump assertSeaNodeVersion to require Node >= 22 matching engines - Guard stepStrip in walker to only strip JS/ESM files in SEA mode, preventing binary corruption of .node addons - Replace blanket eslint-disable in sea-bootstrap.js with targeted no-unused-vars override in eslint config - Wire symlinks through to SEA manifest and bootstrap provider - Document --build-sea Node 25 gating assumption Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of silently falling back to simple SEA mode when the input is a package.json/config but targets are below Node 22, throw a clear error. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Detailed comparison of traditional pkg mode vs enhanced SEA mode covering build pipelines, binary formats, runtime bootstraps, VFS provider architecture, shared code, performance, and code protection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SEA manifest uses POSIX keys but VFS passes platform-native paths on Windows. Normalize all paths to POSIX in the SEAProvider before manifest lookups, SEA asset lookups, and MemoryProvider storage. Remove the now- redundant symlink normalization block. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Explains that traditional mode with --no-bytecode produces a similar code protection profile to enhanced SEA (plaintext source), while still retaining compression and custom VFS format advantages. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace fs.cpSync with recursive copy using patched fs primitives so native addon extraction works through VFS in SEA mode - Use toPosixKey instead of snapshotify for symlink manifest keys to match the key format used by directories/stats/assets - Make assertSeaOutput log explicit skip on unsupported platforms instead of silently passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move VFS tree dump and fs call tracing from prelude/diagnostic.js into bootstrap-shared.js as installDiagnostic(). Both bootstraps now share the same diagnostic implementation. Security: diagnostics are only available when built with --debug / -d. In SEA mode this is controlled via manifest.debug flag set at build time; without it, installDiagnostic is never called. - Delete prelude/diagnostic.js (replaced by shared installDiagnostic) - Packer injects small DICT-dump snippet + shared call when --debug - SEA bootstrap gates on manifest.debug before calling installDiagnostic - Fix assertSeaNodeVersion: restore check to Node >= 20 (simple SEA) - Fix withSeaTmpDir: restore tmpDir cleanup (was commented out) - Update architecture docs with diagnostic usage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Bump assertSeaNodeVersion minimum from Node 20 to 22 (consistent with engines) - Extract generateSeaBlob() helper with --build-sea fallback for Node 25.x - Accept separate defaultEntrypoint in setupProcessPkg for process.pkg compat - Update ARCHITECTURE.md Simple SEA min Node from 20 to 22 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Worker thread support: - Extract VFS setup into shared sea-vfs-setup.js (used by both main and worker threads, no duplication) - Bundle sea-worker-entry.js separately via esbuild — workers get the same @platformatic/vfs module hooks as the main thread - Monkey-patch Worker constructor to intercept /snapshot/ paths and inject the bundled VFS bootstrap via eval mode - Add test-90-sea-worker-threads test Walker fix: - Skip stepDetect (Babel parser) for non-JS files in SEA mode — only run on .js/.cjs/.mjs files, matching the existing stepStrip guard. Eliminates thousands of spurious warnings on .json, .d.ts, .md, .node files in real-world projects. Build: - Extract build:sea-bootstrap into scripts/build-sea-bootstrap.js for two-step bundling (worker entry → string, then main bootstrap) TODO: Remove node_modules/@platformatic/vfs patches once platformatic/vfs#9 is merged and released. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The VFS mount point must always be POSIX '/snapshot' because @platformatic/vfs internally uses '/' as the path separator. The Windows prototype patches convert C:\snapshot\... and V:\snapshot\... to /snapshot/... before they reach the VFS. This was broken during the sea-vfs-setup.js extraction where SNAPSHOT_PREFIX was incorrectly set to 'C:\snapshot' on Windows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…PosixKey Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for the SEA runtime bootstrap: 1. Replace fs.copyFileSync with readFileSync+writeFileSync in the dlopen patch's cpRecursive helper. copyFileSync is not routed through the VFS in overlay mode, causing ENOENT when extracting native addon packages from the snapshot. 2. Wrap fs.realpathSync in try-catch in the diagnostic dumpLevel function. realpathSync can fail on VFS paths, crashing the DEBUG_PKG tree dump. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Packaging Performance Benchmarks — zwave-js-ui v11.15.1Benchmarked the enhanced SEA mode against standard PKG on zwave-js-ui (v11.15.1), a large real-world ESM project with native addons ( Environment: Node.js v22.22.1, Linux x86_64, target Summary
Raw DataBuild Times
Startup Times (5 runs, cold run discarded, average of runs 2-5)
Key Findings
VFS Fixes RequiredTesting against zwave-js-ui uncovered several issues in
Fixes 5-6 are committed on this branch. Fixes 1-4 require an upstream PR to |
…ation Optimize the SEAProvider hot paths for large projects (~20% faster startup): - Override internalModuleStat() with O(1) manifest lookup, bypassing the MemoryProvider tree walk (~30K calls saved for zwave-js-ui) - Override statSync() to return lightweight stat objects from the manifest - Cache readFileSync() results in a flat Map, bypassing MemoryProvider write+read roundtrip. Returns Buffer copies to prevent cache corruption. - Override existsSync() with pure manifest lookup Add DEBUG_PKG_PERF=1 runtime instrumentation (works on any SEA binary, no --debug build required). Reports phase timings (manifest parse, directory tree init, module loading) and provider counters (files loaded, stat/exists/readdir calls, sea.getRawAsset cumulative time). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SEA Provider Performance Optimizations — ResultsFollow-up to the initial benchmarks. After profiling the SEA no-bundle startup with What was optimized
Updated startup times (SEA no-bundle, zwave-js-ui)
Where the remaining time goesProfiled with The dominant remaining bottleneck is Future improvement: single-archive assetThe most impactful next step would be packing all VFS files into a single SEA asset (a blob with an offset index) instead of 16K+ individual assets. This would turn 1,776 Updated summary table
|
Single-Archive SEA — Benchmark ResultsThe archive optimization packs all VFS files into a single SEA asset ( Impact on
|
| Metric | Before (per-file assets) | After (single archive) |
|---|---|---|
getRawAsset() calls |
1,776 | 1 |
getRawAsset() total time |
1,047ms | 0.2ms |
| Module loading phase | 1,686ms | 622ms |
Full benchmark comparison
| Method | Build Time (avg of 3) | Binary Size | Startup (avg of runs 2-5) | Status |
|---|---|---|---|---|
| Standard PKG (no bundle) | 78.2s | 324 MB | N/A | ❌ ESM crash |
| Standard PKG (with bundle) | 16.5s | 111 MB | 261ms | ✅ |
| SEA with bundle | 10.1s | 145 MB | 381ms | ✅ |
| SEA without bundle (archive) | 39.9s | 377 MB | 711ms | ✅ |
Startup time progression (SEA no-bundle)
| Version | Startup | Improvement |
|---|---|---|
| Original (per-file assets, no provider opts) | 2,312ms | baseline |
| + Provider optimizations (fast stat/exists/read) | 1,841ms | -20% |
| + Single archive | 711ms | -69% |
Raw data
Build times
| Method | Run 1 | Run 2 | Run 3 | Average |
|---|---|---|---|---|
| Standard PKG (with bundle) | 16.9s | 16.0s | 16.7s | 16.5s |
| SEA with bundle | 10.0s | 10.4s | 9.9s | 10.1s |
| SEA without bundle (archive) | 39.9s | 39.6s | 40.2s | 39.9s |
Startup times
| Method | Run 1 (cold) | Run 2 | Run 3 | Run 4 | Run 5 | Average (2-5) |
|---|---|---|---|---|---|---|
| Standard PKG (with bundle) | 269ms | 252ms | 260ms | 270ms | 260ms | 261ms |
| SEA with bundle | 381ms | 371ms | 383ms | 379ms | 392ms | 381ms |
| SEA without bundle (archive) | 722ms | 707ms | 711ms | 712ms | 712ms | 711ms |
Perf profile (DEBUG_PKG_PERF=1)
[pkg:perf] phase time
[pkg:perf] ──────────────────────────────
[pkg:perf] manifest parse 21.3ms
[pkg:perf] archive load 0.2ms
[pkg:perf] directory tree init 20.7ms
[pkg:perf] vfs mount + hooks 22.3ms
[pkg:perf] vfs setup total 64.5ms
[pkg:perf] module loading 622.0ms
[pkg:perf]
[pkg:perf] counter value
[pkg:perf] ──────────────────────────────
[pkg:perf] files loaded 1776
[pkg:perf] file cache entries 1776
[pkg:perf] statSync calls 0
[pkg:perf] existsSync calls 1540
[pkg:perf] readdirSync calls 0
Key takeaways
- SEA without bundle is now viable for production — 711ms startup without requiring any bundler, handling ESM natively
- The archive eliminated the dominant bottleneck —
getRawAsset()went from 1,047ms (56% of startup) to 0.2ms - Remaining startup time is V8 parse/compile (~622ms for 1,776 source files) — inherent to source-mode execution
- SEA + bundle remains fastest for startup-critical use cases (381ms), but the no-bundle option is now within 2x
- Build time halved for SEA no-bundle (80.7s → 39.9s) — writing one archive is faster than registering 16K+ individual SEA assets
CLAUDE.md referenced .github/copilot-instructions.md via a markdown link, but Claude Code doesn't follow links — it only auto-loads CLAUDE.md and .claude/rules/*.md. Created 5 focused rule files with path-scoping so Claude Code actually sees the project instructions. Copilot instructions file is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ts for zero-copy access
…efactored VFS - README: fix stale node14 references, add dedicated SEA Mode section with simple vs enhanced variants, link to ARCHITECTURE.md - ARCHITECTURE: rewrite SEA binary format to document single __pkg_archive__ blob with offsets map instead of per-file assets, document sea-vfs-setup.js as shared VFS core, rewrite worker thread section (workers now reuse same @platformatic/vfs via sea-vfs-setup.js), fix all line counts, update VFS provider diagram and performance/code-protection tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Swap the VFS polyfill dependency from @platformatic/vfs to @roberts_lando/vfs@0.3.0 across all source, prelude, scripts, and documentation files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
| if ( | ||
| store === STORE_BLOB || | ||
| (this.params.seaMode && | ||
| (isDotJS(record.file) || isESMFile(record.file))) | ||
| ) { | ||
| const bodyBefore = record.body; | ||
| stepStrip(record); |
There was a problem hiding this comment.
In SEA mode you call stepStrip(record) for JS/ESM files, but stepStrip may remove a BOM/shebang and always overwrites record.body without setting record.bodyModified. generateSeaAssets() only embeds record.body when bodyModified is true; otherwise it streams the original file from disk, which can silently discard the stripped content and make SEA output differ from what the walker analyzed. Set record.bodyModified when stepStrip actually changes content (e.g., compare before/after), or avoid calling stepStrip unless you intend to persist the modified body.
| async function generateSeaBlob(seaConfigFilePath: string, nodeMajor: number) { | ||
| if (nodeMajor >= 25) { | ||
| try { | ||
| log.info('Generating the blob using --build-sea...'); | ||
| await execFileAsync(process.execPath, ['--build-sea', seaConfigFilePath]); | ||
| return; | ||
| } catch { | ||
| log.info( | ||
| '--build-sea not available, falling back to --experimental-sea-config...', | ||
| ); | ||
| } |
There was a problem hiding this comment.
generateSeaBlob() falls back from --build-sea to --experimental-sea-config on any error. This can mask real failures (e.g., invalid sea-config.json) and misleadingly log that --build-sea is unavailable. Narrow the fallback to the specific “unknown option/unsupported” failure (inspect error message/exit code), and rethrow other errors so users don’t get a different code path unexpectedly.
| ```bash | ||
| npm run build # Always build first | ||
| npm run test:22 # Test with Node.js 22 | ||
| npm run test:host # Test with host Node.js version | ||
| node test/test.js node22 no-npm test-50-* # Run specific test pattern | ||
| ``` | ||
|
|
There was a problem hiding this comment.
This testing guidance still references Node 20 (npm run test:20 and node test/test.js node20 ...), but this PR removes the test:20 script and bumps the minimum supported Node version to 22. Update these commands/examples to Node 22+ so contributors follow working instructions.
| ## TypeScript | ||
|
|
||
| - Strict mode enabled. Target: ES2022. Module system: CommonJS. | ||
| - Edit `lib/*.ts` only — never edit `lib-es5/*.js` directly. | ||
| - Requires Node.js >= 22.0.0. | ||
|
|
There was a problem hiding this comment.
This document says TypeScript targets ES2017 and the project requires Node.js >= 20.0.0, but tsconfig.json targets ES2022 and package.json engines is now >= 22.0.0. Please update these lines so contributor guidance matches the actual build/runtime requirements.
|
|
||
| `pkg` packages Node.js projects into standalone executables for Linux, macOS, and Windows. It supports Node.js 22 and newer, virtual filesystem bundling, V8 bytecode compilation, native addons, and compression (Brotli, GZip). | ||
|
|
There was a problem hiding this comment.
Project overview still claims support for Node.js 20 (node20, node22+), but this PR bumps engines.node to >=22 and removes Node 20 from CI/tests. Update this sentence to avoid conflicting guidance about supported Node versions.
| module.exports.assertSeaOutput = function (testName, expected) { | ||
| const platformSuffix = { linux: 'linux', darwin: 'macos', win32: 'win.exe' }; | ||
| const suffix = platformSuffix[process.platform]; | ||
| if (!suffix) { | ||
| console.log( | ||
| ` Skipping SEA assertion: unsupported platform '${process.platform}'`, | ||
| ); | ||
| return; | ||
| } | ||
| assert.equal( | ||
| module.exports.spawn.sync(`./${testName}-${suffix}`, []), | ||
| expected, | ||
| 'Output matches', | ||
| ); |
There was a problem hiding this comment.
assertSeaOutput compares stdout exactly, but SEA executables on Windows often emit different line endings (CRLF) and/or extra leading/trailing newlines (see existing test-00-sea Windows output mismatch). Normalizing the spawned output (e.g., convert CRLF→LF and optionally trim a single trailing newline) before assertion would make these SEA tests stable across platforms while still validating content.
| if (mIndex > 0) { | ||
| // Addon inside node_modules — copy the entire package folder to | ||
| // preserve relative paths for statically linked addons (fix #1075) | ||
| var modulePackagePath = parts.slice(mIndex).join(path.sep); | ||
| var modulePkgFolder = parts.slice(0, mIndex + 1).join(path.sep); | ||
| var destFolder = path.join(tmpFolder, path.basename(modulePkgFolder)); | ||
|
|
||
| if (!fs.existsSync(destFolder)) { | ||
| // Use patched fs primitives instead of fs.cpSync which may not | ||
| // be routed through the VFS in SEA mode. | ||
| (function cpRecursive(src, dest) { | ||
| var st = fs.statSync(src); | ||
| if (st.isDirectory()) { | ||
| fs.mkdirSync(dest, { recursive: true }); | ||
| var entries = fs.readdirSync(src); | ||
| for (var i = 0; i < entries.length; i++) { | ||
| cpRecursive( | ||
| path.join(src, entries[i]), | ||
| path.join(dest, entries[i]), | ||
| ); | ||
| } | ||
| } else { | ||
| // Use readFileSync+writeFileSync instead of copyFileSync because | ||
| // copyFileSync may not be routed through the VFS in SEA mode. | ||
| fs.writeFileSync(dest, fs.readFileSync(src)); | ||
| } | ||
| })(modulePkgFolder, destFolder); | ||
| } | ||
| newPath = path.join(tmpFolder, modulePackagePath, moduleBaseName); | ||
| } else { |
There was a problem hiding this comment.
patchDlopen skips copying the native addon package folder when destFolder already exists. With concurrent process starts, one instance can create destFolder before finishing the recursive copy; a second instance will see destFolder and skip copying, then attempt dlopen from a partially populated directory (ENOENT / load failures). Consider using a completion marker/atomic rename strategy, or at least check for the specific expected target file (newPath) before skipping the copy.
| async function generateSeaBlob(seaConfigFilePath: string, nodeMajor: number) { | ||
| if (nodeMajor >= 25) { | ||
| try { | ||
| log.info('Generating the blob using --build-sea...'); | ||
| await execFileAsync(process.execPath, ['--build-sea', seaConfigFilePath]); | ||
| return; | ||
| } catch { | ||
| log.info( | ||
| '--build-sea not available, falling back to --experimental-sea-config...', | ||
| ); | ||
| } | ||
| } | ||
| log.info('Generating the blob...'); | ||
| await execFileAsync(process.execPath, [ | ||
| '--experimental-sea-config', | ||
| seaConfigFilePath, | ||
| ]); | ||
| } |
There was a problem hiding this comment.
generateSeaBlob falls back from --build-sea to --experimental-sea-config on any error. That can mask real build failures (invalid config, permission issues, etc.) and lead to confusing secondary errors. Prefer falling back only when the failure indicates the flag is unsupported/unknown; otherwise rethrow the original error (or include stderr) so users see the real cause.
…ckage-lock.json, and yarn.lock
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
fc574b6 to
394deb1
Compare
|
So, comparing |
|
Thanks for the feedback! There are some important differences between this PR and fossilize that are worth highlighting: 1. Virtual Filesystem (VFS) — transparent Fossilize bundles everything into a single file with esbuild and requires you to use Our enhanced SEA mode uses 2. Native addon support Fossilize cannot handle 3. Worker thread support We monkey-patch the 4. Dependency walker integration Fossilize relies entirely on esbuild's bundling, which means apps must be fully bundleable. Our approach reuses pkg's battle-tested dependency walker ( 5. Stock Node.js binaries — no more patched builds This is actually a shared advantage with fossilize, and one of the main motivations for this PR. Traditional pkg requires patched Node.js binaries built and released via the yao-pkg/pkg-fetch project. These patches are hard to create, time-consuming to build for every Node.js release, and can lag behind upstream — users often have to wait for new builds before they can use a new Node.js version. With enhanced SEA mode, users can use official Node.js binaries directly from nodejs.org — no patches, no waiting for pkg-fetch releases, no maintenance burden. 6. On the performance comparison You're right about the numbers — SEA with bundle vs Standard PKG with bundle is 261ms vs 381ms startup and 111 MB vs 145 MB binary. That's a meaningful but not dramatic difference, and in return you gain stock Node.js binaries (no pkg-fetch dependency), ESM support, and a much simpler maintenance story. The tradeoff for losing bytecode/source protection is real, but for many use cases (CLI tools, internal services, IoT deployments) it's not a concern — and the elimination of the patched-binary dependency is a significant operational win. |
Summary
Implements #204 — evolves the
--seaflag from a simple single-file wrapper into a full packaging pipeline with walker integration and VFS support.Enhanced SEA mode
seaModeflag that skips bytecode compilation and ESM-to-CJS transform while still discovering all dependencies viastepDetectlib/sea-assets.ts): Transforms walker output into SEA-compatible asset map + manifest JSON with directory listings, stats, symlinks, and native addon detectionprelude/sea-bootstrap.js): Self-contained bootstrap bundled with@platformatic/vfsthat provides transparentfs/require/importsupport via lazySEAProvider, native addon extraction, andprocess.pkgcompatibilityWorkerconstructor to intercept/snapshot/...paths, injecting a self-contained VFS bootstrap (sea-worker-bootstrap.js) that usesnode:seadirectly for module resolution and fs operations in worker threadsseaEnhanced()inlib/sea.ts): Full pipeline: walker → refiner → asset generator → SEA blob → bake, with Node 25.5+--build-seadetection--seaautomatically uses enhanced mode when input haspackage.jsonand all targets are Node >= 22; falls back to simple mode otherwise--seamode (single pre-bundled file, Node 20+) works unchangedDrop Node.js 20 support
>=22.0.0inpackage.jsonci.yml,test.yml) andtest:20scriptupdate-dep.ymlworkflow to use Node 22@platformatic/vfs(requires Node 22+) added as regular dependency@types/nodebumped to^22.0.0Bug fixes
stepDetect(Babel parser) for non-JS files in SEA mode — previously caused thousands of spurious warnings on.json,.d.ts,.md,.nodefilesrequire.resolve()interception, trailing-slash specifiers, andmain-pointing-to-directory — see platformatic/vfs#9 for the upstream VFS fixesPerformance & Code Protection
The implementation plan at
plans/SEA_VFS_IMPLEMENTATION_PLAN.mdincludes a detailed analysis comparing traditional pkg vs enhanced SEA mode on code protection (bytecode vs plaintext), startup time, executable size, build speed, and memory footprint.Node.js Ecosystem
@platformatic/vfsas VFS polyfill (Node 22+), with built-in migration path tonode:vfswhen nodejs/node#61478 landsmainFormat: "module") ready for Node 25.7+ per nodejs/node#61813Upstream dependencies
@platformatic/vfsmodule hooks (built-in shadowing, resolution order,require.resolve, trailing slash, main-as-directory)Test plan
test-85-sea-enhanced— Multi-file CJS project with walker integrationtest-86-sea-assets— Non-JS asset access viafs.readFileSynctest-87-sea-esm— ESM project (skipped untilnode:vfslands in core)test-89-sea-fs-ops— VFS operations:readdir,stat,existsSynctest-90-sea-worker-threads— Worker thread spawning with VFS supporttest-00-sea— Existing simple SEA test (backward compat, no regression)🤖 Generated with Claude Code