First-publish packaging contract
Dual ESM/CJS emit, types-first exports ordering, tsup dir-style entries, tsc-built DTS, sourcemap-free tarballs, peer-dep tightening — the package-build conventions every @pleach/* SKU follows at 1.0.
Every @pleach/* SKU follows the same packaging conventions at its
first 1.x publish. For day-to-day consumers, this page is
informational — knowing the contract makes debugging install or
import issues faster. For forkers and vendored installs, it's
load-bearing: diverging breaks the audit gates that enforce the
contract upstream.
Dual ESM + CJS emit
Every @pleach/* package ships both module systems from a single
TypeScript source tree. ESM (.mjs) is the primary distribution;
CJS (.js) is a compatibility layer for Node consumers on older
toolchains that can't resolve native ESM cleanly.
The package.json exports block routes the right format per
resolver. ESM consumers resolve .mjs; CJS consumers resolve
.js; TypeScript reads .d.ts. The dual emit is non-negotiable —
dropping CJS would break a substantial fraction of consumers on
Node 18 and below.
| Format | Extension | Resolver |
|---|---|---|
| ESM | .mjs | import condition |
| CJS | .js | require condition |
| Types | .d.ts | types condition |
Types-first exports ordering
The Node spec walks exports conditions top-to-bottom and picks
the first match. The types condition must come first in each
export entry. Wrong order → TypeScript falls through to import
or default, reads the JavaScript file as if it were types, and
the consumer sees "no type declarations" errors.
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
}
}This is a Node spec rule, not a Pleach convention — but the
upstream audit:package-export-validation gate asserts the
ordering anyway, since the failure mode is silent and consumer-side.
Dir-style tsup entries
Build entries are directories (src/audit/, src/cache/,
src/sessions/), not single files. tsup walks each entry
directory's index.ts and emits a matching
dist/<dir>/index.{mjs,js,d.ts} triple.
{
"tsup": {
"entry": ["src/audit", "src/cache", "src/sessions"],
"format": ["esm", "cjs"]
}
}The directory convention keeps the entry list compact as the package grows. Adding a new subpath is one entry, not three artifact paths.
DTS via tsc --build, not tsup's dts block
tsup's bundled-DTS mode tends to break with complex type
re-exports — it loses some declarations and mangles others when
a module re-exports a generic across barrels. The convention is
to disable tsup's DTS output entirely and run tsc --build for
declarations.
{
"scripts": {
"build": "tsup && tsc --build tsconfig.build.json"
}
}tsup emits JS only; the separate tsc pass emits .d.ts. The
build script runs both in order. The tsconfig.build.json
typically sets emitDeclarationOnly: true and declarationMap: false.
Sourcemap exclusion from the published tarball
Sourcemaps live on disk in dev (dist/*.map) but the files
field in package.json excludes them from npm pack.
{
"files": [
"dist/**/*.mjs",
"dist/**/*.js",
"dist/**/*.d.ts",
"LICENSE",
"NOTICE",
"README.md"
]
}The empirical reason: including sourcemaps pushed the published tarball from ~25 MB to >100 MB, which slowed CI installs across every consumer. Sourcemaps remain available in the git repo for debugging — clone the source tag and rebuild locally if you need them.
PeerDep tightening
At 1.x, peer-dep ranges tighten. The typical pattern is a caret on a single major version, sometimes narrower for packages with known breakage windows.
| Range style | When |
|---|---|
^18.0.0 | Stable peer with predictable semver |
>=18.0.0 <20 | Peer with known breakage in 20.x |
^18.2.0 | Pinning to a specific minor for a bug-fix floor |
The discipline avoids the "two copies of React" duplicate-install class of bug, where the consumer pulls one major and a transitive dep pulls another. Consumer responsibility: install peers explicitly rather than relying on transitive resolution.
NOTICE, CHANGELOG, provenance, sideEffects
Four baselines every 1.x publish carries.
NOTICE
License-attribution file referenced from package.json via the
files array. Required at first 1.x publish. Carries the
package's own license notice plus third-party attributions for
any bundled or derived code.
CHANGELOG.md
Keep a Changelog format. Every
publish writes an entry; no "Unreleased" tail at publish time.
The audit gate audit:package-version-vs-registry (next section)
fails the publish if the changelog top entry doesn't match the
package.json#version.
npm provenance
Publishes use npm publish --provenance, which ties the tarball
to the GitHub Actions workflow that built it via a signed SLSA
attestation. Consumers verify with:
npm audit signaturesThe attestation links the tarball hash to the commit SHA, the workflow file, and the runner. Useful for supply-chain auditing and for confirming a published artifact matches the source tag.
sideEffects: false
Set in package.json where accurate — for @pleach/*, every
published package qualifies. The flag lets bundlers tree-shake
unused exports out of consumer bundles.
{
"sideEffects": false
}The setting is honored by Webpack, Rollup, esbuild, and Vite.
Roughly a 30% bundle reduction for apps that only import a
handful of named subpaths from @pleach/core.
Audit gates enforcing the contract
Two upstream audits gate every publish.
audit:package-export-validation
Walks every entry in package.json#exports and asserts:
- The file referenced exists on disk after build.
- The file re-exports the declared symbols (parsed from the
matching
.d.ts). - The
typescondition comes first in each entry. - ESM and CJS entries resolve to files with the correct extensions.
The gate runs in CI on every PR and as a pre-publish hook locally. Diverging — adding an export without a corresponding file, or reordering conditions — fails the publish.
audit:package-version-vs-registry
Compares the in-tree package.json#version to the latest tag
on npmjs.org. Catches "silently past 1.0.0" drift between
source and registry — the failure mode where a local bump
happens but the publish never lands, then a subsequent change
publishes with the wrong version.
The gate also asserts the CHANGELOG.md top entry matches the
in-tree version, so the changelog can't drift either.
Consumer-rehearsal audit
Before any first-publish OR any 1.x release tag, run the
consumer-rehearsal audit — a six-phase end-to-end simulation
of what a real npm consumer experiences. The audit runs the
package through npm pack + install into a scratch directory
outside the source tree, then exercises the published surface
the way a consumer would.
npm run audit:consumer-rehearsal:coreThe six phases:
| Phase | What it does | What it catches |
|---|---|---|
| 0 | npm run packages:build | Build failures, missing artifacts |
| 1 | npm pack the target + peer packages; install all tarballs into a scratch dir | Tarball-shape regressions, missing files entries, peer-dep resolution failures |
| 2 | Auto-discovered facet smoke imports from packages/<pkg>/src/facets/*.ts (one accessor probe per facet) | Facet barrel breakage, missing re-exports, accessor-shape drift |
| 3 | Auto-discovered subpath imports from package.json:exports (skipping ., ./package.json, wildcards) | Subpath resolution failures, missing dist/ files behind declared exports |
| 4 | TypeScript consumer surface — tsc --noEmit -p tsconfig.json under strict NodeNext against a consumer file importing the published types + values | .d.ts regressions invisible to source-tree tsc (consumer resolution differs from in-monorepo resolution) |
| 5 | Auto-discovered examples runs — every examples/<name>/index.mjs with a 30s timeout | Example-script breakage; functional smoke on the public surface |
Exit codes: 0 = all phases pass; 1 = any phase failed.
The --json mode emits structured per-phase records for CI
consumption. The audit writes only to the scratch directory
outside the repo, so it's idempotent across runs.
When to run it:
- Before any first-publish of a new
@pleach/*SKU - Before any 1.x release tag
- After touching
packages/<pkg>/package.json:exports - After relocating or renaming any
src/facets/*.tsaccessor - After adding new files to the
filesarray
The audit caught the standalone-install blocker on @pleach/core
that motivated the no-eager-react-import fix — a failure mode
that was invisible to every in-monorepo gate because workspace
path aliases silently resolved cross-tree.
For consumers — debugging install issues
Three common symptoms and what they usually mean.
| Symptom | Likely cause |
|---|---|
Cannot find module "@pleach/core/<subpath>" | The subpath isn't in the exports map — check Subpath exports |
Module has no exported member on a type-only import | types condition out of order in the consumer's bundler resolver — pin to a recent bundler version |
| Sourcemap warnings in dev tools | Benign — the tarball deliberately excludes sourcemaps |
The first one is the most common: a deep import that worked
against @pleach/core/* (wildcard) before a named barrel
landed, then stopped resolving the way the consumer expected.
The fix is to import from the barrel, not the deep path.
The second usually means an old bundler or moduleResolution: "node" instead of "bundler" / "node16" in the consumer's
tsconfig.json. The types-first ordering is correct on the
package side; the resolver needs to honor it.
For forkers and vendored installs
If you vendor @pleach/* into a private registry or fork the
source, mirror the conventions above. The upstream audit gates
will fail on a fork that diverges — the gates live in the
package source, not in the publish pipeline, so they run in any
fork's CI too.
The canonical enforcer scripts live in the source tree:
| Script | Asserts |
|---|---|
scripts/audit-package-exports.mjs | Exports map validity, types-first ordering |
scripts/audit-package-version.mjs | In-tree version vs registry, changelog top entry |
scripts/audit-build-artifacts.mjs | Dual-format emit, DTS presence, tarball contents |
A fork that wants to publish under a different scope should keep the gates intact and update the registry URL the version audit checks against. Don't disable the gates — the failure modes they catch are silent on the consumer side, and disabling them shifts debugging cost onto every downstream user.