Skip to content

Commit 4991e64

Browse files
committed
sea: mount bundled assets as a virtual file system
Add a "useVfs" boolean to the SEA configuration. When enabled, the bundled assets are mounted as a read-only virtual file system before the main script runs, and the main script is placed at the mount point root and executed from there via wrapModuleLoad. __filename, __dirname, relative require() calls, and node_modules lookups then all resolve against the bundled assets, confined to the mount. Since a VFS never shadows the real file system, bundled code reaches the assets through __dirname-relative paths instead of a fixed mount location. The new SEAProvider derives the directory tree from the asset keys and keeps asset content in the executable's SEA blob, copying it into JS memory only when a file is opened. The main script is not duplicated into the assets at build time; its source already lives in the blob and is injected into the provider at runtime. The implicit SEA mount does not emit the VirtualFileSystem experimental warning, which is already covered by the SEA warning. "useVfs" is rejected together with "useSnapshot", "useCodeCache", and "mainFormat": "module"; ESM entry points are left as future work. Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent cb20bb3 commit 4991e64

19 files changed

Lines changed: 915 additions & 2 deletions

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

Lines changed: 85 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,86 @@ 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 - Experimental
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 limitations
239+
240+
The `useVfs` option does not currently support ESM entry points. Using
241+
`"useVfs": true` together with `"mainFormat": "module"` is not supported.
242+
The main script must use CommonJS (`require()`) when VFS is enabled.
243+
244+
#### Snapshot and code caching limitations
245+
246+
`"useVfs": true` cannot be used together with `"useSnapshot": true` or
247+
`"useCodeCache": true`. The code cache limitation is due to incomplete
248+
implementation, not a technical impossibility. Consider bundling the
249+
application if startup performance matters and do not rely on module loading
250+
from the VFS in that case.
251+
252+
#### Native addon limitations
253+
254+
Native addons (`.node` files) cannot be loaded directly from the VFS because
255+
`process.dlopen()` requires files on the real file system. To use native
256+
addons in a SEA with VFS, write the asset to a temporary file first. See
257+
[Using native addons in the injected main script][] for an example.
258+
178259
### Startup snapshot support
179260

180261
The `useSnapshot` field can be used to enable startup snapshot support. In this
@@ -648,6 +729,8 @@ to help us document them.
648729
[Generating single executable preparation blobs]: #1-generating-single-executable-preparation-blobs
649730
[Mach-O]: https://en.wikipedia.org/wiki/Mach-O
650731
[PE]: https://en.wikipedia.org/wiki/Portable_Executable
732+
[Using native addons in the injected main script]: #using-native-addons-in-the-injected-main-script
733+
[VFS documentation]: vfs.md
651734
[Windows SDK]: https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/
652735
[`process.execPath`]: process.md#processexecpath
653736
[`require()`]: modules.md#requireid
@@ -660,8 +743,10 @@ to help us document them.
660743
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
661744
[documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot
662745
[fuse]: https://www.electronjs.org/docs/latest/tutorial/fuses
746+
[module loader integration]: vfs.md#module-loader-integration
663747
[postject]: https://github.com/nodejs/postject
664748
[postject-linux-arm64-issue]: https://github.com/nodejs/postject/issues/105
665749
[signtool]: https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
666750
[single executable applications]: https://github.com/nodejs/single-executable
667751
[supported by Node.js]: https://github.com/nodejs/node/blob/main/BUILDING.md#platform-list
752+
[virtual file system]: vfs.md

‎doc/api/vfs.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,34 @@ 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+
`"useVfs"` cannot be used together with `"useSnapshot"`, `"useCodeCache"`, or
442+
`"mainFormat": "module"`. The SEA configuration parser will error if any of
443+
these combinations are detected.
444+
445+
See the [Single Executable Application][] documentation for more information
446+
on creating SEA builds with assets.
447+
420448
## Class: `VirtualProvider`
421449

422450
<!-- YAML
@@ -540,6 +568,7 @@ fields use synthetic but stable values:
540568
[CommonJS resolution algorithm]: modules.md#all-together
541569
[ES modules resolution algorithm]: esm.md#resolution-algorithm
542570
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
571+
[Single Executable Application]: single-executable-applications.md
543572
[`MemoryProvider`]: #class-memoryprovider
544573
[`RealFSProvider`]: #class-realfsprovider
545574
[`VirtualFileSystem`]: #class-virtualfilesystem

‎lib/internal/main/embedding.js‎

Lines changed: 39 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,42 @@ 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+
format === moduleFormats.kCommonJS) {
154+
// Run the main script from inside the SEA VFS mount so that
155+
// `__filename`, `__dirname`, relative `require()` calls, and
156+
// `node_modules` lookups all resolve against the bundled assets.
157+
const vfsMain = setUpSeaVfs(content);
158+
if (vfsMain !== null) {
159+
return wrapModuleLoad(vfsMain, null, true);
160+
}
161+
}
162+
/* c8 ignore stop */
163+
127164
if (format === moduleFormats.kCommonJS) {
128165
return embedderRunCjs(content, filename);
129166
} else if (format === moduleFormats.kModule) {

0 commit comments

Comments
 (0)