Rework backend #1
@@ -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}
|
||||
# }
|
||||
}
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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:
|
||||
Executable
+12
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [['@babel/preset-env', { targets: { node: 'current' } }]],
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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*\(/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user