Skip to content

RFC: package manifest extensions#889

Merged
owlstronaut merged 3 commits into
npm:mainfrom
manzoorwanijk:rfc-package-manifest-extensions
Jun 18, 2026
Merged

RFC: package manifest extensions#889
owlstronaut merged 3 commits into
npm:mainfrom
manzoorwanijk:rfc-package-manifest-extensions

Conversation

@manzoorwanijk

@manzoorwanijk manzoorwanijk commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an RFC for root-owned packageExtensions, a declarative v1 package metadata repair mechanism for npm installs.

The proposal lets a project add or correct third-party package manifest fields that affect dependency graph construction before Arborist finalizes the ideal tree:

  • dependencies
  • optionalDependencies
  • peerDependencies
  • peerDependenciesMeta

Motivation

install-strategy=linked makes dependency boundaries stricter by avoiding accidental hoisting. That is useful for correctness, but it also exposes packages that import dependencies or type packages they did not declare. Today users often work around those issues by relying on hoisting, adding root dependencies, patching packages after extraction, or maintaining forks. Those options either do not work under linked installs or operate at the wrong phase of installation.

RFC 0042: Isolated mode anticipated this exact problem space:

We may want to later add a feature to npm which allows users to locally declare dependencies on behalf of packages as a stop-gap, if existing solutions to this are not enough.

It also described the older workaround:

If a package is missing a dependency, it can be temporarily fixed [...] by declaring this missing dependency as top level dependency of the repository.

That workaround is not enough for install-strategy=linked, because a root dependency does not become visible inside the isolated dependency boundary of the package that actually imports it.

This RFC proposes a root-only, deterministic way to record small third-party manifest repairs while upstream packages catch up. It intentionally scopes v1 to declarative metadata repairs rather than arbitrary install-time manifest hooks.

Why declarative v1

An imperative hook model, similar to pnpm's .pnpmfile.mjs, could solve a broader class of manifest transformation problems. This RFC proposes the declarative subset first because the linked-install migration cases are mostly small dependency metadata repairs, and a declarative model is easier to validate, lock, audit, explain, and remove once upstream packages are fixed.

Notable semantics

  • Only the root project owns packageExtensions.
  • Workspace package manifests are not extension targets; matching workspace packages warn and are ignored.
  • Selectors match package manifest name and version, not install path.
  • Multiple selectors matching the same package fail rather than merge in order-dependent ways.
  • Extensions are applied to per-ideal-tree manifest metadata copies, not shared pacote or registry cache objects.
  • npm ci validates the canonical extension hash, selector conflicts, and minimal lockfile provenance before trusting locked effective metadata.
  • The installed dependency package.json is not rewritten.
  • Dependency deletion is out of scope for v1.

Prior art

The RFC compares this proposal with pnpm packageExtensions, pnpm .pnpmfile.mjs hooks, Yarn packageExtensions, @yarnpkg/extensions, npm overrides, npm isolated mode, native dependency patching, and install-script policy RFCs.

Tests

Not run. This is a docs-only RFC proposal.

@manzoorwanijk manzoorwanijk requested review from a team as code owners June 2, 2026 14:40
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

@owlstronaut what does the team think about this?

This one is essential for isolated mode to work with packages with phantom dependencies. We are already seeing blockers for our migration to isolated mode in Gutenberg.

I will be more than happy to draft a PR to the CLI repo.

@owlstronaut owlstronaut left a comment

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.

I like the shape of this. A couple comments.

Comment thread accepted/0000-package-manifest-extensions.md Outdated
Comment thread accepted/0000-package-manifest-extensions.md Outdated
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

I like the shape of this. A couple comments.

Thank you for the review @owlstronaut.

On a side note, I would also like to ask a broader design question before going too far down the declarative path: would the npm team be open to adopting an imperative, root-owned manifest hook API, similar to pnpm's .pnpmfile.mjs / .pnpmfile.cjs hooks.readPackage(pkg, context), either instead of or as a later complement to packageExtensions?

The current RFC intentionally proposes a declarative v1 because it is easier to validate, lock, audit, explain, and keep deterministic under npm ci.

An imperative .npmfile.mjs-style API could cover broader transformations, such as deleting dependency entries, conditional metadata repairs, or changes outside the dependency graph, but it would also introduce arbitrary install-time code, lockfile representation questions, reproducibility concerns, and a larger policy surface.

Would maintainers prefer that this RFC stay focused on declarative packageExtensions, or would it be useful to draft a companion RFC for .npmfile.mjs / .npmfile.cjs hooks so the team can compare the two designs directly?

@manzoorwanijk

manzoorwanijk commented Jun 5, 2026

