All docs/Monorepo Patterns

docs/architecture/tsup-configuration-patterns.md

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 current approach uses skipNodeModulesBundle: true to externalize all node_modules (including @repo/*). This requires packages to export compiled JS from dist/:

// apps/*/tsup.config.ts (video-processor, sync-worker) — as of 2026-03
export default defineConfig((options) => ({
  entry: ["src/index.ts"],
  format: ["esm"],
  bundle: true, // Bundle app's own code to resolve relative imports
  skipNodeModulesBundle: true, // Externalize all node_modules (including @repo/*)
  // @repo/* packages export compiled JS from dist/, so they work as external modules.
}));

Why skipNodeModulesBundle works:

  • @repo/* packages build their dist/ via tsup and export compiled JS (not .ts source)
  • skipNodeModulesBundle keeps them as external — Node.js resolves from node_modules
  • No inlining needed as long as packages build correctly
  • Produces smaller bundles than the noExternal inlining approach

Historical note: An alternative approach uses noExternal: [/^@repo\/.*/] to force-inline all @repo/* packages into the bundle. This creates a larger self-contained bundle but doesn't require packages to have pre-compiled dist/. Both work; the current codebases use skipNodeModulesBundle.

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:22-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:22-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.js for each Node.js app
  • Verifies modules load correctly (expects env validation errors, not module errors)
  • Catches ERR_MODULE_NOT_FOUND or ERR_UNKNOWN_FILE_EXTENSION before 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

  1. Build locally:

    pnpm turbo build --filter=video-processor
    
  2. Test app startup:

    node apps/video-processor/dist/index.js
    

    Should fail with "ENVIRONMENT: Required" (modules loaded) not module resolution errors.

  3. Test in Docker:

    docker build -t test -f apps/video-processor/Dockerfile .
    docker run test
    

Configuration Summary

TypebundleskipNodeModulesBundledtsPattern
Packagestrue (default)-falseExplicit entry points
Node.js AppstruetruefalseExternalize all node_modules; @repo/* serves compiled dist/
Next.js AppsN/AN/AN/AUses transpilePackages

Common Mistakes

❌ Mistake 1: Forgetting skipNodeModulesBundle in Node.js apps

// ❌ DON'T DO THIS
export default defineConfig({
  bundle: true,
  // Missing skipNodeModulesBundle! Node.js app imports @repo/* from source (.ts) at runtime.
});

Result: ERR_UNKNOWN_FILE_EXTENSION because @repo/* packages export .ts source paths and Node.js can't load them directly. Fix: add skipNodeModulesBundle: true (requires packages to have compiled dist/) or noExternal: [/^@repo\/.*/] (inlines @repo/* into bundle).

❌ 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