diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..9030888 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,5 @@ +{ + "enabledPlugins": { + "frontend-design@claude-plugins-official": true + } +} diff --git a/src/client/App.svelte b/src/client/App.svelte index 7050879..47a456c 100644 --- a/src/client/App.svelte +++ b/src/client/App.svelte @@ -13,8 +13,8 @@ /** @type {CanvasRenderer} */ let canvasRenderer; - // WebSocket connection for real-time updates - $effect(() => { + // WebSocket connection with auto-reconnect + function connectWebSocket() { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const ws = new WebSocket(`${protocol}//${location.host}/api/ws`); @@ -27,11 +27,14 @@ } catch { /* ignore parse errors */ } }; - ws.onclose = () => { - // Reconnect after 1s - setTimeout(() => { /* effect re-runs on reactive dep change */ }, 1000); - }; + ws.onclose = () => setTimeout(connectWebSocket, 1000); + ws.onerror = () => ws.close(); + return ws; + } + + $effect(() => { + const ws = connectWebSocket(); return () => ws.close(); }); diff --git a/src/client/components/CanvasRenderer.svelte b/src/client/components/CanvasRenderer.svelte index 0521f78..8bce707 100644 --- a/src/client/components/CanvasRenderer.svelte +++ b/src/client/components/CanvasRenderer.svelte @@ -12,6 +12,10 @@ let lastMouse = { x: 0, y: 0 }; let loading = $state(true); + // Cached offscreen canvas — avoids re-allocation every render + const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT); + const offCtx = offscreen.getContext('2d'); + /** Render ImageData onto visible canvas with current zoom/pan */ function render() { if (!canvasEl || !imageData) return; @@ -21,8 +25,7 @@ ctx.fillStyle = '#1a1a1a'; ctx.fillRect(0, 0, canvasEl.width, canvasEl.height); - const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT); - offscreen.getContext('2d').putImageData(imageData, 0, 0); + offCtx.putImageData(imageData, 0, 0); ctx.save(); ctx.translate(pan.x, pan.y); diff --git a/src/lib/get-user-id.js b/src/lib/get-user-id.js index 4b2d93c..cec8fc2 100644 --- a/src/lib/get-user-id.js +++ b/src/lib/get-user-id.js @@ -5,9 +5,8 @@ * @returns {string} user id prefixed with "anon:" */ export function getUserId(request) { - const ip = request.headers.get('cf-connecting-ip') - || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() - || '127.0.0.1'; + // CF-Connecting-IP is set by Cloudflare and cannot be spoofed + const ip = request.headers.get('cf-connecting-ip') || '127.0.0.1'; // Simple hash for privacy let hash = 0; diff --git a/src/worker.js b/src/worker.js index bacfccf..bc97622 100644 --- a/src/worker.js +++ b/src/worker.js @@ -63,13 +63,17 @@ app.post('/api/place', async (c) => { // Write pixels to canvas await setPixels(c.env, pixels); - // Broadcast to all connected WebSocket clients - 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), - })); + // Broadcast to all connected WebSocket clients (non-blocking) + 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); + } return c.json({ ok: true, credits: remaining }); });