feat(api): collective backend — Express + better-sqlite3 + ws — TDD

New api/ service powering admin + judge for the single-day event:
- db.ts: SQLite store (teams/submissions/scores), upserts, leaderboard;
  POST /submissions flips the team's add phase
- app.ts: REST — public PUT /teams/:id + POST /submissions (broadcast),
  code-protected GET /teams, /submissions[/:team], POST /scores, /leaderboard
- auth.ts: shared admin/judge access codes (header/bearer/?code=), const-time compare
- hub.ts + ws.ts: WS feed at /ws — auth via ?code=, snapshot on connect,
  team:update / submission:new / score:new broadcasts, heartbeat
- index.ts: http + ws wiring; types.ts mirrors the client DTOs

13 tests (REST via supertest + WS integration) green; typecheck clean;
boot smoke test: /healthz=ok, protected routes 401 without a code.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-16 19:19:24 -07:00
co-authored by Claude Opus 4.8
parent ca023cdaa5
commit 9808e6958e
13 changed files with 2968 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
{
"name": "apess-api",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.8.1",
"cors": "^2.8.5",
"express": "^5.1.0",
"ws": "^8.18.0"
},
"pnpm": {
"onlyBuiltDependencies": ["better-sqlite3", "esbuild"]
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^24.12.3",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.13",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "~6.0.2",
"vitest": "^4.1.8"
}
}
+2195
View File
File diff suppressed because it is too large Load Diff
+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach } from 'vitest'
import request from 'supertest'
import { openStore, type Store } from './db'
import { createApp } from './app'
import type { WsEvent } from './types'
const ADMIN = 'admin-code'
const JUDGE = 'judge-code'
function team(id: string, over: Record<string, unknown> = {}) {
return {
name: `team ${id}`,
kit: 'KIT-01',
members: ['a'],
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 3, nominal: 2, anomalous: 1, critical: 0 },
deviceConnected: true,
updatedAt: '2026-07-27T13:00:00.000Z',
...over,
}
}
const fullAdd = { L1: { goal: 'x' }, L2: 'two', L3: 'three', L4: 'four', L5: 'five' }
describe('collective API', () => {
let store: Store
let events: WsEvent[]
let app: ReturnType<typeof createApp>
beforeEach(() => {
store = openStore(':memory:')
events = []
app = createApp({
store,
broadcast: (e) => events.push(e),
adminCode: ADMIN,
judgeCode: JUDGE,
now: () => '2026-07-27T14:00:00.000Z',
})
})
it('healthz returns ok', async () => {
const res = await request(app).get('/healthz')
expect(res.text).toBe('ok')
})
describe('auth', () => {
it('rejects protected reads without a code', async () => {
await request(app).get('/teams').expect(401)
})
it('accepts admin or judge code', async () => {
await request(app).get('/teams').set('X-Access-Code', ADMIN).expect(200)
await request(app).get('/teams').set('X-Access-Code', JUDGE).expect(200)
})
it('scoring requires the judge code specifically', async () => {
await request(app).post('/scores').set('X-Access-Code', ADMIN).send({ teamId: 't', total: 5 }).expect(401)
})
})
describe('teams', () => {
it('upserts a team snapshot and broadcasts team:update', async () => {
await request(app).put('/teams/t1').send(team('t1')).expect(204)
const list = await request(app).get('/teams').set('X-Access-Code', ADMIN)
expect(list.body).toHaveLength(1)
expect(list.body[0]).toMatchObject({ id: 't1', name: 'team t1', deviceConnected: true })
expect(events.some((e) => e.type === 'team:update')).toBe(true)
})
it('upsert is last-write-wins for a given id', async () => {
await request(app).put('/teams/t1').send(team('t1'))
await request(app).put('/teams/t1').send(team('t1', { name: 'renamed' }))
const list = await request(app).get('/teams').set('X-Access-Code', ADMIN)
expect(list.body).toHaveLength(1)
expect(list.body[0].name).toBe('renamed')
})
})
describe('submissions', () => {
it('stores a submission, flips the team add phase, and broadcasts', async () => {
await request(app).put('/teams/t1').send(team('t1'))
const res = await request(app)
.post('/submissions')
.send({ teamId: 't1', teamName: 'team t1', code: 'KIT-01-ABC', add: fullAdd, submittedAt: '2026-07-27T18:00:00.000Z' })
.expect(201)
expect(res.body).toMatchObject({ teamId: 't1', scored: false })
expect(events.some((e) => e.type === 'submission:new')).toBe(true)
const teams = await request(app).get('/teams').set('X-Access-Code', ADMIN)
expect(teams.body[0].phases.add).toBe(true)
const sub = await request(app).get('/submissions/t1').set('X-Access-Code', JUDGE)
expect(sub.body.add).toEqual(fullAdd)
})
it('rejects an incomplete submission', async () => {
await request(app).post('/submissions').send({ teamId: 't1' }).expect(400)
})
it('lists submission summaries with a scored flag', async () => {
await request(app).post('/submissions').send({ teamId: 't1', code: 'c', add: fullAdd })
const before = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(before.body[0].scored).toBe(false)
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 't1', total: 8 })
const after = await request(app).get('/submissions').set('X-Access-Code', JUDGE)
expect(after.body[0].scored).toBe(true)
})
})
describe('scores + leaderboard', () => {
it('ranks teams by average total, descending', async () => {
await request(app).post('/submissions').send({ teamId: 'a', teamName: 'Alpha', code: 'c', add: fullAdd })
await request(app).post('/submissions').send({ teamId: 'b', teamName: 'Bravo', code: 'c', add: fullAdd })
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'a', total: 6 })
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'a', total: 10 })
await request(app).post('/scores').set('X-Access-Code', JUDGE).send({ teamId: 'b', total: 9 })
const lb = await request(app).get('/leaderboard').set('X-Access-Code', ADMIN)
expect(lb.body.map((r: { teamId: string }) => r.teamId)).toEqual(['b', 'a'])
expect(lb.body[0]).toMatchObject({ teamName: 'Bravo', avgTotal: 9, scoreCount: 1 })
expect(lb.body[1]).toMatchObject({ avgTotal: 8, scoreCount: 2 })
expect(events.some((e) => e.type === 'score:new')).toBe(true)
})
})
})
+105
View File
@@ -0,0 +1,105 @@
import express, { type Express } from 'express'
import cors from 'cors'
import type { Store } from './db'
import { requireCode, requireAnyCode } from './auth'
import type { TeamSnapshot, SubmissionDTO, WsEvent } from './types'
export interface AppOptions {
store: Store
broadcast: (e: WsEvent) => void
adminCode: string
judgeCode: string
corsOrigin?: string
now?: () => string
}
const emptyPhases = { reg: false, setup: false, m1: false, m2: false, add: false }
const emptyStats = { calls: 0, nominal: 0, anomalous: 0, critical: 0 }
/** Build the collective REST app. Pure of I/O wiring (db + broadcast injected). */
export function createApp(opts: AppOptions): Express {
const { store, broadcast, adminCode, judgeCode } = opts
const now = opts.now ?? (() => new Date().toISOString())
const app = express()
app.use(cors({ origin: opts.corsOrigin ?? true }))
app.use(express.json({ limit: '256kb' }))
app.get('/healthz', (_req, res) => {
res.type('text/plain').send('ok')
})
// --- public participant sync -------------------------------------------
app.put('/teams/:id', (req, res) => {
const b = req.body ?? {}
const team: TeamSnapshot = {
id: req.params.id,
name: typeof b.name === 'string' ? b.name : '',
kit: typeof b.kit === 'string' ? b.kit : '',
members: Array.isArray(b.members) ? b.members : [],
phases: { ...emptyPhases, ...(b.phases ?? {}) },
stats: { ...emptyStats, ...(b.stats ?? {}) },
deviceConnected: !!b.deviceConnected,
updatedAt: typeof b.updatedAt === 'string' ? b.updatedAt : now(),
}
store.upsertTeam(team)
broadcast({ type: 'team:update', team })
res.status(204).end()
})
app.post('/submissions', (req, res) => {
const b = req.body ?? {}
if (typeof b.teamId !== 'string' || typeof b.code !== 'string' || !b.add) {
return res.status(400).json({ error: 'teamId, code and add are required' })
}
const dto: SubmissionDTO = {
teamId: b.teamId,
teamName: typeof b.teamName === 'string' ? b.teamName : '',
code: b.code,
add: b.add,
submittedAt: typeof b.submittedAt === 'string' ? b.submittedAt : now(),
}
const summary = store.upsertSubmission(dto)
broadcast({ type: 'submission:new', submission: summary })
res.status(201).json(summary)
})
// --- protected read / scoring ------------------------------------------
app.get('/teams', requireAnyCode(adminCode, judgeCode), (_req, res) => {
res.json(store.listTeams())
})
app.get('/submissions', requireAnyCode(adminCode, judgeCode), (_req, res) => {
res.json(store.listSubmissions())
})
app.get('/submissions/:team', requireAnyCode(adminCode, judgeCode), (req, res) => {
const sub = store.getSubmission(String(req.params.team))
if (!sub) return res.status(404).json({ error: 'not found' })
res.json(sub)
})
app.post('/scores', requireCode(judgeCode), (req, res) => {
const b = req.body ?? {}
if (typeof b.teamId !== 'string' || typeof b.total !== 'number') {
return res.status(400).json({ error: 'teamId and numeric total are required' })
}
const score = store.insertScore(
{
teamId: b.teamId,
judge: typeof b.judge === 'string' ? b.judge : '',
rubric: b.rubric && typeof b.rubric === 'object' ? b.rubric : {},
total: b.total,
notes: typeof b.notes === 'string' ? b.notes : '',
},
now(),
)
broadcast({ type: 'score:new', teamId: score.teamId, total: score.total })
res.status(201).json(score)
})
app.get('/leaderboard', requireAnyCode(adminCode, judgeCode), (_req, res) => {
res.json(store.leaderboard())
})
return app
}
+36
View File
@@ -0,0 +1,36 @@
import type { RequestHandler } from 'express'
function presentedCode(req: Parameters<RequestHandler>[0]): string {
const header = req.header('x-access-code')
if (header) return header
const auth = req.header('authorization')
if (auth?.startsWith('Bearer ')) return auth.slice(7)
const q = req.query.code
return typeof q === 'string' ? q : ''
}
function matches(a: string, b: string): boolean {
if (!a || a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}
/** Require a specific shared access code (header, bearer, or ?code=). */
export function requireCode(expected: string): RequestHandler {
return (req, res, next) => {
if (matches(presentedCode(req), expected)) return next()
res.status(401).json({ error: 'unauthorized' })
}
}
/** Require any of the given codes (admin OR judge read access). */
export function requireAnyCode(...expected: string[]): RequestHandler {
return (req, res, next) => {
const code = presentedCode(req)
if (expected.some((e) => matches(code, e))) return next()
res.status(401).json({ error: 'unauthorized' })
}
}
export { presentedCode, matches }
+197
View File
@@ -0,0 +1,197 @@
import Database from 'better-sqlite3'
import type {
TeamSnapshot,
SubmissionDTO,
SubmissionSummary,
ScoreInput,
ScoreDTO,
LeaderboardRow,
} from './types'
export interface Store {
upsertTeam(t: TeamSnapshot): void
listTeams(): TeamSnapshot[]
getTeam(id: string): TeamSnapshot | null
upsertSubmission(s: SubmissionDTO): SubmissionSummary
listSubmissions(): SubmissionSummary[]
getSubmission(teamId: string): SubmissionDTO | null
insertScore(input: ScoreInput, createdAt: string): ScoreDTO
leaderboard(): LeaderboardRow[]
close(): void
}
interface TeamRow {
id: string
name: string
kit: string
members: string
phases: string
stats: string
device_connected: number
updated_at: string
}
function rowToTeam(r: TeamRow): TeamSnapshot {
return {
id: r.id,
name: r.name,
kit: r.kit,
members: JSON.parse(r.members),
phases: JSON.parse(r.phases),
stats: JSON.parse(r.stats),
deviceConnected: !!r.device_connected,
updatedAt: r.updated_at,
}
}
export function openStore(path = ':memory:'): Store {
const db = new Database(path)
if (path !== ':memory:') db.pragma('journal_mode = WAL')
db.exec(`
CREATE TABLE IF NOT EXISTS teams (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
kit TEXT NOT NULL DEFAULT '',
members TEXT NOT NULL DEFAULT '[]',
phases TEXT NOT NULL DEFAULT '{}',
stats TEXT NOT NULL DEFAULT '{}',
device_connected INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS submissions (
team_id TEXT PRIMARY KEY,
team_name TEXT NOT NULL DEFAULT '',
code TEXT NOT NULL,
add_json TEXT NOT NULL,
submitted_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS scores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
team_id TEXT NOT NULL,
judge TEXT NOT NULL DEFAULT '',
rubric TEXT NOT NULL DEFAULT '{}',
total INTEGER NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
`)
const upsertTeamStmt = db.prepare(`
INSERT INTO teams (id, name, kit, members, phases, stats, device_connected, updated_at)
VALUES (@id, @name, @kit, @members, @phases, @stats, @device_connected, @updated_at)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, kit=excluded.kit, members=excluded.members,
phases=excluded.phases, stats=excluded.stats,
device_connected=excluded.device_connected, updated_at=excluded.updated_at
`)
const listTeamsStmt = db.prepare('SELECT * FROM teams ORDER BY name')
const getTeamStmt = db.prepare('SELECT * FROM teams WHERE id = ?')
const upsertSubStmt = db.prepare(`
INSERT INTO submissions (team_id, team_name, code, add_json, submitted_at)
VALUES (@team_id, @team_name, @code, @add_json, @submitted_at)
ON CONFLICT(team_id) DO UPDATE SET
team_name=excluded.team_name, code=excluded.code,
add_json=excluded.add_json, submitted_at=excluded.submitted_at
`)
const listSubsStmt = db.prepare(`
SELECT s.team_id, s.team_name, s.submitted_at,
EXISTS(SELECT 1 FROM scores sc WHERE sc.team_id = s.team_id) AS scored
FROM submissions s ORDER BY s.submitted_at
`)
const getSubStmt = db.prepare('SELECT * FROM submissions WHERE team_id = ?')
const insertScoreStmt = db.prepare(`
INSERT INTO scores (team_id, judge, rubric, total, notes, created_at)
VALUES (@team_id, @judge, @rubric, @total, @notes, @created_at)
`)
const leaderboardStmt = db.prepare(`
SELECT s.team_id AS teamId,
COALESCE(sub.team_name, s.team_id) AS teamName,
AVG(s.total) AS avgTotal,
COUNT(*) AS scoreCount
FROM scores s
LEFT JOIN submissions sub ON sub.team_id = s.team_id
GROUP BY s.team_id
ORDER BY avgTotal DESC, scoreCount DESC
`)
const setPhaseAddStmt = db.prepare(
`UPDATE teams SET phases = json_set(phases, '$.add', json('true')) WHERE id = ?`,
)
return {
upsertTeam(t) {
upsertTeamStmt.run({
id: t.id,
name: t.name,
kit: t.kit,
members: JSON.stringify(t.members),
phases: JSON.stringify(t.phases),
stats: JSON.stringify(t.stats),
device_connected: t.deviceConnected ? 1 : 0,
updated_at: t.updatedAt,
})
},
listTeams() {
return (listTeamsStmt.all() as TeamRow[]).map(rowToTeam)
},
getTeam(id) {
const row = getTeamStmt.get(id) as TeamRow | undefined
return row ? rowToTeam(row) : null
},
upsertSubmission(s) {
upsertSubStmt.run({
team_id: s.teamId,
team_name: s.teamName,
code: s.code,
add_json: JSON.stringify(s.add),
submitted_at: s.submittedAt,
})
// mark the team's final phase complete if we know the team
if (getTeamStmt.get(s.teamId)) setPhaseAddStmt.run(s.teamId)
return { teamId: s.teamId, teamName: s.teamName, submittedAt: s.submittedAt, scored: false }
},
listSubmissions() {
return (listSubsStmt.all() as Array<Omit<SubmissionSummary, 'scored'> & { scored: number }>).map((r) => ({
teamId: r.teamId,
teamName: r.teamName,
submittedAt: r.submittedAt,
scored: !!r.scored,
}))
},
getSubmission(teamId) {
const row = getSubStmt.get(teamId) as
| { team_id: string; team_name: string; code: string; add_json: string; submitted_at: string }
| undefined
if (!row) return null
return {
teamId: row.team_id,
teamName: row.team_name,
code: row.code,
add: JSON.parse(row.add_json),
submittedAt: row.submitted_at,
}
},
insertScore(input, createdAt) {
const info = insertScoreStmt.run({
team_id: input.teamId,
judge: input.judge,
rubric: JSON.stringify(input.rubric),
total: input.total,
notes: input.notes,
created_at: createdAt,
})
return { ...input, id: Number(info.lastInsertRowid), createdAt }
},
leaderboard() {
return (leaderboardStmt.all() as LeaderboardRow[]).map((r) => ({
teamId: r.teamId,
teamName: r.teamName,
avgTotal: Math.round(r.avgTotal * 100) / 100,
scoreCount: r.scoreCount,
}))
},
close() {
db.close()
},
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { WebSocket } from 'ws'
import type { WsEvent } from './types'
export interface Hub {
add(ws: WebSocket): void
broadcast(e: WsEvent): void
size(): number
}
const OPEN = 1 // ws.OPEN
/** Tracks connected admin/judge sockets and fans out collective events. */
export function createHub(): Hub {
const clients = new Set<WebSocket>()
return {
add(ws) {
clients.add(ws)
ws.on('close', () => clients.delete(ws))
ws.on('error', () => clients.delete(ws))
},
broadcast(e) {
const msg = JSON.stringify(e)
for (const ws of clients) {
if (ws.readyState === OPEN) ws.send(msg)
}
},
size() {
return clients.size
},
}
}
+32
View File
@@ -0,0 +1,32 @@
import http from 'node:http'
import { openStore } from './db'
import { createHub } from './hub'
import { createApp } from './app'
import { attachWs } from './ws'
const PORT = Number(process.env.PORT ?? 3000)
const DB_PATH = process.env.DB_PATH ?? '/data/apess.db'
const ADMIN_CODE = process.env.ADMIN_CODE ?? ''
const JUDGE_CODE = process.env.JUDGE_CODE ?? ''
const CORS_ORIGIN = process.env.CORS_ORIGIN
if (!ADMIN_CODE || !JUDGE_CODE) {
console.warn('[apess-api] ADMIN_CODE / JUDGE_CODE not set — protected routes will reject all requests')
}
const store = openStore(DB_PATH)
const hub = createHub()
const app = createApp({
store,
broadcast: hub.broadcast,
adminCode: ADMIN_CODE,
judgeCode: JUDGE_CODE,
corsOrigin: CORS_ORIGIN,
})
const server = http.createServer(app)
attachWs(server, { store, hub, adminCode: ADMIN_CODE, judgeCode: JUDGE_CODE })
server.listen(PORT, () => {
console.log(`[apess-api] listening on :${PORT} (db: ${DB_PATH})`)
})
+70
View File
@@ -0,0 +1,70 @@
// Mirror of the client's src/types.ts — keep in sync by hand.
export type PhaseKey = 'reg' | 'setup' | 'm1' | 'm2' | 'add'
export interface SessionStats {
calls: number
nominal: number
anomalous: number
critical: number
}
export interface AddLayers {
L1: unknown | null
L2: string
L3: string
L4: string
L5: string
}
export interface TeamSnapshot {
id: string
name: string
kit: string
members: string[]
phases: Record<PhaseKey, boolean>
stats: SessionStats
deviceConnected: boolean
updatedAt: string
}
export interface SubmissionDTO {
teamId: string
teamName: string
code: string
add: AddLayers
submittedAt: string
}
export interface SubmissionSummary {
teamId: string
teamName: string
submittedAt: string
scored: boolean
}
export interface ScoreInput {
teamId: string
judge: string
rubric: Record<string, number>
total: number
notes: string
}
export interface ScoreDTO extends ScoreInput {
id: number
createdAt: string
}
export interface LeaderboardRow {
teamId: string
teamName: string
avgTotal: number
scoreCount: number
}
export type WsEvent =
| { type: 'snapshot'; teams: TeamSnapshot[]; submissions: SubmissionSummary[] }
| { type: 'team:update'; team: TeamSnapshot }
| { type: 'submission:new'; submission: SubmissionSummary }
| { type: 'score:new'; teamId: string; total: number }
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import http from 'node:http'
import { AddressInfo } from 'node:net'
import { WebSocket } from 'ws'
import request from 'supertest'
import { openStore, type Store } from './db'
import { createHub } from './hub'
import { createApp } from './app'
import { attachWs } from './ws'
import type { WsEvent } from './types'
const ADMIN = 'admin-code'
const JUDGE = 'judge-code'
function nextMessage(ws: WebSocket): Promise<WsEvent> {
return new Promise((resolve) => ws.once('message', (d) => resolve(JSON.parse(d.toString()))))
}
describe('collective WS feed', () => {
let store: Store
let server: http.Server
let port: number
beforeEach(async () => {
store = openStore(':memory:')
const hub = createHub()
const app = createApp({ store, broadcast: hub.broadcast, adminCode: ADMIN, judgeCode: JUDGE })
server = http.createServer(app)
attachWs(server, { store, hub, adminCode: ADMIN, judgeCode: JUDGE })
await new Promise<void>((r) => server.listen(0, r))
port = (server.address() as AddressInfo).port
})
afterEach(async () => {
await new Promise<void>((r) => server.close(() => r()))
store.close()
})
it('rejects a connection without a valid code', async () => {
const ws = new WebSocket(`ws://localhost:${port}/ws?code=nope`)
const code: number = await new Promise((resolve) => ws.on('close', (c) => resolve(c)))
expect(code).toBe(4401)
})
it('sends a snapshot on connect', async () => {
store.upsertTeam({
id: 't1',
name: 'team t1',
kit: 'KIT-01',
members: [],
phases: { reg: true, setup: false, m1: false, m2: false, add: false },
stats: { calls: 0, nominal: 0, anomalous: 0, critical: 0 },
deviceConnected: false,
updatedAt: '2026-07-27T13:00:00.000Z',
})
const ws = new WebSocket(`ws://localhost:${port}/ws?code=${ADMIN}`)
const msg = await nextMessage(ws)
expect(msg.type).toBe('snapshot')
if (msg.type === 'snapshot') expect(msg.teams).toHaveLength(1)
ws.close()
})
it('pushes team:update to a connected client after a PUT /teams', async () => {
const ws = new WebSocket(`ws://localhost:${port}/ws?code=${JUDGE}`)
await nextMessage(ws) // snapshot
const updatePromise = nextMessage(ws)
await request(`http://localhost:${port}`)
.put('/teams/t9')
.send({ name: 'late team', kit: 'KIT-09' })
const evt = await updatePromise
expect(evt.type).toBe('team:update')
if (evt.type === 'team:update') expect(evt.team.id).toBe('t9')
ws.close()
})
})
+42
View File
@@ -0,0 +1,42 @@
import { WebSocketServer } from 'ws'
import type { Server } from 'node:http'
import type { Store } from './db'
import type { Hub } from './hub'
import { matches } from './auth'
const HEARTBEAT_MS = 30000
export interface WsOptions {
store: Store
hub: Hub
adminCode: string
judgeCode: string
}
/** Attach the collective WS feed at /ws: auth via ?code=, snapshot on connect. */
export function attachWs(server: Server, { store, hub, adminCode, judgeCode }: WsOptions): WebSocketServer {
const wss = new WebSocketServer({ server, path: '/ws' })
wss.on('connection', (ws, req) => {
const url = new URL(req.url ?? '', 'http://localhost')
const code = url.searchParams.get('code') ?? ''
if (!matches(code, adminCode) && !matches(code, judgeCode)) {
ws.close(4401, 'unauthorized')
return
}
ws.send(
JSON.stringify({
type: 'snapshot',
teams: store.listTeams(),
submissions: store.listSubmissions(),
}),
)
hub.add(ws)
const ping = setInterval(() => {
if (ws.readyState === ws.OPEN) ws.ping()
}, HEARTBEAT_MS)
ws.on('close', () => clearInterval(ping))
})
return wss
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist"
},
"include": ["src"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
},
})