ADR 004 Bundler and Build Tooling - steilerDev/cornerstone GitHub Wiki

ADR-004: Bundler & Build Tooling

Status

Accepted (Revised -- originally Vite 6, changed to Webpack 5.x)

Context

Cornerstone needs a build toolchain for:

  1. Client: Bundle React + TypeScript + CSS into optimized static assets
  2. Server: Compile TypeScript to JavaScript (Node.js-compatible ESM)
  3. Development: Fast iteration with hot module replacement (HMR)

Constraint: No Native Binary Dependencies for Frontend Tooling

The development environment includes ARM64 emulation (Docker sandbox on Apple Silicon) where native binary addons like esbuild, SWC, and Lightning CSS crash with SIGILL. Frontend build tooling must be pure JavaScript to work reliably in all environments. Native addons for the server (e.g., better-sqlite3) are acceptable since the Docker builder stage can install build tools.

Alternatives Considered

Vite 6 (original decision)

  • Uses esbuild for dependency pre-bundling and TypeScript compilation
  • Native ESM-based development server with instant HMR
  • Production builds via Rollup
  • Rejected: esbuild ships platform-specific native binaries that crash on ARM64 emulation. Tailwind CSS v4 (originally chosen for styling) also depends on native binaries via its oxide engine. These hard dependencies on native code made Vite unusable in all target environments.

esbuild (standalone)

  • Extremely fast builds (Go-based compiler)
  • Rejected: Same native binary issue as Vite. Also lacks native HMR and requires a custom dev server.

Webpack 5

  • The most mature bundler with the largest plugin ecosystem
  • Pure JavaScript -- no native binary dependencies
  • Well-proven HMR via webpack-dev-server
  • Extensive configuration options (more verbose than Vite, but fully controllable)
  • Works reliably in all environments including ARM64 emulation

Decision

Use Webpack 5 for client-side bundling and development.

The server uses tsc (TypeScript compiler) directly for compilation, since it produces standard ESM that Node.js runs natively. No bundler is needed for the server -- it runs compiled .js files directly.

For server development, Node.js --watch mode provides auto-restart on file changes.

Build Pipeline

Stage Tool Input Output
Shared types tsc shared/src/*.ts shared/dist/*.js + .d.ts
Client webpack build client/src/** client/dist/ (static assets)
Server tsc server/src/*.ts server/dist/*.js

Development

Component Tool Command
Client webpack-dev-server (port 5173) npm run dev -w client
Server node --watch (port 3000) npm run dev -w server
Both concurrently npm run dev

The Webpack dev server proxies /api/* requests to the Fastify server at localhost:3000, allowing the client and server to be developed independently.

Configuration

Client bundling is configured in client/webpack.config.cjs:

  • babel-loader (Babel 8) for TypeScript and JSX, with @babel/preset-react (runtime: 'automatic') and @babel/preset-typescript. It transpiles only. Type checking is tsc --noEmit (npm run typecheck). Two preset options are load-bearing and must not be removed (#1823):
    • development: !isProduction on preset-react. Babel 8 derives development from Babel's envName, and babel-loader does not set envName from webpack's --mode. Without this option the production bundle imports jsxDEV from react/jsx-dev-runtime. Under NODE_ENV=production that export is void 0, so the app crashes on its first render while the build and Jest (ts-jest, which never runs babel-loader) both stay green.
    • onlyRemoveTypeImports: false on preset-typescript. Babel 8 defaults it to true, which compiles import { type X } from './a.js' to the side-effect import import './a.js'. That changes the module graph: it pulls extra modules and CSS into chunks and adds mini-css "Conflicting order" warnings. false restores the Babel 7 elision.
    • Do not pass allowDeclareFields. Babel 8 removed it, always enables it, and throws if it is passed.
  • Production optimization: splitChunks: { chunks: 'all' } (vendor and async chunks; ADR-034's lazy pdfmake chunk depends on it) and runtimeChunk: 'single'. The second moves the webpack runtime into its own runtime.[contenthash].js, which html-webpack-plugin injects. A change anywhere in the graph then no longer rewrites main.*.js, and webpack 5.111's performance-recommendation warning goes away. Nothing in server/, e2e/, scripts/ or the Dockerfile depends on bundle file names.
  • webpack-cli and babel-loader resolve webpack and @babel/core from the root node_modules, not from client/. Without an anchor, Docusaurus (docs/) can take the root slot with a different version (@babel/core 7, or an older webpack 5 minor), and the client's pinned copy is never used. Root package.json therefore declares both at the client's exact pins (CLAUDE.md > Dependency Policy). CI's check-single-dep-version.sh konva webpack step enforces the webpack anchor (#2138).
  • css-loader with CSS Modules enabled (see ADR-006)
  • html-webpack-plugin for HTML template processing
  • Dev server proxy for /api/* to the Fastify backend

Consequences

Positive

  • Pure JavaScript toolchain -- works reliably in all environments (including ARM64 emulation)
  • Mature ecosystem with extensive documentation and community support
  • HMR via webpack-dev-server for fast development iteration
  • Full control over build configuration
  • Proxy configuration eliminates CORS issues during development
  • No native binary dependencies in the frontend build chain

Negative

  • More verbose configuration compared to Vite (mitigated by stable, well-understood config patterns)
  • Slower builds than esbuild-based tools (acceptable for a project of this scale)
  • Two processes during development (webpack-dev-server + node --watch), managed by concurrently

Deviation Log

Date Section Deviation Resolution
2026-10-01 Configuration Found in the PR #2155 architecture review (#1823, #2138). The section said the client compiles TypeScript with ts-loader. It actually uses babel-loader, which the Babel 8 migration reconfigures. The migration spec concluded no wiki change was needed because this page did not mention Babel, but that absence was the drift. Wiki was outdated; client/webpack.config.cjs is correct. The ts-loader bullet is replaced with babel-loader and its presets. The section now documents the two load-bearing Babel 8 options and why they exist, the production splitChunks / runtimeChunk: 'single' optimization, and the root hoisting anchors for webpack and @babel/core.