Commit Graph
12 Commits
Author SHA1 Message Date
tiennm99 4f02d30393 feat(canvas): WS hardening, client race fix, and ping/pong heartbeat
Server:
- Origin allowlist on /api/ws (env.ALLOWED_ORIGINS, comma-separated; empty
  = allow all for dev/preview)
- per-identity WS connection cap (MAX_WS_PER_IDENTITY = 5) using
  acceptWebSocket(socket, [identity]) tagging; 6th upgrade returns 429
- ws.send 'ping' triggers a {type:'pong'} reply so dead connections fire
  onclose promptly instead of waiting on TCP keepalive

Client:
- buffer WS pixels arriving during the initial canvas fetch and replay
  them after committedColors is replaced; previously the post-fetch
  Uint8Array assignment silently overwrote any pixels broadcast in the
  fetch window (the documented C2 race)
- 30s ping / 60s pong watchdog closes the socket if pong stops arriving,
  routing through the existing exponential-backoff reconnect path

Tests:
- four /api/ws cases: missing upgrade, disallowed origin, allowed origin,
  empty allowlist (dev default). Sentinel uses status 200 because undici
  rejects 101 in Node-side Response constructors.

Plan: plans/260510-0232-fix-do-migration-followups/phase-03-ws-hardening-client-race.md
2026-05-10 03:05:56 +07:00
tiennm99 42d1ca19ee feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers
- resolveIdentity prefers an opaque rplace_id cookie; falls back to a
  cf-connecting-ip hash; in production a request with neither now returns
  500 no_identity instead of bucketing all such traffic together
- /api/canvas issues Set-Cookie when no cookie is present so subsequent
  requests escape NAT-shared IP buckets (mobile/CGNAT users)
- DO maintains an in-memory monotonic broadcast counter; broadcast frames
  carry { seq } so the client can detect missed pixels and refetch
- client tracks lastSeq, refetches on gap, resets on every (re)connect

NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With
cookie identity they each get their own bucket. Cookie is HttpOnly, Secure,
SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP.

The seq counter resets on DO hibernation rehydrate; client always refetches
on reconnect, so a reset is indistinguishable from a fresh connect.

Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md
2026-05-10 03:00:39 +07:00
tiennm99 3c1263add6 fix(canvas): make multi-chunk pixel writes atomic and refund cooldown on failure
- wrap writePixels in state.storage.transactionSync so a partial multi-chunk
  failure doesn't leave the canvas half-written or out of sync with the WS
  broadcast
- size new chunk buffer against chunkSize(chunkId) instead of the persisted
  blob's length so writes after a canvas-grow no longer silently drop OOB
  bytes in the formerly-last short chunk
- refund the cooldown row when writePixels throws so transient storage
  errors stop soft-DOSing the user (and halving image-uploader throughput)
- bound readAllChunks by chunk_id < CHUNK_COUNT and trim oversized blobs so
  orphan rows from a future shrink no longer crash GET /api/canvas
- require a positive Content-Length on /api/place (411) and reject above the
  pre-parse cap (413); previously a missing or zero header bypassed the cap
- drop String(err) from the 500 response body
- drain the INSERT cursor symmetrically with the UPDATE branch in tryAcquire
- assert CHUNK_BYTES <= 2 MB at module load (DO SQLite per-cell BLOB cap)
- correct the inverted webSocketClose comment and guard the re-close call
- add tests for missing / zero / oversized Content-Length

Plan: plans/260510-0232-fix-do-migration-followups/phase-01-do-storage-atomicity.md
2026-05-10 02:57:30 +07:00
tiennm99 a977adc62d refactor(canvas): drop Upstash entirely after successful DO migration
Production migration ran cleanly (samples_checked: 9, mismatches: []),
canvas data preserved, drawing verified by user. Phase 4 cleanup:

Removed code:
- src/lib/canvas-storage.js (Upstash GETRANGE reader)
- src/lib/redis-client.js   (Upstash REST helpers)
- src/lib/rate-limiter.js   (orphan since Phase 2)
- src/admin/migrate-from-upstash.js (one-shot importer)
- src/durable-objects/canvas-room.js #handleImport route
- src/durable-objects/lib/chunk-storage.js importFullCanvas helper
- src/worker.js /admin/migrate-from-upstash mount + import

