Skip to content

💥 Modernize the toolchain and ship dual ESM + CJS - #13

Merged
SylvainEstevez merged 10 commits into
mainfrom
chore/modernize
Aug 30, 2026
Merged

SylvainEstevez merged 10 commits into
mainfrom
chore/modernize

Conversation

@SylvainEstevez

Copy link
Copy Markdown
Contributor

Third repo through the modernization runbook in the workspace CHECKLIST.md, after
url (the pilot) and thrown. pnpm · tsdown (dual ESM + CJS) · Vitest · Biome ·
TypeScript 7 · shared CI from tselect-npm/.github@v1.

The title is :boom: deliberately. Squash is the only merge method the rulesets
allow, so this PR collapses to one commit and that commit's gitmoji is the only one
the release workflow reads. :wrench: would infer as no release at all. The branch's
series is preserved below, since this is the only place it survives.

:bug:                 Stop shipping test, coverage and config cruft in the published package
:wrench:              Migrate to pnpm
:sparkles:            Add dual ESM + CJS build via tsdown, on TypeScript 7
:white_check_mark:    Migrate test suite to Vitest
:heavy_minus_sign:    Drop lodash, vendoring its one use
:bug:                 Add the missing put member to THTTPMethod
:rotating_light:      Replace TSLint with Biome
:memo:                Add LICENSE, keywords and a full README API section
:construction_worker: Run CI from the org-wide reusable workflow
:construction_worker: Add the release caller workflow

Why this is a major

Three independent reasons:

  1. engines.node is now ">=22". It is the bottom of the CI matrix, so the floor
    is tested by construction. >=20 is not viable: pnpm 11 declares node >=22.13 and
    dies on Node 20 before any test runs.
  2. THTTPMethod gains 'put' | 'PUT'. This widens the return type of toLowerCase
    and toConstantCase, so exhaustive switches over their result stop compiling.
  3. The exports map closes deep imports. gh search code found no consumers of
    @tselect/http-method/dist, but it is a break in principle.

Two behaviour-relevant fixes, both verified rather than argued

toPascalCase was broken under ESM. lodash assigns its exports dynamically, so
cjs-module-lexer cannot detect them for CJS→ESM interop: import * as Lodash from 'lodash' produced a namespace with no capitalize, and the ESM entry threw
Lodash.capitalize is not a function on every call — while the CJS entry worked
perfectly. Nothing was broken in the field, since 1.0.0 is CommonJS-only, but shipping
dual-format without this fix would have broken every ESM consumer. Vendoring the one
helper drops lodash and takes the package to zero runtime dependencies; the vendored
body matched lodash.capitalize on 36 inputs before lodash was removed.

THTTPMethod never listed put, so toLowerCase(HTTPMethod.PUT) — the package's
own enum passed to the package's own function — did not compile. Runtime was always
correct. A compile-time assertion that HTTPMethod is assignable to THTTPMethod now
guards it, and was checked non-vacuous by restoring the old union and confirming it
produces 7 typecheck errors.

Also fixed: the published tarball shipped test, coverage and config cruft. .npmignore
is gone in favour of "files": ["dist"] — an allowlist cannot fail open.

Verification

The whole diff was differential-tested against the published 1.0.0 tarball: 176
comparisons
(44 inputs × 4 functions) with 0 mismatches, plus identical enum
contents and identical export names. toPascalCase's vendored body is the only intended
runtime change.

Re-verified from a clean clone of this branch:

  • 30 tests, 100% statements / branches / functions / lines
  • pnpm audit0 vulnerabilities
  • attw clean on all four resolution modes; es-check es2015 passes on both entries
  • dist/index.d.mts and dist/index.d.cts both present and non-empty
  • tarball is 9 files; require() and import agree on all five runtime exports

