mirror of
https://github.com/tiennm99/rplace.git
synced 2026-05-28 14:22:23 +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:
+69
-8
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user