Files

270 lines
8.9 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Signaling relay and API route (create once)
The relay is **yours**, not part of the kit: copy it as-is or serve the same
`RtcPollResponse` shape from any store. Only rendezvous traffic (roster +
SDP/ICE) passes through it; game data flows peer-to-peer.
## Schema — nothing to do by default
The relay below creates its two tables on first use
(`CREATE TABLE IF NOT EXISTS`, once per process) — nothing ships in
`migrations/` and the template itself never touches your database. If you'd
rather own or extend the schema (extra columns, your own migration ordering),
copy this into one of your app migrations; `IF NOT EXISTS` makes the runtime
ensure and your migration coexist safely:
```sql
-- CREATE TABLE IF NOT EXISTS webrtc_peers (
-- room TEXT NOT NULL,
-- peer_id TEXT NOT NULL,
-- name TEXT NOT NULL DEFAULT '',
-- last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PRIMARY KEY (room, peer_id)
-- );
-- CREATE TABLE IF NOT EXISTS webrtc_signals (
-- id BIGSERIAL PRIMARY KEY,
-- room TEXT NOT NULL,
-- to_peer TEXT NOT NULL,
-- from_peer TEXT NOT NULL,
-- kind TEXT NOT NULL,
-- payload JSONB NOT NULL,
-- created_at TIMESTAMPTZ NOT NULL DEFAULT now()
-- );
-- CREATE INDEX IF NOT EXISTS webrtc_signals_inbox
-- ON webrtc_signals (room, to_peer, id);
```
## `src/lib/multiplayer/signaling.server.ts`
Copy this file as-is (or adapt it — it is yours, not part of the kit):
```ts
// src/lib/multiplayer/signaling.server.ts
/**
* WebRTC signaling over the app database (Neon deployed, PGLite in preview).
* Only rendezvous traffic passes through here — roster + SDP/ICE relay while a
* mesh forms; game data then flows peer-to-peer. DB-backed so any serverless
* instance can serve any poll. Mount at /api/rtc (see the multiplayer-p2p
* skill); the client side lives in `@/lib/multiplayer`.
*
* The GET poll is the whole peer lifecycle: the first poll (since=0) IS the
* join — it registers the peer, returns the roster, and prunes stale rows.
* Peer ids are random per mount, so a fresh inbox never has old signals to
* skip and no join/cursor handshake is needed.
*/
import { z } from "zod";
import { getSql, type Sql } from "@/lib/db";
import type { PeerRow, RtcPollResponse, SignalRow } from "./p2p";
const ID = z.string().regex(/^[a-zA-Z0-9_-]{1,64}$/);
const signalSchema = z.object({
op: z.literal("signal"),
room: ID,
from: ID,
to: ID,
kind: z.enum(["offer", "answer", "ice"]),
// SDP offers are typically 310KB; the cap only blocks abuse (payload is
// re-serialized at insert — cheap at this size). An absent
// payload is rejected here (JSON.stringify(undefined) has no .length).
payload: z.unknown().refine((v) => v !== undefined && JSON.stringify(v).length <= 32_768, {
message: "payload too large",
}),
});
const leaveSchema = z.object({ op: z.literal("leave"), room: ID, peer: ID });
const postSchema = z.discriminatedUnion("op", [signalSchema, leaveSchema]);
const PEER_TTL_SECONDS = 30;
const SIGNAL_TTL_SECONDS = 60;
/**
* The kit ships no migration: tables are created on first use (IF NOT EXISTS)
* so the app's migrations/ namespace stays fully in the agent's hands. Agents
* who want to own/extend the schema can copy the DDL from the multiplayer-p2p
* skill into their own migration — both coexist safely. Memoized on globalThis
* (the db.ts pattern) so dev HMR never runs two ensures concurrently; a failed
* ensure clears the slot so the next request retries.
*/
const globalRef = globalThis as typeof globalThis & {
__rtcSchemaPromise__?: Promise<void>;
};
function ensureSchema(sql: Sql): Promise<void> {
globalRef.__rtcSchemaPromise__ ??= (async () => {
await sql.query(
`CREATE TABLE IF NOT EXISTS webrtc_peers (
room TEXT NOT NULL,
peer_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
last_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (room, peer_id)
)`,
);
await sql.query(
`CREATE TABLE IF NOT EXISTS webrtc_signals (
id BIGSERIAL PRIMARY KEY,
room TEXT NOT NULL,
to_peer TEXT NOT NULL,
from_peer TEXT NOT NULL,
kind TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
);
await sql.query(
`CREATE INDEX IF NOT EXISTS webrtc_signals_inbox
ON webrtc_signals (room, to_peer, id)`,
);
})().catch((err) => {
globalRef.__rtcSchemaPromise__ = undefined;
throw err;
});
return globalRef.__rtcSchemaPromise__;
}
async function roster(sql: Sql, room: string): Promise<PeerRow[]> {
// LIMIT bounds the blast radius of room-stuffing; the mesh caps out ~8.
const rows = await sql.query<{ peer_id: string; name: string }>(
`SELECT peer_id, name FROM webrtc_peers
WHERE room = $1 AND last_seen > now() - make_interval(secs => $2)
ORDER BY peer_id LIMIT 32`,
[room, PEER_TTL_SECONDS],
);
return rows.map((r) => ({ id: r.peer_id, name: r.name }));
}
async function touchPeer(sql: Sql, room: string, peer: string, name: string) {
await sql.query(
`INSERT INTO webrtc_peers (room, peer_id, name, last_seen)
VALUES ($1, $2, $3, now())
ON CONFLICT (room, peer_id)
DO UPDATE SET last_seen = now(), name = EXCLUDED.name`,
[room, peer, name],
);
}
/**
* Rows are ephemeral; GC rides the polls instead of a cron: joins (since=0)
* always prune, and ~2% of all other polls do too — so a busy room whose
* cursors always advance still gets swept, without every heartbeat paying
* the two DELETEs.
*/
async function prune(sql: Sql) {
await Promise.all([
sql.query(`DELETE FROM webrtc_signals WHERE created_at < now() - make_interval(secs => $1)`, [
SIGNAL_TTL_SECONDS,
]),
sql.query(`DELETE FROM webrtc_peers WHERE last_seen < now() - make_interval(secs => $1)`, [
PEER_TTL_SECONDS,
]),
]);
}
function json(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json", "cache-control": "no-store" },
});
}
/** GET /api/rtc?room&peer&name&since — join (since=0), heartbeat, and inbox. */
async function handleGet(url: URL): Promise<Response> {
const parsed = z
.object({
room: ID,
peer: ID,
name: z.string().max(64).default(""),
since: z.coerce.number().int().min(0).default(0),
})
.safeParse({
room: url.searchParams.get("room"),
peer: url.searchParams.get("peer"),
name: url.searchParams.get("name") ?? "",
since: url.searchParams.get("since") ?? 0,
});
if (!parsed.success) return json({ error: "invalid query" }, 400);
const { room, peer, name, since } = parsed.data;
const sql = await getSql();
await ensureSchema(sql);
if (since === 0 || Math.random() < 0.02) await prune(sql);
await touchPeer(sql, room, peer, name);
const rows = await sql.query<{
id: number;
from_peer: string;
kind: SignalRow["kind"];
payload: unknown;
}>(
`SELECT id, from_peer, kind, payload FROM webrtc_signals
WHERE room = $1 AND to_peer = $2 AND id > $3
ORDER BY id LIMIT 200`,
[room, peer, since],
);
const body: RtcPollResponse = {
peers: await roster(sql, room),
signals: rows.map((r) => ({
id: r.id,
from: r.from_peer,
kind: r.kind,
payload: r.payload,
})),
};
return json(body);
}
async function handlePost(request: Request): Promise<Response> {
let body: unknown;
try {
body = await request.json();
} catch {
return json({ error: "invalid JSON" }, 400);
}
const parsed = postSchema.safeParse(body);
if (!parsed.success) return json({ error: "invalid request" }, 400);
const msg = parsed.data;
const sql = await getSql();
await ensureSchema(sql);
if (msg.op === "signal") {
await sql.query(
`INSERT INTO webrtc_signals (room, to_peer, from_peer, kind, payload)
VALUES ($1, $2, $3, $4, $5)`,
[msg.room, msg.to, msg.from, msg.kind, JSON.stringify(msg.payload)],
);
} else {
await sql.query(`DELETE FROM webrtc_peers WHERE room = $1 AND peer_id = $2`, [
msg.room,
msg.peer,
]);
}
return json({ ok: true });
}
/** Request entrypoint for the /api/rtc route (GET poll, POST signal/leave). */
export async function handleSignaling(request: Request): Promise<Response> {
try {
if (request.method === "GET") return await handleGet(new URL(request.url));
if (request.method === "POST") return await handlePost(request);
return json({ error: "method not allowed" }, 405);
} catch (error) {
console.error("[rtc] signaling error:", error);
return json({ error: "signaling failed" }, 500);
}
}
```
## Mount the API route
```ts
// src/routes/api/rtc.ts
import { createFileRoute } from "@tanstack/react-router";
import { handleSignaling } from "@/lib/multiplayer/signaling.server";
const handle = ({ request }: { request: Request }) => handleSignaling(request);
export const Route = createFileRoute("/api/rtc")({
server: { handlers: { GET: handle, POST: handle } },
});
```