From 5d492282d35c57d56b4b745efbd1860aef14ed83 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Sun, 16 Aug 2026 14:31:59 +0000 Subject: [PATCH] lib: load fewer builtins when bootstrapping without a snapshot Contexts that are not deserialized from the built-in snapshot -- worker threads, and the main context of embedders that create their own isolate or of `node --no-node-snapshot` -- compile (with the code cache at best) every builtin the bootstrap touches, so each eagerly required builtin is startup time (~0.15-0.4 ms apiece). A number of them are only required eagerly so that they end up in the snapshot, or for features the bootstrap path never uses. Load lazily what those paths do not need: - is_main_thread.js: preload util, url, the ESM loader (translators, resolver, module_job/map, source maps, node:module, vm modules, mime, data_url, the TypeScript stripper), internal/blob and internal/dns/utils only while building a snapshot; they load on first use otherwise. - fs: internal/blob (+ internal/encoding and its tables) is only used by fs.openAsBlob(). - internal/url: internal/data_url (+ internal/mime) is only used by the Buffer-returning file URL helpers. - internal/process/execution, the CommonJS loader, esm/translators and esm/load: the TypeScript stripper and data: URL helpers are only needed for TypeScript sources / data: URLs. - pre_execution: internal/dns/utils (+ internal/net) is only needed up front to validate an explicit --dns-result-order or to register the resolver's snapshot serializer; the default order becomes the variable's initializer. - internal/worker: event_loop_utilization and error_serdes are only needed once a sub-worker's ELU is read or it reports an error. - worker_threads: `locks` is defined lazily, like util's lazy exports. Main-thread startup with the snapshot is unchanged (the same modules are preloaded into it; the bootstrap-modules test lists are adjusted). A bare worker compiles 95 -> 83 builtins (cold start -5%); without the snapshot an empty CommonJS entry point compiles 76 -> 59 builtins and an empty ES module entry point 76 -> 69. Signed-off-by: Shelley Vohr --- lib/fs.js | 3 +-- .../bootstrap/switches/is_main_thread.js | 23 +++++++++++++++---- lib/internal/dns/utils.js | 8 +++---- lib/internal/modules/cjs/loader.js | 4 ++-- lib/internal/modules/esm/load.js | 4 +--- lib/internal/modules/esm/translators.js | 5 +++- lib/internal/process/execution.js | 4 +++- lib/internal/process/pre_execution.js | 7 +++++- lib/internal/url.js | 10 +++++--- lib/internal/worker.js | 7 ++---- lib/worker_threads.js | 12 +++++++--- 11 files changed, 57 insertions(+), 30 deletions(-) diff --git a/lib/fs.js b/lib/fs.js index 81c4d2b9c884..28e7b28e6a93 100644 --- a/lib/fs.js +++ b/lib/fs.js @@ -64,8 +64,6 @@ const { isArrayBufferView } = require('internal/util/types'); const binding = internalBinding('fs'); -const { createBlobFromFilePath } = require('internal/blob'); - const { Buffer } = require('buffer'); const { isBuffer: BufferIsBuffer } = Buffer; const BufferToString = uncurryThis(Buffer.prototype.toString); @@ -722,6 +720,7 @@ function openAsBlob(path, options = kEmptyObject) { // To give ourselves flexibility to maybe return the Blob asynchronously, // this API returns a Promise. path = getValidatedPath(path); + const { createBlobFromFilePath } = require('internal/blob'); return PromiseResolve(createBlobFromFilePath(path, { type })); } diff --git a/lib/internal/bootstrap/switches/is_main_thread.js b/lib/internal/bootstrap/switches/is_main_thread.js index 06ac27c84869..8cf57b231856 100644 --- a/lib/internal/bootstrap/switches/is_main_thread.js +++ b/lib/internal/bootstrap/switches/is_main_thread.js @@ -292,12 +292,27 @@ rawMethods.resetStdioForTesting = function() { // Needed by the module loader and generally needed everywhere. require('fs'); -require('util'); -require('url'); // eslint-disable-line no-restricted-modules internalBinding('module_wrap'); require('internal/modules/cjs/loader'); -require('internal/modules/esm/loader'); require('internal/modules/esm/utils'); +if (isBuildingSnapshot()) { + // Preloaded so that they are part of the snapshot, where they cost nothing + // at startup. When bootstrapping WITHOUT a snapshot (worker threads, + // embedders that create their own isolate, --no-node-snapshot) they are + // loaded on first use instead: the ESM loader (with its translators, + // resolver and their dependencies) by run_main/import(), the public util + // and url modules by whoever requires them, data: URL and TypeScript + // support by the module loaders, internal/blob by fs.openAsBlob(), and the + // DNS helpers by node:dns or an explicit --dns-result-order (see + // pre_execution). + require('util'); + require('url'); // eslint-disable-line no-restricted-modules + require('internal/modules/esm/loader'); + require('internal/data_url'); + require('internal/modules/typescript'); + require('internal/blob'); + require('internal/dns/utils'); +} // Needed to refresh the time origin. require('internal/perf/utils'); @@ -311,8 +326,6 @@ internalBinding('wasm_web_api'); internalBinding('worker'); // Needed by most execution modes. require('internal/modules/run_main'); -// Needed to refresh DNS configurations. -require('internal/dns/utils'); // Needed by almost all execution modes. It's fine to // load them into the snapshot as long as we don't run // any of the initialization. diff --git a/lib/internal/dns/utils.js b/lib/internal/dns/utils.js index 8e70baffb904..50ec10d3b042 100644 --- a/lib/internal/dns/utils.js +++ b/lib/internal/dns/utils.js @@ -214,15 +214,15 @@ class ResolverBase { } let defaultResolver; -let dnsOrder; +// May already hold a value chosen by the snapshotted application; a +// --dns-result-order flag given at runtime overrides it in initializeDns(). +let dnsOrder = 'verbatim'; const validDnsOrders = ['verbatim', 'ipv4first', 'ipv6first']; const validFamilies = [0, 4, 6]; function initializeDns() { const orderFromCLI = getOptionValue('--dns-result-order'); - if (!orderFromCLI) { - dnsOrder ??= 'verbatim'; - } else { + if (orderFromCLI) { // Allow the deserialized application to override order from CLI. validateOneOf(orderFromCLI, '--dns-result-order', validDnsOrders); dnsOrder = orderFromCLI; diff --git a/lib/internal/modules/cjs/loader.js b/lib/internal/modules/cjs/loader.js index bb466d0b68d5..d5de950e3c8e 100644 --- a/lib/internal/modules/cjs/loader.js +++ b/lib/internal/modules/cjs/loader.js @@ -180,7 +180,7 @@ const { resolveWithHooks, validateLoadStrict, } = require('internal/modules/customization_hooks'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); +const lazyTypeScript = getLazy(() => require('internal/modules/typescript')); const packageJsonReader = require('internal/modules/package_json_reader'); const { getOptionValue, getEmbedderOptions } = require('internal/options'); const shouldReportRequiredModules = getLazy(() => process.env.WATCH_REPORT_DEPENDENCIES); @@ -1888,7 +1888,7 @@ function wrapSafe(filename, content, cjsModuleInstance, format) { Module.prototype._compile = function(content, filename, format) { if (format === 'commonjs-typescript' || format === 'module-typescript' || format === 'typescript') { this[kURL] ??= convertCJSFilenameToURL(filename); - content = stripTypeScriptModuleTypes(content, filename, this[kURL]); + content = lazyTypeScript().stripTypeScriptModuleTypes(content, filename, this[kURL]); switch (format) { case 'commonjs-typescript': { format = 'commonjs'; diff --git a/lib/internal/modules/esm/load.js b/lib/internal/modules/esm/load.js index 94879761553e..0c38ae082e32 100644 --- a/lib/internal/modules/esm/load.js +++ b/lib/internal/modules/esm/load.js @@ -20,9 +20,6 @@ const { ERR_UNSUPPORTED_ESM_URL_SCHEME, } = require('internal/errors').codes; -const { - dataURLProcessor, -} = require('internal/data_url'); /** * @param {URL} url URL to the module @@ -40,6 +37,7 @@ function getSourceSync(url, context) { // Prefer module.registerHooks() or other more formal fs hooks released in the future. source = fs.readFileSync(url); } else if (protocol === 'data:') { + const { dataURLProcessor } = require('internal/data_url'); // Only for data: URLs. const result = dataURLProcessor(url); if (result === 'failure') { throw new ERR_INVALID_URL(responseURL); diff --git a/lib/internal/modules/esm/translators.js b/lib/internal/modules/esm/translators.js index ad3de25bf6d5..e3baaaefa6c0 100644 --- a/lib/internal/modules/esm/translators.js +++ b/lib/internal/modules/esm/translators.js @@ -30,7 +30,10 @@ const { stripBOM, urlToFilename, } = require('internal/modules/helpers'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); +function stripTypeScriptModuleTypes(source, url) { + // Only needed for TypeScript sources; keep it out of the loader's startup path. + return require('internal/modules/typescript').stripTypeScriptModuleTypes(source, url); +} const { kIsCachedByESMLoader, Module: CJSModule, diff --git a/lib/internal/process/execution.js b/lib/internal/process/execution.js index 4a2b57790bd7..46cf9f95407a 100644 --- a/lib/internal/process/execution.js +++ b/lib/internal/process/execution.js @@ -25,7 +25,9 @@ const { kSourcePhase, kEvaluationPhase, } = internalBinding('module_wrap'); -const { stripTypeScriptModuleTypes } = require('internal/modules/typescript'); +function stripTypeScriptModuleTypes(source, filename) { + return require('internal/modules/typescript').stripTypeScriptModuleTypes(source, filename); +} const { executionAsyncId, diff --git a/lib/internal/process/pre_execution.js b/lib/internal/process/pre_execution.js index e379fbe34021..d2b48a463b32 100644 --- a/lib/internal/process/pre_execution.js +++ b/lib/internal/process/pre_execution.js @@ -139,7 +139,12 @@ function prepareExecution(options) { initializeConfigFileSupport(); - require('internal/dns/utils').initializeDns(); + // internal/dns/utils (and internal/net behind it) is only needed up front + // to validate an explicit --dns-result-order or to register the resolver's + // snapshot serialization; otherwise it is loaded with node:dns. + if (getOptionValue('--dns-result-order') || isBuildingSnapshot()) { + require('internal/dns/utils').initializeDns(); + } if (isMainThread) { assert(internalBinding('worker').isMainThread); diff --git a/lib/internal/url.js b/lib/internal/url.js index e96df6148f98..876a5d2a3618 100644 --- a/lib/internal/url.js +++ b/lib/internal/url.js @@ -93,7 +93,11 @@ const { kValidateObjectAllowObjects, } = require('internal/validators'); -const { percentDecode } = require('internal/data_url'); +let percentDecode; +function lazyPercentDecode(input) { + percentDecode ??= require('internal/data_url').percentDecode; + return percentDecode(input); +} const querystring = require('querystring'); @@ -1560,7 +1564,7 @@ function getPathBufferFromURLWin32(url) { // percent encoded characters and we take the string as is. Any invalid // percent encodings, e.g. `%ZZ` are ignored and are passed through // literally. - const decodedu8 = percentDecode(Buffer.from(pathname, 'utf8')); + const decodedu8 = lazyPercentDecode(Buffer.from(pathname, 'utf8')); const decodedPathname = Buffer.from(TypedArrayPrototypeGetBuffer(decodedu8), TypedArrayPrototypeGetByteOffset(decodedu8), TypedArrayPrototypeGetByteLength(decodedu8)); @@ -1635,7 +1639,7 @@ function getPathBufferFromURLPosix(url) { // won't scan for the slashes at all, and instead will decode the bytes // literally into the returned Buffer. We're going to do the best we can and // just interpret the input url as a sequence of bytes. - const u8 = percentDecode(Buffer.from(pathname, 'utf8')); + const u8 = lazyPercentDecode(Buffer.from(pathname, 'utf8')); return Buffer.from(TypedArrayPrototypeGetBuffer(u8), TypedArrayPrototypeGetByteOffset(u8), TypedArrayPrototypeGetByteLength(u8)); diff --git a/lib/internal/worker.js b/lib/internal/worker.js index 60e95273ef5f..f3f3c23a5abe 100644 --- a/lib/internal/worker.js +++ b/lib/internal/worker.js @@ -30,10 +30,6 @@ const { const EventEmitter = require('events'); const assert = require('internal/assert'); const path = require('path'); -const { - internalEventLoopUtilization, -} = require('internal/perf/event_loop_utilization'); - const errorCodes = require('internal/errors').codes; const { ERR_WORKER_NOT_RUNNING, @@ -60,7 +56,6 @@ const { WritableWorkerStdio, } = workerIo; const { createMainThreadPort, destroyMainThreadPort } = require('internal/worker/messaging'); -const { deserializeError } = require('internal/error_serdes'); const { fileURLToPath, isURL, pathToFileURL } = require('internal/url'); const { constructSharedArrayBuffer, @@ -417,6 +412,7 @@ class Worker extends EventEmitter { [kOnErrorMessage](serialized) { // This is what is called for uncaught exceptions. + const { deserializeError } = require('internal/error_serdes'); const error = deserializeError(serialized); this.emit('error', error); } @@ -699,6 +695,7 @@ function makeResourceLimits(float64arr) { } function eventLoopUtilization(util1, util2) { + const { internalEventLoopUtilization } = require('internal/perf/event_loop_utilization'); // TODO(trevnorris): Works to solve the thread-safe read/write issue of // loopTime, but has the drawback that it can't be set until the event loop // has had a chance to turn. So it will be impossible to read the ELU of diff --git a/lib/worker_threads.js b/lib/worker_threads.js index 78007d1502b3..622bef3f55c1 100644 --- a/lib/worker_threads.js +++ b/lib/worker_threads.js @@ -1,5 +1,7 @@ 'use strict'; +const { defineLazyProperties } = require('internal/util'); + const { isInternalThread, isMainThread, @@ -30,8 +32,6 @@ const { isMarkedAsUntransferable, } = require('internal/buffer'); -const { locks } = require('internal/locks'); - module.exports = { isInternalThread, isMainThread, @@ -53,5 +53,11 @@ module.exports = { BroadcastChannel, setEnvironmentData, getEnvironmentData, - locks, }; + +// The Web Locks API implementation is only needed once `locks` is used. +defineLazyProperties( + module.exports, + 'internal/locks', + ['locks'], +);