Skip to content
Open
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
31 changes: 25 additions & 6 deletions doc/api/single-executable-applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ added:
- v19.7.0
- v18.16.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/66768
description: Allowed "useCodeCache" together with "useVfs".
- version: v25.5.0
pr-url: https://github.com/nodejs/node/pull/61167
description: Added built-in single executable application generation via the CLI flag `--build-sea`.
Expand Down Expand Up @@ -317,13 +320,25 @@ with `"vfsArchive"`: `--build-sea` rejects it in `"execArgv"`, and the
executable rejects it at startup when it comes from `--node-options` or from
the `execArgv` of a worker.

#### Snapshot and code caching limitations
#### Code cache for the modules in the VFS

`"useVfs": true` cannot be used together with `"useSnapshot": true` or
`"useCodeCache": true`. The code cache limitation is due to incomplete
implementation, not a technical impossibility. Consider bundling the
application if startup performance matters and do not rely on module loading
from the VFS in that case.
When `"useCodeCache": true` is combined with `"useVfs": true`, the code cache
is not limited to the main script: `--build-sea` compiles every JavaScript
module in the virtual file system (and every TypeScript module, when type
stripping is enabled), including the main script and the modules under
`node_modules`, and embeds their V8 code cache into the preparation blob.
The modules are compiled without being run, so the build has no side
effects. A module that fails to compile is left out of the cache and fails
the same way at run time if the application loads it.

The cache is keyed by the path of each module inside the mount and verified
against its source, so it is used by the executable whenever the modules are
loaded from the VFS, and ignored if they changed. The cache is
platform-specific, like the code cache of the main script.

#### Snapshot limitations

`"useVfs": true` cannot be used together with `"useSnapshot": true`.

#### Native addon limitations

Expand Down Expand Up @@ -373,6 +388,9 @@ executable application is launched, instead of compiling the `main` script from
scratch, Node.js would use the code cache to speed up the compilation, then
execute the script, which would improve the startup performance.

When `useVfs` is also set to `true`, the code cache covers every module in the
virtual file system, see [Code cache for the modules in the VFS][].

### Execution arguments

The `execArgv` field can be used to specify Node.js-specific
Expand Down Expand Up @@ -798,6 +816,7 @@ Suggestions for other resource injection tools/workflows are welcomed. Please
start a discussion at <https://github.com/nodejs/single-executable/discussions>
to help us document them.

[Code cache for the modules in the VFS]: #code-cache-for-the-modules-in-the-vfs
[CommonJS]: modules.md#modules-commonjs-modules
[ECMAScript Modules]: esm.md#modules-ecmascript-modules
[ELF]: https://en.wikipedia.org/wiki/Executable_and_Linkable_Format
Expand Down
6 changes: 4 additions & 2 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -571,8 +571,10 @@ ESM entry points (`"mainFormat": "module"`) are supported: the main module
is loaded from inside the mount through the ESM loader, and
`import.meta.dirname` points at the mount root.

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

Instead of listing individual `"assets"`, the SEA configuration can point
`"vfsArchive"` at a prebuilt ZIP archive; the mount is then backed by a
Expand Down
6 changes: 6 additions & 0 deletions lib/internal/vfs/providers/sea.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ const {
},
} = internalBinding('constants');

// Assets under this prefix are reserved by --build-sea (the compile cache of
// the modules in the virtual file system) and are not files of the
// application. Must match src/node_sea.cc.
const kReservedAssetPrefix = 'node:sea:';

