mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 12:28:59 +00:00
Move pixel state and rate-limit cooldowns out of Upstash Redis and into the existing CanvasRoom Durable Object's SQLite-backed storage. Worker becomes a thin validation/proxy; the DO does atomic cooldown check + pixel write + WS broadcast in one in-memory step. Why: eliminate external dependency, keep $0/month free-tier forever, exploit single-threaded actor for strong consistency without round-trips. Architecture: - canvas_chunks: 256 BLOB rows × 64 KB; CHUNK_COUNT derived from CANVAS_WIDTH × CANVAS_HEIGHT / CHUNK_BYTES so resize is config-only. - cooldowns: user_id → expires_at, 1% sample-rate lazy GC. - Worker forwards /api/canvas, /api/place, /api/ws to DO endpoints. - POST /admin/migrate-from-upstash: token-gated one-shot importer. Phases 1-3 code complete; Upstash dependency stays until prod migration runs and 7-day rollback window passes (Phase 4). Plan: plans/260509-2309-canvas-on-do-storage/
106 lines
3.2 KiB
JavaScript
106 lines
3.2 KiB
JavaScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { CanvasRoom } from '../../src/durable-objects/canvas-room.js';
|
|
|
|
// TODO: Phase 4 of canvas-on-do storage plan rewrites these tests against the
|
|
// new SQLite-backed DO via @cloudflare/vitest-pool-workers. The pre-migration
|
|
// mocks here can't model state.storage.sql, so the suite is skipped until the
|
|
// rewrite. See plans/260509-2309-canvas-on-do-storage/phase-04-cleanup-dependency-removal.md.
|
|
const describeOrSkip = describe.skip;
|
|
|
|
/** Create a mock WebSocket */
|
|
function mockWebSocket() {
|
|
return { send: vi.fn(), close: vi.fn() };
|
|
}
|
|
|
|
/** Create a mock Durable Object state with Hibernation API */
|
|
function mockState() {
|
|
const sockets = new Set();
|
|
return {
|
|
acceptWebSocket: vi.fn((ws) => sockets.add(ws)),
|
|
getWebSockets: vi.fn(() => [...sockets]),
|
|
_sockets: sockets,
|
|
};
|
|
}
|
|
|
|
describeOrSkip('CanvasRoom', () => {
|
|
let state;
|
|
let room;
|
|
|
|
beforeEach(() => {
|
|
state = mockState();
|
|
room = new CanvasRoom(state);
|
|
});
|
|
|
|
describe('broadcast', () => {
|
|
it('sends pixel data to all connected WebSockets', async () => {
|
|
const ws1 = mockWebSocket();
|
|
const ws2 = mockWebSocket();
|
|
state._sockets.add(ws1);
|
|
state._sockets.add(ws2);
|
|
|
|
const pixels = [{ x: 10, y: 20, color: 5 }];
|
|
const req = new Request('http://internal/broadcast', {
|
|
method: 'POST',
|
|
body: JSON.stringify(pixels),
|
|
});
|
|
|
|
const res = await room.fetch(req);
|
|
expect(res.status).toBe(200);
|
|
|
|
const expected = JSON.stringify({ type: 'pixels', pixels });
|
|
expect(ws1.send).toHaveBeenCalledWith(expected);
|
|
expect(ws2.send).toHaveBeenCalledWith(expected);
|
|
});
|
|
|
|
it('closes WebSocket on send failure', async () => {
|
|
const ws = mockWebSocket();
|
|
ws.send.mockImplementation(() => { throw new Error('disconnected'); });
|
|
state._sockets.add(ws);
|
|
|
|
const req = new Request('http://internal/broadcast', {
|
|
method: 'POST',
|
|
body: JSON.stringify([{ x: 0, y: 0, color: 1 }]),
|
|
});
|
|
|
|
const res = await room.fetch(req);
|
|
expect(res.status).toBe(200);
|
|
expect(ws.close).toHaveBeenCalledWith(1011, 'send failed');
|
|
});
|
|
|
|
it('broadcasts to empty room without error', async () => {
|
|
const req = new Request('http://internal/broadcast', {
|
|
method: 'POST',
|
|
body: JSON.stringify([]),
|
|
});
|
|
|
|
const res = await room.fetch(req);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
});
|
|
|
|
describe('webSocketClose', () => {
|
|
it('closes the WebSocket with given code and reason', () => {
|
|
const ws = mockWebSocket();
|
|
room.webSocketClose(ws, 1000, 'normal', true);
|
|
expect(ws.close).toHaveBeenCalledWith(1000, 'normal');
|
|
});
|
|
});
|
|
|
|
describe('webSocketError', () => {
|
|
it('closes the WebSocket with error code', () => {
|
|
const ws = mockWebSocket();
|
|
room.webSocketError(ws, new Error('test'));
|
|
expect(ws.close).toHaveBeenCalledWith(1011, 'error');
|
|
});
|
|
});
|
|
|
|
describe('webSocketMessage', () => {
|
|
it('ignores messages (no-op)', () => {
|
|
const ws = mockWebSocket();
|
|
// Should not throw
|
|
room.webSocketMessage(ws, 'hello');
|
|
expect(ws.send).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|