From 41e3588777e2f2b1ae9e8262e30a76a7bd5c339e Mon Sep 17 00:00:00 2001 From: Ole Kristian Losvik Date: Fri, 31 Jul 2026 19:29:00 +0100 Subject: [PATCH 1/2] fix(vite-config): publish compiled JS instead of raw TypeScript sources The package mapped its export subpaths straight at ./src/*.ts. Inside the monorepo pnpm links the package, so Node's realpath lands outside node_modules and type stripping is allowed. Every consumer installing from the registry instead resolved to a path under node_modules, where Node refuses to strip types, and loading any preset from a vite.config.ts failed with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. The package now emits dist/ with tsc (ESM + declarations) and points exports there. Subpath names are unchanged, so no consumer import has to change and the per-package workarounds (NODE_OPTIONS="--import tsx", --configLoader runner) can be dropped. Type-checking the sources for the first time surfaced two latent bugs, fixed here as well: useSWC: true threw "require is not defined" (bare require() in an ES module, now createRequire), and dts.outDir/dts.rollupTypes were silently ignored because vite-plugin-dts v5 renamed them to outDirs/bundleTypes. Wire the build into the release flow so this cannot regress: prepack rebuilds dist, `pnpm release` builds before changeset publish, and CI's packaging check is now scripts/verify-packaging.mjs, which packs every public package and asserts that each exports/main/types target exists in the tarball and is not raw TypeScript. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/heavy-donkeys-smoke.md | 26 +++++++ .github/workflows/ci.yml | 26 ++----- .github/workflows/release.yml | 2 + config/vite-config/README.md | 17 +++++ config/vite-config/package.json | 31 ++++++-- config/vite-config/src/next-lib.ts | 2 +- config/vite-config/src/react-lib.ts | 20 +++-- config/vite-config/src/vanilla-lib.ts | 2 +- config/vite-config/tsconfig.json | 9 ++- package.json | 3 +- pnpm-lock.yaml | 6 ++ scripts/verify-packaging.mjs | 103 ++++++++++++++++++++++++++ 12 files changed, 213 insertions(+), 34 deletions(-) create mode 100644 .changeset/heavy-donkeys-smoke.md create mode 100644 scripts/verify-packaging.mjs diff --git a/.changeset/heavy-donkeys-smoke.md b/.changeset/heavy-donkeys-smoke.md new file mode 100644 index 0000000..3123b54 --- /dev/null +++ b/.changeset/heavy-donkeys-smoke.md @@ -0,0 +1,26 @@ +--- +"@eventuras/vite-config": minor +--- + +Publish compiled JavaScript and type declarations instead of raw TypeScript sources. + +The package mapped its export subpaths straight at `./src/*.ts`. That works inside +the monorepo, where pnpm links the package and Node's realpath lands outside +`node_modules` — but every consumer installing from the registry hit +`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`, because Node never strips types for +files under `node_modules`. Loading any preset from a `vite.config.ts` failed +outright, forcing consumers into per-package workarounds +(`NODE_OPTIONS="--import tsx"`, `vite build --configLoader runner`). + +The subpaths are unchanged (`./base`, `./react-lib`, `./vanilla-lib`, `./next-lib`); +they now resolve to `dist/*.js` with matching `dist/*.d.ts`. No import needs to +change — consumers can drop the workarounds. + +Two latent bugs surfaced while type-checking the sources for the first time and are +fixed here as well: + +- `useSWC: true` threw `require is not defined`. The SWC plugin was loaded with a + bare `require()` inside an ES module; it now uses `createRequire`. +- `dts.outDir` and `dts.rollupTypes` were silently ignored. vite-plugin-dts v5 + renamed those options to `outDirs` and `bundleTypes`. The preset's own option + names are unchanged. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 685f89d..b7e61f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,24 +33,10 @@ jobs: - name: Build run: pnpm build - # The config packages ship source rather than build output, so nothing - # else here would catch a broken "files" field or export path — it would - # only surface as a half-empty tarball after publishing. + # Workspace consumers resolve packages through a symlink to the source + # directory, so a broken "files" field or an export pointing at a file + # that is never published is invisible here — it only breaks for people + # installing from the registry. This packs every package and checks the + # export targets against the actual tarball contents. - name: Verify packaging - run: | - set -euo pipefail - # `find` rather than a glob: packages/ may not exist yet, and an - # unmatched glob behaves differently across shells. Process - # substitution keeps the loop in this shell so failures propagate. - status=0 - while IFS= read -r manifest; do - [ -n "$manifest" ] || continue - dir=$(dirname "$manifest") - if [ "$(node -p "require('./$manifest').private === true")" = "true" ]; then - echo "skip (private): $dir" - continue - fi - echo "pack: $dir" - (cd "$dir" && npm pack --dry-run) || status=1 - done < <(find config packages -maxdepth 2 -name package.json 2>/dev/null | sort) - exit $status + run: pnpm verify:packaging diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 694a7d5..1a4f762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,6 +38,8 @@ jobs: # Pending changesets -> open/update the "Version Packages" PR. # No pending changesets but unreleased versions -> publish them. + # `pnpm release` builds the workspace before `changeset publish`, so no + # package can be published without its compiled output. - name: Version or publish id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 diff --git a/config/vite-config/README.md b/config/vite-config/README.md index f3391df..0dcb1d3 100644 --- a/config/vite-config/README.md +++ b/config/vite-config/README.md @@ -6,6 +6,12 @@ Shared Vite configurations for Eventuras monorepo libraries. This package provides reusable Vite configuration presets for different types of libraries in the Eventuras monorepo. It helps maintain consistency, reduces duplication, and makes it easier to update build configurations across all libraries. +## Requirements + +- Node.js 24+ +- Vite 7 or 8 (peer dependency) +- TypeScript 6 in the consuming package — the declaration step (`vite-plugin-dts` v5) needs the TypeScript JS Compiler API, which TypeScript 7 no longer ships by default + ## Presets ### Vanilla Library (`vanilla-lib`) @@ -169,3 +175,14 @@ export default defineReactLibConfig({ ### Build errors with Next.js - Use `next-lib` preset instead of `react-lib` - Check that Next.js packages are in `external` array + +### `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` +- Upgrade to 0.3.0 or later. Versions up to 0.2.2 exported raw TypeScript sources, which Node refuses to load from `node_modules`. Workarounds such as `NODE_OPTIONS="--import tsx"` or `vite build --configLoader runner` are no longer needed and can be removed. + +## Development + +The published package is compiled output, not sources: `pnpm build` runs `tsc` and +emits `dist/` (ESM + declarations), and `exports` points there. Do not repoint +`exports` at `src/` — Node never strips types for files under `node_modules`, so +that breaks every consumer installing from the registry. `pnpm verify:packaging` +at the repo root checks this. diff --git a/config/vite-config/package.json b/config/vite-config/package.json index b615b2f..68b25a0 100644 --- a/config/vite-config/package.json +++ b/config/vite-config/package.json @@ -9,14 +9,33 @@ }, "type": "module", "exports": { - "./base": "./src/base.ts", - "./react-lib": "./src/react-lib.ts", - "./vanilla-lib": "./src/vanilla-lib.ts", - "./next-lib": "./src/next-lib.ts" + "./base": { + "types": "./dist/base.d.ts", + "import": "./dist/base.js" + }, + "./react-lib": { + "types": "./dist/react-lib.d.ts", + "import": "./dist/react-lib.js" + }, + "./vanilla-lib": { + "types": "./dist/vanilla-lib.d.ts", + "import": "./dist/vanilla-lib.js" + }, + "./next-lib": { + "types": "./dist/next-lib.d.ts", + "import": "./dist/next-lib.js" + } }, "files": [ - "src" + "dist" ], + "scripts": { + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist", + "dev": "tsc -p tsconfig.json --watch", + "typecheck": "tsc -p tsconfig.json --noEmit", + "prepack": "pnpm run clean && pnpm run build" + }, "dependencies": { "@tailwindcss/vite": "^4.3.2", "@vitejs/plugin-react": "^6.0.3", @@ -25,6 +44,8 @@ "vite-plugin-dts": "^5.0.3" }, "devDependencies": { + "@eventuras/typescript-config": "workspace:*", + "@types/node": "^24.12.0", "typescript": "^6.0.2", "vite": "^8.1.0" }, diff --git a/config/vite-config/src/next-lib.ts b/config/vite-config/src/next-lib.ts index 4b48f23..fd2f822 100644 --- a/config/vite-config/src/next-lib.ts +++ b/config/vite-config/src/next-lib.ts @@ -1,5 +1,5 @@ import type { UserConfig } from 'vite'; -import { defineReactLibConfig, type ReactLibConfig } from './react-lib.ts'; +import { defineReactLibConfig, type ReactLibConfig } from './react-lib.js'; export interface NextLibConfig extends Omit { /** diff --git a/config/vite-config/src/react-lib.ts b/config/vite-config/src/react-lib.ts index ceed578..08edb83 100644 --- a/config/vite-config/src/react-lib.ts +++ b/config/vite-config/src/react-lib.ts @@ -5,8 +5,16 @@ import dts from 'vite-plugin-dts'; import { resolve } from 'node:path'; import { glob } from 'glob'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; -import { getRuntimeDependencyExternals, NODE_BUILTINS_EXTERNAL } from './externals.ts'; +import { getRuntimeDependencyExternals, NODE_BUILTINS_EXTERNAL } from './externals.js'; + +/** + * ESM has no `require`. This one is bound to this module so the SWC plugin can + * stay lazily loaded — it drags in the native `@swc/core` binary, which most + * consumers never need. + */ +const requireFromHere = createRequire(import.meta.url); /** * Plugin to preserve 'use client' directives in React Server Components. @@ -155,8 +163,8 @@ export function defineReactLibConfig(config: ReactLibConfig): UserConfig { // Add React plugin (SWC or standard) if (useSWC) { - // Dynamically import SWC plugin only when needed - const reactSwc = require('@vitejs/plugin-react-swc').default; + // Load the SWC plugin only when actually requested + const reactSwc = requireFromHere('@vitejs/plugin-react-swc').default; plugins.push(reactSwc()); } else { plugins.push(react()); @@ -171,11 +179,13 @@ export function defineReactLibConfig(config: ReactLibConfig): UserConfig { plugins.push( dts({ entryRoot: dtsOptions.entryRoot || 'src', - outDir: dtsOptions.outDir || 'dist', + // vite-plugin-dts v5 renamed `outDir` -> `outDirs` and + // `rollupTypes` -> `bundleTypes`; the preset keeps the old names. + outDirs: dtsOptions.outDir || 'dist', include: ['src/**/*'], exclude: ['**/*.test.ts', '**/*.test.tsx', '**/*.spec.ts', '**/*.spec.tsx', '**/*.stories.tsx'], copyDtsFiles: true, - rollupTypes: dtsOptions.rollupTypes || false, + bundleTypes: dtsOptions.rollupTypes || false, }) ); diff --git a/config/vite-config/src/vanilla-lib.ts b/config/vite-config/src/vanilla-lib.ts index 42bfcfc..49dca3b 100644 --- a/config/vite-config/src/vanilla-lib.ts +++ b/config/vite-config/src/vanilla-lib.ts @@ -2,7 +2,7 @@ import { defineConfig, type UserConfig } from 'vite'; import dts from 'vite-plugin-dts'; import { resolve } from 'node:path'; -import { getRuntimeDependencyExternals, NODE_BUILTINS_EXTERNAL } from './externals.ts'; +import { getRuntimeDependencyExternals, NODE_BUILTINS_EXTERNAL } from './externals.js'; export interface VanillaLibConfig { /** diff --git a/config/vite-config/tsconfig.json b/config/vite-config/tsconfig.json index 95ef3c6..b758d4d 100644 --- a/config/vite-config/tsconfig.json +++ b/config/vite-config/tsconfig.json @@ -2,7 +2,14 @@ "extends": "@eventuras/typescript-config/base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "noEmit": false, + "declaration": true, + "declarationMap": false, + "sourceMap": false, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/package.json b/package.json index 153489b..00d4174 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "scripts": { "build": "pnpm -r --if-present run build", "lint": "pnpm -r --if-present run lint", + "verify:packaging": "node scripts/verify-packaging.mjs", "changeset": "changeset", "changeset:version": "changeset version", - "release": "changeset publish" + "release": "pnpm build && changeset publish" }, "devDependencies": { "@changesets/cli": "^2.30.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91f2163..9882000 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,12 @@ importers: specifier: ^5.0.3 version: 5.0.3(rolldown@1.1.5)(typescript@6.0.3)(vite@8.1.4(@types/node@24.13.3)(jiti@2.7.0)) devDependencies: + '@eventuras/typescript-config': + specifier: workspace:* + version: link:../typescript-config + '@types/node': + specifier: ^24.12.0 + version: 24.13.3 typescript: specifier: ^6.0.2 version: 6.0.3 diff --git a/scripts/verify-packaging.mjs b/scripts/verify-packaging.mjs new file mode 100644 index 0000000..c82c397 --- /dev/null +++ b/scripts/verify-packaging.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +/** + * Verifies that every public workspace package would publish a usable tarball. + * + * For each non-private package under config/ and packages/ it packs the + * package (dry run, which also runs `prepack`) and then checks that every + * target referenced from `exports`, `main`, `module`, `types` and `bin` + * actually exists in the tarball. + * + * This exists because the failure mode is invisible inside the monorepo: + * workspace consumers resolve packages through a symlink to the source + * directory, so a missing `files` entry or an export pointing at a file that + * is never published only breaks for people installing from the registry. + * + * It also rejects export targets that point at raw TypeScript sources. + * Node refuses to strip types for anything under node_modules + * (ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING), so a package that maps a + * subpath at `./src/*.ts` is unloadable for every registry consumer even when + * the file is present in the tarball. + */ + +import { execFileSync } from 'node:child_process'; +import { globSync, readFileSync } from 'node:fs'; +import { dirname, posix } from 'node:path'; + +const PACKAGE_GLOBS = ['config/*/package.json', 'packages/*/package.json']; + +/** Collects the file targets referenced by an `exports` subtree. */ +function collectExportTargets(node, out = []) { + if (typeof node === 'string') { + out.push(node); + } else if (Array.isArray(node)) { + for (const entry of node) collectExportTargets(entry, out); + } else if (node && typeof node === 'object') { + for (const entry of Object.values(node)) collectExportTargets(entry, out); + } + return out; +} + +function packedFiles(dir) { + const raw = execFileSync('npm', ['pack', '--dry-run', '--json'], { + cwd: dir, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'inherit'], + }); + const [result] = JSON.parse(raw); + return new Set((result?.files ?? []).map(f => f.path)); +} + +const manifests = PACKAGE_GLOBS.flatMap(pattern => globSync(pattern)).sort(); +let failed = false; + +for (const manifest of manifests) { + const dir = dirname(manifest); + const pkg = JSON.parse(readFileSync(manifest, 'utf8')); + + if (pkg.private === true) { + console.log(`skip (private): ${dir}`); + continue; + } + + console.log(`pack: ${dir} (${pkg.name})`); + const files = packedFiles(dir); + + const targets = [ + ...collectExportTargets(pkg.exports), + ...collectExportTargets(pkg.bin), + pkg.main, + pkg.module, + pkg.types, + ].filter(t => typeof t === 'string' && t.startsWith('.')); + + for (const target of new Set(targets)) { + // Wildcard subpaths can't be checked against a static file list. + if (target.includes('*')) continue; + + const relative = posix.normalize(target.replace(/^\.\//, '')); + + if (/\.(c|m)?tsx?$/.test(relative) && !relative.endsWith('.d.ts')) { + console.error( + ` ERROR ${pkg.name}: exports "${target}" points at TypeScript source. ` + + `Node cannot strip types under node_modules — publish compiled JS instead.` + ); + failed = true; + continue; + } + + if (!files.has(relative)) { + console.error( + ` ERROR ${pkg.name}: "${target}" is referenced by the manifest but is ` + + `not in the tarball (check "files" and the build output).` + ); + failed = true; + } + } +} + +if (failed) { + console.error('\nPackaging verification failed.'); + process.exit(1); +} + +console.log('\nPackaging verification passed.'); From 04f9503676f8a7203c9717be9f4de711735cc6d8 Mon Sep 17 00:00:00 2001 From: Ole Kristian Losvik Date: Fri, 31 Jul 2026 19:55:08 +0100 Subject: [PATCH 2/2] fix(vite-config): address review feedback on packaging guard and prepack - verify-packaging: treat .d.mts and .d.cts as declaration output, not raw TypeScript sources. The previous check only exempted .d.ts, so a NodeNext package emitting .d.mts would have failed verification for valid output. - verify-packaging: name the offending manifest field (exports/main/module/ types/bin) in the error instead of always saying "exports". - vite-config: make prepack tool-agnostic. It ran `pnpm run` and `rm -rf`, so `npm pack`/`npm publish` depended on pnpm being on PATH and on a POSIX shell. Now `npm run` (npm always ships with Node) and a Node-based clean. Co-Authored-By: Claude Opus 5 (1M context) --- config/vite-config/package.json | 4 ++-- scripts/verify-packaging.mjs | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/config/vite-config/package.json b/config/vite-config/package.json index 68b25a0..e037c86 100644 --- a/config/vite-config/package.json +++ b/config/vite-config/package.json @@ -31,10 +31,10 @@ ], "scripts": { "build": "tsc -p tsconfig.json", - "clean": "rm -rf dist", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", "dev": "tsc -p tsconfig.json --watch", "typecheck": "tsc -p tsconfig.json --noEmit", - "prepack": "pnpm run clean && pnpm run build" + "prepack": "npm run clean && npm run build" }, "dependencies": { "@tailwindcss/vite": "^4.3.2", diff --git a/scripts/verify-packaging.mjs b/scripts/verify-packaging.mjs index c82c397..617dbd9 100644 --- a/scripts/verify-packaging.mjs +++ b/scripts/verify-packaging.mjs @@ -63,22 +63,28 @@ for (const manifest of manifests) { const files = packedFiles(dir); const targets = [ - ...collectExportTargets(pkg.exports), - ...collectExportTargets(pkg.bin), - pkg.main, - pkg.module, - pkg.types, - ].filter(t => typeof t === 'string' && t.startsWith('.')); - - for (const target of new Set(targets)) { + ...collectExportTargets(pkg.exports).map(t => ({ field: 'exports', target: t })), + ...collectExportTargets(pkg.bin).map(t => ({ field: 'bin', target: t })), + { field: 'main', target: pkg.main }, + { field: 'module', target: pkg.module }, + { field: 'types', target: pkg.types }, + ].filter(({ target }) => typeof target === 'string' && target.startsWith('.')); + + const seen = new Set(); + + for (const { field, target } of targets) { // Wildcard subpaths can't be checked against a static file list. - if (target.includes('*')) continue; + if (target.includes('*') || seen.has(target)) continue; + seen.add(target); const relative = posix.normalize(target.replace(/^\.\//, '')); - if (/\.(c|m)?tsx?$/.test(relative) && !relative.endsWith('.d.ts')) { + // `.d.ts`, `.d.mts` and `.d.cts` are declaration output, not sources. + const isDeclaration = /\.d\.(c|m)?ts$/.test(relative); + + if (/\.(c|m)?tsx?$/.test(relative) && !isDeclaration) { console.error( - ` ERROR ${pkg.name}: exports "${target}" points at TypeScript source. ` + + ` ERROR ${pkg.name}: "${field}" points at TypeScript source ("${target}"). ` + `Node cannot strip types under node_modules — publish compiled JS instead.` ); failed = true; @@ -87,8 +93,8 @@ for (const manifest of manifests) { if (!files.has(relative)) { console.error( - ` ERROR ${pkg.name}: "${target}" is referenced by the manifest but is ` + - `not in the tarball (check "files" and the build output).` + ` ERROR ${pkg.name}: "${field}" references "${target}", which is not in ` + + `the tarball (check "files" and the build output).` ); failed = true; }