// Private symbols
const kAssets = Symbol('kAssets');
const kExtraFiles = Symbol('kExtraFiles');
Expand Down Expand Up @@ -91,6 +96,7 @@ class SEAProvider extends VirtualProvider {
const keys = getAssetKeys() || [];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (StringPrototypeStartsWith(key, kReservedAssetPrefix)) continue;
this[kAssets].set(this.#normalizePath(key), key);
}
const extraFiles = options.extraFiles;
Expand Down
6 changes: 4 additions & 2 deletions lib/internal/vfs/sea.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ function initSeaVfsInWorker() {

/**
* The name under which the SEA main script is placed at the mount point root.
* @param {string} [mainPath] The "main" of the SEA configuration; defaults
* to the one recorded in the running executable
* @returns {string}
*/
function getSeaMainName() {
function getSeaMainName(mainPath = mainCodePath || process.execPath) {
const { basename } = require('path');
return basename(mainCodePath || process.execPath);
return basename(mainPath);
}

// The reserved asset key under which --build-sea stores the ZIP archive
Expand Down
234 changes: 234 additions & 0 deletions lib/internal/vfs/sea_compile_cache.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
'use strict';

// Build-time counterpart of the SEA virtual file system: run by --build-sea
// (src/node_sea.cc) in a regular Node.js instance to compile every module of
// the virtual file system and collect their compile cache, which the
// executable then carries as assets. The assets are mounted the way the
// executable mounts them at startup, so the modules are compiled from the
// same paths and sources that the loaders compile them from in the
// executable, and the cache is keyed and hashed exactly as the loaders look
// it up there.

const {
ArrayPrototypeIncludes,
StringPrototypeEndsWith,
StringPrototypeReplaceAll,
} = primordials;

const { Buffer } = require('buffer');
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('internal/url');
const { getOptionValue } = require('internal/options');
const {
enableCompileCache: enableNativeCompileCache,
} = internalBinding('modules');
const {
constants: { compileCacheStatus },
stringify,
} = require('internal/modules/helpers');
const {
codes: {
ERR_INVALID_STATE,
},
} = require('internal/errors');

let debug = require('internal/util/debuglog').debuglog('sea', (fn) => {
debug = fn;
});

// Only files the module loaders compile are cached. Everything else in the
// mount is data.
const kScriptExtensions = ['.js', '.cjs', '.mjs'];
const kTypeScriptExtensions = ['.ts', '.cts', '.mts'];

/**
* Mounts the assets of the SEA configuration the way the executable mounts
* them: the main script at the mount point root, at the layer reserved for
* --vfs-load when they come from an archive (see lib/internal/vfs/sea.js).
* @param {object} options The SEA configuration, see buildSeaCompileCache()
* @param {string} mainName The name of the main script at the mount root
* @returns {string} The mount point
*/
function mountAssets(options, mainName) {
const { VirtualFileSystem, kLoadLayer } = require('internal/vfs/file_system');
const isArchive = options.vfsArchive !== undefined;

let provider;
if (isArchive) {
const { ZipBuffer } = require('internal/zip');
const { ZipProvider } = require('internal/vfs/providers/ziparchive');
const zip = new ZipBuffer(fs.readFileSync(options.vfsArchive));
zip.addSync(mainName, Buffer.from(options.mainSource),
{ __proto__: null, method: 'store' });
provider = new ZipProvider(zip);
} else {
const { MemoryProvider } = require('internal/vfs/providers/memory');
provider = new MemoryProvider();
}

const vfs = new VirtualFileSystem(provider, {
__proto__: null,
emitExperimentalWarning: false,
[kLoadLayer]: isArchive,
});

if (!isArchive) {
// Every asset is a file at its key, as the SEAProvider serves them.
const { assetKeys, assetPaths } = options;
for (let i = 0; i < assetKeys.length; i++) {
const target = path.posix.normalize(
'/' + StringPrototypeReplaceAll(assetKeys[i], '\\', '/'));
const dir = path.posix.dirname(target);
if (dir !== '/') {
vfs.mkdirSync(dir, { __proto__: null, recursive: true });
}
vfs.writeFileSync(target, fs.readFileSync(assetPaths[i]));
}
vfs.writeFileSync('/' + mainName, options.mainSource);
provider.setReadOnly();
}

return vfs.mount();
}

/**
* Compiles a module without running it, the way the loaders compile it when
* it is loaded from the mount, so that its compile cache entry is created.
* @param {string} filename The path of the module inside the mount
* @returns {boolean} Whether the file is a module the loaders compile
*/
function compileModule(filename) {
const { Module, wrapSafe } = require('internal/modules/cjs/loader');
const {
defaultGetFormatWithoutErrors,
} = require('internal/modules/esm/get_format');
const {
compileSourceTextModule,
SourceTextModuleTypes: { kUser },
} = require('internal/modules/esm/utils');

const url = pathToFileURL(filename);
const buffer = fs.readFileSync(filename);
let format = defaultGetFormatWithoutErrors(url, {
__proto__: null,
source: buffer,
});

let source;
switch (format) {
case 'module':
// The ESM loader decodes the source it loads.
source = stringify(buffer);
break;
case 'commonjs':
case 'commonjs-typescript':
case 'module-typescript':
case 'typescript':
// The CommonJS loader reads the source as a UTF-8 string.
source = buffer.toString('utf8');
break;
default:
return false;
}

if (StringPrototypeEndsWith(format, 'typescript')) {
const { stripTypeScriptModuleTypes } = require('internal/modules/typescript');
source = stripTypeScriptModuleTypes(source, filename, url.href);
switch (format) {
case 'commonjs-typescript':
format = 'commonjs';
break;
case 'module-typescript':
format = 'module';
break;
default:
// Detected from the type-stripped source by wrapSafe(), like
// Module.prototype._compile() does.
format = undefined;
break;
}
}

if (format !== 'module') {
const result = wrapSafe(filename, source, new Module(filename, null), format);
if (!result.canParseAsESM) {
return true;
}
}
compileSourceTextModule(url.href, source, kUser);
return true;
}

/**
* Compiles every module under a directory of the mount.
* @param {string} dir The directory
* @param {boolean} stripTypes Whether TypeScript files are modules too
* @returns {number} The number of modules compiled
*/
function compileTree(dir, stripTypes) {
let compiled = 0;
const dirents = fs.readdirSync(dir, { __proto__: null, withFileTypes: true });
for (let i = 0; i < dirents.length; i++) {
const dirent = dirents[i];
const filename = path.join(dir, dirent.name);
if (dirent.isDirectory()) {
compiled += compileTree(filename, stripTypes);
continue;
}
if (!dirent.isFile()) continue;
const ext = path.extname(filename);
if (!ArrayPrototypeIncludes(kScriptExtensions, ext) &&
!(stripTypes && ArrayPrototypeIncludes(kTypeScriptExtensions, ext))) {
continue;
}
// A module that does not compile is left out of the cache: it fails the
// same way at run time if the application ever loads it.
try {
if (compileModule(filename)) compiled++;
} catch (err) {
debug('not caching %s: %s', filename, err?.message);
}
}
return compiled;
}

/**
* Compiles the modules of the SEA virtual file system into the compile cache
* of this Node.js instance, from which --build-sea collects them. The cache
* is keyed relative to the mount point, which is the same in the executable.
* @param {object} options
* @param {string} options.main The "main" of the SEA configuration
* @param {string} options.mainSource The source of the main script
* @param {string} [options.vfsArchive] The "vfsArchive" of the configuration
* @param {string[]} [options.assetKeys] The keys of the "assets"
* @param {string[]} [options.assetPaths] The paths of the "assets"
* @returns {number} The number of modules compiled
*/
function buildSeaCompileCache(options) {
const { getSeaMainName } = require('internal/vfs/sea');
const mountPoint = mountAssets(options, getSeaMainName(options.main));

// Enabling the compile cache takes a directory, but nothing is persisted
// to it: the entries are collected from memory by --build-sea.
const cacheDir = fs.mkdtempSync(
path.join(require('os').tmpdir(), 'node-sea-compile-cache-'));
try {
const { 0: status, 1: message } = enableNativeCompileCache(
cacheDir, true /* portable */, false /* readOnly */, mountPoint);
if (status !== compileCacheStatus.ENABLED) {
throw new ERR_INVALID_STATE(
`cannot enable the compile cache for ${mountPoint}: ${message}`);
}
const compiled = compileTree(mountPoint, getOptionValue('--strip-types'));
debug('compiled %d modules in %s for the compile cache',
compiled, mountPoint);
return compiled;
} finally {
fs.rmSync(cacheDir, { __proto__: null, recursive: true, force: true });
}
}

module.exports = {
buildSeaCompileCache,
};
Loading