Skip to content

feat: enhanced SEA mode with walker integration and VFS#229

Open
robertsLando wants to merge 39 commits intomainfrom
feat/sea-enhanced-vfs
Open

feat: enhanced SEA mode with walker integration and VFS#229
robertsLando wants to merge 39 commits intomainfrom
feat/sea-enhanced-vfs

Conversation

@robertsLando
Copy link
Copy Markdown
Member

@robertsLando robertsLando commented Apr 7, 2026

Summary

Implements #204 — evolves the --sea flag from a simple single-file wrapper into a full packaging pipeline with walker integration and VFS support.

Enhanced SEA mode

  • Walker SEA mode: Reuses the existing dependency walker with seaMode flag that skips bytecode compilation and ESM-to-CJS transform while still discovering all dependencies via stepDetect
  • SEA asset generator (lib/sea-assets.ts): Transforms walker output into SEA-compatible asset map + manifest JSON with directory listings, stats, symlinks, and native addon detection
  • Runtime bootstrap (prelude/sea-bootstrap.js): Self-contained bootstrap bundled with @platformatic/vfs that provides transparent fs/require/import support via lazy SEAProvider, native addon extraction, and process.pkg compatibility
  • Worker thread support: Monkey-patches Worker constructor to intercept /snapshot/... paths, injecting a self-contained VFS bootstrap (sea-worker-bootstrap.js) that uses node:sea directly for module resolution and fs operations in worker threads
  • Enhanced orchestrator (seaEnhanced() in lib/sea.ts): Full pipeline: walker → refiner → asset generator → SEA blob → bake, with Node 25.5+ --build-sea detection
  • CLI routing: --sea automatically uses enhanced mode when input has package.json and all targets are Node >= 22; falls back to simple mode otherwise
  • Backward compatible: Existing simple --sea mode (single pre-bundled file, Node 20+) works unchanged

Drop Node.js 20 support

  • Node.js 20 reached EOL in April 2026
  • Minimum engine bumped to >=22.0.0 in package.json
  • Removed Node 20 from CI matrix (ci.yml, test.yml) and test:20 script
  • Updated update-dep.yml workflow to use Node 22
  • @platformatic/vfs (requires Node 22+) added as regular dependency
  • Updated README examples and target documentation
  • @types/node bumped to ^22.0.0

Bug fixes

  • Walker: Skip stepDetect (Babel parser) for non-JS files in SEA mode — previously caused thousands of spurious warnings on .json, .d.ts, .md, .node files
  • Walker: Fix file-before-directory resolution, built-in module shadowing, require.resolve() interception, trailing-slash specifiers, and main-pointing-to-directory — see platformatic/vfs#9 for the upstream VFS fixes

Performance & Code Protection

The implementation plan at plans/SEA_VFS_IMPLEMENTATION_PLAN.md includes 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

  • Uses @platformatic/vfs as VFS polyfill (Node 22+), with built-in migration path to node:vfs when nodejs/node#61478 lands
  • ESM SEA entry (mainFormat: "module") ready for Node 25.7+ per nodejs/node#61813

Upstream dependencies

  • platformatic/vfs#9 — fixes 5 module resolution issues in @platformatic/vfs module 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 integration
  • test-86-sea-assets — Non-JS asset access via fs.readFileSync
  • test-87-sea-esm — ESM project (skipped until node:vfs lands in core)
  • test-89-sea-fs-ops — VFS operations: readdir, stat, existsSync
  • test-90-sea-worker-threads — Worker thread spawning with VFS support
  • test-00-sea — Existing simple SEA test (backward compat, no regression)
  • Lint and build pass (145/146, 1 pre-existing failure unrelated)

🤖 Generated with Claude Code

robertsLando and others added 6 commits April 7, 2026 11:00
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/vfs as 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.

robertsLando and others added 4 commits April 7, 2026 11:43
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>
robertsLando and others added 3 commits April 7, 2026 13:55
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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 3 comments.

- 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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 36 out of 38 changed files in this pull request and generated 3 comments.

robertsLando and others added 5 commits April 7, 2026 14:44
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>
robertsLando and others added 2 commits April 8, 2026 10:04
…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>
@robertsLando
Copy link
Copy Markdown
Member Author

Packaging Performance Benchmarks — zwave-js-ui v11.15.1

Benchmarked the enhanced SEA mode against standard PKG on zwave-js-ui (v11.15.1), a large real-world ESM project with native addons (@serialport), complex dependency trees, and package.json exports with wildcards and #imports.

