M1 서버: 통합 테스트 + 방 단위 테스트, 종료 대기·중도 이탈 자리 처리 수정

- 실제 서버 + WS 클라이언트로 테스트 문서 3절 시나리오 검증(방 만들기·링크 입장·재접속·
  서버 강제 종료 후 복구·차례 위반·중복 cs·탭 교체·방장 이전·도배 제한·잘못된 메시지·
  Origin 검사·디스코드 콜백 state 검증·게스트 합치기)
- 시계를 조작하는 방 단위 테스트: 시간 초과 자동 착수, 연결 끊김 유예, 재시작 후 마감 30초 유예,
  게임 오류 격리(3회 → 일시 정지), 한 판 더 흑백 교대, 게임 중 나가기
- Bun server.stop()이 서버 쪽 WS 종료 뒤 끝나지 않는 문제: 대기 시간 1초로 제한
- 게임 중 나간 사람의 자리가 게임 종료 후 비워지지 않던 버그 수정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
EJClaw
2026-10-04 04:24:49 +09:00
parent 0f4d22f413
commit b1f3d7c500
5 changed files with 622 additions and 5 deletions

View File

@@ -0,0 +1,182 @@
/** Room-level tests with a controllable clock: timers, grace period, restore, error isolation. */
import { describe, expect, test } from 'bun:test';
import { z } from 'zod';
import type { AnyGameDefinition } from '@bg/engine';
import type { ServerMessage } from '@bg/shared';
import { GAMES } from '@bg/games';
import { openDb } from '../db';
import { Room, type Conn, type RoomConfig, type RoomSnapshot } from './room';
import { RoomStore } from './store';
function harness(games: Record<string, AnyGameDefinition> = GAMES) {
const db = openDb(':memory:');
const store = new RoomStore(db);
let now = 1_000_000;
const clock = { now: () => now, advance: (ms: number) => (now += ms) };
const deps = {
store,
games,
users: { publicUser: (id: string) => ({ id, nickname: id, avatar: null, kind: 'guest' as const }) },
now: clock.now,
};
const config: RoomConfig = {
gameId: Object.keys(games)[0]!,
options: Object.values(games)[0]!.defaultOptions,
maxPlayers: 2,
visibility: 'private',
allowSpectators: true,
chatEnabled: true,
chatFilter: true,
graceSec: 60,
};
const room = new Room('room1', '482913', config, 'A', deps);
store.insertRoom({ id: 'room1', code: '482913', hostId: 'A', gameId: config.gameId, status: 'lobby', visibility: 'private', config, stateJson: '{}', now: now });
const conn = (userId: string) => {
const msgs: ServerMessage[] = [];
const c: Conn & { msgs: ServerMessage[] } = { userId, msgs, send: (m) => msgs.push(m), close: () => {} };
return c;
};
return { room, store, clock, deps, conn, db };
}
describe('Room timers', () => {
test('8. turn timeout applies onTimeout (omok perMove autoMove)', () => {
const { room, clock, conn } = harness();
const a = conn('A');
const b = conn('B');
room.join(a, 'player');
room.join(b, 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
expect(room.start('A')).toBeNull();
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 10_000);
clock.advance(9_999);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).moves).toHaveLength(0);
clock.advance(1);
room.onTimer();
room.clearTimer();
expect((room.game!.state as any).moves).toHaveLength(1);
expect((room.game!.state as any).moves[0].auto).toBe(true);
});
test('disconnected player is auto-acted after the grace period, not before the turn deadline rules', () => {
const { room, clock, conn } = harness();
const a = conn('A');
const b = conn('B');
room.join(a, 'player');
room.join(b, 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'none' } } });
room.start('A');
room.clearTimer();
// No time control: idle limit 300s. Black disconnects → grace 60s wins.
room.disconnect(a);
room.clearTimer();
expect(room.effectiveDeadline()).toBe(clock.now() + 60_000);
expect(b.msgs.some((m) => m.t === 'notice' && m.code === 'disconnected')).toBe(true);
// Reconnect before the grace period ends → deadline goes back to the idle limit.
clock.advance(30_000);
room.join(conn('A'), 'player');
room.clearTimer();
expect(room.effectiveDeadline()).toBe(1_000_000 + 300_000);
});
test('restore postpones overdue deadlines by 30s and keeps game state', () => {
const { room, clock, conn, deps, store } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.configure('A', { options: { colorAssignment: 'hostBlack', timeControl: { kind: 'perMove', perMoveSec: 10 } } });
room.start('A');
room.clearTimer();
room.act('A', 1, { type: 'place', x: 7, y: 7 });
room.clearTimer();
const { seq, stateJson } = store.openRooms()[0]!;
clock.advance(600_000); // server was down for 10 minutes
const restored = Room.restore('room1', '482913', seq, JSON.parse(stateJson) as RoomSnapshot, deps);
restored.clearTimer();
expect((restored.game!.state as any).moves).toHaveLength(1);
expect(restored.effectiveDeadline()).toBe(clock.now() + 30_000);
expect(restored.roomView().seats.every((s) => s && !s.connected)).toBe(true);
});
});
describe('Room isolation and lobby', () => {
const boom: AnyGameDefinition = {
id: 'boom',
nameKo: '폭탄',
minPlayers: 2,
maxPlayers: 2,
stateVersion: 1,
defaultOptions: {},
optionsSchema: z.object({}),
actionSchema: z.object({ type: z.literal('go') }),
setup: ({ players }) => ({ players, n: 0 }),
validate: () => ({ ok: true }),
apply: () => {
throw new Error('bug');
},
view: (s) => s,
activePlayers: (s: any) => s.players,
onTimeout: () => ({ type: 'go' }),
result: () => null,
};
test('a throwing game rejects the action, keeps state, pauses after 3 errors', () => {
const { room, conn } = harness({ boom });
const a = conn('A');
room.join(a, 'player');
room.join(conn('B'), 'player');
room.start('A');
for (let i = 1; i <= 3; i++) room.act('A', i, { type: 'go' });
expect(a.msgs.filter((m) => m.t === 'reject')).toHaveLength(3);
expect(room.status).toBe('paused');
expect((room.game!.state as any).n).toBe(0);
expect(room.voidGame('A')).toBeNull();
expect(room.status).toBe('finished');
});
test('seat rules: start needs min players, only host starts, kicked users cannot rejoin', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
expect(room.start('A')).toBe('1명 더 필요해요.');
room.join(conn('B'), 'player');
expect(room.start('B')).toBe('방장만 시작할 수 있어요.');
room.join(conn('C'), 'player');
expect(room.spectators.has('C')).toBe(true);
expect(room.kick('A', 'C')).toBeNull();
expect(room.join(conn('C'), 'player')).toBe('방장이 내보낸 방에는 다시 들어갈 수 없어요.');
});
test('rematch starts when all seated players vote; colours alternate with gameNo', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.configure('A', { options: { colorAssignment: 'alternate' } });
room.start('A');
room.clearTimer();
expect((room.game!.state as any).players.black).toBe('A');
room.act('B', 1, { type: 'resign' });
expect(room.status).toBe('finished');
expect(room.sessionStats).toEqual({ A: { played: 1, wins: 1 }, B: { played: 1, wins: 0 } });
room.rematch('A');
expect(room.status).toBe('finished');
room.rematch('B');
room.clearTimer();
expect(room.status).toBe('playing');
expect(room.gameNo).toBe(2);
expect((room.game!.state as any).players.black).toBe('B');
});
test('leaving mid-game resigns via onLeave', () => {
const { room, conn } = harness();
room.join(conn('A'), 'player');
room.join(conn('B'), 'player');
room.start('A');
room.clearTimer();
room.leave('B');
expect(room.status).toBe('finished');
expect(room.lastResult?.ranking).toEqual([['A'], ['B']]);
expect(room.seatOf('B')).toBe(-1);
});
});

