From b3dd7ab07039f425a42f5ef027308742c250d012 Mon Sep 17 00:00:00 2001 From: Jay Feng Date: Tue, 28 Jul 2026 15:08:50 +0100 Subject: [PATCH 1/3] MD-2245 Publish an ESM build Node can resolve without a bundler Claude-Session: https://claude.ai/code/session_01TfzbRw7ihUDtTdMVXsiepi --- .changeset/esm-node-resolution.md | 5 + .github/copilot-instructions.md | 7 +- .github/workflows/ci.yml | 19 +++ .github/workflows/release.yml | 3 - package.json | 6 +- pnpm-lock.yaml | 109 ++++++++++++++++ scripts/verify-package-resolution.mjs | 174 ++++++++++++++++++++++++++ tsconfig.build.json | 5 +- 8 files changed, 320 insertions(+), 8 deletions(-) create mode 100644 .changeset/esm-node-resolution.md create mode 100644 scripts/verify-package-resolution.mjs diff --git a/.changeset/esm-node-resolution.md b/.changeset/esm-node-resolution.md new file mode 100644 index 00000000..acbdcfdd --- /dev/null +++ b/.changeset/esm-node-resolution.md @@ -0,0 +1,5 @@ +--- +'@monetizationos/proxy': patch +--- + +Publish an ESM build Node resolves without a bundler. The emitted JavaScript and type declarations previously carried extensionless relative specifiers, so importing the package from plain Node failed with `ERR_UNSUPPORTED_DIR_IMPORT` and a consumer on `moduleResolution: NodeNext` could not resolve its types. Self-hosted Node integrations no longer need a build step. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 081ab0c6..115f2716 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -21,12 +21,14 @@ pnpm lint pnpm typecheck pnpm test:run pnpm build +pnpm verify:package ``` Notes: - `pnpm test` starts watch mode; use `pnpm test:run` in agents and CI. -- `pnpm typecheck` covers `src/**/*.ts` and `test/**/*.ts`; `pnpm build` emits only `src/` to `dist/`. +- `pnpm typecheck` covers `src/**/*.ts` and `test/**/*.ts`; `pnpm build` emits only `src/` to `dist/`, then rewrites the emitted specifiers with `tsc-alias`. +- `pnpm verify:package` builds and packs the tarball, installs it into a scratch consumer, then scans the installed `dist` for relative specifiers Node cannot resolve, imports the package from plain Node, and type-checks against it on `moduleResolution: NodeNext`. Run it when you touch packaging, `tsconfig*.json`, or the shape of an import; CI runs it on Node 22 and 24, the only versions this package supports. - If lint fails, run `pnpm format`, then rerun `pnpm lint`. - Do not commit `dist/`, `node_modules/`, `coverage/`, or lockfile changes unless dependencies changed. @@ -54,6 +56,7 @@ Stages 3-6 are HTML-only and auto-skip otherwise. `.withoutHtmlTransformation()` - Keep `src/` runtime code edge-safe: no Node-only APIs such as `fs`, `path`, `process`, or `Buffer`. This applies to executable code; JSDoc examples may reference consumer-side patterns like `process.env`. - Use ESM `import`/`export`; no `require`. +- Relative specifiers stay extensionless, including directory imports of a barrel: `'./config'`, `'./adapters'`. `moduleResolution: Bundler` accepts that form, and `tsc-alias --resolveFullPaths` rewrites every emitted `.js` and `.d.ts` to the full path Node needs. `pnpm verify:package` scans the packed `dist` and fails on any relative specifier left without a runtime extension, so the artifact is what enforces this, not the source form. - Follow Biome formatting: 4-space indent, single quotes, no semicolons, trailing commas, LF line endings. - `nursery.noFloatingPromises` is an error; always `await` promises or mark them with `void`. - `noUncheckedIndexedAccess` is enabled; narrow indexed values before use. @@ -63,5 +66,5 @@ Stages 3-6 are HTML-only and auto-skip otherwise. `.withoutHtmlTransformation()` - User-facing changes in `src/` need a Changeset: run `pnpm changeset` and commit the generated `.changeset/*.md`. - Package version is duplicated in `src/apiRequestHeaders.ts` as `MOS_PROXY_PACKAGE_VERSION`. If manually bumping `package.json`, also update that constant or run `node scripts/sync-version.mjs && pnpm format`. -- `prepublishOnly` runs `typecheck && test:run && build`; do not bypass it. +- `prepublishOnly` runs `typecheck && test:run && verify:package`; do not bypass it. It carries no separate `build` step because `verify:package` builds. It is also the whole publish gate: `release.yml` only installs and hands off to the Changesets action, because npm runs `prepublishOnly` before packing, against the exact tarball being uploaded. `ci.yml` and `release.yml` are independent workflows on the same push, so a red CI run does not by itself stop a publish. - `pnpm-workspace.yaml` sets `minimumReleaseAge: 4320`, so very recent dependency versions may be rejected during install. Choose an older version instead of disabling the setting. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 28ef5106..3cc7a4db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,3 +23,22 @@ jobs: - run: pnpm typecheck - run: pnpm test:run - run: pnpm build + + package-resolution: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [22, 24] + steps: + - uses: actions/checkout@v6 + + - uses: pnpm/action-setup@v6 + + - uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + cache: pnpm + + - run: pnpm install --frozen-lockfile + - run: pnpm verify:package diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3a45f02..4c677ba0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,9 +30,6 @@ jobs: registry-url: https://registry.npmjs.org - run: pnpm install --frozen-lockfile - - run: pnpm run typecheck - - run: pnpm run test:run - - run: pnpm run build - name: Create Release PR or publish uses: changesets/action@v1 diff --git a/package.json b/package.json index 8c2bad0a..946080d4 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,17 @@ "access": "public" }, "scripts": { - "build": "tsc -p tsconfig.build.json", + "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "test": "vitest", "test:run": "vitest run", "typecheck": "tsc --noEmit", + "verify:package": "node scripts/verify-package-resolution.mjs", "lint": "biome check --diagnostic-level=error --no-errors-on-unmatched", "format": "biome check --write --no-errors-on-unmatched", "changeset": "changeset", "version": "changeset version && node scripts/sync-version.mjs && pnpm run format", "release": "changeset publish", - "prepublishOnly": "pnpm run typecheck && pnpm run test:run && pnpm run build" + "prepublishOnly": "pnpm run typecheck && pnpm run test:run && pnpm run verify:package" }, "dependencies": { "cookie": "^1.1.1" @@ -51,6 +52,7 @@ "@changesets/cli": "^2.31.0", "@types/node": "^24.12.3", "html-rewriter-wasm": "^0.4.1", + "tsc-alias": "^1.9.1", "typescript": "^6.0.3", "vitest": "~4.1.5" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cefd7bc2..dcd860b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: html-rewriter-wasm: specifier: ^0.4.1 version: 0.4.1 + tsc-alias: + specifier: ^1.9.1 + version: 1.9.1 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -540,6 +543,10 @@ packages: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} @@ -558,6 +565,10 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -569,6 +580,14 @@ packages: chardet@2.1.1: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -659,6 +678,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -685,6 +707,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -741,6 +767,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mylas@2.1.14: + resolution: {integrity: sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==} + engines: {node: '>=16.0.0'} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -755,6 +785,10 @@ packages: encoding: optional: true + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -814,6 +848,10 @@ packages: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} + plimit-lit@1.6.1: + resolution: {integrity: sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==} + engines: {node: '>=12'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} @@ -826,6 +864,10 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + queue-lit@1.5.2: + resolution: {integrity: sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==} + engines: {node: '>=12'} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -833,10 +875,17 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -926,6 +975,11 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tsc-alias@1.9.1: + resolution: {integrity: sha512-sFZdVFthH8uvdplPJrOYGOHcxu6UPtcAcY678JPwEQiMzgLZYFO7Qc/rzELp7ingTc+OxtzH6n+8Pn2eVQep6w==} + engines: {node: '>=16.20.2'} + hasBin: true + typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} @@ -1486,6 +1540,11 @@ snapshots: ansi-regex@5.0.1: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 @@ -1500,6 +1559,8 @@ snapshots: dependencies: is-windows: 1.0.2 + binary-extensions@2.3.0: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1508,6 +1569,20 @@ snapshots: chardet@2.1.1: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + commander@9.5.0: {} + convert-source-map@2.0.0: {} cookie@1.1.1: {} @@ -1614,6 +1689,10 @@ snapshots: fsevents@2.3.3: optional: true + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -1639,6 +1718,10 @@ snapshots: ignore@5.3.2: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-extglob@2.1.1: {} is-glob@4.0.3: @@ -1687,12 +1770,16 @@ snapshots: mri@1.2.0: {} + mylas@2.1.14: {} + nanoid@3.3.12: {} node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + normalize-path@3.0.0: {} + obug@2.1.1: {} outdent@0.5.0: {} @@ -1733,6 +1820,10 @@ snapshots: pify@4.0.1: {} + plimit-lit@1.6.1: + dependencies: + queue-lit: 1.5.2 + postcss@8.5.14: dependencies: nanoid: 3.3.12 @@ -1743,6 +1834,8 @@ snapshots: quansync@0.2.11: {} + queue-lit@1.5.2: {} + queue-microtask@1.2.3: {} read-yaml-file@1.1.0: @@ -1752,8 +1845,14 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} + reusify@1.1.0: {} rollup@4.60.3: @@ -1845,6 +1944,16 @@ snapshots: tr46@0.0.3: {} + tsc-alias@1.9.1: + dependencies: + chokidar: 3.6.0 + commander: 9.5.0 + get-tsconfig: 4.14.0 + globby: 11.1.0 + mylas: 2.1.14 + normalize-path: 3.0.0 + plimit-lit: 1.6.1 + typescript@6.0.3: {} undici-types@7.16.0: {} diff --git a/scripts/verify-package-resolution.mjs b/scripts/verify-package-resolution.mjs new file mode 100644 index 00000000..ad50c226 --- /dev/null +++ b/scripts/verify-package-resolution.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)) +const packageName = '@monetizationos/proxy' + +// Under `npm publish --dry-run` this script inherits npm_config_dry_run, which makes `pnpm pack` report a path it never writes. +delete process.env.npm_config_dry_run +const binary = (name) => join(repoRoot, 'node_modules', '.bin', name) + +const expectedRuntimeExports = [ + 'ConfigUnresolvableError', + 'MOSProxy', + 'MOSProxyBuilder', + 'buildIdentity', + 'defaultPersistIdentity', + 'defaultResolveIdentity', + 'getExistingCookies', + 'hostPathMatcher', +] + +const run = (command, args, cwd, { showOutput = false } = {}) => { + try { + return execFileSync(command, args, { + cwd, + encoding: 'utf8', + stdio: showOutput ? ['ignore', 'inherit', 'inherit'] : ['ignore', 'pipe', 'inherit'], + }) + } catch (error) { + throw new Error(`${command} ${args.join(' ')} exited with ${error.status}\n${error.stdout ?? ''}`.trim()) + } +} + +const step = (message) => console.log(`\n=== ${message}`) + +const relativeSpecifier = /\b(?:from|import)\s*\(?\s*(['"])(\.[^'"]+)\1/g +const runtimeExtension = /\.(?:js|mjs|cjs|json)$/ + +const emittedFiles = (directory) => + readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return emittedFiles(path) + } + return /\.(?:js|mjs|cjs|d\.ts)$/.test(entry.name) ? [path] : [] + }) + +const specifiersNodeCannotResolve = (distDir) => + emittedFiles(distDir).flatMap((path) => { + const contents = readFileSync(path, 'utf8') + return [...contents.matchAll(relativeSpecifier)] + .map((match) => match[2]) + .filter((specifier) => !runtimeExtension.test(specifier)) + .map((specifier) => `${relative(distDir, path)}: ${specifier}`) + }) + +const workDir = mkdtempSync(join(tmpdir(), 'mos-proxy-resolution-')) +const consumerDir = join(workDir, 'consumer') +mkdirSync(consumerDir) + +const failures = [] + +// Each check runs even when an earlier one failed, so one run reports every way the package fails to resolve. +const runCheck = (message, body) => { + step(message) + try { + body() + } catch (error) { + failures.push(`${message}\n${error.message}`) + console.log(`FAILED: ${error.message}`) + } +} + +try { + step('Building the package') + run('pnpm', ['run', 'build'], repoRoot) + + step('Packing the tarball') + run('pnpm', ['pack', '--pack-destination', workDir], repoRoot) + const tarball = readdirSync(workDir).find((entry) => entry.endsWith('.tgz')) + if (!tarball) { + throw new Error(`pnpm pack produced no tarball in ${workDir}`) + } + + step('Installing the tarball into a scratch consumer') + writeFileSync( + join(consumerDir, 'package.json'), + `${JSON.stringify({ name: 'mos-proxy-resolution-consumer', private: true, type: 'module' }, null, 2)}\n`, + ) + // npm rather than pnpm because pnpm's symlinked store tests a layout this package is not published for. + run('npm', ['install', join(workDir, tarball), '--no-audit', '--no-fund', '--loglevel=error'], consumerDir) + + // The only check that covers a module no consumer import reaches, and the emitted form rather than one entry point. + runCheck('Scanning the installed dist for relative specifiers Node cannot resolve', () => { + const distDir = join(consumerDir, 'node_modules', ...packageName.split('/'), 'dist') + const unresolvable = specifiersNodeCannotResolve(distDir) + if (unresolvable.length > 0) { + throw new Error(`Relative specifiers without a runtime extension:\n${unresolvable.join('\n')}`) + } + console.log(`Scanned ${emittedFiles(distDir).length} emitted files, every relative specifier carries a runtime extension`) + }) + + runCheck(`Importing ${packageName} from plain Node ${process.version}`, () => { + const importedExports = JSON.parse( + run( + process.execPath, + [ + '--input-type=module', + '-e', + `const module = await import(${JSON.stringify(packageName)}); console.log(JSON.stringify(Object.keys(module).sort()))`, + ], + consumerDir, + ), + ) + const missingExports = expectedRuntimeExports.filter((name) => !importedExports.includes(name)) + if (missingExports.length > 0) { + throw new Error(`The imported module is missing exports: ${missingExports.join(', ')}`) + } + console.log(`Imported exports: ${importedExports.join(', ')}`) + }) + + // skipLibCheck stays off so a relative specifier the consumer's TypeScript cannot follow fails here. + runCheck('Type-checking a NodeNext consumer against the shipped declarations', () => { + writeFileSync( + join(consumerDir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + target: 'esnext', + lib: ['esnext', 'dom', 'dom.iterable'], + module: 'nodenext', + moduleResolution: 'nodenext', + strict: true, + skipLibCheck: false, + noEmit: true, + }, + include: ['consumer.ts'], + }, + null, + 2, + )}\n`, + ) + writeFileSync( + join(consumerDir, 'consumer.ts'), + [ + `import { type MOSConfig, type MOSProxyLogger, MOSProxyBuilder } from ${JSON.stringify(packageName)}`, + '', + 'export const builder = new MOSProxyBuilder()', + 'export const config: MOSConfig | null = null', + 'export const logger: MOSProxyLogger | undefined = undefined', + '', + ].join('\n'), + ) + const tscVersion = run(binary('tsc'), ['--version'], consumerDir).trim() + run(binary('tsc'), ['--project', 'tsconfig.json'], consumerDir, { showOutput: true }) + console.log(`Type-checked with tsc ${tscVersion}`) + }) +} catch (error) { + failures.push(error.message) +} finally { + rmSync(workDir, { recursive: true, force: true }) +} + +if (failures.length > 0) { + console.error(`\nverify-package-resolution: ${packageName} does not resolve without a bundler\n`) + console.error(failures.join('\n\n')) + process.exit(1) +} + +console.log(`\nverify-package-resolution: ${packageName} resolves from plain Node ${process.version} with no bundler.`) diff --git a/tsconfig.build.json b/tsconfig.build.json index d5ecc4eb..f39f6c79 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -10,5 +10,8 @@ "rootDir": "src" }, "include": ["src/**/*.ts"], - "exclude": ["test", "dist", "node_modules"] + "exclude": ["test", "dist", "node_modules"], + "tsc-alias": { + "resolveFullPaths": true + } } From c36a9c782a34d363d198a8c3b49ce34532589630 Mon Sep 17 00:00:00 2001 From: Jay Feng Date: Tue, 28 Jul 2026 15:37:44 +0100 Subject: [PATCH 2/3] MD-2245 Extract a clean script for emptying dist --- .github/copilot-instructions.md | 3 ++- package.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 115f2716..d8dedeec 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,7 +27,8 @@ pnpm verify:package Notes: - `pnpm test` starts watch mode; use `pnpm test:run` in agents and CI. -- `pnpm typecheck` covers `src/**/*.ts` and `test/**/*.ts`; `pnpm build` emits only `src/` to `dist/`, then rewrites the emitted specifiers with `tsc-alias`. +- `pnpm typecheck` covers `src/**/*.ts` and `test/**/*.ts`; `pnpm build` runs `pnpm clean`, emits only `src/` to `dist/`, then rewrites the emitted specifiers with `tsc-alias`. +- `pnpm clean` removes `dist/`. Anything that needs an empty `dist/` calls it rather than deleting the directory itself, so `tsc` never inherits outputs from a source file that has since been renamed or deleted. - `pnpm verify:package` builds and packs the tarball, installs it into a scratch consumer, then scans the installed `dist` for relative specifiers Node cannot resolve, imports the package from plain Node, and type-checks against it on `moduleResolution: NodeNext`. Run it when you touch packaging, `tsconfig*.json`, or the shape of an import; CI runs it on Node 22 and 24, the only versions this package supports. - If lint fails, run `pnpm format`, then rerun `pnpm lint`. - Do not commit `dist/`, `node_modules/`, `coverage/`, or lockfile changes unless dependencies changed. diff --git a/package.json b/package.json index 946080d4..96052f9f 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,8 @@ "access": "public" }, "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "clean": "rm -rf dist", + "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", "test": "vitest", "test:run": "vitest run", "typecheck": "tsc --noEmit", From 49797da18124a1a3c5a1c6a4e17278c5b9fbcbe8 Mon Sep 17 00:00:00 2001 From: Jay Feng Date: Tue, 28 Jul 2026 18:04:44 +0100 Subject: [PATCH 3/3] MD-2245 Build in prepack so packing never depends on a prior build --- .github/copilot-instructions.md | 5 +++-- package.json | 1 + scripts/verify-package-resolution.mjs | 4 +--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d8dedeec..b925a9c4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -29,7 +29,7 @@ Notes: - `pnpm test` starts watch mode; use `pnpm test:run` in agents and CI. - `pnpm typecheck` covers `src/**/*.ts` and `test/**/*.ts`; `pnpm build` runs `pnpm clean`, emits only `src/` to `dist/`, then rewrites the emitted specifiers with `tsc-alias`. - `pnpm clean` removes `dist/`. Anything that needs an empty `dist/` calls it rather than deleting the directory itself, so `tsc` never inherits outputs from a source file that has since been renamed or deleted. -- `pnpm verify:package` builds and packs the tarball, installs it into a scratch consumer, then scans the installed `dist` for relative specifiers Node cannot resolve, imports the package from plain Node, and type-checks against it on `moduleResolution: NodeNext`. Run it when you touch packaging, `tsconfig*.json`, or the shape of an import; CI runs it on Node 22 and 24, the only versions this package supports. +- `pnpm verify:package` packs the tarball, installs it into a scratch consumer, then scans the installed `dist` for relative specifiers Node cannot resolve, imports the package from plain Node, and type-checks against it on `moduleResolution: NodeNext`. It does not build; `prepack` does, so the tarball it verifies is produced the same way the published one is. Run it when you touch packaging, `tsconfig*.json`, or the shape of an import; CI runs it on Node 22 and 24, the only versions this package supports. - If lint fails, run `pnpm format`, then rerun `pnpm lint`. - Do not commit `dist/`, `node_modules/`, `coverage/`, or lockfile changes unless dependencies changed. @@ -67,5 +67,6 @@ Stages 3-6 are HTML-only and auto-skip otherwise. `.withoutHtmlTransformation()` - User-facing changes in `src/` need a Changeset: run `pnpm changeset` and commit the generated `.changeset/*.md`. - Package version is duplicated in `src/apiRequestHeaders.ts` as `MOS_PROXY_PACKAGE_VERSION`. If manually bumping `package.json`, also update that constant or run `node scripts/sync-version.mjs && pnpm format`. -- `prepublishOnly` runs `typecheck && test:run && verify:package`; do not bypass it. It carries no separate `build` step because `verify:package` builds. It is also the whole publish gate: `release.yml` only installs and hands off to the Changesets action, because npm runs `prepublishOnly` before packing, against the exact tarball being uploaded. `ci.yml` and `release.yml` are independent workflows on the same push, so a red CI run does not by itself stop a publish. +- `prepack` runs `build`, so every `pnpm pack` and `pnpm publish` produces a freshly built tarball no matter who invokes it. Nothing else needs to build first, which is why `verify:package` does not. +- `prepublishOnly` runs `typecheck && test:run && verify:package`; do not bypass it. It is the whole publish gate: `release.yml` only installs and hands off to the Changesets action. Note that `prepublishOnly` fires before `prepack`, so it validates the working tree rather than the finished tarball. `ci.yml` and `release.yml` are independent workflows on the same push, so a red CI run does not by itself stop a publish. - `pnpm-workspace.yaml` sets `minimumReleaseAge: 4320`, so very recent dependency versions may be rejected during install. Choose an older version instead of disabling the setting. diff --git a/package.json b/package.json index 96052f9f..2111a9ed 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "scripts": { "clean": "rm -rf dist", "build": "pnpm run clean && tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json", + "prepack": "pnpm run build", "test": "vitest", "test:run": "vitest run", "typecheck": "tsc --noEmit", diff --git a/scripts/verify-package-resolution.mjs b/scripts/verify-package-resolution.mjs index ad50c226..c5d87b3d 100644 --- a/scripts/verify-package-resolution.mjs +++ b/scripts/verify-package-resolution.mjs @@ -76,9 +76,7 @@ const runCheck = (message, body) => { } try { - step('Building the package') - run('pnpm', ['run', 'build'], repoRoot) - + // `prepack` builds, so packing here exercises the same path that produces the published tarball. step('Packing the tarball') run('pnpm', ['pack', '--pack-destination', workDir], repoRoot) const tarball = readdirSync(workDir).find((entry) => entry.endsWith('.tgz'))