Skip to content

spike(compile): nub compile launcher model, proven end-to-end (darwin-arm64) - #536

Draft
colinhacks wants to merge 109 commits into
mainfrom
compile-spike
Draft

spike(compile): nub compile launcher model, proven end-to-end (darwin-arm64)#536
colinhacks wants to merge 109 commits into
mainfrom
compile-spike

Conversation

@colinhacks

@colinhacks colinhacks commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

nub compile turns a JS/TS entry into a standalone executable: a Rust launcher carrying an embedded payload, which extracts real files into a content-addressed cache and runs them under stock Node.

Closes #67
Refs #567

The request in #67 suggested Node's SEA. This uses a Rust launcher with an embedded payload instead: SEA cannot carry a per-file executable bit or relocate a native package closure, both of which the addon cases below need.

What this adds

  • The payload container, the launcher, and the compile pipeline — bundling, Node embedding, --smol, --platform cross-compilation, and the extraction cache.
  • Native islands. When a .node addon survives bundling, its owning package and that package's production dependency closure are relocated under __nub_native/<hash>/, preserving real install geometry as ordinary files. This is what makes sharp work: its libvips shared library lives in a different package, and the addon resolves it by relative path at load time.
  • A per-file executable bit in the payload, so an embedded helper binary stays runnable after extraction.

Verified

Every claim below is a run, not an inference. Platform is named where it matters.

  • Windowsnub-core embed-runtime 429/0 and the launcher cache chain 47/0, on a real windows-latest runner. Both were failing before: a stock C:\ carries an INHERIT_ONLY ACE, the DACL walk counted it as an effective grant on C:\ itself, and ensure_safe_base therefore returned None for every candidate — no cache base was usable on any Windows machine.
  • Native addonssharp compiles and runs with the source tree and node_modules deleted, its transitive libvips resolving from the extracted island. This previously depended on llvm-strip being installed: with only Apple strip on PATH, the embedded Node lost its exported napi_* symbols and every addon failed to dlopen. Since release builds have no llvm-strip, that shape shipped broken; the strip step is now guarded.
  • Process topology — a compiled artifact's cluster workers see [artifact, entry] and nothing else. They previously also saw nub's private flags and a duplicated entry, because the entry chunk evaluates node:cluster before the preamble runs and Node's primary.js captures fork into a module-local const at that moment.
  • CommonJS → ESM interop — a deferred require() of an ES module resolves to the namespace. It previously resolved to undefined, silently, on every Node version; the cause is upstream in rolldown, where a concise arrow body is treated as a discarded expression statement.
  • Module, Worker, loader and cache matrices, diffed against plain Node where comparable.
  • Cache: cold, warm, five-way concurrent cold, and self-heal from a corrupted, truncated or tampered tree.
  • Two real applications compiled and run from a foreign working directory with their sources hidden.
  • macOS artifacts remain re-signable after injection, and still run afterward.

Test coverage this also fixes

CI ran two of roughly 221 compile integration tests, by name, both gated to a single Node version — and the second was unreachable regardless, since the step aborts at the first failure and the other test ran ahead of it. The Linux leg now runs the whole target. Separately, the launcher's cache-directory probe chain ran on ubuntu only, which is precisely why a defect that made every Windows cache base unusable was invisible; it now runs on the platforms it targets.

Known limitations

Documented in compile.mdx, with --external as the remedy where one applies.

  • An addon resolved through bindings, or through a specifier assembled at runtime, is not followed. Only static requires are.
  • A relative createRequire(...) result is an ordinary call the bundler cannot rewrite; use a static import.
  • The concise-arrow require() normalization is a stopgap until the rolldown fix lands upstream and the pin moves.

- nub-core: shared payload container (compile.rs) + host Node provision/resolve helpers
- nub-launcher: minimal runtime binary (own workspace) — section read, embed decompress-to-cache, --smol discover/shell-out provision, flag injection, signal-forwarding spawn
- nub-cli: nub compile arm behind the compile feature (Rolldown in-process bundle, strip+resign+zstd-19 Node, libsui section-inject + ad-hoc sign)

