diff --git a/src/NodeBuilder.js b/src/NodeBuilder.js index 8b67c19..ced6ae2 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,18 @@ 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'; + // 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 + ? (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); @@ -329,6 +357,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 +444,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 +527,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_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); +} 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.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) { 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() {} +