Skip to content

Commit 4dc22f7

Browse files
mcollinajasnell
andcommitted
vfs: integrate with CJS and ESM module loaders
Co-authored-by: James M Snell <jasnell@gmail.com> Signed-off-by: Matteo Collina <hello@matteocollina.com>
1 parent 9d2db70 commit 4dc22f7

18 files changed

Lines changed: 2791 additions & 86 deletions

doc/api/vfs.md

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ callback-based, and promise-based file system methods that mirror the
4646
shape of the [`node:fs`][] API. All paths are POSIX-style and absolute
4747
(starting with `/`).
4848

49+
By default, the file tree is private to the VFS instance. To expose
50+
it through the global `node:fs` module, `require()`, and `import`,
51+
call [`vfs.mount(prefix)`][]; call [`vfs.unmount()`][] (or rely on a
52+
`using` declaration) to detach again.
53+
4954
## `vfs.create([provider][, options])`
5055

5156
<!-- YAML
@@ -92,6 +97,126 @@ added: REPLACEME
9297
* `emitExperimentalWarning` {boolean} Whether to emit the experimental
9398
warning. **Default:** `true`.
9499

100+
### `vfs.mount(prefix)`
101+
102+
<!-- YAML
103+
added: REPLACEME
104+
-->
105+
106+
* `prefix` {string} The path prefix where the VFS will be mounted.
107+
* Returns: {VirtualFileSystem} The VFS instance, for chaining or `using`.
108+
109+
Mounts the virtual file system at the specified path prefix. After
110+
mounting, files in the VFS can be accessed through the `node:fs`
111+
module — and resolved through `require()` and `import` — using paths
112+
that start with the prefix.
113+
114+
If a real file-system path already exists at the mount prefix, the
115+
VFS **shadows** that path: every operation against a path under the
116+
mount point is directed to the VFS until the VFS is unmounted.
117+
118+
```cjs
119+
const vfs = require('node:vfs');
120+
const fs = require('node:fs');
121+
122+
const myVfs = vfs.create();
123+
myVfs.writeFileSync('/data.txt', 'Hello');
124+
myVfs.mount('/virtual');
125+
126+
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
127+
```
128+
129+
Each `VirtualFileSystem` instance may be mounted at most once at a
130+
time. Attempting to mount an already-mounted instance throws
131+
`ERR_INVALID_STATE`. Mounting two instances at overlapping prefixes
132+
(e.g., `/virtual` and `/virtual/sub`) also throws `ERR_INVALID_STATE`.
133+
134+
The VFS supports the [Explicit Resource Management][] proposal. Use
135+
a `using` declaration to unmount automatically when leaving scope:
136+
137+
```cjs
138+
const vfs = require('node:vfs');
139+
const fs = require('node:fs');
140+
141+
{
142+
using myVfs = vfs.create();
143+
myVfs.writeFileSync('/data.txt', 'Hello');
144+
myVfs.mount('/virtual');
145+
146+
fs.readFileSync('/virtual/data.txt', 'utf8'); // 'Hello'
147+
} // VFS is automatically unmounted here
148+
149+
fs.existsSync('/virtual/data.txt'); // false
150+
```
151+
152+
### `vfs.unmount()`
153+
154+
<!-- YAML
155+
added: REPLACEME
156+
-->
157+
158+
Unmounts the virtual file system. After unmounting, virtual files
159+
are no longer reachable through `node:fs`, `require()`, or `import`.
160+
The same instance may be mounted again, at the same or a different
161+
prefix, by calling `mount()`.
162+
163+
This method is idempotent: calling `unmount()` on a VFS that is not
164+
currently mounted has no effect.
165+
166+
### `vfs.mounted`
167+
168+
<!-- YAML
169+
added: REPLACEME
170+
-->
171+
172+
* {boolean}
173+
174+
`true` while the VFS is mounted; `false` otherwise.
175+
176+
### `vfs.mountPoint`
177+
178+
<!-- YAML
179+
added: REPLACEME
180+
-->
181+
182+
* {string | null}
183+
184+
The current mount-point path as an absolute string, or `null` when
185+
the VFS is not mounted.
186+
187+
### `vfs.layerId`
188+
189+
<!-- YAML
190+
added: REPLACEME
191+
-->
192+
193+
* {number}
194+
195+
A per-process monotonically increasing identifier assigned at
196+
construction. The id is stable across `mount()` / `unmount()` cycles
197+
for the lifetime of the instance, and is independent of the order in
198+
which VFS layers are mounted.
199+
200+
The layer id is the building block for cache scoping (see
201+
[Module loader integration][]):
202+
203+
* it surfaces in `import.meta.url` for ES modules loaded from this
204+
VFS, as a `?vfs-layer=<id>` search parameter, so that the cascaded
205+
loader's caches can be scoped per VFS;
206+
* it appears in the `NODE_DEBUG=vfs` output for `register` and
207+
`deregister` events;
208+
* it appears in the `ERR_INVALID_STATE` error message thrown when two
209+
VFS instances try to mount at overlapping prefixes.
210+
211+
```cjs
212+
const vfs = require('node:vfs');
213+
214+
const a = vfs.create();
215+
const b = vfs.create();
216+
console.log(a.layerId); // e.g. 0
217+
console.log(b.layerId); // a.layerId + 1
218+
```
219+
95220
### `vfs.provider`
96221