Claude-Session: https://claude.ai/code/session_01EcDRwN5Z9arPRLCdZUsafL
- launcher: dispatch __pdeath-watch verb (spawn.rs re-invokes current_exe); ruzstd 0.8 (match nub-core); build.rs reserves Mach-O headerpad so libsui section injection does not shift __TEXT (root cause of a post-injection SIGILL on the optimized binary)
- cli: register compile in SUBCOMMANDS + CLAP_HELP_COMMANDS so dispatch reaches it
- verified: AC1 embed (PATH-scrubbed cold 2.7s / warm 0.05s / dedup), AC2 real app (dep+TLA+dynamic import), AC3 --smol 0.56MB discovery

Claude-Session: https://claude.ai/code/session_01EcDRwN5Z9arPRLCdZUsafL
The post-resign --version verification could not exec the staged Node (fs::write
lands 0644), so every compile fell back to unstripped. chmod +x the staged copy
before strip/verify. Embed binary now 25.9 MB (stripped) vs 29.4 MB (unstripped).

Claude-Session: https://claude.ai/code/session_01EcDRwN5Z9arPRLCdZUsafL
@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nub Ready Ready Preview Aug 1, 2026 6:11am

Request Review

…erify, Init --help

- nub-core decode: checked_add on cumulative offsets + .get() slices (a corrupted
  section can no longer panic-abort the launcher); same for decode_app_region.
  Test: an overflowing length Errs cleanly.
- launcher --smol: verify the shell-out-provisioned Node's sha256 against
  SHASUMS256.txt before committing into the shared store (which nub run + the embed
  dedup path trust un-re-verified). Test: a bad checksum aborts.
- Init --help: the Compile variant's insertion had stolen Init's doc comment;
  restored so both summaries render.

Claude-Session: https://claude.ai/code/session_01EcDRwN5Z9arPRLCdZUsafL
… self-probe, CI leg

- cli.rs: gate the Compile clap variant + its SUBCOMMANDS/CLAP_HELP_COMMANDS
  entries behind #[cfg(feature = "compile")] so a shipped build exposes no
  compile verb (verified: default build → "not a nub command").
- launcher: reject payload file names that escape the extraction dir (absolute /
  ..  / leading separator) before writing; test added.
- compile.rs: error early on a non-macOS host (Mach-O-only injection) instead of
  failing deep in libsui.
- self-probe: the launcher gains a __probe verb (section read + decode + String
  alloc, no extract/spawn); nub compile runs it on the produced binary so an
  under-padded/corrupt injection is caught at compile time, not as a runtime SIGILL.
- ci.yml: lint + build the excluded nub-launcher crate (bit-rot guard).
- gitignore the launcher's in-tree target/.

Claude-Session: https://claude.ai/code/session_01EcDRwN5Z9arPRLCdZUsafL
… / smol range / error on none)

`nub compile` had its own standalone default (`--target` defaulting to "24")
rather than reading the project's Node pin, so a compiled binary could silently
target a different version than `nub run` uses in the same tree.

`--target` is now optional and overrides an inferred pin; with the flag omitted
the version comes from `resolve_pin_chain` — the same chain `nub run` walks
(devEngines.runtime, .node-version, .nvmrc, .tool-versions, engines.node) — and
provisioning reports it through the existing `Using Node.js <v> (resolved from
<source>)` surface rather than a compile-specific one.

The two shapes consume the pin differently. Embed resolves it to one exact
release at compile time (`resolve_host_pin`: a range/major/alias collapses to
the newest satisfying release) because exactly one Node is baked in. Smol keeps
the requirement open: the manifest records the acceptance floor (`pin_floor`) in
`node_version`, which the launcher's existing `discovered >= floor` rule already
enforces, plus the raw spec in a new `node_range` field for provenance.

