fix: critical Upstash API and UX issues from second review

- Fix setPixels() to use Upstash bitfield builder (.set().exec())
  instead of flat array (would throw at runtime)
- Fix getFullCanvas() to use GETRANGE for reliable binary retrieval
  instead of GET which may mangle BITFIELD data
- Fix getUserId hash overflow: use >>> 0 instead of Math.abs()
- Add client-side credit regeneration timer (1/sec)
- Add exponential backoff to WebSocket reconnection (1s → 30s cap)
This commit is contained in:
tiennm99 committed 2026-04-16 16:37:07 +07:00
1 parent b3b4916263
commit 7793163e0b
3 files changed
+43 -15

No files matched your search

+19 -3
View File
@@ -1,5 +1,5 @@
<script> <script>
import { MAX_CREDITS } from '../lib/constants.js'; import { MAX_CREDITS, CREDIT_REGEN_RATE } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte'; import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte'; import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte'; import CanvasControls from './components/CanvasControls.svelte';
@@ -13,7 +13,19 @@
/** @type {CanvasRenderer} */ /** @type {CanvasRenderer} */
let canvasRenderer; let canvasRenderer;
// WebSocket connection with auto-reconnect // Client-side credit regeneration (server corrects on placement)
$effect(() => {
const interval = setInterval(() => {
if (credits < MAX_CREDITS) {
credits = Math.min(credits + CREDIT_REGEN_RATE, MAX_CREDITS);
}
}, 1000);
return () => clearInterval(interval);
});
// WebSocket connection with auto-reconnect + exponential backoff
let wsRetryDelay = 1000;
function connectWebSocket() { function connectWebSocket() {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`); const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
@@ -27,7 +39,11 @@
} catch { /* ignore parse errors */ } } catch { /* ignore parse errors */ }
}; };
ws.onclose = () => setTimeout(connectWebSocket, 1000); ws.onopen = () => { wsRetryDelay = 1000; };
ws.onclose = () => {
setTimeout(connectWebSocket, wsRetryDelay);
wsRetryDelay = Math.min(wsRetryDelay * 2, 30000);
};
ws.onerror = () => ws.close(); ws.onerror = () => ws.close();
return ws; return ws;
+23 -11
View File
@@ -6,29 +6,42 @@ const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/** /**
* Get the full canvas as a Uint8Array of raw bytes. * Get the full canvas as a Uint8Array of raw bytes.
* Uses GETRANGE to fetch raw binary reliably (avoids encoding issues with GET).
* Returns a zero-filled buffer if canvas doesn't exist yet. * Returns a zero-filled buffer if canvas doesn't exist yet.
* @param {object} env * @param {object} env
* @returns {Promise<Uint8Array>} * @returns {Promise<Uint8Array>}
*/ */
export async function getFullCanvas(env) { export async function getFullCanvas(env) {
const redis = getRedis(env); const redis = getRedis(env);
const data = await redis.get(REDIS_CANVAS_KEY);
if (!data) { // Use GETRANGE to fetch the full string as raw bytes
// This is more reliable than GET for binary data written by BITFIELD
const data = await redis.getrange(REDIS_CANVAS_KEY, 0, CANVAS_BYTES - 1);
if (!data || data.length === 0) {
return new Uint8Array(CANVAS_BYTES); return new Uint8Array(CANVAS_BYTES);
} }
// Upstash REST returns string — convert to bytes
if (typeof data === 'string') { if (typeof data === 'string') {
const binary = atob(data); const bytes = new Uint8Array(data.length);
const bytes = new Uint8Array(binary.length); for (let i = 0; i < data.length; i++) {
for (let i = 0; i < binary.length; i++) { bytes[i] = data.charCodeAt(i);
bytes[i] = binary.charCodeAt(i); }
// Pad to full canvas size if shorter
if (bytes.length < CANVAS_BYTES) {
const padded = new Uint8Array(CANVAS_BYTES);
padded.set(bytes);
return padded;
} }
return bytes; return bytes;
} }
return new Uint8Array(data);
return new Uint8Array(CANVAS_BYTES);
} }
/** /**
* Set multiple pixels in a single atomic BITFIELD command. * Set multiple pixels in a single atomic BITFIELD command.
* Uses Upstash's builder pattern: redis.bitfield(key).set().set().exec()
* @param {object} env * @param {object} env
* @param {Array<{x: number, y: number, color: number}>} pixels * @param {Array<{x: number, y: number, color: number}>} pixels
*/ */
@@ -36,11 +49,10 @@ export async function setPixels(env, pixels) {
if (!pixels.length) return; if (!pixels.length) return;
const redis = getRedis(env); const redis = getRedis(env);
const commands = []; let chain = redis.bitfield(REDIS_CANVAS_KEY);
for (const { x, y, color } of pixels) { for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x; const offset = y * CANVAS_WIDTH + x;
commands.push('SET', 'u5', `#${offset}`, color); chain = chain.set('u5', `#${offset}`, color);
} }
await chain.exec();
await redis.bitfield(REDIS_CANVAS_KEY, commands);
} }
+1 -1
View File
@@ -13,5 +13,5 @@ export function getUserId(request) {
for (let i = 0; i < ip.length; i++) { for (let i = 0; i < ip.length; i++) {
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0; hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
} }
return `anon:${Math.abs(hash).toString(36)}`; return `anon:${(hash >>> 0).toString(36)}`;
} }