Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/heavy-donkeys-smoke.md
Original file line number Diff line number Diff line change
@@ -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.
26 changes: 6 additions & 20 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions config/vite-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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.
31 changes: 26 additions & 5 deletions config/vite-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
"dev": "tsc -p tsconfig.json --watch",
"typecheck": "tsc -p tsconfig.json --noEmit",
"prepack": "npm run clean && npm run build"
},
"dependencies": {
"@tailwindcss/vite": "^4.3.2",
"@vitejs/plugin-react": "^6.0.3",
Expand All @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion config/vite-config/src/next-lib.ts
Original file line number Diff line number Diff line change
@@ -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<ReactLibConfig, 'external'> {
/**
Expand Down
20 changes: 15 additions & 5 deletions config/vite-config/src/react-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand All @@ -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,
})
);

Expand Down
2 changes: 1 addition & 1 deletion config/vite-config/src/vanilla-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down
9 changes: 8 additions & 1 deletion config/vite-config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

109 changes: 109 additions & 0 deletions scripts/verify-packaging.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
#!/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).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('*') || seen.has(target)) continue;
seen.add(target);

const relative = posix.normalize(target.replace(/^\.\//, ''));

// `.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}: "${field}" points at TypeScript source ("${target}"). ` +
`Node cannot strip types under node_modules — publish compiled JS instead.`
);
failed = true;
continue;
}

if (!files.has(relative)) {
console.error(
` ERROR ${pkg.name}: "${field}" references "${target}", which 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.');