With nothing pinned and no `--target`, compile errors instead of falling back to
"latest" — a deliberate divergence from `nub run`, since a compiled artifact's
Node version has to be reproducible.

Also anchors the self-probe's spawn path: `Command::new` PATH-searches a bare
name, so the default `--out` (the entry stem, no directory component) failed to
spawn and made `nub compile app.ts` fail at its last step.
The launcher resolved its extraction directory through
discovery::cache_dir(), which returns Some whenever HOME is set and never
tests writability, so the temp fallback chained off its None could not
fire on the boxes it existed for. On AWS Lambda, a readOnlyRootFilesystem
pod, or any read-only container the launcher failed with EROFS while a
writable /tmp went unused.

Replace it with a write-probed chain in the launcher:
NUB_COMPILE_CACHE_DIR, XDG_CACHE_HOME/nub, HOME/.cache/nub, then a per-uid
directory under the temp dir. Each candidate is created, written to, and
checked for a noexec mount; a rejection falls through to the next, and
exhausting the chain reports every path tried with its obstacle plus the
four remedies. discovery::cache_dir() is unchanged, so nub run and the
package manager keep their current behavior.

The temp candidate is per-uid, created 0700, and refused when it is a
symlink or already owned by another user — the hardcoded world-shared
/tmp/pkg shape behind CVE-2024-24828. Foreign ownership disqualifies every
candidate; other-write does too, while group-write is tolerated outside
the temp dir so a umask-002 host keeps the shared cache.

Node acquisition, app extraction, and smol provisioning now share the one
probed base rather than resolving separately, and an exec denial under
that base reports the noexec mount and its remedies instead of a bare
permission error.

Claude-Session: https://claude.ai/code/session_01FP7osXQ1YL9zSAZaBzzF6y
A cold start spends seconds decompressing the embedded Node, or longer
downloading one under --smol, with nothing on screen. The launcher owns
the terminal before it does any of that work, so it now prints a line
first. One mechanism covers both shapes: the message is baked into the
manifest at compile time and printed once, either before decompression or
before provisioning, replacing the smol-only "Installing Node ..." line.

The text defaults to "Setting up <output-name>" and is set with
--install-message; --no-install-message keeps the old silence. It is
printed to stderr on a terminal only, so piped output and logs are
unchanged, and a warm run never reaches the call.

Claude-Session: https://claude.ai/code/session_01FP7osXQ1YL9zSAZaBzzF6y
Decompression went through read_to_end into a Vec, so a cold run held the
whole ~94 MB decompressed Node in memory on top of the bytes it was
writing. Streaming the decoder into a buffered file writer measures the
same wall time (the decode dominates at ~3 s; the write is ~60 ms of it)
at roughly half the peak RSS, which is what decides the run on a
memory-capped host whose only writable filesystem is tmpfs charged
against the same limit.

Claude-Session: https://claude.ai/code/session_01FP7osXQ1YL9zSAZaBzzF6y
Decompression is essentially the whole cold start, and the pure-Rust
decoder is what makes it slow. Measured on darwin-arm64 against the 25 MB
zstd-19 payload, back to back on one contended host:

  ruzstd  decode 94 MB   2.81 s / 4.04 s / 4.29 s
  libzstd decode 94 MB   0.70 s / 0.82 s / 0.83 s
  write   94 MB to disk  0.055 s / 0.059 s / 0.064 s

End to end through a real compiled binary, cold, alternating runs:
libzstd 2.74 / 3.36 / 3.82 s against ruzstd 5.69 / 5.72 / 11.96 s. Disk
I/O is under 2% of the extraction; there is no fsync, hash, or
verification pass in the launcher's cold path to account for the rest.

ruzstd was chosen to keep the C zstd out of the launcher on size grounds.
The fat-LTO'd template measures 522,912 bytes with libzstd against 523,056
with ruzstd, so the premise does not hold, and the launcher already links
C through nub-core. Extraction is byte-identical under both decoders,
verified by sha256 against the source Node.

