Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions doc/api/vfs.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ callback-based, and promise-based file system methods that mirror the
shape of the [`node:fs`][] API. All paths are POSIX-style and absolute
(starting with `/`).

By default, the file tree is private to the VFS instance. To expose
it through the global `node:fs` module, `require()`, and `import`,
call [`vfs.mount(prefix)`][]; call [`vfs.unmount()`][] (or rely on a
`using` declaration) to detach again.

## `vfs.create([provider][, options])`

<!-- YAML
Expand Down Expand Up @@ -92,6 +97,93 @@ added: REPLACEME
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
warning. **Default:** `true`.

### `vfs.mount(prefix)`

<!-- YAML
added: REPLACEME
-->

* `prefix` {string} The path prefix where the VFS will be mounted.
* Returns: {VirtualFileSystem} The VFS instance, for chaining or `using`.

Mounts the virtual file system at the specified path prefix. After
mounting, files in the VFS can be accessed through the `node:fs`
module — and resolved through `require()` and `import` — using paths
that start with the prefix.

If a real file-system path already exists at the mount prefix, the
VFS **shadows** that path: every operation against a path under the
mount point is directed to the VFS until the VFS is unmounted.

```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');

const myVfs = vfs.create();
myVfs.writeFileSync('/data.txt', 'Hello');
myVfs.mount('/virtual');

fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
```

Each `VirtualFileSystem` instance may be mounted at most once at a
time. Attempting to mount an already-mounted instance throws
`ERR_INVALID_STATE`. Mounting two instances at overlapping prefixes
(e.g., `/virtual` and `/virtual/sub`) also throws `ERR_INVALID_STATE`.

The VFS supports the [Explicit Resource Management][] proposal. Use
a `using` declaration to unmount automatically when leaving scope:

```cjs
const vfs = require('node:vfs');
const fs = require('node:fs');

{
using myVfs = vfs.create();
myVfs.writeFileSync('/data.txt', 'Hello');
myVfs.mount('/virtual');

fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
} // VFS is automatically unmounted here

fs.existsSync('/virtual/data.txt'); // false
```

### `vfs.unmount()`

<!-- YAML
added: REPLACEME
-->

Unmounts the virtual file system. After unmounting, virtual files
are no longer reachable through `node:fs`, `require()`, or `import`.
The same instance may be mounted again, at the same or a different
prefix, by calling `mount()`.

This method is idempotent: calling `unmount()` on a VFS that is not
currently mounted has no effect.

### `vfs.mounted`

<!-- YAML
added: REPLACEME
-->

* {boolean}

`true` while the VFS is mounted; `false` otherwise.

### `vfs.mountPoint`

<!-- YAML
added: REPLACEME
-->

* {string | null}

The current mount-point path as an absolute string, or `null` when
the VFS is not mounted.

### `vfs.provider`

<!-- YAML
Expand Down Expand Up @@ -302,9 +394,12 @@ fields use synthetic but stable values:
* `blocks` is `Math.ceil(size / 512)`.
* Times default to the moment the entry was created/last modified.

