fix(docker): single container (caddy+node), ESM adapters fix blank page

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.
This commit is contained in:
david raistrick
2026-07-01 19:03:59 -04:00
parent c1d982b4a4
commit da25f46e3e
14 changed files with 145 additions and 157 deletions
-36
View File
@@ -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}
# }
}
-29
View File
@@ -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
-59
View File
@@ -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 <hashed-pass>"
# 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:
+18
View File
@@ -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
}
}
+53
View File
@@ -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"]
+22
View File
@@ -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:
+12
View File
@@ -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
-23
View File
@@ -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"]
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
};
+4 -4
View File
@@ -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;
+2 -2
View File
@@ -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 };
+12 -4
View File
@@ -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 };
+19
View File
@@ -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*\(/);
});
});