All docs/Monorepo Patterns

docs/architecture/internal-packages-and-docker.md

Internal Packages & Docker Build Architecture

This document describes the monorepo's approach to internal packages, bundling, and Docker builds. The pattern is designed to provide:

  • Instant hot-reload in development
  • Safe production builds with no TypeScript runtime errors
  • Efficient Docker builds using Turborepo's prune feature

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                     Source-First Pattern (Airtight)                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │  Package Exports: Source-First                                   │    │
│  │  ─────────────────────────────────────────────────────────────   │    │
│  │  exports.types  → ./src/*.ts                                     │    │
│  │  exports.import → ./src/*.ts                                     │    │
│  │                                                                   │    │
│  │  Benefits:                                                        │    │
│  │  • Instant hot-reload (no rebuild wait)                          │    │
│  │  • Types resolve from source (no .d.ts needed)                   │    │
│  │  • tsx/Next.js read source directly                              │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │  Node.js Apps: noExternal Bundling                               │    │
│  │  ─────────────────────────────────────────────────────────────   │    │
│  │  noExternal: [/^@repo\/.*/]                                      │    │
│  │                                                                   │    │
│  │  Forces esbuild to inline all @repo/* packages, making the      │    │
│  │  bundled output self-contained. Node.js never tries to          │    │
│  │  resolve exports at runtime.                                     │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                                                                          │
│  ┌─────────────────────────────────────────────────────────────────┐    │
│  │  Docker: turbo prune Multi-Stage Builds                          │    │
│  │  ─────────────────────────────────────────────────────────────   │    │
│  │  1. turbo prune <app> --docker  (extract minimal workspace)     │    │
│  │  2. pnpm install                (install only needed deps)      │    │
│  │  3. turbo build                 (build with noExternal)         │    │
│  │  4. node dist/index.js          (run self-contained bundle)     │    │
│  └─────────────────────────────────────────────────────────────────┘    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Why This Pattern?

The Problem

Node.js cannot load .ts files directly. If package exports point to .ts files and the bundler doesn't fully inline them, Node.js fails with:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

The Solution

Use noExternal: [/^@repo\/.*/] in Node.js app tsup configs. This forces esbuild to inline ALL @repo/* packages, regardless of how their exports are configured. The bundled output is completely self-contained.

Configuration Details

Package Exports (Source-First)

// packages/*/package.json
{
  "exports": {
    ".": {
      "types": "./src/index.ts",
      "import": "./src/index.ts"
    },
    "./server": {
      "types": "./src/server/index.ts",
      "import": "./src/server/index.ts"
    }
  }
}

Why source-first?

  • tsx and Next.js can read TypeScript directly
  • Changes are picked up immediately (no rebuild wait)
  • Types resolve from source (no .d.ts generation needed)

Package tsup Config

// packages/*/tsup.config.ts
export default defineConfig({
  entry: [
    "src/index.ts",
    "src/server/index.ts",
    // ... explicit entries matching exports
  ],
  format: ["esm"],
  dts: false,  // Types resolve from source
  outDir: "dist",
  // Bundling enabled by default (handles internal relative imports)
});

Node.js App tsup Config (CRITICAL)

// apps/video-processor/tsup.config.ts (current as of 2026-03)

export default defineConfig({
  entry: ["src/index.ts"],
  format: ["esm"],
  bundle: true,
  skipNodeModulesBundle: true,  // Externalizes node_modules (including @repo/*) — packages must be compiled
  // Note: @repo/* packages export compiled JS from dist/, so they work as external modules.
  // This differs from the noExternal approach which inlines @repo/* into the bundle.
});

