Files
rplace/src/worker.js
T
tiennm99 4f02d30393 feat(canvas): WS hardening, client race fix, and ping/pong heartbeat
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
2026-05-10 03:05:56 +07:00

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;