tiennm99 b890dfb3b7 fix(canvas): code-review fixes + sync user-facing docs to DO storage
Fixes from code review of canvas-on-do migration (commit c3f7c02):

- worker.js /api/ws: rewrite request URL to '/ws' so the DO pathname
  switch dispatches correctly. The original c.req.raw kept '/api/ws'
  which the DO never matched → 404 on every WS upgrade.
- migrate-from-upstash.js pickSampleOffsets: use TOTAL_PIXELS - 1 for the
  last byte instead of CANVAS_WIDTH * CANVAS_WIDTH (only correct when
  the canvas is square; constants explicitly invite non-square).
- chunk-storage.js writePixels: clarify atomicity comment — the loop is
  atomic *because it has no awaits*, not because of any implicit DO
  transaction. Added guidance for future maintainers.
- cooldown-store.js tryAcquire: GC sweep wrapped in try/catch so a
  transient failure can't drop the user's allowed: true response.

Docs:
- README.md: drop Upstash from tech stack, redraw architecture,
  document new project layout (durable-objects/lib, admin/), add
  CHUNK_BYTES to configuration table.
- docs/system-architecture.md: full rewrite for DO-storage data flow,
  document SQLite schema, race-safe rate-limit pattern, free-tier table.
- docs/deployment-guide.md: drop Upstash setup, add optional one-shot
  migration runbook, update free-tier table to actual May 2026 limits.

Tests: 112 pass, 6 skipped (pending Phase 4 rewrite via
@cloudflare/vitest-pool-workers). Bundle dry-run clean.

Local wrangler dev smoke test was attempted but the sandboxed env
hangs HTTP requests at the workerd layer (TCP connects, no response).
Routing fix verified by code inspection; user must verify in their
own dev or production.
2026-05-10 00:37:27 +07:00
2026-04-16 16:27:35 +07:00
2026-04-16 14:36:24 +07:00

rplace

A collaborative pixel art canvas inspired by Reddit's r/place. Place pixels, create art together in real-time.

Features

  • 4096×4096 canvas with a 256-color palette (16-step grayscale + 240-hue HSL wheel)
  • Real-time updates via WebSocket (Cloudflare Durable Objects)
  • Batch pixel placement up to 2048 pixels per request
  • Rate limit — 1 request per second per user (batch size independent)
  • Zoom/pan with mouse wheel + drag (desktop) and pinch-zoom + drag (mobile)
  • Long-press to place on touch devices
  • Image importer — upload, dither, and auto-paint images onto the canvas

Tech Stack

Layer Technology
Frontend Svelte 5 (runes) + HTML5 Canvas
Backend Hono on Cloudflare Workers
Real-time WebSocket via Cloudflare Durable Objects (Hibernation API)
Storage Durable Object SQLite — chunked BLOB rows for canvas, TTL rows for cooldowns
Build Vite

Architecture

Browser (Svelte SPA + WebSocket)
  |  GET  /api/canvas   → full canvas binary (16 MB, edge-cached 10s)
  |  POST /api/place    → batch pixel placement (validated at edge)
  |  WS   /api/ws       → CanvasRoom Durable Object broadcast
  v
Cloudflare Worker (Hono — thin proxy)
  └─▶ CanvasRoom Durable Object  (single instance, idFromName('main'))
        ├── canvas_chunks   SQLite BLOB rows × 256 (64 KB each = 16 MB)
        ├── cooldowns       SQLite TTL rows (1s rate-limit, lazy GC)
        └── WebSocket hub   Hibernation API broadcasts pixel deltas

CHUNK_COUNT = ceil(CANVAS_WIDTH × CANVAS_HEIGHT / CHUNK_BYTES) — bumping canvas dimensions in src/lib/constants.js and redeploying lazy-allocates new chunks on first read. See docs/canvas-resize-procedure.md.

Getting Started

Prerequisites

Setup

git clone <repo-url>
cd rplace
npm install

No external storage to configure. The canvas and rate-limit state live inside the Durable Object.

