module: expose `getPackageJSON` utility by JakobJingleheimer · Pull Request #55229 · nodejs/node · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
48ab696
module: expose `getPackageJSON` utility
JakobJingleheimer Sep 30, 2024
dcddd1e
fixup: convert `path_value` to string before fn casting to path
JakobJingleheimer Oct 1, 2024
98c72f4
fixup: remove obsolete reference to `findNearestPackageJSON`
JakobJingleheimer Oct 1, 2024
d4ede54
fixup: add some tests cases
JakobJingleheimer Oct 1, 2024
92f2c62
feat: adjust `getNearestParentPackageJSON().path` to pjsonPath
JakobJingleheimer Oct 1, 2024
b83707b
feat: conditionally include non-required fields only when they exist
JakobJingleheimer Oct 1, 2024
e0beefa
fixup: remove unnecessary `JSON.parse()` (it's already parsed)
JakobJingleheimer Oct 1, 2024
6e715ec
test: add more cases
JakobJingleheimer Oct 1, 2024
458dcca
fix: provide missing `pjsonPath` for `GetNearestRawParentPackageJSON`
JakobJingleheimer Oct 1, 2024
903d201
fix: correct internal bindings type decs
JakobJingleheimer Oct 1, 2024
b567e9a
fixup: remove unnecessary string manipulation
JakobJingleheimer Oct 1, 2024
ed4db64
fixup: de-lint
JakobJingleheimer Oct 1, 2024
9a4b1d1
this is awful. why does anyone want this lint rule
JakobJingleheimer Oct 1, 2024
c23c8f1
fixup: correct yaml version needle
JakobJingleheimer Oct 2, 2024
c865d55
fixup: americanize name
JakobJingleheimer Oct 2, 2024
a9bf393
fixup: mangle md
JakobJingleheimer Oct 3, 2024
190e315
fixup: mangle c++ code
JakobJingleheimer Oct 3, 2024
a9e3ded
fixup: specific → nondescript to satisfy linting
JakobJingleheimer Oct 4, 2024
926b2b0
fixup: correct return dec in md
JakobJingleheimer Oct 5, 2024
485b1e3
fixup: remove temp fields & correct type decs
JakobJingleheimer Oct 5, 2024
f824ce9
fixup: wordsmith doc & expand examples
JakobJingleheimer Oct 5, 2024
7deeb7f
fixup: leverage validate* utils
JakobJingleheimer Oct 5, 2024
cf49f71
fixup: `ToString` → `ToStringView `
JakobJingleheimer Oct 5, 2024
358486c
fixup: support file URL strings, update arg name, add test case
JakobJingleheimer Oct 5, 2024
d9bdf23
fixup: damn this limited docs types validator
JakobJingleheimer Oct 5, 2024
bf5265d
fixup: require url → internal/url
JakobJingleheimer Oct 5, 2024
3e1cda8
fixup: pjson.name is optional
JakobJingleheimer Oct 6, 2024
da29815
fixup: remove unnecessary cost for default value object instantiation
JakobJingleheimer Oct 6, 2024
b531497
fixup: throw on relative `statLocation` with remediation instructions
JakobJingleheimer Oct 6, 2024
eef2b60
fixup: add test-case for require() resolving via package.json
JakobJingleheimer Oct 7, 2024
395cfa6
fixup: update consumption of `readPackage` & `read()`
JakobJingleheimer Oct 7, 2024
841848f
Revert "fixup: update consumption of `readPackage` & `read()`"
JakobJingleheimer Oct 8, 2024
945c207
fixus: restore return signature of jpsonReader.read
JakobJingleheimer Oct 8, 2024
4adac4c
fixup: null proto for read return
JakobJingleheimer Oct 9, 2024
0064379
fixup: `!== null` → `!= null`
JakobJingleheimer Oct 9, 2024
eed739c
fixup: spread with `__proto__: null`
JakobJingleheimer Oct 9, 2024
864b98a
fixup: add `exists` to `DeserializedPackageConfig`
JakobJingleheimer Oct 9, 2024
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
42 changes: 42 additions & 0 deletions doc/api/module.md
22 changes: 11 additions & 11 deletions lib/internal/modules/cjs/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -604,11 +604,11 @@ function trySelf(parentPath, request) {
try {
const { packageExportsResolve } = require('internal/modules/esm/resolve');
return finalizeEsmResolution(packageExportsResolve(
pathToFileURL(pkg.path + '/package.json'), expansion, pkg.data,
pathToFileURL(pkg.path), expansion, pkg.data,
pathToFileURL(parentPath), getCjsConditions()), parentPath, pkg.path);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw createEsmNotFoundErr(request, pkg.path + '/package.json');
throw createEsmNotFoundErr(request, pkg.path);
}
throw e;
}
Expand Down Expand Up @@ -1214,14 +1214,15 @@ Module._resolveFilename = function(request, parent, isMain, options) {

if (request[0] === '#' && (parent?.filename || parent?.id === '<repl>')) {
const parentPath = parent?.filename ?? process.cwd() + path.sep;
const pkg = packageJsonReader.getNearestParentPackageJSON(parentPath) || { __proto__: null };
if (pkg.data?.imports != null) {
const pkg = packageJsonReader.getNearestParentPackageJSON(parentPath);
if (pkg?.data.imports != null) {
try {
const { packageImportsResolve } = require('internal/modules/esm/resolve');
return finalizeEsmResolution(
packageImportsResolve(request, pathToFileURL(parentPath),
getCjsConditions()), parentPath,
pkg.path);
packageImportsResolve(request, pathToFileURL(parentPath), getCjsConditions()),
parentPath,
pkg.path,
);
} catch (e) {
if (e.code === 'ERR_MODULE_NOT_FOUND') {
throw createEsmNotFoundErr(request);
Expand Down Expand Up @@ -1281,8 +1282,7 @@ function finalizeEsmResolution(resolved, parentPath, pkgPath) {
if (actual) {
return actual;
}
const err = createEsmNotFoundErr(filename,
path.resolve(pkgPath, 'package.json'));
const err = createEsmNotFoundErr(filename, pkgPath);
throw err;
}

Expand Down Expand Up @@ -1614,7 +1614,7 @@ function loadTS(module, filename) {

const parent = module[kModuleParent];
const parentPath = parent?.filename;
const packageJsonPath = path.resolve(pkg.path, 'package.json');
const packageJsonPath = pkg.path;
const usesEsm = containsModuleSyntax(content, filename);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath,
packageJsonPath);
Expand Down Expand Up @@ -1673,7 +1673,7 @@ Module._extensions['.js'] = function(module, filename) {
// This is an error path because `require` of a `.js` file in a `"type": "module"` scope is not allowed.
const parent = module[kModuleParent];
const parentPath = parent?.filename;
const packageJsonPath = path.resolve(pkg.path, 'package.json');
const packageJsonPath = pkg.path;
const usesEsm = containsModuleSyntax(content, filename);
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath,
packageJsonPath);
Expand Down
2 changes: 1 addition & 1 deletion lib/internal/modules/esm/resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ const legacyMainResolveExtensionsIndexes = {
* 4. TRY(pkg_url/index.js, pkg_url/index.json, pkg_url/index.node)
* 5. NOT_FOUND
* @param {URL} packageJSONUrl
* @param {import('typings/internalBinding/modules').PackageConfig} packageConfig
* @param {import('typings/internalBinding/modules').RecognizedPackageConfig} packageConfig
* @param {string | URL | undefined} base
* @returns {URL}
*/
Expand Down
167 changes: 117 additions & 50 deletions lib/internal/modules/package_json_reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,31 +2,49 @@

const {
ArrayIsArray,
ArrayPrototypeJoin,
JSONParse,
ObjectDefineProperty,
StringPrototypeLastIndexOf,
StringPrototypeSlice,
StringPrototypeEndsWith,
} = primordials;
const {
codes: {
ERR_INVALID_ARG_VALUE,
},
} = require('internal/errors');
const modulesBinding = internalBinding('modules');
const { resolve, sep } = require('path');
const path = require('path');
const { kEmptyObject } = require('internal/util');
const { fileURLToPath, URL } = require('internal/url');
const {
validateBoolean,
validateString,
} = require('internal/validators');

/**
* @typedef {import('typings/internalBinding/modules').DeserializedPackageConfig} DeserializedPackageConfig
* @typedef {import('typings/internalBinding/modules').FullPackageConfig} FullPackageConfig
* @typedef {import('typings/internalBinding/modules').RecognizedPackageConfig} RecognizedPackageConfig
* @typedef {import('typings/internalBinding/modules').SerializedPackageConfig} SerializedPackageConfig
*/

/**
* @param {string} path
* @param {import('typings/internalBinding/modules').SerializedPackageConfig} contents
* @returns {import('typings/internalBinding/modules').PackageConfig}
* @param {SerializedPackageConfig} contents
* @returns {DeserializedPackageConfig}
*/
function deserializePackageJSON(path, contents) {
if (contents === undefined) {
return {
__proto__: null,
Comment thread
JakobJingleheimer marked this conversation as resolved.
data: {
__proto__: null,
type: 'none', // Ignore unknown types for forwards compatibility
},
exists: false,
pjsonPath: path,
type: 'none', // Ignore unknown types for forwards compatibility
path,
};
}

let pjsonPath = path;
const {
0: name,
1: main,
Expand All @@ -37,36 +55,40 @@ function deserializePackageJSON(path, contents) {
} = contents;

// This is required to be used in getPackageScopeConfig.
if (optionalFilePath) {
pjsonPath = optionalFilePath;
}

// The imports and exports fields can be either undefined or a string.
// - If it's a string, it's either plain string or a stringified JSON string.
// - If it's a stringified JSON string, it starts with either '[' or '{'.
const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' || value[0] === '{'));
const pjsonPath = optionalFilePath ?? path;

return {
__proto__: null,
Comment thread
JakobJingleheimer marked this conversation as resolved.
exists: true,
pjsonPath,
name,
main,
type,
// This getters are used to lazily parse the imports and exports fields.
get imports() {
const value = requiresJSONParse(plainImports) ? JSONParse(plainImports) : plainImports;
ObjectDefineProperty(this, 'imports', { __proto__: null, value });
return this.imports;
},
get exports() {
const value = requiresJSONParse(plainExports) ? JSONParse(plainExports) : plainExports;
ObjectDefineProperty(this, 'exports', { __proto__: null, value });
return this.exports;
data: {
__proto__: null,
...(name != null && { __proto__: null, name }),
...(main != null && { __proto__: null, main }),
...(type != null && { __proto__: null, type }),
Comment on lines +63 to +65

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null prototype is not useful here, spread only copies own properties

Suggested change
...(name != null && { __proto__: null, name }),
...(main != null && { __proto__: null, main }),
...(type != null && { __proto__: null, type }),
...(name != null && { name }),
...(main != null && { main }),
...(type != null && { type }),

...(plainImports != null && {
// This getters are used to lazily parse the imports and exports fields.
get imports() {
const value = requiresJSONParse(plainImports) ? JSONParse(plainImports) : plainImports;
ObjectDefineProperty(this, 'imports', { __proto__: null, value });
return this.imports;
},
}),
...(plainExports != null && {
get exports() {
const value = requiresJSONParse(plainExports) ? JSONParse(plainExports) : plainExports;
ObjectDefineProperty(this, 'exports', { __proto__: null, value });
return this.exports;
},
}),
},
exists: true,
path: pjsonPath,
};
}

// The imports and exports fields can be either undefined or a string.
// - If it's a string, it's either plain string or a stringified JSON string.
// - If it's a stringified JSON string, it starts with either '[' or '{'.
const requiresJSONParse = (value) => (value !== undefined && (value[0] === '[' || value[0] === '{'));

/**
* Reads a package.json file and returns the parsed contents.
* @param {string} jsonPath
Expand All @@ -75,7 +97,7 @@ function deserializePackageJSON(path, contents) {
* specifier?: URL | string,
* isESM?: boolean,
* }} options
* @returns {import('typings/internalBinding/modules').PackageConfig}
* @returns {RecognizedPackageConfig}
*/
function read(jsonPath, { base, specifier, isESM } = kEmptyObject) {
// This function will be called by both CJS and ESM, so we need to make sure
Expand All @@ -87,52 +109,97 @@ function read(jsonPath, { base, specifier, isESM } = kEmptyObject) {
specifier == null ? undefined : `${specifier}`,
);

return deserializePackageJSON(jsonPath, parsed);
const result = deserializePackageJSON(jsonPath, parsed);

return {
__proto__: null,
...result.data,
Comment thread
JakobJingleheimer marked this conversation as resolved.
exists: result.exists,
pjsonPath: result.path,
};
}

/**
* @deprecated Expected to be removed in favor of `read` in the future.
* Behaves the same was as `read`, but appends package.json to the path.
* @param {string} requestPath
* @return {PackageConfig}
* @return {RecognizedPackageConfig}
*/
function readPackage(requestPath) {
// TODO(@anonrig): Remove this function.
return read(resolve(requestPath, 'package.json'));
return read(path.resolve(requestPath, 'package.json'));
}

/**
* Get the nearest parent package.json file from a given path.
* Return the package.json data and the path to the package.json file, or undefined.
* @param {string} checkPath The path to start searching from.
* @returns {undefined | {data: import('typings/internalBinding/modules').PackageConfig, path: string}}
* @param {URL['href'] | URL['pathname']} startLocation The path to start searching from.
* @param {boolean} everything Whether to include the full contents of the package.json.
* @returns {undefined | DeserializedPackageConfig<everything>}
*/
function getNearestParentPackageJSON(checkPath) {
const result = modulesBinding.getNearestParentPackageJSON(checkPath);
function getNearestParentPackageJSON(startLocation, everything = false) {
let startPath = URL.canParse(startLocation) ? fileURLToPath(startLocation) : startLocation;

validateString(startPath, 'startPath');
validateBoolean(everything, 'everything');

if (!path.isAbsolute(startPath)) {
throw new ERR_INVALID_ARG_VALUE(
'startLocation',
startLocation,
ArrayPrototypeJoin([
'must be a fully resolved location. To use a relative location, first wrap with',
'`import.meta.resolve(startLocation)` in ESM',
'or',
'`path.resolve(__dirname, startLocation) in CJS',
], ' '),
);
}

if (result === undefined) {
return undefined;
if (!StringPrototypeEndsWith(startPath, path.sep)) { startPath += path.sep; }

if (everything) {
const result = modulesBinding.getNearestRawParentPackageJSON(startPath);
Comment thread
JakobJingleheimer marked this conversation as resolved.

return {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this also need to be null prototyped

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aduh95 please advise 🙏

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no, it's user-facing

data: {
__proto__: null,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having null-prototype object returned to the object feels weird

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I did it for consistency between everything (not everything is used internally and needs the null prototype).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That feels like another reason not to expose everything and let the user parse the JSON themselves. We should be freezing it to avoid folks mutating it – but really, why do we bother? The API would be much simpler if it only returns a path

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason is because we've already done the work, and handled the caching. Doing it this way also provides better performance. Users having to do exactly the same thing is the whole reason we as engineers create utilities, and there is basically no chance any user would not subsequently parse it.

Freezing seems like a good idea.

...JSONParse(result?.[0]),
},
exists: true,
path: result?.[1],
};
}

const data = deserializePackageJSON(checkPath, result);
const result = modulesBinding.getNearestParentPackageJSON(startPath);

// Path should be the root folder of the matched package.json
// For example for ~/path/package.json, it should be ~/path

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: Why does this statement doesn't hold anymore?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const path = StringPrototypeSlice(data.pjsonPath, 0, StringPrototypeLastIndexOf(data.pjsonPath, sep));
if (result === undefined) {
return undefined;
}

return { data, path };
return deserializePackageJSON(startPath, result);
}

/**
* Returns the package configuration for the given resolved URL.
* @param {URL | string} resolved - The resolved URL.
* @returns {import('typings/internalBinding/modules').PackageConfig} - The package configuration.
* @returns {RecognizedPackageConfig & {
* exists: boolean,
* pjsonPath: DeserializedPackageConfig['path'],
* }} - The package configuration.
*/
function getPackageScopeConfig(resolved) {
const result = modulesBinding.getPackageScopeConfig(`${resolved}`);

if (ArrayIsArray(result)) {
return deserializePackageJSON(`${resolved}`, result);
const { data, exists, path } = deserializePackageJSON(`${resolved}`, result);

return {
__proto__: null,
...data,
exists,
pjsonPath: path,
};
}

// This means that the response is a string
Expand Down
4 changes: 4 additions & 0 deletions lib/module.js
Loading