From 816cbc0a74dabb7420970c9178b99c8b37dab1a6 Mon Sep 17 00:00:00 2001 From: Kevin Walker Date: Wed, 5 Aug 2026 10:51:27 +0200 Subject: [PATCH] feat!: ship dual ESM/CJS builds and require Node 20.9+ Ship dual ESM and CommonJS builds for every public entry point (`.`, `/http`, `/datocms`, `/next`), verified against a packed tarball and by real Next builds on both bundlers. The `/next` subpath keeps bare `next/*` specifiers. The fully-specified `next/headers.js` form resolves under Node and webpack but makes Turbopack miss its react-server aliases, so `next build` fails with MODULE_UNPARSABLE on app-router-context. BREAKING CHANGE: engines.node is now >=20.9.0 Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 11 +- .github/workflows/test.yml | 98 +++++++- .gitignore | 3 + README.md | 21 +- package-lock.json | 3 + package.json | 70 +++++- scripts/verify-next-build.mjs | 269 ++++++++++++++++++++ scripts/verify-packed-package.mjs | 401 ++++++++++++++++++++++++++++++ scripts/write-cjs-package.mjs | 9 + tsconfig.build.json | 4 - tsconfig.cjs.json | 10 + tsconfig.esm.json | 10 + tsconfig.json | 10 +- 13 files changed, 896 insertions(+), 23 deletions(-) create mode 100644 scripts/verify-next-build.mjs create mode 100644 scripts/verify-packed-package.mjs create mode 100644 scripts/write-cjs-package.mjs delete mode 100644 tsconfig.build.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ce23bb..dc8e7e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -26,11 +26,14 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 + cache: npm - run: npm ci - - run: npm run prettier - - run: npm run lint - - run: npm run test - - run: npm run build + # Deliberately not `npm run verify`: the install-based export smokes and the + # Next build smokes need the npm registry, and a registry blip must not fail a + # release. PR CI (test.yml) gates those on the same content; keep the required + # status checks enabled on main so nothing reaches here unverified. This job + # still builds and asserts the published layout offline. + - run: npm run verify:release - name: semantic release uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6571851..75fc423 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,7 +3,7 @@ name: Test on: pull_request: branches: - - "**" + - '**' concurrency: group: tests-${{ github.ref }} @@ -21,8 +21,104 @@ jobs: - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 + cache: npm - run: npm ci - run: npm run prettier - run: npm run lint - run: npm run test - run: npm run build + - run: npm run test:exports + - name: Pack package for smoke tests + run: | + mkdir -p artifacts + npm pack --silent --pack-destination artifacts + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: packed-package + path: artifacts/*.tgz + if-no-files-found: error + + package-smoke: + name: Package smoke (Node ${{ matrix.node }}) + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Node 24 is already covered by the lint/test job's export smoke. + node: [20.9.0, 22] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: ${{ matrix.node }} + # Warms ~/.npm from the lockfile so the fixture installs below can run + # --prefer-offline instead of hitting the registry for pinned peers. + cache: npm + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: packed-package + path: artifacts + - name: Verify packed package + run: | + tarball="$(ls artifacts/*.tgz)" + node scripts/verify-packed-package.mjs --tarball "$tarball" + + next-build: + name: Next build smoke (next@${{ matrix.next }}) + needs: test + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # Both ends of the `next` peer range, each built on Turbopack AND webpack. + # Node-level resolution checks cannot catch bundler-specific breakage: + # fully-specified 'next/headers.js' imports pass under Node and webpack but + # make Turbopack miss its react-server aliases, failing the build. + next: ['15.5.12', '16.2.12'] + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: 24 + cache: npm + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: packed-package + path: artifacts + - name: Verify Next build + run: | + tarball="$(ls artifacts/*.tgz)" + node scripts/verify-next-build.mjs --tarball "$tarball" --next '${{ matrix.next }}' + + # Single stable context to require in branch protection on main. The jobs above + # carry matrix values in their names ("Next build smoke (next@16.2.12)"), so + # requiring them directly would break protection every time a matrix value is + # bumped: the old context stops reporting and blocks every PR. Require only "CI". + # + # release.yml deliberately skips the registry-dependent smokes and trusts that + # this gate ran on the same content, so keep it required. + ci: + name: CI + if: always() + needs: [test, package-smoke, next-build] + runs-on: ubuntu-latest + + steps: + - name: Verify all required jobs succeeded + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + echo "dependency results: $RESULTS" + for result in $RESULTS; do + if [ "$result" != "success" ]; then + echo "::error::a required job did not succeed ($RESULTS)" + exit 1 + fi + done diff --git a/.gitignore b/.gitignore index fa28420..0f42edf 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# packed tarballs +*.tgz diff --git a/README.md b/README.md index 9b0547d..639d4bb 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,21 @@ A collection of general purpose utilities and helpers for web projects. npm install @smartive/utils ``` +Requires **Node.js 20.9+** (breaking change if you are still on older Node). The package ships +dual ESM and CommonJS builds for every public entry point (`.`, `/http`, `/datocms`, `/next`), +so both `import` and `require` work: + +```typescript +// ESM +import { createDatoClient } from '@smartive/utils/datocms'; + +// CommonJS +const { createDatoClient } = require('@smartive/utils/datocms'); +``` + The root export (`@smartive/utils`) stays dependency-free. Optional peer dependencies are only -required when you import the corresponding subpath. +required when you import the corresponding subpath. One caveat applies to `/next` — see +[`@smartive/utils/next`](#smartiveutilsnext) below. ## Utilities @@ -86,6 +99,12 @@ Next.js App Router helpers for draft mode, DatoCMS web previews, and cache reval npm install next ``` +**Requires a bundler.** This subpath imports `next/headers`, `next/navigation`, and `next/server` +as bare specifiers. Next's bundlers (Turbopack and webpack) resolve those, but Node's ESM loader +cannot, because Next ships no package `exports` map. Inside a Next app — the only place these APIs +work, since they need a request context — this is transparent. Outside one, `require()` resolves +but a raw `import` does not. + ```typescript // app/api/draft/enable/route.ts import { createDraftHandlers } from '@smartive/utils/next'; diff --git a/package-lock.json b/package-lock.json index f262987..b073e48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,9 @@ "typescript": "5.9.3", "vitest": "4.1.10" }, + "engines": { + "node": ">=20.9.0" + }, "peerDependencies": { "@datocms/cda-client": "^0.2.10", "next": "^15.0.0 || ^16.0.0" diff --git a/package.json b/package.json index aa9ebc6..4d95300 100644 --- a/package.json +++ b/package.json @@ -5,22 +5,53 @@ "type": "module", "source": "./src/index.ts", "sideEffects": false, + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "types": "./dist/cjs/index.d.ts", "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + }, + "default": "./dist/esm/index.js" }, "./http": { - "types": "./dist/http/index.d.ts", - "import": "./dist/http/index.js" + "import": { + "types": "./dist/esm/http/index.d.ts", + "default": "./dist/esm/http/index.js" + }, + "require": { + "types": "./dist/cjs/http/index.d.ts", + "default": "./dist/cjs/http/index.js" + }, + "default": "./dist/esm/http/index.js" }, "./datocms": { - "types": "./dist/datocms/index.d.ts", - "import": "./dist/datocms/index.js" + "import": { + "types": "./dist/esm/datocms/index.d.ts", + "default": "./dist/esm/datocms/index.js" + }, + "require": { + "types": "./dist/cjs/datocms/index.d.ts", + "default": "./dist/cjs/datocms/index.js" + }, + "default": "./dist/esm/datocms/index.js" }, "./next": { - "types": "./dist/next/index.d.ts", - "import": "./dist/next/index.js" + "import": { + "types": "./dist/esm/next/index.d.ts", + "default": "./dist/esm/next/index.js" + }, + "require": { + "types": "./dist/cjs/next/index.d.ts", + "default": "./dist/cjs/next/index.js" + }, + "default": "./dist/esm/next/index.js" } }, "files": [ @@ -28,13 +59,30 @@ "src/**/*", "!src/**/*.test.ts" ], + "typesVersions": { + "*": { + "http": ["./dist/cjs/http/index.d.ts"], + "datocms": ["./dist/cjs/datocms/index.d.ts"], + "next": ["./dist/cjs/next/index.d.ts"] + } + }, + "engines": { + "node": ">=20.9.0" + }, "scripts": { "clean": "rimraf dist", "prebuild": "npm run clean", - "build": "tsc -p tsconfig.build.json", + "build:esm": "tsc -p tsconfig.esm.json", + "build:cjs": "tsc -p tsconfig.cjs.json && node scripts/write-cjs-package.mjs", + "build": "npm run build:esm && npm run build:cjs", "lint": "eslint src", - "prettier": "prettier --check src", - "test": "vitest run" + "prettier": "prettier --check src scripts README.md", + "test": "vitest run", + "test:exports": "node scripts/verify-packed-package.mjs", + "test:exports:layout": "node scripts/verify-packed-package.mjs --layout-only", + "test:next-build": "node scripts/verify-next-build.mjs", + "verify": "npm run prettier && npm run lint && npm run test && npm run build && npm run test:exports", + "verify:release": "npm run prettier && npm run lint && npm run test && npm run build && npm run test:exports:layout" }, "publishConfig": { "access": "public" diff --git a/scripts/verify-next-build.mjs b/scripts/verify-next-build.mjs new file mode 100644 index 0000000..77e7edb --- /dev/null +++ b/scripts/verify-next-build.mjs @@ -0,0 +1,269 @@ +// Builds a real Next.js app against the packed package and asserts that every +// /next export survives a production build on BOTH bundlers. +// +// This exists because Node-level resolution checks (verify-packed-package.mjs) +// cannot catch bundler-specific breakage. Concretely: writing fully-specified +// 'next/headers.js' imports in src resolves fine under Node and webpack, but +// Turbopack does not apply its react-server aliases to the .js form, so a route +// handler pulls the client navigation module and the build dies with +// MODULE_UNPARSABLE on app-router-context. Only a real `next build` catches it. +import { spawnSync } from 'node:child_process'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const cleanupDirs = []; +const isWindows = process.platform === 'win32'; +const npmCmd = isWindows ? 'npm.cmd' : 'npm'; +const runTimeoutMs = 10 * 60 * 1000; + +const readFlag = (name) => { + const index = process.argv.indexOf(`--${name}`); + return index === -1 ? null : (process.argv[index + 1] ?? null); +}; + +const providedTarball = readFlag('tarball') === null ? null : resolve(readFlag('tarball')); +const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')); +const nextVersion = readFlag('next') ?? packageJson.devDependencies.next; +const reactVersion = packageJson.devDependencies.react; +const reactDomVersion = packageJson.devDependencies['react-dom']; +const typescriptVersion = packageJson.devDependencies.typescript; +const typesNodeVersion = packageJson.devDependencies['@types/node']; + +const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } +}; + +const run = (command, args, cwd, env) => { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: runTimeoutMs, + shell: false, + env: { ...process.env, ...env }, + }); + + if (result.status !== 0) { + throw new Error( + [ + `${command} ${args.join(' ')} failed in ${cwd}`, + result.error ? `spawn error: ${result.error.message}` : null, + result.signal ? `signal: ${result.signal}` : null, + `exit status: ${result.status}`, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join('\n'), + ); + } + + return `${result.stdout ?? ''}\n${result.stderr ?? ''}`; +}; + +const writeApp = (appDir) => { + const write = (relativePath, contents) => { + const target = join(appDir, relativePath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + }; + + write('package.json', `${JSON.stringify({ name: 'next-build-smoke', private: true, version: '0.0.0' }, null, 2)}\n`); + write('next.config.mjs', 'export default {};\n'); + write( + 'tsconfig.json', + `${JSON.stringify( + { + compilerOptions: { + target: 'ES2022', + lib: ['ES2022', 'DOM'], + strict: true, + noEmit: true, + module: 'esnext', + // What real Next apps use, and the resolution mode our published + // declarations must satisfy. + moduleResolution: 'bundler', + jsx: 'preserve', + skipLibCheck: true, + esModuleInterop: true, + isolatedModules: true, + incremental: true, + plugins: [{ name: 'next' }], + }, + include: ['**/*.ts', '**/*.tsx', 'next-env.d.ts'], + }, + null, + 2, + )}\n`, + ); + + write( + 'app/layout.tsx', + `export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} +`, + ); + + // Server Component: exercises makeDraftModeWorkWithinIframes (next/headers) in + // the RSC layer, plus the dependency-free root entry point. + write( + 'app/page.tsx', + `import { classNames } from '@smartive/utils'; +import { makeDraftModeWorkWithinIframes } from '@smartive/utils/next'; + +export default async function Page() { + await makeDraftModeWorkWithinIframes(); + + return
ok
; +} +`, + ); + + // Route handler: exercises next/headers + next/navigation (redirect) + next/server + // in the server-only app-route layer. This is what the .js specifiers broke. + write( + 'app/api/draft/route.ts', + `import { createDraftHandlers } from '@smartive/utils/next'; + +const handlers = createDraftHandlers({ secret: 'test-secret' }); + +export const GET = handlers.enable; +export const DELETE = handlers.disable; +`, + ); + + write( + 'app/api/revalidate/route.ts', + `import { createRevalidateHandler } from '@smartive/utils/next'; + +export const POST = createRevalidateHandler({ secret: 'test-secret', paths: ['/sitemap.xml'] }); +`, + ); + + write( + 'app/api/preview-links/route.ts', + `import { createWebPreviewsHandler } from '@smartive/utils/next'; + +const handlers = createWebPreviewsHandler({ + baseUrl: 'https://example.com/api/draft', + secret: 'test-secret', + resolvePreviewUrl: async () => '/preview', +}); + +export const OPTIONS = handlers.OPTIONS; +export const POST = handlers.POST; +`, + ); +}; + +const expectedRoutes = ['/api/draft', '/api/preview-links', '/api/revalidate']; + +let primaryError = null; + +try { + let tarballPath; + + if (providedTarball) { + assert(existsSync(providedTarball), `--tarball path not found: ${providedTarball}`); + const packDir = mkdtempSync(join(tmpdir(), 'smartive-utils-next-pack-')); + cleanupDirs.push(packDir); + tarballPath = join(packDir, 'package.tgz'); + copyFileSync(providedTarball, tarballPath); + } else { + const packDir = mkdtempSync(join(tmpdir(), 'smartive-utils-next-pack-')); + cleanupDirs.push(packDir); + const tarballName = run(npmCmd, ['pack', '--silent', '--pack-destination', packDir], rootDir).trim(); + tarballPath = join(packDir, tarballName); + } + + const appDir = mkdtempSync(join(tmpdir(), 'smartive-utils-next-app-')); + cleanupDirs.push(appDir); + writeApp(appDir); + + run( + npmCmd, + [ + 'install', + '--no-package-lock', + '--no-fund', + '--no-audit', + '--prefer-offline', + tarballPath, + `next@${nextVersion}`, + `react@${reactVersion}`, + `react-dom@${reactDomVersion}`, + `typescript@${typescriptVersion}`, + `@types/node@${typesNodeVersion}`, + '@types/react', + '@types/react-dom', + ], + appDir, + ); + + const nextBin = join(appDir, 'node_modules', 'next', 'dist', 'bin', 'next'); + assert(existsSync(nextBin), `next binary not found at ${nextBin}`); + + // Bundler flags differ across the supported peer range: Next 15 defaults to + // webpack and opts into Turbopack, Next 16 is the reverse. Probe the CLI rather + // than hardcoding majors so this keeps working as the range moves. + const buildHelp = run('node', [nextBin, 'build', '--help'], appDir); + const bundlers = []; + + if (buildHelp.includes('--turbopack')) { + bundlers.push(['turbopack', ['--turbopack']]); + } else { + console.log(`next@${nextVersion}: no --turbopack build flag, skipping the Turbopack build`); + } + + bundlers.push(buildHelp.includes('--webpack') ? ['webpack', ['--webpack']] : ['webpack (default)', []]); + + assert(bundlers.length === 2, `next@${nextVersion}: expected to cover both bundlers, got ${bundlers.length}`); + + for (const [label, flags] of bundlers) { + rmSync(join(appDir, '.next'), { recursive: true, force: true }); + const output = run('node', [nextBin, 'build', ...flags], appDir, { + NODE_ENV: 'production', + NEXT_TELEMETRY_DISABLED: '1', + }); + + for (const route of expectedRoutes) { + assert(output.includes(route), `next@${nextVersion} ${label}: route ${route} missing from build output:\n${output}`); + } + + console.log(`next@${nextVersion} build (${label}): ok`); + } +} catch (error) { + primaryError = error; +} finally { + const cleanupErrors = []; + for (const dir of cleanupDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(`${dir}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (primaryError) { + if (cleanupErrors.length > 0) { + primaryError.message = `${primaryError.message}\nCleanup warnings:\n${cleanupErrors.join('\n')}`; + } + throw primaryError; + } + + if (cleanupErrors.length > 0) { + throw new Error(`Verification passed but cleanup failed:\n${cleanupErrors.join('\n')}`); + } +} + +console.log('Next build smoke tests passed.'); diff --git a/scripts/verify-packed-package.mjs b/scripts/verify-packed-package.mjs new file mode 100644 index 0000000..43e0c99 --- /dev/null +++ b/scripts/verify-packed-package.mjs @@ -0,0 +1,401 @@ +import { spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, readFileSync, existsSync, copyFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const cleanupDirs = []; +const isWindows = process.platform === 'win32'; +const npmCmd = isWindows ? 'npm.cmd' : 'npm'; +const runTimeoutMs = 5 * 60 * 1000; + +const tarballArgIndex = process.argv.indexOf('--tarball'); +const providedTarball = tarballArgIndex === -1 ? null : resolve(process.argv[tarballArgIndex + 1] ?? ''); +// --layout-only asserts the built dist/ matches the exports map and nothing else. +// It touches no network, so the release job can sanity-check the artifact it is +// about to publish without depending on registry availability. The install-based +// smokes below need the registry and belong in PR CI. +const layoutOnly = process.argv.includes('--layout-only'); + +const assert = (condition, message) => { + if (!condition) { + throw new Error(message); + } +}; + +const formatRunFailure = (command, args, cwd, result) => { + const parts = [`${command} ${args.join(' ')} failed in ${cwd}`]; + if (result.error) { + parts.push(`spawn error: ${result.error.message}`); + } + if (result.signal) { + parts.push(`signal: ${result.signal}`); + } + if (result.status !== null && result.status !== undefined) { + parts.push(`exit status: ${result.status}`); + } + if (result.stdout) { + parts.push(result.stdout); + } + if (result.stderr) { + parts.push(result.stderr); + } + return parts.filter(Boolean).join('\n'); +}; + +const run = (command, args, cwd) => { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: runTimeoutMs, + shell: false, + }); + + if (result.status !== 0) { + throw new Error(formatRunFailure(command, args, cwd, result)); + } + + return result.stdout.trim(); +}; + +const runExpectFailure = (command, args, cwd, { label, pattern }) => { + const result = spawnSync(command, args, { + cwd, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + timeout: runTimeoutMs, + shell: false, + }); + + assert(result.status !== 0, `${label}: expected failure but command succeeded`); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + assert( + pattern.test(output), + [ + `${label}: output did not match ${pattern}`, + `exit status: ${result.status}`, + result.error ? `spawn error: ${result.error.message}` : null, + result.stdout, + result.stderr, + ] + .filter(Boolean) + .join('\n'), + ); +}; + +const assertKeysEqual = (keys, expectedNames, label) => { + const actual = [...keys].sort(); + const expected = [...expectedNames].sort(); + assert( + actual.length === expected.length && actual.every((key, index) => key === expected[index]), + `${label}: expected exports [${expected.join(', ')}] but got [${actual.join(', ')}]`, + ); +}; + +const packageJson = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8')); +const typescriptVersion = packageJson.devDependencies.typescript; +const typesNodeVersion = packageJson.devDependencies['@types/node']; +const datocmsVersion = packageJson.devDependencies['@datocms/cda-client']; +const nextVersion = packageJson.devDependencies.next; +const reactVersion = packageJson.devDependencies.react; +const reactDomVersion = packageJson.devDependencies['react-dom']; + +const rootExports = ['classNames', 'getTelLink']; +const httpExports = ['isSafeRelativePath', 'isValidToken', 'withCORS']; +const datocmsExports = ['createDatoClient', 'queryDatoCMS']; +const nextExports = [ + 'createDraftHandlers', + 'createRevalidateHandler', + 'createWebPreviewsHandler', + 'makeDraftModeWorkWithinIframes', +]; + +const assertBuiltLayout = () => { + for (const entry of ['.', './http', './datocms', './next']) { + const conditions = packageJson.exports[entry]; + assert(conditions?.import?.default, `exports.${entry}.import.default missing`); + assert(conditions?.require?.default, `exports.${entry}.require.default missing`); + assert(conditions?.import?.types, `exports.${entry}.import.types missing`); + assert(conditions?.require?.types, `exports.${entry}.require.types missing`); + assert(existsSync(join(rootDir, conditions.import.default)), `missing file ${conditions.import.default}`); + assert(existsSync(join(rootDir, conditions.require.default)), `missing file ${conditions.require.default}`); + assert(existsSync(join(rootDir, conditions.import.types)), `missing file ${conditions.import.types}`); + assert(existsSync(join(rootDir, conditions.require.types)), `missing file ${conditions.require.types}`); + } + + assert(existsSync(join(rootDir, 'dist/cjs/package.json')), 'missing dist/cjs/package.json marker'); + assert( + JSON.parse(readFileSync(join(rootDir, 'dist/cjs/package.json'), 'utf8')).type === 'commonjs', + 'cjs package marker must be commonjs', + ); +}; + +if (layoutOnly) { + assertBuiltLayout(); + console.log('Built package layout matches the exports map.'); + process.exit(0); +} + +const installFixture = ({ name, type, packages }) => { + const fixtureDir = mkdtempSync(join(tmpdir(), `smartive-utils-${name}-`)); + cleanupDirs.push(fixtureDir); + writeFileSync( + join(fixtureDir, 'package.json'), + `${JSON.stringify({ name: `@smartive/utils-smoke-${name}`, private: true, type }, null, 2)}\n`, + ); + run(npmCmd, ['install', '--no-package-lock', '--no-fund', '--no-audit', '--prefer-offline', ...packages], fixtureDir); + return fixtureDir; +}; + +const writeAndRunKeysScript = (fixtureDir, fileName, specifier, type) => { + const filePath = join(fixtureDir, fileName); + if (type === 'module') { + writeFileSync(filePath, `import * as mod from '${specifier}';\nconsole.log(JSON.stringify(Object.keys(mod).sort()));\n`); + } else { + writeFileSync(filePath, `const mod = require('${specifier}');\nconsole.log(JSON.stringify(Object.keys(mod).sort()));\n`); + } + + return JSON.parse(run('node', [filePath], fixtureDir)); +}; + +const writeTsconfig = (dir, { module, moduleResolution, esModuleInterop = false }) => { + writeFileSync( + join(dir, 'tsconfig.json'), + `${JSON.stringify( + { + compilerOptions: { + module, + moduleResolution, + strict: true, + noEmit: true, + // Next's published types pull in incomplete peer graphs; we only assert + // that our package entry points resolve and type-check for consumers. + skipLibCheck: true, + esModuleInterop, + types: ['node'], + }, + include: ['./smoke.ts'], + }, + null, + 2, + )}\n`, + ); +}; + +let primaryError = null; + +try { + let tarballPath; + + if (providedTarball) { + assert(providedTarball && existsSync(providedTarball), `--tarball path not found: ${providedTarball}`); + const packDir = mkdtempSync(join(tmpdir(), 'smartive-utils-pack-')); + cleanupDirs.push(packDir); + tarballPath = join(packDir, 'package.tgz'); + copyFileSync(providedTarball, tarballPath); + } else { + assertBuiltLayout(); + const packDir = mkdtempSync(join(tmpdir(), 'smartive-utils-pack-')); + cleanupDirs.push(packDir); + const tarballName = run(npmCmd, ['pack', '--silent', '--pack-destination', packDir], rootDir); + tarballPath = join(packDir, tarballName); + } + + const sharedPeers = [ + tarballPath, + `@datocms/cda-client@${datocmsVersion}`, + `next@${nextVersion}`, + `react@${reactVersion}`, + `react-dom@${reactDomVersion}`, + ]; + + const minimalDir = installFixture({ + name: 'minimal', + type: 'module', + packages: [tarballPath], + }); + + assertKeysEqual( + writeAndRunKeysScript(minimalDir, 'smoke-root.mjs', '@smartive/utils', 'module'), + rootExports, + 'minimal ESM root', + ); + assertKeysEqual( + writeAndRunKeysScript(minimalDir, 'smoke-http.mjs', '@smartive/utils/http', 'module'), + httpExports, + 'minimal ESM http', + ); + + writeFileSync(join(minimalDir, 'smoke-datocms-fail.mjs'), `import '@smartive/utils/datocms';\n`); + runExpectFailure('node', [join(minimalDir, 'smoke-datocms-fail.mjs')], minimalDir, { + label: 'datocms without peer', + pattern: /@datocms\/cda-client/, + }); + + // The /next subpath is bundler-only: it imports bare 'next/headers' etc., and next + // ships no "exports" map, so Node's ESM loader cannot resolve those (no extension + // guessing in ESM). Do NOT switch src to 'next/headers.js' to make a raw ESM import + // work here -- Turbopack does not apply its react-server aliases to the .js form and + // `next build` then fails with MODULE_UNPARSABLE on app-router-context. + // Real coverage for this subpath lives in scripts/verify-next-build.mjs. + const minimalCjsDir = installFixture({ + name: 'minimal-cjs', + type: 'commonjs', + packages: [tarballPath], + }); + + writeFileSync(join(minimalCjsDir, 'smoke-next-fail.cjs'), `require('@smartive/utils/next');\n`); + runExpectFailure('node', [join(minimalCjsDir, 'smoke-next-fail.cjs')], minimalCjsDir, { + label: 'next without peer', + pattern: /next\/headers/, + }); + + const peerDir = installFixture({ + name: 'peers', + type: 'module', + packages: sharedPeers, + }); + + const cjsDir = installFixture({ + name: 'cjs', + type: 'commonjs', + packages: sharedPeers, + }); + + assertKeysEqual(writeAndRunKeysScript(cjsDir, 'smoke-root.cjs', '@smartive/utils', 'commonjs'), rootExports, 'cjs root'); + assertKeysEqual( + writeAndRunKeysScript(cjsDir, 'smoke-http.cjs', '@smartive/utils/http', 'commonjs'), + httpExports, + 'cjs http', + ); + assertKeysEqual( + writeAndRunKeysScript(cjsDir, 'smoke-datocms.cjs', '@smartive/utils/datocms', 'commonjs'), + datocmsExports, + 'cjs datocms', + ); + assertKeysEqual( + writeAndRunKeysScript(cjsDir, 'smoke-next.cjs', '@smartive/utils/next', 'commonjs'), + nextExports, + 'cjs next', + ); + + const cjsRequire = createRequire(join(cjsDir, 'package.json')); + assert(typeof cjsRequire('@smartive/utils/datocms').createDatoClient === 'function', 'createRequire datocms failed'); + assert(typeof cjsRequire('@smartive/utils/next').createDraftHandlers === 'function', 'createRequire next failed'); + + assertKeysEqual(writeAndRunKeysScript(peerDir, 'smoke-root.mjs', '@smartive/utils', 'module'), rootExports, 'esm root'); + assertKeysEqual( + writeAndRunKeysScript(peerDir, 'smoke-http.mjs', '@smartive/utils/http', 'module'), + httpExports, + 'esm http', + ); + assertKeysEqual( + writeAndRunKeysScript(peerDir, 'smoke-datocms.mjs', '@smartive/utils/datocms', 'module'), + datocmsExports, + 'esm datocms', + ); + // No raw ESM check for '@smartive/utils/next' -- see the bundler-only note above. + // scripts/verify-next-build.mjs builds a real Next app on both bundlers instead. + + const typesDir = mkdtempSync(join(tmpdir(), 'smartive-utils-types-')); + cleanupDirs.push(typesDir); + mkdirSync(join(typesDir, 'esm'), { recursive: true }); + mkdirSync(join(typesDir, 'cjs'), { recursive: true }); + mkdirSync(join(typesDir, 'node10'), { recursive: true }); + writeFileSync( + join(typesDir, 'package.json'), + `${JSON.stringify({ name: '@smartive/utils-smoke-types', private: true, type: 'module' }, null, 2)}\n`, + ); + run( + npmCmd, + [ + 'install', + '--no-package-lock', + '--no-fund', + '--no-audit', + '--prefer-offline', + ...sharedPeers, + `typescript@${typescriptVersion}`, + `@types/node@${typesNodeVersion}`, + ], + typesDir, + ); + + const smokeSource = ` +import { classNames, getTelLink } from '@smartive/utils'; +import { isSafeRelativePath, isValidToken, withCORS } from '@smartive/utils/http'; +import { createDatoClient, queryDatoCMS } from '@smartive/utils/datocms'; +import { + createDraftHandlers, + createRevalidateHandler, + createWebPreviewsHandler, + makeDraftModeWorkWithinIframes, +} from '@smartive/utils/next'; + +classNames('a'); +getTelLink('+41 44'); +isSafeRelativePath('/x'); +isValidToken('a', 'a'); +withCORS(); +createDatoClient({ apiToken: 'token' }); +void queryDatoCMS; +createDraftHandlers(); +createRevalidateHandler({ paths: ['/'] }); +createWebPreviewsHandler({ baseUrl: 'https://example.com', resolvePreviewUrl: async () => null }); +void makeDraftModeWorkWithinIframes; +`; + + writeTsconfig(join(typesDir, 'esm'), { module: 'nodenext', moduleResolution: 'nodenext' }); + writeFileSync(join(typesDir, 'esm', 'smoke.ts'), smokeSource); + writeFileSync(join(typesDir, 'esm', 'package.json'), `${JSON.stringify({ type: 'module' }, null, 2)}\n`); + + writeTsconfig(join(typesDir, 'cjs'), { + module: 'nodenext', + moduleResolution: 'nodenext', + esModuleInterop: true, + }); + writeFileSync(join(typesDir, 'cjs', 'smoke.ts'), smokeSource); + writeFileSync(join(typesDir, 'cjs', 'package.json'), `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`); + + writeTsconfig(join(typesDir, 'node10'), { + module: 'commonjs', + moduleResolution: 'node', + esModuleInterop: true, + }); + writeFileSync(join(typesDir, 'node10', 'smoke.ts'), smokeSource); + writeFileSync(join(typesDir, 'node10', 'package.json'), `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`); + + // Child folders resolve packages from the parent install by walking up node_modules. + const tscJs = join(typesDir, 'node_modules', 'typescript', 'lib', 'tsc.js'); + assert(existsSync(tscJs), 'typescript was not installed in the types fixture'); + for (const folder of ['esm', 'cjs', 'node10']) { + run('node', [tscJs, '-p', 'tsconfig.json'], join(typesDir, folder)); + } +} catch (error) { + primaryError = error; +} finally { + const cleanupErrors = []; + for (const dir of cleanupDirs) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(`${dir}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (primaryError) { + if (cleanupErrors.length > 0) { + primaryError.message = `${primaryError.message}\nCleanup warnings:\n${cleanupErrors.join('\n')}`; + } + throw primaryError; + } + + if (cleanupErrors.length > 0) { + throw new Error(`Verification passed but cleanup failed:\n${cleanupErrors.join('\n')}`); + } +} + +console.log('Packed package export smoke tests passed.'); diff --git a/scripts/write-cjs-package.mjs b/scripts/write-cjs-package.mjs new file mode 100644 index 0000000..a1070e7 --- /dev/null +++ b/scripts/write-cjs-package.mjs @@ -0,0 +1,9 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), '..'); +const cjsPackagePath = join(rootDir, 'dist', 'cjs', 'package.json'); + +await mkdir(dirname(cjsPackagePath), { recursive: true }); +await writeFile(cjsPackagePath, `${JSON.stringify({ type: 'commonjs' }, null, 2)}\n`); diff --git a/tsconfig.build.json b/tsconfig.build.json deleted file mode 100644 index eeccde4..0000000 --- a/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["./src/**/*.test.ts"] -} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..facc39a --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node10", + "outDir": "./dist/cjs", + "noEmit": false + }, + "exclude": ["./src/**/*.test.ts"] +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..8f0afbd --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "./dist/esm", + "noEmit": false + }, + "exclude": ["./src/**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 316d109..1ab8f45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,6 @@ { "compilerOptions": { "declaration": true, - "outDir": "./dist/", "rootDir": "./src", "sourceMap": true, "strict": true, @@ -12,12 +11,19 @@ "allowJs": true, "resolveJsonModule": true, "module": "nodenext", - "target": "esnext", + "target": "ES2022", + "lib": ["ES2022"], "skipLibCheck": true, + "noEmit": true, // next ships no package "exports" map, so NodeNext cannot resolve bare // subpaths like "next/headers". These paths are local type-check only; // emitted JS still imports 'next/headers' etc. and consumers resolve them // via their bundler (Next apps use moduleResolution: "bundler"). + // + // Do NOT "fix" this by writing fully-specified 'next/headers.js' imports in + // src: Turbopack does not apply its react-server aliases to the .js form, + // so server-layer code pulls the client navigation module and `next build` + // fails with MODULE_UNPARSABLE on app-router-context. "paths": { "next/cache": ["./node_modules/next/cache.d.ts"], "next/headers": ["./node_modules/next/headers.d.ts"],