add first draft working docker setup
This commit is contained in:
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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 &
|
||||
|
||||
|
||||
@@ -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
@@ -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,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);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --outfile=dist/index.js",
|
||||
"start": "node dist/index.js",
|
||||
"seed": "tsx scripts/seed.ts"
|
||||
},
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.0.0",
|
||||
"esbuild": "^0.28.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
|
||||
+217
-4
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user