mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
Server:
- Origin allowlist on /api/ws (env.ALLOWED_ORIGINS, comma-separated; empty
= allow all for dev/preview)
- per-identity WS connection cap (MAX_WS_PER_IDENTITY = 5) using
acceptWebSocket(socket, [identity]) tagging; 6th upgrade returns 429
- ws.send 'ping' triggers a {type:'pong'} reply so dead connections fire
onclose promptly instead of waiting on TCP keepalive
Client:
- buffer WS pixels arriving during the initial canvas fetch and replay
them after committedColors is replaced; previously the post-fetch
Uint8Array assignment silently overwrote any pixels broadcast in the
fetch window (the documented C2 race)
- 30s ping / 60s pong watchdog closes the socket if pong stops arriving,
routing through the existing exponential-backoff reconnect path
Tests:
- four /api/ws cases: missing upgrade, disallowed origin, allowed origin,
empty allowlist (dev default). Sentinel uses status 200 because undici
rejects 101 in Node-side Response constructors.
Plan: plans/260510-0232-fix-do-migration-followups/phase-03-ws-hardening-client-race.md
147 lines
4.7 KiB
JavaScript
147 lines
4.7 KiB
JavaScript
import { Hono } from 'hono';
|
|
import { resolveIdentity, NoIdentityError } from './lib/get-user-id.js';
|
|
import { formatSetCookie } from './lib/cookie.js';
|
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
|
|
|
|
export { CanvasRoom } from './durable-objects/canvas-room.js';
|
|
|
|
const app = new Hono();
|
|
|
|
// ~64 bytes is generous per pixel JSON object {"x":2047,"y":2047,"color":31}
|
|
const MAX_BODY_BYTES = MAX_BATCH_SIZE * 64;
|
|
|
|
const COOKIE_OPTS = {
|
|
httpOnly: true,
|
|
secure: true,
|
|
sameSite: 'Lax',
|
|
path: '/',
|
|
maxAge: 60 * 60 * 24 * 365, // 1 year
|
|
};
|
|
|
|
/** Resolve the singleton CanvasRoom DO stub. */
|
|
function room(env) {
|
|
return env.CANVAS_ROOM.get(env.CANVAS_ROOM.idFromName('main'));
|
|
}
|
|
|
|
/** GET /api/canvas — full canvas binary, served by the DO directly. Issues
|
|
* the rplace_id cookie on first request so future calls bypass NAT-shared
|
|
* IP buckets. */
|
|
app.get('/api/canvas', async (c) => {
|
|
let identity;
|
|
try {
|
|
identity = await resolveIdentity(c.req.raw, c.env);
|
|
} catch (err) {
|
|
if (err instanceof NoIdentityError) {
|
|
return c.json({ error: 'no_identity' }, 500);
|
|
}
|
|
throw err;
|
|
}
|
|
const upstream = await room(c.env).fetch('http://do/canvas');
|
|
if (!identity.mintCookieValue) return upstream;
|
|
// Attach Set-Cookie without mutating the upstream Response (its body is a
|
|
// stream; new Response keeps it linked).
|
|
const out = new Response(upstream.body, upstream);
|
|
out.headers.append(
|
|
'Set-Cookie',
|
|
formatSetCookie('rplace_id', identity.mintCookieValue, COOKIE_OPTS),
|
|
);
|
|
return out;
|
|
});
|
|
|
|
/** POST /api/place — validate at the edge, forward to the DO. */
|
|
app.post('/api/place', async (c) => {
|
|
// Require a positive Content-Length. Missing or zero would otherwise let a
|
|
// chunked-transfer-encoded body bypass the MAX_BODY_BYTES pre-parse cap.
|
|
const contentLengthRaw = c.req.header('content-length');
|
|
const contentLength = parseInt(contentLengthRaw ?? '', 10);
|
|
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
|
return c.json({ error: 'content_length_required' }, 411);
|
|
}
|
|
if (contentLength > MAX_BODY_BYTES) {
|
|
return c.json({ error: 'body_too_large', max: MAX_BODY_BYTES }, 413);
|
|
}
|
|
|
|
let body;
|
|
try {
|
|
body = await c.req.json();
|
|
} catch {
|
|
return c.json({ error: 'invalid_json' }, 400);
|
|
}
|
|
|
|
const { pixels } = body;
|
|
if (!Array.isArray(pixels) || pixels.length === 0) {
|
|
return c.json({ error: 'pixels_required' }, 400);
|
|
}
|
|
if (pixels.length > MAX_BATCH_SIZE) {
|
|
return c.json({ error: 'batch_too_large', max: MAX_BATCH_SIZE }, 400);
|
|
}
|
|
for (const p of pixels) {
|
|
if (
|
|
!Number.isInteger(p?.x) || !Number.isInteger(p?.y) || !Number.isInteger(p?.color) ||
|
|
p.x < 0 || p.x >= CANVAS_WIDTH ||
|
|
p.y < 0 || p.y >= CANVAS_HEIGHT ||
|
|
p.color < 0 || p.color >= MAX_COLORS
|
|
) {
|
|
return c.json({ error: 'invalid_pixel', pixel: p }, 400);
|
|
}
|
|
}
|
|
|
|
let identity;
|
|
try {
|
|
identity = await resolveIdentity(c.req.raw, c.env);
|
|
} catch (err) {
|
|
if (err instanceof NoIdentityError) {
|
|
return c.json({ error: 'no_identity' }, 500);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// DO does cooldown check + pixel write + broadcast in one atomic step.
|
|
return room(c.env).fetch('http://do/place', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId: identity.id, pixels }),
|
|
});
|
|
});
|
|
|
|
/** GET /api/ws — WebSocket upgrade routed to the DO.
|
|
* Origin allowlist + identity resolution + per-identity cap (in DO). */
|
|
app.get('/api/ws', async (c) => {
|
|
const upgradeHeader = c.req.header('Upgrade');
|
|
if (upgradeHeader !== 'websocket') {
|
|
return c.text('Expected WebSocket', 426);
|
|
}
|
|
// Origin allowlist. Empty ALLOWED_ORIGINS ⇒ allow all (dev / preview).
|
|
const origin = c.req.header('Origin');
|
|
const allowed = parseAllowedOrigins(c.env?.ALLOWED_ORIGINS);
|
|
if (origin && allowed.size > 0 && !allowed.has(origin)) {
|
|
return c.text('forbidden_origin', 403);
|
|
}
|
|
let identity;
|
|
try {
|
|
identity = await resolveIdentity(c.req.raw, c.env);
|
|
} catch (err) {
|
|
if (err instanceof NoIdentityError) {
|
|
return c.json({ error: 'no_identity' }, 500);
|
|
}
|
|
throw err;
|
|
}
|
|
// Identity goes via URL query so the original request (with Upgrade header)
|
|
// forwards unchanged. CF DO routing reads it from request.url.
|
|
const url = `http://do/ws?identity=${encodeURIComponent(identity.id)}`;
|
|
return room(c.env).fetch(url, c.req.raw);
|
|
});
|
|
|
|
/** Parse the comma-separated ALLOWED_ORIGINS env var into a Set. */
|
|
function parseAllowedOrigins(raw) {
|
|
const out = new Set();
|
|
if (!raw) return out;
|
|
for (const o of String(raw).split(',')) {
|
|
const trimmed = o.trim();
|
|
if (trimmed) out.add(trimmed);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export default app;
|