Pattern note: video-processor uses skipNodeModulesBundle: true (externalize all node_modules), NOT noExternal: [/^@repo\/.*/] (inline @repo/* into bundle). Both approaches work, but skipNodeModulesBundle requires packages to have compiled dist/ output. The noExternal approach produces a larger self-contained bundle but doesn't require package compilation.

Why this works:

  • @repo/* packages build their dist/ via tsup and export compiled JS
  • skipNodeModulesBundle keeps them as external — Node.js resolves from node_modules
  • No inlining needed as long as packages build correctly

Docker Build Pattern

Use Turborepo's official turbo prune --docker for efficient multi-stage builds:

# apps/video-processor/Dockerfile (simplified — actual Dockerfile has additional system deps)
# Node.js version: 22 LTS (upgraded from 20 in March 2026)

FROM node:22-slim AS base
RUN npm install -g pnpm turbo

# Stage 1: Prune workspace to only include needed packages
FROM base AS pruner
WORKDIR /app
COPY . .
RUN turbo prune video-processor --docker

# Stage 2: Install dependencies from pruned lockfile
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

# Stage 3: Build the app
COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter=video-processor

# Stage 4: Run
FROM node:22-slim AS runner
WORKDIR /app
COPY --from=installer /app .

EXPOSE 8080
CMD ["node", "apps/video-processor/dist/index.js"]

Benefits of turbo prune

BenefitDescription
Smaller contextOnly copies files needed for the app
Better cachingPruned lockfile changes less often
Faster installsOnly installs needed dependencies
Future-proofAuto-includes new dependencies

Verification

ESM Import Verification

Run after any bundling changes:

pnpm verify:esm-imports

This script:

  1. Runs node dist/index.js for each Node.js app
  2. Expects apps to fail on env validation (modules loaded OK)
  3. 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

The verification runs automatically in .github/workflows/ci-merge-group.yml:

- name: Verify ESM imports (Node.js apps)
  run: pnpm verify:esm-imports

Flow Diagram

┌─────────────────────────────────────────────────────────────────────────┐
│                         Development Flow                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐               │
│  │   Edit       │    │   tsx/Next   │    │   Hot        │               │
│  │   Source     │───▶│   Reads      │───▶│   Reload     │               │
│  │   .ts file   │    │   Source     │    │   (instant)  │               │
│  └──────────────┘    └──────────────┘    └──────────────┘               │
│                                                                          │
│  Source-first exports enable instant hot-reload                          │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│                         Docker Build Flow                                │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  ┌──────────────┐    ┌──────────────┐    ┌──────────────┐               │
│  │   turbo      │    │   pnpm       │    │   tsup       │               │
│  │   prune      │───▶│   install    │───▶│   build      │───┐           │
│  │   --docker   │    │   (pruned)   │    │   noExternal │   │           │
│  └──────────────┘    └──────────────┘    └──────────────┘   │           │
│                                                              │           │
│                      ┌──────────────────────────────────────┘           │
│                      ▼                                                   │
│               ┌──────────────┐                                          │
│               │   node       │                                          │
│               │   dist/      │  Self-contained bundle                   │
│               │   index.js   │  (all @repo/* inlined)                   │
│               └──────────────┘                                          │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Common Issues & Solutions

ERR_UNKNOWN_FILE_EXTENSION

Symptom:

TypeError [ERR_UNKNOWN_FILE_EXTENSION]: Unknown file extension ".ts"

Cause: Node.js app's tsup config is missing noExternal.

Fix: Add to the app's tsup.config.ts:

noExternal: [/^@repo\/.*/],

ERR_MODULE_NOT_FOUND

Symptom:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module './helper'

Cause: Package has internal relative imports without .js extensions.

Fix: Ensure the package tsup config uses bundling (don't set bundle: false).

Related Documentation

Summary

ComponentConfigurationPurpose
Package exportsimport./src/*.tsHot-reload in dev
Package tsupBundling enabledHandle relative imports
App tsupnoExternal: [/^@repo\/.*/]Force inline @repo/*
Dockerturbo prune --dockerEfficient builds
Verificationpnpm verify:esm-importsCatch issues pre-deploy