add first draft working docker setup

This commit is contained in:
JCEEE
2026-08-05 20:03:01 +01:00
parent fed2f32258
commit 0fb90de5a7
20 changed files with 318 additions and 38 deletions
+5
View File
@@ -6,3 +6,8 @@ PB_EMAIL=
PB_PASSWORD=
DEBUG_RECORD_ID=
PUBLIC_PB_URL=
# docker-compose (staging)
PORT=3001
PB_DATA=./pb_data
PB_ENDPOINT=http://127.0.0.1:8090
+14 -3
View File
@@ -1,4 +1,7 @@
# ── Build stage ──────────────────────────────────────────
FROM node:22-alpine AS builder
ARG PUBLIC_PB_URL=/pb
ENV PUBLIC_PB_URL=$PUBLIC_PB_URL
RUN corepack enable
WORKDIR /app
COPY . .
@@ -6,8 +9,12 @@ RUN pnpm install --frozen-lockfile
RUN pnpm --filter frontend build
RUN pnpm --filter proxy build
# ── Runtime stage ────────────────────────────────────────
FROM node:22-alpine
RUN corepack enable && apk add --no-cache nginx
ARG PUBLIC_PB_URL=/pb
ENV PUBLIC_PB_URL=$PUBLIC_PB_URL
ENV PB_ENDPOINT=http://127.0.0.1:8090
RUN corepack enable && apk add --no-cache nginx wget unzip ca-certificates
WORKDIR /app
COPY --from=builder /app/frontend/build ./frontend/build
COPY --from=builder /app/proxy/dist ./proxy/dist
@@ -15,8 +22,12 @@ COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/pnpm-lock.yaml ./
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Bundle PocketBase (same version as Dockerfile.dev) into the app container.
RUN wget -qO /tmp/pb.zip https://github.com/pocketbase/pocketbase/releases/download/v0.25.8/pocketbase_0.25.8_linux_amd64.zip \
&& unzip -o /tmp/pb.zip -d /usr/local/bin/ \
&& rm /tmp/pb.zip
RUN chmod +x /entrypoint.sh /usr/local/bin/pocketbase
ENV FRONTEND_PORT=2080
ENV PROXY_PORT=3456
EXPOSE 3001
EXPOSE 3001 8090
CMD ["/entrypoint.sh"]
+23
View File
@@ -1,6 +1,29 @@
#!/bin/sh
set -e
PB_DATA=${PB_DATA_DIR:-/app/pb_data}
# Seed a platform superuser from env (idempotent — no-op if it already exists).
# If PB_EMAIL/PB_PASSWORD aren't set, skip and rely on manual web setup.
if [ -n "$PB_EMAIL" ] && [ -n "$PB_PASSWORD" ]; then
echo "[entrypoint] Ensuring PocketBase superuser..."
pocketbase superuser upsert "$PB_EMAIL" "$PB_PASSWORD" --dir="$PB_DATA" || true
fi
# Start PocketBase (internal only; published via loopback for admin UI).
pocketbase serve --http=0.0.0.0:8090 --dir="$PB_DATA" &
# Wait for PB to be healthy before starting the proxy (which runs migrate).
echo "[entrypoint] Waiting for PocketBase..."
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:8090/api/health >/dev/null 2>&1; then
echo "[entrypoint] PocketBase is healthy."
break
fi
sleep 1
done
# Start the app (frontend + proxy). The proxy auto-runs schema migration.
PORT=${FRONTEND_PORT:-2080} node /app/frontend/build/index.js &
PROXY_PORT=${PROXY_PORT:-3456} node /app/proxy/dist/index.js &
+22
View File
@@ -11,6 +11,7 @@ server {
proxy_cache_bypass $http_upgrade;
}
# App's Hono proxy (/api/*).
location /api/ {
proxy_pass http://127.0.0.1:3456;
proxy_http_version 1.1;
@@ -19,4 +20,25 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Browser → internal PocketBase SDK (REST + realtime WebSocket). Only the
# /api subtree the PocketBase JS SDK uses. The admin UI (/_ and everything
# else under /pb/) is intentionally NOT proxied, keeping it internal.
location /pb/api/ {
proxy_pass http://127.0.0.1:8090/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
}
# Everything else under /pb/ (including /pb/_ admin UI) → 404.
location /pb/ {
return 404;
}
}
+1 -3
View File
@@ -2,7 +2,5 @@ import { defineEnvVars } from '@sveltejs/kit/hooks';
export const variables = defineEnvVars({
DEBUG_RECORD_ID: {},
SERVER_IP: {public: true},
PB_PORT: {public: true},
PUBLIC_PB_URL: {public: true}
PUBLIC_PB_URL: { public: true }
});
+1 -1
View File
@@ -1,6 +1,6 @@
import type { Handle } from '@sveltejs/kit';
const COOKIE_NAME = 'session';
const COOKIE_NAME = 'famdon-sesh';
export const handle: Handle = async ({ event, resolve }) => {
const raw = event.cookies.get(COOKIE_NAME);
+7 -5
View File
@@ -1,8 +1,10 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
const BASE_URL = typeof window === 'undefined'
? `http://${SERVER_IP}:${PROXY_PORT}`
: '';
const BASE_URL =
typeof window === 'undefined'
? typeof process !== 'undefined'
? process.env.PROXY_URL ||
`http://${process.env.SERVER_IP || '192.168.1.225'}:${process.env.PROXY_PORT || '3456'}`
: ''
: '';
async function memberFetch<T = unknown>(
method: string,
+2 -2
View File
@@ -1,8 +1,8 @@
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from '@sveltejs/kit';
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
export function getSession(event: RequestEvent) {
return event.locals.session;
+2 -2
View File
@@ -1,7 +1,7 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
import type { RequestEvent } from '@sveltejs/kit';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
function sessionHeaders(event: RequestEvent): Record<string, string> {
const s = event.locals.session;
+1 -3
View File
@@ -1,6 +1,4 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from './env';
let token: string | null = null;
let tokenExpiry = 0;
+2 -2
View File
@@ -1,6 +1,6 @@
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
async function paydayCheck(famId: string, headers: Record<string, string>) {
try {
@@ -1,9 +1,9 @@
import { fail, redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { memberApi } from '$lib/client/api';
import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
export async function load(event) {
const session = event.locals.session;
@@ -1,8 +1,8 @@
import { redirect } from '@sveltejs/kit';
import { hono } from '$lib/server/hono';
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
export async function load(event) {
const session = event.locals.session;
+1 -1
View File
@@ -1,6 +1,6 @@
import { pbAdmin } from '$lib/server/pb-admin';
import { redirect, fail } from '@sveltejs/kit';
import { PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
import { PB_EMAIL, PB_PASSWORD } from '$lib/server/env';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies }) => {
@@ -1,8 +1,8 @@
import { fail, redirect } from '@sveltejs/kit';
import { setDeviceTokenCookie } from '$lib/server/auth';
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
import { PROXY_URL } from '$lib/server/env';
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
const HONO_URL = PROXY_URL;
export const actions = {
default: async (event) => {
+6
View File
@@ -1,10 +1,16 @@
{
"name": "famchamp-monorepo",
"private": true,
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "lsof -ti tcp:3456 | xargs -r kill -9 && pnpm -r --parallel dev",
"start": "pnpm dev",
"build": "pnpm -r build"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
},
"version": "0.2.0"
}
+3
View File
@@ -66,6 +66,9 @@ importers:
'@types/node':
specifier: ^26.0.0
version: 26.0.0
esbuild:
specifier: ^0.28.1
version: 0.28.1
tsx:
specifier: ^4.19.0
version: 4.22.4
+4 -3
View File
@@ -3,9 +3,9 @@
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
"start": "node dist/index.js",
"seed": "tsx scripts/seed.ts"
},
"dependencies": {
@@ -14,6 +14,7 @@
},
"devDependencies": {
"@types/node": "^26.0.0",
"esbuild": "^0.28.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
+217 -4
View File
@@ -1,6 +1,4 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts";
let token: string | null = null;
@@ -32,7 +30,7 @@ async function getCollection(name: string): Promise<any | null> {
return data?.items?.[0] || null;
}
async function createCollection(col: any): Promise<void> {
async function createCollection(col: any): Promise<string | null> {
const t = await auth();
const res = await fetch(`${PB_ENDPOINT}/api/collections`, {
method: "POST",
@@ -46,6 +44,7 @@ async function createCollection(col: any): Promise<void> {
if (!res.ok)
throw new Error(`Create ${col.name} failed: ${JSON.stringify(data)}`);
console.log(` ✓ Created collection: ${col.name}`);
return data?.id || null;
}
async function updateCollection(id: string, col: any): Promise<void> {
@@ -64,9 +63,223 @@ async function updateCollection(id: string, col: any): Promise<void> {
console.log(` ✓ Updated collection: ${col.name || id}`);
}
// ── Field helpers (mirror proxy/scripts/seed.ts) ──
function text(name: string, required = false) {
return { name, type: "text", required };
}
function uniqueText(name: string) {
return { name, type: "text", required: true, unique: true };
}
function number(name: string, required = false) {
return { name, type: "number", required };
}
function bool(name: string) {
return { name, type: "bool" };
}
function date(name: string) {
return { name, type: "date" };
}
function jsonField(name: string) {
return { name, type: "json" };
}
function select(name: string, values: string[], required = false) {
return { name, type: "select", required, values, maxSelect: 1 };
}
function rel(name: string, collectionId: string, required = false) {
return {
name,
type: "relation",
required,
collectionId,
maxSelect: 1,
cascadeDelete: false,
};
}
function colDef(name: string, fields: any[]): any {
return {
name,
type: "base",
listRule: "",
viewRule: "",
createRule: null,
updateRule: null,
deleteRule: null,
fields,
};
}
// Bootstrap the full base schema on a fresh PocketBase (docker first boot).
// Idempotent — skips collections that already exist.
async function ensureSchema(): Promise<void> {
if (await getCollection("fams")) {
console.log("[migrate] Base schema already present — skipping bootstrap.");
return;
}
console.log("[migrate] Bootstrapping base schema on fresh PocketBase...");
// Pass 1: independent
const famsId = (await createCollection(
colDef("fams", [
text("name", true),
uniqueText("slug"),
text("inviteCode"),
text("stripeCustomerId"),
jsonField("featureFlags"),
jsonField("seasons"),
]),
))!;
await createCollection(
colDef("bonus_templates", [
rel("famId", famsId, true),
text("name", true),
text("description"),
select("target", ["individual", "competitive", "collaborative"], true),
select("type", ["threshold", "count", "manual"], true),
select("occurrence", ["recurring", "once"], true),
select("rewardType", ["points", "cash", "prize"], true),
text("rewardValue", true),
number("criteriaValue"),
select("period", ["schedule", "daily", "weekly", "monthly"]),
]),
);
// Pass 2: reference fams
await createCollection(
colDef("settings", [rel("famId", famsId, true), text("webhookUrl")]),
);
const membersId = (await createCollection(
colDef("members", [
rel("famId", famsId, true),
text("name", true),
text("color"),
text("deviceToken"),
text("deviceTokenHint"),
]),
))!;
const choreTemplatesId = (await createCollection(
colDef("chore_templates", [
rel("famId", famsId, true),
text("name", true),
text("description"),
select("defaultFrequency", ["daily", "weekly"], true),
select("defaultType", ["points", "money"], true),
number("defaultValue", true),
]),
))!;
await createCollection(
colDef("fam_admins", [
rel("famId", famsId, true),
text("userId", true),
text("email", true),
text("name"),
text("color"),
]),
);
const bonusConfigsId = (await createCollection(
colDef("bonus_configs", [
rel("famId", famsId, true),
text("name", true),
text("description"),
select("target", ["individual", "competitive", "collaborative"], true),
select("type", ["threshold", "count", "manual"], true),
select("occurrence", ["recurring", "once"], true),
select("rewardType", ["points", "cash", "prize"], true),
text("rewardValue", true),
number("criteriaValue"),
rel("memberId", membersId),
select("period", ["schedule", "daily", "weekly", "monthly"]),
select("status", ["active", "completed"], true),
]),
))!;
// Pass 3: nested deps
await createCollection(
colDef("weekly_history", [
rel("famId", famsId, true),
rel("memberId", membersId, true),
date("weekStart"),
number("pointsEarned"),
number("moneyEarned"),
number("choresCompleted"),
number("bonusEarned"),
]),
);
await createCollection(
colDef("rewards", [
rel("famId", famsId, true),
rel("memberId", membersId, true),
rel("bonusConfigId", bonusConfigsId),
text("label", true),
number("value", true),
select("rewardType", ["cash", "prize", "points"], true),
select("status", ["unclaimed", "requested", "claimed"], true),
select("claimable", ["immediate", "payday"], true),
text("settleDate"),
date("claimedAt"),
date("requestedAt"),
text("date"),
]),
);
const assignedChoresId = (await createCollection(
colDef("assigned_chores", [
rel("famId", famsId, true),
rel("memberId", membersId, true),
rel("templateId", choreTemplatesId, true),
select("frequency", ["daily", "weekly"], true),
select("type", ["points", "money"], true),
number("value", true),
text("customName"),
jsonField("seasonIds"),
]),
))!;
await createCollection(
colDef("seasons", [
rel("famId", famsId, true),
text("name", true),
text("color"),
bool("active"),
date("autoDisable"),
date("autoStart"),
]),
);
await createCollection(
colDef("completions", [
rel("famId", famsId, true),
rel("memberId", membersId, true),
rel("assignedChoreId", assignedChoresId, true),
date("date"),
date("completedAt"),
]),
);
await createCollection(
colDef("messages", [
rel("famId", famsId, true),
select("authorType", ["admin", "member"], true),
text("authorId", true),
text("authorName", true),
text("authorColor"),
text("content", true),
date("createdAt"),
]),
);
await createCollection(
colDef("chat_typing", [
rel("famId", famsId, true),
text("actorId", true),
select("actorType", ["admin", "member"], true),
text("authorName", true),
text("authorColor"),
bool("typing"),
]),
);
console.log("[migrate] Base schema bootstrapped.");
}
export async function migrate(): Promise<void> {
console.log("[migrate] Checking PB collection schemas...");
await ensureSchema();
// ── 1. Create bonus_configs if missing ──
const existing = await getCollection("bonus_configs");
if (!existing) {
+1 -3
View File
@@ -1,6 +1,4 @@
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from "../../config.ts";
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
import { PB_ENDPOINT, PB_EMAIL, PB_PASSWORD } from "./env.ts";
let adminToken: string | null = null;
let tokenExpiry = 0;