Skip to content
Draft
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
104 changes: 103 additions & 1 deletion doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1492,7 +1492,9 @@ added: v26.4.0

> Stability: 1 - Experimental

Enable the experimental [`node:vfs`][] module.
Enable the experimental [`node:vfs`][] module. This flag also gates the
[`--vfs`][] and [`--vfs-manifest`][] startup flags, which are only allowed
when `--experimental-vfs` is set.

### `--experimental-vm-modules`

Expand Down Expand Up @@ -3532,6 +3534,95 @@ added: v0.1.3

Print node's version.

### `--vfs-manifest=file`

<!-- YAML
added: REPLACEME
-->

* `file` {string} Where to write the manifest. Must be an explicit path -
there is no default derived from the [`--vfs`][] target, so a run can
never silently overwrite the wrong file.

Requires [`--experimental-vfs`][]. Used together with a directory [`--vfs`][]
target. The path of every file actually read through the mount during this
run - by module resolution or by the program's own [`node:fs`][] calls - is
appended, one per line, to `file` as soon as it's read.

```bash
node --vfs=./my-app --vfs-manifest=./my-app.manifest main.js
# -> ./my-app.manifest lists every file this run actually touched
```

This lets you exercise an app once against its real source directory and
come away with exactly the list of files it needed, without hand-picking
what to bundle - useful as an input to building an archive of just those
files, for example to run later via `--vfs=apparchive.zip`. Throws
[`ERR_VFS_MANIFEST_REQUIRES_DIRECTORY`][] if [`--vfs`][]'s target is a file
rather than a directory.

Entries are appended immediately as each file is read, not buffered and
flushed at the end, so the manifest reflects everything read up to
whatever point the process reaches - including a process that's killed
rather than exiting normally. Duplicate reads of the same file are only
recorded once per thread; a [`Worker`][] appends to the very same manifest
file directly, since it shares the real file system with the main thread.

### `--vfs=target`

<!-- YAML
added: REPLACEME
-->

* `target` {string} A directory or a ZIP archive to mount.

Requires [`--experimental-vfs`][].

Mounts `target` as a virtual file system ([`node:vfs`][]) and resolves the
entry point (`process.argv[1]`) and all subsequent `require()`/`import`
resolution against it, instead of against the real file system.

* If `target` is a directory, it's mounted with a [`RealFSProvider`][] rooted
there. The files are already real, so mounting doesn't change what bytes
are read - it adds path containment, rejecting resolution that would
escape above `target` via `..`.
* If `target` is a file, it's opened as a ZIP archive ([`zlib.ZipFile`][],
read-only) and mounted with a [`ZipProvider`][], turning `target`
itself into a virtual directory for module-resolution purposes (e.g.
`--vfs=/path/to/app.zip` resolves `/path/to/app.zip/index.js` inside the
archive).

`process.argv[1]` becomes the mount root itself, as if `node <target>` had
been run: the mount's own `package.json` `"main"` (or `index.js`) selects the
entry point, and any positional command-line argument is the program's own
(available from `process.argv[2]` onward), never an entry-point override. So a
ZIP archive can be made directly executable by prepending a shebang line and
marking it executable:

```console
$ (printf '#!/usr/bin/env -S node --vfs\n'; cat app.zip) > app && chmod +x app
$ ./app arg1 arg2 # runs the archive's index.js with ['arg1', 'arg2']
```

Module resolution under `--vfs` is fully sandboxed: `package.json` lookups,
`node_modules`-style resolution, and legacy `main` resolution never fall back
to the real file system once they would step outside the mounted target - a
dependency has to be inside the mounted directory or archive to be found.

This only affects module _resolution_. The running program's own [`node:fs`][]
calls to paths outside the mounted target work normally against the real file
system; only paths under `target` are redirected, the same as any other
[`node:vfs`][] mount.

Native addons (`.node` files) are supported: from a directory-backed mount
they're loaded directly from their real underlying path; from an
archive-backed mount, the addon's bytes are extracted to a content-hashed
file under the OS temporary directory before being loaded, and that file is
best-effort removed when the process exits.

A [`Worker`][] created from a process started with `--vfs` inherits the same
mount unless its own `execArgv` explicitly overrides it.

### `--watch`

