Skip to content

Commit bd9fc12

Browse files
committed
vfs: add --vfs-mount and --vfs-load startup flags
Mounting a virtual file system requires calling vfs.mount() from inside the program, so a program cannot itself be served from one: something already running has to mount the VFS first. Add two startup flags. --vfs-mount=<source> mounts a directory or an archive as a virtual file system, and may be repeated. --vfs-load=<source> mounts that source the same way and additionally runs the entry point and all subsequent require()/import resolution against it rather than against the real file system, so an application can be run straight out of a directory or a ZIP archive: node --experimental-vfs --vfs-load=my-app.zip Both options append to one list, so mounts happen in the order written: node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c mounts a, b and c in that order and runs b. Mounting the same source twice mounts it twice, at two mount points, and the entry point comes from the mount --vfs-load contributed rather than the earlier one. The provider backing a source is chosen from the source itself rather than from its name: a directory is served by RealFSProvider and a file whose bytes are a ZIP archive by ZipProvider, so an archive can carry any extension. vfs.registerProvider() registers a provider for formats there is no built-in for; selection is deferred until -r and --import preloads have run, so a preloaded module can register one before its source is claimed. The entry point comes from the mount the way `node <directory>` takes one, from package.json "main" or index.js. Nothing is consumed as an entry point argument, so every positional reaches the program unchanged from argv[2] on, and argv[1] reports the named source rather than the reserved mount point, which is an opaque implementation detail. --vfs-load may be given at most once, and is not permitted in NODE_OPTIONS: which entry point runs is the command line's decision, and NODE_OPTIONS is parsed first, so an environment variable could otherwise redirect any invocation. --vfs-mount is permitted there. Workers inherit the same mounts in the same order but not the loading: a worker mounts what the parent mounted and runs its own entry point. Signed-off-by: Philipp Dunkel <pip@pipobscure.com>
1 parent 6071f8b commit bd9fc12

15 files changed

Lines changed: 899 additions & 2 deletions

File tree

doc/api/cli.md

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3633,6 +3633,78 @@ added: v0.1.3
36333633

36343634
Print node's version.
36353635