Copy link
Copy Markdown
Contributor Author

Here is the implementation PR npm/cli#9496

The reason for the above question about imperative API, is that the list in the package.json can get too long.

For example, while testing the implementation in Gutenberg, here is what the list looked like 😄

packageExtensions
"packageExtensions": {
	"@ariakit/react-core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@ariakit/test": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/accessibility": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/sortable": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@dnd-kit/utilities": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@emotion/primitives-core": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@emotion/use-insertion-effect-with-fallbacks": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@floating-ui/react-dom": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"@tanstack/react-router": {
		"peerDependencies": {
			"@types/react": "*",
			"@types/react-dom": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			},
			"@types/react-dom": {
				"optional": true
			}
		}
	},
	"@use-gesture/react": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"cmdk": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"framer-motion": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"re-resizable": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-autosize-textarea": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-colorful": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-day-picker": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-easy-crop": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"react-freeze": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"reselect": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	},
	"use-latest-callback": {
		"peerDependencies": {
			"@types/react": "*"
		},
		"peerDependenciesMeta": {
			"@types/react": {
				"optional": true
			}
		}
	}
}

With an imperative API, this could be simplified to a list of dependencies, looped over to fix.

@manzoorwanijk manzoorwanijk force-pushed the rfc-package-manifest-extensions branch from 4e4daf7 to 463cd08 Compare June 5, 2026 14:34
@owlstronaut

owlstronaut commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

@manzoorwanijk for a package the size of Gutenberg only having that size of a list, it makes me like the declarative even more. The package.json is ugly, but that's among the worst it'll get and is a lot safer. We can always revisit

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

@manzoorwanijk for a package the size of Gutenberg only having that size of a list, it makes me like the declarative even more. The package.json is ugly, but that's among the worst it'll get and is a lot safer. We can always revisit

True. We can revisit this later.

Some benefits of an imperative API are that you can:

  • Explain the reason in comments
  • Link to open issues on target repos
  • Make conditional changes
  • Read the existing dependency versions etc.

@ljharb

ljharb commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

If this is going to go in package.json, then it probably shouldn't work in packages that don't have private:true, since it'll greatly inflate the size of the packument.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

If this is going to go in package.json, then it probably shouldn't work in packages that don't have private:true, since it'll greatly inflate the size of the packument.

Good point. Since packageExtensions is root-only and ignored when it appears in dependencies, publishing it does not make sense.

There is precedent from the native dependency patching work: patchedDependencies is root-only, so it is stripped from the published manifest and from the packed tarball's package.json in npm/pacote#497.

Would it be acceptable for this RFC to require the same publishing behavior for packageExtensions, either by failing npm publish for non-private packages that contain the field or by stripping the field during pack/publish?

That would let public packages use packageExtensions locally for their own CI and isolated-mode migrations, while preventing the field from inflating registry metadata or appearing in consumers' packuments.

@ljharb

ljharb commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Failing seems better, since it more firmly establishes that it's only for apps.

If we want packages to use it, I think it needs to go somewhere other than package.json.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Failing seems better, since it more firmly establishes that it's only for apps.

If we want packages to use it, I think it needs to go somewhere other than package.json.

One concern with failing npm publish for non-private packages that contain packageExtensions: what should happen for a solo public package repo that uses packageExtensions only for its own local install, CI, or isolated-mode migration?

For example, a public library may have a single root package.json, publish that package to npm, and still need packageExtensions locally to repair third-party dependency metadata during its own build or tests.

Should this RFC intentionally make that unsupported because packageExtensions is application-only policy, or should npm allow that use case by keeping packageExtensions out of published artifacts instead of failing publish?

@ljharb

ljharb commented Jun 7, 2026

Copy link
Copy Markdown
Contributor

If that's a use case we want to support, then imo we must provide a non-package.json place to store this config.

Until that time, that use case is simply not permitted.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

If that's a use case we want to support, then imo we must provide a non-package.json place to store this config.

Until that time, that use case is simply not permitted.

Then in that case, the imperative API seems more compelling.

@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Since the imperative API needs its own RFC (IMHO), for this RFC, we can simply err in case a non-private package contains packageExtensions.

@ljharb

ljharb commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

That's definitely the most futureproof approach - even if we decide to simply remove the restriction in the future.

Adding features for private packages only is FAR FAR less dangerous than adding any feature for published packages.

manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Jun 8, 2026
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

Alright, the implementation PR is also updated with the same - npm/cli@8803bd7

manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Jun 15, 2026
manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Jun 18, 2026
@manzoorwanijk manzoorwanijk force-pushed the rfc-package-manifest-extensions branch from 50520f9 to 1c42023 Compare June 18, 2026 16:12
@owlstronaut owlstronaut merged commit cd27a6c into npm:main Jun 18, 2026
7 checks passed
@manzoorwanijk manzoorwanijk deleted the rfc-package-manifest-extensions branch June 18, 2026 16:28
manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Jun 18, 2026
manzoorwanijk added a commit to manzoorwanijk/npm-cli that referenced this pull request Jun 18, 2026
owlstronaut pushed a commit to npm/cli that referenced this pull request Jun 18, 2026
…9496)

Implements package manifest extensions per [RFC
#889](npm/rfcs#889): a root-only
`packageExtensions` field in `package.json` that applies declarative
repairs to third-party dependency manifests **before** Arborist
finalizes the ideal tree. It lets a project add missing
`dependencies`/`optionalDependencies`, add or correct
`peerDependencies`, and mark peers optional via `peerDependenciesMeta`,
without forking and republishing a package.

```json
{
  "packageExtensions": {
    "broken-package@1": {
      "dependencies": { "missing-runtime-dep": "^2.0.0" }
    },
    "typescript-plugin@4.3.0": {
      "peerDependencies": { "typescript": ">=5" },
      "peerDependenciesMeta": { "typescript": { "optional": true } }
    }
  }
}
```

## Why

`install-strategy=linked` gives installs strong package boundaries,
which is also what makes adoption hard: a package only sees what it
actually declared, so one that worked under a hoisted layout because a
dependency happened to be hoisted above it can fail. A root-level
dependency masks this under hoisting but does not make the package
available inside the isolated boundary of the importer — the repair has
to be attached to the broken package's manifest before its edges are
resolved. This is the pre-resolution complement to `overrides` (which
needs an existing edge to retarget) and to [native dependency patching
#9439](#9439) (which edits package
contents after resolution).

## The field

Each key is a package selector: a name with an optional semver range
(`foo`, `foo@1`, `@scope/foo@^2.3.0`). Selectors match a candidate's own
manifest `name`/`version` (the underlying name for aliases) and reject
dist-tag, git, file, URL, and `npm:` specs. At most one selector may
match a candidate. Honored only in the root `package.json` (the
workspace root); the field in dependencies and non-root workspaces, and
selectors matching a workspace member, are ignored with a warning —
matching the root-authority model of `overrides`.

## Merge semantics

Only the four resolution-affecting fields may be extended.

- `dependencies`/`optionalDependencies` add a missing name only;
providing a name already declared in either field is an error (use
`overrides` to change a version), which also forbids moving a name
between the two.
- `peerDependencies` shallow-merges by name, replacing an existing
range.
- `peerDependenciesMeta` merges by name then key (e.g. add `optional:
true`); every meta entry must have a corresponding `peerDependencies`
entry.
- Deletion (`null`/`false`/`"-"`) is not supported.

The extension applies to a per-tree manifest copy: the shared
pacote/cache manifest is never mutated, the installed
`node_modules/<pkg>/package.json` is not rewritten, and
`bundleDependencies` is unchanged. `overrides` still controls the final
resolution target of an extension-created edge.

## Lockfile

The root entry stores a canonical `packageExtensionsHash`, and each
affected entry stores minimal provenance (`packageExtensionsApplied`);
effective dependency metadata is recorded as usual. Extension state
forces `lockfileVersion: 4` so older npm clients abort rather than
silently dropping the repaired graph. `npm install` re-resolves affected
packages when the rule set changes; `npm ci` validates the hash,
selector conflicts, and stale provenance before trusting the locked
metadata.

## Visibility

`npm explain` appends `(added by
packageExtensions["foo@1"].dependencies.bar)` to the edge; `npm ls`
annotates the node and `npm ls --json` includes
`packageExtensionsApplied`. Publishing a non-private package containing
the field warns that it does not affect consumers.

## Notes

- `lockfileVersion: 4` is shared with native dependency patching
([#9439](#9439)) as a common "older npm
must not silently drop this" tripwire; both bump only when their own
state is present. Whichever lands second should reuse the same
`maxLockfileVersion`/bump constants rather than introduce a competing
version.
- Opt-in and additive, so it can ship in a minor release.

## References

Implements npm/rfcs#889
@manzoorwanijk

Copy link
Copy Markdown
Contributor Author

I have created an RFC for the imperative API - #903

owlstronaut pushed a commit that referenced this pull request Jun 19, 2026
## Summary

Adds an RFC for a root-owned `.npm-extension.mjs` / `.npm-extension.cjs`
file with a top-level `transformManifest(pkg, context)` extension point.

The proposal lets a project imperatively repair third-party package
manifests before Arborist reads dependency and peer edges. It builds on
the accepted [Package Manifest Extensions
proposal](#889) and its [npm CLI
implementation](npm/cli#9496), which established
the pre-resolution manifest repair phase, root-only authority model,
lockfile visibility model, and publish isolation for local dependency
metadata repairs.

`packageExtensions` remains the safer declarative default for common
metadata repairs. `.npm-extension` covers the cases where projects need
comments, upstream issue links, repeated transformations, conditional
logic, reading existing manifest values, deletion, dependency-range
replacement, or a local policy file that does not live in publishable
`package.json`.

## Motivation

`install-strategy=linked` makes dependency boundaries stricter by
avoiding accidental hoisting. That is useful for correctness, but it
exposes packages that import dependencies or type packages they did not
declare. The accepted `packageExtensions` RFC handles the most common
form of this problem: small deterministic additions to dependency and
peer metadata.

Some repairs are harder to keep clear as declarative JSON. During the
[Package Manifest Extensions proposal
discussion](#889 (comment)),
a Gutenberg migration example repeated the same optional `@types/react`
peer repair across many React-related dependencies. The declarative form
worked, but the underlying policy was really a named list or predicate:
"these packages import React types but do not declare an optional
`@types/react` peer."

Other local repairs need conditional logic, such as adding a type
package only when a matching runtime peer exists, copying an existing
peer range into a type dependency, narrowing a known bad peer range, or
throwing when upstream has fixed metadata and a local repair should be
removed. `packageExtensions` intentionally does not support that kind of
code.

This RFC proposes an explicit advanced escape hatch for those cases
while preserving npm's root-owned authority model, lockfile visibility,
and publish isolation.

## Why a separate extension file

The RFC intentionally keeps executable policy out of `package.json`. A
public package may need local dependency repairs for its own tests,
build, or linked-install migration, but it should not publish root-only
install policy to the registry manifest or packument.

`.npm-extension` is also deliberately not named `.npmfile` or shaped
like pnpm's `hooks.readPackage`. The proposal borrows the useful
manifest-transform idea from pnpm, but defines npm-specific semantics
for trust, lockfile hashing, `npm ci`, publish exclusion, disable
behavior, supported mutations, and future extension points.

## Notable semantics

- Only the root project owns `.npm-extension`.
- Workspace package manifests are not extension targets; non-root
workspace `.npm-extension` files warn and are ignored.
- Dependency package `.npm-extension` files are ignored.
- The only extension point in this RFC is top-level
`transformManifest(pkg, context)`.
- The extension point runs synchronously before dependency and peer
edges are read.
- Supported output changes are limited to `dependencies`,
`optionalDependencies`, `peerDependencies`, and `peerDependenciesMeta`.
- Unlike `packageExtensions`, the imperative form can delete supported
dependency entries and replace existing normal dependency ranges.
- Unsupported field changes such as `scripts`, `bin`, `exports`,
`types`, and `bundleDependencies` are rejected.
- `transformManifest` runs for non-root, non-workspace dependency
manifests from registry, git, remote tarball, local file, local
directory, and symlinked dependency sources.
- npm records an extension entry-file hash and minimal
`npmExtensionApplied` provenance in `package-lock.json`.
- `npm ci` verifies matching extension state without importing or
executing `.npm-extension`.
- Changed extension file bytes make `npm install` re-run
`transformManifest` across candidate manifests rather than relying on
selector-based selective re-resolution.
- Extension-created, extension-changed, and extension-removed dependency
metadata is visible through lockfile provenance and npm inspection
output.
- `.npm-extension.mjs` and `.npm-extension.cjs` are excluded from `npm
pack` and `npm publish`, even when listed in `files`.
- `ignore-extension=true` disables extension execution, and
`ignore-scripts=true` implies `ignore-extension=true` for commands that
would otherwise execute it.

## Relationship to `packageExtensions`

This RFC is not trying to replace `packageExtensions`. The declarative
feature should remain the first choice for small, reviewable manifest
repairs. The imperative extension file is for cases where the
declarative shape becomes repetitive, cannot express the needed local
policy, or cannot live in a publishable package manifest.

The RFC keeps the two features ordered and auditable:
`transformManifest` runs before `packageExtensions`, then npm reads
dependency and peer edges from the resulting effective manifest. That
lets imperative repairs inspect the upstream manifest before declarative
repairs are applied, while preserving the accepted `packageExtensions`
validation and provenance model.

## References

Follow up of #889 

---

> **Disclosure**: [Codex](https://chatgpt.com/codex/) and [Claude
Code](https://claude.com/claude-code) were used to draft the initial
version of this RFC and to iterate on it during review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants