fix: binary-safe canvas read via Upstash base64 encoding

Use path-based REST API with Upstash-Encoding: base64 header for
GETRANGE to prevent binary data corruption through JSON text encoding.
Verified all 32 colors round-trip correctly.
This commit is contained in:
tiennm99 committed 2026-04-16 22:19:26 +07:00
1 parent e3eb34c6de
commit b35769cc73
2 files changed
+39 -29

No files matched your search

+16 -29
View File
@@ -1,4 +1,4 @@
import { getRedis, redisRaw } from './redis-client.js';
import { redisRaw, redisRawBinary } from './redis-client.js';
import { CANVAS_WIDTH, TOTAL_PIXELS, BITS_PER_PIXEL, REDIS_CANVAS_KEY } from './constants.js';
/** Total bytes needed for the canvas bitfield */
@@ -6,55 +6,42 @@ const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
/**
* Get the full canvas as a Uint8Array of raw bytes.
* Uses GETRANGE to fetch raw binary reliably (avoids encoding issues with GET).
* Uses raw REST API to avoid SDK binary encoding corruption.
* Returns a zero-filled buffer if canvas doesn't exist yet.
* @param {object} env
* @returns {Promise<Uint8Array>}
*/
export async function getFullCanvas(env) {
const redis = getRedis(env);
// Use base64 encoding for binary-safe transport of BITFIELD data
const base64 = await redisRawBinary(env, ['GETRANGE', REDIS_CANVAS_KEY, '0', String(CANVAS_BYTES - 1)]);
// 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) {
if (!base64 || base64.length === 0) {
return new Uint8Array(CANVAS_BYTES);
}
// Upstash REST may return base64 or raw string for binary data
if (typeof data === 'string') {
let raw;
try {
raw = atob(data);
} catch {
raw = data; // Already a raw string
}
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.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;
const raw = atob(base64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
return new Uint8Array(CANVAS_BYTES);
if (bytes.length < CANVAS_BYTES) {
const padded = new Uint8Array(CANVAS_BYTES);
padded.set(bytes);
return padded;
}
return bytes;
}
/**
* Set multiple pixels in a single atomic BITFIELD command.
* Uses Upstash's builder pattern: redis.bitfield(key).set().set().exec()
* Uses raw REST API — SDK bitfield builder is broken in @upstash/redis 1.x.
* @param {object} env
* @param {Array<{x: number, y: number, color: number}>} pixels
*/
export async function setPixels(env, pixels) {
if (!pixels.length) return;
// Use raw REST API for BITFIELD — SDK builder API is broken in @upstash/redis 1.x
const command = ['BITFIELD', REDIS_CANVAS_KEY];
for (const { x, y, color } of pixels) {
const offset = y * CANVAS_WIDTH + x;
+23
View File
@@ -33,3 +33,26 @@ export async function redisRaw(env, command) {
}
return res.json();
}
/**
* Execute a Redis command via Upstash path-based REST API with base64 response.
* Uses Upstash-Encoding: base64 for binary-safe response transport.
* @param {object} env
* @param {string[]} command - Redis command as array, e.g. ['GETRANGE', 'key', '0', '100']
* @returns {Promise<string|null>} base64-encoded result string
*/
export async function redisRawBinary(env, command) {
const path = command.map((arg) => encodeURIComponent(String(arg))).join('/');
const res = await fetch(`${env.UPSTASH_REDIS_REST_URL}/${path}`, {
headers: {
Authorization: `Bearer ${env.UPSTASH_REDIS_REST_TOKEN}`,
'Upstash-Encoding': 'base64',
},
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Redis command failed: ${res.status} ${text}`);
}
const { result } = await res.json();
return result; // base64-encoded string
}