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-processorusesskipNodeModulesBundle: true(externalize all node_modules), NOTnoExternal: [/^@repo\/.*/](inline @repo/* into bundle). Both approaches work, butskipNodeModulesBundlerequires packages to have compileddist/output. ThenoExternalapproach produces a larger self-contained bundle but doesn't require package compilation.
Why this works:
@repo/*packages build theirdist/via tsup and export compiled JSskipNodeModulesBundlekeeps them as external — Node.js resolves fromnode_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
| Benefit | Description |
|---|---|
| Smaller context | Only copies files needed for the app |
| Better caching | Pruned lockfile changes less often |
| Faster installs | Only installs needed dependencies |
| Future-proof | Auto-includes new dependencies |
Verification
ESM Import Verification
Run after any bundling changes:
pnpm verify:esm-imports
This script:
- Runs
node dist/index.jsfor each Node.js app - Expects apps to fail on env validation (modules loaded OK)
- Catches
ERR_MODULE_NOT_FOUNDorERR_UNKNOWN_FILE_EXTENSIONbefore 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
- tsup-configuration-patterns.md - Detailed bundling strategies
- development-workflow.md - Dev environment setup
- Internal Packages Rule - Quick reference
- Deployment Guide - CI/CD and Docker
Summary
| Component | Configuration | Purpose |
|---|---|---|
| Package exports | import → ./src/*.ts | Hot-reload in dev |
| Package tsup | Bundling enabled | Handle relative imports |
| App tsup | noExternal: [/^@repo\/.*/] | Force inline @repo/* |
| Docker | turbo prune --docker | Efficient builds |
| Verification | pnpm verify:esm-imports | Catch issues pre-deploy |