[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
[`MemoryProvider`]: #class-memoryprovider
[`VirtualFileSystem`]: #class-virtualfilesystem
[`VirtualProvider`]: #class-virtualprovider
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
[`fs.Stats`]: fs.md#class-fsstats
[`node:fs`]: fs.md
[`vfs.mount(prefix)`]: #vfsmountprefix
[`vfs.unmount()`]: #vfsunmount
36 changes: 32 additions & 4 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ const kFormat = Symbol('kFormat');

// Set first due to cycle with ESM loader functions.
module.exports = {
clearStatCache,
clearStatCacheForVFS,
kModuleSource,
kModuleExport,
kModuleExportNames,
Expand Down Expand Up @@ -155,14 +157,14 @@ const {
} = internalBinding('contextify');

const assert = require('internal/assert');
const fs = require('fs');
const path = require('path');
const internalFsBinding = internalBinding('fs');
const { safeGetenv } = internalBinding('credentials');
const {
getCjsConditions,
getCjsConditionsArray,
initializeCjsConditions,
loaderReadFile,
loaderStat,
loadBuiltinModule,
makeRequireFunction,
setHasStartedUserCJSExecution,
Expand Down Expand Up @@ -272,14 +274,40 @@ function stat(filename) {
const result = statCache.get(filename);
if (result !== undefined) { return result; }
}
const result = internalFsBinding.internalModuleStat(filename);
const result = loaderStat(filename);
if (statCache !== null && result >= 0) {
// Only set cache when `internalModuleStat(filename)` succeeds.
statCache.set(filename, result);
}
return result;
}

/**
* Clear the stat cache. Called when VFS instances are unmounted
* to prevent stale stat results from being returned.
*/
function clearStatCache() {
if (statCache !== null) {
statCache = new SafeMap();
}
}

/**
* Drop only the stat-cache entries owned by the given VFS instance.
* Real-fs entries and entries owned by other VFSes are untouched.
* @param {{shouldHandle: (path: string) => boolean}} vfs
*/
function clearStatCacheForVFS(vfs) {
if (statCache === null) {
return;
}
for (const filename of statCache.keys()) {
if (vfs.shouldHandle(filename)) {
statCache.delete(filename);
}
}
}

let _stat = stat;
ObjectDefineProperty(Module, '_stat', {
__proto__: null,
Expand Down Expand Up @@ -1201,7 +1229,7 @@ function defaultLoadImpl(filename, format) {
case 'module-typescript':
case 'commonjs-typescript':
case 'typescript': {
return fs.readFileSync(filename, 'utf8');
return loaderReadFile(filename, 'utf8');
}
case 'builtin':
return null;
Expand Down
4 changes: 2 additions & 2 deletions lib/internal/modules/esm/get_format.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const {
} = primordials;
const { getOptionValue } = require('internal/options');
const { getValidatedPath } = require('internal/fs/utils');
const fsBindings = internalBinding('fs');
const { internal: internalConstants } = internalBinding('constants');

const extensionFormatMap = {
Expand Down Expand Up @@ -59,7 +58,8 @@ function mimeToFormat(mime) {
*/
function getFormatOfExtensionlessFile(url) {
const path = getValidatedPath(url);
switch (fsBindings.getFormatOfExtensionlessFile(path)) {
const { loaderGetFormatOfExtensionlessFile } = require('internal/modules/helpers');
switch (loaderGetFormatOfExtensionlessFile(path)) {
case internalConstants.EXTENSIONLESS_FORMAT_WASM:
return 'wasm';
default:
Expand Down
12 changes: 7 additions & 5 deletions lib/internal/modules/esm/load.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const {

const { defaultGetFormat } = require('internal/modules/esm/get_format');
const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert');
const fs = require('fs');
const { loaderReadFile } = require('internal/modules/helpers');

const { Buffer: { from: BufferFrom } } = require('buffer');

Expand All @@ -34,11 +34,13 @@ function getSourceSync(url, context) {
const responseURL = href;
let source;
if (protocol === 'file:') {
// If you are reading this code to figure out how to patch Node.js module loading
// behavior - DO NOT depend on the patchability in new code: Node.js
// If you are reading this code to figure out how to patch Node.js module
// loading behavior - DO NOT depend on the patchability in new code: Node.js
// internals may stop going through the JavaScript fs module entirely.
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
source = fs.readFileSync(url);
// Prefer module.registerHooks(), node:vfs, or other more formal fs hooks
// released in the future. loaderReadFile is the toggleable hook used by
// node:vfs and is not part of the public API.
source = loaderReadFile(url);
Comment thread
mcollina marked this conversation as resolved.
} else if (protocol === 'data:') {
const result = dataURLProcessor(url);
if (result === 'failure') {
Expand Down
44 changes: 29 additions & 15 deletions lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ const {
ObjectPrototypeHasOwnProperty,
RegExpPrototypeExec,
RegExpPrototypeSymbolReplace,
SafeMap,
SafeSet,
String,
StringPrototypeEndsWith,
Expand All @@ -23,16 +22,13 @@ const {
encodeURIComponent,
} = primordials;
const assert = require('internal/assert');
const internalFS = require('internal/fs/utils');
const { BuiltinModule } = require('internal/bootstrap/realm');
const fs = require('fs');
const { getOptionValue } = require('internal/options');
// Do not eagerly grab .manifest, it may be in TDZ
const { sep, posix: { relative: relativePosixPath }, resolve } = require('path');
const { URL, pathToFileURL, fileURLToPath, isURL, URLParse } = require('internal/url');
const { getCWDURL, setOwnProperty } = require('internal/util');
const { canParse: URLCanParse } = internalBinding('url');
const { legacyMainResolve: FSLegacyMainResolve } = internalBinding('fs');
const {
ERR_INPUT_TYPE_NOT_ALLOWED,
ERR_INVALID_ARG_TYPE,
Expand All @@ -49,7 +45,12 @@ const {
const { defaultGetFormatWithoutErrors } = require('internal/modules/esm/get_format');
const { getConditionsSet } = require('internal/modules/esm/utils');
const packageJsonReader = require('internal/modules/package_json_reader');
const internalFsBinding = internalBinding('fs');
const {
loaderGetLayerForPath,
loaderLegacyMainResolve,
loaderStat,
toRealPath,
} = require('internal/modules/helpers');

/**
* @typedef {import('internal/modules/esm/package_config.js').PackageConfig} PackageConfig
Expand Down Expand Up @@ -149,8 +150,6 @@ function emitLegacyIndexDeprecation(url, path, pkgPath, base, main) {
}
}

const realpathCache = new SafeMap();

const legacyMainResolveExtensions = [
'',
'.js',
Expand Down Expand Up @@ -198,7 +197,7 @@ function legacyMainResolve(packageJSONUrl, packageConfig, base) {

const baseStringified = isURL(base) ? base.href : base;

const resolvedOption = FSLegacyMainResolve(pkgPath, packageConfig.main, baseStringified);
const resolvedOption = loaderLegacyMainResolve(pkgPath, packageConfig.main, baseStringified);

const maybeMain = resolvedOption <= legacyMainResolveExtensionsIndexes.kResolvedByMainIndexNode ?
packageConfig.main || './' : '';
Expand Down Expand Up @@ -244,7 +243,7 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
throw err;
}

const stats = internalFsBinding.internalModuleStat(
const stats = loaderStat(
StringPrototypeEndsWith(path, '/') ? StringPrototypeSlice(path, -1) : path,
);

Expand Down Expand Up @@ -273,20 +272,35 @@ function finalizeResolution(resolved, base, preserveSymlinks) {
}

if (!preserveSymlinks) {
// If you are reading this code to figure out how to patch Node.js module loading
// behavior - DO NOT depend on the patchability in new code: Node.js
// If you are reading this code to figure out how to patch Node.js module
// loading behavior - DO NOT depend on the patchability in new code: Node.js
// internals may stop going through the JavaScript fs module entirely.
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
const real = fs.realpathSync(path, {
[internalFS.realpathCacheKey]: realpathCache,
});
// Prefer module.registerHooks(), node:vfs, or other more formal fs hooks
// released in the future. toRealPath is the toggleable hook used by
// node:vfs and is not part of the public API.
const real = toRealPath(path);
Comment thread
mcollina marked this conversation as resolved.
const { search, hash } = resolved;
resolved =
pathToFileURL(real + (StringPrototypeEndsWith(path, sep) ? '/' : ''));
resolved.search = search;
resolved.hash = hash;
}

// If the resolved path is owned by an installed VFS layer, append a
// `vfs-layer=N` search param so cache entries are tagged with the
// owning layer. On deregister, entries matching the unmounted layer
// can be scope-purged without touching unrelated real-fs imports.
// The tag is surfaced in `import.meta.url`, matching the
// cache-busting pattern used by HMR tooling.
const layerId = loaderGetLayerForPath(path);
if (layerId !== undefined) {
if (resolved.search) {
resolved.search += `&vfs-layer=${layerId}`;
} else {
resolved.search = `?vfs-layer=${layerId}`;
}
}

return resolved;
}

Expand Down
Loading
Loading