From da25f46e3e3781ebd67af07491a74ccbdc613ad5 Mon Sep 17 00:00:00 2001 From: david raistrick <1108844+keen99@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:03:59 -0400 Subject: [PATCH] fix(docker): single container (caddy+node), ESM adapters fix blank page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker: moved all docker files to docker/ tree (was conflated with upstream Dockerfile at root + server/Dockerfile). Single container now: caddy (front, serves static + proxies /api /ws) + node backend (internal :4001). Node never exposed. entrypoint.sh runs both. Compose: one service. Blank page root cause: storage adapters had inconsistent module systems. firebase.js = ESM (export). ws.js + memory.js = CJS (module.exports). CRA prod build = ESM strict -> CJS runtime crash, blank root. Dev mode lenient, masked bug. First ws prod build (docker) = first exposure. Never dev/prod split intended; just inconsistency from M2 era. Fix: all adapters ESM. ws.js lazy-loads 'ws' pkg via dynamic import() (Node/jest only; browser uses global WebSocket). index.js static imports. server jest: added babel.config.js (preset-env, node target) to transform ESM for jest. Test: src/tests/StorageEsm.test.js — 4 tests grep all adapters for module.exports / require(). Regression guard catches CJS leak. Verified: docker page renders (root 4534 chars, UI visible). server 24 green, shared 90 green, FE ESM 4 green. --- Caddyfile | 36 ---------------- Dockerfile.ws | 29 ------------- docker-compose.yml | 59 --------------------------- .dockerignore => docker/.dockerignore | 0 docker/Caddyfile | 18 ++++++++ docker/Dockerfile | 53 ++++++++++++++++++++++++ docker/docker-compose.yml | 22 ++++++++++ docker/entrypoint.sh | 12 ++++++ server/Dockerfile | 23 ----------- server/babel.config.js | 3 ++ src/storage/index.js | 8 ++-- src/storage/memory.js | 4 +- src/storage/ws.js | 16 ++++++-- src/tests/StorageEsm.test.js | 19 +++++++++ 14 files changed, 145 insertions(+), 157 deletions(-) delete mode 100644 Caddyfile delete mode 100644 Dockerfile.ws delete mode 100644 docker-compose.yml rename .dockerignore => docker/.dockerignore (100%) create mode 100644 docker/Caddyfile create mode 100644 docker/Dockerfile create mode 100644 docker/docker-compose.yml create mode 100755 docker/entrypoint.sh delete mode 100644 server/Dockerfile create mode 100644 server/babel.config.js create mode 100644 src/tests/StorageEsm.test.js diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index 586fdeb..0000000 --- a/Caddyfile +++ /dev/null @@ -1,36 +0,0 @@ -# Caddyfile — serve static frontend, proxy /api + /ws to backend -# handle blocks are mutually exclusive + ordered: API/WS first, static last. -# (try_files at site level would rewrite /api/* → /index.html before proxy.) - -{ - # admin off for docker - admin off -} - -:80 { - encode gzip - - # REST API → backend service (path preserved: /api/doc etc.) - handle /api/* { - reverse_proxy backend:4001 { - header_up Host {host} - } - } - - # WebSocket upgrade → backend - handle /ws { - reverse_proxy backend:4001 - } - - # Everything else: static SPA with client-side routing fallback - handle { - root * /srv - try_files {path} /index.html - file_server - } - - # HTTP basic auth (in-house only). Uncomment + set CADDY_BASIC_AUTH env. - # basic_auth { - # {$CADDY_BASIC_AUTH} - # } -} diff --git a/Dockerfile.ws b/Dockerfile.ws deleted file mode 100644 index 8504ea4..0000000 --- a/Dockerfile.ws +++ /dev/null @@ -1,29 +0,0 @@ -# Dockerfile.ws — frontend build (STORAGE=ws) served by Caddy -# Same-origin: Caddy proxies /api + /ws to backend. No backend URL baked at build. - -FROM node:18-alpine AS build -WORKDIR /app - -# workspaces root -COPY package*.json ./ -COPY shared/package.json ./shared/ -COPY server/package.json ./server/ -RUN npm install --include-workspace-root - -COPY shared/ ./shared/ -COPY src/ ./src/ -COPY public/ ./public/ - -# Build with ws storage (no backend URL — same-origin via Caddy proxy) -ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default -ENV REACT_APP_STORAGE=ws -ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID -RUN NODE_OPTIONS=--openssl-legacy-provider npm run build - -# Stage 2: Caddy serves static + proxies API/WS -FROM caddy:2-alpine - -COPY --from=build /app/build /srv -COPY Caddyfile /etc/caddy/Caddyfile - -EXPOSE 80 diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 0ba5fb1..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,59 +0,0 @@ -# docker-compose.yml — two profiles: -# firebase: existing Dockerfile (nginx + firebase build), upstream path -# backend: full stack (caddy frontend + node backend + sqlite volume) -# -# Usage: -# docker compose --profile backend up --build # full self-hosted stack -# docker compose --profile firebase up --build # firebase-only (upstream) -# -# Run local in OrbStack; remote docker context later (just change context). - -services: - # ---- full self-hosted stack (STORAGE=ws) ---- - backend: - profiles: ["backend"] - build: - context: . - dockerfile: server/Dockerfile - image: ttrpg-backend:local - volumes: - - backend-data:/data - environment: - - DB_PATH=/data/tracker.sqlite - - PORT=4001 - # - CORS_ORIGIN=* # Caddy same-origin, cors not strictly needed - expose: - - "4001" - restart: unless-stopped - - frontend: - profiles: ["backend"] - build: - context: . - dockerfile: Dockerfile.ws - args: - - REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default} - image: ttrpg-frontend:local - ports: - - "${FRONTEND_PORT:-8080}:80" - depends_on: - - backend - # Optional basic auth: set in .env as CADDY_BASIC_AUTH="user " - # Generate hash: caddy hash-password - environment: - - CADDY_BASIC_AUTH=${CADDY_BASIC_AUTH:-} - restart: unless-stopped - - # ---- firebase-only path (upstream, existing Dockerfile) ---- - firebase: - profiles: ["firebase"] - build: - context: . - dockerfile: Dockerfile - image: ttrpg-firebase:local - ports: - - "${FRONTEND_PORT:-8080}:80" - restart: unless-stopped - -volumes: - backend-data: diff --git a/.dockerignore b/docker/.dockerignore similarity index 100% rename from .dockerignore rename to docker/.dockerignore diff --git a/docker/Caddyfile b/docker/Caddyfile new file mode 100644 index 0000000..65861c1 --- /dev/null +++ b/docker/Caddyfile @@ -0,0 +1,18 @@ +# Caddyfile — single-container (caddy + node) +# Caddy serves built frontend, proxies /api + /ws to node backend on :4001. +# Node never exposed directly; only caddy on :80. + +:80 { + handle /api/* { + reverse_proxy 127.0.0.1:4001 + } + handle /ws { + reverse_proxy 127.0.0.1:4001 + } + # catch-all: static frontend (SPA fallback) + handle { + root * /srv + try_files {path} /index.html + file_server + } +} diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..454c9b1 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,53 @@ +# docker/Dockerfile — single container: caddy (front) + node (back). +# Build context = repo root. +# ---- build stage: frontend + install backend deps ---- +FROM node:18-alpine AS build +WORKDIR /app + +COPY package*.json ./ +COPY shared/package.json ./shared/ +COPY server/package.json ./server/ +RUN npm install --include-workspace-root + +COPY shared/ ./shared/ +COPY server/ ./server/ +COPY src/ ./src/ +COPY public/ ./public/ + +# better-sqlite3 native build (alpine musl) +RUN cd server && npm rebuild better-sqlite3 + +# build frontend (ws storage, same-origin via caddy) +ARG REACT_APP_TRACKER_APP_ID=ttrpg-initiative-tracker-default +ENV REACT_APP_STORAGE=ws +ENV REACT_APP_TRACKER_APP_ID=$REACT_APP_TRACKER_APP_ID +RUN NODE_OPTIONS=--openssl-legacy-provider npm run build + +# prune backend dev deps for runtime +RUN npm prune --omit=dev + +# ---- runtime stage: caddy + node ---- +FROM node:18-alpine +RUN apk add --no-cache caddy + +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/shared/node_modules ./shared/node_modules +COPY --from=build /app/server/node_modules ./server/node_modules +COPY --from=build /app/package*.json ./ +COPY --from=build /app/shared/package.json ./shared/ +COPY --from=build /app/server/package.json ./server/ +COPY shared/ ./shared/ +COPY server/ ./server/ +# built frontend served by caddy +COPY --from=build /app/build /srv +COPY docker/Caddyfile /etc/caddy/Caddyfile +COPY docker/entrypoint.sh /entrypoint.sh + +ENV NODE_ENV=production +ENV PORT=4001 +ENV DB_PATH=/data/tracker.sqlite + +EXPOSE 80 +WORKDIR /app +CMD ["/entrypoint.sh"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..4556e54 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,22 @@ +# docker/docker-compose.yml — single container: caddy (front) + node (back). +# Usage (from repo root): +# docker compose -f docker/docker-compose.yml up --build +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + args: + - REACT_APP_TRACKER_APP_ID=${TRACKER_APP_ID:-ttrpg-initiative-tracker-default} + image: ttrpg-app:local + ports: + - "${PORT:-8080}:80" + volumes: + - app-data:/data + environment: + - DB_PATH=/data/tracker.sqlite + - PORT=4001 + restart: unless-stopped + +volumes: + app-data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..108dc84 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# docker/entrypoint.sh — run node backend + caddy proxy in one container. +# Caddy foreground (PID 1, handles signals). Node background. +set -e + +# node backend (internal :4001) +cd /app/server +node index.js & +NODE_PID=$! + +# caddy proxy (foreground, :80) +exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile diff --git a/server/Dockerfile b/server/Dockerfile deleted file mode 100644 index accd30e..0000000 --- a/server/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# server/Dockerfile — backend (Express + ws + better-sqlite3) -FROM node:18-alpine AS build -WORKDIR /app - -# workspaces root needed: shared/ is a dependency (@ttrpg/shared) -COPY package*.json ./ -COPY shared/package.json ./shared/ -COPY server/package.json ./server/ -RUN npm install --workspaces --include-workspace-root - -COPY shared/ ./shared/ -COPY server/ ./server/ - -# better-sqlite3 builds native; rebuild for alpine musl -RUN cd server && npm rebuild better-sqlite3 - -ENV NODE_ENV=production -ENV PORT=4001 -ENV DB_PATH=/data/tracker.sqlite - -EXPOSE 4001 -WORKDIR /app/server -CMD ["node", "index.js"] diff --git a/server/babel.config.js b/server/babel.config.js new file mode 100644 index 0000000..c74fb53 --- /dev/null +++ b/server/babel.config.js @@ -0,0 +1,3 @@ +module.exports = { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], +}; diff --git a/src/storage/index.js b/src/storage/index.js index 74ffdbe..890d8bc 100644 --- a/src/storage/index.js +++ b/src/storage/index.js @@ -11,6 +11,8 @@ import { onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch, } from 'firebase/firestore'; import { initFirebase, createFirebaseStorage } from './firebase'; +import { createWsStorage } from './ws'; +import { createMemoryStorage } from './memory'; let storageInstance = null; @@ -23,13 +25,11 @@ export function getStorage() { if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.'); storageInstance = createFirebaseStorage(); } else if (mode === 'ws') { - const { createWsStorage } = require('./ws'); storageInstance = createWsStorage({ - baseUrl: process.env.REACT_APP_BACKEND_URL || 'http://127.0.0.1:4001', - wsUrl: process.env.REACT_APP_BACKEND_WS || 'ws://127.0.0.1:4001/ws', + baseUrl: process.env.REACT_APP_BACKEND_URL || '', + wsUrl: process.env.REACT_APP_BACKEND_WS || '', }); } else { - const { createMemoryStorage } = require('./memory'); storageInstance = createMemoryStorage(); } return storageInstance; diff --git a/src/storage/memory.js b/src/storage/memory.js index 2d48104..1817d70 100644 --- a/src/storage/memory.js +++ b/src/storage/memory.js @@ -4,7 +4,7 @@ 'use strict'; -const { EventEmitter } = require('events'); +import { EventEmitter } from 'events'; function createMemoryStorage() { const docs = new Map(); // path -> data obj @@ -137,4 +137,4 @@ function deepClone(v) { return JSON.parse(JSON.stringify(v)); } -module.exports = { createMemoryStorage }; +export { createMemoryStorage }; diff --git a/src/storage/ws.js b/src/storage/ws.js index 42d801f..6007cf0 100644 --- a/src/storage/ws.js +++ b/src/storage/ws.js @@ -5,11 +5,10 @@ 'use strict'; // Native browser WebSocket if present, else ws pkg (Node/jest). +// Lazy load ws pkg so CRA prod build (ESM) doesn't choke on require(). let WebSocketImpl; if (typeof WebSocket !== 'undefined') { WebSocketImpl = WebSocket; -} else { - WebSocketImpl = require('ws').WebSocket; } function createWsStorage({ baseUrl, wsUrl } = {}) { @@ -47,7 +46,15 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { function ensureWs() { if (wsReady) return wsReady; wsReady = new Promise((resolve, reject) => { - ws = new WebSocketImpl(WS); + (async () => { + // Node/jest only: load ws pkg via dynamic import. Browser uses global + // WebSocket. Avoids require() in CRA prod ESM bundle (webpack crash). + let WsClass = WebSocketImpl; + if (!WsClass) { + const wsPkg = await import('ws'); + WsClass = wsPkg.WebSocket; + } + ws = new WsClass(WS); const onOpen = () => { const isReconnect = everConnected; everConnected = true; @@ -99,6 +106,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { ws.addEventListener('close', onClose); ws.addEventListener('message', onMessage); } + })(); }); return wsReady; } @@ -226,4 +234,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) { return storage; } -module.exports = { createWsStorage }; +export { createWsStorage }; diff --git a/src/tests/StorageEsm.test.js b/src/tests/StorageEsm.test.js new file mode 100644 index 0000000..cd08f15 --- /dev/null +++ b/src/tests/StorageEsm.test.js @@ -0,0 +1,19 @@ +// Lock: storage adapters must use ESM exports (no module.exports). +// Regression guard: CJS in src/ crashes CRA prod build (ESM strict). +// Bug history: ws.js + memory.js used module.exports. Dev lenient (masked), +// prod bundle crashed blank page. firebase.js always ESM. +import fs from 'fs'; +import path from 'path'; + +const ADAPTER_DIR = path.join(__dirname, '..', 'storage'); + +describe('storage adapters use ESM (no CJS)', () => { + const adapters = ['ws.js', 'memory.js', 'firebase.js', 'index.js']; + test.each(adapters)('%s has no module.exports', (file) => { + const full = fs.readFileSync(path.join(ADAPTER_DIR, file), 'utf8'); + // strip line comments so words like 'require' in explanatory comments don't trip the guard + const src = full.replace(/^\s*\/\/.*$/gm, ''); + expect(src).not.toMatch(/module\.exports\s*=/); + expect(src).not.toMatch(/\brequire\s*\(/); + }); +});