Claude-Session: https://claude.ai/code/session_01FP7osXQ1YL9zSAZaBzzF6y
The install-message section described a smol-only flag and a progress bar
that does not exist; both shapes now print a static line and the default
text derives from the output name. Adds a section for the cache the
default shape extracts into: the candidate order, NUB_COMPILE_CACHE_DIR,
what happens on a read-only or noexec filesystem, and warming the cache at
image-build time for a locked-down deploy.

Claude-Session: https://claude.ai/code/session_01FP7osXQ1YL9zSAZaBzzF6y
The `nub compile` spike pulled in rolldown 1.2.0, which requires oxc ^0.140,
while nub-native and nub-phantom-core pinned =0.132. oxc is pre-1.0, so those
never unify: a `--features compile` build compiled two full oxc stacks, and the
root Cargo.lock carried both.

Bump every consumer to one version:

  - nub-native: oxc/oxc_napi 0.132 -> 0.140, oxc_sourcemap 6.0.1 -> 8.1.0
  - nub-phantom-core: the lean parser subset 0.132 -> 0.140
  - the root [workspace.dependencies] declaration (read by version-check)
  - npm @oxc-project/runtime 0.132.0 -> 0.140.0 (the emit helpers are the JS
    half of the same oxc release) plus the three npm lockfiles

Two API breaks in nub-native, both fixed without a behavior change:

  - CompilerInterface::handle_errors now takes oxc_diagnostics::Diagnostics
    instead of Vec<OxcDiagnostic>.
  - DecoratorOptions gained strict_null_checks. Set to true, which reproduces
    pre-0.140 emit exactly; wiring it to tsconfig's strictNullChecks would
    change existing decorator metadata and is a separate decision.

oxc 0.140 requires rustc 1.95, which nub-native already declared, so the main
workspace MSRV moves 1.93 -> 1.95 and the CI check job installs one toolchain
for all three workspaces.

The lockstep guard the duplication argues for: scripts/check-oxc-lockstep.mjs
asserts every manifest, the npm dependency and all three npm lockfiles name one
oxc version, and that no Cargo.lock resolves two versions of any oxc crate. It
runs as `make oxc-lockstep-check` (folded into `make verify` and `make
version-check`, which no longer carries its own copy of the npm-vs-Cargo
assertion) and as an unconditional ci-gate-required job.
…platform

nub compile was Mach-O-only: it errored on a non-macOS host and called
libsui's Macho writer unconditionally. Add the ELF (note) and PE (RCDATA
resource) legs and route every platform-dependent decision through the
TARGET, not the host.

- nub-core gains TargetPlatform/ContainerFormat: the eight supported
  triples, their container format, exe suffix, and whether they are the
  host.
- Per-target strip and sign policy. macOS strips only when the result can
  be re-signed (stripping invalidates the signature and arm64 will not run
  an unsigned image); Linux and Windows are never signed, and a foreign
  format is stripped only with the multi-format llvm-strip.
- A foreign target's Node is provisioned into a triple-scoped store so it
  can never be mistaken for a runnable host Node.
- The produced artifact is verified by scanning it for its payload the way
  the target's loader will; the exec-it __probe check additionally runs
  when the target is the host, and is skipped with a note otherwise.
- The launcher template lookup is target-aware and names the missing file
  when a foreign template is absent.
…freezes

The wrapper existed to satisfy the WHATWG requirement that
`MessageEvent.ports` be a read-only array. Node 22.3 replaced the global
with undici's (nodejs/node#52370), whose own `get ports()` freezes, so
from 22.3 on the wrapper is a strict no-op — but the `typeof
globalThis.MessageEvent` guard that reached it was not free. That global
is a V8 lazy data property, and any read of it realizes
`internal/deps/undici/undici` plus ~112 further builtins (node:http, net,
the whole internal/streams tree, worker_threads) on EVERY augmented
startup.

