Skip to content

Commit 8edf13e

Browse files
committed
sea: generate a compile cache for the VFS modules
"useCodeCache" was rejected together with "useVfs" because the SEA code cache is only consumed by the embedder compile paths, while a main script in the VFS runs through the regular loaders and a main-only cache would not cover the modules it depends on anyway. Compile every JavaScript (and, with --strip-types, TypeScript) module in the VFS when the preparation blob is generated, without executing them, through a portable CompileCacheHandler keyed by the path relative to the mount point. The entries are serialized in the cache file layout and embedded as reserved node:sea:compile-cache/<key> assets, which the SEA provider hides from the mount. Modules that fail to compile are skipped. CompileCacheHandler gains a key root for portable caches and in-memory serialization of its entries, shared with Persist(). The build runs in a Node.js instance whose environment ignores NODE_COMPILE_CACHE. Reading the embedded entries at startup is left to a follow-up. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 29bd740 commit 8edf13e

16 files changed

Lines changed: 661 additions & 71 deletions

‎doc/api/single-executable-applications.md‎

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ added:
77
- v19.7.0
88
- v18.16.0
99
changes:
10+
- version: REPLACEME
11+
pr-url: https://github.com/nodejs/node/pull/66768
12+
description: Allowed "useCodeCache" together with "useVfs".
1013
- version: v25.5.0
1114
pr-url: https://github.com/nodejs/node/pull/61167
1215
description: Added built-in single executable application generation via the CLI flag `--build-sea`.
@@ -317,13 +320,25 @@ with `"vfsArchive"`: `--build-sea` rejects it in `"execArgv"`, and the
317320
executable rejects it at startup when it comes from `--node-options` or from
318321
the `execArgv` of a worker.
319322
320-
#### Snapshot and code caching limitations
323+
#### Code cache for the modules in the VFS
321324
322-
`"useVfs": true` cannot be used together with `"useSnapshot": true` or
323-
`"useCodeCache": true`. The code cache limitation is due to incomplete
324-
implementation, not a technical impossibility. Consider bundling the
325-
application if startup performance matters and do not rely on module loading
326-
from the VFS in that case.
325+
When `"useCodeCache": true` is combined with `"useVfs": true`, the code cache
326+
is not limited to the main script: `--build-sea` compiles every JavaScript
327+
module in the virtual file system (and every TypeScript module, when type
328+
stripping is enabled), including the main script and the modules under
329+
`node_modules`, and embeds their V8 code cache into the preparation blob.
330+
The modules are compiled without being run, so the build has no side
331+
effects. A module that fails to compile is left out of the cache and fails
332+
the same way at run time if the application loads it.
333+
334+
The cache is keyed by the path of each module inside the mount and verified
335+
against its source, so it is used by the executable whenever the modules are
336+
loaded from the VFS, and ignored if they changed. The cache is
337+
platform-specific, like the code cache of the main script.
338+
339+
#### Snapshot limitations
340+
341+
`"useVfs": true` cannot be used together with `"useSnapshot": true`.
327342
328343
#### Native addon limitations
329344
@@ -373,6 +388,9 @@ executable application is launched, instead of compiling the `main` script from
373388
scratch, Node.js would use the code cache to speed up the compilation, then
374389
execute the script, which would improve the startup performance.
375390
391+
When `useVfs` is also set to `true`, the code cache covers every module in the
392+
virtual file system, see [Code cache for the modules in the VFS][].
393+
376394
### Execution arguments
377395
378396
The `execArgv` field can be used to specify Node.js-specific
@@ -798,6 +816,7 @@ Suggestions for other resource injection tools/workflows are welcomed. Please
798816
start a discussion at <https://github.com/nodejs/single-executable/discussions>
799817
to help us document them.
800818

819+
[Code cache for the modules in the VFS]: #code-cache-for-the-modules-in-the-vfs
801820
[CommonJS]: modules.md#modules-commonjs-modules
802821
[ECMAScript Modules]: esm.md#modules-ecmascript-modules
803822
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format