Removed tests:
- test/lib/canvas-storage.test.js
- test/lib/redis-client.test.js
- test/integration/redis-canvas-roundtrip.test.js
- test/durable-objects/canvas-room.test.js (was skipped pending rewrite)
- vitest.integration.config.js (only Redis testcontainers used it)

Removed deps:
- @upstash/redis, ioredis, testcontainers (-184 packages)

Removed constants:
- REDIS_KEY_PREFIX, REDIS_CANVAS_KEY (only used by deleted code)

Removed package.json scripts: test:integration, test:all
Removed CF Worker secrets in production:
  UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN, MIGRATION_TOKEN

Tests: 94/94 pass (down from 112 — 18 deleted Upstash-specific).
Bundle: 71.90 KiB (down from 79.43 KiB).
Production verified: canvas data intact (38% non-zero bytes), migration
endpoint returns 404, secret list empty.
2026-05-10 02:05:43 +07:00
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
tiennm99 c3f7c02f6d feat(canvas): migrate canvas + cooldown storage to DO SQLite
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/
2026-05-09 23:53:21 +07:00
tiennm99 f59e55a852 refactor(rate-limit): 1 req/sec cooldown, batch up to 2048 (#3)
* refactor(rate-limit): switch to 1 req/sec cooldown, batch size up to 2048

Replace per-pixel credit/token-bucket model with a simple per-user cooldown
(SET NX EX 1). Batch size is now independent of the rate limit and capped
at MAX_BATCH_SIZE = 2048.

- rate-limiter: SET NX EX replaces Lua credit script
- worker: response shape { ok: true } (no credits field)
- client: drop credit state/timer/UserInfo; uploader paces by cooldown
- tests: mock checkRateLimit; integration test exercises SET NX EX
- docs: README, system-architecture, code-standards, deployment-guide

* chore(plans): remove implemented plan directories

rplace-implementation (base build), review-fixes, and
image-importer-enhancements are all shipped. Keep plans/reports/ as
historical code-review and research references.
2026-04-18 10:19:00 +07:00
tiennm99 a0c32b18ee fix: serve /api/canvas raw, drop manual gzip + add error envelope
Manual CompressionStream + Content-Encoding: gzip caused the browser
to receive un-decoded gzipped bytes through the wrangler-dev + vite
proxy path, surfacing as "Canvas buffer truncated: got 3598 bytes"
in the new decoder bounds check.

Cloudflare's edge auto-compresses compressible content already, so the
manual gzip layer was redundant in prod and broken in dev.

Also wraps getFullCanvas in try/catch and returns a JSON error envelope
on failure instead of letting Hono's default handler return a 500 with
text body the client decoder would mis-parse.
2026-04-17 10:31:05 +07:00
tiennm99 a823f8527d 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
2026-04-17 10:14:45 +07:00
tiennm99 e3eb34c6de fix: BITFIELD via raw REST API and improve error handling
- Use Upstash REST API directly for BITFIELD (SDK builder broken in v1.37)
- Add redisRaw() helper for raw command execution
- Wrap setPixels in try-catch, return JSON errors instead of 500 text
- Client handles non-JSON server responses gracefully
- Toolbar: larger 44px touch targets, separators, better contrast
2026-04-16 22:00:58 +07:00
tiennm99 b3b4916263 fix: address code review findings
- Fix WebSocket reconnection logic in App.svelte (was a no-op)
- Remove spoofable x-forwarded-for fallback, use cf-connecting-ip only
- Wrap Durable Object broadcast in try/catch to prevent place failures
- Cache OffscreenCanvas to avoid re-allocation every render frame
2026-04-16 16:27:35 +07:00
tiennm99 2ccf1c9779 refactor: migrate from Next.js/Vercel to Svelte/Hono/Cloudflare Workers
Replace Next.js + SSE with Svelte frontend + Hono API + Durable Objects
WebSocket on Cloudflare Workers. Single worker serves static assets and
API routes. Native WebSocket replaces SSE polling for real-time updates.
17kb gzipped client bundle vs 90kb+ React runtime.
2026-04-16 16:23:27 +07:00