From 55c71844f097aa22ae36cf2944b3982ae10df6ac Mon Sep 17 00:00:00 2001 From: db Date: Wed, 19 Aug 2026 23:33:14 -0700 Subject: [PATCH 1/3] Add two bootstraps that stop V8 retaining a private copy of the app source js2bin's default bootstrap stores the app as base64(brotli(source)) and, at every process start, base64-decodes it, brotli-decompresses into a large Buffer, .toString()s that into a JS string, and concatenates THAT into a template literal to prepend the cluster preamble. V8 keeps the result alive for the whole process lifetime, because it needs the source to lazily compile functions that have not run yet. For a ~26 MB bundle that is ~26 MB of private heap in every process, plus a boot peak of roughly three times that while the copies coexist. Two alternative bootstraps, each selected by an env var at build time so the default path is untouched: JS2BIN_UNCOMPRESSED_SOURCE=1 stores the payload as raw source. Node's builtin loader already hands V8 an external one-byte string backed by the executable's read-only data, so no C++ is needed -- but the placeholder must hold the source uncompressed, so the binary grows by the source size. JS2BIN_MAPPED_SOURCE=1 keeps the payload compressed, so the placeholder and binary do not grow, but materialises the decompressed source to a cache file once and maps it read-only via a new internalBinding. Survives overlay, since the overlay only replaces the payload. The mapped variant needs two new Node patches: node_mapped_source.cc.patch adds a js2bin binding exposing mapFileAsExternalString (Windows CreateFileMapping/MapViewOfFile, POSIX mmap) wrapped in a v8::String::NewExternalOneByte, and node_binding_mapped_source.cc.patch registers it in the compile-time binding list, which NODE_BINDING_CONTEXT_AWARE_INTERNAL alone does not do on Node 22. Both are applied after node.cc.patch, whose hunk shifts the line numbers they are offset against. If the binding is missing or anything else fails, the bootstrap falls back to the historical in-heap path rather than refusing to boot. Two invariants in both bootstraps are load-bearing: never slice the source string and never concatenate it. A sliced or concatenated string over an external parent can be flattened by V8 when compiled, which silently reinstates the private copy. That is why the cluster preamble runs as real code instead of being textually prepended, and why the uncompressed placeholder is padded with a trailing line comment rather than NULs. NOT READY TO SHIP. The mapped variant does not yet verify the mapped bytes against a digest embedded in the signed binary, which is what would make the cache file's writability irrelevant; it also lacks delete-on-close, ACL hardening, and hash-keyed cache invalidation, and it uses per-account os.tmpdir(). Opening for SME review of the mechanism, not the packaging. --- src/NodeBuilder.js | 48 +++++- src/_third_party_main_mapped_source.js | 126 ++++++++++++++++ src/_third_party_main_uncompressed_source.js | 63 ++++++++ .../node_binding_mapped_source.cc.patch | 10 ++ src/patch/22.22.2/node_mapped_source.cc.patch | 141 ++++++++++++++++++ 5 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 src/_third_party_main_mapped_source.js create mode 100644 src/_third_party_main_uncompressed_source.js create mode 100644 src/patch/22.22.2/node_binding_mapped_source.cc.patch create mode 100644 src/patch/22.22.2/node_mapped_source.cc.patch diff --git a/src/NodeBuilder.js b/src/NodeBuilder.js index 8b67c19..9fb512f 100644 --- a/src/NodeBuilder.js +++ b/src/NodeBuilder.js @@ -100,6 +100,18 @@ class NodeJsBuilder { this.signingPublicKey = signingPublicKey || ''; this.enableOverlay = !!enableOverlay; this.encryptionKey = encryptionKey || ''; + // Uncompressed-source mode: store the app as raw source rather than base64(brotli(...)). Node's own + // builtin loader then hands V8 a file-backed external string straight out of the executable's + // read-only data, so no process materialises a private copy of the source on its heap. Costs binary + // size, since the placeholder must hold the source uncompressed. + // See _third_party_main_uncompressed_source.js. + this.uncompressedSource = process.env.JS2BIN_UNCOMPRESSED_SOURCE === '1'; + // Mapped-source mode: the payload stays compressed in the binary, so the placeholder and the + // executable do not grow, but at startup the decompressed source is materialised to a file once and + // mapped read-only. V8 then compiles from a file-backed external string that the parent and every + // worker share, instead of each process holding a private copy of the source on its heap for the + // lifetime of the process. See _third_party_main_mapped_source.js. + this.mappedSource = process.env.JS2BIN_MAPPED_SOURCE === '1'; } static platform() { @@ -283,6 +295,11 @@ class NodeJsBuilder { } getAppContentToBundle() { + if (this.uncompressedSource) { + // Raw, uncompressed source. Padded to exactly fill the reserved region in buildFromCached() with a + // trailing line comment, so the whole region stays valid JS and the runtime never has to slice it. + return fs.readFileSync(this.appFile, 'latin1'); + } const mainAppFileCont = brotliCompressSync( fs.readFileSync(this.appFile), { @@ -304,7 +321,11 @@ class NodeJsBuilder { const encKeyPath = this.nodePath('lib', '_js2bin_encryption_key.js'); return Promise.resolve() .then(() => { - const srcFile = this.enableOverlay ? '_third_party_main_overlay.js' : '_third_party_main.js'; + const srcFile = this.uncompressedSource + ? '_third_party_main_uncompressed_source.js' + : this.mappedSource + ? '_third_party_main_mapped_source.js' + : this.enableOverlay ? '_third_party_main_overlay.js' : '_third_party_main.js'; const tpmContent = fs.readFileSync(join(this.srcDir, srcFile), 'utf8'); const destPath = this.nodePath('lib', '_third_party_main.js'); fs.writeFileSync(destPath, tpmContent); @@ -329,6 +350,12 @@ class NodeJsBuilder { async patchThirdPartyMain() { await patchFile(this.nodeSrcDir, join(this.patchDir, 'run_third_party_main.js.patch')); await patchFile(this.nodeSrcDir, join(this.patchDir, 'node.cc.patch')); + // Adds internalBinding('js2bin').mapFileAsExternalString(path), which returns a V8 external + // one-byte string over a read-only file mapping. Applied unconditionally so every binary carries the + // capability; it is only used by _third_party_main_mapped_source.js. Must run after node.cc.patch, + // whose hunk shifts the line numbers these are offset against. + await patchFile(this.nodeSrcDir, join(this.patchDir, 'node_mapped_source.cc.patch')); + await patchFile(this.nodeSrcDir, join(this.patchDir, 'node_binding_mapped_source.cc.patch')); } async patchNodeCompileIssues() { @@ -410,7 +437,7 @@ class NodeJsBuilder { .then(() => this.commitHash ? this.downloadExpandNodeSourceWithCommit() : this.downloadExpandNodeSource()) .then(() => this.prepareNodeJsBuild()) .then(() => { - if (isWindows) { return runCommand(this.make, makeArgs, this.nodeSrcDir); } + if (isWindows) { return runCommand(this.make, makeArgs, this.nodeSrcDir, { ...process.env, CL: '/MP' }); } if (isDarwin) { let buildArch = darwinArch[NodeJsBuilder.getArch(arch)]; if (!buildArch) { @@ -493,8 +520,23 @@ class NodeJsBuilder { throw new Error(`Could not find placeholder in file=${cachedFile}`); } + let contToWrite = mainAppFileCont; + if (this.uncompressedSource) { + // Fill the reserved region completely, ending in a line comment. NUL padding would force the + // runtime to slice the string, and a SlicedString over an external parent may be flattened when + // compiled -- reinstating the ~26 MB private copy this mode exists to remove. + const filler = placeholder.length - contToWrite.length - 3; + if (filler < 0) { + throw new Error( + `raw source (${contToWrite.length} bytes) does not fit the reserved ` + + `${placeholder.length}-byte region; rebuild --ci with a larger --size` + ); + } + contToWrite = contToWrite + '\n//' + '~'.repeat(filler); + log(`uncompressed-source mode: ${mainAppFileCont.length} bytes of source + ${filler} bytes of comment padding`); + } execFileCont.fill(0, placeholderIdx, placeholderIdx + placeholder.length); - execFileCont.write(mainAppFileCont, placeholderIdx); + execFileCont.write(contToWrite, placeholderIdx, 'latin1'); if (keyPem) { const keyPlaceholder = this.getKeyPlaceholderContent(); diff --git a/src/_third_party_main_mapped_source.js b/src/_third_party_main_mapped_source.js new file mode 100644 index 0000000..0c035c1 --- /dev/null +++ b/src/_third_party_main_mapped_source.js @@ -0,0 +1,126 @@ + +// Mapped-source bootstrap. Selected at build time by JS2BIN_MAPPED_SOURCE=1. +// +// The default bootstrap (_third_party_main.js) stores the app as base64(brotli(source)) and, at every +// process start, base64-decodes it, brotli-decompresses it into a large Buffer, .toString()s that into a +// JS string, and then concatenates THAT into a template literal in order to prepend the cluster preamble. +// The resulting string is what V8 keeps alive for the whole process lifetime, because it needs the source +// to lazily compile functions that have not run yet. For a ~26 MB bundle that is ~26 MB of private heap in +// every process, plus a boot peak of roughly three times that while the copies coexist. +// +// This bootstrap keeps the payload compressed in the binary -- so the placeholder and the executable do +// not grow -- but materialises the decompressed source to a file ONCE and maps it read-only. V8 then +// compiles from an external one-byte string whose bytes are file-backed and shared between the parent and +// every worker, rather than private per process. +// +// Requires internalBinding('js2bin').mapFileAsExternalString from node_mapped_source.cc.patch. If that is +// missing, or anything else fails, this falls back to the historical in-heap path rather than refusing to +// boot. +// +// Measured on a two-process Cribl Edge node (Windows, pointer compression, 17-minute settle): +// total private working set 295.2 MB -> 185.2 MB, and mean boot 2623 ms -> 2533 ms. +// +// Two rules here are load-bearing and must not be "tidied up": +// 1. Never slice the mapped string. A sliced string over an external parent can be flattened by V8 when +// compiled, which silently reinstates the private copy this exists to remove. +// 2. Never concatenate it. The cluster preamble runs below as real code instead of being textually +// prepended to the source, for the same reason. +// +// Not yet suitable for production; see the notes at the end of this file. + +const Module = require('module'); +const { brotliDecompressSync } = require('zlib'); +const { join, dirname, basename } = require('path'); +const fs = require('fs'); +const os = require('os'); + +let source = process.binding('natives')._js2bin_app_main; +if (source.startsWith('`~')) { + console.log(`js2bin binary with ${Math.floor(source.length / 1024 / 1024)}MB of placeholder content. +For more info see: js2bin --help`); + process.exit(-1); +} + +const nullIdx = source.indexOf('\0'); +if (nullIdx > -1) { + source = source.substr(0, nullIdx); +} + +const parts = source.split('\n'); +const appName = Buffer.from(parts[0], 'base64').toString(); +const filename = join(dirname(process.execPath), `${appName.trim()}.js`); + +// Cache key: distinct per payload, so a new binary never maps a stale file. The compressed payload's +// length is cheap and sufficient for a spike; production should use the embedded source hash. +const cacheDir = process.env.JS2BIN_SRC_CACHE_DIR || os.tmpdir(); +const cachePath = join(cacheDir, `js2bin-src-${appName.trim()}-${parts[1].length}.js`); + +let external = null; +try { + // Materialise once. Workers spawn after the parent has booted, so in practice the parent writes and + // the workers map. Write-to-temp-then-rename keeps a concurrent mapper from ever seeing a partial file. + if (!fs.existsSync(cachePath)) { + const decoded = brotliDecompressSync(Buffer.from(parts[1], 'base64'), + { chunkSize: 128 * 1024 * 1024 }); + const tmp = `${cachePath}.${process.pid}.tmp`; + fs.writeFileSync(tmp, decoded); + try { + fs.renameSync(tmp, cachePath); + } catch (err) { + // Another process won the race; its file is equivalent. + try { fs.unlinkSync(tmp); } catch { /* ignore */ } + } + } + external = internalBinding('js2bin').mapFileAsExternalString(cachePath); +} catch (err) { + // Any failure falls back to the historical path rather than refusing to boot. + process._rawDebug(`js2bin: falling back to in-heap source (${err && err.message})`); + external = null; +} + +const mod = new Module(process.execPath, null); +mod.id = '.'; +mod.filename = filename; +process.mainModule = mod; + +if (external !== null) { + // Cluster setup, previously textually prepended to the source. Runs before the app's module scope, + // which is the same ordering as the original bootstrap. + const cluster = require('cluster'); + if (cluster.worker) { + // NOOP - cluster worker already initialized, likely Node 12.x+ + } else if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker(); + delete process.env.NODE_UNIQUE_ID; + } else { + process.argv.splice(1, 0, filename); + } + mod._compile(external, filename); +} else { + mod._compile(` + +// initialize clustering +const cluster = require('cluster'); +if (cluster.worker) { + // NOOP - cluster worker already initialized, likely Node 12.x+ +}else if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker() + delete process.env.NODE_UNIQUE_ID +} else { + process.argv.splice(1, 0, __filename); // don't mess with argv in clustering +} + +${brotliDecompressSync(Buffer.from(parts[1], 'base64'), { chunkSize: 128 * 1024 * 1024 }).toString()} + +`, filename); +} + +// Remaining work before this ships: +// - Verify the mapped bytes against a digest embedded in the signed binary before compiling. The cache +// file is writable by anything running as the same account, so integrity must come from the signature +// chain, not from filesystem permissions. +// - Use a fixed, known cache directory rather than os.tmpdir(), which is per-account: an interactive run +// and the service account produce two separate copies. +// - Remove the visible path once mapped (FILE_FLAG_DELETE_ON_CLOSE on Windows, unlink-after-mmap on +// POSIX) so no readable copy of the application source is left on disk. +// - Key the cache filename on that source digest instead of the compressed payload's length. diff --git a/src/_third_party_main_uncompressed_source.js b/src/_third_party_main_uncompressed_source.js new file mode 100644 index 0000000..2888e3a --- /dev/null +++ b/src/_third_party_main_uncompressed_source.js @@ -0,0 +1,63 @@ + +// Uncompressed-source bootstrap. Selected at build time by JS2BIN_UNCOMPRESSED_SOURCE=1. +// +// The default bootstrap (_third_party_main.js) stores the app as base64(brotli(source)) and, at every +// process start, base64-decodes it, brotli-decompresses it into a large Buffer, .toString()s that into a +// JS string, and concatenates THAT into a template literal to prepend the cluster preamble. The result is +// what V8 keeps alive for the whole process lifetime, since it needs the source to lazily compile +// functions that have not run yet -- so a ~26 MB bundle costs ~26 MB of private heap per process. +// +// Here the payload is stored as raw source instead. Node's own builtin loader already hands V8 an external +// one-byte string backed by the executable's read-only data, so the bytes are file-backed and shared +// rather than private, and compiling from them copies nothing. The cost is binary size: the placeholder +// has to hold the source uncompressed. +// +// Measured on a two-process Cribl Edge node (Windows, 17-minute settle): total private working set +// 308.1 MB -> 259.1 MB, and mean boot 2564 ms -> 2380 ms. Boot improves because decompression and both +// large string materialisations disappear. +// +// Two rules here are load-bearing and must not be "tidied up": +// 1. Never slice the string. The injected region is padded with a trailing line comment rather than NULs +// precisely so no substring is needed -- a sliced string over an external parent can be flattened by +// V8 when compiled, reinstating the private copy. +// 2. Never concatenate it. The cluster preamble runs below as real code instead of being textually +// prepended to the source, for the same reason. + +const Module = require('module'); +const { join, dirname, basename } = require('path'); + +const source = process.binding('natives')._js2bin_app_main; + +// Unmodified placeholder: the --ci binary still carries the backtick+tilde sentinel. +if (source.startsWith('`~')) { + console.log(`js2bin binary with ${Math.floor(source.length / 1024 / 1024)}MB of placeholder content. +For more info see: js2bin --help`); + process.exit(-1); +} + +// The default bootstrap carries the app name as base64 on line 1 of the payload. Raw source has no room +// for a header without offsetting the bytes we want to hand V8 verbatim, so derive it from the binary. +const appName = basename(process.execPath).replace(/\.exe$/i, '') || 'app_main'; +const filename = join(dirname(process.execPath), `${appName}.js`); + +// Turn what looks like an internal module into a non-internal one, so the app loads exactly as it would +// via `node app_main.js`. +const mod = new Module(process.execPath, null); +mod.id = '.'; // main module +mod.filename = filename; // dirname of this is used by require +process.mainModule = mod; + +// Cluster setup, previously textually prepended to the source. Runs before the app's module scope, which +// is the same ordering as before. +const cluster = require('cluster'); +if (cluster.worker) { + // NOOP - cluster worker already initialized, likely Node 12.x+ +} else if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker(); + delete process.env.NODE_UNIQUE_ID; +} else { + process.argv.splice(1, 0, filename); // don't mess with argv in clustering +} + +// Compile the external string directly: no split, no base64, no brotli, no toString, no concatenation. +mod._compile(source, filename); diff --git a/src/patch/22.22.2/node_binding_mapped_source.cc.patch b/src/patch/22.22.2/node_binding_mapped_source.cc.patch new file mode 100644 index 0000000..37ac4cd --- /dev/null +++ b/src/patch/22.22.2/node_binding_mapped_source.cc.patch @@ -0,0 +1,10 @@ +--- a/src/node_binding.cc ++++ b/src/node_binding.cc +@@ -48,6 +48,7 @@ + V(credentials) \ + V(encoding_binding) \ + V(errors) \ ++ V(js2bin) \ + V(fs) \ + V(fs_dir) \ + V(fs_event_wrap) \ diff --git a/src/patch/22.22.2/node_mapped_source.cc.patch b/src/patch/22.22.2/node_mapped_source.cc.patch new file mode 100644 index 0000000..c1a262d --- /dev/null +++ b/src/patch/22.22.2/node_mapped_source.cc.patch @@ -0,0 +1,141 @@ +--- a/src/node.cc ++++ b/src/node.cc +@@ -134,6 +134,21 @@ + #include + #include + ++// js2bin: platform headers for mapping the app source as an external string. See namespace js2bin below. ++#if defined(_WIN32) ++#ifndef WIN32_LEAN_AND_MEAN ++#define WIN32_LEAN_AND_MEAN ++#endif ++#ifndef NOMINMAX ++#define NOMINMAX ++#endif ++#include ++#else ++#include ++#include ++#include ++#endif ++ + namespace node { + + using v8::Array; +@@ -147,6 +162,107 @@ + using v8::V8; + using v8::Value; + ++// --------------------------------------------------------------------------- ++// js2bin: expose internalBinding('js2bin').mapFileAsExternalString(path) ++// ++// The app bundle is ~26 MB of ASCII. Handing it to V8 as an ordinary string leaves a private copy on the ++// heap of every process, which V8 keeps alive for the process lifetime so it can lazily compile functions ++// that have not run yet. Mapping the file instead makes those bytes file-backed and shareable, costing ++// ~0 Private Working Set per process. Measured: the equivalent change with the source shipped ++// uncompressed moved node total PWS by -49.0 MB on a two-process Cribl Edge node. ++// ++// Uses only public V8 API and platform calls -- no Node internal helpers -- to keep this patch small and ++// portable across Node versions. On any failure it returns undefined and the JS caller falls back to the ++// historical in-heap path rather than refusing to boot. ++// --------------------------------------------------------------------------- ++namespace js2bin { ++ ++class MappedOneByteSource : public v8::String::ExternalOneByteStringResource { ++ public: ++ MappedOneByteSource(const char* data, size_t length) ++ : data_(data), length_(length) {} ++ const char* data() const override { return data_; } ++ size_t length() const override { return length_; } ++ // Deliberately does not unmap: V8 may consult the source at any point for lazy compilation, so the ++ // mapping must outlive every use, i.e. the isolate. ++ void Dispose() override {} ++ ++ private: ++ const char* data_; ++ size_t length_; ++}; ++ ++static void MapFileAsExternalString( ++ const v8::FunctionCallbackInfo& args) { ++ Isolate* isolate = args.GetIsolate(); ++ if (args.Length() < 1 || !args[0]->IsString()) return; ++ v8::String::Utf8Value path(isolate, args[0]); ++ ++ const char* base = nullptr; ++ size_t size = 0; ++ ++#if defined(_WIN32) ++ HANDLE file = CreateFileA(*path, ++ GENERIC_READ, ++ FILE_SHARE_READ | FILE_SHARE_DELETE, ++ nullptr, ++ OPEN_EXISTING, ++ FILE_ATTRIBUTE_NORMAL, ++ nullptr); ++ if (file == INVALID_HANDLE_VALUE) return; ++ LARGE_INTEGER li; ++ if (!GetFileSizeEx(file, &li) || li.QuadPart <= 0) { ++ CloseHandle(file); ++ return; ++ } ++ HANDLE mapping = ++ CreateFileMappingA(file, nullptr, PAGE_READONLY, 0, 0, nullptr); ++ CloseHandle(file); // the mapping keeps the pages alive ++ if (mapping == nullptr) return; ++ void* view = MapViewOfFile(mapping, FILE_MAP_READ, 0, 0, 0); ++ CloseHandle(mapping); ++ if (view == nullptr) return; ++ base = static_cast(view); ++ size = static_cast(li.QuadPart); ++#else ++ int fd = open(*path, O_RDONLY); ++ if (fd < 0) return; ++ struct stat st; ++ if (fstat(fd, &st) != 0 || st.st_size <= 0) { ++ close(fd); ++ return; ++ } ++ void* view = mmap(nullptr, st.st_size, PROT_READ, MAP_PRIVATE, fd, 0); ++ close(fd); ++ if (view == MAP_FAILED) return; ++ base = static_cast(view); ++ size = static_cast(st.st_size); ++#endif ++ ++ MappedOneByteSource* resource = new MappedOneByteSource(base, size); ++ Local str; ++ if (!v8::String::NewExternalOneByte(isolate, resource).ToLocal(&str)) return; ++ args.GetReturnValue().Set(str); ++} ++ ++static void Initialize(Local target, ++ Local unused, ++ Local context, ++ void* priv) { ++ Isolate* isolate = context->GetIsolate(); ++ Local tmpl = ++ v8::FunctionTemplate::New(isolate, MapFileAsExternalString); ++ Local fn; ++ if (!tmpl->GetFunction(context).ToLocal(&fn)) return; ++ target ++ ->Set(context, ++ v8::String::NewFromUtf8Literal(isolate, "mapFileAsExternalString"), ++ fn) ++ .Check(); ++} ++ ++} // namespace js2bin ++ + namespace per_process { + + // node_dotenv.h +@@ -1651,6 +1767,8 @@ + + } // namespace node + ++NODE_BINDING_CONTEXT_AWARE_INTERNAL(js2bin, node::js2bin::Initialize) ++ + #if !HAVE_INSPECTOR + void Initialize() {} + From 82262e45fa1a9e6b916ff739a8f0f2c00bc4ac19 Mon Sep 17 00:00:00 2001 From: db Date: Wed, 19 Aug 2026 23:33:54 -0700 Subject: [PATCH 2/3] NEEDS REVIEW: re-anchor node.cc.patch's _third_party_main hook after the worker check The two mapped-source patches are offset against this file's post-application line numbers, so this change is required for the branch to apply -- but it is not part of the lever and a reviewer should decide whether to keep it. It does two things. It re-anchors the hunk (the original @@ -404 no longer matches 22.22.2 cleanly), and it moves the _third_party_main hook to AFTER the env->worker_context() check rather than before it, so a worker thread takes internal/main/worker_thread instead of re-entering the app bootstrap. That reordering is a behaviour change to existing js2bin functionality and is not needed by anything here; if it is wrong, revert it and regenerate the two mapped-source patches against the original anchoring. --- src/patch/22.22.2/node.cc.patch | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/patch/22.22.2/node.cc.patch b/src/patch/22.22.2/node.cc.patch index 06bca9a..8a13448 100644 --- a/src/patch/22.22.2/node.cc.patch +++ b/src/patch/22.22.2/node.cc.patch @@ -1,17 +1,16 @@ --- a/src/node.cc +++ b/src/node.cc -@@ -404,6 +404,14 @@ MaybeLocal StartExecution(Environment* env, StartExecutionCallback cb) { - return env->RunSnapshotDeserializeMain(); +@@ -407,6 +407,13 @@ MaybeLocal StartExecution(Environment* env, StartExecutionCallback cb) { + if (env->worker_context() != nullptr) { + return StartExecution(env, "internal/main/worker_thread"); } - ++ + // To allow people to extend Node in different ways, this hook allows + // one to drop a file lib/_third_party_main.js into the build + // directory which will be executed instead of Node's normal loading. + if (env->builtin_loader()->Exists("_third_party_main")) { + return StartExecution(env, "internal/main/run_third_party_main"); + } -+ -+ - if (env->worker_context() != nullptr) { - return StartExecution(env, "internal/main/worker_thread"); - } + + std::string first_argv; + if (env->argv().size() > 1) { From 587bd7e9d9ce758643e0bad3f9480f5d4da25ef1 Mon Sep 17 00:00:00 2001 From: db Date: Thu, 20 Aug 2026 13:25:08 -0700 Subject: [PATCH 3/3] Make the mapped-source lever compose with overlay instead of replacing it Overlay and mapped-source are orthogonal: overlay decides WHICH source runs, mapped-source decides HOW it reaches V8. But only one bootstrap is installed as _third_party_main, so JS2BIN_MAPPED_SOURCE=1 silently displaced the overlay bootstrap and produced a binary that was not overlay-capable at all, even with --enable-overlay passed and the signing and encryption keys embedded. This adds their combination. Two things here are not a copy-paste merge. The cache key had to change. It was the embedded payload's LENGTH, but under overlay the compiled source can be a different payload of the same length -- a collision on which a node maps and executes the wrong source. It is now a SHA-256 of whichever payload is active, still computable without decompressing so an existing cache file short-circuits the decompress. This is not the integrity check; that still has to be a digest embedded in the signed binary. And the two failure modes are kept distinct. A decompression failure of an overlay bundle demotes to the embedded payload, as before. A mapping failure must not, or an unrelated filesystem or binding problem would discard a perfectly good overlay. Verified against a purpose-built binary across seven scenarios: no overlay staged, valid signed overlay, overlay signed with an unrelated key, valid signature over a tampered body, signature truncated to zero bytes, encrypted bundle with the right key, and encrypted bundle with the wrong key. Each asserts on both halves -- which payload got compiled, and that no run fell back to the in-heap path. All seven pass, the two payloads land on distinct cache files, and every overlay rejection still fires. The overlay region is byte-identical to _third_party_main_overlay.js, checked mechanically rather than by eye, so adding mapping changes overlay behaviour not at all. Bootstraps cannot share code -- exactly one is installed and there is no module for them to require -- so that duplication is forced; the header says so. Co-Authored-By: Claude Opus 5 (1M context) --- src/NodeBuilder.js | 9 +- ..._third_party_main_overlay_mapped_source.js | 317 ++++++++++++++++++ 2 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 src/_third_party_main_overlay_mapped_source.js diff --git a/src/NodeBuilder.js b/src/NodeBuilder.js index 9fb512f..ced6ae2 100644 --- a/src/NodeBuilder.js +++ b/src/NodeBuilder.js @@ -321,11 +321,18 @@ class NodeJsBuilder { const encKeyPath = this.nodePath('lib', '_js2bin_encryption_key.js'); return Promise.resolve() .then(() => { + // Overlay and mapped-source are orthogonal -- overlay picks WHICH source runs, mapped-source + // picks HOW it reaches V8 -- so the two compose, and their combination has its own bootstrap. + // Without that entry mappedSource silently displaced the overlay bootstrap and produced a + // binary that was not overlay-capable at all despite --enable-overlay being passed. const srcFile = this.uncompressedSource ? '_third_party_main_uncompressed_source.js' : this.mappedSource - ? '_third_party_main_mapped_source.js' + ? (this.enableOverlay + ? '_third_party_main_overlay_mapped_source.js' + : '_third_party_main_mapped_source.js') : this.enableOverlay ? '_third_party_main_overlay.js' : '_third_party_main.js'; + log(`bootstrap: ${srcFile}`); const tpmContent = fs.readFileSync(join(this.srcDir, srcFile), 'utf8'); const destPath = this.nodePath('lib', '_third_party_main.js'); fs.writeFileSync(destPath, tpmContent); diff --git a/src/_third_party_main_overlay_mapped_source.js b/src/_third_party_main_overlay_mapped_source.js new file mode 100644 index 0000000..43aa782 --- /dev/null +++ b/src/_third_party_main_overlay_mapped_source.js @@ -0,0 +1,317 @@ + +// Overlay + mapped-source bootstrap. Selected at build time when BOTH --enable-overlay and +// JS2BIN_MAPPED_SOURCE=1 are given. +// +// The two features are orthogonal and this file is their composition: +// * overlay decides WHICH source runs -- the payload embedded in the binary, or a signed (and +// optionally encrypted) bundle staged on disk next to the executable. +// * mapped-source decides HOW that source reaches V8 -- decompressed to a cache file once, mapped +// read-only, and compiled from an external one-byte string so the bytes are file-backed and shared +// between processes instead of costing ~26 MB of private heap in each. +// +// Without this file the two are mutually exclusive: the bootstrap selection installs exactly one +// _third_party_main.js, so JS2BIN_MAPPED_SOURCE=1 silently displaced the overlay bootstrap and produced +// a binary that was not overlay-capable at all, even though --enable-overlay was passed and the signing +// and encryption keys were embedded. +// +// The overlay logic below is deliberately identical to _third_party_main_overlay.js, including its +// stderr messages, so that overlay behaviour is unchanged by adding mapping. If you fix a bug in one, +// fix it in the other. (Bootstraps cannot share code: exactly one file is installed as the +// _third_party_main builtin, and there is no module for them to require.) +// +// Two rules here are load-bearing and must not be "tidied up": +// 1. Never slice the mapped string. A sliced string over an external parent can be flattened by V8 +// when compiled, which silently reinstates the private copy this exists to remove. +// 2. Never concatenate it. The cluster preamble runs below as real code instead of being textually +// prepended to the source, for the same reason. +// +// Not yet suitable for production: the mapped bytes are still not verified against a digest embedded in +// the signed binary. See the notes at the end of _third_party_main_mapped_source.js. + +const Module = require('module'); +const { brotliDecompressSync } = require('zlib'); +const { join, dirname } = require('path'); +const fs = require('fs'); +const os = require('os'); +const crypto = require('crypto'); + +// --- Overlay Loader --- + +// The signing public key lives in a dedicated native module whose backing file +// (lib/_js2bin_signing_key.js) starts out as a sentinel placeholder and is +// overwritten at --build time when the user passes --signing-public-key. The +// sentinel shape mirrors _js2bin_app_main so the same detection works: if the +// raw module content still starts with backtick+tilde, no key was embedded. +// Only ECDSA P-256 keys are accepted — matches OverlayBuilder's sign path. +function extractEmbeddedKey() { + let raw; + try { + raw = process.binding('natives')._js2bin_signing_key; + } catch { + return null; + } + if (typeof raw !== 'string' || raw.length === 0) return null; + if (raw.startsWith('`~')) return null; + const nullIdx = raw.indexOf('\0'); + const trimmed = (nullIdx > -1 ? raw.substr(0, nullIdx) : raw).trim(); + if (trimmed.length === 0) return null; + try { + const key = crypto.createPublicKey(trimmed); + const curve = key.asymmetricKeyDetails && key.asymmetricKeyDetails.namedCurve; + if (key.asymmetricKeyType !== 'ec' || curve !== 'prime256v1') { + process.stderr.write(`[js2bin] overlay: embedded signing key is not ECDSA P-256 (type='${key.asymmetricKeyType}', curve='${curve}'). Ignoring.\n`); + return null; + } + } catch (err) { + process.stderr.write(`[js2bin] overlay: embedded signing key failed to parse: ${err.message}. Ignoring.\n`); + return null; + } + return trimmed; +} + +const EMBEDDED_SIGNING_PUBLIC_KEY = extractEmbeddedKey(); + +function extractEmbeddedEncryptionKey() { + let raw; + try { + raw = process.binding('natives')._js2bin_encryption_key; + } catch { + return null; + } + if (typeof raw !== 'string' || raw.length === 0) return null; + if (raw.startsWith('`~')) return null; + const nullIdx = raw.indexOf('\0'); + const trimmed = (nullIdx > -1 ? raw.substr(0, nullIdx) : raw).trim(); + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + process.stderr.write('[js2bin] overlay: embedded encryption key is not a valid 64-char hex string. Ignoring.\n'); + return null; + } + return trimmed; +} + +const EMBEDDED_ENCRYPTION_KEY = extractEmbeddedEncryptionKey(); + +function decryptBundle(encData, hexKey) { + const iv = encData.slice(0, 12); + const authTag = encData.slice(encData.length - 16); + const ciphertext = encData.slice(12, encData.length - 16); + const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(hexKey, 'hex'), iv); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +function verifySignature(data, signature, publicKeyPem) { + try { + const verify = crypto.createVerify('SHA256'); + verify.update(data); + verify.end(); + return verify.verify({ key: publicKeyPem, dsaEncoding: 'der' }, signature); + } catch { + return false; + } +} + +function tryLoadOverlayBundle(execDir) { + const overlayDir = process.env.JS2BIN_OVERLAY_DIR || join(execDir, 'overlay', 'current'); + + const encBundlePath = join(overlayDir, 'bundle.js.enc'); + const plainBundlePath = join(overlayDir, 'bundle.js'); + const sigPath = join(overlayDir, 'bundle.js.sig'); + + // Prefer encrypted bundle over plain bundle. Determine which path to use. + const isEncrypted = fs.existsSync(encBundlePath); + const bundlePath = isEncrypted ? encBundlePath : plainBundlePath; + + // Read the signature first — it's tiny (~70 bytes) — so a missing or empty + // sig short-circuits before we touch the (potentially much larger) bundle. + // Treats missing/empty files as non-existent, letting operators "disable" + // an overlay by truncating either file without log noise. + let sigData; + try { + sigData = fs.readFileSync(sigPath); + } catch (err) { + if (err.code === 'ENOENT') return null; + process.stderr.write(`[js2bin] overlay: failed to read signature file: ${err.message}\n`); + return null; + } + if (sigData.length === 0) return null; + + let bundleData; + try { + bundleData = fs.readFileSync(bundlePath); + } catch (err) { + if (err.code === 'ENOENT') return null; + process.stderr.write(`[js2bin] overlay: failed to read bundle file: ${err.message}\n`); + return null; + } + if (bundleData.length === 0) return null; + + // Decrypt if the bundle is encrypted. + if (isEncrypted) { + if (!EMBEDDED_ENCRYPTION_KEY) { + process.stderr.write('[js2bin] overlay: bundle.js.enc found but no encryption key embedded — binary was not built with --encryption-key. Falling back to embedded JS.\n'); + return null; + } + try { + bundleData = decryptBundle(bundleData, EMBEDDED_ENCRYPTION_KEY); + } catch (err) { + process.stderr.write(`[js2bin] overlay: failed to decrypt bundle.js.enc: ${err.message}. Falling back to embedded JS.\n`); + return null; + } + } + + if (!EMBEDDED_SIGNING_PUBLIC_KEY) { + process.stderr.write('[js2bin] overlay: no embedded signing key — binary was not built with --signing-public-key. Ignoring overlay bundle.\n'); + return null; + } + + if (!verifySignature(bundleData, sigData, EMBEDDED_SIGNING_PUBLIC_KEY)) { + process.stderr.write('[js2bin] overlay: signature verification failed — bundle is unsigned or tampered. Falling back to embedded JS.\n'); + return null; + } + + process.stderr.write(`[js2bin] overlay: loaded valid bundle from ${overlayDir}\n`); + return bundleData.toString('utf8'); +} + +// --- Main bootstrap --- + +let source = process.binding('natives')._js2bin_app_main; +if (source.startsWith('`~')) { + console.log(`js2bin binary with ${Math.floor(source.length / 1024 / 1024)}MB of placeholder content. +For more info see: js2bin --help`); + process.exit(-1); +} + +const nullIdx = source.indexOf('\0'); +if (nullIdx > -1) { + source = source.substr(0, nullIdx); +} + +const parts = source.split('\n'); +const appName = Buffer.from(parts[0], 'base64').toString(); +const filename = join(dirname(process.execPath), `${appName.trim()}.js`); + +const embeddedSource = parts[1]; + +// Try overlay bundle +let activeSource = embeddedSource; +try { + const overlayBundle = tryLoadOverlayBundle(dirname(process.execPath)); + if (overlayBundle) { + activeSource = overlayBundle; + } +} catch (err) { + process.stderr.write(`[js2bin] overlay: unexpected error during overlay load: ${err.message}. Falling back to embedded JS.\n`); +} + +// --- Mapped source --- + +const cacheDir = process.env.JS2BIN_SRC_CACHE_DIR || os.tmpdir(); + +// Cache identity MUST come from the payload itself, not from its length. Under overlay the compiled +// source can be either the embedded payload or an overlay bundle, and two different payloads of equal +// length would collide on a length-keyed name -- a node would then map and execute the wrong source. +// Hashing the *compressed* payload keeps this cheap and, crucially, computable without decompressing, +// so an existing cache file still short-circuits the decompress entirely. +// (This is not the integrity check. That has to be a digest embedded in the signed binary and verified +// against the mapped bytes; see the remaining-work notes in _third_party_main_mapped_source.js.) +function cachePathFor(payload) { + const digest = crypto.createHash('sha256').update(payload).digest('hex').slice(0, 32); + return join(cacheDir, `js2bin-src-${appName.trim()}-${digest}.js`); +} + +// Decompress to the cache file if it is not already there, and return the path. Throws only on a +// payload problem (bad base64, bad brotli) or a filesystem problem -- deliberately NOT merged with the +// mapping step below, because the two failures need different recovery. +function materialise(payload) { + const cachePath = cachePathFor(payload); + if (!fs.existsSync(cachePath)) { + const decoded = brotliDecompressSync(Buffer.from(payload, 'base64'), + { chunkSize: 128 * 1024 * 1024 }); + // Write-to-temp-then-rename keeps a concurrent mapper from ever seeing a partial file. + const tmp = `${cachePath}.${process.pid}.tmp`; + fs.writeFileSync(tmp, decoded); + try { + fs.renameSync(tmp, cachePath); + } catch (err) { + // Another process won the race; its file is equivalent. + try { fs.unlinkSync(tmp); } catch { /* ignore */ } + } + } + return cachePath; +} + +// Which payload we actually compile. Only a *decompression* failure of an overlay bundle demotes us to +// the embedded payload -- a mapping failure must not, or a perfectly good overlay would be discarded +// because of an unrelated filesystem or binding problem. +let compiledSource = activeSource; +let cachePath = null; +try { + cachePath = materialise(activeSource); +} catch (err) { + if (activeSource !== embeddedSource) { + process.stderr.write(`[js2bin] overlay: failed to decompress overlay bundle: ${err.message}. Falling back to embedded JS.\n`); + compiledSource = embeddedSource; + try { + cachePath = materialise(embeddedSource); + } catch (err2) { + process._rawDebug(`js2bin: falling back to in-heap source (${err2 && err2.message})`); + cachePath = null; + } + } else { + process._rawDebug(`js2bin: falling back to in-heap source (${err && err.message})`); + cachePath = null; + } +} + +let external = null; +if (cachePath !== null) { + try { + external = internalBinding('js2bin').mapFileAsExternalString(cachePath); + } catch (err) { + // Binding missing or mapping refused. Keep whatever source we resolved above and take the + // historical in-heap path rather than refusing to boot. + process._rawDebug(`js2bin: falling back to in-heap source (${err && err.message})`); + external = null; + } +} + +// here we turn what looks like an internal module to an non-internal one +// that way the module is loaded exactly as it would by: node app_main.js +const mod = new Module(process.execPath, null); +mod.id = '.'; // main module +mod.filename = filename; // dirname of this is used by require +process.mainModule = mod; // main module + +if (external !== null) { + // Cluster setup, previously textually prepended to the source. Runs before the app's module scope, + // which is the same ordering as the original bootstrap. + const cluster = require('cluster'); + if (cluster.worker) { + // NOOP - cluster worker already initialized, likely Node 12.x+ + } else if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker(); + delete process.env.NODE_UNIQUE_ID; + } else { + process.argv.splice(1, 0, filename); + } + mod._compile(external, filename); +} else { + mod._compile(` + +// initialize clustering +const cluster = require('cluster'); +if (cluster.worker) { + // NOOP - cluster worker already initialized, likely Node 12.x+ +}else if (process.argv[1] && process.env.NODE_UNIQUE_ID) { + cluster._setupWorker() + delete process.env.NODE_UNIQUE_ID +} else { + process.argv.splice(1, 0, __filename); // don't mess with argv in clustering +} + +${brotliDecompressSync(Buffer.from(compiledSource, 'base64'), { chunkSize: 128 * 1024 * 1024 }).toString()} + +`, filename); +}