‎doc/api/vfs.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -571,8 +571,10 @@ ESM entry points (`"mainFormat": "module"`) are supported: the main module
571571
is loaded from inside the mount through the ESM loader, and
572572
`import.meta.dirname` points at the mount root.
573573

574-
`"useVfs"` cannot be used together with `"useSnapshot"` or `"useCodeCache"`.
575-
The SEA configuration parser will error if either combination is detected.
574+
`"useVfs"` cannot be used together with `"useSnapshot"`; the SEA
575+
configuration parser will error if the combination is detected. With
576+
`"useCodeCache"`, every module in the mount is compiled at build time and its
577+
code cache is embedded into the executable.
576578

577579
Instead of listing individual `"assets"`, the SEA configuration can point
578580
`"vfsArchive"` at a prebuilt ZIP archive; the mount is then backed by a

‎lib/internal/vfs/providers/sea.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ const {
4444
},
4545
} = internalBinding('constants');
4646

47+
// Assets under this prefix are reserved by --build-sea (the compile cache of
48+
// the modules in the virtual file system) and are not files of the
49+
// application. Must match src/node_sea.cc.
50+
const kReservedAssetPrefix = 'node:sea:';
51+
4752
// Private symbols
4853
const kAssets = Symbol('kAssets');
4954
const kExtraFiles = Symbol('kExtraFiles');
@@ -91,6 +96,7 @@ class SEAProvider extends VirtualProvider {
9196
const keys = getAssetKeys() || [];
9297
for (let i = 0; i < keys.length; i++) {
9398
const key = keys[i];
99+
if (StringPrototypeStartsWith(key, kReservedAssetPrefix)) continue;
94100
this[kAssets].set(this.#normalizePath(key), key);
95101
}
96102
const extraFiles = options.extraFiles;

‎lib/internal/vfs/sea.js‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,13 @@ function initSeaVfsInWorker() {
8383

8484
/**
8585
* The name under which the SEA main script is placed at the mount point root.
86+
* @param {string} [mainPath] The "main" of the SEA configuration; defaults
87+
* to the one recorded in the running executable
8688
* @returns {string}
8789
*/
88-
function getSeaMainName() {
90+
function getSeaMainName(mainPath = mainCodePath || process.execPath) {
8991
const { basename } = require('path');
90-
return basename(mainCodePath || process.execPath);
92+
return basename(mainPath);
9193
}
9294

9395
// The reserved asset key under which --build-sea stores the ZIP archive
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
'use strict';
2+
3+
// Build-time counterpart of the SEA virtual file system: run by --build-sea
4+
// (src/node_sea.cc) in a regular Node.js instance to compile every module of
5+
// the virtual file system and collect their compile cache, which the
6+
// executable then carries as assets. The assets are mounted the way the
7+
// executable mounts them at startup, so the modules are compiled from the
8+
// same paths and sources that the loaders compile them from in the
9+
// executable, and the cache is keyed and hashed exactly as the loaders look
10+
// it up there.
11+
12+
const {
13+
ArrayPrototypeIncludes,
14+
StringPrototypeEndsWith,
15+
StringPrototypeReplaceAll,
16+
} = primordials;
17+
18+
const { Buffer } = require('buffer');
19+
const fs = require('fs');
20+
const path = require('path');
21+
const { pathToFileURL } = require('internal/url');
22+
const { getOptionValue } = require('internal/options');
23+
const {
24+
enableCompileCache: enableNativeCompileCache,
25+
} = internalBinding('modules');
26+
const {
27+
constants: { compileCacheStatus },
28+
stringify,
29+
} = require('internal/modules/helpers');
30+
const {
31+
codes: {
32+
ERR_INVALID_STATE,
33+
},
34+
} = require('internal/errors');
35+
36+
let debug = require('internal/util/debuglog').debuglog('sea', (fn) => {
37+
debug = fn;
38+
});
39+
40+
// Only files the module loaders compile are cached. Everything else in the
41+
// mount is data.
42+
const kScriptExtensions = ['.js', '.cjs', '.mjs'];
43+
const kTypeScriptExtensions = ['.ts', '.cts', '.mts'];
44+
45+
/**
46+
* Mounts the assets of the SEA configuration the way the executable mounts
47+
* them: the main script at the mount point root, at the layer reserved for
48+
* --vfs-load when they come from an archive (see lib/internal/vfs/sea.js).
49+
* @param {object} options The SEA configuration, see buildSeaCompileCache()
50+
* @param {string} mainName The name of the main script at the mount root
51+
* @returns {string} The mount point
52+
*/
53+
function mountAssets(options, mainName) {
54+
const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
55+
const isArchive = options.vfsArchive !== undefined;
56+
57+
let provider;
58+
if (isArchive) {
59+
const { ZipBuffer } = require('internal/zip');
60+
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
61+
const zip = new ZipBuffer(fs.readFileSync(options.vfsArchive));
62+
zip.addSync(mainName, Buffer.from(options.mainSource),
63+
{ __proto__: null, method: 'store' });
64+
provider = new ZipProvider(zip);
65+
} else {
66+
const { MemoryProvider } = require('internal/vfs/providers/memory');
67+
provider = new MemoryProvider();
68+
}
69+
70+
const vfs = new VirtualFileSystem(provider, {
71+
__proto__: null,
72+
emitExperimentalWarning: false,
73+
[kLoadLayer]: isArchive,
74+
});
75+
76+
if (!isArchive) {
77+
// Every asset is a file at its key, as the SEAProvider serves them.
78+
const { assetKeys, assetPaths } = options;
79+
for (let i = 0; i < assetKeys.length; i++) {
80+
const target = path.posix.normalize(
81+
'/' + StringPrototypeReplaceAll(assetKeys[i], '\\', '/'));
82+
const dir = path.posix.dirname(target);
83+
if (dir !== '/') {
84+
vfs.mkdirSync(dir, { __proto__: null, recursive: true });
85+
}
86+
vfs.writeFileSync(target, fs.readFileSync(assetPaths[i]));
87+
}
88+
vfs.writeFileSync('/' + mainName, options.mainSource);
89+
provider.setReadOnly();
90+
}
91+
92+
return vfs.mount();
93+
}
94+
95+
/**
96+
* Compiles a module without running it, the way the loaders compile it when
97+
* it is loaded from the mount, so that its compile cache entry is created.
98+
* @param {string} filename The path of the module inside the mount
99+
* @returns {boolean} Whether the file is a module the loaders compile
100+
*/
101+
function compileModule(filename) {
102+
const { Module, wrapSafe } = require('internal/modules/cjs/loader');
103+
const {
104+
defaultGetFormatWithoutErrors,
105+
} = require('internal/modules/esm/get_format');
106+
const {
107+
compileSourceTextModule,
108+
SourceTextModuleTypes: { kUser },
109+
} = require('internal/modules/esm/utils');
110+
111+
const url = pathToFileURL(filename);
112+
const buffer = fs.readFileSync(filename);
113+
let format = defaultGetFormatWithoutErrors(url, {
114+
__proto__: null,
115+
source: buffer,
116+
});
117+
118+
let source;
119+
switch (format) {
120+
case 'module':
121+
// The ESM loader decodes the source it loads.
122+
source = stringify(buffer);
123+
break;
124+
case 'commonjs':
125+
case 'commonjs-typescript':
126+
case 'module-typescript':
127+
case 'typescript':
128+
// The CommonJS loader reads the source as a UTF-8 string.
129+
source = buffer.toString('utf8');
130+
break;
131+
default:
132+
return false;
133+
}
134+
135+
if (StringPrototypeEndsWith(format, 'typescript')) {
136+
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
137+
source = stripTypeScriptModuleTypes(source, filename, url.href);
138+
switch (format) {
139+
case 'commonjs-typescript':
140+
format = 'commonjs';
141+
break;
142+
case 'module-typescript':
143+
format = 'module';
144+
break;
145+
default:
146+
// Detected from the type-stripped source by wrapSafe(), like
147+
// Module.prototype._compile() does.
148+
format = undefined;
149+
break;
150+
}
151+
}
152+
153+
if (format !== 'module') {
154+
const result = wrapSafe(filename, source, new Module(filename, null), format);
155+
if (!result.canParseAsESM) {
156+
return true;
157+
}
158+
}
159+
compileSourceTextModule(url.href, source, kUser);
160+
return true;
161+
}
162+
163+
/**
164+
* Compiles every module under a directory of the mount.
165+
* @param {string} dir The directory
166+
* @param {boolean} stripTypes Whether TypeScript files are modules too
167+
* @returns {number} The number of modules compiled
168+
*/
169+
function compileTree(dir, stripTypes) {
170+
let compiled = 0;
171+
const dirents = fs.readdirSync(dir, { __proto__: null, withFileTypes: true });
172+
for (let i = 0; i < dirents.length; i++) {
173+
const dirent = dirents[i];
174+
const filename = path.join(dir, dirent.name);
175+
if (dirent.isDirectory()) {
176+
compiled += compileTree(filename, stripTypes);
177+
continue;
178+
}
179+
if (!dirent.isFile()) continue;
180+
const ext = path.extname(filename);
181+
if (!ArrayPrototypeIncludes(kScriptExtensions, ext) &&
182+
!(stripTypes && ArrayPrototypeIncludes(kTypeScriptExtensions, ext))) {
183+
continue;
184+
}
185+
// A module that does not compile is left out of the cache: it fails the
186+
// same way at run time if the application ever loads it.
187+
try {
188+
if (compileModule(filename)) compiled++;
189+
} catch (err) {
190+
debug('not caching %s: %s', filename, err?.message);
191+
}
192+
}
193+
return compiled;
194+
}
195+
196+
/**
197+
* Compiles the modules of the SEA virtual file system into the compile cache
198+
* of this Node.js instance, from which --build-sea collects them. The cache
199+
* is keyed relative to the mount point, which is the same in the executable.
200+
* @param {object} options
201+
* @param {string} options.main The "main" of the SEA configuration
202+
* @param {string} options.mainSource The source of the main script
203+
* @param {string} [options.vfsArchive] The "vfsArchive" of the configuration
204+
* @param {string[]} [options.assetKeys] The keys of the "assets"
205+
* @param {string[]} [options.assetPaths] The paths of the "assets"
206+
* @returns {number} The number of modules compiled
207+
*/
208+
function buildSeaCompileCache(options) {
209+
const { getSeaMainName } = require('internal/vfs/sea');
210+
const mountPoint = mountAssets(options, getSeaMainName(options.main));
211+
212+
// Enabling the compile cache takes a directory, but nothing is persisted
213+
// to it: the entries are collected from memory by --build-sea.
214+
const cacheDir = fs.mkdtempSync(
215+
path.join(require('os').tmpdir(), 'node-sea-compile-cache-'));
216+
try {
217+
const { 0: status, 1: message } = enableNativeCompileCache(
218+
cacheDir, true /* portable */, false /* readOnly */, mountPoint);
219+
if (status !== compileCacheStatus.ENABLED) {
220+
throw new ERR_INVALID_STATE(
221+
`cannot enable the compile cache for ${mountPoint}: ${message}`);
222+
}
223+
const compiled = compileTree(mountPoint, getOptionValue('--strip-types'));
224+
debug('compiled %d modules in %s for the compile cache',
225+
compiled, mountPoint);
226+
return compiled;
227+
} finally {
228+
fs.rmSync(cacheDir, { __proto__: null, recursive: true, force: true });
229+
}
230+
}
231+
232+
module.exports = {
233+
buildSeaCompileCache,
234+
};

0 commit comments

Comments
 (0)