nodejs/node · #65748

vfs: add --vfs-mount and --vfs-load startup flags

pipobscure · merged Sep 17, 202617 files · 946 + / 5
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#dnssetdefaultresultorderorder
doc/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-virtualfilesystem
doc/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\fR
lib/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 inspector
test/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']);+}