97222
<!-- YAML
@@ -180,6 +305,69 @@ The promise namespace mirrors `fs.promises` and includes `readFile`,
180305
`access`, `rm`, `truncate`, `link`, `mkdtemp`, `chmod`, `chown`, `lchown`,
181306
`utimes`, `lutimes`, `open`, `lchmod`, and `watch`.
182307

308+
## Module loader integration
309+
310+
Once a `VirtualFileSystem` is mounted, paths under the mount prefix
311+
participate in module resolution and loading. Both
312+
`require()` / `require.resolve()` (CommonJS) and `import` /
313+
`import.meta.resolve()` (ECMAScript modules) consult the VFS through
314+
the same toggleable hooks that `node:fs` uses, so files served from
315+
the VFS are first-class modules: `package.json` is honoured,
316+
extensionless files are sniffed for Wasm vs. JavaScript, conditional
317+
`exports` / `imports` work, and so on.
318+
319+
```cjs
320+
const vfs = require('node:vfs');
321+
322+
const myVfs = vfs.create();
323+
myVfs.mkdirSync('/lib');
324+
myVfs.writeFileSync('/lib/greet.js', 'module.exports = () => "hi";');
325+
myVfs.writeFileSync(
326+
'/lib/package.json', '{"main": "./greet.js"}');
327+
myVfs.mount('/virtual');
328+
329+
const greet = require('/virtual/lib');
330+
console.log(greet()); // 'hi'
331+
332+
myVfs.unmount();
333+
```
334+
335+
### Cache scoping and `import.meta.url`
336+
337+
Module loaders maintain caches that survive the lifetime of any
338+
single VFS. To keep entries from leaking once a VFS is unmounted
339+
without invalidating unrelated real-fs imports, two mechanisms are
340+
combined:
341+
342+
* **CommonJS caches** (`require.cache`, the internal stat and
343+
realpath caches, and the `package.json` caches) are filtered on
344+
`unmount()`: entries whose absolute filename would be claimed by
345+
the VFS going away are deleted. `__filename` and `module.filename`
346+
are unchanged - they remain plain absolute paths.
347+
348+
* **ECMAScript module URLs** are tagged at resolve time. When the
349+
resolver determines that a path belongs to a mounted VFS, it
350+
appends `?vfs-layer=<id>` (where `<id>` is the owning instance's
351+
[`vfs.layerId`][]) to the resolved URL. The tag therefore appears
352+
in `import.meta.url` and in cache keys, and on `unmount()` the
353+
cascaded loader's caches drop just the entries that carry the tag
354+
for the unmounting layer.
355+
356+
```mjs
357+
// inside /virtual/lib/greet.mjs after the VFS above is mounted
358+
console.log(import.meta.url);
359+
// e.g. 'file:///virtual/lib/greet.mjs?vfs-layer=0'
360+
```
361+
362+
User code that compares `import.meta.url` literally should account
363+
for the search parameter; use `new URL(import.meta.url).pathname` or
364+
`fileURLToPath()` to obtain the underlying path.
365+
366+
Mounting and unmounting do not invalidate ESM modules that are
367+
already executing. As with any other module-system teardown,
368+
unmounting a VFS while the import graph below it is still loading is
369+
the caller's responsibility to avoid.
370+
183371
## Class: `VirtualProvider`
184372
185373
<!-- YAML
@@ -302,9 +490,14 @@ fields use synthetic but stable values:
302490
* `blocks` is `Math.ceil(size / 512)`.
303491
* Times default to the moment the entry was created/last modified.
304492
493+
[Explicit Resource Management]: https://github.com/tc39/proposal-explicit-resource-management
494+
[Module loader integration]: #module-loader-integration
305495
[`MemoryProvider`]: #class-memoryprovider
306496
[`VirtualFileSystem`]: #class-virtualfilesystem
307497
[`VirtualProvider`]: #class-virtualprovider
308498
[`fs.BigIntStats`]: fs.md#class-fsbigintstats
309499
[`fs.Stats`]: fs.md#class-fsstats
310500
[`node:fs`]: fs.md
501+
[`vfs.layerId`]: #vfslayerid
502+
[`vfs.mount(prefix)`]: #vfsmountprefix
503+
[`vfs.unmount()`]: #vfsunmount

lib/internal/modules/cjs/loader.js

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ const kFormat = Symbol('kFormat');
113113