<!-- YAML
Expand Down Expand Up @@ -3951,6 +4042,8 @@ one is included in the list below.
* `--use-openssl-ca`
* `--use-system-ca`
* `--v8-pool-size`
* `--vfs`
* `--vfs-manifest`
* `--watch-kill-signal`
* `--watch-path`
* `--watch-preserve-output`
Expand Down Expand Up @@ -4454,6 +4547,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--env-file-if-exists`]: #--env-file-if-existsfile
[`--env-file`]: #--env-filefile
[`--experimental-sea-config`]: single-executable-applications.md#1-generating-single-executable-preparation-blobs
[`--experimental-vfs`]: #--experimental-vfs
[`--heap-prof-dir`]: #--heap-prof-dir
[`--import`]: #--importmodule
[`--no-require-module`]: #--no-require-module
Expand All @@ -4465,23 +4559,30 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`--require`]: #-r---require-module
[`--use-env-proxy`]: #--use-env-proxy
[`--use-system-ca`]: #--use-system-ca
[`--vfs-manifest`]: #--vfs-manifestfile
[`--vfs`]: #--vfstarget
[`AsyncLocalStorage`]: async_context.md#class-asynclocalstorage
[`Buffer`]: buffer.md#class-buffer
[`CRYPTO_secure_malloc_init`]: https://www.openssl.org/docs/man3.0/man3/CRYPTO_secure_malloc_init.html
[`ERR_INVALID_TYPESCRIPT_SYNTAX`]: errors.md#err_invalid_typescript_syntax
[`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`]: errors.md#err_unsupported_typescript_syntax
[`ERR_VFS_MANIFEST_REQUIRES_DIRECTORY`]: errors.md#err_vfs_manifest_requires_directory
[`NODE_OPTIONS`]: #node_optionsoptions
[`NODE_USE_ENV_PROXY=1`]: #node_use_env_proxy1
[`NO_COLOR`]: https://no-color.org
[`RealFSProvider`]: vfs.md#class-realfsprovider
[`Web Storage`]: https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API
[`Worker`]: worker_threads.md#class-worker
[`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328
[`ZipProvider`]: vfs.md#class-zipprovider
[`dns.lookup()`]: dns.md#dnslookuphostname-options-callback
[`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorder
[`dnsPromises.lookup()`]: dns.md#dnspromiseslookuphostname-options
[`import.meta.url`]: esm.md#importmetaurl
[`import` specifier]: esm.md#import-specifiers
[`net.getDefaultAutoSelectFamilyAttemptTimeout()`]: net.md#netgetdefaultautoselectfamilyattempttimeout
[`node:ffi`]: ffi.md
[`node:fs`]: fs.md
[`node:sqlite`]: sqlite.md
[`node:stream/iter`]: stream_iter.md
[`node:vfs`]: vfs.md
Expand All @@ -4492,6 +4593,7 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12
[`v8.startupSnapshot.addDeserializeCallback()`]: v8.md#v8startupsnapshotadddeserializecallbackcallback-data
[`v8.startupSnapshot.setDeserializeMainFunction()`]: v8.md#v8startupsnapshotsetdeserializemainfunctioncallback-data
[`v8.startupSnapshot` API]: v8.md#startup-snapshot-api
[`zlib.ZipFile`]: zlib.md#class-zlibzipfile
[asynchronous module customization hooks]: module.md#asynchronous-customization-hooks
[captured by the built-in snapshot of Node.js]: https://github.com/nodejs/node/blob/b19525a33cc84033af4addd0f80acd4dc33ce0cf/test/parallel/test-bootstrap-modules.js#L24
[collecting code coverage from tests]: test.md#collecting-code-coverage
Expand Down
17 changes: 17 additions & 0 deletions doc/api/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -3490,6 +3490,21 @@ An attempt was made to use something that was already closed.
While using the Performance Timing API (`perf_hooks`), no valid performance
entry types are found.

<a id="ERR_VFS_INVALID_TARGET"></a>

### `ERR_VFS_INVALID_TARGET`

The target passed to [`--vfs`][] does not exist, or is neither a regular file
nor a directory.

<a id="ERR_VFS_MANIFEST_REQUIRES_DIRECTORY"></a>

### `ERR_VFS_MANIFEST_REQUIRES_DIRECTORY`

[`--vfs-manifest`][] was used, but [`--vfs`][]'s target is not a directory.
Recording reads into a manifest only makes sense when running against a real
directory.

<a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a>

### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`
Expand Down Expand Up @@ -4652,6 +4667,8 @@ An error occurred trying to allocate memory. This should never happen.
[`--force-fips`]: cli.md#--force-fips
[`--no-addons`]: cli.md#--no-addons
[`--unhandled-rejections`]: cli.md#--unhandled-rejectionsmode
[`--vfs-manifest`]: cli.md#--vfs-manifest
[`--vfs`]: cli.md#--vfstarget
[`BoundSocket`]: net.md#class-netboundsocket
[`Class: assert.AssertionError`]: assert.md#class-assertassertionerror
[`ERR_INCOMPATIBLE_OPTION_PAIR`]: #err_incompatible_option_pair
Expand Down
12 changes: 9 additions & 3 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ It does not isolate untrusted code from the host file system or from other
Node.js capabilities. Code that can access a [`VirtualFileSystem`][] instance,
mount it, select its provider, or pass paths to it is trusted application code.

Mounting a VFS only redirects supported [`node:fs`][] calls whose resolved paths
are under the mount point. It does not prevent code from using other paths or
other Node.js APIs to access resources available to the process.
Mounting a VFS only redirects supported [`node:fs`][] calls (and, since the
CJS/ESM module loader ultimately resolves and reads files the same way,
`require()`/`import` resolution) whose resolved paths are under the mount
point. It does not prevent code from using other paths or other Node.js APIs
to access resources available to the process.
[`RealFSProvider`][] maps VFS paths under its configured root and rejects paths
that resolve outside that root, but that check is not a security boundary.
[`ZipProvider`][] has no real file-system paths of its own to escape; its
Expand Down Expand Up @@ -167,6 +169,10 @@ signatures as their [`node:fs`][] counterparts:
`fstatSync`
* Streams: `createReadStream`, `createWriteStream`
* Watchers: `watch`, `watchFile`, `unwatchFile`
* `toProviderPath(path)`: converts an absolute mounted path to the
provider-relative POSIX path passed to [`VirtualProvider`][] methods -
useful for code that needs to reach `vfs.provider` directly, bypassing
this class's own `fs`-shaped methods.

#### Callback API

Expand Down
84 changes: 83 additions & 1 deletion doc/node.1
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,9 @@ filter value to run. See Test tags for details on declaring and
inheriting tags.
.
.It Fl -experimental-vfs
Enable the experimental \fBnode:vfs\fR module.
Enable the experimental \fBnode:vfs\fR module. This flag also gates the
\fB--vfs\fR and \fB--vfs-manifest\fR startup flags, which are only allowed
when \fB--experimental-vfs\fR is set.
.
.It Fl -experimental-vm-modules
Enable experimental ES Module support in the \fBnode:vm\fR module.
Expand Down Expand Up @@ -1760,6 +1762,82 @@ amount of CPUs, but it may diverge in environments such as VMs or containers.
.It Fl v , Fl -version
Print node's version.
.
.It Fl -vfs-manifest Ns = Ns Ar file
.Bl -bullet
.It
\fBfile\fR \fB<string>\fR Where to write the manifest. Must be an explicit path -
there is no default derived from the \fB--vfs\fR target, so a run can
never silently overwrite the wrong file.
.El
Requires \fB--experimental-vfs\fR. Used together with a directory \fB--vfs\fR
target. The path of every file actually read through the mount during this
run - by module resolution or by the program's own \fBnode:fs\fR calls - is
appended, one per line, to \fBfile\fR as soon as it's read.
.Bd -literal
node --vfs=./my-app --vfs-manifest=./my-app.manifest main.js
# -> ./my-app.manifest lists every file this run actually touched
.Ed
This lets you exercise an app once against its real source directory and
come away with exactly the list of files it needed, without hand-picking
what to bundle - useful as an input to building an archive of just those
files, for example to run later via \fB--vfs=apparchive.zip\fR. Throws
\fBERR_VFS_MANIFEST_REQUIRES_DIRECTORY\fR if \fB--vfs\fR's target is a file
rather than a directory.
Entries are appended immediately as each file is read, not buffered and
flushed at the end, so the manifest reflects everything read up to
whatever point the process reaches - including a process that's killed
rather than exiting normally. Duplicate reads of the same file are only
recorded once per thread; a \fBWorker\fR appends to the very same manifest
file directly, since it shares the real file system with the main thread.
.
.It Fl -vfs Ns = Ns Ar target
.Bl -bullet
.It
\fBtarget\fR \fB<string>\fR A directory or a ZIP archive to mount.
.El
Requires \fB--experimental-vfs\fR.
Mounts \fBtarget\fR as a virtual file system (\fBnode:vfs\fR) and resolves the
entry point (\fBprocess.argv[1]\fR) and all subsequent \fBrequire()\fR/\fBimport\fR
resolution against it, instead of against the real file system.
.Bl -bullet
.It
If \fBtarget\fR is a directory, it's mounted with a \fBRealFSProvider\fR rooted
there. The files are already real, so mounting doesn't change what bytes
are read - it adds path containment, rejecting resolution that would
escape above \fBtarget\fR via \fB..\fR.
.It
If \fBtarget\fR is a file, it's opened as a ZIP archive (\fBzlib.ZipFile\fR,
read-only) and mounted with a \fBZipProvider\fR, turning \fBtarget\fR
itself into a virtual directory for module-resolution purposes (e.g.
\fB--vfs=/path/to/app.zip\fR resolves \fB/path/to/app.zip/index.js\fR inside the
archive).
.El
\fBprocess.argv[1]\fR becomes the mount root itself, as if \fBnode <target>\fR had
been run: the mount's own \fBpackage.json\fR \fB"main"\fR (or \fBindex.js\fR) selects the
entry point, and any positional command-line argument is the program's own
(available from \fBprocess.argv[2]\fR onward), never an entry-point override. So a
ZIP archive can be made directly executable by prepending a shebang line and
marking it executable:
.Bd -literal
$ (printf '#!/usr/bin/env -S node --vfs\\n'; cat app.zip) > app && chmod +x app
$ ./app arg1 arg2 # runs the archive's index.js with ['arg1', 'arg2']
.Ed
Module resolution under \fB--vfs\fR is fully sandboxed: \fBpackage.json\fR lookups,
\fBnode_modules\fR-style resolution, and legacy \fBmain\fR resolution never fall back
to the real file system once they would step outside the mounted target - a
dependency has to be inside the mounted directory or archive to be found.
This only affects module \fIresolution\fR. The running program's own \fBnode:fs\fR
calls to paths outside the mounted target work normally against the real file
system; only paths under \fBtarget\fR are redirected, the same as any other
\fBnode:vfs\fR mount.
Native addons (\fB.node\fR files) are supported: from a directory-backed mount
they're loaded directly from their real underlying path; from an
archive-backed mount, the addon's bytes are extracted to a content-hashed
file under the OS temporary directory before being loaded, and that file is
best-effort removed when the process exits.
A \fBWorker\fR created from a process started with \fB--vfs\fR inherits the same
mount unless its own \fBexecArgv\fR explicitly overrides it.
.
.It Fl -watch
Starts Node.js in watch mode.
When in watch mode, changes in the watched files cause the Node.js process to
Expand Down Expand Up @@ -2220,6 +2298,10 @@ one is included in the list below.
.It
\fB--v8-pool-size\fR
.It
\fB--vfs\fR
.It
\fB--vfs-manifest\fR
.It
\fB--watch-kill-signal\fR
.It
\fB--watch-path\fR
Expand Down
4 changes: 4 additions & 0 deletions lib/internal/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -1954,6 +1954,10 @@ E('ERR_USE_AFTER_CLOSE', '%s was closed', Error);
// This should probably be a `TypeError`.
E('ERR_VALID_PERFORMANCE_ENTRY_TYPE',
'At least one valid performance entry type is required', Error);
E('ERR_VFS_INVALID_TARGET',
'%s is not a valid --vfs target: must be an existing file or directory', Error);
E('ERR_VFS_MANIFEST_REQUIRES_DIRECTORY',
'--vfs-manifest requires --vfs to target a directory, got %s', Error);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING',
'A dynamic import callback was not specified.', TypeError);
E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',
Expand Down
8 changes: 5 additions & 3 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ const {
const assert = require('internal/assert');
const fs = require('fs');
const path = require('path');
const internalFsBinding = internalBinding('fs');
const { internalModuleStat: vfsInternalModuleStat } = require('internal/modules/vfs_resolution');
const { safeGetenv } = internalBinding('credentials');
const {
getCjsConditions,
Expand Down Expand Up @@ -278,7 +278,7 @@ function stat(filename) {
const result = statCache.get(filename);
if (result !== undefined) { return result; }
}
const result = internalFsBinding.internalModuleStat(filename);
const result = vfsInternalModuleStat(filename);
if (statCache !== null && result >= 0) {
// Only set cache when `internalModuleStat(filename)` succeeds.
statCache.set(filename, result);
Expand Down Expand Up @@ -2101,7 +2101,9 @@ Module._extensions['.json'] = function(module, filename) {
*/
Module._extensions['.node'] = function(module, filename) {
// Be aware this doesn't use `content`
return process.dlopen(module, path.toNamespacedPath(filename));
const { resolveAddonRealPath } = require('internal/modules/vfs_addons');
const realFilename = resolveAddonRealPath(filename) ?? filename;
return process.dlopen(module, path.toNamespacedPath(realFilename));
};

/**
Expand Down
5 changes: 3 additions & 2 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const {
} = primordials;
const { getOptionValue } = require('internal/options');
const { getValidatedPath } = require('internal/fs/utils');
const fsBindings = internalBinding('fs');
const { getFormatOfExtensionlessFile: vfsGetFormatOfExtensionlessFile } =
require('internal/modules/vfs_resolution');
const { internal: internalConstants } = internalBinding('constants');

const extensionFormatMap = {
Expand Down Expand Up @@ -66,7 +67,7 @@ function mimeToFormat(mime) {
*/
function getFormatOfExtensionlessFile(url) {
const path = getValidatedPath(url);
switch (fsBindings.getFormatOfExtensionlessFile(path)) {
switch (vfsGetFormatOfExtensionlessFile(path)) {
case internalConstants.EXTENSIONLESS_FORMAT_WASM:
return 'wasm';
default:
Expand Down
Loading