Skip to content

Build prebuilt libnode packs for Node islands - #1684

Open
cramforce wants to merge 5 commits into
ni/1-libnode-loaderfrom
ni/13-libnode-packs
Open

cramforce wants to merge 5 commits into
ni/1-libnode-loaderfrom
ni/13-libnode-packs

Conversation

@cramforce

@cramforce cramforce commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Build prebuilt libnode packs for Node islands

Milestone NI-13 of the scope-B plan: the @scriptc/libnode-<platform> packs that programs with Node islands load Node from, in process. Like the LLVM and runtime packs, they are built only by an explicit build:native script and a dedicated workflow, never by pnpm -r build or the tests.

What a pack is

packages/libnode-<platform>/ (linux-x64-gnu, linux-arm64-gnu, darwin-arm64, darwin-x64), after build:native:

  • libnode.so.137 / libnode.137.dylib: Node.js 24.22.0 (the repository's pin, .node-version and internal/compatibility/node-v24.json) built --shared from the official, SHA-256-verified source tarball, with full ICU and the built-in startup snapshot, stripped;
  • libscr_node_bridge.so / .dylib: the scriptc bridge compiled against that release's headers, linked to libnode through $ORIGIN / @loader_path, so the pack directory can move;
  • LICENSE (Node's, which carries every bundled component's license), SCRIPTC_LICENSE (Apache-2.0, for the bridge), THIRD_PARTY_NOTICES;
  • manifest.json: package and version, platform, Node version, NODE_MODULE_VERSION, bridge ABI, size and SHA-256 of both libraries, the libnode cache key, the bridge hash, configure flags, ICU flavor, toolchain and glibc floor.

The packs version in lockstep with the other platform packages and stay private until the in-process island runtime ships (RELEASING.md has the publishing steps).

Build (packages/libnode-common)

  • libnode.json pins the release, tarball hash, module version, configure flags and ICU flavor; a test keeps it equal to the repository's Node pin.
  • build-pack.mjs is NI-1's pack assembler (Load Node.js in-process on first use: libnode bridge and runtime loader #1685), kept CLI- and manifest-compatible and extended: it strips the bridge, re-signs it on macOS, links it with -z defs, records the libnode and bridge file names, and exports buildPack() for the platform packs, which add package, version, target, license and their build inputs to the manifest.
  • scripts/build.mjs fetches and verifies the tarball, configures, builds only the libnode ninja target, strips, and stages the library with its headers and LICENSE under a cache directory keyed by (Node release, flags, platform, toolchain). A cache hit skips Node entirely; the bridge rebuilds through buildPack() in about 2 s.
  • scripts/verify.mjs (prepack) checks the manifest against the package, pin and ABI, and the files against their hashes.
  • smoke/smoke.c + scripts/smoke.mjs: a plain C program that loads a pack the way the runtime does (dlopen the bridge, check the ABI, start Node from the snapshot) and evaluates synchronous, asynchronous, throwing and rejecting expressions, including full-ICU formatting, crypto, zlib, fs and timers. Its stdout is compared with an exact transcript. packages/libnode-common/test/smoke.test.ts runs it when SCRIPTC_LIBNODE names a pack.

CI (.github/workflows/libnode-packs.yml)

A matrix over the four platforms on native runners (Linux x64 and arm64 inside quay.io/pypa/manylinux_2_28_*, glibc 2.28 and gcc-toolset-14; macOS 15 arm64 and Intel with Xcode clang), triggered only by changes to the packs, the bridge or the workflow, or by hand. It restores the libnode build from actions/cache, builds, verifies, smoke-tests both inside the build image (the glibc floor) and on the runner, reports sizes, and uploads each pack plus zstd copies of both libraries.

Lookup and CI test lanes

  • libnodeRecord (NI-1) now falls back to the installed @scriptc/libnode-<platform> pack when neither --libnode nor SCRIPTC_LIBNODE names one: the nearest node_modules above the entry, then next to the compiler (packages/compiler/src/node-islands/libnode-pack.ts). That module also reads and validates pack manifests, parses nodeIslands.libnode: "embedded" | "beside" | <absolute path> for the project configuration (NI-2), and writes the <output>.licenses.txt notices.
  • .github/actions/libnode-linux replaces Load Node.js in-process on first use: libnode bridge and runtime loader #1685's interim libnode-dev-pack action: it restores the libnode build that this workflow cached, assembles @scriptc/libnode-linux-x64-gnu from it (build.mjs --from-cache, about 2 s, never builds Node) and exports SCRIPTC_LIBNODE for the Linux test lanes. Until that cache exists for a ref (caches written on main are readable from every PR; until this lands, only this PR has one), it falls back to Load Node.js in-process on first use: libnode bridge and runtime loader #1685's pinned development pack (fetch-dev-pack.mjs), so the island tests never skip. On this PR the lanes already use the NI-13 pack (cache hit on libnode-linux-x64-gnu-24.22.0-6b41a418…).
  • A rerun with the libnode builds cached takes about 2 minutes per platform (bridge rebuild, verify, smoke), confirmed by the second libnode-packs run on this PR after the bridge ABI moved to 2.

Measurements (Linux Vercel Sandbox, 8 vCPU, 16 GB)

sandbox seed (Ubuntu 26.04, gcc 15) portable pack (manylinux_2_28, gcc-toolset-14)
Node build (ninja libnode, 8 vCPU, cold, no ccache) 1,587 s (all targets, design §2.16.2) 3,290 s (54.8 min)
libnode.so.137 as built / stripped 152.4 / 132.4 MB 150.1 / 129.6 MB
zstd -19 (-T0) 35.1 MB, 23.8 s 35.0 MB, 20.1 s
bridge 39.8 KB, 2.0 s to build 35.6 KB, 1.5 s
glibc floor 2.43 (not distributable) 2.28
smoke: dlopen → first result, median of 6 warm 39.8 ms 32.7 ms (dlopen 12.7 ms, start 19.8 ms)
smoke: max RSS 68 MB 66 MB

The portable pack passes the smoke test on Ubuntu 26.04 (glibc 2.43) and inside manylinux_2_28 (glibc 2.28), and NI-1's island tests pass against it (throwaway merge with #1686: node-island.test.ts 7/7, corpus 6800–6803).

CI (first, cold run of libnode-packs on this PR, run 39631732110)

Pack Runner Node build libnode stripped zstd bridge smoke: dlopen → first result RSS
linux-x64-gnu ubuntu-24.04, manylinux_2_28 7,049 s (117 min) 129.6 MB 35.0 MB 35.6 KB 27.1 ms 59 MB
linux-arm64-gnu ubuntu-24.04-arm, manylinux_2_28 6,533 s (109 min) 121.4 MB 34.2 MB 199.5 KB 26.7 ms 59 MB
darwin-arm64 macos-15 4,559 s (76 min) 97.5 MB 26.4 MB 62.5 KB 63.3 ms (cold) 46 MB
darwin-x64 macos-15-intel 4,821 s (80 min) 100.9 MB 27.5 MB 50.9 KB 100.7 ms (cold) 34 MB

All four smoke tests pass (Linux both in the glibc 2.28 build image and on the runner). The uploaded libnode-<platform> artifacts are 37–48 MB zipped. Each libnode build is now cached; a rerun with an unchanged pin restores it and only rebuilds the bridge.

Notes

  • Node version: the packs stay at 24.22.0, built from the official, SHA-256-verified tarball (still published), matching .node-version, internal/compatibility/node-v24.json and the Node CI runs; the pin test keeps the three equal. Only the fallback development pack follows dockfries to 24.22.1 (its 24.22.0 asset was withdrawn). Moving to 24.22.1 means bumping all three pins together (and pnpm node-compat), which rebuilds the packs once.

  • After merge: the libnode-packs workflow runs on the push to main (its path filter covers packages/libnode-*/**), builds cold (about 2 h on the Linux runners), and saves caches in main's scope, which every PR can restore; until then the lanes use the development pack.

  • Stacked on Load Node.js in-process on first use: libnode bridge and runtime loader #1685 (NI-1 loader and bridge); the bridge source and scr_node_bridge.h are unchanged from it. Review and merge after Load Node.js in-process on first use: libnode bridge and runtime loader #1685.

  • The design's measured 26.5-minute build used the sandbox's Ubuntu 26.04 toolchain, whose library needs glibc 2.43: not distributable. Packs build on manylinux_2_28 instead.

  • Not shipped in the npm packages: the .zst copies. Nothing in scriptc can decode zstd yet (neither the runtime nor the compiler under the native CLI), so embedded placement waits for NI-4 to pick a decoder.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

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

Project Deployment Actions Updated
scriptc Ready Ready Preview, v0 Oct 22, 2026 3:13am UTC

@socket-security

socket-security Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedgithub/​actions/​cache@​0057852bfaa89a56745cba8c7296529d2fc398309810010010080

View full report

@vercel-security-reviewer

Copy link
Copy Markdown

Security review details

cramforce and others added 4 commits October 21, 2026 17:52
Add the @scriptc/libnode-<platform> packs (linux-x64-gnu, linux-arm64-gnu,
darwin-arm64, darwin-x64): the pinned Node.js 24.22.0 built as a stripped
shared library with full ICU and its startup snapshot, the scriptc bridge
compiled against that release's headers, Node's LICENSE and a manifest
with the Node version, NODE_MODULE_VERSION, bridge ABI and file hashes.

packages/libnode-common holds the pin, the build (fetch and verify the
source tarball, configure --shared, build only the libnode target, strip,
stage with headers in a cache keyed by release, flags, platform and
toolchain; the bridge rebuilds in seconds against the cached headers),
a verifier, and a smoke test: a C program that loads a pack the way the
runtime does, starts Node from the snapshot and evaluates expressions
synchronously, asynchronously and throwing.

The libnode-packs workflow builds the packs on native runners, Linux in
the manylinux_2_28 images for a glibc 2.28 floor, caches each libnode
build, smoke-tests and uploads every pack. It never runs in the ordinary
build or test path. The compiler gains the pack lookup (SCRIPTC_LIBNODE,
then the installed package), validation, the nodeIslands.libnode
placements and the license notices for the Node island loader to use.

The bridge source and its C ABI header are the NI-1 bridge agent's,
included here so the packs build; the packs stay private until the
in-process island runtime ships.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s in CI

Stack the libnode packs on the in-process loader (#1685) and use its
pack assembler and manifest format instead of a second one:
build-pack.mjs keeps its CLI and fields (schema, nodeVersion,
moduleVersion, bridgeAbi, platform, arch, files), adds the libnode and
bridge names, strips and (on macOS) re-signs the bridge and links it with
-z defs, and exports buildPack(), which the platform packs call with their
package, target and build inputs as extra manifest fields.

libnodeRecord falls back to the installed @scriptc/libnode-<platform>
pack, found from the entry's directory and then next to the compiler,
when neither --libnode nor SCRIPTC_LIBNODE names one.

The Linux test lanes restore the libnode build that the libnode-packs
workflow cached, assemble the linux-x64 pack from it in seconds
(build.mjs --from-cache never builds Node) and export SCRIPTC_LIBNODE, so
the Node island corpus and harness tests run instead of skipping.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An installed @scriptc/libnode-<platform> pack versions in lockstep with
the compiler, whose version every executable cache key already holds;
keying on it as well made the full compiler's key differ from the
bootstrap's routed key wherever a pack directory was present, so routed
cache hits stopped. Only directories with a manifest.json count as
installed packs, so an unbuilt workspace package is not one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
delete mod.nodeIsland;
} else if (outputKind === "exe") {
const recorded = libnodeRecord(opts.libnode, opts.outPath);
const recorded = libnodeRecord(opts.libnode, opts.outPath, process.env, dirname(entryPath));

@vercel vercel Bot Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When an entry outside the working directory resolves its project-local libnode pack, a subsequent cached --libnode=beside build searches from the working directory instead and can fail with SC8005.

Fix on Vercel

Comment on lines +11 to +17
describe.skipIf(pack === undefined || pack === "")("libnode pack smoke test", () => {
test("the pack is complete", () => {
expect(verifyPack(pack!)).toEqual([]);
});

test("a C program starts Node from the pack and evaluates expressions", () => {
const { status, stdout } = runSmoke(pack!);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

When CI falls back to the development pack, this suite fails because that pack lacks package.json and uses Node 24.22.1 rather than the smoke transcript’s pinned 24.22.0.

Fix on Vercel

This branch was successfully deployed

1 active deployment
Preview — 43cd8dea Deployed Oct 22, 2026 by vercel[bot]
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.

1 participant