npm
Version:
a package manager for JavaScript
238 lines (220 loc) • 10.2 kB
JavaScript
// Root-owned `.npm-extension.{mjs,cjs}`: an imperative `transformManifest(pkg, context)` extension point that repairs third-party manifests before Arborist reads a candidate's dependency edges.
// See RFC: https://github.com/npm/rfcs/pull/903
// This module discovers and hashes the root extension file, loads its `transformManifest` export, and applies it to a deeply isolated manifest copy, returning an extended manifest plus minimal provenance.
// It never mutates the input manifest or any shared cache object.
const { resolve, sep } = require('node:path')
const { readFileSync, existsSync } = require('node:fs')
const { pathToFileURL } = require('node:url')
const { isDeepStrictEqual } = require('node:util')
const { log } = require('proc-log')
const ssri = require('ssri')
const { EXTENSION_FIELDS } = require('./package-extensions.js')
const EXTENSION_POINT = 'transformManifest'
// The two supported module formats and their default discovery filenames.
const FORMATS = [
{ ext: 'mjs', file: '.npm-extension.mjs' },
{ ext: 'cjs', file: '.npm-extension.cjs' },
]
const err = (message, code, extra = {}) =>
Object.assign(new Error(message), { code, ...extra })
// Read a file's bytes, or null when it does not exist.
const readBytes = path => {
try {
return readFileSync(path)
} catch (e) {
if (e.code === 'ENOENT') {
return null
}
throw e
}
}
// Resolve which extension file to load, returning { path, format, bytes } or null when none is present.
// A configured `extension-file` wins over default discovery; it must resolve inside the project root and use a `.mjs` or `.cjs` extension.
// When both default files exist npm fails rather than choosing one implicitly.
const discover = (root, extensionFile) => {
if (extensionFile) {
const path = resolve(root, extensionFile)
if (path !== root && !path.startsWith(root + sep)) {
throw err(`extension-file "${extensionFile}" must resolve inside the project root`, 'ENPMEXTENSIONPATH')
}
const format = FORMATS.find(f => path.endsWith(`.${f.ext}`))?.ext
if (!format) {
throw err(`extension-file "${extensionFile}" must use a .mjs or .cjs extension`, 'ENPMEXTENSIONPATH')
}
const bytes = readBytes(path)
if (bytes === null) {
throw err(`extension-file "${extensionFile}" was not found`, 'ENPMEXTENSIONPATH')
}
return { path, format, bytes }
}
const found = FORMATS
.map(f => ({ path: resolve(root, f.file), format: f.ext, bytes: readBytes(resolve(root, f.file)) }))
.filter(f => f.bytes !== null)
if (found.length > 1) {
throw err('found both .npm-extension.mjs and .npm-extension.cjs; keep only one', 'ENPMEXTENSIONDUP')
}
return found[0] || null
}
// Hash the selected extension file: a format-tagged prefix plus the raw file bytes, using npm's lockfile digest encoding.
// The prefix keeps `.mjs` and `.cjs` files with identical bytes distinct, and excludes any path so the digest is machine-independent.
const hashFile = (format, bytes) =>
ssri.fromData(
Buffer.concat([Buffer.from(`npm-extension:v1:${format}\n`), bytes]),
{ algorithms: ['sha512'] }
).toString()
class NpmExtension {
#cache = new Map()
#transform = null
constructor ({ root, extensionFile = null } = {}) {
this.root = root
this.path = null
this.format = null
this.hash = null
const selected = root ? discover(root, extensionFile) : null
if (!selected) {
this.present = false
return
}
this.present = true
this.path = selected.path
this.format = selected.format
this.hash = hashFile(selected.format, selected.bytes)
}
// Import the extension module and capture its `transformManifest` export.
// ESM files are loaded with dynamic import; CommonJS files with require.
// The export must be a function; anything else fails the install.
async load () {
if (!this.present) {
return
}
// Key the module load by the file hash so a changed file is reloaded rather than served stale from a module cache within one process.
let mod
if (this.format === 'mjs') {
mod = await import(`${pathToFileURL(this.path).href}?h=${this.hash}`)
} else {
delete require.cache[require.resolve(this.path)]
mod = require(this.path)
}
const transform = mod?.transformManifest ?? mod?.default?.transformManifest
if (typeof transform !== 'function') {
throw err(`.npm-extension must export a transformManifest function`, 'ENPMEXTENSIONSHAPE')
}
this.#transform = transform
}
// Apply transformManifest to a candidate manifest, returning { pkg, applied } or null when nothing changed.
// Results are cached once per resolved package identity; consumers get a deeply isolated copy so they cannot mutate the cached effective manifest.
// Pass { memoize: false } to run without caching, e.g. a staleness probe over partial lockfile manifests that must not seed the cache used by full-manifest fetches.
apply (pkg, { memoize = true } = {}) {
if (!this.#transform || !pkg?.name) {
return null
}
const key = this.#identity(pkg)
let result
if (this.#cache.has(key)) {
result = this.#cache.get(key)
} else {
result = this.#run(pkg)
if (memoize) {
this.#cache.set(key, result)
}
}
return result && { pkg: structuredClone(result.pkg), applied: structuredClone(result.applied) }
}
// Identity key for the transform cache: package integrity when available, otherwise resolved source plus name and version.
#identity (pkg) {
return pkg._integrity || `${pkg._resolved || ''}:${pkg.name}@${pkg.version || ''}`
}
// Run transformManifest on a deeply isolated copy and validate the result.
// Returns { pkg, applied } when dependency or peer metadata changed, or null when it did not.
#run (pkg) {
const context = {
log: message => log.silly('npm-extension', message),
root: this.root,
extensionPoint: EXTENSION_POINT,
}
let returned
try {
returned = this.#transform(structuredClone(pkg), context)
} catch (e) {
throw err(
`.npm-extension transformManifest threw while processing ${pkg.name}@${pkg.version}: ${e.message}`,
'ENPMEXTENSIONTHROW', { pkgid: `${pkg.name}@${pkg.version}` })
}
if (returned && typeof returned.then === 'function') {
throw err(
`.npm-extension transformManifest must return a manifest synchronously, not a promise, for ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` })
}
if (!returned || typeof returned !== 'object' || Array.isArray(returned)) {
throw err(
`.npm-extension transformManifest must return a manifest object for ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONRETURN', { pkgid: `${pkg.name}@${pkg.version}` })
}
// Only dependency and peer fields may change; any other field the returned manifest explicitly alters is rejected.
// Fields the returned object omits are left untouched, so a handler may return a new object with only the fields it repairs.
for (const k of Object.keys(returned)) {
if (!EXTENSION_FIELDS.includes(k) && !isDeepStrictEqual(returned[k], pkg[k])) {
throw err(
`.npm-extension transformManifest changed unsupported field "${k}" on ${pkg.name}@${pkg.version}; only ${EXTENSION_FIELDS.join(', ')} may change`,
'ENPMEXTENSIONFIELD', { pkgid: `${pkg.name}@${pkg.version}`, field: k })
}
}
// Build the effective manifest from the normalized baseline plus the returned allowlisted fields.
// A field the handler omits is left as the baseline; delete individual entries by returning a field object without them.
const next = { ...pkg }
for (const field of EXTENSION_FIELDS) {
if (returned[field] === undefined) {
continue
}
this.#validateField(field, returned[field], pkg)
next[field] = returned[field]
}
const applied = this.#provenance(pkg, next)
return applied && { pkg: next, applied }
}
// Validate a returned allowlisted field and its entries, so invalid output fails with .npm-extension and package context.
// Dependency maps hold version strings; peerDependenciesMeta holds metadata objects.
#validateField (field, value, pkg) {
const fail = (suffix) => err(
`.npm-extension transformManifest set ${suffix} to an invalid value on ${pkg.name}@${pkg.version}`,
'ENPMEXTENSIONVALUE', { pkgid: `${pkg.name}@${pkg.version}`, field })
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw fail(field)
}
for (const [name, entry] of Object.entries(value)) {
if (field === 'peerDependenciesMeta') {
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
throw fail(`${field}.${name}`)
}
} else if (typeof entry !== 'string') {
throw fail(`${field}.${name}`)
}
}
}
// Minimal provenance: the extension point plus, for each changed allowlisted field, a sorted array of affected dependency names.
// Returns null when nothing changed.
#provenance (before, after) {
const applied = { extensionPoint: EXTENSION_POINT }
let changed = false
for (const field of EXTENSION_FIELDS) {
const b = before[field] || {}
const a = after[field] || {}
const names = [...new Set([...Object.keys(b), ...Object.keys(a)])]
.filter(n => !isDeepStrictEqual(b[n], a[n]))
.sort()
if (names.length) {
applied[field] = names
changed = true
}
}
return changed ? applied : null
}
}
// Whether a directory contains any default .npm-extension file; a non-throwing existence check used for non-root workspace warnings.
const hasExtensionFile = dir => FORMATS.some(f => existsSync(resolve(dir, f.file)))
module.exports = NpmExtension
module.exports.NpmExtension = NpmExtension
module.exports.discover = discover
module.exports.hasExtensionFile = hasExtensionFile
module.exports.hashFile = hashFile
module.exports.EXTENSION_POINT = EXTENSION_POINT