nodejs/node · #65748
vfs: add --vfs-mount and --vfs-load startup flags
doc/api/cli.md78 + / 0 −
@@ -3785,6 +3785,78 @@ added: v0.1.3 Print node's version. +### `--vfs-load=source`++<!-- YAML+added: REPLACEME+-->++* `source` {string} A directory or an archive file to mount and run.++Requires [`--experimental-vfs`][]. May be given at most once.++Mounts `source` exactly as [`--vfs-mount`][] does, and additionally runs the+entry point and all subsequent `require()`/`import` resolution against that+mount rather than the real file system. The entry point is taken from the mount+the same way `node <directory>` takes one: the mount's own `package.json`+`"main"`, or `index.js`. Any positional command-line argument is the program's+own (available from `process.argv[2]` onward), never an entry-point override.++`process.argv[1]` reports `source` rather than the reserved mount point, since+the mount point is an opaque implementation detail.++Mounting the same source twice mounts it twice, at two separate mount points.+The entry point then comes from the mount `--vfs-load` itself contributed, not+from an earlier `--vfs-mount` of the same source.++In worker threads `--vfs-load` mounts but does not load: a worker inherits the+same mounts, in the same order, and runs its own entry point.++`--vfs-load` is not permitted in [`NODE_OPTIONS`][]: which entry point runs is+the command line's decision, and the environment must not be able to redirect+it.++```console+$ node --experimental-vfs --vfs-load=app.zip+$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip+```++### `--vfs-mount=source`++<!-- YAML+added: REPLACEME+-->++* `source` {string} A directory or an archive file to mount.++Requires [`--experimental-vfs`][]. May be repeated to mount several sources.++Mounts `source` as a virtual file system ([`node:vfs`][]). Each mount is placed+at a reserved mount point assigned by Node.js, so mounts never shadow real+paths and no target can be chosen. Mounting alone does not change the entry+point; use [`--vfs-load`][] for the source to run from.++`--vfs-mount` and [`--vfs-load`][] mount in the order they are written, so++```console+$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c+```++mounts `a`, `b` and `c` in that order and runs `b`. Mounts contributed by+[`NODE_OPTIONS`][] are mounted before the command line's.++The provider backing a source is chosen from the source itself rather than from+its file name:++* A directory is mounted with a [`RealFSProvider`][] rooted there.+* A file whose bytes are a ZIP archive is mounted with a [`ZipProvider`][], so+ an archive can carry any name.++Providers registered with `vfs.registerProvider()` (typically from a module+preloaded with [`--require`][] or [`--import`][]) are consulted first, in+reverse registration order, and may claim directories as well as files. If no+provider claims the source, Node.js exits with an error.+ ### `--watch` <!-- YAML@@ -4227,6 +4299,7 @@ one is included in the list below. * `--use-openssl-ca` * `--use-system-ca` * `--v8-pool-size`+* `--vfs-mount` * `--watch-kill-signal` * `--watch-path` * `--watch-preserve-output`@@ -4740,6 +4813,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@@ -4751,6 +4825,8 @@ 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-load`]: #--vfs-loadsource+[`--vfs-mount`]: #--vfs-mountsource [`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@@ -4759,8 +4835,10 @@ node --stack-trace-limit=12 -p -e "Error.stackTraceLimit" # prints 12 [`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 [`YoungGenerationSizeFromSemiSpaceSize`]: https://chromium.googlesource.com/v8/v8.git/+/refs/tags/10.3.129/src/heap/heap.cc#328+[`ZipProvider`]: vfs.md#class-zipprovider [`crypto.createPrivateKey()`]: crypto.md#cryptocreateprivatekeykey [`dns.lookup()`]: dns.md#dnslookuphostname-options-callback [`dns.setDefaultResultOrder()`]: dns.md#dnssetdefaultresultorderorderdoc/api/errors.md7 + / 0 −
@@ -3564,6 +3564,13 @@ 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`++A `--vfs-mount` source does not exist, is neither a regular file nor a+directory, or is a source no provider claims.+ <a id="ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING"></a> ### `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`doc/api/vfs.md62 + / 0 −
@@ -93,6 +93,65 @@ const memoryVfs = vfs.create(); const realVfs = vfs.create(new vfs.RealFSProvider('/tmp/vfs-root')); ``` +## `vfs.registerProvider(entry)`++<!-- YAML+added: REPLACEME+-->++* `entry` {Object}+ * `name` {string} A short identifier, used in diagnostics.+ * `canHandle` {Function} Called with the resolved path and its+ [`fs.Stats`][]. Returns `true` if this provider should back the source.+ * `create` {Function} Called with the resolved path and its [`fs.Stats`][].+ Returns the {VirtualProvider} backing the source.++Registers a provider that [`--vfs-mount`][] can select for a source it+recognizes, so a file format Node.js has no built-in provider for can still be+mounted.++A source is claimed by the first provider whose `canHandle()` returns `true`.+Registered providers are consulted before the built-in ones, newest+registration first, and are offered directories as well as files, so a+registered provider can back, wrap, or vet any source. If none claims the+source, the built-in providers handle it: a directory with+[`RealFSProvider`][], and a file whose bytes are a ZIP archive with+[`ZipProvider`][].++Providers must be registered before the mounts are created. Register from a+module preloaded with [`--require`][] or [`--import`][]:++```cjs+// provider.js, preloaded with --require+const fs = require('node:fs');+const vfs = require('node:vfs');++const MAGIC = Buffer.from('CUSTOMFMT');++vfs.registerProvider({+ name: 'customfmt',+ canHandle(path, stats) {+ if (!stats.isFile()) return false;+ const head = Buffer.alloc(MAGIC.length);+ const fd = fs.openSync(path, 'r');+ try {+ fs.readSync(fd, head, 0, MAGIC.length, 0);+ } finally {+ fs.closeSync(fd);+ }+ return head.equals(MAGIC);+ },+ create(path) {+ return new MyCustomProvider(path);+ },+});+```++```console+$ node --experimental-vfs --require ./provider.js \+ --vfs-load archive.customfmt+```+ ## Class: `VirtualFileSystem` <!-- YAML@@ -635,6 +694,9 @@ fields use synthetic but stable values: [ES modules resolution algorithm]: esm.md#resolution-algorithm [Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management [Single Executable Application]: single-executable-applications.md+[`--import`]: cli.md#--importmodule+[`--require`]: cli.md#-r---require-module+[`--vfs-mount`]: cli.md#--vfs-mountsource [`MemoryProvider`]: #class-memoryprovider [`RealFSProvider`]: #class-realfsprovider [`VirtualFileSystem`]: #class-virtualfilesystemdoc/node.159 + / 0 −
@@ -1882,6 +1882,63 @@ 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-load Ns = Ns Ar source+.Bl -bullet+.It+\fBsource\fR \fB{string}\fR A directory or an archive file to mount and run.+.El+Requires \fB--experimental-vfs\fR. May be given at most once.+Mounts \fBsource\fR exactly as \fB--vfs-mount\fR does, and additionally runs the+entry point and all subsequent \fBrequire()\fR/\fBimport\fR resolution against that+mount rather than the real file system. The entry point is taken from the mount+the same way \fBnode <directory>\fR takes one: the mount's own \fBpackage.json\fR+\fB"main"\fR, or \fBindex.js\fR. Any positional command-line argument is the program's+own (available from \fBprocess.argv[2]\fR onward), never an entry-point override.+\fBprocess.argv[1]\fR reports \fBsource\fR rather than the reserved mount point, since+the mount point is an opaque implementation detail.+Mounting the same source twice mounts it twice, at two separate mount points.+The entry point then comes from the mount \fB--vfs-load\fR itself contributed, not+from an earlier \fB--vfs-mount\fR of the same source.+In worker threads \fB--vfs-load\fR mounts but does not load: a worker inherits the+same mounts, in the same order, and runs its own entry point.+\fB--vfs-load\fR is not permitted in \fBNODE_OPTIONS\fR: which entry point runs is+the command line's decision, and the environment must not be able to redirect+it.+.Bd -literal+$ node --experimental-vfs --vfs-load=app.zip+$ node --experimental-vfs --vfs-mount=lib.zip --vfs-load=app.zip+.Ed+.+.It Fl -vfs-mount Ns = Ns Ar source+.Bl -bullet+.It+\fBsource\fR \fB{string}\fR A directory or an archive file to mount.+.El+Requires \fB--experimental-vfs\fR. May be repeated to mount several sources.+Mounts \fBsource\fR as a virtual file system (\fBnode:vfs\fR). Each mount is placed+at a reserved mount point assigned by Node.js, so mounts never shadow real+paths and no target can be chosen. Mounting alone does not change the entry+point; use \fB--vfs-load\fR for the source to run from.+\fB--vfs-mount\fR and \fB--vfs-load\fR mount in the order they are written, so+.Bd -literal+$ node --experimental-vfs --vfs-mount=a --vfs-load=b --vfs-mount=c+.Ed+mounts \fBa\fR, \fBb\fR and \fBc\fR in that order and runs \fBb\fR. Mounts contributed by+\fBNODE_OPTIONS\fR are mounted before the command line's.+The provider backing a source is chosen from the source itself rather than from+its file name:+.Bl -bullet+.It+A directory is mounted with a \fBRealFSProvider\fR rooted there.+.It+A file whose bytes are a ZIP archive is mounted with a \fBZipProvider\fR, so+an archive can carry any name.+.El+Providers registered with \fBvfs.registerProvider()\fR (typically from a module+preloaded with \fB--require\fR or \fB--import\fR) are consulted first, in+reverse registration order, and may claim directories as well as files. If no+provider claims the source, Node.js exits with an error.+. .It Fl -watch Starts Node.js in watch mode. When in watch mode, changes in the watched files cause the Node.js process to@@ -2373,6 +2430,8 @@ one is included in the list below. .It \fB--v8-pool-size\fR .It+\fB--vfs-mount\fR+.It \fB--watch-kill-signal\fR .It \fB--watch-path\fRlib/internal/errors.js2 + / 0 −
@@ -1961,6 +1961,8 @@ 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-mount source: must be an existing file or directory', Error); E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING', 'A dynamic import callback was not specified.', TypeError); E('ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING_FLAG',lib/internal/main/worker_thread.js8 + / 0 −
@@ -16,6 +16,7 @@ const { const { prepareWorkerThreadExecution, initializeModuleLoaders,+ finishVfsMounts, markBootstrapComplete, } = require('internal/process/pre_execution'); @@ -144,6 +145,13 @@ port.on('message', (message) => { // initializeAsyncLoaderHooksOnLoaderHookWorker() which needs to run preloads // after the asynchronous loader hooks are registered. initializeModuleLoaders({ shouldSpawnLoaderHookWorker: true, shouldPreloadModules: true });+ // Re-mount inherited --vfs-mount sources so their reserved paths (which a+ // worker filename may point into) resolve in this thread too. With+ // --import, mounting is deferred to after that loop in run_main, matching+ // the main thread; finishVfsMounts() is idempotent so it runs once.+ if (getOptionValue('--import').length === 0) {+ finishVfsMounts();+ } } if (!hasStdin)lib/internal/modules/helpers.js2 + / 0 −
@@ -85,6 +85,8 @@ const nativeLoaderMethods = { modulesBinding.readPackageJSON(jsonPath, isESM, base, specifier), getNearestParentPackageJSON: (checkPath) => modulesBinding.getNearestParentPackageJSON(checkPath),+ getNearestParentPackageJSONType: (checkPath) =>+ modulesBinding.getNearestParentPackageJSONType(checkPath), getPackageScopeConfig: (resolved) => modulesBinding.getPackageScopeConfig(resolved), getPackageType: (url) => modulesBinding.getPackageType(url),lib/internal/modules/run_main.js20 + / 3 −
@@ -5,8 +5,10 @@ const { globalThis, } = primordials; -const { getNearestParentPackageJSONType } = internalBinding('modules'); const { getOptionValue } = require('internal/options');+// Through loaderMethods rather than the `modules` binding directly: a mounted+// VFS overrides these, and a worker entry point can live inside one.+const { loaderMethods } = require('internal/modules/helpers'); const path = require('path'); const { pathToFileURL, URL } = require('internal/url'); const { kEmptyObject, getCWDURL } = require('internal/util');@@ -37,7 +39,6 @@ function resolveMainPath(main) { const preserveSymlinksMain = getOptionValue('--preserve-symlinks-main'); if (!preserveSymlinksMain) {- const { loaderMethods } = require('internal/modules/helpers'); mainPath = loaderMethods.realpathSync(mainPath); } @@ -73,7 +74,7 @@ function shouldUseESMLoader(mainPath) { if (mainPath && StringPrototypeEndsWith(mainPath, '.mts')) { return true; } } - const type = getNearestParentPackageJSONType(mainPath);+ const type = loaderMethods.getNearestParentPackageJSONType(mainPath); // No package.json or no `type` field. if (type === undefined || type === 'none') {@@ -98,6 +99,9 @@ async function asyncRunEntryPointWithESMLoader(callback) { } else { cascadedLoader.waitForAsyncLoaderHookInitialization(); }+ // Any --import above may have registered a VFS provider, so mount now (no-op+ // if prepareExecution already mounted in the no-import case).+ require('internal/process/pre_execution').finishVfsMounts(); await callback(cascadedLoader); } catch (err) { if (hasUncaughtExceptionCaptureCallback()) {@@ -138,6 +142,19 @@ function runEntryPointWithESMLoader(callback) { * @param {string} main - First positional CLI argument, such as `'entry.js'` from `node entry.js` */ function executeUserEntryPoint(main = process.argv[1]) {+ if (getOptionValue('[vfs_load_set]')) {+ // The entry is a directory mount whose reserved root only exists after+ // finishVfsMounts() runs (inside runEntryPointWithESMLoader, once --import+ // preloads have had a chance to registerProvider()). Load it through the CJS+ // main loader so the reserved directory resolves to its index the same way+ // any require() of a directory does, sidestepping ESM directory-import.+ runEntryPointWithESMLoader(() => {+ const { getVfsLoadRoot } = require('internal/process/pre_execution');+ const { wrapModuleLoad } = require('internal/modules/cjs/loader');+ return wrapModuleLoad(getVfsLoadRoot(), null, true);+ });+ return;+ } let useESMLoader; let resolvedMain; if (getOptionValue('--entry-url')) {lib/internal/process/pre_execution.js116 + / 1 −
@@ -2,6 +2,7 @@ const { ArrayPrototypeForEach,+ ArrayPrototypeSplice, Date, DatePrototypeGetDate, DatePrototypeGetFullYear,@@ -13,6 +14,8 @@ const { ObjectDefineProperty, ObjectFreeze, String,+ StringPrototypeIndexOf,+ StringPrototypeSlice, globalThis, } = primordials; @@ -32,6 +35,7 @@ const { const { ERR_MISSING_OPTION, ERR_ACCESS_DENIED,+ ERR_VFS_INVALID_TARGET, } = require('internal/errors').codes; const assert = require('internal/assert'); const {@@ -179,6 +183,14 @@ function prepareExecution(options) { initializeModuleLoaders({ shouldSpawnLoaderHookWorker, shouldPreloadModules }); } + // Mount here only when there is no --import: those preloads run later (inside+ // run_main's ESM entry flow), and a preload may registerProvider() before a+ // target's provider is chosen, so with --import the mount is deferred to after+ // that loop via finishVfsMounts(), which is idempotent.+ if (isMainThread && getOptionValue('--import').length === 0) {+ finishVfsMounts();+ }+ // This has to be done after the user module loader is initialized, // in case undici is externalized. setupHttpProxy();@@ -201,6 +213,103 @@ function setupVmModules() { } } +let vfsMounted = false;+let vfsLoadRoot;++// --vfs-mount and --vfs-load append to one list, so `mounts` is already in the+// order the command line gave, and the entry point comes from whichever of them+// --vfs-load contributed. The parser stores plain strings and cannot record+// which flag produced an entry, so its position is recovered from execArgv -+// the command line's own node options, in order. NODE_OPTIONS may add mounts+// but not a --vfs-load, so anything it contributed sits ahead of these.+// Returns -1 when no --vfs-load was given.+function getVfsLoadIndex(mountCount) {+ if (!getOptionValue('[vfs_load_set]')) return -1;++ const execArgv = process.execArgv;+ let seen = 0;+ let found = -1;+ for (let i = 0; i < execArgv.length; i++) {+ const arg = execArgv[i];+ let name = arg;+ const eq = StringPrototypeIndexOf(arg, '=');+ let spaced = false;+ if (eq !== -1) {+ name = StringPrototypeSlice(arg, 0, eq);+ } else {+ // `--vfs-mount value`: the value is the next argument, so skip it rather+ // than counting it as a flag of its own.+ spaced = true;+ }+ if (name !== '--vfs-mount' && name !== '--vfs-load') continue;+ if (name === '--vfs-load') found = seen;+ seen++;+ if (spaced) i++;+ }+ if (found === -1) return -1;+ // Mounts from NODE_OPTIONS are parsed first and so precede the command+ // line's; `seen` counts only the latter.+ return mountCount - seen + found;+}++// Mounts every --vfs-mount source. Called from prepareExecution() when there is+// no --import, and otherwise from run_main after the --import loop has run; the+// guard makes the second call a no-op so a provider registered by either a -r or+// an --import preload is available before its source's provider is chosen.+function finishVfsMounts() {+ if (vfsMounted) return;+ vfsMounted = true;++ const entries = getOptionValue('--vfs-mount');+ if (entries.length === 0) return;+ emitExperimentalWarning('--vfs-mount');++ const fs = require('fs');+ const path = require('path');+ const { selectProvider } = require('internal/vfs/provider_registry');+ const { VirtualFileSystem } = require('internal/vfs/file_system');++ // --vfs-load is forced off in workers (see node_worker.cc), so this records a+ // load root only on the main thread; a worker re-mounts the same sources in+ // the same order (the reserved paths line up) but runs its own entry.+ const loadIndex = getVfsLoadIndex(entries.length);+ for (let i = 0; i < entries.length; i++) {+ const resolvedSource = path.resolve(entries[i]);+ let stats;+ try {+ stats = fs.statSync(resolvedSource);+ } catch {+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);+ }+ if (!stats.isDirectory() && !stats.isFile()) {+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);+ }+ const provider = selectProvider(resolvedSource, stats);+ if (provider === null) {+ throw new ERR_VFS_INVALID_TARGET(resolvedSource);+ }+ const vfs = new VirtualFileSystem(provider, { emitExperimentalWarning: false });+ const mountPoint = vfs.mount();+ // The mount --vfs-load contributed is what the entry is require()d from;+ // process.argv[1] names the real source instead, since the reserved mount+ // point is an opaque implementation detail.+ //+ // The source is spliced in rather than assigned over argv[1]: the entry+ // comes from the mount, so nothing was consumed as an entry point and the+ // first positional argument is the program's own. Overwriting would drop it.+ if (i === loadIndex) {+ vfsLoadRoot = mountPoint;+ ArrayPrototypeSplice(process.argv, 1, 0, resolvedSource);+ }+ }+}++// The reserved mount point of the --vfs-load entry, available once+// finishVfsMounts() has run. run_main require()s the entry from here.+function getVfsLoadRoot() {+ return vfsLoadRoot;+}+ function setupHttpProxy() { // This normalized from both --use-env-proxy and NODE_USE_ENV_PROXY settings. if (!getOptionValue('--use-env-proxy')) {@@ -287,7 +396,11 @@ function patchProcessObject(expandArgv1) { let mainEntry; // If requested, update process.argv[1] to replace whatever the user provided with the resolved absolute file path of // the entry point.- if (expandArgv1 && process.argv[1] && process.argv[1][0] !== '-') {+ // Under --vfs-load the entry point comes from the mount, so no positional+ // argument was consumed as one: argv[1] is the program's own first argument+ // and expanding it to a path would corrupt it.+ if (expandArgv1 && !getOptionValue('[vfs_load_set]') &&+ process.argv[1] && process.argv[1][0] !== '-') { // Expand process.argv[1] into a full path. const path = require('path'); try {@@ -841,6 +954,8 @@ function getHeapSnapshotFilename(diagnosticDir) { } module.exports = {+ finishVfsMounts,+ getVfsLoadRoot, initializeModuleLoaders, prepareMainThreadExecution, prepareWorkerThreadExecution,lib/internal/vfs/provider_registry.jsadded95 + / 0 −
@@ -0,0 +1,95 @@+'use strict';++// Maps a `--vfs-mount` source to the provider that backs it. Directories are+// served by RealFSProvider and ZIP archives by ZipProvider; both are claimed+// from the source itself (a stat, or a trial open) rather than its file+// extension. Any other source type is added via node:vfs's registerProvider().++const {+ ArrayPrototypeUnshift,+} = primordials;+const {+ validateFunction,+ validateObject,+ validateString,+} = require('internal/validators');++// A source is claimed by the first provider whose canHandle() returns true.+// Registered providers are unshifted ahead of these built-ins so a custom+// provider can back, wrap, or vet any source. Requires are deferred to the+// provider methods so the zlib/zip and fs-provider machinery stays off the+// startup path until a mount actually needs it.+//+// ZipFile.openSync() locates the end-of-central-directory record and throws+// when the source is not a ZIP, so it doubles as the content check; the opened+// archive is stashed and handed to the provider rather than reopened.+let pendingZipFile = null;+const providers = [+ {+ name: 'zip',+ canHandle(resolvedPath, stats) {+ if (!stats.isFile()) return false;+ const { ZipFile } = require('internal/zip');+ try {+ pendingZipFile = ZipFile.openSync(resolvedPath);+ return true;+ } catch {+ pendingZipFile = null;+ return false;+ }+ },+ create(resolvedPath) {+ const { ZipFile } = require('internal/zip');+ const { ZipProvider } = require('internal/vfs/providers/ziparchive');+ const source = pendingZipFile ?? ZipFile.openSync(resolvedPath);+ pendingZipFile = null;+ return new ZipProvider(source);+ },+ },+ {+ name: 'dir',+ canHandle(resolvedPath, stats) { return stats.isDirectory(); },+ create(resolvedPath) {+ const { RealFSProvider } = require('internal/vfs/providers/real');+ return new RealFSProvider(resolvedPath);+ },+ },+];++/**+ * Registers a provider that `--vfs-mount` can select for a source it+ * recognizes. The newest registration is consulted first, and all registered+ * providers outrank the built-in directory provider, so a custom provider can+ * back, wrap, or vet any mount.+ * @param {object} entry+ * @param {string} entry.name A short identifier, used in diagnostics.+ * @param {(resolvedPath: string, stats: object) => boolean} entry.canHandle+ * Returns `true` if this provider should back `resolvedPath`.+ * @param {(resolvedPath: string, stats: object) => object} entry.create+ * Returns the VirtualProvider backing `resolvedPath`.+ */+function registerProvider(entry) {+ validateObject(entry, 'entry');+ validateString(entry.name, 'entry.name');+ validateFunction(entry.canHandle, 'entry.canHandle');+ validateFunction(entry.create, 'entry.create');+ ArrayPrototypeUnshift(providers, {+ name: entry.name,+ canHandle: entry.canHandle,+ create: entry.create,+ });+}++function selectProvider(resolvedPath, stats) {+ for (let i = 0; i < providers.length; i++) {+ if (providers[i].canHandle(resolvedPath, stats)) {+ return providers[i].create(resolvedPath, stats);+ }+ }+ return null;+}++module.exports = {+ registerProvider,+ selectProvider,+};lib/internal/vfs/setup.js10 + / 0 −
@@ -953,6 +953,16 @@ function installModuleLoaderOverrides() { const found = findVFSPackageJSON(r.vfs, checkPath, r.normalized); return found.tuple ?? kLoaderOverrideNoResult; },+ getNearestParentPackageJSONType(checkPath) {+ const r = findVFSOrRoot(checkPath);+ if (r === null) return undefined;+ if (r.vfs === null) return kLoaderOverrideNoResult;+ const found = findVFSPackageJSON(r.vfs, checkPath, r.normalized);+ // Tuple shape: [name, main, type, imports, exports, filePath]. No+ // package.json above the path is "no result", which the caller reads+ // the same way it reads a scope without a `type`.+ return found.tuple?.[2] ?? kLoaderOverrideNoResult;+ }, getPackageScopeConfig(resolved) { let filePath; if (StringPrototypeStartsWith(resolved, 'file:')) {lib/vfs.js2 + / 0 −
@@ -9,6 +9,7 @@ const { VirtualProvider } = require('internal/vfs/provider'); const { MemoryProvider } = require('internal/vfs/providers/memory'); const { RealFSProvider } = require('internal/vfs/providers/real'); const { ZipProvider } = require('internal/vfs/providers/ziparchive');+const { registerProvider } = require('internal/vfs/provider_registry'); /** * Creates a new VirtualFileSystem instance.@@ -31,6 +32,7 @@ function create(provider, options) { module.exports = { create,+ registerProvider, VirtualFileSystem, VirtualProvider, MemoryProvider,src/node.cc35 + / 1 −
@@ -399,7 +399,10 @@ MaybeLocal<Value> StartExecution(Environment* env, return StartExecution(env, "internal/main/watch_mode"); } - if (!first_argv.empty() && first_argv != "-") {+ // --vfs-load takes the entry point from the source it names, mounted in+ // prepareExecution(), so route to run_main_module even with no positional+ // argument rather than falling through to the REPL/stdin.+ if ((!first_argv.empty() && first_argv != "-") || env->options()->vfs_load) { return StartExecution(env, "internal/main/run_main_module"); } @@ -1033,6 +1036,37 @@ static ExitCode InitializeNodeWithArgsInternal( CheckGlobalBenchOptions(errors); if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument; + // Checked here rather than in EnvironmentOptions::CheckOptions(), which runs+ // at the end of every parse: NODE_OPTIONS is parsed before the command line,+ // so a check there would reject `NODE_OPTIONS=--vfs-mount=x node+ // --experimental-vfs` for an --experimental-vfs it had not read yet. These+ // options only make sense as a set, so they are validated once all of them+ // are in.+ {+ auto* env_options = per_process::cli_options->per_isolate->per_env.get();+ if (!env_options->experimental_vfs) {+ if (!env_options->vfs_mounts.empty()) {+ errors->push_back("--vfs-mount requires --experimental-vfs");+ }+ if (env_options->vfs_load) {+ errors->push_back("--vfs-load requires --experimental-vfs");+ }+ }+ // --vfs-load shares vfs_mounts with --vfs-mount, so the options themselves+ // cannot say how often it was given; count it in the node options the+ // command line yielded. A second one would silently win over the first.+ if (env_options->vfs_load && exec_argv != nullptr) {+ size_t seen = 0;+ for (const std::string& arg : *exec_argv) {+ if (arg == "--vfs-load" || arg.starts_with("--vfs-load=")) seen++;+ }+ if (seen > 1) {+ errors->push_back("--vfs-load may only be given once");+ }+ }+ if (!errors->empty()) return ExitCode::kInvalidCommandLineArgument;+ }+ // Set the process.title immediately after processing argv if --title is set. if (!per_process::cli_options->title.empty()) uv_set_process_title(per_process::cli_options->title.c_str());src/node_options.cc20 + / 0 −
@@ -691,6 +691,26 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() { "experimental node:vfs module", BOOL_FIELD(experimental_vfs), kAllowedInEnvvar);+ // --vfs-mount and --vfs-load both append to vfs_mounts, so the list holds+ // every mount in the order the command line asked for them. Which of those+ // the entry point comes from is recovered from the position of --vfs-load,+ // rather than an index the user has to count out.+ AddOption("--vfs-mount",+ "mount a directory or archive as a virtual file system "+ "(option can be repeated; requires --experimental-vfs)",+ &EnvironmentOptions::vfs_mounts,+ kAllowedInEnvvar);+ // Choosing the entry point is the command line's alone: an environment+ // variable must not be able to redirect what a `node <args>` invocation runs,+ // so this is rejected in NODE_OPTIONS.+ AddOption("--vfs-load",+ "mount a directory or archive as a virtual file system and run the "+ "entry point and module resolution against it instead of the real "+ "file system (may be given once; requires --experimental-vfs)",+ &EnvironmentOptions::vfs_mounts,+ kDisallowedInEnvvar);+ AddOption("[vfs_load_set]", "", BOOL_FIELD(vfs_load));+ Implies("--vfs-load", "[vfs_load_set]"); AddOption("--experimental-quic", #ifndef OPENSSL_NO_QUIC "experimental QUIC support",src/node_options.h2 + / 0 −
@@ -178,6 +178,7 @@ class EnvironmentOptions : public Options { std::vector<std::string> watch_mode_paths; std::vector<std::string> preload_cjs_modules; std::vector<std::string> preload_esm_modules;+ std::vector<std::string> vfs_mounts; std::vector<std::string> user_argv; int64_t heap_snapshot_near_heap_limit = 0;@@ -214,6 +215,7 @@ class EnvironmentOptions : public Options { DEFINE_BOOL_FIELD(experimental_sqlite) = HAVE_SQLITE; DEFINE_BOOL_FIELD(experimental_stream_iter) = EXPERIMENTALS_DEFAULT_VALUE; DEFINE_BOOL_FIELD(experimental_vfs) = EXPERIMENTALS_DEFAULT_VALUE;+ DEFINE_BOOL_FIELD(vfs_load) = false; DEFINE_BOOL_FIELD(webstorage) = HAVE_SQLITE; DEFINE_BOOL_FIELD(experimental_dtls) = EXPERIMENTALS_DEFAULT_VALUE; DEFINE_BOOL_FIELD(experimental_quic) = EXPERIMENTALS_DEFAULT_VALUE;src/node_worker.cc5 + / 0 −
@@ -704,6 +704,11 @@ void Worker::New(const FunctionCallbackInfo<Value>& args) { per_isolate_opts = env->isolate_data()->options()->Clone(); } + // --vfs-load selects the main thread's entry point; a worker always starts+ // from its own entry (which may itself live inside a --vfs-mount), so the+ // mounts are inherited but the load behavior must not be.+ per_isolate_opts->per_env->vfs_load = false;+ // Internal workers should not wait for inspector frontend to connect or // break on the first line of internal scripts. Module loader threads are // essential to load user codes and must not be blocked by the inspectortest/parallel/test-vfs-mount-load.jsadded423 + / 0 −
@@ -0,0 +1,423 @@+'use strict';++// Covers --vfs-mount / --vfs-load: running a mounted directory's entry point+// with require() resolving inside the mount, a provider registered by either a+// -r (CJS) or an --import (ESM) preload backing a non-directory source, a ZIP+// archive claimed by the built-in provider, a worker inheriting the mounts,+// and the position of --vfs-load among the mounts deciding which one runs.+//+// Native addon loading from a mount is not exercised here (it needs a compiled+// .node), only the startup wiring around it.++const common = require('../common');+const tmpdir = require('../common/tmpdir');+const assert = require('assert');+const fs = require('fs');+const path = require('path');+const { pathToFileURL } = require('url');+const { spawnSync } = require('child_process');++tmpdir.refresh();+let id = 0;+function fixture(name) { return path.join(tmpdir.path, `${id++}-${name}`); }++function run(args) {+ return spawnSync(process.execPath, ['--experimental-vfs', ...args], { encoding: 'utf8' });+}++// Node.js can be built without NODE_OPTIONS support, in which case the+// environment cannot carry a flag at all and there is nothing to assert.+const hasNodeOptions = !process.config.variables.node_without_node_options;++// NODE_OPTIONS is tokenized with shell-like quoting, so a path holding a space+// or a quote - as the checkout directory does on some CI machines - has to be+// quoted and escaped rather than interpolated raw.+function envArg(flag, value) {+ return `"${flag}=${value.replace(/[\\"]/g, '\\$&')}"`;+}++// A directory source: the entry point runs and require() resolves inside it.+{+ const dir = fixture('app');+ fs.mkdirSync(path.join(dir, 'lib'), { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'),+ "console.log(require('./lib/greet')());\n");+ fs.writeFileSync(path.join(dir, 'lib', 'greet.js'),+ "module.exports = () => 'hello from inside the mount';\n");+ const res = run([`--vfs-load=${dir}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from inside the mount/);+}++// A provider registered by a -r (CommonJS) preload backs a custom file format.+{+ const providerModule = fixture('provider.js');+ fs.writeFileSync(providerModule, `+'use strict';+const fs = require('fs');+const vfs = require('node:vfs');+const MAGIC = Buffer.from('CUSTOMFMT');+vfs.registerProvider({+ name: 'customfmt',+ canHandle(p, stats) {+ if (!stats.isFile()) return false;+ const fd = fs.openSync(p, 'r');+ try {+ const buf = Buffer.alloc(MAGIC.length);+ fs.readSync(fd, buf, 0, MAGIC.length, 0);+ return buf.equals(MAGIC);+ } finally { fs.closeSync(fd); }+ },+ create(p) {+ const body = fs.readFileSync(p).subarray(MAGIC.length).toString('utf8');+ const provider = new vfs.MemoryProvider();+ provider.writeFileSync('/index.js', body);+ return provider;+ },+});+`);+ const target = fixture('app.customfmt');+ fs.writeFileSync(target, Buffer.concat([+ Buffer.from('CUSTOMFMT'),+ Buffer.from("console.log('hello from custom provider');"),+ ]));+ const res = run(['-r', providerModule, `--vfs-load=${target}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from custom provider/);+}++// A provider registered by an --import (ES module) preload: this only works+// because mounting is deferred until after the --import loop has run.+{+ const providerModule = fixture('provider.mjs');+ fs.writeFileSync(providerModule, `+import fs from 'node:fs';+import { registerProvider, MemoryProvider } from 'node:vfs';+const MAGIC = Buffer.from('ESMFMT');+registerProvider({+ name: 'esmfmt',+ canHandle(p, stats) {+ if (!stats.isFile()) return false;+ return fs.readFileSync(p).subarray(0, MAGIC.length).equals(MAGIC);+ },+ create(p) {+ const body = fs.readFileSync(p).subarray(MAGIC.length).toString('utf8');+ const provider = new MemoryProvider();+ provider.writeFileSync('/index.js', body);+ return provider;+ },+});+`);+ const target = fixture('app.esmfmt');+ fs.writeFileSync(target, Buffer.concat([+ Buffer.from('ESMFMT'),+ Buffer.from("console.log('hello from ESM-imported provider');"),+ ]));+ const res = run([+ '--import', pathToFileURL(providerModule).href,+ `--vfs-load=${target}`,+ ]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from ESM-imported provider/);+}++// A ZIP archive is claimed by the built-in provider (detected by opening it,+// not by extension).+{+ const zlib = require('zlib');+ const zipPath = fixture('app.zip');+ const entry = zlib.ZipEntry.createSync(+ 'index.js', Buffer.from("console.log('hello from zip archive');"));+ const chunks = [];+ for (const chunk of zlib.createZipArchiveSync([entry])) chunks.push(chunk);+ fs.writeFileSync(zipPath, Buffer.concat(chunks));+ const res = run([`--vfs-load=${zipPath}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from zip archive/);+}++// Two different ZIP archives mounted together each keep their own contents.+// The built-in provider opens the archive while deciding whether it can claim+// the source and hands that same handle to the provider it then creates, so+// this pins down that the handle belongs to the source it was opened for and+// is not shared between mounts.+{+ const zlib = require('zlib');++ // Each archive prints which one it is and what it can see, so a mix-up shows+ // up as the wrong marker or the other archive's file.+ const body = Buffer.from(+ 'const fs = require("fs");\n' ++ 'console.log("marker:" + fs.readFileSync(__dirname + "/marker.txt", "utf8").trim());\n' ++ 'console.log("entries:" + fs.readdirSync(__dirname).sort().join(","));\n');++ function archive(name, unique) {+ const zipPath = fixture(`${name}.zip`);+ const entries = [+ zlib.ZipEntry.createSync('index.js', body),+ zlib.ZipEntry.createSync('marker.txt', Buffer.from(`${name}\n`)),+ zlib.ZipEntry.createSync(unique, Buffer.from('x\n')),+ ];+ const chunks = [];+ for (const chunk of zlib.createZipArchiveSync(entries)) chunks.push(chunk);+ fs.writeFileSync(zipPath, Buffer.concat(chunks));+ return zipPath;+ }++ const first = archive('first-archive', 'first-only.txt');+ const second = archive('second-archive', 'second-only.txt');++ // Whichever archive --vfs-load names is the one that runs, in either order,+ // and it sees its own entries rather than the other archive's.+ for (const [args, name, unique, absent] of [+ [[`--vfs-load=${first}`, `--vfs-mount=${second}`],+ 'first-archive', 'first-only.txt', 'second-only.txt'],+ [[`--vfs-mount=${first}`, `--vfs-load=${second}`],+ 'second-archive', 'second-only.txt', 'first-only.txt'],+ [[`--vfs-mount=${second}`, `--vfs-load=${first}`],+ 'first-archive', 'first-only.txt', 'second-only.txt'],+ ]) {+ const res = run(args);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, new RegExp(`marker:${name}`));+ assert.match(res.stdout, new RegExp(`entries:.*${unique}`));+ assert.doesNotMatch(res.stdout, new RegExp(absent));+ }+}++// A worker inherits --vfs-mount, so a worker script that lives inside the mount+// (addressed here via the entry's own __dirname) resolves and runs.+{+ const dir = fixture('worker-app');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'), `+'use strict';+const path = require('path');+const { Worker } = require('worker_threads');+const w = new Worker(path.join(__dirname, 'worker.js'));+w.on('message', (m) => { console.log(m); process.exit(0); });+w.on('error', (e) => { console.error(e); process.exit(1); });+`);+ fs.writeFileSync(path.join(dir, 'worker.js'), `+'use strict';+require('worker_threads').parentPort.postMessage('hello from worker in mount');+`);+ const res = run([`--vfs-load=${dir}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from worker in mount/);+}++// The same, for a worker whose nearest package.json inside the mount says+// "module": the entry point's type is read from the mount rather than from+// the real file system above the reserved mount point.+{+ const dir = fixture('worker-esm-app');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'package.json'),+ '{"type":"module","main":"index.js"}\n');+ fs.writeFileSync(path.join(dir, 'index.js'), `+import path from 'node:path';+import { Worker } from 'node:worker_threads';+import { fileURLToPath } from 'node:url';+const here = path.dirname(fileURLToPath(import.meta.url));+const w = new Worker(path.join(here, 'worker.js'));+w.on('message', (m) => { console.log(m); process.exit(0); });+w.on('error', (e) => { console.error(e); process.exit(1); });+`);+ // No extension hint and no CJS wrapper: this only parses if the worker is+ // loaded as ESM, which takes reading "type" out of the mount's package.json.+ fs.writeFileSync(path.join(dir, 'worker.js'), `+import { parentPort } from 'node:worker_threads';+parentPort.postMessage('hello from esm worker in mount');+`);+ const res = run([`--vfs-load=${dir}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /hello from esm worker in mount/);+}++// --vfs-load names the source it loads, so it always takes a value.+{+ const res = run(['--vfs-load']);+ assert.notStrictEqual(res.status, 0);+ assert.match(res.stderr, /--vfs-load requires an argument/);+}++// --vfs-mount and --vfs-load share one ordered list, so mounts happen in the+// order written and the entry point comes from whichever source --vfs-load+// names, wherever it sits among them.+{+ const dirs = {};+ for (const name of ['a', 'b', 'c']) {+ dirs[name] = fixture(name);+ fs.mkdirSync(dirs[name], { recursive: true });+ fs.writeFileSync(path.join(dirs[name], 'index.js'),+ `console.log('ran:${name}');\n`);+ }++ for (const [args, expected] of [+ [[`--vfs-load=${dirs.a}`, `--vfs-mount=${dirs.b}`], 'a'],+ [[`--vfs-mount=${dirs.a}`, `--vfs-load=${dirs.b}`, `--vfs-mount=${dirs.c}`], 'b'],+ [[`--vfs-mount=${dirs.a}`, `--vfs-mount=${dirs.b}`, `--vfs-load=${dirs.c}`], 'c'],+ // The value may also be given as a separate argument.+ [['--vfs-mount', dirs.a, '--vfs-load', dirs.b], 'b'],+ ]) {+ const res = run(args);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, new RegExp(`ran:${expected}`));+ }+}++// The same source given twice is mounted twice, at two mount points. The entry+// point comes from the one --vfs-load contributed, not from the earlier mount+// of the same source.+{+ const dir = fixture('twice');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'),+ 'console.log("dir:" + __dirname);\n');++ const res = run([`--vfs-mount=${dir}`, `--vfs-load=${dir}`]);+ assert.strictEqual(res.status, 0, res.stderr);+ const [, first] = /dir:(\S+)/.exec(res.stdout);++ // With the order reversed the entry point is the other mount point, which is+ // what shows that the position decides and not the source.+ const reversed = run([`--vfs-load=${dir}`, `--vfs-mount=${dir}`]);+ assert.strictEqual(reversed.status, 0, reversed.stderr);+ const [, second] = /dir:(\S+)/.exec(reversed.stdout);+ assert.notStrictEqual(first, second);+}++// --vfs-load may only be given once: it shares one list with --vfs-mount, so a+// second one would otherwise quietly win over the first.+{+ const dirs = {};+ for (const name of ['once-a', 'once-b']) {+ dirs[name] = fixture(name);+ fs.mkdirSync(dirs[name], { recursive: true });+ fs.writeFileSync(path.join(dirs[name], 'index.js'),+ `console.log('ran:${name}');\n`);+ }++ const twice = run([`--vfs-load=${dirs['once-a']}`,+ `--vfs-load=${dirs['once-b']}`]);+ assert.notStrictEqual(twice.status, 0);+ assert.match(twice.stderr, /--vfs-load may only be given once/);++ // Repeating --vfs-mount stays allowed; only the loading one is limited.+ const many = run([`--vfs-mount=${dirs['once-a']}`,+ `--vfs-load=${dirs['once-b']}`,+ `--vfs-mount=${dirs['once-a']}`]);+ assert.strictEqual(many.status, 0, many.stderr);+ assert.match(many.stdout, /ran:once-b/);+}++// --vfs-load picks the entry point, so it is refused in NODE_OPTIONS: the+// environment must not be able to redirect what a `node <args>` run executes.+// Everything but the flag under test is passed on the command line, so a build+// that ignores NODE_OPTIONS cannot make this pass for the wrong reason.+if (hasNodeOptions) {+ const dir = fixture('env-refused');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran");\n');++ // On its own, and alongside a --vfs-load the command line legitimately gave:+ // the environment is refused either way rather than merged.+ for (const args of [['--experimental-vfs'],+ ['--experimental-vfs', `--vfs-load=${dir}`]]) {+ const res = spawnSync(process.execPath, args, {+ encoding: 'utf8',+ env: { ...process.env, NODE_OPTIONS: envArg('--vfs-load', dir) },+ });+ assert.notStrictEqual(res.status, 0);+ assert.match(res.stderr, /--vfs-load.* is not allowed in NODE_OPTIONS/);+ }++ // --vfs-mount, by contrast, is accepted from the environment.+ const mountFromEnv = spawnSync(+ process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {+ encoding: 'utf8',+ env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },+ });+ assert.strictEqual(mountFromEnv.status, 0, mountFromEnv.stderr);+}++// --experimental-vfs and --vfs-mount may arrive from different places. The+// options are validated once every source has been parsed, so a mount from+// NODE_OPTIONS is not rejected for an --experimental-vfs that only the command+// line carries.+if (hasNodeOptions) {+ const dir = fixture('env-mount-cli-flag');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran");\n');++ const res = spawnSync(+ process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`],+ { encoding: 'utf8',+ env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) } });+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /ran/);+}++// --vfs-mount is allowed in NODE_OPTIONS and adds to the same ordered list.+// Because --vfs-load names its source rather than counting a position, it no+// longer matters that the environment is parsed first: what the command line+// loads is unaffected by how many mounts the environment contributed.+if (hasNodeOptions) {+ const dirs = {};+ for (const name of ['envA', 'cliX']) {+ dirs[name] = fixture(name);+ fs.mkdirSync(dirs[name], { recursive: true });+ fs.writeFileSync(path.join(dirs[name], 'index.js'),+ `console.log('ran:${name}');\n`);+ }++ const res = spawnSync(+ process.execPath, ['--experimental-vfs', `--vfs-load=${dirs.cliX}`], {+ encoding: 'utf8',+ env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dirs.envA) },+ });+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /ran:cliX/);+}++// A mount source holding spaces or quotes survives NODE_OPTIONS when quoted,+// which is the only way such a path can be expressed there at all. Windows+// forbids `"` in a file name, so only the spaces and the `$` can be exercised+// there; the quote escaping itself stays covered on every other platform.+if (hasNodeOptions) {+ const oddName = common.isWindows ? `${id++}-od d $x` : `${id++}-od d "q" $x`;+ const dir = path.join(tmpdir.path, oddName);+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'), 'console.log("ran:odd");\n');++ const res = spawnSync(+ process.execPath, ['--experimental-vfs', `--vfs-load=${dir}`], {+ encoding: 'utf8',+ env: { ...process.env, NODE_OPTIONS: envArg('--vfs-mount', dir) },+ });+ assert.strictEqual(res.status, 0, res.stderr);+ assert.match(res.stdout, /ran:odd/);+}++// Under --vfs-load the entry point comes from the mount, so no positional+// argument is consumed as one: every positional reaches the program verbatim+// from argv[2] onward, and argv[1] reports the mounted source.+{+ const dir = fixture('argv-app');+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(path.join(dir, 'index.js'),+ 'console.log(JSON.stringify(process.argv.slice(1)));\n');++ for (const extra of [[], ['alpha'], ['alpha', 'beta']]) {+ const res = run([`--vfs-load=${dir}`, ...extra]);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.deepStrictEqual(JSON.parse(res.stdout), [dir, ...extra]);+ }++ // A path-like argument must not be resolved against the real file system the+ // way a genuine entry-point argument would be.+ const res = run([`--vfs-load=${dir}`, './not/an/entry.js']);+ assert.strictEqual(res.status, 0, res.stderr);+ assert.deepStrictEqual(JSON.parse(res.stdout), [dir, './not/an/entry.js']);+}