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 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 .ts files → Node.js fails with ERR_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.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

TypebundlenoExternaldtsPattern
Packagestrue (default)-falseExplicit entry points
Node.js Appstrue[/^@repo\/.*/]falseForce bundle @repo/*
Next.js AppsN/AN/AN/AUses 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