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:
2026-04-17 09:48:13 +07:00
committed by Tien Nguyen Minh
parent 8e1f8c4049
commit a823f8527d
14 changed files with 418 additions and 154 deletions
+69 -8
View File
@@ -1,5 +1,5 @@
<script>
import { MAX_CREDITS, CREDIT_REGEN_RATE } from '../lib/constants.js';
import { MAX_CREDITS, CREDIT_REGEN_RATE, MAX_BATCH_SIZE } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
@@ -13,10 +13,18 @@
let mode = $state('paint');
let submitting = $state(false);
let bufferState = $state({ canUndo: false, canRedo: false, pixelCount: 0 });
let toast = $state(null); // { kind: 'error'|'info', text: string }
let toastTimer = null;
/** @type {CanvasRenderer} */
let canvasRenderer;
function showToast(kind, text, ttlMs = 4000) {
toast = { kind, text };
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => { toast = null; }, ttlMs);
}
// Client-side credit regeneration (server corrects on submit)
$effect(() => {
const interval = setInterval(() => {
@@ -29,6 +37,7 @@
// WebSocket connection with auto-reconnect + exponential backoff
let wsRetryDelay = 1000;
let isReconnect = false;
function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -43,7 +52,14 @@
} catch { /* ignore parse errors */ }
};
ws.onopen = () => { wsRetryDelay = 1000; };
ws.onopen = () => {
// Refetch canvas after a reconnect to recover any pixels missed while disconnected.
if (isReconnect && canvasRenderer) {
canvasRenderer.refetchCanvas();
}
wsRetryDelay = 1000;
isReconnect = true;
};
ws.onclose = () => {
setTimeout(connectWebSocket, wsRetryDelay);
wsRetryDelay = Math.min(wsRetryDelay * 2, 30000);
@@ -60,6 +76,8 @@
// Keyboard shortcuts
function handleKeyDown(e) {
// Don't intercept when user is typing in an input
if (e.target?.matches?.('input, textarea, [contenteditable]')) return;
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
canvasRenderer?.undo();
@@ -73,6 +91,10 @@
async function handleSubmit() {
const pixels = canvasRenderer?.getPendingPixels();
if (!pixels?.length) return;
if (pixels.length > MAX_BATCH_SIZE) {
showToast('error', `Batch too large (${pixels.length} > ${MAX_BATCH_SIZE}). Submit fewer pixels.`);
return;
}
submitting = true;
try {
@@ -81,24 +103,43 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pixels }),
});
let data = null;
const text = await res.text();
let data;
try { data = JSON.parse(text); } catch {
console.error('Server returned non-JSON:', res.status, text);
try { data = JSON.parse(text); } catch { /* non-JSON body */ }
if (!res.ok) {
if (res.status === 429) {
const retryAfter = data?.retryAfter ?? '?';
if (typeof data?.remaining === 'number') credits = data.remaining;
showToast('error', `Rate limited — try again in ${retryAfter}s.`, 6000);
} else if (res.status === 413) {
showToast('error', 'Request too large. Reduce batch size.');
} else if (res.status === 400) {
showToast('error', `Rejected: ${data?.error || 'invalid request'}`);
} else {
showToast('error', `Server error (${res.status}): ${data?.error || text || 'unknown'}`);
}
return;
}
if (data.ok) {
if (data?.ok) {
credits = data.credits;
canvasRenderer.commitPending();
showToast('info', 'Submitted', 1500);
} else {
console.warn('Submit rejected:', data.error, data);
showToast('error', `Unexpected response: ${data?.error || 'no data'}`);
}
} catch (err) {
console.error('Submit failed:', err);
showToast('error', `Network error: ${err.message || err}`, 6000);
} finally {
submitting = false;
}
}
function handleBufferFull() {
showToast('info', `Buffer at max (${MAX_BATCH_SIZE} pixels) — submit or undo to draw more.`, 3000);
}
</script>
<svelte:window onkeydown={handleKeyDown} />
@@ -112,6 +153,7 @@
onZoomChange={(z) => zoom = z}
onCursorMove={(pos) => cursorPos = pos}
onBufferChange={(s) => bufferState = s}
onBufferFull={handleBufferFull}
/>
<CanvasControls
{zoom}
@@ -134,6 +176,10 @@
/>
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
<UserInfo {credits} />
{#if toast}
<div class="toast {toast.kind}" role="status" aria-live="polite">{toast.text}</div>
{/if}
</main>
<style>
@@ -142,4 +188,19 @@
height: 100%;
position: relative;
}
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
padding: 10px 18px;
border-radius: 8px;
font-size: 0.95rem;
z-index: 30;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
max-width: 90vw;
text-align: center;
}
.toast.error { background: #8b2222; color: #fff; border: 1px solid #a33; }
.toast.info { background: #1d3a8a; color: #fff; border: 1px solid #3b5cb8; }
</style>
+98 -26
View File
@@ -1,19 +1,21 @@
<script>
import { onMount } from 'svelte';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '../../lib/constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA, MAX_BATCH_SIZE } from '../../lib/constants.js';
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange } = $props();
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull } = $props();
let canvasEl;
let imageData = null;
/** Committed color index per pixel (server-confirmed state) */
let committedColors = null;
/** Committed color index per pixel (server-confirmed state). Allocated upfront so WS
* updates that arrive during the initial canvas fetch don't null-deref. */
let committedColors = new Uint8Array(CANVAS_WIDTH * CANVAS_HEIGHT);
let pan = { x: 0, y: 0 };
let dragging = $state(false);
let lastMouse = { x: 0, y: 0 };
let loading = $state(true);
let loadError = $state(null);
// Active stroke being drawn (not yet in buffer)
let currentStroke = [];
@@ -28,18 +30,19 @@
let touchStartTime = 0;
let touchMoved = false;
function render() {
function render(effZoom = zoom) {
if (!canvasEl || !imageData) return;
const dpr = window.devicePixelRatio || 1;
const ctx = canvasEl.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.setTransform(1, 0, 0, 1, 0, 0); // reset before clear
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
offCtx.putImageData(imageData, 0, 0);
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS pixels → device pixels
ctx.translate(pan.x, pan.y);
ctx.scale(zoom, zoom);
ctx.scale(effZoom, effZoom);
ctx.drawImage(offscreen, 0, 0);
ctx.restore();
}
function screenToCanvas(clientX, clientY) {
@@ -60,7 +63,7 @@
}
function notifyBuffer() {
onBufferChange({
onBufferChange?.({
canUndo: buffer.canUndo, canRedo: buffer.canRedo, pixelCount: buffer.pixelCount,
});
}
@@ -71,10 +74,20 @@
setPixelRgba(x, y, pending >= 0 ? pending : committedColors[y * CANVAS_WIDTH + x]);
}
function totalPendingPixels() {
return buffer.pixelCount + currentStrokeKeys.size;
}
function addToStroke(x, y) {
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
const key = y * 65536 + x;
if (currentStrokeKeys.has(key)) return;
// Cap unique pending pixels at MAX_BATCH_SIZE — prevents OOM on long draws and matches server limit.
// Don't block if this coord is already in buffer (overwrite doesn't grow total).
if (totalPendingPixels() >= MAX_BATCH_SIZE && buffer.getColorAt(x, y) < 0) {
onBufferFull?.();
return;
}
currentStrokeKeys.add(key);
currentStroke.push({ x, y, color: selectedColor });
setPixelRgba(x, y, selectedColor);
@@ -89,6 +102,14 @@
notifyBuffer();
}
function cancelStroke() {
if (!currentStroke.length) return;
for (const { x, y } of currentStroke) restorePixel(x, y);
currentStroke = [];
currentStrokeKeys = new Set();
render();
}
// --- Public API (called by App.svelte) ---
export function applyUpdates(pixels) {
@@ -140,6 +161,10 @@
notifyBuffer();
}
export async function refetchCanvas() {
await loadCanvas();
}
// --- Mouse handlers ---
function handleMouseDown(e) {
@@ -205,7 +230,8 @@
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
pan.x = e.clientX - (e.clientX - pan.x) * (newZoom / zoom);
pan.y = e.clientY - (e.clientY - pan.y) * (newZoom / zoom);
onZoomChange(newZoom);
if (newZoom !== zoom) onZoomChange(newZoom);
render(newZoom); // explicit — covers the case where zoom was clamped
}
// --- Touch handlers ---
@@ -244,8 +270,13 @@
const dy = e.touches[0].clientY - lastMouse.y;
if (Math.abs(dx) > 4 || Math.abs(dy) > 4) touchMoved = true;
const pos = screenToCanvas(e.touches[0].clientX, e.touches[0].clientY);
onCursorMove({
x: Math.max(0, Math.min(pos.x, CANVAS_WIDTH - 1)),
y: Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1)),
});
if (mode === 'draw') {
const pos = screenToCanvas(e.touches[0].clientX, e.touches[0].clientY);
addToStroke(pos.x, pos.y);
lastMouse = { x: e.touches[0].clientX, y: e.touches[0].clientY };
} else {
@@ -266,7 +297,8 @@
pan.y += center.y - lastMouse.y;
lastTouchDist = dist;
lastMouse = center;
onZoomChange(newZoom);
if (newZoom !== zoom) onZoomChange(newZoom);
render(newZoom);
}
}
@@ -281,31 +313,49 @@
}
}
// Re-render when zoom changes
// Cancel any in-progress stroke on mode change to avoid merging across modes.
$effect(() => {
mode;
if (currentStroke.length) cancelStroke();
});
// Re-render when zoom changes (effect runs after parent prop update)
$effect(() => { zoom; render(); });
onMount(async () => {
function resize() {
canvasEl.width = window.innerWidth;
canvasEl.height = window.innerHeight;
render();
}
resize();
window.addEventListener('resize', resize);
async function loadCanvas() {
loading = true;
loadError = null;
try {
const res = await fetch('/api/canvas');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = await res.arrayBuffer();
const indices = decodeCanvas(buf);
committedColors = new Uint8Array(indices);
committedColors = new Uint8Array(indices); // replace pre-allocated zero array
const rgba = indicesToRgba(indices);
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
render();
} catch (err) {
console.error('Failed to load canvas:', err);
loadError = err.message || String(err);
} finally {
loading = false;
}
}
onMount(() => {
function resize() {
const dpr = window.devicePixelRatio || 1;
canvasEl.width = window.innerWidth * dpr;
canvasEl.height = window.innerHeight * dpr;
canvasEl.style.width = `${window.innerWidth}px`;
canvasEl.style.height = `${window.innerHeight}px`;
render();
}
resize();
window.addEventListener('resize', resize);
// Async load runs in background — onMount cleanup must be sync to avoid leaking listener.
loadCanvas();
return () => window.removeEventListener('resize', resize);
});
@@ -315,6 +365,12 @@
{#if loading}
<div class="loading">Loading canvas...</div>
{/if}
{#if loadError}
<div class="error">
<div>Failed to load canvas: {loadError}</div>
<button onclick={loadCanvas}>Retry</button>
</div>
{/if}
<canvas
bind:this={canvasEl}
onmousedown={handleMouseDown}
@@ -332,13 +388,29 @@
<style>
.canvas-container { width: 100%; height: 100%; }
canvas { display: block; }
.loading {
.loading, .error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 1.5rem;
color: #888;
font-size: 1.2rem;
color: #ccc;
z-index: 5;
text-align: center;
}
.error {
background: rgba(40, 0, 0, 0.92);
border: 1px solid #a33;
padding: 16px 24px;
border-radius: 8px;
}
.error button {
margin-top: 10px;
padding: 6px 14px;
background: #a33;
color: #fff;
border: 0;
border-radius: 6px;
cursor: pointer;
}
</style>
+15 -7
View File
@@ -3,8 +3,9 @@
* Uses Hibernation API so connections survive DO eviction.
*/
export class CanvasRoom {
constructor(state) {
constructor(state, env) {
this.state = state;
this.env = env;
}
async fetch(request) {
@@ -17,7 +18,8 @@ export class CanvasRoom {
for (const ws of this.state.getWebSockets()) {
try {
ws.send(message);
} catch {
} catch (err) {
console.warn('WS send failed, closing socket:', err?.message || err);
ws.close(1011, 'send failed');
}
}
@@ -33,18 +35,24 @@ export class CanvasRoom {
return new Response(null, { status: 101, webSocket: client });
}
/** Called when a WebSocket receives a message (required by Hibernation API) */
webSocketMessage(ws, message) {
// Clients don't send messages in this protocol; ignore.
/** Called when a WebSocket receives a message (required by Hibernation API).
* Clients aren't expected to send anything in this protocol; close defensively. */
webSocketMessage(ws) {
ws.close(1003, 'unexpected client message');
}
/** Called when a WebSocket is closed */
/** Called when a WebSocket is closed. */
webSocketClose(ws, code, reason, wasClean) {
if (!wasClean) {
console.warn(`WS unclean close: code=${code} reason=${reason || '<none>'}`);
}
// Required pre-2026-04-07 compat date; harmless after.
ws.close(code, reason);
}
/** Called on WebSocket error */
/** Called on WebSocket error. */
webSocketError(ws, error) {
console.error('WS error:', error?.message || error);
ws.close(1011, 'error');
}
}
+11 -6
View File
@@ -1,21 +1,26 @@
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS_RGBA } from './constants.js';
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/**
* Decode 5-bit packed canvas buffer into an array of color indices.
* Throws if buffer is shorter than the canvas size — silent zero-padding masks corruption.
* @param {ArrayBuffer} buffer - raw canvas bytes
* @returns {Uint8Array} color index per pixel
*/
export function decodeCanvas(buffer) {
if (buffer.byteLength < EXPECTED_BYTES) {
throw new Error(`Canvas buffer truncated: got ${buffer.byteLength} bytes, expected ${EXPECTED_BYTES}`);
}
const bytes = new Uint8Array(buffer);
const totalPixels = CANVAS_WIDTH * CANVAS_HEIGHT;
const indices = new Uint8Array(totalPixels);
const indices = new Uint8Array(TOTAL_PIXELS);
let bitPos = 0;
for (let i = 0; i < totalPixels; i++) {
for (let i = 0; i < TOTAL_PIXELS; i++) {
const byteIndex = bitPos >> 3;
const bitOffset = bitPos & 7;
const value =
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
const value = ((bytes[byteIndex] << 8 | bytes[byteIndex + 1]) >> (11 - bitOffset)) & 0x1f;
indices[i] = value;
bitPos += 5;
}
+1
View File
@@ -26,6 +26,7 @@ export async function getFullCanvas(env) {
}
if (bytes.length < CANVAS_BYTES) {
console.warn(`Canvas read truncated: got ${bytes.length} bytes, expected ${CANVAS_BYTES}; zero-padding tail`);
const padded = new Uint8Array(CANVAS_BYTES);
padded.set(bytes);
return padded;
+2 -1
View File
@@ -8,9 +8,10 @@ export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const MAX_BATCH_SIZE = 512;
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;
// Batch cannot exceed credits cap — anything larger is guaranteed-rejected by rate-limit.
export const MAX_BATCH_SIZE = MAX_CREDITS;
/** Redis keys */
export const REDIS_KEY_PREFIX = 'rplace:';
+18 -11
View File
@@ -1,17 +1,24 @@
/**
* Extract a user identifier from the request.
* Uses CF-Connecting-IP header (provided by Cloudflare).
* Uses CF-Connecting-IP header (provided by Cloudflare, cannot be spoofed).
* Returns SHA-256 hex (truncated to 16 chars) — collision-resistant for rate-limit buckets.
* @param {Request} request
* @returns {string} user id prefixed with "anon:"
* @returns {Promise<string>} user id prefixed with "anon:"
*/
export function getUserId(request) {
// 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;
for (let i = 0; i < ip.length; i++) {
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
export async function getUserId(request) {
const ip = request.headers.get('cf-connecting-ip');
if (!ip) {
// No CF-Connecting-IP means dev/local or misconfig; bucket all such traffic together.
console.warn('cf-connecting-ip missing — falling back to shared dev bucket');
return 'anon:dev';
}
return `anon:${(hash >>> 0).toString(36)}`;
const data = new TextEncoder().encode(ip);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const bytes = new Uint8Array(hashBuffer);
let hex = '';
for (let i = 0; i < 8; i++) {
hex += bytes[i].toString(16).padStart(2, '0');
}
return `anon:${hex}`;
}
+41 -28
View File
@@ -1,79 +1,92 @@
import { CANVAS_WIDTH } from './constants.js';
/** Pack (x,y) into a single integer key. CANVAS_WIDTH ≤ 65535 invariant. */
function keyOf(x, y) { return y * 65536 + x; }
function xOf(key) { return key % 65536; }
function yOf(key) { return Math.floor(key / 65536); }
/**
* Manages pending pixel strokes with undo/redo support.
* Pixels accumulate locally until explicit submit.
* Maintains a Map<key,color> cache of last-write-wins state across all strokes —
* O(1) getColorAt / pixelCount, rebuilt only on stroke add/undo/redo/clear.
*/
export function createPixelBuffer() {
if (CANVAS_WIDTH > 65535) {
throw new Error(`pixel-buffer key encoding requires CANVAS_WIDTH <= 65535, got ${CANVAS_WIDTH}`);
}
let strokes = [];
let undone = [];
let cache = null; // Map<key, color> | null (null = needs rebuild)
function rebuild() {
cache = new Map();
for (const stroke of strokes) {
for (const { x, y, color } of stroke) {
cache.set(keyOf(x, y), color);
}
}
}
function ensureCache() {
if (cache === null) rebuild();
return cache;
}
return {
/** Add a completed stroke (array of {x, y, color}) */
addStroke(pixels) {
if (!pixels.length) return;
strokes.push([...pixels]);
undone = [];
cache = null;
},
/** Remove last stroke and push to redo stack */
undo() {
if (!strokes.length) return null;
const stroke = strokes.pop();
undone.push(stroke);
cache = null;
return stroke;
},
/** Re-apply last undone stroke */
redo() {
if (!undone.length) return null;
const stroke = undone.pop();
strokes.push(stroke);
cache = null;
return stroke;
},
/** Clear all pending strokes and redo history */
clear() {
strokes = [];
undone = [];
cache = null;
},
/** Deduplicated pending pixels (last stroke wins per coord) */
getAllPixels() {
const map = new Map();
for (const stroke of strokes) {
for (const { x, y, color } of stroke) {
map.set(y * 65536 + x, { x, y, color });
}
const map = ensureCache();
const out = [];
for (const [key, color] of map) {
out.push({ x: xOf(key), y: yOf(key), color });
}
return [...map.values()];
return out;
},
/** Get pending color at (x,y), or -1 if not pending */
getColorAt(x, y) {
for (let i = strokes.length - 1; i >= 0; i--) {
for (const p of strokes[i]) {
if (p.x === x && p.y === y) return p.color;
}
}
return -1;
const c = ensureCache().get(keyOf(x, y));
return c === undefined ? -1 : c;
},
/** Set of all affected coordinate keys (y*65536+x) */
getAffectedKeys() {
const set = new Set();
for (const stroke of strokes) {
for (const { x, y } of stroke) set.add(y * 65536 + x);
}
return set;
return new Set(ensureCache().keys());
},
get canUndo() { return strokes.length > 0; },
get canRedo() { return undone.length > 0; },
get pixelCount() {
const set = new Set();
for (const stroke of strokes) {
for (const { x, y } of stroke) set.add(y * 65536 + x);
}
return set.size;
},
get pixelCount() { return ensureCache().size; },
};
}
+30 -11
View File
@@ -3,13 +3,19 @@ import { MAX_CREDITS, CREDIT_REGEN_RATE, REDIS_KEY_PREFIX } from './constants.js
/**
* Lua script for atomic check-and-deduct of stackable credits.
* Returns: [allowed (0/1), remaining, retryAfter]
* Stores lastUpdate as ms (avoids fractional credit loss across calls).
* Returns: [allowed (0/1), remaining, retryAfterSeconds]
*/
const CREDIT_SCRIPT = `
local data = redis.call('HGETALL', KEYS[1])
local lastUpdate = 0
local credits = tonumber(ARGV[3])
local now = tonumber(ARGV[2])
local maxCredits = tonumber(ARGV[3])
local regen = tonumber(ARGV[4])
local count = tonumber(ARGV[1])
local lastUpdate = now
local credits = maxCredits
local data = redis.call('HGETALL', KEYS[1])
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
@@ -17,16 +23,28 @@ if #data > 0 then
end
end
local elapsed = tonumber(ARGV[2]) - lastUpdate
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
local count = tonumber(ARGV[1])
local elapsedMs = now - lastUpdate
local msPerCredit = 1000 / regen
local accruedDelta = math.floor(elapsedMs / msPerCredit)
local accrued = math.min(maxCredits, credits + accruedDelta)
if accrued < count then
return {0, accrued, count - accrued}
local deficit = count - accrued
local retryAfter = math.ceil(deficit * msPerCredit / 1000)
return {0, accrued, retryAfter}
end
-- Advance lastUpdate by exact ms used to accrue credits (preserves fractional residue).
-- When capped at maxCredits, discard residue (else lu drifts arbitrarily far back).
local newLastUpdate
if credits + accruedDelta > maxCredits then
newLastUpdate = now
else
newLastUpdate = lastUpdate + math.floor(accruedDelta * msPerCredit)
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lu', ARGV[2], 'cr', remaining)
redis.call('HSET', KEYS[1], 'lu', newLastUpdate, 'cr', remaining)
redis.call('EXPIRE', KEYS[1], 86400)
return {1, remaining, 0}
`;
@@ -37,16 +55,17 @@ return {1, remaining, 0}
* @param {string} userId
* @param {number} count
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
* retryAfter is in seconds.
*/
export async function checkAndDeductCredits(env, userId, count) {
const redis = getRedis(env);
const now = Math.floor(Date.now() / 1000);
const nowMs = Date.now();
const key = `${REDIS_KEY_PREFIX}credits:${userId}`;
const result = await redis.eval(
CREDIT_SCRIPT,
[key],
[count, now, MAX_CREDITS, CREDIT_REGEN_RATE],
[count, nowMs, MAX_CREDITS, CREDIT_REGEN_RATE],
);
return {
+14 -5
View File
@@ -17,6 +17,7 @@ export function getRedis(env) {
* Useful for commands where the SDK API is unreliable (e.g., BITFIELD).
* @param {object} env
* @param {string[]} command - Redis command as array, e.g. ['BITFIELD', 'key', 'SET', ...]
* @returns {Promise<*>} the `result` field from the Upstash response
*/
export async function redisRaw(env, command) {
const res = await fetch(env.UPSTASH_REDIS_REST_URL, {
@@ -29,9 +30,14 @@ export async function redisRaw(env, command) {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Redis command failed: ${res.status} ${text}`);
throw new Error(`Redis HTTP ${res.status}: ${text}`);
}
return res.json();
// Upstash returns 200 with {"error":"..."} for application errors.
const body = await res.json();
if (body && body.error) {
throw new Error(`Redis error: ${body.error}`);
}
return body.result;
}
/**
@@ -51,8 +57,11 @@ export async function redisRawBinary(env, command) {
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Redis command failed: ${res.status} ${text}`);
throw new Error(`Redis HTTP ${res.status}: ${text}`);
}
const { result } = await res.json();
return result; // base64-encoded string
const body = await res.json();
if (body && body.error) {
throw new Error(`Redis error: ${body.error}`);
}
return body.result; // base64-encoded string
}
+51 -18
View File
@@ -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');
+22 -15
View File
@@ -1,16 +1,18 @@
import { describe, it, expect } from 'vitest';
import { decodeCanvas, indicesToRgba } from '../../src/lib/canvas-decoder.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, BITS_PER_PIXEL, COLORS, COLORS_RGBA } from '../../src/lib/constants.js';
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage) */
function encodeIndices(indices) {
const totalBits = indices.length * 5;
const bytes = new Uint8Array(Math.ceil(totalBits / 8));
const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
const EXPECTED_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/** Encode color indices into 5-bit packed bytes (test helper, mirrors BITFIELD storage).
* Returns a full-canvas-sized buffer (zero-padded tail) so decodeCanvas accepts it. */
function encodeIndicesPadded(indices) {
const bytes = new Uint8Array(EXPECTED_BYTES);
for (let i = 0; i < indices.length; i++) {
const bitPos = i * 5;
const byteIndex = bitPos >> 3;
const bitOffset = bitPos & 7;
// Write 5-bit value across 1-2 bytes
bytes[byteIndex] |= (indices[i] << (11 - bitOffset)) >> 8;
if (bitOffset > 3) {
bytes[byteIndex + 1] |= (indices[i] << (11 - bitOffset)) & 0xff;
@@ -22,17 +24,22 @@ function encodeIndices(indices) {
}
describe('decodeCanvas', () => {
it('decodes empty buffer as all zeros', () => {
const buffer = new ArrayBuffer(0);
it('decodes a full zero-filled buffer as all zeros', () => {
const buffer = new ArrayBuffer(EXPECTED_BYTES);
const indices = decodeCanvas(buffer);
expect(indices.length).toBe(CANVAS_WIDTH * CANVAS_HEIGHT);
expect(indices.length).toBe(TOTAL_PIXELS);
expect(indices.every((v) => v === 0)).toBe(true);
});
it('throws on truncated buffer', () => {
expect(() => decodeCanvas(new ArrayBuffer(0))).toThrow(/truncated/);
expect(() => decodeCanvas(new ArrayBuffer(EXPECTED_BYTES - 1))).toThrow(/truncated/);
});
it('decodes a single pixel', () => {
// Color 15 at pixel 0: binary 01111 in first 5 bits
// Byte 0: 0111_1000 = 0x78
const bytes = new Uint8Array([0x78, 0]);
// Color 15 at pixel 0: binary 01111 in first 5 bits → byte 0 = 0111_1000 = 0x78
const bytes = new Uint8Array(EXPECTED_BYTES);
bytes[0] = 0x78;
const indices = decodeCanvas(bytes.buffer);
expect(indices[0]).toBe(15);
});
@@ -40,7 +47,7 @@ describe('decodeCanvas', () => {
it('round-trips all 32 color values', () => {
const input = new Uint8Array(32);
for (let i = 0; i < 32; i++) input[i] = i;
const encoded = encodeIndices(input);
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 32; i++) {
expect(decoded[i]).toBe(i);
@@ -50,7 +57,7 @@ describe('decodeCanvas', () => {
it('round-trips repeated color patterns', () => {
const input = new Uint8Array(100);
for (let i = 0; i < 100; i++) input[i] = i % 32;
const encoded = encodeIndices(input);
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 100; i++) {
expect(decoded[i]).toBe(i % 32);
@@ -59,7 +66,7 @@ describe('decodeCanvas', () => {
it('handles max color value (31) at various offsets', () => {
const input = new Uint8Array(8).fill(31);
const encoded = encodeIndices(input);
const encoded = encodeIndicesPadded(input);
const decoded = decodeCanvas(encoded.buffer);
for (let i = 0; i < 8; i++) {
expect(decoded[i]).toBe(31);
+19 -14
View File
@@ -9,27 +9,32 @@ function mockRequest(headers = {}) {
}
describe('getUserId', () => {
it('returns anon: prefix', () => {
const id = getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
it('returns anon: prefix', async () => {
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '1.2.3.4' }));
expect(id).toMatch(/^anon:/);
});
it('returns deterministic ID for same IP', () => {
const req1 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
const req2 = mockRequest({ 'cf-connecting-ip': '192.168.1.1' });
expect(getUserId(req1)).toBe(getUserId(req2));
it('returns deterministic ID for same IP', async () => {
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '192.168.1.1' }));
expect(id1).toBe(id2);
});
it('returns different IDs for different IPs', () => {
const id1 = getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
const id2 = getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
it('returns different IDs for different IPs', async () => {
const id1 = await getUserId(mockRequest({ 'cf-connecting-ip': '1.1.1.1' }));
const id2 = await getUserId(mockRequest({ 'cf-connecting-ip': '2.2.2.2' }));
expect(id1).not.toBe(id2);
});
it('falls back to 127.0.0.1 when header is missing', () => {
const id = getUserId(mockRequest({}));
expect(id).toMatch(/^anon:/);
// Should be deterministic for missing header too
expect(getUserId(mockRequest({}))).toBe(id);
it('falls back to a shared dev bucket when header is missing', async () => {
const id = await getUserId(mockRequest({}));
expect(id).toBe('anon:dev');
// Deterministic for missing header
expect(await getUserId(mockRequest({}))).toBe(id);
});
it('uses 16-hex-char (8-byte) suffix from SHA-256', async () => {
const id = await getUserId(mockRequest({ 'cf-connecting-ip': '203.0.113.45' }));
expect(id).toMatch(/^anon:[0-9a-f]{16}$/);
});
});
+27 -4
View File
@@ -32,14 +32,30 @@ describe('redisRaw', () => {
});
});
it('throws on non-ok response', async () => {
it('returns the response result field', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ result: 'PONG' }),
});
const result = await redisRaw(env, ['PING']);
expect(result).toBe('PONG');
});
it('throws on non-ok HTTP response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 401,
text: () => Promise.resolve('Unauthorized'),
});
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis HTTP 401');
});
await expect(redisRaw(env, ['PING'])).rejects.toThrow('Redis command failed: 401');
it('throws on Upstash 200 with error envelope', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ error: 'ERR wrong number of arguments' }),
});
await expect(redisRaw(env, ['BITFIELD'])).rejects.toThrow(/Redis error.*wrong number of arguments/);
});
});
@@ -82,13 +98,20 @@ describe('redisRawBinary', () => {
expect(result).toBe('AQID');
});
it('throws on non-ok response', async () => {
it('throws on non-ok HTTP response', async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 500,
text: () => Promise.resolve('Internal Error'),
});
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis HTTP 500');
});
await expect(redisRawBinary(env, ['GET', 'key'])).rejects.toThrow('Redis command failed: 500');
it('throws on Upstash 200 with error envelope', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve({ error: 'ERR no such key' }),
});
await expect(redisRawBinary(env, ['GET', 'missing'])).rejects.toThrow(/Redis error.*no such key/);
});
});