Environment: Node.js v22.22.1, Linux x86_64, target node22-linux-x64

Summary

Method Build Time (avg of 3) Binary Size Startup (avg of 4) Status
Standard PKG (no bundle) 78.2s 324 MB N/A ❌ ESM runtime crash
Standard PKG (with bundle) 42.3s 111 MB 265ms
SEA with bundle 24.9s 145 MB 382ms
SEA without bundle 80.7s 377 MB 2274ms

Raw Data

Build Times

Method Run 1 Run 2 Run 3 Average
Standard PKG (no bundle) 78.2s 78.2s
Standard PKG (with bundle) 42.7s 42.4s 41.7s 42.3s
SEA with bundle 24.9s 26.1s 23.9s 24.9s
SEA without bundle 95.8s 90.4s 56.0s 80.7s

Startup Times (5 runs, cold run discarded, average of runs 2-5)

Method Run 1 (cold) Run 2 Run 3 Run 4 Run 5 Average (2-5)
Standard PKG (no bundle) FAIL N/A
Standard PKG (with bundle) 254ms 266ms 277ms 275ms 243ms 265ms
SEA with bundle 380ms 374ms 391ms 391ms 373ms 382ms
SEA without bundle 2266ms 2280ms 2234ms 2278ms 2303ms 2274ms

Key Findings

  • SEA + bundle is the fastest to build (24.9s) — 41% faster than PKG + bundle (42.3s), because it skips V8 bytecode compilation entirely
  • PKG + bundle produces the smallest binary (111 MB) — bytecode is more compact than plaintext source (SEA + bundle: 145 MB)
  • PKG + bundle has the fastest startup (265ms) — pre-compiled bytecode avoids JS parse overhead
  • Standard PKG without bundle fails for ESM projects — bytecode compiler can't handle "type": "module" projects, crashes with ReferenceError: module is not defined in ES module scope
  • SEA without bundle works natively with ESM — no pre-bundling needed, walker intercepts all files. Trade-off: largest binary (377 MB), slowest startup (~2.3s) due to loading thousands of individual files from VFS
  • SEA + bundle is the best overall for ESM projects — fastest build, reasonable size, good startup, and full ESM support

VFS Fixes Required

Testing against zwave-js-ui uncovered several issues in @platformatic/vfs module hooks that were fixed during this work:

  1. Wildcard pattern support in exports maps (e.g., "./bindings/*")
  2. Package #imports (subpath imports) resolution for VFS-hosted files
  3. CJS exports field support in Module._resolveFilename patch
  4. CJS subpath directory resolution (e.g., require('pkg/subdir')subdir/index.js)
  5. fs.copyFileSync not routed through VFS in SEA overlay mode (dlopen patch)
  6. fs.realpathSync crash in diagnostic dumpLevel function

Fixes 5-6 are committed on this branch. Fixes 1-4 require an upstream PR to @platformatic/vfs (plan written at ../vfs/VFS_MODULE_HOOKS_FIXES_PLAN.md).

robertsLando and others added 2 commits April 8, 2026 14:10
…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>
@robertsLando
Copy link
Copy Markdown
Member Author

SEA Provider Performance Optimizations — Results

Follow-up to the initial benchmarks. After profiling the SEA no-bundle startup with DEBUG_PKG_PERF=1, we identified and optimized the VFS provider hot paths.

What was optimized

Optimization Impact
internalModuleStat() — O(1) manifest hash lookup instead of MemoryProvider tree walk Eliminated ~30K tree walks
statSync() — lightweight stat objects from manifest Avoids full VirtualStats allocation
readFileSync() — flat Map cache bypassing MemoryProvider write+read roundtrip Saves ~82ms of MemoryProvider overhead
existsSync() — pure manifest lookup, no fallback Removes redundant MemoryProvider stat calls

Updated startup times (SEA no-bundle, zwave-js-ui)

Variant Run 1 Run 2 Run 3 Run 4 Run 5 Average
Before (original) 2332ms 2300ms 2320ms 2282ms 2324ms 2312ms
After (optimized) 1835ms 1840ms 1863ms 1849ms 1820ms 1841ms
Improvement -20% (-471ms)

Where the remaining time goes

Profiled with DEBUG_PKG_PERF=1:

[pkg:perf] phase                 time
[pkg:perf] ──────────────────────────────
[pkg:perf] manifest parse        14.1ms
[pkg:perf] directory tree init   19.1ms
[pkg:perf] vfs mount + hooks     20.3ms
[pkg:perf] vfs setup total       53.4ms
[pkg:perf] module loading      1685.6ms
[pkg:perf]
[pkg:perf] counter                  value
[pkg:perf] ──────────────────────────────
[pkg:perf] files loaded              1776
[pkg:perf] file cache entries        1776
[pkg:perf] sea.getRawAsset()   1046.8ms
[pkg:perf] statSync calls               0
[pkg:perf] existsSync calls          1540
[pkg:perf] readdirSync calls            0

The dominant remaining bottleneck is sea.getRawAsset() — 1,047ms for 1,776 file reads (56% of total). This is the Node.js native SEA API reading assets from the executable binary at ~0.6ms per call.

Future improvement: single-archive asset

The 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 getRawAsset() calls into 1 call + buffer slicing, potentially cutting startup from ~1.8s to ~0.8s.

Updated summary table

Method Build Time Binary Size Startup Status
Standard PKG (no bundle) 78.2s 324 MB N/A ❌ ESM crash
Standard PKG (with bundle) 42.3s 111 MB 265ms
SEA with bundle 24.9s 145 MB 382ms
SEA without bundle (before) 80.7s 377 MB 2312ms
SEA without bundle (after) 80.7s 377 MB 1841ms

@robertsLando
Copy link
Copy Markdown
Member Author

Single-Archive SEA — Benchmark Results

The archive optimization packs all VFS files into a single SEA asset (__pkg_archive__) with an offset index. At runtime, one sea.getRawAsset() call loads the entire blob, and individual files are extracted via zero-copy Buffer.subarray().

Impact on sea.getRawAsset() bottleneck

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 bottleneckgetRawAsset() 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

robertsLando and others added 5 commits April 8, 2026 15:20
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>
…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>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 57 changed files in this pull request and generated 7 comments.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 57 changed files in this pull request and generated 5 comments.

Comment on lines +1002 to 1008
if (
store === STORE_BLOB ||
(this.params.seaMode &&
(isDotJS(record.file) || isESMFile(record.file)))
) {
const bodyBefore = record.body;
stepStrip(record);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +428 to +438
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...',
);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +11 to +17
```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
```

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +31
## 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.

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +6 to +8

`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).

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 53 out of 57 changed files in this pull request and generated 3 comments.

Comment on lines +288 to +301
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',
);
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +82
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 {
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +428 to +445
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,
]);
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
robertsLando and others added 2 commits April 8, 2026 16:51
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@robertsLando robertsLando force-pushed the feat/sea-enhanced-vfs branch from fc574b6 to 394deb1 Compare April 8, 2026 15:01
@Niek
Copy link
Copy Markdown

Niek commented Apr 8, 2026

So, comparing SEA with bundle vs Standard PKG (with bundle) = 44% slower startup, 30% larger binary, and you lose source code protection. No offence, but that sounds like a very hard sell. Additionally, if people prefer that route they would rather go with something like fossilize I think, which is a repo dedicated to creating Node SEA executables.

@robertsLando
Copy link
Copy Markdown
Member Author

robertsLando commented Apr 8, 2026

Thanks for the feedback! There are some important differences between this PR and fossilize that are worth highlighting:

1. Virtual Filesystem (VFS) — transparent fs/require/import support

Fossilize bundles everything into a single file with esbuild and requires you to use sea.getAsset() to access embedded files. There's no filesystem patching — code that does fs.readFileSync(path.join(__dirname, 'config.json')) or dynamically requires modules simply won't work without refactoring your application.

Our enhanced SEA mode uses @platformatic/vfs (and will migrate to node:vfs when nodejs/node#61478 lands) to provide a full VFS layer that transparently patches fs, require, and import. Existing code works without modification — just like traditional pkg mode.

2. Native addon support

Fossilize cannot handle .node native addons at all (esbuild limitation). Our implementation detects native addons via the walker, embeds them as SEA assets, and extracts them at runtime — same as traditional pkg.

3. Worker thread support

We monkey-patch the Worker constructor to intercept /snapshot/... paths and inject a VFS-aware bootstrap, so worker threads work transparently. Fossilize has no worker thread support.

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 (stepDetect) to discover all dependencies — including edge cases handled by our dictionary of package-specific configs. It then generates a SEA asset map with directory listings, stats, and symlinks.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants