Skip to content

Commit 1f58ba9

Browse files
committed
vfs: make package.json reader VFS-aware
The C++ package.json reader reads files via libuv, bypassing VFS entirely. This means "exports", "imports", and "type" fields in package.json don't work for VFS packages. Add toggleable wrappers to package_json_reader.js (same pattern as helpers.js) for read, getPackageScopeConfig, getPackageType, and getNearestParentPackageJSON. When VFS is active, module_hooks.js registers overrides that read from VFS and reimplement upward directory walks in JS. When VFS is inactive, the fast C++ path is used unchanged. Also fix getPackageJSONURL to use the toggleable internalModuleStat from helpers instead of the captured internalFsBinding, and update getFormatFromExtension to use the now-VFS-aware getPackageType.
1 parent 0f908cb commit 1f58ba9

File tree

7 files changed

+279
-16
lines changed

7 files changed

+279
-16
lines changed

lib/internal/modules/package_json_reader.js

Lines changed: 57 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ const { kEmptyObject } = require('internal/util');
2727
const modulesBinding = internalBinding('modules');
2828
const path = require('path');
2929
const { validateString } = require('internal/validators');
30-
const internalFsBinding = internalBinding('fs');
3130

3231

3332
/**
@@ -36,6 +35,27 @@ const internalFsBinding = internalBinding('fs');
3635
* @typedef {import('typings/internalBinding/modules').SerializedPackageConfig} SerializedPackageConfig
3736
*/
3837

38+
let customRead = null;
39+
let customGetPackageScopeConfig = null;
40+
let customGetPackageType = null;
41+
let customGetNearestParentPackageJSON = null;
42+
43+
function setCustomRead(fn) {
44+
customRead = fn;
45+
}
46+
47+
function setCustomGetPackageScopeConfig(fn) {
48+
customGetPackageScopeConfig = fn;
49+
}
50+
51+
function setCustomGetPackageType(fn) {
52+
customGetPackageType = fn;
53+
}
54+
55+
function setCustomGetNearestParentPackageJSON(fn) {
56+
customGetNearestParentPackageJSON = fn;
57+
}
58+
3959
/**
4060
* @param {URL['pathname']} path
4161
* @param {SerializedPackageConfig} contents
@@ -116,10 +136,17 @@ const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' |
116136
* base?: URL | string,
117137
* specifier?: URL | string,
118138
* isESM?: boolean,
119-
* }} options
139+
* }} opts
120140
* @returns {PackageConfig}
121141
*/
122-
function read(jsonPath, { base, specifier, isESM } = kEmptyObject) {
142+
function read(jsonPath, opts) {
143+
if (customRead !== null) {
144+
return customRead(jsonPath, opts, defaultRead);
145+
}
146+
return defaultRead(jsonPath, opts);
147+
}
148+
149+
function defaultRead(jsonPath, { base, specifier, isESM } = kEmptyObject) {
123150
// This function will be called by both CJS and ESM, so we need to make sure
124151
// non-null attributes are converted to strings.
125152
const parsed = modulesBinding.readPackageJSON(
@@ -165,6 +192,13 @@ const deserializedPackageJSONCache = new SafeMap();
165192
* @returns {undefined | DeserializedPackageConfig}
166193
*/
167194
function getNearestParentPackageJSON(checkPath) {
195+
if (customGetNearestParentPackageJSON !== null) {
196+
return customGetNearestParentPackageJSON(checkPath, defaultGetNearestParentPackageJSON);
197+
}
198+
return defaultGetNearestParentPackageJSON(checkPath);
199+
}
200+
201+
function defaultGetNearestParentPackageJSON(checkPath) {
168202
const parentPackageJSONPath = moduleToParentPackageJSONCache.get(checkPath);
169203
if (parentPackageJSONPath !== undefined) {
170204
return deserializedPackageJSONCache.get(parentPackageJSONPath);
@@ -190,6 +224,13 @@ function getNearestParentPackageJSON(checkPath) {
190224
* @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration.
191225
*/
192226
function getPackageScopeConfig(resolved) {
227+
if (customGetPackageScopeConfig !== null) {
228+
return customGetPackageScopeConfig(resolved, defaultGetPackageScopeConfig);
229+
}
230+
return defaultGetPackageScopeConfig(resolved);
231+
}
232+
233+
function defaultGetPackageScopeConfig(resolved) {
193234
const result = modulesBinding.getPackageScopeConfig(`${resolved}`);
194235

195236
if (ArrayIsArray(result)) {
@@ -219,6 +260,13 @@ function getPackageScopeConfig(resolved) {
219260
* @returns {string}
220261
*/
221262
function getPackageType(url) {
263+
if (customGetPackageType !== null) {
264+
return customGetPackageType(url, defaultGetPackageType);
265+
}
266+
return defaultGetPackageType(url);
267+
}
268+
269+
function defaultGetPackageType(url) {
222270
const type = modulesBinding.getPackageType(`${url}`);
223271
return type ?? 'none';
224272
}
@@ -276,11 +324,12 @@ function getPackageJSONURL(specifier, base) {
276324
}
277325
}
278326

327+
const { internalModuleStat } = require('internal/modules/helpers');
279328
let packageJSONUrl = new URL(`./node_modules/${packageName}/package.json`, base);
280329
let packageJSONPath = fileURLToPath(packageJSONUrl);
281330
let lastPath;
282331
do {
283-
const stat = internalFsBinding.internalModuleStat(
332+
const stat = internalModuleStat(
284333
StringPrototypeSlice(packageJSONPath, 0, packageJSONPath.length - 13),
285334
);
286335
// Check for !stat.isDirectory()
@@ -360,4 +409,8 @@ module.exports = {
360409
getPackageType,
361410
getPackageJSONURL,
362411
findPackageJSON,
412+
setCustomRead,
413+
setCustomGetPackageScopeConfig,
414+
setCustomGetPackageType,
415+
setCustomGetNearestParentPackageJSON,
363416
};

lib/internal/vfs/module_hooks.js

Lines changed: 203 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ const {
55
ArrayPrototypePush,
66
ArrayPrototypeSplice,
77
FunctionPrototypeCall,
8+
JSONParse,
9+
StringPrototypeEndsWith,
10+
StringPrototypeLastIndexOf,
811
StringPrototypeStartsWith,
912
} = primordials;
1013

@@ -336,17 +339,61 @@ function findVFSForWatch(filename) {
336339
return null;
337340
}
338341

342+
/**
343+
* Checks whether any active VFS could potentially handle the given path.
344+
* Used for early exit on non-VFS paths to avoid unnecessary JS walks.
345+
* @param {string} filePath The absolute file path to check
346+
* @returns {boolean}
347+
*/
348+
function anyVFSCouldHandle(filePath) {
349+
const normalized = normalizeVFSPath(filePath);
350+
for (let i = 0; i < activeVFSList.length; i++) {
351+
if (activeVFSList[i].shouldHandle(normalized)) {
352+
return true;
353+
}
354+
}
355+
return false;
356+
}
357+
358+
/**
359+
* Parse VFS package.json content into the flat PackageConfig format
360+
* returned by read() (not the nested DeserializedPackageConfig format).
361+
* @param {string} jsonPath Path to the package.json
362+
* @param {string|Buffer} content Raw file content
363+
* @returns {object} PackageConfig with { name, main, type, exports, imports, exists, pjsonPath }
364+
*/
365+
function parseVFSPackageJSON(jsonPath, content) {
366+
const str = typeof content === 'string' ? content : content.toString('utf8');
367+
const parsed = JSONParse(str);
368+
let type = 'none';
369+
if (parsed.type === 'commonjs' || parsed.type === 'module') {
370+
type = parsed.type;
371+
}
372+
return {
373+
__proto__: null,
374+
...(parsed.name != null && { name: parsed.name }),
375+
...(parsed.main != null && { main: parsed.main }),
376+
type,
377+
...(parsed.exports != null && { exports: parsed.exports }),
378+
...(parsed.imports != null && { imports: parsed.imports }),
379+
exists: true,
380+
pjsonPath: jsonPath,
381+
};
382+
}
383+
339384
/**
340385
* Determine module format from file extension.
341-
* Uses the shared extensionFormatMap, falling back to commonjs for .js
342-
* and unknown extensions since VFS does not check package.json "type".
386+
* Uses the shared extensionFormatMap. For .js files, checks the package.json
387+
* "type" field via getPackageType (which is now VFS-aware).
343388
* @param {string} filePath The file path
344389
* @returns {string} The format ('module', 'commonjs', 'json', etc.)
345390
*/
346391
function getFormatFromExtension(filePath) {
347392
const ext = extname(filePath);
348393
if (ext === '.js') {
349-
// TODO: Check package.json "type" field for proper detection
394+
const { getPackageType } = require('internal/modules/package_json_reader');
395+
const type = getPackageType(pathToFileURL(filePath));
396+
if (type === 'module') return 'module';
350397
return 'commonjs';
351398
}
352399
return extensionFormatMap[ext] ?? 'commonjs';
@@ -487,7 +534,16 @@ function installModuleHooks() {
487534
setCustomToRealPath,
488535
setCustomReadFileSync,
489536
setCustomInternalModuleStat,
537+
internalModuleStat,
490538
} = require('internal/modules/helpers');
539+
const {
540+
read: readPackageJSON,
541+
getPackageScopeConfig,
542+
setCustomRead,
543+
setCustomGetPackageScopeConfig,
544+
setCustomGetPackageType,
545+
setCustomGetNearestParentPackageJSON,
546+
} = require('internal/modules/package_json_reader');
491547

492548
// Save original Module._stat
493549
originalStat = Module._stat;
@@ -539,6 +595,150 @@ function installModuleHooks() {
539595
return defaultFn(path);
540596
});
541597

598+
// Set VFS-aware package.json read override.
599+
// The C++ readPackageJSON binding reads from disk via libuv, bypassing VFS.
600+
// This override intercepts package.json reads for paths under VFS mounts.
601+
setCustomRead(function vfsAwareRead(jsonPath, opts, defaultFn) {
602+
if (!anyVFSCouldHandle(jsonPath)) {
603+
return defaultFn(jsonPath, opts);
604+
}
605+
// Check if the file exists in VFS via stat (avoids ENOENT throw from findVFSForRead)
606+
const statResult = findVFSForStat(jsonPath);
607+
if (statResult !== null) {
608+
if (statResult.result === 0) {
609+
// File exists -read and parse it
610+
const vfsResult = findVFSForRead(jsonPath, 'utf8');
611+
if (vfsResult !== null) {
612+
return parseVFSPackageJSON(jsonPath, vfsResult.content);
613+
}
614+
}
615+
// Path is handled by VFS but file doesn't exist -return not-found
616+
return {
617+
__proto__: null,
618+
exists: false,
619+
pjsonPath: jsonPath,
620+
type: 'none',
621+
};
622+
}
623+
return defaultFn(jsonPath, opts);
624+
});
625+
626+
// Set VFS-aware getPackageScopeConfig override.
627+
// The C++ implementation walks upward from a file URL to find the nearest
628+
// package.json. This JS reimplementation does the same walk for VFS paths.
629+
setCustomGetPackageScopeConfig(function vfsAwareGetPackageScopeConfig(resolved, defaultFn) {
630+
let resolvedPath;
631+
try {
632+
resolvedPath = fileURLToPath(`${resolved}`);
633+
} catch {
634+
return defaultFn(resolved);
635+
}
636+
637+
if (!anyVFSCouldHandle(resolvedPath)) {
638+
return defaultFn(resolved);
639+
}
640+
641+
// Walk upward from the resolved path, checking for package.json at each level.
642+
// This mirrors the C++ GetPackageScopeConfig logic.
643+
let currentDir = dirname(resolvedPath);
644+
let lastDir;
645+
646+
while (currentDir !== lastDir) {
647+
// Stop at node_modules boundaries
648+
if (StringPrototypeEndsWith(currentDir, '/node_modules') ||
649+
StringPrototypeEndsWith(currentDir, '\\node_modules')) {
650+
break;
651+
}
652+
653+
const pjsonPath = resolve(currentDir, 'package.json');
654+
const result = readPackageJSON(pjsonPath);
655+
if (result.exists) {
656+
return result;
657+
}
658+
659+
lastDir = currentDir;
660+
currentDir = dirname(currentDir);
661+
}
662+
663+
// No package.json found -return not-found result
664+
return {
665+
__proto__: null,
666+
pjsonPath: resolve(dirname(resolvedPath), 'package.json'),
667+
exists: false,
668+
type: 'none',
669+
};
670+
});
671+
672+
// Set VFS-aware getPackageType override.
673+
// Delegates to getPackageScopeConfig (which is already toggled).
674+
setCustomGetPackageType(function vfsAwareGetPackageType(url, defaultFn) {
675+
let urlPath;
676+
try {
677+
urlPath = fileURLToPath(`${url}`);
678+
} catch {
679+
return defaultFn(url);
680+
}
681+
682+
if (!anyVFSCouldHandle(urlPath)) {
683+
return defaultFn(url);
684+
}
685+
686+
const config = getPackageScopeConfig(url);
687+
return config.type ?? 'none';
688+
});
689+
690+
// Set VFS-aware getNearestParentPackageJSON override.
691+
// The C++ TraverseParent walks upward from a file path (not URL) to find
692+
// the nearest package.json. Returns DeserializedPackageConfig format:
693+
// { data: { name, main, type, exports, imports }, exists, path }.
694+
setCustomGetNearestParentPackageJSON(function vfsAwareGetNearestParent(checkPath, defaultFn) {
695+
if (!anyVFSCouldHandle(checkPath)) {
696+
return defaultFn(checkPath);
697+
}
698+
699+
// Walk upward from dirname(checkPath)
700+
let currentDir = dirname(checkPath);
701+
let lastDir;
702+
703+
while (currentDir !== lastDir) {
704+
// Stop at node_modules boundaries (matches C++ TraverseParent)
705+
const basename = currentDir.substring(
706+
StringPrototypeLastIndexOf(currentDir, '/') + 1,
707+
);
708+
if (basename === 'node_modules') {
709+
return undefined;
710+
}
711+
712+
const pjsonPath = resolve(currentDir, 'package.json');
713+
714+
// Check if the package.json file exists via stat
715+
const stat = internalModuleStat(pjsonPath);
716+
if (stat === 0) {
717+
// File exists -read and parse it
718+
const flat = readPackageJSON(pjsonPath);
719+
if (flat.exists) {
720+
// Convert flat PackageConfig to nested DeserializedPackageConfig
721+
const data = { __proto__: null, type: flat.type ?? 'none' };
722+
if (flat.name != null) data.name = flat.name;
723+
if (flat.main != null) data.main = flat.main;
724+
if (flat.exports != null) data.exports = flat.exports;
725+
if (flat.imports != null) data.imports = flat.imports;
726+
727+
return {
728+
data,
729+
exists: true,
730+
path: pjsonPath,
731+
};
732+
}
733+
}
734+
735+
lastDir = currentDir;
736+
currentDir = dirname(currentDir);
737+
}
738+
739+
return undefined;
740+
});
741+
542742
// Register ESM hooks using Module.registerHooks
543743
Module.registerHooks({
544744
resolve: vfsResolveHook,

test/fixtures/sea/vfs/sea-config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
"modules/math.js": "math.js",
99
"modules/calculator.js": "calculator.js",
1010
"node_modules/test-pkg/package.json": "test-pkg-package.json",
11-
"node_modules/test-pkg/index.js": "test-pkg-index.js"
11+
"node_modules/test-pkg/index.js": "test-pkg-index.js",
12+
"node_modules/test-exports-pkg/package.json": "test-exports-pkg-package.json",
13+
"node_modules/test-exports-pkg/lib/entry.js": "test-exports-pkg-entry.js"
1214
}
1315
}

test/fixtures/sea/vfs/sea.js

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,17 @@ assert.strictEqual(buf2[0], original, 'buf2 should be unaffected by buf1 mutatio
6464
assert.strictEqual(buf1[0], 0xFF, 'buf1 mutation should persist');
6565
console.log('buffer independence test passed');
6666

67-
// TODO(mcollina): The CJS module loader reads package.json via C++ binding
68-
// (internalBinding('modules').readPackageJSON), which doesn't go through VFS.
69-
// This means "exports" conditions in package.json won't work for VFS packages.
70-
// The test below works because "main": "index.js" matches the default fallback.
71-
// A follow-up PR should make the C++ package.json reader VFS-aware.
72-
73-
// Test node_modules package lookup via VFS
67+
// Test node_modules package lookup via VFS (resolved through "exports" field)
7468
const testPkg = require('test-pkg');
7569
assert.strictEqual(testPkg.name, 'test-pkg', 'package name should match');
7670
assert.strictEqual(testPkg.greet('World'), 'Hello, World!', 'package function should work');
7771
console.log('node_modules package lookup test passed');
7872

73+
// Test exports-only package (no "main" field, entry in subdirectory)
74+
// This proves the package.json reader is VFS-aware — without it,
75+
// "exports" would not be consulted and resolution would fail.
76+
const exportsPkg = require('test-exports-pkg');
77+
assert.strictEqual(exportsPkg.fromExports, true, 'exports-only package should resolve');
78+
console.log('exports-only package lookup test passed');
79+
7980
console.log('All SEA VFS tests passed!');

0 commit comments

Comments
 (0)