Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 52 additions & 3 deletions src/NodeBuilder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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),
{
Expand All @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand Down
126 changes: 126 additions & 0 deletions src/_third_party_main_mapped_source.js
Original file line number Diff line number Diff line change
@@ -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.
Loading