Tsup Configuration Patterns
Overview
This monorepo uses different tsup bundling strategies depending on package type. The key pattern is source-first exports with forced bundling for Node.js apps.
Architecture Summary
┌─────────────────────────────────────────────────────────────┐
│ Source-First Pattern │
├─────────────────────────────────────────────────────────────┤
│ Packages: exports.import → ./src/*.ts │
│ ├── Enables instant hot-reload in development │
│ └── Types resolve from source (no .d.ts needed) │
│ │
│ Node.js Apps: noExternal + bundle: true │
│ ├── Forces @repo/* packages to be inlined │
│ └── Bundled output is self-contained │
│ │
│ Next.js Apps: transpilePackages │
│ └── Compiles TypeScript directly from source │
└─────────────────────────────────────────────────────────────┘
The Problem: Node.js ESM Module Resolution
When using ESM ("type": "module"), Node.js requires explicit file extensions for relative imports:
// ❌ FAILS in Node ESM (ERR_MODULE_NOT_FOUND)
import { something } from "./helper";
// ✅ WORKS in Node ESM
import { something } from "./helper.js";
Additionally, Node.js cannot load .ts files directly. If package exports point to .ts files and the bundler doesn't inline them, Node.js fails with ERR_UNKNOWN_FILE_EXTENSION.
Solution: noExternal for Node.js Apps
The key insight is that Node.js apps must use noExternal to force esbuild to bundle all @repo/* packages:
// apps/*/tsup.config.ts (video-processor, sync-worker)
export default defineConfig((options) => ({
entry: ["src/index.ts"],
format: ["esm"],
bundle: true,
noExternal: [/^@repo\/.*/], // CRITICAL: Forces bundling of all @repo/* packages
external: [
// Node.js built-ins
"fs", "path", "os", "crypto", "stream", "util", "events",
// Runtime dependencies (not bundled)
"express", "cors", "multer", "dotenv",
],
}));
Why noExternal is required:
- Without it, esbuild may leave
@repo/*imports in the bundle - At runtime, Node.js tries to resolve these via package exports
- Exports point to
.tsfiles → Node.js fails withERR_UNKNOWN_FILE_EXTENSION - With
noExternal, esbuild inlines all@repo/*code → bundle is self-contained
Package Configuration
Packages use bundling with explicit entry points to handle internal relative imports:
// packages/*/tsup.config.ts
export default defineConfig((options) => ({
entry: [
"src/index.ts",
"src/server/env.ts",
"src/server/service.ts",
// ... explicit list matching package.json exports
],
format: ["esm"],
dts: false, // Types resolve from source via exports.types
// NO bundle: false - bundling handles internal relative imports
}));
Why bundling for packages:
- Packages contain internal relative imports like
from "./env" - Without bundling, these compile to extensionless imports that Node ESM rejects
- Bundling inlines internal imports → Node never sees extensionless paths
Docker Build Pattern
Use Turborepo's turbo prune --docker for efficient multi-stage Docker builds:
FROM node:20-slim AS base
RUN npm install -g pnpm turbo
FROM base AS pruner
WORKDIR /app
COPY . .
RUN turbo prune <app-name> --docker
FROM base AS installer
WORKDIR /app
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
RUN pnpm install --frozen-lockfile
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=<app-name>
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=installer /app .
CMD ["node", "apps/<app-name>/dist/index.js"]
Benefits over manual COPY:
- Automatically includes all needed packages
- Better Docker layer caching
- Smaller build context
- Future-proof as dependencies change
Verification
Automated Verification
Run the ESM import verification script after any bundling changes:
pnpm verify:esm-imports
This script:
- Runs
node dist/index.jsfor each Node.js app - Verifies modules load correctly (expects env validation errors, not module errors)
- Catches
ERR_MODULE_NOT_FOUNDorERR_UNKNOWN_FILE_EXTENSIONbefore deployment
Expected output:
✅ video-processor: Modules loaded correctly
✅ sync-worker: Modules loaded correctly
CI Integration:
This verification runs automatically in .github/workflows/ci-merge-group.yml after builds complete.
Manual Testing
-
Build locally:
pnpm turbo build --filter=video-processor -
Test app startup:
node apps/video-processor/dist/index.jsShould fail with "ENVIRONMENT: Required" (modules loaded) not module resolution errors.
-
Test in Docker:
docker build -t test -f apps/video-processor/Dockerfile . docker run test
Configuration Summary
| Type | bundle | noExternal | dts | Pattern |
|---|---|---|---|---|
| Packages | true (default) | - | false | Explicit entry points |
| Node.js Apps | true | [/^@repo\/.*/] | false | Force bundle @repo/* |
| Next.js Apps | N/A | N/A | N/A | Uses transpilePackages |
Common Mistakes
❌ Mistake 1: Forgetting noExternal in Node.js apps
// ❌ DON'T DO THIS
export default defineConfig({
bundle: true,
// Missing noExternal!
});
Result: ERR_UNKNOWN_FILE_EXTENSION because @repo/* imports aren't inlined.
❌ Mistake 2: Using bundle: false for packages with relative imports
// ❌ DON'T DO THIS
export default defineConfig({
entry: ["src/**/*.{ts,tsx}"],
bundle: false, // This breaks relative imports!
});
Result: ERR_MODULE_NOT_FOUND because internal relative imports don't have .js extensions.
References
- Node.js ESM Resolution
- Tsup Bundling Documentation
- Turborepo Docker Guide
- Verification script:
packages/ci-scripts/src/verify-esm-imports.ts