Skip to content

Commit 70c5a3f

Browse files
committed
sea: mount bundled assets as a virtual file system
Support "useVfs": true in the SEA configuration: mount the bundled assets as a read-only VFS and run the CommonJS or ESM main script from inside the mount. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent cb20bb3 commit 70c5a3f

28 files changed

Lines changed: 1059 additions & 2 deletions

doc/api/single-executable-applications.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ The configuration currently reads the following top-level fields:
116116
"disableExperimentalSEAWarning": true, // Default: false
117117
"useSnapshot": false, // Default: false
118118
"useCodeCache": true, // Default: false
119+
"useVfs": true, // Default: false
119120
"execArgv": ["--no-warnings", "--max-old-space-size=4096"], // Optional
120121
"execArgvExtension": "env", // Default: "env", options: "none", "env", "cli"
121122
"assets": { // Optional
@@ -175,6 +176,105 @@ const raw = getRawAsset('a.jpg');
175176
See documentation of the [`sea.getAsset()`][], [`sea.getAssetAsBlob()`][],
176177
[`sea.getRawAsset()`][] and [`sea.getAssetKeys()`][] APIs for more information.
177178

179+
### Virtual file system (VFS) for assets
180+
181+
<!-- YAML
182+
added: REPLACEME
183+
-->
184+
185+
> Stability: 1.0 - Early development
186+
187+
In addition to using the `node:sea` API to access individual assets, the
188+
bundled assets can be exposed as a read-only [virtual file system][] and
189+
accessed through standard `node:fs` APIs. To enable this, set
190+
`"useVfs": true` in the SEA configuration.
191+
192+
A virtual file system never shadows the real file system: it is mounted at a
193+
reserved mount point that cannot exist on the real file system, and the mount
194+
point is chosen at runtime rather than being a fixed path. When `useVfs` is
195+
enabled, the injected main script itself is placed at the root of the mount
196+
and executed from there, so `__filename` and `__dirname` point inside the
197+
virtual file system instead of reflecting [`process.execPath`][]. Bundled
198+
code therefore reaches the assets through `__dirname`-relative paths and
199+
relative [`require()`][] calls, without having to know the mount point:
200+
201+
```cjs
202+
const fs = require('node:fs');
203+
const path = require('node:path');
204+
205+
// __dirname is the root of the virtual file system holding the assets.
206+
const rawConfig = fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8');
207+
const data = fs.readFileSync(path.join(__dirname, 'data/file.txt'));
208+
209+
// Directory operations work too.
210+
const files = fs.readdirSync(path.join(__dirname, 'assets'));
211+
212+
// Check if a bundled file exists.
213+
if (fs.existsSync(path.join(__dirname, 'optional.json'))) {
214+
// ...
215+
}
216+
```
217+
218+
The VFS supports the `node:fs` operations for reading files and directories.
219+
Since the SEA VFS is read-only, write operations fail with `EROFS`. See the
220+
[VFS documentation][] for the full list of supported operations.
221+
222+
#### Loading modules from the VFS in a SEA
223+
224+
When `useVfs` is enabled, the main script is executed from inside the
225+
virtual file system, and `require()` uses the [module loader
226+
integration][] of the VFS to load modules from the bundled assets. This
227+
supports relative requires (e.g. `require('./helper.js')`) as well as
228+
`node_modules` package lookups, which are confined to the mount:
229+
230+
```cjs
231+
// Require bundled modules using relative paths.
232+
const myModule = require('./lib/mymodule.js');
233+
234+
// Packages bundled under the node_modules asset prefix also resolve.
235+
const dep = require('some-package');
236+
```
237+
238+
#### ESM entry points
239+
240+
`"useVfs": true` also supports `"mainFormat": "module"`. The ESM main
241+
script is loaded from inside the mount through the ESM loader, so
242+
`import.meta.url`, `import.meta.filename`, and `import.meta.dirname`
243+
reflect the location of the main script in the virtual file system, and
244+
static and dynamic imports resolve against the bundled assets:
245+
246+
```mjs
247+
import fs from 'node:fs';
248+
import path from 'node:path';
249+
250+
// import.meta.dirname is the root of the virtual file system.
251+
const data = fs.readFileSync(
252+
path.join(import.meta.dirname, 'data/file.txt'));
253+
254+
// Relative and bare specifier imports resolve inside the mount.
255+
import myModule from './lib/mymodule.mjs';
256+
const lazy = await import('./lib/lazy.mjs');
257+
```
258+
259+
Module format detection works the same way as on the real file
260+
system: name bundled ES modules with the `.mjs` extension (or provide the
261+
relevant `package.json` files as assets) so they are interpreted as ESM.
262+
263+
#### Snapshot and code caching limitations
264+
265+
`"useVfs": true` cannot be used together with `"useSnapshot": true` or
266+
`"useCodeCache": true`. The code cache limitation is due to incomplete
267+
implementation, not a technical impossibility. Consider bundling the
268+
application if startup performance matters and do not rely on module loading
269+
from the VFS in that case.
270+
271+
#### Native addon limitations
272+
273+
Native addons (`.node` files) cannot be loaded directly from the VFS because
274+
`process.dlopen()` requires files on the real file system. To use native
275+
addons in a SEA with VFS, write the asset to a temporary file first. See
276+
[Using native addons in the injected main script][] for an example.
277+
178278
### Startup snapshot support
179279
180280
The `useSnapshot` field can be used to enable startup snapshot support. In this
@@ -648,6 +748,8 @@ to help us document them.
648748
[Generating single executable preparation blobs]: #1-generating-single-executable-preparation-blobs
649749
[Mach-O]: https://en.wikipedia.org/wiki/Mach-O
650750
[PE]: https://en.wikipedia.org/wiki/Portable_Executable
751+
[Using native addons in the injected main script]: #using-native-addons-in-the-injected-main-script
752+
[VFS documentation]: vfs.md
651753
[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
652754
[`process.execPath`]: process.md#processexecpath
653755
[`require()`]: modules.md#requireid
@@ -660,8 +762,10 @@ to help us document them.
660762
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
661763
[documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot
662764
[fuse]: https://www.electronjs.org/docs/latest/tutorial/fuses
765+
[module loader integration]: vfs.md#module-loader-integration
663766
[postject]: https://github.com/nodejs/postject
664767
[postject-linux-arm64-issue]: https://github.com/nodejs/postject/issues/105
665768
[signtool]: https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
666769
[single executable applications]: https://github.com/nodejs/single-executable
667770
[supported by Node.js]: https://github.com/nodejs/node/blob/main/BUILDING.md#platform-list
771+
[virtual file system]: vfs.md

doc/api/vfs.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,37 @@ system, the callers are responsible for avoiding removal or
417417
invalidation of modules in the virtual file system while they are
418418
being loaded.
419419

420+
## Use with Single Executable Applications
421+
422+
When running as a [Single Executable Application][] built with
423+
`"useVfs": true` in the SEA configuration, the bundled assets are
424+
automatically mounted as a read-only virtual file system and the injected
425+
main script is executed from the root of the mount. No additional setup is
426+
required. Since the mount point is reserved and chosen at runtime, bundled
427+
code accesses the assets through `__dirname`-relative paths and relative
428+
`require()` calls rather than through a fixed path:
429+
430+
```cjs
431+
// In the SEA main script, __dirname is the root of the mounted assets.
432+
const fs = require('node:fs');
433+
const path = require('node:path');
434+
435+
const config = JSON.parse(
436+
fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
437+
const template = fs.readFileSync(
438+
path.join(__dirname, 'templates/index.html'), 'utf8');
439+
```
440+
441+
ESM entry points (`"mainFormat": "module"`) are supported: the main module
442+
is loaded from inside the mount through the ESM loader, and
443+
`import.meta.dirname` points at the mount root.
444+
445+
`"useVfs"` cannot be used together with `"useSnapshot"` or `"useCodeCache"`.
446+
The SEA configuration parser will error if either combination is detected.
447+
448+
See the [Single Executable Application][] documentation for more information
449+
on creating SEA builds with assets.
450+
420451
## Class: `VirtualProvider`
421452

422453
<!-- YAML
@@ -540,6 +571,7 @@ fields use synthetic but stable values:
540571
[CommonJS resolution algorithm]: modules.md#all-together
541572
[ES modules resolution algorithm]: esm.md#resolution-algorithm
542573
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
574+
[Single Executable Application]: single-executable-applications.md
543575
[`MemoryProvider`]: #class-memoryprovider
544576
[`RealFSProvider`]: #class-realfsprovider
545577
[`VirtualFileSystem`]: #class-virtualfilesystem

lib/internal/main/embedding.js

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,15 @@
1212
const {
1313
prepareMainThreadExecution,
1414
} = require('internal/process/pre_execution');
15-
const { isExperimentalSeaWarningNeeded, isSea } = internalBinding('sea');
15+
const {
16+
isExperimentalSeaWarningNeeded,
17+
isSea,
18+
isVfsEnabled,
19+
mainCodePath: seaMainCodePath,
20+
} = internalBinding('sea');
1621
const { emitExperimentalWarning } = require('internal/util');
1722
const { emitWarningSync } = require('internal/process/warning');
18-
const { Module } = require('internal/modules/cjs/loader');
23+
const { Module, wrapModuleLoad } = require('internal/modules/cjs/loader');
1924
const { compileFunctionForCJSLoader } = internalBinding('contextify');
2025
const { maybeCacheSourceMap } = require('internal/source_map/source_map_cache');
2126
const { pathToFileURL } = require('internal/url');
@@ -120,10 +125,54 @@ function embedderRunESM(content, filename) {
120125
return wrap.getNamespace();
121126
}
122127

128+
/* c8 ignore start -- only reachable in an actual SEA binary */
129+
/**
130+
* Mounts the SEA virtual file system with the main script placed at the
131+
* mount point root, and returns the path of the main script inside the
132+
* mount, or null when the VFS could not be set up.
133+
* @param {string} content The source of the SEA main script
134+
* @returns {string|null} The VFS path of the main script
135+
*/
136+
function setUpSeaVfs(content) {
137+
const mainName = path.basename(seaMainCodePath || process.execPath);
138+
const { initSeaVfs } = require('internal/vfs/sea');
139+
const seaVfs = initSeaVfs({ extraFiles: { [mainName]: content } });
140+
if (seaVfs === null) {
141+
return null;
142+
}
143+
return path.join(seaVfs.mountPoint, mainName);
144+
}
145+
/* c8 ignore stop */
146+
123147
function embedderRunEntryPoint(content, format, filename) {
124148
format ||= moduleFormats.kCommonJS;
125149
filename ||= process.execPath;
126150

151+
/* c8 ignore start -- only reachable in an actual SEA binary */
152+
if (isLoadingSea && isVfsEnabled()) {
153+
// Run the main script from inside the SEA VFS mount so that
154+
// `__filename`, `__dirname`, `import.meta`, relative requires and
155+
// imports, and `node_modules` lookups all resolve against the
156+
// bundled assets.
157+
const vfsMain = setUpSeaVfs(content);
158+
if (vfsMain !== null) {
159+
if (format === moduleFormats.kCommonJS) {
160+
return wrapModuleLoad(vfsMain, null, true);
161+
} else if (format === moduleFormats.kModule) {
162+
const { runEntryPointWithESMLoader } =
163+
require('internal/modules/run_main');
164+
const mainURL = pathToFileURL(vfsMain);
165+
return runEntryPointWithESMLoader((cascadedLoader) => {
166+
// Note that if the graph contains unsettled TLA, this may never
167+
// resolve even after the event loop stops running.
168+
return cascadedLoader.import(
169+
mainURL, undefined, { __proto__: null }, undefined, true);
170+
});
171+
}
172+
}
173+
}
174+
/* c8 ignore stop */
175+
127176
if (format === moduleFormats.kCommonJS) {
128177
return embedderRunCjs(content, filename);
129178
} else if (format === moduleFormats.kModule) {

0 commit comments

Comments
 (0)