3636+
### `--vfs-load=source`
3637+
3638+
<!-- YAML
3639+
added: REPLACEME
3640+
-->
3641+
3642+
* `source` {string} A directory or an archive file to mount and run.
3643+
3644+
Requires [`--experimental-vfs`][]. May be given at most once.
3645+
3646+
Mounts `source` exactly as [`--vfs-mount`][] does, and additionally runs the
3647+
entry point and all subsequent `require()`/`import` resolution against that
3648+
mount rather than the real file system. The entry point is taken from the mount
3649+
the same way `node <directory>` takes one: the mount's own `package.json`
3650+
`"main"`, or `index.js`. Any positional command-line argument is the program's
3651+
own (available from `process.argv[2]` onward), never an entry-point override.
3652+
3653+
`process.argv[1]` reports `source` rather than the reserved mount point, since
3654+
the mount point is an opaque implementation detail.
3655+
3656+
Mounting the same source twice mounts it twice, at two separate mount points.
3657+
The entry point then comes from the mount `--vfs-load` itself contributed, not
3658+
from an earlier `--vfs-mount` of the same source.
3659+
3660+
In worker threads `--vfs-load` mounts but does not load: a worker inherits the
3661+
same mounts, in the same order, and runs its own entry point.
3662+
3663+
`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is
3664+
the command line's decision, and the environment must not be able to redirect
3665+
it.
3666+
3667+
```console
3668+
$ node --experimental-vfs --vfs-load=app.zip
3669+
$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
3670+
```
3671+
3672+
### `--vfs-mount=source`
3673+
3674+
<!-- YAML
3675+
added: REPLACEME
3676+
-->
3677+
3678+
* `source` {string} A directory or an archive file to mount.
3679+
3680+
Requires [`--experimental-vfs`][]. May be repeated to mount several sources.
3681+
3682+
Mounts `source` as a virtual file system ([`node:vfs`][]). Each mount is placed
3683+
at a reserved mount point assigned by Node.js, so mounts never shadow real
3684+
paths and no target can be chosen. Mounting alone does not change the entry
3685+
point; use [`--vfs-load`][] for the source to run from.
3686+
3687+
`--vfs-mount` and [`--vfs-load`][] mount in the order they are written, so
3688+
3689+
```console
3690+
$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
3691+
```
3692+
3693+
mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by
3694+
[`NODE_OPTIONS`][] are mounted before the command line's.
3695+
3696+
The provider backing a source is chosen from the source itself rather than from
3697+
its file name:
3698+
3699+
* A directory is mounted with a [`RealFSProvider`][] rooted there.
3700+
* A file whose bytes are a ZIP archive is mounted with a [`ZipProvider`][], so
3701+
an archive can carry any name.
3702+
3703+
Providers registered with `vfs.registerProvider()` (typically from a module
3704+
preloaded with [`--require`][] or [`--import`][]) are consulted first, in
3705+
reverse registration order, and may claim directories as well as files. If no
3706+
provider claims the source, Node.js exits with an error.
3707+
36363708
### `--watch`
36373709

36383710
<!-- YAML
@@ -4066,6 +4138,7 @@ one is included in the list below.
40664138
* `--use-openssl-ca`
40674139
* `--use-system-ca`
40684140
* `--v8-pool-size`
4141+
* `--vfs-mount`
40694142
* `--watch-kill-signal`
40704143
* `--watch-path`
40714144
* `--watch-preserve-output`
@@ -4579,6 +4652,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
45794652
[`--env-file-if-exists`]: #--env-file-if-existsfile
45804653
[`--env-file`]: #--env-filefile
45814654
[`--experimental-sea-config`]: single-executable-applications.md#1-generating-single-executable-preparation-blobs
4655+
[`--experimental-vfs`]: #--experimental-vfs
45824656
[`--heap-prof-dir`]: #--heap-prof-dir
45834657
[`--import`]: #--importmodule
45844658
[`--no-require-module`]: #--no-require-module
@@ -4590,6 +4664,8 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
45904664
[`--require`]: #-r---require-module
45914665
[`--use-env-proxy`]: #--use-env-proxy
45924666
[`--use-system-ca`]: #--use-system-ca
4667+
[`--vfs-load`]: #--vfs-loadsource
4668+
[`--vfs-mount`]: #--vfs-mountsource
45934669
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
45944670
[`Buffer`]: buffer.md#class-buffer
45954671
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
@@ -4598,8 +4674,10 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
45984674
[`NODE_OPTIONS`]: #node_optionsoptions
45994675
[`NODE_USE_ENV_PROXY=1`]: #node_use_env_proxy1
46004676
[`NO_COLOR`]: https://no-color.org
4677+
[`RealFSProvider`]: vfs.md#class-realfsprovider
46014678
[`Web Storage`]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
46024679
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
4680+
[`ZipProvider`]: vfs.md#class-zipprovider
46034681
[`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey
46044682
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
46054683
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder

doc/api/errors.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3530,6 +3530,13 @@ An attempt was made to use something that was already closed.
35303530
While using the Performance Timing API (`perf_hooks`), no valid performance
35313531
entry types are found.
35323532

3533+
<a id="ERR_VFS_INVALID_TARGET"></a>
3534+
3535+
### `ERR_VFS_INVALID_TARGET`
3536+
3537+
A `--vfs-mount` source does not exist, is neither a regular file nor a
3538+
directory, or is a source no provider claims.
3539+
35333540
<a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>
35343541

35353542
### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`

