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,
|
onSnapshot, updateDoc, deleteDoc, query, orderBy, limit, writeBatch,
|
||||||
} from 'firebase/firestore';
|
} from 'firebase/firestore';
|
||||||
import { initFirebase, createFirebaseStorage } from './firebase';
|
import { initFirebase, createFirebaseStorage } from './firebase';
|
||||||
|
import { createWsStorage } from './ws';
|
||||||
|
import { createMemoryStorage } from './memory';
|
||||||
|
|
||||||
let storageInstance = null;
|
let storageInstance = null;
|
||||||
|
|
||||||
@@ -23,13 +25,11 @@ export function getStorage() {
|
|||||||
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
if (!ok) throw new Error('Firebase config missing. Check REACT_APP_FIREBASE_* env.');
|
||||||
storageInstance = createFirebaseStorage();
|
storageInstance = createFirebaseStorage();
|
||||||
} else if (mode === 'ws') {
|
} else if (mode === 'ws') {
|
||||||
const { createWsStorage } = require('./ws');
|
|
||||||
storageInstance = createWsStorage({
|
storageInstance = createWsStorage({
|
||||||
baseUrl: process.env.REACT_APP_BACKEND_URL || 'http://127.0.0.1:4001',
|
baseUrl: process.env.REACT_APP_BACKEND_URL || '',
|
||||||
wsUrl: process.env.REACT_APP_BACKEND_WS || 'ws://127.0.0.1:4001/ws',
|
wsUrl: process.env.REACT_APP_BACKEND_WS || '',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const { createMemoryStorage } = require('./memory');
|
|
||||||
storageInstance = createMemoryStorage();
|
storageInstance = createMemoryStorage();
|
||||||
}
|
}
|
||||||
return storageInstance;
|
return storageInstance;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const { EventEmitter } = require('events');
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
function createMemoryStorage() {
|
function createMemoryStorage() {
|
||||||
const docs = new Map(); // path -> data obj
|
const docs = new Map(); // path -> data obj
|
||||||
@@ -137,4 +137,4 @@ function deepClone(v) {
|
|||||||
return JSON.parse(JSON.stringify(v));
|
return JSON.parse(JSON.stringify(v));
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createMemoryStorage };
|
export { createMemoryStorage };
|
||||||
|
|||||||
+12
-4
@@ -5,11 +5,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Native browser WebSocket if present, else ws pkg (Node/jest).
|
// 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;
|
let WebSocketImpl;
|
||||||
if (typeof WebSocket !== 'undefined') {
|
if (typeof WebSocket !== 'undefined') {
|
||||||
WebSocketImpl = WebSocket;
|
WebSocketImpl = WebSocket;
|
||||||
} else {
|
|
||||||
WebSocketImpl = require('ws').WebSocket;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
function createWsStorage({ baseUrl, wsUrl } = {}) {
|
||||||
@@ -47,7 +46,15 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
function ensureWs() {
|
function ensureWs() {
|
||||||
if (wsReady) return wsReady;
|
if (wsReady) return wsReady;
|
||||||
wsReady = new Promise((resolve, reject) => {
|
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 onOpen = () => {
|
||||||
const isReconnect = everConnected;
|
const isReconnect = everConnected;
|
||||||
everConnected = true;
|
everConnected = true;
|
||||||
@@ -99,6 +106,7 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
ws.addEventListener('close', onClose);
|
ws.addEventListener('close', onClose);
|
||||||
ws.addEventListener('message', onMessage);
|
ws.addEventListener('message', onMessage);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
return wsReady;
|
return wsReady;
|
||||||
}
|
}
|
||||||
@@ -226,4 +234,4 @@ function createWsStorage({ baseUrl, wsUrl } = {}) {
|
|||||||
return storage;
|
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