Not in this PR

  • Publishing. Deferred until all seven repos are modernized; merging here is not the
    release. The version in package.json stays 1.0.0 and the major goes out later.
  • The ci-required ruleset and the npm-publish environment, which this repo does
    not yet have. Both belong to the separate scripted provisioning pass.

The @bluejay-era tags (1.0.0, v2.0.0, v2.0.1, v2.0.2, v3.0.0-beta.0) have
been removed and v1.0.0 recreated at b86f554 — the commit the registry records as
@tselect/http-method@1.0.0's gitHead — so the release workflow's commit range
resolves on the first try. master was renamed to main as part of this work.

The .npmignore denylist could not enumerate files that did not exist when it
was written, so @tselect/http-method@1.0.0 shipped .nyc_output/ (5 files),
.travis.yml and tslint.json alongside dist/. Replace it with a "files"
allowlist, which cannot fail open.

Also fixes prepare: "tsc", which was a silent no-op under noEmit: true — a
clean checkout packed whatever dist/ happened to be lying around. prepack now
runs the real build.
Replaces package-lock.json with pnpm-lock.yaml and pins packageManager to
pnpm@11.21.0. pnpm settings live in pnpm-workspace.yaml — pnpm 11 ignores
pnpm.onlyBuiltDependencies in package.json.

Resolving the existing ranges against already-published patches drops the
audit from 16 vulnerabilities (2 low, 4 moderate, 10 high) to 3 (2 moderate,
1 high). The remaining three are all in the nyc tree and go away with mocha.

Note: the suite could not be run before or after this change — ts-node@9 with
Node 24 rejects the angle-bracket cast in test/setup.ts. Pre-existing, fixed
by the Vitest migration.
Replaces the tsc-to-CommonJS build with tsdown, emitting both formats plus
separate .d.mts and .d.cts declarations behind an exports map.

The TypeScript 7 upgrade is folded into this commit rather than kept separate
because the two cannot be sequenced apart: tsdown's peer range is ^5 || ^6 ||
^7, so it cannot run on the previous typescript@4.9.5, while TypeScript 7
removes moduleResolution node10 (TS5108) and so cannot keep the old config.
Whichever landed first would leave the tree unbuildable.

src/index.ts now re-exports THTTPMethod with 'export type'. It is a type, and
a bundler compiling modules in isolation cannot know that from a value export
— rolldown failed the build with MISSING_EXPORT. No effect on consumers; the
symbol was never present at runtime.

tsconfig drops declaration, outDir, inlineSourceMap and removeComments (tsdown
owns emit now), moves to moduleResolution bundler, and gains skipLibCheck.
@types/node 12.7.0 -> 22.20.1.
Ports the 7 existing tests one-for-one — same describe/it structure, chai's
to.equal becoming toBe — so the assertion count is unchanged at 7. Drops
test/setup.ts, test/mocha.opts and test/global.d.ts, whose only job was wiring
chai's expect onto the global object.

Removes mocha, chai, nyc, ts-node, @types/mocha and @types/chai, plus typedoc,
which was declared but never referenced by any script. Deleting those trees
takes pnpm audit from 3 vulnerabilities to 0 by subtraction rather than by
audit fix.

tsconfig now includes test/ as well as src/, which is what makes skipLibCheck
necessary — without it tsc typechecks Vitest's own declarations.

Coverage is 100% of statements, functions and lines, over 0 branches; the
source has no conditionals. Threshold set to the shared floor of 95.
toPascalCase was the only consumer, calling Lodash.capitalize once. Per the
workspace dependency-removal policy a dep used for a single helper is vendored
rather than carried, which takes this package to zero runtime dependencies.

This also fixes a bug the dual-format build exposed: lodash assigns its exports
dynamically, so cjs-module-lexer cannot detect them for CJS-to-ESM interop and
'import * as Lodash' resolved to a namespace with no capitalize. The ESM entry
threw 'Lodash.capitalize is not a function' on every toPascalCase call while
the CJS entry worked, which is why the previous CommonJS-only build never
surfaced it.

The replacement was differential-tested against lodash.capitalize across 36
inputs (every method in lower, upper, pascal and two mixed cases, plus the
empty string) with zero mismatches before lodash was removed. 12 of those are
pinned as regression tests here; the rest exercise inputs the signature does
not admit.

Tests: 7 -> 19.
HTTPMethod.PUT has always existed, but THTTPMethod stopped at 'head' and never
listed 'put' or 'PUT'. Every function in the package takes THTTPMethod, so
toLowerCase(HTTPMethod.PUT) — passing the package's own enum to the package's
own function — did not compile. The runtime behaviour was always correct; only
the type disagreed.

Widening the union also widens the return type of toLowerCase and
toConstantCase, so an exhaustive switch over their result now needs a 'put'
arm. That is a compile-time break, which this release can absorb: engines.node
>=22 already makes it a major.

Guarded against recurrence by asserting HTTPMethod is assignable to
THTTPMethod at compile time. Verified non-vacuous: restoring the old union
fails typecheck with 7 errors.

Tests: 19 -> 30.
TSLint has been end-of-life since 2019. Biome covers lint and format in one
tool, matching url and thrown.

Config uses preset: "recommended" rather than the recommended: true spelling,
which Biome 2.5.8 deprecates and warns on for every run. url still carries the
old spelling and should be corrected when it is next touched.

All five findings were mechanical, with no public API decisions of the kind
thrown's Biome step forced: useImportType on the two remaining value-imports of
THTTPMethod, a missing trailing comma in the enum, and export ordering in
src/index.ts. Reordering the re-exports changes the order of the export
statements only — the exported names are unchanged, verified against the
recorded baseline.

Tests and typecheck still pass; no source behaviour changed.
The repo declared "license": "MIT" in package.json but carried no LICENSE
file, so the published package asserted a licence whose text it never shipped.

README restructured to the workspace order — title, badges, description,
Requirements, Installation, Usage, API, License — with url's README as the
reference. Badges gain CI and coverage; the title becomes the package name.
The API section is new: every export is documented, including THTTPMethod,
which was not mentioned at all before.

keywords was empty, so the package was effectively unfindable on npm.

All 21 runnable README examples were executed against the built dist and
matched. The type-level examples were typechecked from a scratch consumer
resolving through the exports map, which also confirms the documented claim
that every function accepts the enum as well as either literal case. The
Requirements section's "tested on 22, 24 and 26" matches the reusable
workflow's default node-versions matrix.
Adds the standard four-line caller pinned at @v1. No inputs are overridden:
es-check-target's es2015 default is already this package's emitted-syntax
ceiling, unlike thrown which has to say es2016 out loud.

Declares engines.node >=22, the bottom of the CI matrix, so the supported floor
is proven by construction rather than asserted. This is what makes the next
release a major.

Verified locally against what CI will run: lint, typecheck, cov and build all
pass, es-check confirms the emitted ESM and CJS both parse as ES2015, and both
declaration files are present and non-empty.

The .travis.yml this replaces was deleted earlier in the branch; it pinned Node
8.6.0 and 12.8.1 and had not run since the repo moved to the tselect org.
Publishing runs from CI over npm trusted publishing (OIDC), never from a
laptop. This is the thin caller for the reusable release workflow in
`tselect-npm/.github`, pinned at `@v1` like ci.yml.

The filename is load-bearing: npm's trusted publisher config validates the
*caller's* workflow filename, so renaming this file breaks publishing with
a 404 that says nothing about filenames.

`id-token: write` is declared here as well as in the reusable workflow — a
called workflow can only narrow the permissions it is given, so omitting it
would produce a job with no OIDC token.
@SylvainEstevez
SylvainEstevez merged commit 6c05232 into main Aug 30, 2026
9 checks passed
@SylvainEstevez
SylvainEstevez deleted the chore/modernize branch August 30, 2026 13:41
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.

1 participant