mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
fix: address ultrareview findings across backend and frontend
Backend: - rate-limiter: retryAfter now in seconds; ms-precision lu preserves fractional regen residue across calls (C1, C2) - redis-client: throw on Upstash 200-with-error envelope; redisRaw returns body.result (NH1) - constants: MAX_BATCH_SIZE = MAX_CREDITS = 256 (was 512 vs 256) - worker: content-length cap, gzip + s-maxage=10 on /api/canvas, broadcast via executionCtx.waitUntil with r.ok check (NC2, H4, H5) - canvas-storage: warn on truncated Upstash read instead of silent zero-pad (NH2) - get-user-id: SHA-256 (16 hex chars) replaces 32-bit string hash; missing cf-connecting-ip routes to anon:dev with warn (H1, H2); function is now async - canvas-room: log unclean WS closes and errors; defensive close on unexpected client message (NH4, N5) Frontend: - pixel buffer capped at MAX_BATCH_SIZE with toast (NC2) - Submit error UX: toast for 429/413/400/5xx/network; honor retryAfter (NC1) - committedColors allocated upfront so WS updates during initial fetch no longer null-deref (NC3) - handleWheel always renders even when zoom is clamped (C1, C2) - canvas-decoder throws on truncated input instead of reading past end with || 0 (C3) - WS reconnect refetches canvas to recover missed pixels (C4) - pixel-buffer Map cache for O(1) getColorAt/pixelCount (NH1) - cancel in-progress stroke on mode switch (NH3) - canvas load error overlay with Retry button (NH5) - DPR-aware canvas sizing (H1) - onMount cleanup is now sync (no leaked resize listener) (H2) Tests: - update for async getUserId, decoder bounds check, redis error format; add Upstash error-envelope coverage
This commit is contained in:
1 parent
8e1f8c4049
commit
a823f8527d
14 files changed
+418
-154
No files matched your search
+51
-18
@@ -8,19 +8,35 @@ export { CanvasRoom } from './durable-objects/canvas-room.js';
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
/** GET /api/canvas — full canvas as binary */
|
||||
// ~64 bytes is generous per pixel JSON object {"x":2047,"y":2047,"color":31}
|
||||
const MAX_BODY_BYTES = MAX_BATCH_SIZE * 64;
|
||||
|
||||
/** GET /api/canvas — full canvas as binary; gzip when supported */
|
||||
app.get('/api/canvas', async (c) => {
|
||||
const buffer = await getFullCanvas(c.env);
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=1, s-maxage=1, stale-while-revalidate=5',
|
||||
},
|
||||
});
|
||||
const acceptsGzip = (c.req.header('accept-encoding') || '').includes('gzip');
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=10, s-maxage=10, stale-while-revalidate=30',
|
||||
Vary: 'Accept-Encoding',
|
||||
};
|
||||
|
||||
if (acceptsGzip) {
|
||||
const gzStream = new Response(buffer).body.pipeThrough(new CompressionStream('gzip'));
|
||||
headers['Content-Encoding'] = 'gzip';
|
||||
return new Response(gzStream, { headers });
|
||||
}
|
||||
return new Response(buffer, { headers });
|
||||
});
|
||||
|
||||
/** POST /api/place — batch pixel placement */
|
||||
app.post('/api/place', async (c) => {
|
||||
const contentLength = parseInt(c.req.header('content-length') || '0', 10);
|
||||
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();
|
||||
@@ -52,7 +68,7 @@ app.post('/api/place', async (c) => {
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
const userId = getUserId(c.req.raw);
|
||||
const userId = await getUserId(c.req.raw);
|
||||
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
|
||||
c.env, userId, pixels.length,
|
||||
);
|
||||
@@ -60,7 +76,7 @@ app.post('/api/place', async (c) => {
|
||||
return c.json({ error: 'rate_limited', remaining, retryAfter }, 429);
|
||||
}
|
||||
|
||||
// Write pixels to canvas + broadcast
|
||||
// Persist pixels (must succeed before broadcast)
|
||||
try {
|
||||
await setPixels(c.env, pixels);
|
||||
} catch (err) {
|
||||
@@ -68,20 +84,37 @@ app.post('/api/place', async (c) => {
|
||||
return c.json({ error: 'storage_failed', message: String(err) }, 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const roomId = c.env.CANVAS_ROOM.idFromName('main');
|
||||
const room = c.env.CANVAS_ROOM.get(roomId);
|
||||
await room.fetch(new Request('http://internal/broadcast', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(pixels),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Broadcast failed:', err);
|
||||
// Broadcast in background — don't block the user response on DO fetch.
|
||||
// In non-CF runtimes (tests), executionCtx is unavailable; fall back to fire-and-forget.
|
||||
const broadcastTask = broadcastPixels(c.env, pixels);
|
||||
let ctx = null;
|
||||
try { ctx = c.executionCtx; } catch { /* no-op */ }
|
||||
if (ctx) {
|
||||
ctx.waitUntil(broadcastTask);
|
||||
} else {
|
||||
broadcastTask.catch((err) => console.error('Broadcast:', err));
|
||||
}
|
||||
|
||||
return c.json({ ok: true, credits: remaining });
|
||||
});
|
||||
|
||||
async function broadcastPixels(env, pixels) {
|
||||
try {
|
||||
const roomId = env.CANVAS_ROOM.idFromName('main');
|
||||
const room = env.CANVAS_ROOM.get(roomId);
|
||||
const r = await room.fetch(new Request('http://internal/broadcast', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(pixels),
|
||||
}));
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
console.error('Broadcast non-OK:', r.status, text);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Broadcast threw:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** WebSocket upgrade — delegate to Durable Object */
|
||||
app.get('/api/ws', async (c) => {
|
||||
const upgradeHeader = c.req.header('Upgrade');
|
||||
|
||||
Reference in new issue
Block a user