Gate the wrapper on the Node version instead, so it installs only below
22.3 where the native `ports` really is mutable, and the global is never
touched above it. Measured on Node 26.5: bootstrap modules 225 -> 113,
startup 128.0 -> 91.0 CPU-ms.

The version gate is not a stylistic choice — a feature detect is
impossible here. Reading, describing, or redefining the lazy property all
materialize it; the one operation that does not (a plain assignment)
discards the native constructor irrecoverably, so there is no lazy
wrapper to install in its place.
`installLazyEsmPolyfills` decided whether it was running in a worker by
looking for `NativeModule worker_threads` in `process.moduleLoadList`.
That only reports residency, which anything running earlier in the
process can make true on the main thread — and something did: touching
the lazy `MessageEvent` global in `installSyncPolyfills` loaded
worker_threads, so the main thread took the worker branch and eagerly
loaded worker-polyfill.mjs and navigator-locks.mjs, which is exactly the
cold-start cost this lazy path exists to defer.

Keep the module-list probe as a gate, then confirm with
`worker_threads.isMainThread`. Consulting it is free because we only do
so once the module is already resident, and it cannot be poisoned: a
worker's bootstrap always loads worker_threads before any preload runs.
For an importer inside nub's own runtime directory, `resolveSpec`
resolves natively via `createRequire(parentURL).resolve(specifier)`. That
call runs through Node's CJS resolver, which invokes the registered
resolve hook — back into `resolveSpec` with the same specifier and
parent. The two called each other until V8 exhausted the stack, at which
point the RangeError landed in the surrounding catch and the resolution
completed through the fallback path.

It produced correct answers, so nothing failed; it just did so ~849
frames deep. The fast-tier preload made 1698 resolve-hook invocations
before user code ran, for three internal requires.

Guard the branch: a re-entrant call returns null and delegates to
`nextResolve`. The outer frame still short-circuits the user loader
chain, and Node's default resolver is what `require.resolve` was going to
consult anyway. Resolve-hook invocations at startup drop 1698 -> 5 and
startup 91.0 -> 50.4 CPU-ms on Node 26.5.
The MessageEvent regression cost ~112 extra internal modules on every
augmented startup with no functional symptom, so no behavior test could
have caught it. Measure the module set directly instead: assert that an
augmented startup never has `NativeModule http` or `NativeModule
worker_threads` resident (either means a preload step realized a lazy
global), and that on the fast tier the augmented count stays within 25
modules of the same fixture under `--node`.

The budget is a cliff detector for a whole internal tree, not a golden
count — `--node` supplies the baseline so it tracks whatever Node the
runner is on rather than a pinned number.
…(esm)

The lazy `Worker` and `navigator.locks` accessors load their ESM
side-effect module on first access, which only works while
`loadEsmSideEffect` can use a synchronous require(esm). Under
`--no-experimental-require-module` it falls back to a dynamic `import()`,
so the getter returned before the module had defined the real global and
`new Worker(...)` threw.

The path was unreachable until now — the poisoned worker detection sent
the main thread down the eager branch, which loaded the same module by a
route that tolerated the async fallback. With that corrected, load
eagerly whenever require(esm) is disabled. The startup cost is the right
trade there: the user opted out of the mechanism the lazy path is built
on, and correctness is not negotiable against it.
…e Windows Node correctly

Two defects found running the pipeline end to end.

The post-strip verification asked the staged Node for --version with the
environment inherited. A developer machine routinely exports a NODE_OPTIONS
aimed at a different Node — nub's own dev shell does — and Node rejects the
whole invocation when one of its flags is unknown to that binary, so a
perfectly good stripped Node looked broken and the fallback silently
embedded the unstripped one, ~27 MB heavier, behind a note. The check now
scrubs the ambient Node configuration so it tests the binary.

