From aa2d3dc0d2fbef261554efdbf01c98f0da809b7b Mon Sep 17 00:00:00 2001
From: Humza Butt
Date: Sun, 28 Jun 2026 17:02:50 +0100
Subject: [PATCH 1/7] Implement API for session management with WebSocket
support and local development configuration
- Added new API routes for session management including creation, retrieval, and updates.
- Introduced WebSocket support for real-time updates in session rooms.
- Configured local development environment variables in `.env.example`.
- Updated package scripts for local API development.
- Added middleware for request validation and body size limits.
- Implemented pagination for session listing.
- Created utility functions for database connection and error handling.
- Added OpenAPI documentation for the new endpoints.
- Included tests for session routes and pagination functionality.
- Refactored existing code for improved structure and maintainability.
---
.env.example | 4 +
apps/api/src/__tests__/pagination.test.ts | 14 +
apps/api/src/__tests__/scores.test.ts | 24 ++
apps/api/src/__tests__/session-room.test.ts | 35 +++
.../api/src/__tests__/sessions.routes.test.ts | 190 ++++++++++++++
apps/api/src/app.ts | 29 +++
apps/api/src/env.ts | 7 +-
apps/api/src/index.ts | 156 +-----------
apps/api/src/lib/db.ts | 15 ++
apps/api/src/lib/errors.ts | 18 ++
apps/api/src/lib/pagination.ts | 30 +++
apps/api/src/lib/scores.ts | 15 ++
apps/api/src/lib/serialize.ts | 82 ++++++
apps/api/src/middleware.ts | 10 +
apps/api/src/openapi/spec.ts | 239 ++++++++++++++++++
apps/api/src/principal.ts | 3 +-
apps/api/src/routes/sessions.ts | 73 ++++++
apps/api/src/services/sessions.ts | 165 ++++++++++++
apps/api/src/session-room.ts | 22 +-
apps/api/wrangler.toml | 5 +
apps/web/src/components/PlayerAvatar.tsx | 32 +++
apps/web/src/hooks/useLiveSession.ts | 25 +-
apps/web/src/lib/api.ts | 2 +-
apps/web/src/main.tsx | 41 ++-
apps/web/src/routes/History.tsx | 42 ++-
apps/web/src/routes/Overview.tsx | 58 ++++-
apps/web/src/routes/SessionDetail.tsx | 23 +-
apps/web/vite.config.d.ts.map | 2 +-
apps/web/vite.config.js | 1 +
apps/web/vite.config.ts | 1 +
package.json | 2 +-
.../db/migrations/meta/0000_snapshot.json | 58 ++---
packages/db/migrations/meta/_journal.json | 2 +-
packages/db/migrations/zzzz_force_rls.sql | 8 +
packages/db/src/client.ts | 2 +
packages/db/src/force-rls.ts | 2 +-
packages/db/{ => src}/load-env.ts | 2 +-
packages/db/src/reset.ts | 2 +-
packages/db/src/seed.ts | 2 +-
scripts/wrangler-dev.mjs | 29 +++
40 files changed, 1224 insertions(+), 248 deletions(-)
create mode 100644 apps/api/src/__tests__/pagination.test.ts
create mode 100644 apps/api/src/__tests__/scores.test.ts
create mode 100644 apps/api/src/__tests__/session-room.test.ts
create mode 100644 apps/api/src/__tests__/sessions.routes.test.ts
create mode 100644 apps/api/src/app.ts
create mode 100644 apps/api/src/lib/db.ts
create mode 100644 apps/api/src/lib/errors.ts
create mode 100644 apps/api/src/lib/pagination.ts
create mode 100644 apps/api/src/lib/scores.ts
create mode 100644 apps/api/src/lib/serialize.ts
create mode 100644 apps/api/src/openapi/spec.ts
create mode 100644 apps/api/src/routes/sessions.ts
create mode 100644 apps/api/src/services/sessions.ts
create mode 100644 apps/web/src/components/PlayerAvatar.tsx
rename packages/db/{ => src}/load-env.ts (96%)
create mode 100644 scripts/wrangler-dev.mjs
diff --git a/.env.example b/.env.example
index 6f679c1..dfdf5de 100644
--- a/.env.example
+++ b/.env.example
@@ -18,3 +18,7 @@ R2_PUBLIC_BASE_PROD="https://oche-media.r2.example"
# --- Cloudflare (CI) ---
CLOUDFLARE_ACCOUNT_ID=""
CLOUDFLARE_API_TOKEN=""
+
+# --- Local dev (SPA + API) ---
+VITE_API_BASE="http://localhost:8787"
+APP_ORIGIN="http://localhost:5173"
diff --git a/apps/api/src/__tests__/pagination.test.ts b/apps/api/src/__tests__/pagination.test.ts
new file mode 100644
index 0000000..8f865be
--- /dev/null
+++ b/apps/api/src/__tests__/pagination.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { decodeCursor, encodeCursor } from '../lib/pagination.js';
+
+describe('pagination cursor', () => {
+ it('round-trips a cursor', () => {
+ const cursor = { createdAt: '2026-01-01T12:00:00.000Z', id: '11111111-1111-1111-1111-111111111111' };
+ const encoded = encodeCursor(cursor);
+ expect(decodeCursor(encoded)).toEqual(cursor);
+ });
+
+ it('returns null for invalid cursor', () => {
+ expect(decodeCursor('not-valid')).toBeNull();
+ });
+});
diff --git a/apps/api/src/__tests__/scores.test.ts b/apps/api/src/__tests__/scores.test.ts
new file mode 100644
index 0000000..176998a
--- /dev/null
+++ b/apps/api/src/__tests__/scores.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { applyScoreChange, SCORE_MAX, SCORE_MIN } from '../lib/scores.js';
+
+describe('applyScoreChange', () => {
+ it('applies a positive delta', () => {
+ expect(applyScoreChange(40, { delta: 20 })).toEqual({ ok: true, next: 60, delta: 20 });
+ });
+
+ it('rejects scores below minimum', () => {
+ const r = applyScoreChange(5, { delta: -10 });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain(String(SCORE_MIN));
+ });
+
+ it('rejects scores above maximum', () => {
+ const r = applyScoreChange(SCORE_MAX, { delta: 1 });
+ expect(r.ok).toBe(false);
+ if (!r.ok) expect(r.error).toContain(String(SCORE_MAX));
+ });
+
+ it('honours absolute set', () => {
+ expect(applyScoreChange(40, { set: 100 })).toEqual({ ok: true, next: 100, delta: 60 });
+ });
+});
diff --git a/apps/api/src/__tests__/session-room.test.ts b/apps/api/src/__tests__/session-room.test.ts
new file mode 100644
index 0000000..f37ae08
--- /dev/null
+++ b/apps/api/src/__tests__/session-room.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import type { WsServerMessage } from '@oche/shared';
+import { SessionRoom } from '../session-room.js';
+
+describe('SessionRoom', () => {
+ it('accepts POST /broadcast and fans out to websockets', async () => {
+ const sent: string[] = [];
+ const ws = { send: (msg: string) => sent.push(msg) } as unknown as WebSocket;
+ const ctx = {
+ getWebSockets: () => [ws],
+ acceptWebSocket: () => {},
+ storage: { setAlarm: async () => {}, deleteAlarm: async () => {} },
+ };
+
+ const room = new SessionRoom(ctx as never, {} as never);
+ const message: WsServerMessage = {
+ type: 'score',
+ playerId: '11111111-1111-1111-1111-111111111111',
+ newScore: 50,
+ delta: 10,
+ };
+
+ const res = await room.fetch(
+ new Request('https://session-room/broadcast', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(message),
+ }),
+ );
+
+ expect(res.status).toBe(200);
+ expect(sent).toHaveLength(1);
+ expect(JSON.parse(sent[0]!)).toEqual(message);
+ });
+});
diff --git a/apps/api/src/__tests__/sessions.routes.test.ts b/apps/api/src/__tests__/sessions.routes.test.ts
new file mode 100644
index 0000000..874944a
--- /dev/null
+++ b/apps/api/src/__tests__/sessions.routes.test.ts
@@ -0,0 +1,190 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createApp } from '../app.js';
+import type { Env } from '../env.js';
+
+const OWNER_A = '11111111-1111-1111-1111-111111111111';
+const OWNER_B = '22222222-2222-2222-2222-222222222222';
+const SESSION_ID = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
+const PLAYER_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
+
+const mockSession = {
+ id: SESSION_ID,
+ ownerId: OWNER_A,
+ status: 'active' as const,
+ title: 'Test Session',
+ videoUrl: null,
+ videoPoster: null,
+ hlsUrl: null,
+ createdAt: new Date('2026-06-01T12:00:00.000Z'),
+ updatedAt: new Date('2026-06-01T12:00:00.000Z'),
+};
+
+const mockPlayer = {
+ id: PLAYER_ID,
+ sessionId: SESSION_ID,
+ name: 'Alice',
+ score: 40,
+ photoUrl: null,
+ position: 0,
+};
+
+const mockTx = {
+ select: vi.fn(),
+ insert: vi.fn(),
+ update: vi.fn(),
+ delete: vi.fn(),
+ execute: vi.fn(),
+};
+
+vi.mock('@oche/db', () => ({
+ createDb: vi.fn(() => ({})),
+ withPrincipal: vi.fn(async (_db, ownerId, fn) => {
+ if (ownerId === OWNER_B) {
+ const emptyTx = {
+ select: vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({
+ limit: vi.fn(async () => []),
+ })),
+ })),
+ })),
+ })),
+ };
+ return fn(emptyTx);
+ }
+ return fn(mockTx);
+ }),
+ sessions: { id: 'id', createdAt: 'createdAt', ownerId: 'ownerId' },
+ players: { id: 'id', sessionId: 'sessionId', position: 'position' },
+ scoreEvents: { playerId: 'playerId', createdAt: 'createdAt' },
+}));
+
+function chainSelect(rows: unknown[]) {
+ const limit = vi.fn(async () => rows);
+ const orderBy = vi.fn(() => ({ limit, orderBy: vi.fn(() => ({ limit })) }));
+ const where = vi.fn(() => ({ orderBy, limit, where: vi.fn(() => ({ orderBy, limit })) }));
+ const from = vi.fn(() => ({ where, orderBy, limit }));
+ mockTx.select.mockReturnValue({ from });
+ return { where, orderBy, limit };
+}
+
+function mockEnv(): Env {
+ return {
+ DATABASE_URL: 'postgresql://test',
+ APP_ORIGIN: 'http://localhost:5173',
+ ENVIRONMENT: 'development',
+ SESSION_ROOM: {
+ idFromName: () => ({ fetch: vi.fn(async () => new Response(JSON.stringify({ ok: true }))) }),
+ } as unknown as Env['SESSION_ROOM'],
+ MEDIA: {} as Env['MEDIA'],
+ };
+}
+
+describe('sessions API routes', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('GET /health is public', async () => {
+ const app = createApp();
+ const res = await app.request('/health', {}, mockEnv());
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ ok: true });
+ });
+
+ it('GET /openapi.json is public', async () => {
+ const app = createApp();
+ const res = await app.request('/openapi.json', {}, mockEnv());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { openapi: string };
+ expect(body.openapi).toBe('3.1.0');
+ });
+
+ it('returns 401 without principal header', async () => {
+ const app = createApp();
+ const res = await app.request('/sessions', {}, mockEnv());
+ expect(res.status).toBe(401);
+ });
+
+ it('GET /sessions returns paginated summaries', async () => {
+ chainSelect([mockSession]);
+ mockTx.select.mockImplementationOnce(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(() => ({
+ orderBy: vi.fn(() => ({
+ limit: vi.fn(async () => [mockSession]),
+ })),
+ })),
+ })),
+ }));
+ mockTx.select.mockImplementationOnce(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn(async () => [mockPlayer]),
+ })),
+ }));
+
+ const app = createApp();
+ const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-a' } }, mockEnv());
+ expect(res.status).toBe(200);
+ expect(res.headers.get('Cache-Control')).toContain('max-age=15');
+ const body = (await res.json()) as { data: unknown[]; nextCursor: string | null };
+ expect(body.data).toHaveLength(1);
+ });
+
+ it('GET /sessions/:id returns 404 when hidden by RLS', async () => {
+ chainSelect([]);
+ mockTx.select.mockReturnValue({
+ from: vi.fn(() => ({
+ where: vi.fn(async () => []),
+ })),
+ });
+
+ const app = createApp();
+ const res = await app.request(
+ `/sessions/${SESSION_ID}`,
+ { headers: { 'x-oche-owner': 'demo-key-a' } },
+ mockEnv(),
+ );
+ expect(res.status).toBe(404);
+ });
+
+ it('POST /sessions rejects unknown keys (strict Zod)', async () => {
+ const app = createApp();
+ const res = await app.request(
+ '/sessions',
+ {
+ method: 'POST',
+ headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' },
+ body: JSON.stringify({ title: 'x', players: [{ name: 'A' }], extra: true }),
+ },
+ mockEnv(),
+ );
+ expect(res.status).toBe(422);
+ const body = (await res.json()) as { error: string; issues?: unknown[] };
+ expect(body.error).toBe('Invalid body');
+ expect(body.issues?.length).toBeGreaterThan(0);
+ });
+
+ it('PATCH /sessions/:id rejects empty body', async () => {
+ const app = createApp();
+ const res = await app.request(
+ `/sessions/${SESSION_ID}`,
+ {
+ method: 'PATCH',
+ headers: { 'content-type': 'application/json', 'x-oche-owner': 'demo-key-a' },
+ body: JSON.stringify({}),
+ },
+ mockEnv(),
+ );
+ expect(res.status).toBe(422);
+ });
+
+ it('RLS isolation: other owner sees empty list', async () => {
+ const app = createApp();
+ const res = await app.request('/sessions', { headers: { 'x-oche-owner': 'demo-key-b' } }, mockEnv());
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as { data: unknown[] };
+ expect(body.data).toEqual([]);
+ });
+});
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
new file mode 100644
index 0000000..734d850
--- /dev/null
+++ b/apps/api/src/app.ts
@@ -0,0 +1,29 @@
+import { Hono } from 'hono';
+import { cors } from 'hono/cors';
+import type { Env } from './env.js';
+import { bodySizeLimit, rateLimit, securityHeaders } from './middleware.js';
+import { openApiDocument } from './openapi/spec.js';
+import { sessions } from './routes/sessions.js';
+
+export function createApp() {
+ const app = new Hono<{ Bindings: Env }>();
+
+ app.use('*', securityHeaders);
+ app.use('*', bodySizeLimit());
+ app.use('*', rateLimit());
+ app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next));
+
+ app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT }));
+ app.get('/openapi.json', (c) => c.json(openApiDocument));
+
+ app.route('/sessions', sessions);
+
+ app.onError((err, c) => {
+ console.error(err);
+ return c.json({ error: 'Internal error' }, 500);
+ });
+
+ return app;
+}
+
+export default createApp();
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
index 9122e39..fd7f8e5 100644
--- a/apps/api/src/env.ts
+++ b/apps/api/src/env.ts
@@ -1,9 +1,12 @@
import type { DurableObjectNamespace, Hyperdrive, R2Bucket } from '@cloudflare/workers-types';
export interface Env {
- HYPERDRIVE: Hyperdrive;
+ /** Hyperdrive in staging/production; optional locally. */
+ HYPERDRIVE?: Hyperdrive;
+ /** Direct Neon URL for local wrangler dev (.dev.vars). */
+ DATABASE_URL?: string;
SESSION_ROOM: DurableObjectNamespace;
MEDIA: R2Bucket;
APP_ORIGIN: string;
- ENVIRONMENT: 'staging' | 'production';
+ ENVIRONMENT: 'development' | 'staging' | 'production';
}
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 7a8d573..0d07d16 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -1,158 +1,8 @@
/**
- * Oche API — Hono on Cloudflare Workers.
- * Endpoints: GET /sessions, GET /sessions/:id, POST /sessions, PATCH /sessions/:id, WS /sessions/:id/live.
- * Every DB call runs inside withPrincipal(), so RLS enforces per-owner isolation.
+ * Oche API — Cloudflare Worker entry (REST + SessionRoom Durable Object).
*/
-import { createDb, players, scoreEvents, sessions, withPrincipal } from '@oche/db';
-import { CreateSessionInput, PatchSessionInput } from '@oche/shared';
-import { and, desc, eq } from 'drizzle-orm';
-import { Hono } from 'hono';
-import { cors } from 'hono/cors';
-import type { Env } from './env.js';
-import { rateLimit, securityHeaders } from './middleware.js';
-import { resolvePrincipal } from './principal.js';
+import { createApp } from './app.js';
export { SessionRoom } from './session-room.js';
-const app = new Hono<{ Bindings: Env; Variables: { ownerId: string } }>();
-
-app.use('*', securityHeaders);
-app.use('*', rateLimit());
-app.use('*', (c, next) => cors({ origin: c.env.APP_ORIGIN, credentials: true })(c, next));
-
-// Resolve principal once per request.
-app.use('/sessions/*', async (c, next) => {
- const ownerId = resolvePrincipal(c);
- if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
- c.set('ownerId', ownerId);
- await next();
-});
-
-app.get('/health', (c) => c.json({ ok: true, env: c.env.ENVIRONMENT }));
-
-app.get('/sessions', async (c) => {
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const rows = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const list = await tx.select().from(sessions).orderBy(desc(sessions.createdAt)).limit(50);
- return Promise.all(
- list.map(async (s) => {
- const ps = await tx.select().from(players).where(eq(players.sessionId, s.id));
- return { ...s, playerCount: ps.length };
- }),
- );
- });
- return c.json({ data: rows, nextCursor: null });
-});
-
-app.get('/sessions/:id', async (c) => {
- const id = c.req.param('id');
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const result = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx.select().from(sessions).where(eq(sessions.id, id));
- if (!s) return null;
- const ps = await tx.select().from(players).where(eq(players.sessionId, id)).orderBy(players.position);
- const events = ps.length
- ? await tx
- .select()
- .from(scoreEvents)
- .where(eq(scoreEvents.playerId, ps[0]!.id))
- .orderBy(desc(scoreEvents.createdAt))
- .limit(20)
- : [];
- return { ...s, players: ps, scoreEvents: events };
- });
- return result ? c.json(result) : c.json({ error: 'Session not found' }, 404);
-});
-
-app.post('/sessions', async (c) => {
- const parsed = CreateSessionInput.safeParse(await c.req.json().catch(() => null));
- if (!parsed.success) {
- return c.json(
- {
- error: 'Invalid body',
- issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
- },
- 422,
- );
- }
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const created = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx
- .insert(sessions)
- .values({
- ownerId: c.get('ownerId'),
- title: parsed.data.title,
- status: parsed.data.status,
- videoUrl: parsed.data.videoUrl,
- videoPoster: parsed.data.videoPoster,
- })
- .returning();
- await tx.insert(players).values(parsed.data.players.map((p) => ({ ...p, sessionId: s!.id })));
- return s;
- });
- return c.json(created, 201);
-});
-
-app.patch('/sessions/:id', async (c) => {
- const id = c.req.param('id');
- const parsed = PatchSessionInput.safeParse(await c.req.json().catch(() => null));
- if (!parsed.success) {
- return c.json(
- {
- error: 'Invalid body',
- issues: parsed.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })),
- },
- 422,
- );
- }
- const db = createDb(c.env.HYPERDRIVE.connectionString);
- const updates = await withPrincipal(db, c.get('ownerId'), async (tx) => {
- const [s] = await tx.select().from(sessions).where(eq(sessions.id, id));
- if (!s) return null;
- if (parsed.data.status) {
- await tx
- .update(sessions)
- .set({ status: parsed.data.status, updatedAt: new Date() })
- .where(eq(sessions.id, id));
- }
- const broadcasts: Array<{ playerId: string; newScore: number; delta: number }> = [];
- for (const change of parsed.data.scores ?? []) {
- const [p] = await tx
- .select()
- .from(players)
- .where(and(eq(players.id, change.playerId), eq(players.sessionId, id)));
- if (!p) continue;
- const newScore = change.set ?? p.score + (change.delta ?? 0);
- const delta = newScore - p.score;
- await tx.update(players).set({ score: newScore }).where(eq(players.id, p.id));
- await tx.insert(scoreEvents).values({ playerId: p.id, delta, newScore });
- broadcasts.push({ playerId: p.id, newScore, delta });
- }
- return { status: parsed.data.status, broadcasts };
- });
- if (!updates) return c.json({ error: 'Session not found' }, 404);
-
- // Fan out live updates via the Durable Object.
- const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
- for (const b of updates.broadcasts) {
- await stub.fetch('https://do/broadcast', {
- method: 'POST',
- body: JSON.stringify({ type: 'score', ...b }),
- });
- }
- return c.json({ ok: true });
-});
-
-// WebSocket upgrade → routed to the per-session Durable Object.
-app.get('/sessions/:id/live', (c) => {
- const id = c.req.param('id');
- const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
- return stub.fetch(c.req.raw);
-});
-
-app.onError((err, c) => {
- console.error(err); // structured log; never returned to the client
- return c.json({ error: 'Internal error' }, 500);
-});
-
-export default app;
+export default createApp();
diff --git a/apps/api/src/lib/db.ts b/apps/api/src/lib/db.ts
new file mode 100644
index 0000000..b6465fe
--- /dev/null
+++ b/apps/api/src/lib/db.ts
@@ -0,0 +1,15 @@
+import { createDb, type Db } from '@oche/db';
+import type { Env } from '../env.js';
+
+/** Runtime DB URL: Hyperdrive in deployed envs, DATABASE_URL from .dev.vars locally. */
+export function getConnectionString(env: Env): string {
+ const url = env.HYPERDRIVE?.connectionString ?? env.DATABASE_URL;
+ if (!url) {
+ throw new Error('Database not configured (set HYPERDRIVE or DATABASE_URL in .dev.vars)');
+ }
+ return url;
+}
+
+export function getDb(env: Env): Db {
+ return createDb(getConnectionString(env));
+}
diff --git a/apps/api/src/lib/errors.ts b/apps/api/src/lib/errors.ts
new file mode 100644
index 0000000..df0c3b2
--- /dev/null
+++ b/apps/api/src/lib/errors.ts
@@ -0,0 +1,18 @@
+import type { Context } from 'hono';
+import type { ZodError } from 'zod';
+
+export function validationError(c: Context, error: ZodError) {
+ return c.json(
+ {
+ error: 'Invalid body',
+ issues: error.issues.map((i) => ({ path: i.path.join('.') || '(root)', message: i.message })),
+ },
+ 422,
+ );
+}
+
+export async function readJsonBody(c: Context): Promise {
+ const ct = c.req.header('content-type') ?? '';
+ if (!ct.includes('application/json')) return null;
+ return c.req.json().catch(() => null);
+}
diff --git a/apps/api/src/lib/pagination.ts b/apps/api/src/lib/pagination.ts
new file mode 100644
index 0000000..6b672bd
--- /dev/null
+++ b/apps/api/src/lib/pagination.ts
@@ -0,0 +1,30 @@
+const DEFAULT_LIMIT = 20;
+const MAX_LIMIT = 50;
+
+export function parseListQuery(url: string): { limit: number; cursor: ListCursor | null } {
+ const params = new URL(url).searchParams;
+ const limitRaw = Number(params.get('limit') ?? DEFAULT_LIMIT);
+ const limit = Number.isFinite(limitRaw)
+ ? Math.min(MAX_LIMIT, Math.max(1, Math.floor(limitRaw)))
+ : DEFAULT_LIMIT;
+
+ const cursorRaw = params.get('cursor');
+ const cursor = cursorRaw ? decodeCursor(cursorRaw) : null;
+ return { limit, cursor };
+}
+
+export type ListCursor = { createdAt: string; id: string };
+
+export function encodeCursor(cursor: ListCursor): string {
+ return Buffer.from(JSON.stringify(cursor)).toString('base64url');
+}
+
+export function decodeCursor(raw: string): ListCursor | null {
+ try {
+ const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as ListCursor;
+ if (typeof parsed.createdAt === 'string' && typeof parsed.id === 'string') return parsed;
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/api/src/lib/scores.ts b/apps/api/src/lib/scores.ts
new file mode 100644
index 0000000..d7c9447
--- /dev/null
+++ b/apps/api/src/lib/scores.ts
@@ -0,0 +1,15 @@
+/** Score bounds for PATCH updates (501-style cumulative scoring). */
+export const SCORE_MIN = 0;
+export const SCORE_MAX = 999;
+
+export function applyScoreChange(
+ current: number,
+ change: { delta?: number; set?: number },
+): { ok: true; next: number; delta: number } | { ok: false; error: string } {
+ const next = change.set ?? current + (change.delta ?? 0);
+ if (!Number.isInteger(next)) return { ok: false, error: 'Score must be an integer' };
+ if (next < SCORE_MIN || next > SCORE_MAX) {
+ return { ok: false, error: `Score must be between ${SCORE_MIN} and ${SCORE_MAX}` };
+ }
+ return { ok: true, next, delta: next - current };
+}
diff --git a/apps/api/src/lib/serialize.ts b/apps/api/src/lib/serialize.ts
new file mode 100644
index 0000000..ed27f98
--- /dev/null
+++ b/apps/api/src/lib/serialize.ts
@@ -0,0 +1,82 @@
+import type { Player, ScoreEvent, Session, SessionSummary } from '@oche/shared';
+
+type SessionRow = {
+ id: string;
+ status: 'active' | 'completed';
+ title: string;
+ videoUrl: string | null;
+ videoPoster: string | null;
+ hlsUrl: string | null;
+ createdAt: Date;
+ updatedAt: Date;
+};
+
+type PlayerRow = {
+ id: string;
+ name: string;
+ score: number;
+ photoUrl: string | null;
+ position: number;
+};
+
+type ScoreEventRow = {
+ id: string;
+ playerId: string;
+ delta: number;
+ newScore: number;
+ createdAt: Date;
+};
+
+function toIso(d: Date): string {
+ return d.toISOString();
+}
+
+export function serializePlayer(p: PlayerRow): Player {
+ return {
+ id: p.id,
+ name: p.name,
+ score: p.score,
+ photoUrl: p.photoUrl ?? undefined,
+ position: p.position,
+ };
+}
+
+export function serializeScoreEvent(e: ScoreEventRow): ScoreEvent {
+ return {
+ id: e.id,
+ playerId: e.playerId,
+ delta: e.delta,
+ newScore: e.newScore,
+ createdAt: toIso(e.createdAt),
+ };
+}
+
+export function serializeSession(
+ s: SessionRow,
+ players: PlayerRow[],
+ scoreEvents: ScoreEventRow[] = [],
+): Session {
+ return {
+ id: s.id,
+ status: s.status,
+ title: s.title,
+ videoUrl: s.videoUrl ?? undefined,
+ videoPoster: s.videoPoster ?? undefined,
+ hlsUrl: s.hlsUrl ?? undefined,
+ players: players.map(serializePlayer),
+ scoreEvents: scoreEvents.map(serializeScoreEvent),
+ createdAt: toIso(s.createdAt),
+ updatedAt: toIso(s.updatedAt),
+ };
+}
+
+export function serializeSessionSummary(s: SessionRow, playerCount: number): SessionSummary {
+ return {
+ id: s.id,
+ status: s.status,
+ title: s.title,
+ videoPoster: s.videoPoster ?? undefined,
+ createdAt: toIso(s.createdAt),
+ playerCount,
+ };
+}
diff --git a/apps/api/src/middleware.ts b/apps/api/src/middleware.ts
index 62082a2..1d4535a 100644
--- a/apps/api/src/middleware.ts
+++ b/apps/api/src/middleware.ts
@@ -6,8 +6,18 @@ export const securityHeaders: MiddlewareHandler = async (c, next) => {
c.header('X-Frame-Options', 'DENY');
c.header('Referrer-Policy', 'strict-origin-when-cross-origin');
c.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+ c.header('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'; base-uri 'none'");
};
+/** Reject oversized JSON bodies before parsing. */
+export const bodySizeLimit =
+ (maxBytes = 32_768): MiddlewareHandler =>
+ async (c, next) => {
+ const len = Number(c.req.header('content-length') ?? 0);
+ if (len > maxBytes) return c.json({ error: 'Request body too large' }, 413);
+ await next();
+ };
+
/** Tiny in-memory token bucket per colo. Production: use a Durable Object or KV. */
const buckets = new Map();
export const rateLimit =
diff --git a/apps/api/src/openapi/spec.ts b/apps/api/src/openapi/spec.ts
new file mode 100644
index 0000000..6b1d38b
--- /dev/null
+++ b/apps/api/src/openapi/spec.ts
@@ -0,0 +1,239 @@
+/** OpenAPI 3.1 document — kept in sync with @oche/shared Zod schemas. */
+export const openApiDocument = {
+ openapi: '3.1.0',
+ info: {
+ title: 'Oche API',
+ version: '1.0.0',
+ description:
+ 'Game Session Dashboard API. All /sessions routes require header `x-oche-owner` (take-home stand-in for JWT auth).',
+ },
+ servers: [{ url: 'http://localhost:8787', description: 'Local wrangler dev' }],
+ tags: [{ name: 'sessions' }],
+ components: {
+ securitySchemes: {
+ OcheOwner: { type: 'apiKey', in: 'header', name: 'x-oche-owner' },
+ },
+ schemas: {
+ ApiError: {
+ type: 'object',
+ required: ['error'],
+ properties: {
+ error: { type: 'string' },
+ issues: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: { path: { type: 'string' }, message: { type: 'string' } },
+ },
+ },
+ },
+ },
+ SessionSummary: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ status: { type: 'string', enum: ['active', 'completed'] },
+ title: { type: 'string' },
+ videoPoster: { type: 'string', format: 'uri' },
+ createdAt: { type: 'string', format: 'date-time' },
+ playerCount: { type: 'integer' },
+ },
+ },
+ Session: {
+ allOf: [
+ { $ref: '#/components/schemas/SessionSummary' },
+ {
+ type: 'object',
+ properties: {
+ videoUrl: { type: 'string', format: 'uri' },
+ hlsUrl: { type: 'string', format: 'uri' },
+ updatedAt: { type: 'string', format: 'date-time' },
+ players: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/Player' },
+ },
+ scoreEvents: {
+ type: 'array',
+ items: { $ref: '#/components/schemas/ScoreEvent' },
+ },
+ },
+ },
+ ],
+ },
+ Player: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ name: { type: 'string' },
+ score: { type: 'integer' },
+ photoUrl: { type: 'string', format: 'uri' },
+ position: { type: 'integer' },
+ },
+ },
+ ScoreEvent: {
+ type: 'object',
+ properties: {
+ id: { type: 'string', format: 'uuid' },
+ playerId: { type: 'string', format: 'uuid' },
+ delta: { type: 'integer' },
+ newScore: { type: 'integer' },
+ createdAt: { type: 'string', format: 'date-time' },
+ },
+ },
+ CreateSessionInput: {
+ type: 'object',
+ required: ['title', 'players'],
+ additionalProperties: false,
+ properties: {
+ title: { type: 'string', minLength: 1, maxLength: 160 },
+ status: { type: 'string', enum: ['active', 'completed'], default: 'active' },
+ videoUrl: { type: 'string', format: 'uri' },
+ videoPoster: { type: 'string', format: 'uri' },
+ players: {
+ type: 'array',
+ minItems: 1,
+ maxItems: 12,
+ items: {
+ type: 'object',
+ required: ['name'],
+ properties: {
+ name: { type: 'string' },
+ score: { type: 'integer', default: 0 },
+ photoUrl: { type: 'string', format: 'uri' },
+ position: { type: 'integer', default: 0 },
+ },
+ },
+ },
+ },
+ },
+ PatchSessionInput: {
+ type: 'object',
+ additionalProperties: false,
+ properties: {
+ status: { type: 'string', enum: ['active', 'completed'] },
+ scores: {
+ type: 'array',
+ maxItems: 12,
+ items: {
+ type: 'object',
+ required: ['playerId'],
+ properties: {
+ playerId: { type: 'string', format: 'uuid' },
+ delta: { type: 'integer' },
+ set: { type: 'integer' },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ security: [{ OcheOwner: [] }],
+ paths: {
+ '/health': {
+ get: {
+ security: [],
+ responses: {
+ '200': {
+ description: 'Liveness probe',
+ content: { 'application/json': { schema: { type: 'object' } } },
+ },
+ },
+ },
+ },
+ '/sessions': {
+ get: {
+ tags: ['sessions'],
+ parameters: [
+ { name: 'limit', in: 'query', schema: { type: 'integer', default: 20, maximum: 50 } },
+ { name: 'cursor', in: 'query', schema: { type: 'string' } },
+ ],
+ responses: {
+ '200': {
+ description: 'Paginated session list (owner-scoped via RLS)',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ data: { type: 'array', items: { $ref: '#/components/schemas/SessionSummary' } },
+ nextCursor: { type: 'string', nullable: true },
+ },
+ },
+ },
+ },
+ },
+ '401': {
+ description: 'Missing/invalid principal',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ post: {
+ tags: ['sessions'],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateSessionInput' } } },
+ },
+ responses: {
+ '201': {
+ description: 'Created session',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } },
+ },
+ '422': {
+ description: 'Validation error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ '/sessions/{id}': {
+ get: {
+ tags: ['sessions'],
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ responses: {
+ '200': {
+ description: 'Session detail',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/Session' } } },
+ },
+ '404': {
+ description: 'Not found or not visible (RLS)',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ patch: {
+ tags: ['sessions'],
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ requestBody: {
+ required: true,
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/PatchSessionInput' } } },
+ },
+ responses: {
+ '200': {
+ description: 'Updated',
+ content: {
+ 'application/json': { schema: { type: 'object', properties: { ok: { type: 'boolean' } } } },
+ },
+ },
+ '404': {
+ description: 'Not found',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ '422': {
+ description: 'Validation error',
+ content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } },
+ },
+ },
+ },
+ },
+ '/sessions/{id}/live': {
+ get: {
+ tags: ['sessions'],
+ summary: 'WebSocket upgrade for live score/status stream',
+ parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
+ responses: { '101': { description: 'Switching Protocols' } },
+ },
+ },
+ },
+} as const;
diff --git a/apps/api/src/principal.ts b/apps/api/src/principal.ts
index eb0a659..2c50793 100644
--- a/apps/api/src/principal.ts
+++ b/apps/api/src/principal.ts
@@ -13,6 +13,7 @@ const DEMO_OWNERS: Record = {
};
export function resolvePrincipal(c: Context): string | null {
- const key = c.req.header('x-oche-owner') ?? 'demo-key-a';
+ const key = c.req.header('x-oche-owner') ?? c.req.query('key');
+ if (!key) return null;
return DEMO_OWNERS[key] ?? null;
}
diff --git a/apps/api/src/routes/sessions.ts b/apps/api/src/routes/sessions.ts
new file mode 100644
index 0000000..e39ec44
--- /dev/null
+++ b/apps/api/src/routes/sessions.ts
@@ -0,0 +1,73 @@
+import { CreateSessionInput, PatchSessionInput } from '@oche/shared';
+import { Hono } from 'hono';
+import type { Env } from '../env.js';
+import { readJsonBody, validationError } from '../lib/errors.js';
+import { getDb } from '../lib/db.js';
+import { parseListQuery } from '../lib/pagination.js';
+import {
+ createSession,
+ getSession,
+ listSessions,
+ notifySessionRoom,
+ patchSession,
+} from '../services/sessions.js';
+import { resolvePrincipal } from '../principal.js';
+
+export type ApiVariables = { ownerId: string };
+
+const sessions = new Hono<{ Bindings: Env; Variables: ApiVariables }>();
+
+sessions.use('*', async (c, next) => {
+ const ownerId = resolvePrincipal(c);
+ if (!ownerId) return c.json({ error: 'Unauthorised' }, 401);
+ c.set('ownerId', ownerId);
+ await next();
+});
+
+sessions.get('/', async (c) => {
+ const { limit, cursor } = parseListQuery(c.req.url);
+ const db = getDb(c.env);
+ const result = await listSessions(db, c.get('ownerId'), { limit, cursor });
+ c.header('Cache-Control', 'private, max-age=15');
+ return c.json(result);
+});
+
+/** Must be registered before `/:id` so `live` is not captured as an id. */
+sessions.get('/:id/live', (c) => {
+ const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(c.req.param('id')));
+ return stub.fetch(c.req.raw);
+});
+
+sessions.get('/:id', async (c) => {
+ const db = getDb(c.env);
+ const session = await getSession(db, c.get('ownerId'), c.req.param('id'));
+ return session ? c.json(session) : c.json({ error: 'Session not found' }, 404);
+});
+
+sessions.post('/', async (c) => {
+ const parsed = CreateSessionInput.safeParse(await readJsonBody(c));
+ if (!parsed.success) return validationError(c, parsed.error);
+
+ const db = getDb(c.env);
+ const created = await createSession(db, c.get('ownerId'), parsed.data);
+ return c.json(created, 201);
+});
+
+sessions.patch('/:id', async (c) => {
+ const parsed = PatchSessionInput.safeParse(await readJsonBody(c));
+ if (!parsed.success) return validationError(c, parsed.error);
+
+ const id = c.req.param('id');
+ const db = getDb(c.env);
+ const result = await patchSession(db, c.get('ownerId'), id, parsed.data);
+
+ if (result === null) return c.json({ error: 'Session not found' }, 404);
+ if ('error' in result) return c.json({ error: result.error }, result.status);
+
+ const stub = c.env.SESSION_ROOM.get(c.env.SESSION_ROOM.idFromName(id));
+ await notifySessionRoom(stub, result.broadcasts);
+
+ return c.json({ ok: true as const });
+});
+
+export { sessions };
diff --git a/apps/api/src/services/sessions.ts b/apps/api/src/services/sessions.ts
new file mode 100644
index 0000000..8f8f9ad
--- /dev/null
+++ b/apps/api/src/services/sessions.ts
@@ -0,0 +1,165 @@
+import { players, scoreEvents, sessions, withPrincipal, type Db } from '@oche/db';
+import type { CreateSessionInput, PatchSessionInput, WsServerMessage } from '@oche/shared';
+import { and, desc, eq, inArray, lt, or } from 'drizzle-orm';
+import { encodeCursor, type ListCursor } from '../lib/pagination.js';
+import { applyScoreChange } from '../lib/scores.js';
+import { serializeSession, serializeSessionSummary } from '../lib/serialize.js';
+
+export async function listSessions(
+ db: Db,
+ ownerId: string,
+ opts: { limit: number; cursor: ListCursor | null },
+) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const cursorDate = opts.cursor ? new Date(opts.cursor.createdAt) : null;
+ const cursorFilter =
+ opts.cursor && cursorDate
+ ? or(
+ lt(sessions.createdAt, cursorDate),
+ and(eq(sessions.createdAt, cursorDate), lt(sessions.id, opts.cursor!.id)),
+ )
+ : undefined;
+
+ const list = await tx
+ .select()
+ .from(sessions)
+ .where(cursorFilter)
+ .orderBy(desc(sessions.createdAt), desc(sessions.id))
+ .limit(opts.limit + 1);
+
+ const page = list.slice(0, opts.limit);
+ const hasMore = list.length > opts.limit;
+
+ const data = await Promise.all(
+ page.map(async (s) => {
+ const ps = await tx.select().from(players).where(eq(players.sessionId, s.id));
+ return serializeSessionSummary(s, ps.length);
+ }),
+ );
+
+ const last = page.at(-1);
+ const nextCursor =
+ hasMore && last ? encodeCursor({ createdAt: last.createdAt.toISOString(), id: last.id }) : null;
+
+ return { data, nextCursor };
+ });
+}
+
+export async function getSession(db: Db, ownerId: string, sessionId: string) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId));
+ if (!s) return null;
+
+ const ps = await tx
+ .select()
+ .from(players)
+ .where(eq(players.sessionId, sessionId))
+ .orderBy(players.position);
+
+ const playerIds = ps.map((p) => p.id);
+ const events =
+ playerIds.length > 0
+ ? await tx
+ .select()
+ .from(scoreEvents)
+ .where(inArray(scoreEvents.playerId, playerIds))
+ .orderBy(desc(scoreEvents.createdAt))
+ .limit(50)
+ : [];
+
+ return serializeSession(s, ps, events);
+ });
+}
+
+export async function createSession(db: Db, ownerId: string, input: CreateSessionInput) {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx
+ .insert(sessions)
+ .values({
+ ownerId,
+ title: input.title,
+ status: input.status,
+ videoUrl: input.videoUrl ?? null,
+ videoPoster: input.videoPoster ?? null,
+ })
+ .returning();
+
+ const inserted = await tx
+ .insert(players)
+ .values(input.players.map((p) => ({ ...p, sessionId: s!.id, photoUrl: p.photoUrl ?? null })))
+ .returning();
+
+ return serializeSession(s!, inserted, []);
+ });
+}
+
+export type PatchResult = {
+ broadcasts: WsServerMessage[];
+};
+
+export async function patchSession(
+ db: Db,
+ ownerId: string,
+ sessionId: string,
+ input: PatchSessionInput,
+): Promise {
+ return withPrincipal(db, ownerId, async (tx) => {
+ const [s] = await tx.select().from(sessions).where(eq(sessions.id, sessionId));
+ if (!s) return null;
+
+ const broadcasts: WsServerMessage[] = [];
+
+ if (input.status && input.status !== s.status) {
+ await tx
+ .update(sessions)
+ .set({ status: input.status, updatedAt: new Date() })
+ .where(eq(sessions.id, sessionId));
+ broadcasts.push({ type: 'status', status: input.status });
+ }
+
+ for (const change of input.scores ?? []) {
+ const [p] = await tx
+ .select()
+ .from(players)
+ .where(and(eq(players.id, change.playerId), eq(players.sessionId, sessionId)));
+ if (!p) continue;
+
+ const result = applyScoreChange(p.score, change);
+ if (!result.ok) return { error: result.error, status: 422 as const };
+
+ if (result.delta === 0) continue;
+
+ await tx.update(players).set({ score: result.next }).where(eq(players.id, p.id));
+ await tx.insert(scoreEvents).values({
+ playerId: p.id,
+ delta: result.delta,
+ newScore: result.next,
+ });
+ broadcasts.push({
+ type: 'score',
+ playerId: p.id,
+ newScore: result.next,
+ delta: result.delta,
+ });
+ }
+
+ if (input.scores?.length || input.status) {
+ await tx.update(sessions).set({ updatedAt: new Date() }).where(eq(sessions.id, sessionId));
+ }
+
+ return { broadcasts };
+ });
+}
+
+export async function notifySessionRoom(
+ stub: { fetch(input: RequestInfo, init?: RequestInit): Promise },
+ messages: WsServerMessage[],
+): Promise {
+ for (const message of messages) {
+ await stub.fetch('https://session-room/broadcast', {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(message),
+ });
+ }
+}
diff --git a/apps/api/src/session-room.ts b/apps/api/src/session-room.ts
index 1670cc0..d6ca2ba 100644
--- a/apps/api/src/session-room.ts
+++ b/apps/api/src/session-room.ts
@@ -1,10 +1,9 @@
/**
- * SessionRoom — a SQLite-backed Durable Object (free-tier eligible) using
- * hibernatable WebSockets. One instance per session id. Broadcasts typed score/status
- * messages and can self-drive a "simulate" mode so reviewers see live updates.
+ * SessionRoom — SQLite-backed Durable Object (free-tier) with hibernatable WebSockets.
+ * One instance per session id. Broadcasts typed score/status messages; supports simulate mode.
*/
import type { DurableObjectState } from '@cloudflare/workers-types';
-import type { WsServerMessage } from '@oche/shared';
+import { WsServerMessage } from '@oche/shared';
import type { Env } from './env.js';
export class SessionRoom {
@@ -14,23 +13,32 @@ export class SessionRoom {
) {}
async fetch(req: Request): Promise {
+ const url = new URL(req.url);
+
+ if (req.method === 'POST' && url.pathname === '/broadcast') {
+ const raw = await req.json().catch(() => null);
+ const parsed = WsServerMessage.safeParse(raw);
+ if (!parsed.success) return Response.json({ error: 'Invalid broadcast payload' }, { status: 422 });
+ await this.broadcast(parsed.data);
+ return Response.json({ ok: true });
+ }
+
if (req.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 });
}
+
const pair = new WebSocketPair();
const [client, server] = [pair[0], pair[1]];
- // Hibernation API: accept on the DO context, not server.accept().
this.ctx.acceptWebSocket(server);
return new Response(null, { status: 101, webSocket: client });
}
- /** Called by the Worker (via RPC/fetch) after a score PATCH. */
async broadcast(message: WsServerMessage): Promise {
const payload = JSON.stringify(message);
for (const ws of this.ctx.getWebSockets()) ws.send(payload);
}
- async webSocketMessage(ws: WebSocket, raw: string | ArrayBuffer): Promise {
+ async webSocketMessage(_ws: WebSocket, raw: string | ArrayBuffer): Promise {
if (typeof raw !== 'string') return;
try {
const msg = JSON.parse(raw) as { type: string; enabled?: boolean };
diff --git a/apps/api/wrangler.toml b/apps/api/wrangler.toml
index f74c341..db77663 100644
--- a/apps/api/wrangler.toml
+++ b/apps/api/wrangler.toml
@@ -3,6 +3,11 @@ main = "src/index.ts"
compatibility_date = "2025-01-01"
compatibility_flags = ["nodejs_compat"]
+# Local dev defaults — DATABASE_URL goes in apps/api/.dev.vars (see scripts/wrangler-dev.mjs)
+[vars]
+ENVIRONMENT = "development"
+APP_ORIGIN = "http://localhost:5173"
+
# Durable Object: SQLite backend is required for the free plan.
[[durable_objects.bindings]]
name = "SESSION_ROOM"
diff --git a/apps/web/src/components/PlayerAvatar.tsx b/apps/web/src/components/PlayerAvatar.tsx
new file mode 100644
index 0000000..935db55
--- /dev/null
+++ b/apps/web/src/components/PlayerAvatar.tsx
@@ -0,0 +1,32 @@
+/** Player photo with responsive sizing, lazy load, and initials fallback. */
+export function PlayerAvatar({ name, photoUrl }: { name: string; photoUrl?: string | null }) {
+ const initials = name
+ .split(/\s+/)
+ .map((w) => w[0])
+ .join('')
+ .slice(0, 2)
+ .toUpperCase();
+
+ if (photoUrl) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {initials}
+
+ );
+}
diff --git a/apps/web/src/hooks/useLiveSession.ts b/apps/web/src/hooks/useLiveSession.ts
index 8c03bfc..6539a1c 100644
--- a/apps/web/src/hooks/useLiveSession.ts
+++ b/apps/web/src/hooks/useLiveSession.ts
@@ -1,14 +1,16 @@
-import { WsServerMessage } from '@oche/shared';
-import { useEffect, useRef } from 'react';
+import { WsServerMessage, type WsServerMessage as WsMessage } from '@oche/shared';
+import { useEffect, useRef, useState } from 'react';
import { api } from '@/lib/api';
/**
* Subscribe to a session's live updates via WebSocket, with auto-reconnect.
- * Falls back to TanStack Query polling if the socket can't connect (handled by caller).
+ * Returns `connected` so callers can enable TanStack Query polling as a fallback
+ * when the socket is down (see docs/PERFORMANCE.md).
*/
-export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage) => void) {
+export function useLiveSession(sessionId: string, onMessage: (m: WsMessage) => void) {
const onMsg = useRef(onMessage);
onMsg.current = onMessage;
+ const [connected, setConnected] = useState(false);
useEffect(() => {
let ws: WebSocket | null = null;
@@ -17,21 +19,30 @@ export function useLiveSession(sessionId: string, onMessage: (m: WsServerMessage
const connect = () => {
ws = new WebSocket(api.wsUrl(sessionId));
- ws.onopen = () => (retry = 0);
+ ws.onopen = () => {
+ retry = 0;
+ setConnected(true);
+ };
ws.onmessage = (e) => {
- const parsed = WsServerMessage.safeParse(JSON.parse(e.data));
+ const parsed = WsServerMessage.safeParse(JSON.parse(String(e.data)));
if (parsed.success) onMsg.current(parsed.data);
};
ws.onclose = () => {
+ setConnected(false);
if (closed) return;
retry = Math.min(retry + 1, 5);
- setTimeout(connect, retry * 1000); // backoff; caller polls in the meantime
+ setTimeout(connect, retry * 1000);
};
+ ws.onerror = () => ws?.close();
};
+
connect();
return () => {
closed = true;
+ setConnected(false);
ws?.close();
};
}, [sessionId]);
+
+ return { connected };
}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 42d043f..6505fa4 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -19,5 +19,5 @@ export const api = {
getSession: (id: string) => req(`/sessions/${id}`),
patchScores: (id: string, scores: Array<{ playerId: string; delta?: number; set?: number }>) =>
req<{ ok: true }>(`/sessions/${id}`, { method: 'PATCH', body: JSON.stringify({ scores }) }),
- wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live`,
+ wsUrl: (id: string) => `${BASE.replace(/^http/, 'ws')}/sessions/${id}/live?key=demo-key-a`,
};
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index c4e6c62..a451717 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -1,22 +1,51 @@
import { QueryClientProvider } from '@tanstack/react-query';
+import { lazy, Suspense } from 'react';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { RouterProvider, createBrowserRouter } from 'react-router-dom';
import App from './App';
import './index.css';
import { queryClient } from './lib/query';
-import { History } from './routes/History';
-import { Overview } from './routes/Overview';
-import { SessionDetail } from './routes/SessionDetail';
+
+const Overview = lazy(() => import('./routes/Overview').then((m) => ({ default: m.Overview })));
+const History = lazy(() => import('./routes/History').then((m) => ({ default: m.History })));
+const SessionDetail = lazy(() =>
+ import('./routes/SessionDetail').then((m) => ({ default: m.SessionDetail })),
+);
+
+function Page({ children }: { children: React.ReactNode }) {
+ return Loading…
}>{children};
+}
const router = createBrowserRouter([
{
path: '/',
element: ,
children: [
- { index: true, element: },
- { path: 'history', element: },
- { path: 'history/:id', element: },
+ {
+ index: true,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'history',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: 'history/:id',
+ element: (
+
+
+
+ ),
+ },
],
},
]);
diff --git a/apps/web/src/routes/History.tsx b/apps/web/src/routes/History.tsx
index d58dd8b..45071c7 100644
--- a/apps/web/src/routes/History.tsx
+++ b/apps/web/src/routes/History.tsx
@@ -2,23 +2,53 @@ import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
-/** Match history: a list of past sessions; click loads detail + plays the video. */
+function formatWhen(iso: string) {
+ return new Date(iso).toLocaleDateString(undefined, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ });
+}
+
+/** Match history: paginated list; each row links to detail + video. */
export function History() {
const { data, isLoading, error } = useQuery({ queryKey: ['sessions'], queryFn: api.listSessions });
+
if (isLoading) return Loading history…
;
if (error) return Couldn’t load history.
;
+ if (!data?.data.length) return No completed sessions yet.
;
return (
- {data!.data.map((s) => (
+ {data.data.map((s) => (
-
- {s.title}
-
- {s.playerCount} players · {s.status}
+ {s.videoPoster ? (
+
+ ) : (
+
+ No video
+
+ )}
+
+ {s.title}
+
+ {formatWhen(s.createdAt)} · {s.playerCount} players · {s.status}
+
diff --git a/apps/web/src/routes/Overview.tsx b/apps/web/src/routes/Overview.tsx
index d40fa42..d2e1abb 100644
--- a/apps/web/src/routes/Overview.tsx
+++ b/apps/web/src/routes/Overview.tsx
@@ -1,6 +1,7 @@
import type { Session } from '@oche/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
+import { PlayerAvatar } from '@/components/PlayerAvatar';
import { useLiveSession } from '@/hooks/useLiveSession';
import { api } from '@/lib/api';
@@ -20,15 +21,26 @@ export function Overview() {
function SessionPanel({ id }: { id: string }) {
const qc = useQueryClient();
- const { data: session } = useQuery({ queryKey: ['session', id], queryFn: () => api.getSession(id) });
const [pulse, setPulse] = useState(null);
+ const [saveError, setSaveError] = useState(null);
- useLiveSession(id, (m) => {
+ const { connected } = useLiveSession(id, (m) => {
if (m.type === 'score') {
setPulse(m.playerId);
qc.invalidateQueries({ queryKey: ['session', id] });
}
+ if (m.type === 'status') {
+ qc.invalidateQueries({ queryKey: ['session', id] });
+ qc.invalidateQueries({ queryKey: ['sessions'] });
+ }
});
+
+ const { data: session } = useQuery({
+ queryKey: ['session', id],
+ queryFn: () => api.getSession(id),
+ refetchInterval: connected ? false : 5_000,
+ });
+
useEffect(() => {
if (!pulse) return;
const t = setTimeout(() => setPulse(null), 600);
@@ -36,7 +48,7 @@ function SessionPanel({ id }: { id: string }) {
}, [pulse]);
async function bump(playerId: string, delta: number, current: Session) {
- // optimistic
+ setSaveError(null);
qc.setQueryData(['session', id], {
...current,
players: current.players.map((p) => (p.id === playerId ? { ...p, score: p.score + delta } : p)),
@@ -45,33 +57,54 @@ function SessionPanel({ id }: { id: string }) {
await api.patchScores(id, [{ playerId, delta }]);
} catch {
qc.invalidateQueries({ queryKey: ['session', id] });
+ setSaveError('Score update failed — reverted.');
}
}
if (!session) return null;
+
return (
-
-
{session.title}
+
+
+
{session.title}
+
+ {connected ? 'Live' : 'Polling every 5s (WebSocket reconnecting…)'}
+
+
{session.status}
-
+
+ {saveError ? (
+
+ {saveError}
+
+ ) : null}
+
+
{session.players.map((p) => (
-
- {p.name}
+
+
+ {p.name}
+
- {p.score}
+
+ {p.score}
+