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:
- Client: Bundle React + TypeScript + CSS into optimized static assets
- Server: Compile TypeScript to JavaScript (Node.js-compatible ESM)
- 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 istsc --noEmit(npm run typecheck). Two preset options are load-bearing and must not be removed (#1823):development: !isProductiononpreset-react. Babel 8 derivesdevelopmentfrom Babel's envName, andbabel-loaderdoes not set envName from webpack's--mode. Without this option the production bundle importsjsxDEVfromreact/jsx-dev-runtime. UnderNODE_ENV=productionthat export isvoid 0, so the app crashes on its first render while the build and Jest (ts-jest, which never runsbabel-loader) both stay green.onlyRemoveTypeImports: falseonpreset-typescript. Babel 8 defaults it totrue, which compilesimport { type X } from './a.js'to the side-effect importimport './a.js'. That changes the module graph: it pulls extra modules and CSS into chunks and adds mini-css "Conflicting order" warnings.falserestores 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) andruntimeChunk: 'single'. The second moves the webpack runtime into its ownruntime.[contenthash].js, whichhtml-webpack-plugininjects. A change anywhere in the graph then no longer rewritesmain.*.js, and webpack 5.111's performance-recommendation warning goes away. Nothing inserver/,e2e/,scripts/or the Dockerfile depends on bundle file names. webpack-cliandbabel-loaderresolvewebpackand@babel/corefrom the rootnode_modules, not fromclient/. Without an anchor, Docusaurus (docs/) can take the root slot with a different version (@babel/core7, or an older webpack 5 minor), and the client's pinned copy is never used. Rootpackage.jsontherefore declares both at the client's exact pins (CLAUDE.md > Dependency Policy). CI'scheck-single-dep-version.sh konva webpackstep enforces thewebpackanchor (#2138).css-loaderwith CSS Modules enabled (see ADR-006)html-webpack-pluginfor 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. |