View File

@@ -90,6 +90,8 @@ export class Room {
presence = new Map<string, Presence>();
conns = new Map<string, Conn>();
kicked = new Set<string>();
/** Players who left during a game; their seat is freed when the game ends. */
leftDuringGame = new Set<string>();
gameNo = 0;
sessionStats: Record<string, { played: number; wins: number }> = {};
rematchVotes = new Set<string>();
@@ -314,7 +316,8 @@ export class Room {
}
}
const prev = this.presence.get(u);
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt ?? now });
this.leftDuringGame.delete(u);
this.presence.set(u, { connected: true, since: now, joinedAt: prev?.joinedAt || now });
this.conns.set(u, conn);
if (wasSeated && prev && !prev.connected && this.inGame(u)) {
this.notice('reconnected', `${this.nick(u)}님이 다시 연결됐어요.`);
@@ -358,10 +361,11 @@ export class Room {
const seat = this.seatOf(userId);
if (seat >= 0) {
if (this.inGame(userId)) {
// Stay in the seat as "away" until the game ends so the game state stays consistent.
this.leftDuringGame.add(userId);
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
const action = this.def.onLeave?.(this.game!.state, userId);
if (action) this.runAction(userId, action, undefined, true);
// Stay in the seat as "away" until the game ends so the game state stays consistent.
this.presence.set(userId, { connected: false, since: 0, joinedAt: 0 });
} else {
this.seats[seat] = null;
this.rematchVotes.delete(userId);
@@ -708,12 +712,13 @@ export class Room {
// Players who left during the game lose their seat now.
for (let i = 0; i < this.seats.length; i++) {
const s = this.seats[i];
if (s && !this.presence.get(s.userId)?.connected && this.presence.get(s.userId)?.joinedAt === 0) {
if (s && this.leftDuringGame.has(s.userId)) {
this.seats[i] = null;
this.presence.delete(s.userId);
this.deps.onSeatChange?.(this, s.userId, false);
}
}
this.leftDuringGame.clear();
this.persist({ actorId: null, kind: 'finish', data: { result, voided } });
this.broadcastState([{ type: 'gameOver' }]);
for (const c of this.conns.values()) c.send({ t: 'result', seq: this.seq, result });

View File

@@ -0,0 +1,300 @@
/** Integration scenarios from docs/10-testing.md §3. */
import { afterEach, describe, expect, test } from 'bun:test';
import { Client, api, boot, guest, sleep } from './test-utils';
type Booted = ReturnType<typeof boot>;
const running: Booted[] = [];
const clients: Client[] = [];
afterEach(async () => {
for (const c of clients.splice(0)) {
try {
c.close();
} catch {}
}
for (const s of running.splice(0)) {
try {
await s.stop();
} catch {}
}
});
function start(dbPath?: string): Booted {
const s = boot(dbPath);
running.push(s);
return s;
}
async function client(s: Booted, nick: string, cookie?: { cookie: string; id: string }): Promise<Client> {
const g = cookie ?? (await guest(s.base, nick));
const c = await new Client(s.base, g.cookie, g.id).connect();
clients.push(c);
return c;
}
async function createRoom(s: Booted, c: Client, gameId = 'omok'): Promise<string> {
const r = await api(s.base, '/api/rooms', { method: 'POST', cookie: c.cookie, body: JSON.stringify({ gameId }) });
expect(r.res.status).toBe(200);
return r.body.code;
}
async function join(c: Client, code: string, as: 'player' | 'spectator' = 'player') {
c.send({ t: 'join', code, as });
return c.next('room');
}
/** A hosts (black), B joins; game started. */
async function twoPlayerGame(s: Booted) {
const a = await client(s, '호랑이');
const b = await client(s, '고양이');
const code = await createRoom(s, a);
await join(a, code);
await join(b, code);
a.send({ t: 'config', options: { colorAssignment: 'hostBlack', ruleSet: 'free' } });
await a.next('room', (m) => (m.room.options as any).colorAssignment === 'hostBlack');
const sa = a.next('state');
const sb = b.next('state');
a.send({ t: 'start' });
await Promise.all([sa, sb]);
return { a, b, code };
}
const place = (c: Client, x: number, y: number) => c.act({ type: 'place', x, y });
describe('M1 integration scenarios', () => {
test('1. create room, join by code, auto-seat, play omok to the end, result stored', async () => {
const s = start();
const { a, b, code } = await twoPlayerGame(s);
expect(code).toMatch(/^\d{6}$/);
const room = a.lastOf('room')!.room;
expect(room.seats.filter(Boolean).map((x) => x!.user.nickname)).toEqual(['호랑이', '고양이']);
// Black (A) plays a horizontal five, White (B) answers elsewhere.
for (let i = 0; i < 4; i++) {
let p = b.next('state', (m) => (m.view as any).turn === 'white');
place(a, 3 + i, 7);
await p;
p = a.next('state', (m) => (m.view as any).turn === 'black');
place(b, 3 + i, 0);
await p;
}
const res = a.next('result');
place(a, 7, 7);
const r = await res;
expect(r.result.ranking).toEqual([[a.id], [b.id]]);
const stats = await api(s.base, '/api/me/stats', { cookie: a.cookie });
expect(stats.body.stats).toEqual([{ gameId: 'omok', played: 1, wins: 1, draws: 0 }]);
const room2 = (await a.next('room', (m) => m.room.status === 'finished').catch(() => a.lastOf('room')!)).room;
expect(room2.lastSeed).toMatch(/^[0-9a-f]{32}$/);
expect(room2.sessionStats[a.id]).toEqual({ played: 1, wins: 1 });
});
test('2. joining via link preview without login, then guest + auto seat', async () => {
const s = start();
const a = await client(s, '방장님');
const code = await createRoom(s, a);
await join(a, code);
const preview = await api(s.base, `/api/rooms/${code}`);
expect(preview.body).toMatchObject({ code, gameId: 'omok', host: '방장님', seated: 1, maxPlayers: 2 });
const b = await client(s, '손님');
const r = await join(b, code);
expect(r.room.seats[1]?.user.id).toBe(b.id);
// A third person becomes a spectator.
const c = await client(s, '구경꾼');
const r3 = await join(c, code);
expect(r3.room.spectators.map((u) => u.id)).toContain(c.id);
});
test('3. disconnect and reconnect keeps seat and state', async () => {
const s = start();
const { a, b, code } = await twoPlayerGame(s);
const p = b.next('state', (m) => (m.view as any).moveCount === 1);
place(a, 7, 7);
await p;
const notice = a.next('notice', (m) => m.code === 'disconnected');
b.close();
await notice;
const roomMsg = await a.next('room', (m) => m.room.seats[1]?.connected === false).catch(() => a.lastOf('room')!);
expect(roomMsg.room.seats[1]?.connected).toBe(false);
const b2 = await client(s, '', { cookie: b.cookie, id: b.id });
expect(b2.lastOf('welcome')!.activeRoom).toBe(code);
b2.send({ t: 'join', code, as: 'player' });
const st = await b2.next('state');
expect((st.view as any).board[7 * 15 + 7]).toBe(1);
expect((st.view as any).turn).toBe('white');
const after = b2.lastOf('room') ?? (await b2.next('room'));
expect(after.room.seats[1]?.user.id).toBe(b.id);
});
test('4. server crash (no graceful stop) → restart restores the game', async () => {
const s1 = boot();
const { a, b, code } = await twoPlayerGame(s1);
let p = b.next('state', (m) => (m.view as any).moveCount === 1);
place(a, 7, 7);
await p;
p = a.next('state', (m) => (m.view as any).moveCount === 2);
place(b, 0, 0);
await p;
// Simulate a crash: drop the listener and timers without the graceful bye/cleanup.
for (const r of s1.rooms.all()) r.clearTimer();
await Promise.race([s1.server.stop(true), sleep(300)]);
s1.db.close();
const s2 = start(s1.dbPath);
const a2 = await client(s2, '', { cookie: a.cookie, id: a.id });
const b2 = await client(s2, '', { cookie: b.cookie, id: b.id });
expect(a2.lastOf('welcome')!.activeRoom).toBe(code);
a2.send({ t: 'join', code, as: 'player' });
b2.send({ t: 'join', code, as: 'player' });
const st = await a2.next('state');
const v = st.view as any;
expect(v.moveCount).toBe(2);
expect(v.turn).toBe('black');
const p2 = b2.next('state', (m) => (m.view as any).moveCount === 3);
place(a2, 8, 8);
await p2;
});
test('5. acting out of turn is rejected without state change', async () => {
const s = start();
const { a, b } = await twoPlayerGame(s);
const before = b.lastOf('state')!.seq;
const rej = b.next('reject');
const cs = place(b, 1, 1);
const r = await rej;
expect(r.cs).toBe(cs);
expect(r.reason).toBe('지금은 내 차례가 아니에요.');
await sleep(50);
expect(a.lastOf('state')!.seq).toBe(before);
});
test('6. duplicate cs is processed once', async () => {
const s = start();
const { a, b } = await twoPlayerGame(s);
const cs = place(a, 7, 7);
a.send({ t: 'act', cs, a: { type: 'place', x: 8, y: 8 } });
await b.next('state', (m) => (m.view as any).moveCount >= 1);
await sleep(100);
const states = b.messages.filter((m) => m.t === 'state');
expect((states[states.length - 1]!.view as any).moveCount).toBe(1);
const mine = a.messages.filter((m) => m.t === 'state' && m.cs === cs);
expect(mine).toHaveLength(1);
});
test('7. second tab replaces the first', async () => {
const s = start();
const a = await client(s, '탭주인');
const code = await createRoom(s, a);
await join(a, code);
const a2 = await client(s, '', { cookie: a.cookie, id: a.id });
const bye = a.next('bye');
await join(a2, code);
expect((await bye).reason).toBe('replaced');
});
test('9. host leaves → host transfers', async () => {
const s = start();
const a = await client(s, '첫방장');
const b = await client(s, '다음방장');
const code = await createRoom(s, a);
await join(a, code);
await join(b, code);
const r = b.next('room', (m) => m.room.hostId === b.id);
a.send({ t: 'leave' });
expect((await r).room.seats.filter(Boolean)).toHaveLength(1);
});
test('10. flooding is rate limited and the server stays healthy', async () => {
const s = start();
const a = await client(s, '도배왕');
for (let i = 0; i < 100; i++) a.send({ t: 'ping', ts: i });
await sleep(200);
const pongs = a.messages.filter((m) => m.t === 'pong').length;
expect(pongs).toBeLessThanOrEqual(45);
expect(pongs).toBeGreaterThan(0);
const h = await api(s.base, '/healthz');
expect(h.res.status).toBe(200);
});
test('11. malformed / oversized / schema-invalid messages', async () => {
const s = start();
const a = await client(s, '이상한값');
a.ws.send('{not json');
await a.next('error', (m) => m.code === 'bad-json');
a.send({ t: 'act', cs: 'x' });
await a.next('error', (m) => m.code === 'bad-message');
a.ws.send('x'.repeat(20 * 1024));
for (let i = 0; i < 20 && !a.closed; i++) await sleep(25);
expect(a.closed?.code).toBe(1009);
const h = await api(s.base, '/healthz');
expect(h.res.status).toBe(200);
});
test('12a. discord login is hidden when not configured', async () => {
const s = boot(undefined, {
discordExchange: async () => ({ id: '123', username: 'disco', global_name: '디코친구', avatar: null }),
});
running.push(s);
// Discord disabled in this config → 404.
const off = await api(s.base, '/api/auth/discord/start');
expect(off.res.status).toBe(404);
});
test('origin check: foreign origin cannot open WS or POST', async () => {
const s = start();
const g = await guest(s.base, '보안확인');
const res = await fetch(s.base + '/ws', { headers: { origin: 'https://evil.example', cookie: g.cookie, upgrade: 'websocket', connection: 'Upgrade', 'sec-websocket-key': 'dGhlIHNhbXBsZSBub25jZQ==', 'sec-websocket-version': '13' } });
expect(res.status).toBe(403);
const post = await fetch(s.base + '/api/rooms', { method: 'POST', headers: { origin: 'https://evil.example', cookie: g.cookie, 'content-type': 'application/json' }, body: '{"gameId":"omok"}' });
expect(post.status).toBe(403);
});
test('nickname rules and change', async () => {
const s = start();
const bad = await api(s.base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname: 'a' }) });
expect(bad.res.status).toBe(400);
const g = await guest(s.base, ' 처음 이름 ');
const me = await api(s.base, '/api/me', { cookie: g.cookie });
expect(me.body.me.nickname).toBe('처음 이름');
const ch = await api(s.base, '/api/me', { method: 'PATCH', cookie: g.cookie, body: JSON.stringify({ nickname: '바뀐이름' }) });
expect(ch.body.me.nickname).toBe('바뀐이름');
});
test('chat is filtered and spectators receive public view only', async () => {
const s = start();
const { a, code } = await twoPlayerGame(s);
const c = await client(s, '관전자');
c.send({ t: 'join', code, as: 'spectator' });
const st = await c.next('state');
expect((st.view as any).myColor).toBeNull();
const chat = c.next('chat');
a.send({ t: 'chat', text: '안녕 씨발' });
expect((await chat).text).toBe('안녕 **');
});
});
describe('Discord login flow', () => {
test('state mismatch rejected, valid flow merges guest into new member', async () => {
const s = boot(undefined, {
discordExchange: async () => ({ id: '42', username: 'disco', global_name: '디코친구', avatar: 'abc' }),
});
running.push(s);
s.config.discord = { clientId: 'cid', clientSecret: 'sec' };
const g = await guest(s.base, '게스트였음');
const startRes = await api(s.base, '/api/auth/discord/start?next=/r/123456', { cookie: g.cookie });
expect(startRes.res.status).toBe(302);
const loc = new URL(startRes.res.headers.get('location')!);
expect(loc.host).toBe('discord.com');
const state = loc.searchParams.get('state')!;
const oauthCookie = startRes.setCookie!.split(';')[0]!;
const bad = await api(s.base, `/api/auth/discord/callback?code=x&state=wrong`, { cookie: `${g.cookie}; ${oauthCookie}` });
expect(bad.res.headers.get('location')).toBe('/?login=failed');
const good = await api(s.base, `/api/auth/discord/callback?code=x&state=${state}`, { cookie: `${g.cookie}; ${oauthCookie}` });
expect(good.res.headers.get('location')).toBe('/r/123456?linked=1');
const sid = good.setCookie!.split(/,(?=\s*\w+=)/).find((c) => c.trim().startsWith('sid='))!.split(';')[0]!.trim();
const me = await api(s.base, '/api/me', { cookie: sid });
expect(me.body.me).toMatchObject({ id: g.id, nickname: '게스트였음', kind: 'member' });
expect(me.body.me.avatar).toBe('https://cdn.discordapp.com/avatars/42/abc.png?size=128');
// Old guest session no longer valid.
const old = await api(s.base, '/api/me', { cookie: g.cookie });
expect(old.body.me).toBeNull();
});
});

View File

@@ -106,7 +106,9 @@ export function startServer(opts: StartOptions) {
clearInterval(sweeper);
gateway.shutdown();
rooms.shutdown();
await server.stop(true);
// Bun's stop() promise can stay pending after server-initiated WS closes even though the
// listener is already closed, so bound the wait.
await Promise.race([server.stop(true), Bun.sleep(1000)]);
db.close();
};

View File

@@ -0,0 +1,128 @@
/** Helpers for integration tests: boot a real server, act as browser clients. */
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ServerMessage } from '@bg/shared';
import { loadConfig } from './config';
import { startServer, type StartOptions } from './server';
export const ORIGIN = 'http://localhost:5173';
export function tempDbPath(): string {
const dir = mkdtempSync(join(process.env.TMPDIR ?? tmpdir(), 'bg-test-'));
return join(dir, 'app.db');
}
export function boot(dbPath = tempDbPath(), extra: Partial<StartOptions> = {}) {
const config = loadConfig({ PORT: '0', DB_PATH: dbPath, PUBLIC_ORIGIN: ORIGIN });
const app = startServer({ config, log: () => {}, staticDir: null, ...extra });
const base = `http://localhost:${app.server.port}`;
return { ...app, base, dbPath, config };
}
export async function api(base: string, path: string, init: RequestInit & { cookie?: string } = {}) {
const headers = new Headers(init.headers);
headers.set('origin', ORIGIN);
if (init.cookie) headers.set('cookie', init.cookie);
if (init.body && !headers.has('content-type')) headers.set('content-type', 'application/json');
const res = await fetch(base + path, { ...init, headers, redirect: 'manual' });
const setCookie = res.headers.get('set-cookie');
let body: any = null;
try {
body = await res.clone().json();
} catch {
body = await res.text();
}
return { res, body, setCookie };
}
export async function guest(base: string, nickname: string): Promise<{ cookie: string; id: string }> {
const r = await api(base, '/api/auth/guest', { method: 'POST', body: JSON.stringify({ nickname }) });
if (r.res.status !== 200) throw new Error(`guest failed: ${JSON.stringify(r.body)}`);
const cookie = r.setCookie!.split(';')[0]!;
return { cookie, id: r.body.me.id };
}
export class Client {
ws!: WebSocket;
messages: ServerMessage[] = [];
private waiters: { pred: (m: ServerMessage) => boolean; resolve: (m: ServerMessage) => void }[] = [];
closed: { code: number; reason: string } | null = null;
cs = 0;
constructor(
private base: string,
public cookie: string,
public id: string,
) {}
async connect(): Promise<this> {
this.closed = null;
const url = this.base.replace('http', 'ws') + '/ws';
// Bun's WebSocket client accepts custom headers.
this.ws = new WebSocket(url, { headers: { cookie: this.cookie, origin: ORIGIN } } as unknown as string[]);
this.ws.onmessage = (e) => {
const m = JSON.parse(String(e.data)) as ServerMessage;
this.messages.push(m);
for (const w of [...this.waiters]) {
if (w.pred(m)) {
this.waiters.splice(this.waiters.indexOf(w), 1);
w.resolve(m);
}
}
};
this.ws.onclose = (e) => {
this.closed = { code: e.code, reason: e.reason };
};
await this.waitFor((m) => m.t === 'welcome');
return this;
}
send(m: unknown): void {
this.ws.send(JSON.stringify(m));
}
/** Resolves with the first message (already received after `since`, or future) matching pred. */
waitFor<T extends ServerMessage['t']>(
pred: (m: ServerMessage) => boolean,
timeout = 3000,
since = 0,
): Promise<Extract<ServerMessage, { t: T }>> {
const existing = this.messages.slice(since).find(pred);
if (existing) return Promise.resolve(existing as any);
return new Promise((resolve, reject) => {
const w = { pred, resolve: resolve as (m: ServerMessage) => void };
this.waiters.push(w);
setTimeout(() => {
const i = this.waiters.indexOf(w);
if (i >= 0) {
this.waiters.splice(i, 1);
reject(new Error(`timeout waiting; last: ${JSON.stringify(this.messages.slice(-3))}`));
}
}, timeout);
});
}
/** Waits for the next message of type t received after now. */
next<T extends ServerMessage['t']>(t: T, pred: (m: Extract<ServerMessage, { t: T }>) => boolean = () => true, timeout = 3000) {
const since = this.messages.length;
return this.waitFor<T>((m) => m.t === t && pred(m as any), timeout, since);
}
lastOf<T extends ServerMessage['t']>(t: T): Extract<ServerMessage, { t: T }> | undefined {
for (let i = this.messages.length - 1; i >= 0; i--) if (this.messages[i]!.t === t) return this.messages[i] as any;
return undefined;
}
act(a: unknown): number {
const cs = ++this.cs;
this.send({ t: 'act', cs, a });
return cs;
}
close(): void {
this.ws.close();
}
}
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));