8.9 KiB
8.9 KiB
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:
-- 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):
// 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 3–10KB; 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
// 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 } },
});