Skip to content

Commit ae96ad7

Browse files
feat: dual-publish ESM + CJS builds (#52)
## Summary Closes #48. The SDK has been ESM-only since extraction, which forced CJS consumers (Node services that `require()`) to either re-implement the client or use dynamic-import workarounds. This PR adds a CommonJS build alongside the existing ESM output so a plain `require("@layerv/qurl")` works out of the box. ## What changed - **Two `tsc` passes from one source tree.** `tsconfig.json` keeps emitting ESM (now under `dist/esm/`); a new `tsconfig.cjs.json` extends it and emits CJS to `dist/cjs/`. - **Per-condition `exports` map** — `import` and `require` each point at their matching build, with `types` first inside each so `moduleResolution: Node16` consumers resolve the correct `.d.ts`. - **Sidecar `package.json` per dist tree.** `scripts/postbuild.mjs` writes `{"type":"commonjs"}` into `dist/cjs/` and `{"type":"module"}` into `dist/esm/` so Node pins each tree's module format independently of the root `"type"` field. - **End-to-end smoke fixtures.** `smoke/cjs.cjs` and `smoke/esm.mjs` self-reference `@layerv/qurl` (matching how a downstream consumer resolves it) and assert the public surface loads. CI runs both after `npm run build`. - **Legacy `main` / `module` / `types` fields** kept for tooling that doesn't read `exports`. - **README + CONTRIBUTING** updated to drop the ESM-only note and document the dual-build layout. ## Acceptance criteria (from #48) - [x] `require('@layerv/qurl')` works without flags or workarounds — `smoke/cjs.cjs` proves it. - [x] Existing ESM consumers keep working — `smoke/esm.mjs` proves it; existing test suite (67 tests) still passes. - [x] Both entry points come out of one build — `npm run build` runs both tsc passes + the postbuild sidecar drop. - [x] `exports` gains a `require` condition; types resolve from both entry points. - [x] CI smoke: both fixtures run on every PR. - [x] Additive change — no breaking change to ESM consumers; Release Please will publish as a minor. ## Heads-up for reviewers - The `dist/` layout changes from `dist/index.js` to `dist/{esm,cjs}/index.js`. Anyone deep-importing past the public root (e.g. `@layerv/qurl/dist/index.js`) would break. Issue #48 notes the SDK has zero real consumers today, so this is acceptable; the `exports` field is the public contract going forward. - TypeScript 6 deprecated `moduleResolution: Node10`. The CJS tsconfig sets `ignoreDeprecations: "6.0"` to silence the warning until we migrate; the resolution algorithm itself still works correctly for CJS output. ## Test plan - [x] `npm run build` — both passes succeed, sidecars land - [x] `npm test` — 67 existing tests pass - [x] `npm run smoke:dist` — CJS + ESM self-reference fixtures both resolve and instantiate - [x] `node -e 'const {QURLClient} = require("./dist/cjs"); console.log(new QURLClient({apiKey:"x"}))'` — masked-key inspect output prints - [x] `node --input-type=module -e '...'` ESM equivalent — same output - [x] `npm pack --dry-run` — only `dist/` ships; `smoke/`, `scripts/`, `tsconfig.cjs.json` correctly excluded
1 parent e12f44f commit ae96ad7

11 files changed

Lines changed: 177 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ jobs:
2323
- run: npm run lint
2424
- run: npm run format:check
2525
- run: npm test
26+
- run: npm run smoke:dist
2627

2728
notify:
2829
name: Notify

CONTRIBUTING.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,48 @@ npm install
1313
## Running Checks
1414

1515
```bash
16-
npm run build # Compile TypeScript
16+
npm run build # Compile TypeScript (ESM + CJS)
1717
npm test # Run tests (vitest)
18+
npm run smoke:dist # Verify the built ESM and CJS entry points load
1819
npm run format:check # Check formatting (prettier)
1920
npx eslint src/ # Lint
2021
```
2122

2223
All must pass before submitting a PR.
2324

25+
## Dual Build (ESM + CJS)
26+
27+
The package emits two builds from one source tree:
28+
29+
- `tsconfig.json``dist/esm/` (ESM, `module: Node16`)
30+
- `tsconfig.cjs.json``dist/cjs/` (CJS, `module: CommonJS`)
31+
32+
`scripts/postbuild.mjs` drops a `package.json` sidecar into each output
33+
directory so Node resolves the emitted `.js` files as the right format
34+
regardless of the root `"type"` field. The package's `exports` field
35+
points each condition (`import`, `require`) at its matching build, with
36+
per-condition `types` so `moduleResolution: Node16` consumers get the
37+
right `.d.ts`.
38+
39+
`smoke/cjs.cjs` and `smoke/esm.mjs` self-reference `@layerv/qurl` and
40+
exercise both entry points end-to-end; `smoke/parity.mjs` additionally
41+
asserts both builds export the same runtime name set. CI runs all three
42+
after the build. These are the load-bearing checks that the consumer-
43+
facing surface still loads — don't skip them when changing build
44+
configuration.
45+
46+
**`npm run dev` only watches the ESM build.** Both configs share
47+
everything except `module`/`moduleResolution`/`outDir`, so a CJS-only
48+
break is unlikely, but run `npm run build` before publishing any
49+
build-config change to confirm both trees still compile.
50+
51+
**Avoid module-scope mutable state** (caches, singletons, `WeakMap`
52+
registries). A mixed-dependency tree can load both `dist/esm/index.js`
53+
and `dist/cjs/index.js` as separate module instances — `instanceof`
54+
checks across the boundary would fail and any shared state would
55+
diverge. Classes and plain constants are safe; only flag state added
56+
at module scope is the hazard.
57+
2458
## API Contract Snapshot
2559

2660
`contract/openapi.snapshot.yaml` is a hand-maintained minimal OpenAPI

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ AI agents need to access protected resources — APIs, databases, internal tools
1616
npm install @layerv/qurl
1717
```
1818

19-
> **Note:** This package is ESM-only. It requires Node.js 18+ and `"type": "module"` in your `package.json` (or use `.mjs` extensions).
19+
Requires Node.js 18+. Both `import { QURLClient } from '@layerv/qurl'` (ESM) and `const { QURLClient } = require('@layerv/qurl')` (CJS) work.
2020

2121
## Quick Start
2222

package.json

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,32 @@
33
"version": "0.1.0",
44
"description": "TypeScript SDK for the QURL API - secure, time-limited access links",
55
"type": "module",
6-
"main": "./dist/index.js",
7-
"types": "./dist/index.d.ts",
6+
"sideEffects": false,
7+
"main": "./dist/cjs/index.js",
8+
"module": "./dist/esm/index.js",
9+
"types": "./dist/esm/index.d.ts",
810
"exports": {
911
".": {
10-
"types": "./dist/index.d.ts",
11-
"import": "./dist/index.js"
12+
"import": {
13+
"types": "./dist/esm/index.d.ts",
14+
"default": "./dist/esm/index.js"
15+
},
16+
"require": {
17+
"types": "./dist/cjs/index.d.ts",
18+
"default": "./dist/cjs/index.js"
19+
}
1220
}
1321
},
1422
"scripts": {
15-
"build": "tsc",
16-
"dev": "tsc --watch",
23+
"clean": "node scripts/clean.mjs",
24+
"build": "npm run clean && tsc -p tsconfig.json && tsc -p tsconfig.cjs.json && node scripts/postbuild.mjs",
25+
"dev": "tsc -p tsconfig.json --watch",
1726
"test": "vitest run",
1827
"lint": "eslint src/",
19-
"format": "prettier --write 'src/**/*.ts'",
20-
"format:check": "prettier --check 'src/**/*.ts'",
21-
"prepublishOnly": "npm run build"
28+
"format": "prettier --write 'src/**/*.ts' 'smoke/**/*.{cjs,mjs}' 'scripts/**/*.mjs'",
29+
"format:check": "prettier --check 'src/**/*.ts' 'smoke/**/*.{cjs,mjs}' 'scripts/**/*.mjs'",
30+
"smoke:dist": "node smoke/cjs.cjs && node smoke/esm.mjs && node smoke/parity.mjs",
31+
"prepublishOnly": "npm run build && npm run smoke:dist"
2232
},
2333
"keywords": [
2434
"qurl",

scripts/clean.mjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
// Pre-build cleanup. Extracted from an inline `node -e` in package.json
2+
// to match the style of postbuild.mjs and stay robust to any future
3+
// Node default-module-type change.
4+
import { rmSync } from "node:fs";
5+
import { fileURLToPath } from "node:url";
6+
7+
const dist = fileURLToPath(new URL("../dist", import.meta.url));
8+
rmSync(dist, { recursive: true, force: true });

scripts/postbuild.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Pin each dist tree's module format via a sidecar package.json so a
2+
// future flip of the root `"type"` field can't silently mis-resolve
3+
// downstream consumers.
4+
import { writeFileSync } from "node:fs";
5+
import { fileURLToPath } from "node:url";
6+
7+
// Resolve against this file's location rather than process.cwd() so
8+
// the script behaves identically whether invoked via npm scripts
9+
// (cwd = package root) or directly from another directory.
10+
const esm = fileURLToPath(new URL("../dist/esm/package.json", import.meta.url));
11+
const cjs = fileURLToPath(new URL("../dist/cjs/package.json", import.meta.url));
12+
13+
writeFileSync(esm, '{"type":"module"}\n');
14+
writeFileSync(cjs, '{"type":"commonjs"}\n');

smoke/cjs.cjs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// CJS consumer smoke test. Resolves the package via its `exports.require`
2+
// condition using a package self-reference, exactly like a downstream
3+
// `require("@layerv/qurl")` would. Intentionally covers only a minimal
4+
// happy-path surface — full-surface drift between the two builds is
5+
// caught by smoke/parity.mjs, and end-to-end client behavior is covered
6+
// by the vitest suite. Don't pad this out.
7+
const { QURLClient, QURLError, ValidationError, VERSION } = require("@layerv/qurl");
8+
9+
if (typeof QURLClient !== "function") {
10+
throw new Error("QURLClient is not a constructor");
11+
}
12+
if (typeof QURLError !== "function" || typeof ValidationError !== "function") {
13+
throw new Error("error classes did not load");
14+
}
15+
if (typeof VERSION !== "string") {
16+
throw new Error("VERSION not exported");
17+
}
18+
19+
const client = new QURLClient({ apiKey: "lv_live_smoke" });
20+
if (typeof client.create !== "function" || typeof client.resolve !== "function") {
21+
throw new Error("client methods not callable");
22+
}
23+
24+
console.log("CJS smoke ok");

smoke/esm.mjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// ESM consumer smoke test. Mirror of cjs.cjs through `exports.import`
2+
// via a package self-reference. Same scope as cjs.cjs — minimal happy-
3+
// path; full-surface drift is caught by smoke/parity.mjs.
4+
import { QURLClient, QURLError, ValidationError, VERSION } from "@layerv/qurl";
5+
6+
if (typeof QURLClient !== "function") {
7+
throw new Error("QURLClient is not a constructor");
8+
}
9+
if (typeof QURLError !== "function" || typeof ValidationError !== "function") {
10+
throw new Error("error classes did not load");
11+
}
12+
if (typeof VERSION !== "string") {
13+
throw new Error("VERSION not exported");
14+
}
15+
16+
const client = new QURLClient({ apiKey: "lv_live_smoke" });
17+
if (typeof client.create !== "function" || typeof client.resolve !== "function") {
18+
throw new Error("client methods not callable");
19+
}
20+
21+
console.log("ESM smoke ok");

smoke/parity.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Structural drift detector: a runtime name added to only one of the two
2+
// builds (e.g. a future conditional export that ships in ESM but not CJS)
3+
// would land in prod silently without this check — the per-build cjs.cjs
4+
// and esm.mjs smokes can't see the other side.
5+
import { createRequire } from "node:module";
6+
import assert from "node:assert/strict";
7+
8+
const require = createRequire(import.meta.url);
9+
const cjs = require("@layerv/qurl");
10+
const esm = await import("@layerv/qurl");
11+
12+
// Raw Object.keys on both — no filtering. A `default` export landing in
13+
// only one build is exactly the ESM-only drift this check exists to
14+
// catch; filtering it would silently pass the asymmetric case. The
15+
// Node-synthesized `default` for CJS-interop imports doesn't apply
16+
// here since both sides are resolved through their own `exports`
17+
// condition, not via cross-format interop.
18+
//
19+
// TS's CJS emit marks the namespace with `__esModule` via
20+
// `Object.defineProperty(exports, "__esModule", { value: true })`,
21+
// which is non-enumerable by default and so is correctly excluded
22+
// from Object.keys. A future TS emit change that makes it enumerable
23+
// would surface as a spurious "cjs has __esModule, esm doesn't" diff
24+
// here — that's the correct outcome (loud signal to investigate).
25+
const cjsKeys = Object.keys(cjs).sort();
26+
const esmKeys = Object.keys(esm).sort();
27+
28+
assert.deepStrictEqual(
29+
cjsKeys,
30+
esmKeys,
31+
`ESM/CJS export drift:\n cjs: ${cjsKeys.join(", ")}\n esm: ${esmKeys.join(", ")}`,
32+
);
33+
34+
console.log(`Parity ok (${cjsKeys.length} names)`);

tsconfig.cjs.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"extends": "./tsconfig.json",
3+
"compilerOptions": {
4+
"module": "CommonJS",
5+
// TODO: TS 7 will drop `Node10`. Migrate to `module: "Node16"` + a
6+
// source-side CJS sidecar (or matching `.cts` entry) before then.
7+
"moduleResolution": "Node10",
8+
"outDir": "./dist/cjs",
9+
"ignoreDeprecations": "6.0",
10+
// The ESM tree already emits declaration + source maps from the same
11+
// `src/` tree; emitting them again into `dist/cjs/` doubles tarball
12+
// size for no additional signal. The ESM maps themselves ship but
13+
// reference `src/` which `files: ["dist/"]` excludes (see #53);
14+
// fixing that is a separate decision, and double-shipping broken
15+
// maps on the CJS side wouldn't help either way.
16+
"declarationMap": false,
17+
"sourceMap": false
18+
}
19+
}

0 commit comments

Comments
 (0)