The launcher looked for its Node under bin/node everywhere. The Windows
dist zip puts node.exe at the version-dir root, so a Windows artifact would
have failed to find the Node it had just extracted.
Fallout of the version bump. flake.nix pinned the @oxc-project/runtime tarball
name + sha512 by hand, so the Nix build would have assembled the embedded
runtime/ tree with the 0.132 helpers against a 0.140 transformer. The remaining
edits are the doc comments that name the mirrored oxc release.

detect.rs's transformable_syntax set carries a "RE-DERIVE THIS SET ON ANY oxc
BUMP" mandate. Re-derived: oxc 0.140 has the same es20xx transform modules as
0.132 (es2015..es2022 plus es2026, no es2023/24/25), and a per-syntax probe of
both versions at target es2022 shows an identical lowered/verbatim verdict for
using, await using, the RegExp v flag, RegExp modifiers, duplicate named groups,
the d flag, import attributes, static blocks, private-in, logical assignment,
top-level await, optional chaining and import.meta. The set is unchanged.

Also corrects transform.rs's claim that the in-process mirror keeps existing
cache entries valid with no NUB_VERSION bump: that held for the JS-to-native
move, not across oxc versions.
Colin McDonnell added 5 commits July 31, 2026 17:43
Colin McDonnell added 2 commits July 31, 2026 18:02
Seven conflicts — six textual, one semantic — all resolved by keeping both
sides' intent rather than picking a side:

- Cargo.toml, crates/nub-native/Cargo.toml: main's jsonc-parser 0.32.4 bump
  (required by nub.jsonc) plus this branch's oxc 0.140.0 — the adjacent comment
  now names 0.140.0, the version the workspace actually pins.
- crates/nub-cli/Cargo.toml: main's nub-json-guard path dep alongside the
  rolldown/oxc compile-feature block.
- Makefile: main's schema-snapshot equality check added to version-check, on
  top of this branch's oxc-lockstep-check prerequisite and inlined-manifest
  version loop. The inline @oxc-project/runtime check main extended is not
  re-added — this branch moved that assertion into
  scripts/check-oxc-lockstep.mjs, which is a superset of it.
- .github/workflows/ci.yml: ci-gate needs is the union — windows-embed-roundtrip
  and oxc-lockstep from this branch, site-build from main.
- runtime/preload-common.cjs: main's core.dataExtsFor(url) (DATA_EXTS no longer
  exists) with this branch's getBuiltin("node:fs"), which keeps an inherited
  user loader from redirecting builtins before compiled startup.
- crates/nub-core/src/node/spawn.rs: merged clean but did not compile. This
  branch moved NEUTRALIZE_LOCALSTORAGE_ENV into flags.rs so nub-launcher (a
  separate workspace) can reach it; main added a new bare-name use of it in
  augmentation_environment_restoration. Qualified as flags::.
# Conflicts:
#	.claude/skills/release/SKILL.md
Colin McDonnell and others added 12 commits July 31, 2026 18:19
… fixture

The immutable-release jobs are new here and lacked the ignore every other
setup-node in this workflow carries; neither runs a package install, so the
default cache is never populated. The set-version fixture came from main and
creates only nub-native's inlined manifest, while this branch also stamps
nub-core's — merged, the script exits on ENOENT before reaching the schema
snapshot the test asserts.
…r grant

directory_is_stable evaluated INHERIT_ONLY ACEs as if they controlled access to
the directory carrying them. Stock Windows always ships C:\ with
`Authenticated Users:(OI)(CI)(IO)` granting GENERIC_WRITE|DELETE, so the volume
root — the first component of every chain — was rejected, and no runtime-cache or
compiled-artifact base could be validated or created on any Windows machine.
Skip inherit-only ACEs on non-leaf components, where they grant nothing and the
propagated ACE is examined on the next component anyway; the leaf still counts
them, because the cache contents created inside do receive them.

Also open the runtime_tree fixture's handle for write: Windows SetFileTime needs
FILE_WRITE_ATTRIBUTES, which File::open does not carry.
macOS serves the temp dir as /var/... while a process reports its own path as
/private/var/..., so the compiled-path assertions failed on every macOS host.
This masked a real Node 24 cluster-argv defect behind an environment artifact.
…uses

