Skip to content

Commit 1312520

Browse files
committed
vfs: add virtual process.chdir() support
Add virtual working directory support to the VFS with process.chdir() and process.cwd() interception: - Add `virtualCwd` option to fs.createVirtual() (disabled by default) - Add vfs.cwd(), vfs.chdir(), and vfs.resolvePath() methods - Intercept process.chdir() for VFS paths when virtualCwd is enabled - Intercept process.cwd() to return virtual cwd when set - Restore original process methods on unmount - Add tests for basic chdir functionality and process interception - Add worker thread tests verifying independent VFS instances - Update documentation with examples and worker thread notes
1 parent 1a05a8f commit 1312520

4 files changed

Lines changed: 627 additions & 0 deletions

File tree

doc/api/fs.md

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8347,6 +8347,8 @@ added: REPLACEME
83478347
fall through to the real file system. **Default:** `true`.
83488348
* `moduleHooks` {boolean} When `true`, enables hooks for `require()` and
83498349
`import` to load modules from the VFS. **Default:** `true`.
8350+
* `virtualCwd` {boolean} When `true`, enables virtual working directory
8351+
support via `vfs.chdir()` and `vfs.cwd()`. **Default:** `false`.
83508352
* Returns: {VirtualFileSystem}
83518353
83528354
Creates a new virtual file system instance.
@@ -8531,6 +8533,147 @@ added: REPLACEME
85318533
85328534
Removes a file or directory from the VFS.
85338535
8536+
#### `vfs.virtualCwdEnabled`
8537+
8538+
<!-- YAML
8539+
added: REPLACEME
8540+
-->
8541+
8542+
* {boolean}
8543+
8544+
Returns `true` if virtual working directory support is enabled for this VFS
8545+
instance. This is determined by the `virtualCwd` option passed to
8546+
`fs.createVirtual()`.
8547+
8548+
#### `vfs.cwd()`
8549+
8550+
<!-- YAML
8551+
added: REPLACEME
8552+
-->
8553+
8554+
* Returns: {string|null} The current virtual working directory, or `null` if
8555+
not set.
8556+
8557+
Gets the virtual current working directory. Throws `ERR_INVALID_STATE` if
8558+
`virtualCwd` option was not enabled when creating the VFS.
8559+
8560+
```cjs
8561+
const fs = require('node:fs');
8562+
8563+
const vfs = fs.createVirtual({ virtualCwd: true });
8564+
vfs.addDirectory('/project');
8565+
vfs.mount('/app');
8566+
8567+
console.log(vfs.cwd()); // null (not set yet)
8568+
8569+
vfs.chdir('/app/project');
8570+
console.log(vfs.cwd()); // '/app/project'
8571+
```
8572+
8573+
#### `vfs.chdir(path)`
8574+
8575+
<!-- YAML
8576+
added: REPLACEME
8577+
-->
8578+
8579+
* `path` {string} The directory path to set as the current working directory.
8580+
8581+
Sets the virtual current working directory. The path must exist in the VFS and
8582+
must be a directory. Throws `ENOENT` if the path does not exist, `ENOTDIR` if
8583+
the path is not a directory, or `ERR_INVALID_STATE` if `virtualCwd` option was
8584+
not enabled.
8585+
8586+
```cjs
8587+
const fs = require('node:fs');
8588+
8589+
const vfs = fs.createVirtual({ virtualCwd: true });
8590+
vfs.addDirectory('/project');
8591+
vfs.addDirectory('/project/src');
8592+
vfs.addFile('/project/src/index.js', 'module.exports = "hello";');
8593+
vfs.mount('/app');
8594+
8595+
vfs.chdir('/app/project');
8596+
console.log(vfs.cwd()); // '/app/project'
8597+
8598+
vfs.chdir('/app/project/src');
8599+
console.log(vfs.cwd()); // '/app/project/src'
8600+
```
8601+
8602+
##### `process.chdir()` and `process.cwd()` interception
8603+
8604+
When `virtualCwd` is enabled and the VFS is mounted or in overlay mode,
8605+
`process.chdir()` and `process.cwd()` are intercepted to support transparent
8606+
virtual working directory operations:
8607+
8608+
* `process.chdir(path)` - When called with a path that resolves to the VFS,
8609+
the virtual cwd is updated instead of changing the real process working
8610+
directory. Paths outside the VFS fall through to the real `process.chdir()`.
8611+
8612+
* `process.cwd()` - When a virtual cwd is set, returns the virtual cwd.
8613+
Otherwise, returns the real process working directory.
8614+
8615+
```cjs
8616+
const fs = require('node:fs');
8617+
8618+
const vfs = fs.createVirtual({ virtualCwd: true });
8619+
vfs.addDirectory('/project');
8620+
vfs.mount('/virtual');
8621+
8622+
const originalCwd = process.cwd();
8623+
8624+
// Change to a VFS directory using process.chdir
8625+
process.chdir('/virtual/project');
8626+
console.log(process.cwd()); // '/virtual/project'
8627+
console.log(vfs.cwd()); // '/virtual/project'
8628+
8629+
// Change to a real directory (falls through)
8630+
process.chdir('/tmp');
8631+
console.log(process.cwd()); // '/tmp' (real cwd)
8632+
8633+
// Restore and unmount
8634+
process.chdir(originalCwd);
8635+
vfs.unmount();
8636+
```
8637+
8638+
When the VFS is unmounted, `process.chdir()` and `process.cwd()` are restored
8639+
to their original implementations.
8640+
8641+
> **Note:** VFS hooks are not automatically shared with worker threads. Each
8642+
> worker thread has its own `process` object and must set up its own VFS
8643+
> instance if virtual cwd support is needed.
8644+
8645+
#### `vfs.resolvePath(path)`
8646+
8647+
<!-- YAML
8648+
added: REPLACEME
8649+
-->
8650+
8651+
* `path` {string} The path to resolve.
8652+
* Returns: {string} The resolved absolute path.
8653+
8654+
Resolves a path relative to the virtual current working directory. If the path
8655+
is absolute, it is returned as-is (normalized). If `virtualCwd` is enabled and
8656+
a virtual cwd is set, relative paths are resolved against it. Otherwise,
8657+
relative paths are resolved using the real process working directory.
8658+
8659+
```cjs
8660+
const fs = require('node:fs');
8661+
8662+
const vfs = fs.createVirtual({ virtualCwd: true });
8663+
vfs.addDirectory('/project');
8664+
vfs.addDirectory('/project/src');
8665+
vfs.mount('/app');
8666+
8667+
vfs.chdir('/app/project');
8668+
8669+
// Absolute paths returned as-is
8670+
console.log(vfs.resolvePath('/other/path')); // '/other/path'
8671+
8672+
// Relative paths resolved against virtual cwd
8673+
console.log(vfs.resolvePath('src/index.js')); // '/app/project/src/index.js'
8674+
console.log(vfs.resolvePath('./src/index.js')); // '/app/project/src/index.js'
8675+
```
8676+
85348677
### VFS file system operations
85358678
85368679
The `VirtualFileSystem` instance provides direct access to file system