114114
// Set first due to cycle with ESM loader functions.
115115
module.exports = {
116+
clearStatCache,
117+
clearStatCacheForVFS,
116118
kModuleSource,
117119
kModuleExport,
118120
kModuleExportNames,
@@ -155,14 +157,14 @@ const {
155157
} = internalBinding('contextify');
156158

157159
const assert = require('internal/assert');
158-
const fs = require('fs');
159160
const path = require('path');
160-
const internalFsBinding = internalBinding('fs');
161161
const { safeGetenv } = internalBinding('credentials');
162162
const {
163163
getCjsConditions,
164164
getCjsConditionsArray,
165165
initializeCjsConditions,
166+
loaderReadFile,
167+
loaderStat,
166168
loadBuiltinModule,
167169
makeRequireFunction,
168170
setHasStartedUserCJSExecution,
@@ -277,14 +279,39 @@ function stat(filename) {
277279
const result = statCache.get(filename);
278280
if (result !== undefined) { return result; }
279281
}
280-
const result = internalFsBinding.internalModuleStat(filename);
282+
const result = loaderStat(filename);
281283
if (statCache !== null && result >= 0) {
282284
// Only set cache when `internalModuleStat(filename)` succeeds.
283285
statCache.set(filename, result);
284286
}
285287
return result;
286288
}
287289

290+
/**
291+
* Clear the stat cache. Called when VFS instances are unmounted
292+
* to prevent stale stat results from being returned.
293+
*/
294+
function clearStatCache() {
295+
if (statCache !== null) {
296+
statCache = new SafeMap();
297+
}
298+
}
299+
300+
/**
301+
* Drop only the stat-cache entries owned by the given VFS instance.
302+
* Real-fs entries and entries owned by other VFSes are untouched.
303+
* @param {{shouldHandle: (path: string) => boolean}} vfs
304+
*/
305+
function clearStatCacheForVFS(vfs) {
306+
if (statCache !== null) {
307+
for (const filename of statCache.keys()) {
308+
if (vfs.shouldHandle(filename)) {
309+
statCache.delete(filename);
310+
}
311+
}
312+
}
313+
}
314+
288315
let _stat = stat;
289316
ObjectDefineProperty(Module, '_stat', {
290317
__proto__: null,
@@ -1247,7 +1274,7 @@ function defaultLoadImpl(filename, format) {
12471274
case 'module-typescript':
12481275
case 'commonjs-typescript':
12491276
case 'typescript': {
1250-
return fs.readFileSync(filename, 'utf8');
1277+
return loaderReadFile(filename, 'utf8');
12511278
}
12521279
case 'builtin':
12531280
return null;

lib/internal/modules/esm/get_format.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const {
1010
} = primordials;
1111
const { getOptionValue } = require('internal/options');
1212
const { getValidatedPath } = require('internal/fs/utils');
13-
const fsBindings = internalBinding('fs');
1413
const { internal: internalConstants } = internalBinding('constants');
1514

1615
const extensionFormatMap = {
@@ -59,7 +58,8 @@ function mimeToFormat(mime) {
5958
*/
6059
function getFormatOfExtensionlessFile(url) {
6160
const path = getValidatedPath(url);
62-
switch (fsBindings.getFormatOfExtensionlessFile(path)) {
61+
const { loaderGetFormatOfExtensionlessFile } = require('internal/modules/helpers');
62+
switch (loaderGetFormatOfExtensionlessFile(path)) {
6363
case internalConstants.EXTENSIONLESS_FORMAT_WASM:
6464
return 'wasm';
6565
default:

lib/internal/modules/esm/load.js

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ const {
99

1010
const { defaultGetFormat } = require('internal/modules/esm/get_format');
1111
const { validateAttributes, emitImportAssertionWarning } = require('internal/modules/esm/assert');
12-
const fs = require('fs');
12+
const { loaderReadFile } = require('internal/modules/helpers');
1313

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

@@ -34,11 +34,13 @@ function getSourceSync(url, context) {
3434
const responseURL = href;
3535
let source;
3636
if (protocol === 'file:') {
37-
// If you are reading this code to figure out how to patch Node.js module loading
38-
// behavior - DO NOT depend on the patchability in new code: Node.js
37+
// If you are reading this code to figure out how to patch Node.js module
38+
// loading behavior - DO NOT depend on the patchability in new code: Node.js
3939
// internals may stop going through the JavaScript fs module entirely.
40-
// Prefer module.registerHooks() or other more formal fs hooks released in the future.
41-
source = fs.readFileSync(url);
40+
// Prefer module.registerHooks(), node:vfs, or other more formal fs hooks
41+
// released in the future. loaderReadFile is the toggleable hook used by
42+
// node:vfs and is not part of the public API.
43+
source = loaderReadFile(url);
4244
} else if (protocol === 'data:') {
4345
const result = dataURLProcessor(url);
4446
if (result === 'failure') {

0 commit comments

Comments
 (0)