Development

# Run worker locally (serves API + static frontend)
npm run dev

# Or split frontend + worker
npm run dev:client   # Vite dev server on :5173 (proxies /api to :8787)
npm run dev          # Wrangler dev server on :8787

Deploy

npm run deploy   # Builds frontend + deploys worker to Cloudflare

Project Structure

src/
├── worker.js                          # Hono entry — thin proxy + edge validation
├── admin/
│   └── migrate-from-upstash.js        # One-shot Upstash → DO importer (token-gated)
├── durable-objects/
│   ├── canvas-room.js                 # DO: storage + cooldown + WS hub
│   └── lib/
│       ├── schema.js                  # Idempotent CREATE TABLE
│       ├── chunk-storage.js           # BLOB chunk read/write/import
│       └── cooldown-store.js          # Rate-limit acquire + lazy GC
├── lib/
│   ├── constants.js                   # CANVAS_WIDTH/HEIGHT, CHUNK_BYTES, palette
│   ├── canvas-decoder.js              # Raw bytes → RGBA (client-side)
│   ├── canvas-storage.js              # Legacy Upstash reader (used by migration only)
│   ├── redis-client.js                # Legacy Upstash REST helpers (migration only)
│   ├── rate-limiter.js                # Legacy Upstash cooldown (orphaned, awaits removal)
│   ├── image-uploader.js              # Browser-side batched uploader
│   └── get-user-id.js                 # IP-based identity
├── client/
│   ├── main.js                        # Svelte mount
│   ├── App.svelte                     # Root + WebSocket
│   ├── app.css                        # Global styles
│   └── components/
│       ├── CanvasRenderer.svelte      # Canvas + zoom/pan + touch
│       ├── ColorPicker.svelte         # Favorites + 256-color grid + custom picker
│       ├── CanvasControls.svelte      # Zoom buttons + coordinates
│       ├── DrawToolbar.svelte         # Paint / submit / undo / redo
│       └── ImageImporter.svelte       # Image-to-canvas uploader
└── index.html                         # Vite entry

API

GET /api/canvas

Returns the full canvas as raw binary (1 byte per pixel, 16 MB — Cloudflare gzips it on the edge). Cached for 10s at the edge.

POST /api/place

Place pixels on the canvas.

{
  "pixels": [
    { "x": 100, "y": 200, "color": 27 }
  ]
}

Response: { "ok": true }

Errors:

  • 400 — invalid pixel data or batch > 2048
  • 413 — request body too large
  • 429 — rate limited (includes retryAfter seconds)

WS /api/ws

WebSocket for real-time pixel updates. Messages are JSON:

{ "type": "pixels", "pixels": [{ "x": 100, "y": 200, "color": 27 }] }

POST /admin/migrate-from-upstash (transitional)

Token-gated one-shot endpoint that pulls the canvas from a legacy Upstash Redis instance and imports it into the Durable Object. Slated for removal after the production migration completes (Phase 4 of plans/260509-2309-canvas-on-do-storage).

Configuration

Key constants in src/lib/constants.js:

Constant Default Description
CANVAS_WIDTH 4096 Canvas width in pixels
CANVAS_HEIGHT 4096 Canvas height in pixels
MAX_COLORS 256 Number of palette entries
MAX_BATCH_SIZE 2048 Max pixels per placement request
REQUEST_COOLDOWN_SEC 1 Minimum seconds between requests per user
CHUNK_BYTES 65536 Bytes per SQLite BLOB chunk (must stay ≤ 2 MB CF DO row cap)
CHUNK_COUNT derived ceil(TOTAL_PIXELS / CHUNK_BYTES) — auto-recomputed on resize

Credits & References

License

MIT

S
Description
Collaborative pixel-art canvas (r/place clone). Svelte 5 + Hono on Cloudflare Workers + Durable Object SQLite.
Readme Apache-2.0
1 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 61.1%
Svelte 38.6%
HTML 0.2%
CSS 0.1%