lib/internal/vfs/virtual_fs.js

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@ const kOverlay = Symbol('kOverlay');
5959
const kFallthrough = Symbol('kFallthrough');
6060
const kModuleHooks = Symbol('kModuleHooks');
6161
const kPromises = Symbol('kPromises');
62+
const kVirtualCwd = Symbol('kVirtualCwd');
63+
const kVirtualCwdEnabled = Symbol('kVirtualCwdEnabled');
64+
const kOriginalChdir = Symbol('kOriginalChdir');
65+
const kOriginalCwd = Symbol('kOriginalCwd');
6266

6367
/**
6468
* Virtual File System implementation.
@@ -69,6 +73,7 @@ class VirtualFileSystem {
6973
* @param {object} [options] Configuration options
7074
* @param {boolean} [options.fallthrough] Whether to fall through to real fs on miss
7175
* @param {boolean} [options.moduleHooks] Whether to enable require/import hooks
76+
* @param {boolean} [options.virtualCwd] Whether to enable virtual working directory
7277
*/
7378
constructor(options = {}) {
7479
emitExperimentalWarning('fs.createVirtual');
@@ -79,6 +84,10 @@ class VirtualFileSystem {
7984
this[kFallthrough] = options.fallthrough !== false;
8085
this[kModuleHooks] = options.moduleHooks !== false;
8186
this[kPromises] = null; // Lazy-initialized
87+
this[kVirtualCwdEnabled] = options.virtualCwd === true;
88+
this[kVirtualCwd] = null; // Set when chdir() is called
89+
this[kOriginalChdir] = null; // Saved process.chdir
90+
this[kOriginalCwd] = null; // Saved process.cwd
8291
}
8392

8493
/**
@@ -113,6 +122,74 @@ class VirtualFileSystem {
113122
return this[kFallthrough];
114123
}
115124

125+
/**
126+
* Returns true if virtual working directory is enabled.
127+
* @returns {boolean}
128+
*/
129+
get virtualCwdEnabled() {
130+
return this[kVirtualCwdEnabled];
131+
}
132+
133+
// ==================== Virtual Working Directory ====================
134+
135+
/**
136+
* Gets the virtual current working directory.
137+
* Returns null if no virtual cwd is set.
138+
* @returns {string|null}
139+
*/
140+
cwd() {
141+
if (!this[kVirtualCwdEnabled]) {
142+
throw new ERR_INVALID_STATE('virtual cwd is not enabled');
143+
}
144+
return this[kVirtualCwd];
145+
}
146+
147+
/**
148+
* Sets the virtual current working directory.
149+
* The path must exist in the VFS.
150+
* @param {string} dirPath The directory path to set as cwd
151+
*/
152+
chdir(dirPath) {
153+
if (!this[kVirtualCwdEnabled]) {
154+
throw new ERR_INVALID_STATE('virtual cwd is not enabled');
155+
}
156+
157+
const normalized = normalizePath(dirPath);
158+
const entry = this._resolveEntry(normalized);
159+
160+
if (!entry) {
161+
throw createENOENT('chdir', normalized);
162+
}
163+
164+
if (!entry.isDirectory()) {
165+
throw createENOTDIR('chdir', normalized);
166+
}
167+
168+
this[kVirtualCwd] = normalized;
169+
}
170+
171+
/**
172+
* Resolves a path relative to the virtual cwd if set.
173+
* If the path is absolute or no virtual cwd is set, returns the path as-is.
174+
* @param {string} inputPath The path to resolve
175+
* @returns {string} The resolved path
176+
*/
177+
resolvePath(inputPath) {
178+
// If path is absolute, return as-is
179+
if (inputPath.startsWith('/')) {
180+
return normalizePath(inputPath);
181+
}
182+
183+
// If virtual cwd is enabled and set, resolve relative to it
184+
if (this[kVirtualCwdEnabled] && this[kVirtualCwd] !== null) {
185+
const resolved = this[kVirtualCwd] + '/' + inputPath;
186+
return normalizePath(resolved);
187+
}
188+
189+
// Fall back to normalizing the path (will use real cwd)
190+
return normalizePath(inputPath);
191+
}
192+
116193
// ==================== Entry Management ====================
117194

118195
/**
@@ -240,6 +317,9 @@ class VirtualFileSystem {
240317
if (this[kModuleHooks]) {
241318
registerVFS(this);
242319
}
320+
if (this[kVirtualCwdEnabled]) {
321+
this._hookProcessCwd();
322+
}
243323
}
244324

245325
/**
@@ -253,16 +333,81 @@ class VirtualFileSystem {
253333
if (this[kModuleHooks]) {
254334
registerVFS(this);
255335
}
336+
if (this[kVirtualCwdEnabled]) {
337+
this._hookProcessCwd();
338+
}
256339
}
257340

258341
/**
259342
* Unmounts the VFS.
260343
*/
261344
unmount() {
345+
this._unhookProcessCwd();
262346
unregisterVFS(this);
263347
this[kMountPoint] = null;
264348
this[kMounted] = false;
265349
this[kOverlay] = false;
350+
this[kVirtualCwd] = null; // Reset virtual cwd on unmount
351+
}
352+
353+
/**
354+
* Hooks process.chdir and process.cwd to support virtual cwd.
355+
* @private
356+
*/
357+
_hookProcessCwd() {
358+
if (this[kOriginalChdir] !== null) {
359+
// Already hooked
360+
return;
361+
}
362+
363+
const vfs = this;
364+
365+
// Save original process methods
366+
this[kOriginalChdir] = process.chdir;
367+
this[kOriginalCwd] = process.cwd;
368+
369+
// Override process.chdir
370+
process.chdir = function chdir(directory) {
371+
const normalized = normalizePath(directory);
372+
373+
// Check if this path is within VFS
374+
if (vfs.shouldHandle(normalized)) {
375+
vfs.chdir(normalized);
376+
return;
377+
}
378+
379+
// Fall through to real chdir
380+
return vfs[kOriginalChdir].call(process, directory);
381+
};
382+
383+
// Override process.cwd
384+
process.cwd = function cwd() {
385+
// If virtual cwd is set, return it
386+
if (vfs[kVirtualCwd] !== null) {
387+
return vfs[kVirtualCwd];
388+
}
389+
390+
// Fall through to real cwd
391+
return vfs[kOriginalCwd].call(process);
392+
};
393+
}
394+
395+
/**
396+
* Restores original process.chdir and process.cwd.
397+
* @private
398+
*/
399+
_unhookProcessCwd() {
400+
if (this[kOriginalChdir] === null) {
401+
// Not hooked
402+
return;
403+
}
404+
405+
// Restore original process methods
406+
process.chdir = this[kOriginalChdir];
407+
process.cwd = this[kOriginalCwd];
408+
409+
this[kOriginalChdir] = null;
410+
this[kOriginalCwd] = null;
266411
}
267412

268413
// ==================== Path Resolution ====================

0 commit comments

Comments
 (0)