pleach
Reference

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.

FormatExtensionResolver
ESM.mjsimport condition
CJS.jsrequire condition
Types.d.tstypes 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 styleWhen
^18.0.0Stable peer with predictable semver
>=18.0.0 <20Peer with known breakage in 20.x
^18.2.0Pinning 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 signatures

The 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:

  1. The file referenced exists on disk after build.
  2. The file re-exports the declared symbols (parsed from the matching .d.ts).
  3. The types condition comes first in each entry.
  4. 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:core

The six phases:

PhaseWhat it doesWhat it catches
0npm run packages:buildBuild failures, missing artifacts
1npm pack the target + peer packages; install all tarballs into a scratch dirTarball-shape regressions, missing files entries, peer-dep resolution failures
2Auto-discovered facet smoke imports from packages/<pkg>/src/facets/*.ts (one accessor probe per facet)Facet barrel breakage, missing re-exports, accessor-shape drift
3Auto-discovered subpath imports from package.json:exports (skipping ., ./package.json, wildcards)Subpath resolution failures, missing dist/ files behind declared exports
4TypeScript 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)
5Auto-discovered examples runs — every examples/<name>/index.mjs with a 30s timeoutExample-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/*.ts accessor
  • After adding new files to the files array

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.

SymptomLikely 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 importtypes condition out of order in the consumer's bundler resolver — pin to a recent bundler version
Sourcemap warnings in dev toolsBenign — 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:

ScriptAsserts
scripts/audit-package-exports.mjsExports map validity, types-first ordering
scripts/audit-package-version.mjsIn-tree version vs registry, changelog top entry
scripts/audit-build-artifacts.mjsDual-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.

Where to go next

On this page