On Windows the fixture asserted the opposite of its own preceding assertion.
`PathBuf::push` deletes a `.` component from a VERBATIM (`\\?\`) path — std
normalizes there because Windows does not resolve it — so `base.join(".")` on the
canonicalized base returned the canonical target itself, and the alias assertion
demanded that target be rejected. Strip the verbatim prefix first, which is the
spelling a real alias arrives in (`find_public_preload` hands over a stripped
path) and a no-op off Windows, and pin the `.`'s survival byte-wise, since
`Path`'s component-wise `PartialEq` drops it.
…of skipping

mklink rejects the \\?\ verbatim spelling tmp_base produces, so on CI this test
always took its skip branch and its green proved nothing. Fall back to
symlink_dir — same FILE_ATTRIBUTE_REPARSE_POINT the check reads — and skip only
on a genuinely withheld privilege, matching the sibling base-reparse test.
runtime_tree refuses a reparse-point ROOT rather than following it, so hashing
the link can never be clean and the control was unsatisfiable — invisible while
the test skipped. Hash the directory behind it, matching the unix sibling.
Of ~221 integration tests behind the compile feature, CI ran two by name, and
the second was unreachable anyway: set -e aborts the step at the first failure
and the topology test runs ahead of it. A silent require(esm)-from-CJS defect,
where a deferred require resolved to undefined, sat in that gap. The addon these
tests need is already built earlier in the same job.
A compiled artifact publishes the outer executable as process.execPath, and
child_process.fork defaults its executable to that value, so fork has to be
redirected back to the real Node binary. That override lived in the bundled
preamble, which runs too late: the emitted entry chunk hoists the program's
static builtin imports above its body, so node:cluster is evaluated first and
internal/cluster/primary.js binds `const { fork } = require("child_process")`
into a module-local const before the preamble can patch the exports object.

Every cluster worker therefore re-executed the ARTIFACT with the forwarded Node
command line as ordinary arguments, and its process.argv carried nub's private
flags plus a duplicated entry: ten entries where the contract is two. An app
that parses argv in a cluster worker got a wrong answer with no error.

Move the policy into compile-bootstrap.cjs, which --require runs before the ESM
graph, so nothing can capture fork ahead of it. It reads both inputs at call
time rather than taking them from the preamble: process.execPath having moved
off the value captured at bootstrap is the compiled-identity signal, and it is
absent in a plain forked child, which loads the bootstrap but no preamble and
must keep stock fork behavior. preload-common.cjs keeps only the
NODE_COMPILE_CACHE concern, leaving one owner for the identity policy.

Verified on Node 22.15, 24.17 and 26.5: cluster worker argv is 2, was 10.
In a compiled artifact `() => require("./dep.mjs")` evaluated to undefined
instead of the module namespace. No error, no warning, successful compile: an
app reading a property off the result got a TypeError far from the cause, and
one reading it defensively got a silently wrong value.

The cause is upstream, in rolldown 1.2.0 and still on rolldown main.
process_global_require_call decides a require's result is discarded by walking
outward to the nearest ExpressionStatement, and oxc represents a CONCISE arrow
body as a FunctionBody holding exactly that node. So `() => require(x)` is
flagged IsRequireUnused and the finalizer emits a bare init_xxx() in place of
`(init_xxx(), __toCommonJS(xxx_exports))`. A CommonJS importee is unaffected,
because its wrapper returns module.exports either way; only an ES-module
importee loses its value. Any surrounding expression, including a block body or
a member access, ends the walk early and escapes the misread.

Give such a body an explicit block and return before rolldown scans it. The
rewrite is a pure syntax normalization -- `() => e` and `() => { return e; }`
are the same program for every arrow, async included -- so it needs no CommonJS
gating and is inert rather than wrong on a module rolldown classifies as ESM.
This is a stopgap; the real fix belongs upstream and is not yet reported.

Replaces the lazy half of the mixed CommonJS/ESM cycle test, which asserted
output no Node version produces: run directly, that fixture throws
ERR_REQUIRE_CYCLE_MODULE on 20.19 through 26.5, because the back-edge is called
during the ES module's own evaluation. The acyclic deferred require is the
contract that is unambiguously Node's, so the test now pins that instead and
keeps the immediate cycle half unchanged.
Its cache-directory probe chain — owner and DACL checks, exec bits, junction
rejection — ran on ubuntu only, from the clippy job. That is exactly where the
inherit-only-ACE defect lived, and no CI leg could see it. The launcher is
already built on each leg for the topology test.
Apple's /usr/bin/strip, given no flags, rewrites a Mach-O executable's dyld
export trie: Node 26 darwin-arm64 goes from 159 exported napi_* symbols to 4.
macOS addons bind Node-API through the host executable's flat namespace, so
every native addon in a compiled artifact then failed dlopen with

  symbol not found in flat namespace '_napi_create_error'

and napi-rs addons, which resolve lazily, loaded with zero exports after
printing "Node-API symbol ... has not been loaded". llvm-strip does not have
this behaviour, which is why it reproduced only on hosts without Homebrew LLVM
- including every GitHub macOS runner, and so every release build.

Pass -x on Mach-O (same spelling for Apple strip, GNU strip and llvm-strip):
all 159 exports survive and 36 of the 38 MB the default flags recover are still
recovered (145.3 MB original, 104.6 MB default, 107.0 MB with -x).

Also verify it. Executing the stripped binary cannot catch this class - a Node
with no exported Node-API answers --version perfectly - so read the export trie
directly and compare against the untouched original. The check is a differential
and its only action is to embed Node unstripped, so an image it cannot parse can
never fail a build. Reading the classic symbol table instead would report the
working llvm-strip case as broken; the trie is what dyld consults.

The fixture leaked the host nub's own NODE_OPTIONS into the artifact run, which
both preloaded an outside runtime into a run whose point is self-containment and
made any --target below the host Node exit 9 on a flag it does not accept.
fs::canonicalize returns the Windows verbatim form, and the compile root embeds
that as a module specifier, so every Windows compile died in the bundler with
"Could not resolve '\\?\C:\...'". Strip the prefix where a path stops being a
filesystem handle and becomes a specifier. The worker-dedup hash needs the same
spelling as worker_root or one file derives two ids and emits twice; cwd and
launch_root feed resolution and importer paths. No-op off Windows.
3539b65 unpublished the JSON Schemas on purpose — nubjs.com was documenting a
config file no installed nub reads — but project_config.rs include_str!s one, so
main has been red since: Check, Clippy and every Test leg fail to read it. A unit
test's oracle must not be a web-published asset a product decision can withdraw.
Keep the crate's own copy and leave the site unpublished.
cache::resolve canonicalizes, and Rust's fs::canonicalize returns the \?\ form.
Node cannot resolve a module through one: CJS resolution ends in fs.realpathSync,
whose Windows walk lstats the path root, and Node's native ToNamespacedPath
re-resolves \\?\C:\ to \\?\C:  the volume device rather than its root
directory  so the call fails EISDIR ... lstat 'C:'. The launcher's own --require
bootstrap preload died there, before any application code ran, so no compiled
artifact started on Windows.

Strip the prefix on the two cache paths that become Node arguments. The canonical
spelling stays everywhere the Rust side uses it, including the cache's ownership
and containment checks, and libuv re-applies the prefix itself past MAX_PATH.

Reproduced and one-variable differential on a real windows-latest runner: same
tree, same Node 24, plain entry path resolves and dlopens; verbatim entry path
fails with the byte-identical error.
# Conflicts:
#	crates/nub-cli/src/project_config.rs
#	crates/nub-core/src/node/spawn.rs
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.

Support single-file self-contained executables via Node SEA (--build-sea)

1 participant