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"],