doc/api/vfs.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,65 @@ const memoryVfs = vfs.create();
9393
const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/vfs-root'));
9494
```
9595

96+
## `vfs.registerProvider(entry)`
97+
98+
<!-- YAML
99+
added: REPLACEME
100+
-->
101+
102+
* `entry` {Object}
103+
* `name` {string} A short identifier, used in diagnostics.
104+
* `canHandle` {Function} Called with the resolved path and its
105+
[`fs.Stats`][]. Returns `true` if this provider should back the source.
106+
* `create` {Function} Called with the resolved path and its [`fs.Stats`][].
107+
Returns the {VirtualProvider} backing the source.
108+
109+
Registers a provider that [`--vfs-mount`][] can select for a source it
110+
recognizes, so a file format Node.js has no built-in provider for can still be
111+
mounted.
112+
113+
A source is claimed by the first provider whose `canHandle()` returns `true`.
114+
Registered providers are consulted before the built-in ones, newest
115+
registration first, and are offered directories as well as files, so a
116+
registered provider can back, wrap, or vet any source. If none claims the
117+
source, the built-in providers handle it: a directory with
118+
[`RealFSProvider`][], and a file whose bytes are a ZIP archive with
119+
[`ZipProvider`][].
120+
121+
Providers must be registered before the mounts are created. Register from a
122+
module preloaded with [`--require`][] or [`--import`][]:
123+
124+
```cjs
125+
// provider.js, preloaded with --require
126+
const fs = require('node:fs');
127+
const vfs = require('node:vfs');
128+
129+
const MAGIC = Buffer.from('CUSTOMFMT');
130+
131+
vfs.registerProvider({
132+
name: 'customfmt',
133+
canHandle(path, stats) {
134+
if (!stats.isFile()) return false;
135+
const head = Buffer.alloc(MAGIC.length);
136+
const fd = fs.openSync(path, 'r');
137+
try {
138+
fs.readSync(fd, head, 0, MAGIC.length, 0);
139+
} finally {
140+
fs.closeSync(fd);
141+
}
142+
return head.equals(MAGIC);
143+
},
144+
create(path) {
145+
return new MyCustomProvider(path);
146+
},
147+
});
148+
```
149+
150+
```console
151+
$ node --experimental-vfs --require ./provider.js \
152+
--vfs-load archive.customfmt
153+
```
154+
96155
## Class: `VirtualFileSystem`
97156

98157
<!-- YAML
@@ -591,6 +650,9 @@ fields use synthetic but stable values:
591650
[CommonJS resolution algorithm]: modules.md#all-together
592651
[ES modules resolution algorithm]: esm.md#resolution-algorithm
593652
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
653+
[`--import`]: cli.md#--importmodule
654+
[`--require`]: cli.md#-r---require-module
655+
[`--vfs-mount`]: cli.md#--vfs-mountsource
594656
[`MemoryProvider`]: #class-memoryprovider
595657
[`RealFSProvider`]: #class-realfsprovider
596658
[`VirtualFileSystem`]: #class-virtualfilesystem

doc/node.1

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1811,6 +1811,63 @@ amount of CPUs, but it may diverge in environments such as VMs or containers.
18111811
.It Fl v , Fl -version
18121812
Print node's version.
18131813
.
1814+
.It Fl -vfs-load Ns = Ns Ar source
1815+
.Bl -bullet
1816+
.It
1817+
\fBsource\fR \fB<string>\fR A directory or an archive file to mount and run.
1818+
.El
1819+
Requires \fB--experimental-vfs\fR. May be given at most once.
1820+
Mounts \fBsource\fR exactly as \fB--vfs-mount\fR does, and additionally runs the
1821+
entry point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that
1822+
mount rather than the real file system. The entry point is taken from the mount
1823+
the same way \fBnode <directory>\fR takes one: the mount's own \fBpackage.json\fR
1824+
\fB"main"\fR, or \fBindex.js\fR. Any positional command-line argument is the program's
1825+
own (available from \fBprocess.argv[2]\fR onward), never an entry-point override.
1826+
\fBprocess.argv[1]\fR reports \fBsource\fR rather than the reserved mount point, since
1827+
the mount point is an opaque implementation detail.
1828+
Mounting the same source twice mounts it twice, at two separate mount points.
1829+
The entry point then comes from the mount \fB--vfs-load\fR itself contributed, not
1830+
from an earlier \fB--vfs-mount\fR of the same source.
1831+
In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the
1832+
same mounts, in the same order, and runs its own entry point.
1833+
\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is
1834+
the command line's decision, and the environment must not be able to redirect
1835+
it.
1836+
.Bd -literal
1837+
$ node --experimental-vfs --vfs-load=app.zip
1838+
$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip
1839+
.Ed
1840+
.
1841+
.It Fl -vfs-mount Ns = Ns Ar source
1842+
.Bl -bullet
1843+
.It
1844+
\fBsource\fR \fB<string>\fR A directory or an archive file to mount.
1845+
.El
1846+
Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.
1847+
Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed
1848+
at a reserved mount point assigned by Node.js, so mounts never shadow real
1849+
paths and no target can be chosen. Mounting alone does not change the entry
1850+
point; use \fB--vfs-load\fR for the source to run from.
1851+
\fB--vfs-mount\fR and \fB--vfs-load\fR mount in the order they are written, so
1852+
.Bd -literal
1853+
$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c
1854+
.Ed
1855+
mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by
1856+
\fBNODE_OPTIONS\fR are mounted before the command line's.
1857+
The provider backing a source is chosen from the source itself rather than from
1858+
its file name:
1859+
.Bl -bullet
1860+
.It
1861+
A directory is mounted with a \fBRealFSProvider\fR rooted there.
1862+
.It
1863+
A file whose bytes are a ZIP archive is mounted with a \fBZipProvider\fR, so
1864+
an archive can carry any name.
1865+
.El
1866+
Providers registered with \fBvfs.registerProvider()\fR (typically from a module
1867+
preloaded with \fB--require\fR or \fB--import\fR) are consulted first, in
1868+
reverse registration order, and may claim directories as well as files. If no
1869+
provider claims the source, Node.js exits with an error.
1870+
.
18141871
.It Fl -watch
18151872
Starts Node.js in watch mode.
18161873
When in watch mode, changes in the watched files cause the Node.js process to
@@ -2284,6 +2341,8 @@ one is included in the list below.
22842341
.It
22852342
\fB--v8-pool-size\fR
22862343
.It
2344+
\fB--vfs-mount\fR
2345+
.It
22872346
\fB--watch-kill-signal\fR
22882347
.It
22892348
\fB--watch-path\fR

lib/internal/errors.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1957,6 +1957,8 @@ E('ERR_USE_AFTER_CLOSE', '%s was closed', Error);
19571957
// This should probably be a `TypeError`.
19581958
E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
19591959
'At least one valid performance entry type is required', Error);
1960+
E('ERR_VFS_INVALID_TARGET',
1961+
'%s is not a valid --vfs-mount source: must be an existing file or directory', Error);
19601962
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
19611963
'A dynamic import callback was not specified.', TypeError);
19621964
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',

lib/internal/main/worker_thread.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const {
1616
const {
1717
prepareWorkerThreadExecution,
1818
initializeModuleLoaders,
19+
finishVfsMounts,
1920
markBootstrapComplete,
2021
} = require('internal/process/pre_execution');
2122

@@ -144,6 +145,13 @@ port.on('message', (message) => {
144145
// initializeAsyncLoaderHooksOnLoaderHookWorker() which needs to run preloads
145146
// after the asynchronous loader hooks are registered.
146147
initializeModuleLoaders({ shouldSpawnLoaderHookWorker: true, shouldPreloadModules: true });
148+
// Re-mount inherited --vfs-mount sources so their reserved paths (which a
149+
// worker filename may point into) resolve in this thread too. With
150+
// --import, mounting is deferred to after that loop in run_main, matching
151+
// the main thread; finishVfsMounts() is idempotent so it runs once.
152+
if (getOptionValue('--import').length === 0) {
153+
finishVfsMounts();
154+
}
147155
}
148156

149157
if (!hasStdin)

lib/internal/modules/run_main.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ async function asyncRunEntryPointWithESMLoader(callback) {
9898
} else {
9999
cascadedLoader.waitForAsyncLoaderHookInitialization();
100100
}
101+
// Any --import above may have registered a VFS provider, so mount now (no-op
102+
// if prepareExecution already mounted in the no-import case).
103+
require('internal/process/pre_execution').finishVfsMounts();
101104
await callback(cascadedLoader);
102105
} catch (err) {
103106
if (hasUncaughtExceptionCaptureCallback()) {
@@ -138,6 +141,19 @@ function runEntryPointWithESMLoader(callback) {
138141
* @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js`
139142
*/
140143
function executeUserEntryPoint(main = process.argv[1]) {
144+
if (getOptionValue('[vfs_load_set]')) {
145+
// The entry is a directory mount whose reserved root only exists after
146+
// finishVfsMounts() runs (inside runEntryPointWithESMLoader, once --import
147+
// preloads have had a chance to registerProvider()). Load it through the CJS
148+
// main loader so the reserved directory resolves to its index the same way
149+
// any require() of a directory does, sidestepping ESM directory-import.
150+
runEntryPointWithESMLoader(() => {
151+
const { getVfsLoadRoot } = require('internal/process/pre_execution');
152+
const { wrapModuleLoad } = require('internal/modules/cjs/loader');
153+
return wrapModuleLoad(getVfsLoadRoot(), null, true);
154+
});
155+
return;
156+
}
141157
let useESMLoader;
142158
let resolvedMain;
143159
if (getOptionValue('--entry-url')) {

0 commit comments

Comments
 (0)