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.
This commit is contained in:
tiennm99 authored and GitHub committed 2026-04-18 10:19:00 +07:00
1 parent 2d61225ee4
commit f59e55a852
29 files changed
+119 -1918

No files matched your search

+16 -14
View File
@@ -6,11 +6,11 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd
- **2048x2048 canvas** with 32-color palette (from [rplace.live](https://rplace.live/))
- **Real-time updates** via WebSocket (Cloudflare Durable Objects)
- **Batch pixel placement** up to 32 pixels per request
- **Stackable credit system** — earn 1 pixel/sec, stack up to 256, spend in batches
- **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
- **Credit bar** with visual regeneration feedback
- **Image importer** — upload, dither, and auto-paint images onto the canvas
## Tech Stack
@@ -19,7 +19,7 @@ A collaborative pixel art canvas inspired by [Reddit's r/place](https://www.redd
| Frontend | [Svelte 5](https://svelte.dev/) (runes) + HTML5 Canvas |
| Backend | [Hono](https://hono.dev/) on Cloudflare Workers |
| Real-time | WebSocket via Cloudflare Durable Objects |
| Storage | [Upstash Redis](https://upstash.com/) (BITFIELD for canvas, Lua for rate limiting) |
| Storage | [Upstash Redis](https://upstash.com/) (BITFIELD for canvas, SET NX EX for rate limiting) |
| Build | [Vite](https://vite.dev/) |
## Architecture
@@ -32,12 +32,12 @@ Browser (Svelte SPA + WebSocket)
v
Cloudflare Worker (Hono)
├── Canvas API (read/write pixels via Redis BITFIELD)
├── Rate Limiter (Lua script, atomic token bucket)
├── Rate Limiter (SET NX EX — atomic per-user cooldown)
└── Durable Object (WebSocket broadcast to all clients)
↕
Upstash Redis
├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB)
└── HASH "credits:{userId}" (lastUpdate + credits)
└── STRING "cooldown:{userId}" (1s TTL, blocks repeat requests)
```
## Getting Started
@@ -94,17 +94,19 @@ src/
│ ├── redis-client.js # Upstash Redis factory
│ ├── canvas-storage.js # BITFIELD read/write
│ ├── canvas-decoder.js # 5-bit → RGBA (client-side)
│ ├── rate-limiter.js # Lua token bucket
│ ├── rate-limiter.js # SET NX EX cooldown
│ ├── image-uploader.js # Browser-side batched uploader
│ └── get-user-id.js # IP-based identity
├── client/
│ ├── main.js # Svelte mount
│ ├── App.svelte # Root + WebSocket + credit timer
│ ├── App.svelte # Root + WebSocket
│ ├── app.css # Global styles
│ └── components/
│ ├── CanvasRenderer.svelte # Canvas + zoom/pan + touch
│ ├── ColorPicker.svelte # 32-color palette grid
│ ├── CanvasControls.svelte # Zoom buttons + coordinates
│ └── UserInfo.svelte # Credit counter + bar
│ ├── DrawToolbar.svelte # Paint / submit / undo / redo
│ └── ImageImporter.svelte # Image-to-canvas uploader
└── index.html # Vite entry
```
@@ -126,10 +128,11 @@ Place pixels on the canvas.
}
```
**Response:** `{ "ok": true, "credits": 255 }`
**Response:** `{ "ok": true }`
**Errors:**
- `400` — invalid pixel data or batch > 32
- `400` — invalid pixel data or batch > 2048
- `413` — request body too large
- `429` — rate limited (includes `retryAfter` seconds)
### `WS /api/ws`
@@ -149,9 +152,8 @@ Key constants in `src/lib/constants.js`:
| `CANVAS_WIDTH` | 2048 | Canvas width in pixels |
| `CANVAS_HEIGHT` | 2048 | Canvas height in pixels |
| `MAX_COLORS` | 32 | Number of colors in palette |
| `MAX_BATCH_SIZE` | 32 | Max pixels per placement request |
| `MAX_CREDITS` | 256 | Max stackable credits |
| `CREDIT_REGEN_RATE` | 1 | Credits earned per second |
| `MAX_BATCH_SIZE` | 2048 | Max pixels per placement request |
| `REQUEST_COOLDOWN_SEC` | 1 | Minimum seconds between requests per user |
## Credits & References
+2 -2
View File
@@ -46,5 +46,5 @@ src/
## API Response Format
Success: `{ ok: true, credits: N }`
Error: `{ error: "error_code", ...details }` with appropriate HTTP status
Success: `{ ok: true }`
Error: `{ error: "error_code", ...details }` with appropriate HTTP status (e.g., `retryAfter` on 429)
+2 -2
View File
@@ -62,7 +62,7 @@ npx wrangler domains add rplace.yourdomain.com
|---|---|---|
| CF Workers | 100K requests/day | Canvas reads + pixel placements |
| CF Durable Objects | Free with Workers | WebSocket connections |
| Upstash Redis | 10K commands/day | BITFIELD reads/writes + rate limiting |
| Upstash Redis | 10K commands/day | BITFIELD reads/writes + cooldown SET NX EX |
For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash pay-as-you-go ($0.2/100K commands) is the first thing to hit limits.
@@ -71,4 +71,4 @@ For hobby traffic (< few hundred users/day), free tiers are sufficient. Upstash
- **Canvas loads empty**: Check Upstash credentials in secrets
- **Pixels don't persist**: Verify BITFIELD support — test with `redis-cli BITFIELD canvas SET u5 #0 1`
- **WebSocket not connecting**: Ensure Durable Object migration ran (check `wrangler.json` migrations)
- **Rate limiting not working**: Verify `redis.eval()` works on your Upstash tier (Lua scripting)
- **Rate limiting not working**: Verify `SET key value NX EX 1` returns `"OK"` / `null` as expected on your Upstash tier
+18 -19
View File
@@ -9,14 +9,14 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T
### Pixel Placement
```
1. User clicks canvas → optimistic render + credit deduction
2. POST /api/place { pixels: [{x, y, color}] }
3. Worker validates input (bounds, types, batch size ≤ 32)
4. Worker checks credits via Lua script (atomic check-and-deduct)
1. User draws on canvas → optimistic render into a pending buffer
2. User hits Submit → POST /api/place { pixels: [{x, y, color}] }
3. Worker validates input (bounds, types, batch size ≤ 2048)
4. Worker checks cooldown via SET NX EX (atomic per-user lock)
5. Worker writes pixels via Redis BITFIELD (atomic batch)
6. Worker sends pixels to Durable Object /broadcast
7. Durable Object fans out to all WebSocket clients
8. Response: { ok: true, credits: N }
8. Response: { ok: true }
```
### Canvas Loading
@@ -50,32 +50,31 @@ rplace is a collaborative pixel canvas deployed as a single Cloudflare Worker. T
- Offset: `y * CANVAS_WIDTH + x`
- Atomic batch writes: single BITFIELD command with chained .set() calls
### Redis HASH (Credits)
### Redis STRING (Cooldown)
- Key pattern: `credits:{userId}`
- Fields: `lu` (last update, unix seconds), `cr` (current credits)
- TTL: 24 hours (auto-expire inactive users)
- Accessed via Lua script for atomic check-and-deduct
- Key pattern: `cooldown:{userId}`
- Value: `"1"` (presence is the signal; content is irrelevant)
- TTL: `REQUEST_COOLDOWN_SEC` (1s) — auto-expires, no explicit cleanup
- Atomic via `SET key "1" NX EX 1`
## Rate Limiting
Token bucket algorithm implemented as a Lua script:
Fixed-window cooldown, one request per second per user:
```
On placement request:
1. Read stored credits + lastUpdate from HASH
2. Calculate accrued = stored + floor(elapsed_seconds * regen_rate)
3. Cap at MAX_CREDITS (256)
4. If accrued < requested → reject (429)
5. Else → deduct, update HASH, return remaining
1. SET cooldown:{userId} "1" NX EX 1
2. If reply == "OK" → allow (key set, TTL 1s)
3. If reply == null → reject (429, retryAfter = 1)
```
New users start with full credits (256). Anonymous identity via CF-Connecting-IP hash.
Batch size is independent of the cooldown; it is validated separately
(MAX_BATCH_SIZE = 2048). Anonymous identity via CF-Connecting-IP hash.
## Security
- **Rate limiting**: Atomic Lua script prevents race conditions
- **Rate limiting**: Atomic `SET NX EX` prevents race conditions
- **Identity**: CF-Connecting-IP (set by Cloudflare, unspoofable)
- **Input validation**: Bounds checking, type checking, integer validation on all pixel data
- **Batch cap**: Max 32 pixels per request to limit burst damage
- **Batch cap**: Max 2048 pixels per request + request body size guard
- **DO isolation**: /broadcast route only reachable via DO stub, not externally
@@ -1,140 +0,0 @@
---
phase: 1
title: "Project Setup"
status: pending
effort: 1.5h
priority: P1
---
# Phase 1 — Project Setup
## Context Links
- [Next.js App Router docs](https://nextjs.org/docs/app)
- [Upstash Redis SDK](https://github.com/upstash/upstash-redis)
## Overview
Initialize Next.js project with App Router, install dependencies, configure environment variables, and establish project structure with kebab-case naming.
## Requirements
### Functional
- Next.js App Router project scaffolded
- All dependencies installed
- Environment config for Upstash Redis + NextAuth
- Project directory structure established
### Non-functional
- JavaScript only (no TypeScript)
- Files under 200 lines
- kebab-case file naming
## Dependencies to Install
```
next react react-dom
@upstash/redis # Redis client (REST-based, Vercel-friendly)
next-auth # OAuth (Google, GitHub)
```
Dev dependencies:
```
eslint eslint-config-next
```
## Project Structure
```
src/
├── app/
│ ├── layout.js
│ ├── page.js
│ ├── api/
│ │ ├── canvas/
│ │ │ ├── route.js # GET full canvas
│ │ │ ├── place/
│ │ │ │ └── route.js # POST batch pixel placement
│ │ │ └── stream/
│ │ │ └── route.js # GET SSE stream
│ │ └── auth/
│ │ └── [...nextauth]/
│ │ └── route.js # NextAuth catch-all
│ ├── components/
│ │ ├── canvas-renderer.js # HTML5 Canvas rendering
│ │ ├── color-picker.js # 32-color palette UI
│ │ ├── canvas-controls.js # Zoom/pan controls
│ │ └── user-info.js # Auth status + cooldown display
│ └── globals.css
├── lib/
│ ├── redis-client.js # Upstash Redis singleton
│ ├── canvas-storage.js # BITFIELD read/write helpers
│ ├── rate-limiter.js # Stackable credit system
│ ├── sse-broadcaster.js # Pub/Sub → SSE bridge
│ ├── auth-options.js # NextAuth config
│ └── constants.js # Canvas size, colors, limits
└── .env.example
```
## Implementation Steps
1. Run `npx create-next-app@latest . --js --app --eslint --no-tailwind --no-src-dir --import-alias "@/*"` (adjust if src dir preferred — using `src/` per structure above, so add `--src-dir`)
2. Install production deps: `npm i @upstash/redis next-auth`
3. Create `.env.example` with required vars:
```
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
```
4. Create `src/lib/constants.js` with canvas config:
```js
export const CANVAS_WIDTH = 2048;
export const CANVAS_HEIGHT = 2048;
export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
export const MAX_BATCH_SIZE = 256;
export const CREDIT_REGEN_RATE = 1; // per second
export const MAX_CREDITS = 256;
export const REDIS_CANVAS_KEY = 'canvas';
export const REDIS_PUBSUB_CHANNEL = 'canvas:updates';
export const COLORS = [
'#6d001a','#be0039','#ff4500','#ffa800','#ffd635','#fff8b8',
'#00a368','#00cc78','#7eed56','#00756f','#009eaa','#00ccc0',
'#2450a4','#3690ea','#51e9f4','#493ac1','#6a5cff','#94b3ff',
'#811e9f','#b44ac0','#e4abff','#de107f','#ff3881','#ff99aa',
'#6d482f','#9c6926','#ffb470','#000000','#515252','#898d90',
'#d4d7d9','#ffffff',
];
```
5. Create `src/lib/redis-client.js` — Upstash Redis singleton
6. Create stub files for remaining `lib/` and `api/` routes
7. Verify `npm run dev` starts without errors
## Todo List
- [ ] Scaffold Next.js project
- [ ] Install dependencies
- [ ] Create `.env.example`
- [ ] Create `constants.js` with palette and config
- [ ] Create `redis-client.js` singleton
- [ ] Create directory structure with stub files
- [ ] Verify dev server starts clean
## Success Criteria
- `npm run dev` runs without errors
- Project structure matches spec
- All stub files exist and export empty functions/components
- `.env.example` documents all required vars
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| create-next-app flags change | Low | Low | Check docs, adjust flags |
| Upstash SDK version mismatch | Low | Med | Pin version in package.json |
## Rollback
Delete generated files, re-scaffold. No data at risk.
@@ -1,143 +0,0 @@
---
phase: 2
title: "Canvas Backend"
status: pending
effort: 3h
priority: P1
blocked_by: [1]
---
# Phase 2 — Canvas Backend
## Context Links
- [Redis BITFIELD command](https://redis.io/docs/latest/commands/bitfield/)
- [Upstash Redis REST API](https://docs.upstash.com/redis/features/restapi)
## Overview
Implement Redis BITFIELD-based canvas storage and two API routes: GET full canvas (binary), POST batch pixel placement.
## Key Insights
- 5 bits per pixel → offset = `(y * CANVAS_WIDTH + x) * 5` for bit-level, or use `u5 #(y * CANVAS_WIDTH + x)` for field-level indexing
- Upstash `@upstash/redis` supports BITFIELD via `redis.bitfield(key, ...commands)`
- Single BITFIELD command can batch multiple SET subcommands → one round-trip for 256 pixels
- Full canvas = `2048 * 2048 * 5 / 8 = 2,621,440 bytes` (~2.5MB raw, ~1.5MB gzip)
## Data Flow
```
GET /api/canvas:
Client → API Route → redis.get("canvas") as Buffer → gzip → Response (binary)
POST /api/canvas/place:
Client → API Route
→ Validate batch (coords, color indices, size ≤ 256)
→ Check rate limit credits
→ redis.bitfield("canvas", ...SET commands)
→ Publish update to Pub/Sub
→ Return {ok: true, remaining_credits}
```
## Architecture
### `src/lib/canvas-storage.js`
```js
// getFullCanvas() → Buffer (raw BITFIELD bytes)
// setPixels(pixels: [{x, y, color}]) → void (batch BITFIELD SET)
// getPixel(x, y) → colorIndex (single BITFIELD GET)
```
### `src/app/api/canvas/route.js`
```js
// GET handler:
// 1. Call getFullCanvas()
// 2. Gzip compress
// 3. Return with Content-Type: application/octet-stream
// + Content-Encoding: gzip
// + Cache-Control: public, max-age=1, stale-while-revalidate=5
```
### `src/app/api/canvas/place/route.js`
```js
// POST handler:
// 1. Parse body: { pixels: [{x, y, color}] }
// 2. Validate: all coords in range, color 0-31, batch ≤ 256
// 3. Identify user (IP or auth session)
// 4. Check/deduct rate limit credits
// 5. Call setPixels(pixels)
// 6. Publish batch to Redis Pub/Sub
// 7. Return { ok: true, credits: remaining }
```
## Related Code Files
### Create
- `src/lib/canvas-storage.js`
- `src/app/api/canvas/route.js`
- `src/app/api/canvas/place/route.js`
### Modify
- `src/lib/redis-client.js` (if stub needs fleshing out)
## Implementation Steps
1. **Implement `canvas-storage.js`**
- `getFullCanvas()`: Use `redis.get(REDIS_CANVAS_KEY)` — Upstash returns base64, decode to Buffer
- `setPixels(pixels)`: Build BITFIELD command array: for each pixel, push `['SET', 'u5', `#${y * W + x}`, color]`, execute as single `redis.bitfield()`
- `getPixel(x, y)`: `redis.bitfield(key, ['GET', 'u5', `#${y * W + x}`])`
- Handle empty canvas (key doesn't exist) → return zeroed buffer
2. **Implement GET `/api/canvas`**
- Import `getFullCanvas`
- Compress with `zlib.gzipSync()`
- Return `new Response(gzipped, { headers })` with proper content headers
- Add `Cache-Control: public, max-age=1, s-maxage=1, stale-while-revalidate=5`
3. **Implement POST `/api/canvas/place`**
- Parse JSON body
- Validate input schema:
- `pixels` is array, length 1-256
- Each pixel: `x` int 0-2047, `y` int 0-2047, `color` int 0-31
- Extract user identity (IP from `request.headers.get('x-forwarded-for')` or fallback)
- Rate limiting call (stub for now, Phase 3)
- Call `setPixels(validatedPixels)`
- Publish to Pub/Sub (stub for now, Phase 4)
- Return JSON response
4. **Initialize canvas** — add a utility or on-demand initialization: if canvas key missing, SET empty buffer of correct size
## Todo List
- [ ] Implement `canvas-storage.js` with getFullCanvas, setPixels, getPixel
- [ ] Handle empty/missing canvas key initialization
- [ ] Implement GET `/api/canvas` with gzip compression
- [ ] Implement POST `/api/canvas/place` with validation
- [ ] Add input validation helpers
- [ ] Test with curl / httpie against local dev
- [ ] Verify BITFIELD offset calculation is correct
## Success Criteria
- GET `/api/canvas` returns gzipped binary of correct size (2.5MB uncompressed)
- POST `/api/canvas/place` with valid payload writes pixels and returns success
- POST with invalid coords/colors returns 400
- POST with >256 pixels returns 400
- Empty canvas initializes correctly on first read
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Upstash BITFIELD API differences from raw Redis | Med | High | Test early; check Upstash docs for BITFIELD support |
| Buffer encoding issues (base64 vs binary) | Med | Med | Log and compare byte lengths; add unit tests |
| BITFIELD command size limit per request | Low | Med | Upstash allows large commands; if limited, chunk into batches of 50 |
## Failure Modes
1. **Redis unavailable** → API returns 503, client retries with backoff
2. **Corrupt canvas data** → Validate BITFIELD size on read; re-init if wrong size
3. **Race condition on concurrent writes** → BITFIELD is atomic per command; batch SET is atomic → safe
## Rollback
Remove API route files and canvas-storage.js. No persistent side effects beyond Redis data (flush key).
@@ -1,166 +0,0 @@
---
phase: 3
title: "Rate Limiting"
status: pending
effort: 2h
priority: P1
blocked_by: [2]
---
# Phase 3 — Rate Limiting (Stackable Credits)
## Context Links
- [Token bucket algorithm](https://en.wikipedia.org/wiki/Token_bucket)
## Overview
Implement stackable credit system: users accumulate 1 credit/second (max 256). Each pixel placement costs 1 credit. Batch placement deducts batch size from credits. All state stored in Redis Hash per user.
## Key Insights
- This is a **token bucket** pattern stored in Redis
- No background process needed — calculate credits on-demand from elapsed time
- Atomic check-and-deduct prevents race conditions via Redis scripting or MULTI/EXEC
- User key: `credits:{userId}` where userId = IP hash (anonymous) or user ID (authenticated)
## Data Flow
```
POST /api/canvas/place:
1. Identify user → userId
2. HGETALL credits:{userId} → {lastUpdate, credits}
3. elapsed = now - lastUpdate
4. newCredits = min(MAX_CREDITS, storedCredits + floor(elapsed))
5. if newCredits < batchSize → reject 429
6. remaining = newCredits - batchSize
7. HSET credits:{userId} lastUpdate=now credits=remaining
8. Proceed with pixel placement
```
## Architecture
### `src/lib/rate-limiter.js`
```js
// checkAndDeductCredits(userId, count) → { allowed: bool, remaining: int, retryAfter?: int }
//
// Algorithm:
// 1. Get stored state from Redis Hash
// 2. Calculate accrued credits from elapsed time
// 3. If sufficient: deduct and update; return allowed=true
// 4. If insufficient: return allowed=false with retryAfter seconds
//
// Edge cases:
// - First-time user (no hash exists) → initialize with MAX_CREDITS
// - Clock skew → use Redis server time (TIME command) if needed
```
### User Identity Extraction
```js
// src/lib/get-user-id.js
// getUserId(request) → string
// 1. Check NextAuth session → user.id
// 2. Fallback: hash of x-forwarded-for or request IP
// 3. Prefix: "auth:" or "anon:" to avoid collision
```
## Related Code Files
### Create
- `src/lib/rate-limiter.js`
- `src/lib/get-user-id.js`
### Modify
- `src/app/api/canvas/place/route.js` — integrate rate limiter
## Implementation Steps
1. **Create `get-user-id.js`**
- Extract IP from `x-forwarded-for` header (first IP if multiple)
- Hash IP with simple hash (e.g., substring of SHA-256) for privacy
- If NextAuth session exists, use `session.user.id` with `auth:` prefix
- Anonymous users get `anon:` prefix
2. **Create `rate-limiter.js`**
- `checkAndDeductCredits(userId, count)`:
- `redis.hgetall(`credits:${userId}`)` → parse lastUpdate, credits
- If null (new user): set credits = MAX_CREDITS, lastUpdate = now
- Calculate: `accrued = min(MAX_CREDITS, stored + floor((now - lastUpdate) / 1000))`
- If `accrued < count`: return `{ allowed: false, remaining: accrued, retryAfter: count - accrued }`
- Else: `redis.hset(key, { lastUpdate: now, credits: accrued - count })`, return `{ allowed: true, remaining: accrued - count }`
- Use seconds (Unix timestamp) for lastUpdate
- **Race condition mitigation**: Use Redis Lua script or pipeline with WATCH for atomicity. Upstash supports `redis.eval()` for Lua scripts.
3. **Integrate into place route**
- Import `getUserId`, `checkAndDeductCredits`
- Before pixel write: check credits
- On rejection: return 429 with `{ error: 'rate_limited', retryAfter, remaining }`
- On success: include `remaining` credits in response
4. **Add credits info endpoint** (optional, could be part of place response)
- GET `/api/credits` → returns current credit count for user (calculated, not stored)
## Lua Script for Atomicity
```lua
-- KEYS[1] = credits:{userId}
-- ARGV[1] = count (pixels to place)
-- ARGV[2] = now (unix seconds)
-- ARGV[3] = max credits
-- ARGV[4] = regen rate (credits per second)
local data = redis.call('HGETALL', KEYS[1])
local lastUpdate = 0
local credits = tonumber(ARGV[3]) -- default max for new users
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lastUpdate' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'credits' then credits = tonumber(data[i+1]) end
end
end
local elapsed = tonumber(ARGV[2]) - lastUpdate
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
local count = tonumber(ARGV[1])
if accrued < count then
return {0, accrued, count - accrued} -- denied, remaining, retryAfter
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lastUpdate', ARGV[2], 'credits', remaining)
return {1, remaining, 0} -- allowed, remaining, 0
```
## Todo List
- [ ] Create `get-user-id.js` with IP extraction + hashing
- [ ] Create `rate-limiter.js` with credit calculation logic
- [ ] Implement Lua script for atomic check-and-deduct
- [ ] Integrate into POST `/api/canvas/place`
- [ ] Return 429 with retryAfter on rate limit
- [ ] Test: new user gets full credits
- [ ] Test: credits deplete and regenerate correctly
- [ ] Test: batch larger than available credits rejected
## Success Criteria
- New user can place 256 pixels immediately
- After depleting credits, requests return 429 with correct retryAfter
- Credits regenerate at 1/sec (verified by waiting and retrying)
- Concurrent requests don't grant double credits (Lua script atomicity)
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Upstash Lua script limitations | Med | High | Test EVAL support early; fallback to non-atomic HGETALL+HSET |
| IP spoofing for unlimited credits | Med | Low | Vercel provides real IP; rate limit is soft defense anyway |
| Clock drift between app and Redis | Low | Low | Use Redis TIME or consistent Date.now() |
## Failure Modes
1. **Redis EVAL not supported** → Fallback to HGETALL + HSET (small race window acceptable for MVP)
2. **IP header missing** → Use fallback `127.0.0.1` hash (all anonymous users share limit — degrade gracefully)
3. **Hash key explosion** (many unique IPs) → Set TTL on credit hashes (e.g., 24h expiry via EXPIRE)
## Rollback
Remove rate-limiter.js, get-user-id.js. Remove rate limit check from place route (it was a stub before). Place route works without rate limiting.
@@ -1,170 +0,0 @@
---
phase: 4
title: "Real-time Updates (SSE + Pub/Sub)"
status: pending
effort: 3h
priority: P1
blocked_by: [2]
---
# Phase 4 — Real-time Updates
## Context Links
- [MDN Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events)
- [Upstash Redis Pub/Sub](https://docs.upstash.com/redis/howto/pubsub)
## Overview
Implement SSE endpoint that streams pixel updates to connected clients. When pixels are placed, the place route publishes deltas to Redis Pub/Sub. The SSE route subscribes and forwards to clients.
## Key Insights
- Vercel serverless functions have max execution time (10s free, 60s pro). SSE on Vercel works via **streaming responses** with Edge Runtime.
- Use **Edge Runtime** for SSE route — no cold start, streaming support
- Upstash Redis Pub/Sub works differently from traditional Redis: use `@upstash/redis` REST-based pub/sub or polling approach
- Alternative: Use Upstash's `@upstash/redis` with `subscribe` (if available) or implement polling-based SSE
- **Practical approach for Vercel**: SSE endpoint polls Redis for updates using a sorted set or list as message queue, rather than true Pub/Sub (which requires persistent connection)
## Data Flow
```
Pixel Placement:
place/route.js → setPixels() → redis.publish("canvas:updates", JSON.stringify(batch))
+ redis.lpush("canvas:queue", JSON.stringify({ts, pixels}))
SSE Stream:
stream/route.js (Edge Runtime):
1. Client connects via EventSource
2. Send initial heartbeat
3. Poll loop: redis.lrange("canvas:queue", ...) for new updates since client's last seen ts
4. Send each batch as SSE event
5. Trim old entries periodically (LTRIM)
Alternative (simpler, recommended for MVP):
stream/route.js:
1. Client connects with ?since={timestamp}
2. Server polls Redis list every 500ms
3. Sends new batches as SSE data events
4. Client reconnects on disconnect (EventSource auto-reconnects)
```
## Architecture
### Message Format (SSE event data)
```json
{
"ts": 1713200000000,
"pixels": [
{"x": 100, "y": 200, "color": 5},
{"x": 101, "y": 200, "color": 5}
]
}
```
### `src/lib/sse-broadcaster.js`
```js
// publishPixelUpdates(pixels) → void
// - Add timestamped batch to Redis sorted set (score = timestamp)
// - ZADD canvas:updates {score: Date.now(), member: JSON.stringify(batch)}
// - ZREMRANGEBYSCORE to trim entries older than 60s (keep queue bounded)
// getUpdatesSince(since) → Array<batch>
// - ZRANGEBYSCORE canvas:updates since +inf
// - Parse and return batches
```
### `src/app/api/canvas/stream/route.js`
```js
// Edge Runtime for streaming
export const runtime = 'edge';
// GET handler:
// 1. Create ReadableStream
// 2. In stream: poll getUpdatesSince() every 500ms
// 3. Send SSE-formatted events for each batch
// 4. Send heartbeat comment every 15s to keep connection alive
// 5. Respect AbortSignal for cleanup
```
## Related Code Files
### Create
- `src/lib/sse-broadcaster.js`
- `src/app/api/canvas/stream/route.js`
### Modify
- `src/app/api/canvas/place/route.js` — add publishPixelUpdates() call after successful placement
## Implementation Steps
1. **Implement `sse-broadcaster.js`**
- `publishPixelUpdates(pixels)`:
- `redis.zadd('canvas:updates', { score: Date.now(), member: JSON.stringify({ ts: Date.now(), pixels }) })`
- `redis.zremrangebyscore('canvas:updates', 0, Date.now() - 60000)` — trim old
- `getUpdatesSince(since)`:
- `redis.zrangebyscore('canvas:updates', since, '+inf')`
- Parse each member, return array
2. **Implement SSE stream route**
- Use Edge Runtime (`export const runtime = 'edge'`)
- Create `ReadableStream` with `start(controller)`:
```js
const encoder = new TextEncoder();
let lastSeen = parseInt(url.searchParams.get('since') || '0');
const interval = setInterval(async () => {
const updates = await getUpdatesSince(lastSeen + 1);
for (const update of updates) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(update)}\n\n`));
lastSeen = Math.max(lastSeen, update.ts);
}
}, 500);
// heartbeat every 15s
const heartbeat = setInterval(() => {
controller.enqueue(encoder.encode(': heartbeat\n\n'));
}, 15000);
```
- Return `new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' } })`
3. **Integrate publishing into place route**
- After `setPixels()` succeeds, call `publishPixelUpdates(pixels)`
4. **Handle edge cases**
- AbortSignal / client disconnect → clear intervals
- Empty poll → no-op (don't send empty events)
- Reconnection: client sends `Last-Event-ID` or `?since=` param
## Todo List
- [ ] Create `sse-broadcaster.js` with ZADD/ZRANGEBYSCORE helpers
- [ ] Create SSE stream route with Edge Runtime
- [ ] Implement polling loop with heartbeat
- [ ] Integrate publishPixelUpdates into place route
- [ ] Handle client disconnect cleanup
- [ ] Test SSE stream with curl: `curl -N localhost:3000/api/canvas/stream`
- [ ] Test update delivery latency (<1s)
## Success Criteria
- SSE endpoint streams events to connected client
- Placing a pixel triggers SSE event within 1 second
- Heartbeat keeps connection alive
- Client reconnect resumes from last seen timestamp
- Old updates (>60s) cleaned up automatically
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Vercel Edge Runtime limits SSE duration | High | High | Document limit; client auto-reconnects via EventSource; accept 30s reconnect cycle |
| Upstash REST latency for 500ms polling | Med | Med | Acceptable for MVP; upgrade to WebSocket/Ably if needed |
| Sorted set grows unbounded | Med | Med | ZREMRANGEBYSCORE trims old; add ZCARD check as safety |
## Failure Modes
1. **SSE connection drops** → EventSource auto-reconnects; `?since=` param ensures no missed updates
2. **Redis sorted set too large** → Trim runs on every publish; worst case: add ZCARD limit check
3. **High-frequency updates overwhelm client** → Batch multiple updates per SSE event; client-side throttle rendering
4. **Edge Runtime timeout** → Client reconnects; stateless polling means no server-side state lost
## Rollback
Remove stream route and sse-broadcaster.js. Remove publish call from place route. Frontend falls back to periodic full canvas refresh (degrade to polling).
@@ -1,190 +0,0 @@
---
phase: 5
title: "Frontend Canvas"
status: pending
effort: 5h
priority: P1
blocked_by: [2, 3, 4]
---
# Phase 5 — Frontend Canvas
## Overview
Build interactive HTML5 Canvas UI: load full canvas from API, render 2048x2048 grid, support zoom/pan, color picker with 32-color palette, pixel placement on click, real-time SSE updates.
## Key Insights
- HTML5 Canvas with `ImageData` is the performant way to render millions of pixels
- Use `OffscreenCanvas` or direct `putImageData` for bulk updates
- Zoom/pan via CSS `transform` on a wrapper or by scaling canvas draw calls
- Decode 5-bit packed binary into RGBA ImageData on client side
- Use `requestAnimationFrame` for smooth rendering
## Data Flow
```
Initial Load:
1. fetch('/api/canvas') → gzipped binary (auto-decompressed by browser)
2. Decode 5-bit packed buffer → Uint8Array of color indices
3. Map color indices → RGBA via palette lookup
4. Create ImageData, putImageData to canvas
Live Updates (SSE):
1. EventSource('/api/canvas/stream?since=0')
2. On message: parse pixel batch
3. For each pixel: update ImageData at (x,y), queue re-render
4. Batch re-renders via requestAnimationFrame
Pixel Placement:
1. User clicks canvas → translate screen coords to canvas coords (account for zoom/pan)
2. Validate selected color
3. POST /api/canvas/place with [{x, y, color}]
4. Optimistic update: paint pixel immediately
5. On error: revert pixel
```
## Architecture
### Components
```
src/app/page.js
└── Client-side canvas app
├── CanvasRenderer — HTML5 Canvas element, ImageData management
├── ColorPicker — 32-color palette grid
├── CanvasControls — Zoom buttons, coordinates display
└── UserInfo — Credit counter, auth status
```
### `src/app/components/canvas-renderer.js`
Core rendering component. Manages:
- Canvas element ref
- ImageData buffer (2048x2048 RGBA)
- Zoom level and pan offset
- Mouse/touch event handlers for pan, zoom, click-to-place
- SSE connection lifecycle
### `src/app/components/color-picker.js`
- Grid of 32 color swatches
- Selected color highlighted
- Click to select
### `src/app/components/canvas-controls.js`
- Zoom in/out buttons
- Reset view button
- Coordinates display (current hover position)
- Scroll wheel zoom
### `src/app/components/user-info.js`
- Display remaining credits (poll or derive from last placement response)
- Login/logout button (Phase 6)
- Cooldown timer visualization
## Related Code Files
### Create
- `src/app/components/canvas-renderer.js`
- `src/app/components/color-picker.js`
- `src/app/components/canvas-controls.js`
- `src/app/components/user-info.js`
- `src/app/hooks/use-canvas-state.js` — shared state hook
- `src/app/hooks/use-sse-updates.js` — SSE connection hook
### Modify
- `src/app/page.js` — compose components
- `src/app/globals.css` — canvas styles
- `src/app/layout.js` — metadata, viewport
## Implementation Steps
1. **Binary decoding utility** (`src/lib/canvas-decoder.js` — client-side)
- `decodeCanvas(buffer)` → Uint8Array of color indices
- Read 5-bit values from packed binary: bit manipulation with DataView
- `indicesToImageData(indices, palette)` → ImageData (RGBA)
2. **Canvas renderer component**
- `useRef` for canvas element
- On mount: fetch `/api/canvas`, decode, render with `putImageData`
- Track zoom (1x-40x) and pan offset in state
- Apply transform: scale canvas context or use CSS transform
- Mouse events:
- `mousedown` + `mousemove` → pan (when not placing)
- `click` → place pixel (when color selected)
- `wheel` → zoom in/out centered on cursor
- Touch events for mobile: pinch-to-zoom, drag-to-pan
3. **SSE updates hook**
- `useSSEUpdates(onBatch)` custom hook
- Create `EventSource('/api/canvas/stream?since={ts}')`
- On message: parse JSON, call `onBatch(pixels)`
- Handle reconnection (EventSource does this natively)
- Track last event timestamp for reconnect `since` param
4. **Color picker component**
- Display 32 colors in 4x8 or 8x4 grid
- CSS grid layout
- Selected state with border/highlight
- Keyboard shortcuts (number keys for quick select)
5. **Canvas controls**
- Zoom level display (e.g., "4x")
- +/- buttons
- "Reset" to fit canvas in viewport
- Coordinate display updating on mousemove
6. **User info component**
- Display credit count from last placement response
- Animate credit regeneration client-side (increment every second)
- Show "Ready" / "Cooldown: Xs" status
7. **Main page composition**
- Import all components
- Shared state via `use-canvas-state.js` hook or props
- Layout: canvas fills viewport, color picker bottom, controls top-right, user-info top-left
## Todo List
- [ ] Create binary decoder (5-bit unpacking to RGBA)
- [ ] Create canvas-renderer with zoom/pan
- [ ] Create SSE updates hook
- [ ] Create color-picker component
- [ ] Create canvas-controls component
- [ ] Create user-info component
- [ ] Compose in page.js
- [ ] Add mobile touch support (pinch-zoom, drag-pan)
- [ ] Style with globals.css
- [ ] Test: full canvas loads and renders
- [ ] Test: click places pixel with optimistic update
- [ ] Test: SSE updates render in real-time
- [ ] Test: zoom/pan works smoothly
## Success Criteria
- Canvas loads and renders 2048x2048 pixels from API
- Zoom in/out works (1x to 40x), smooth with mouse wheel
- Pan by click-drag works
- Color picker shows 32 colors, selection is visible
- Clicking canvas with selected color sends POST and updates pixel
- SSE updates from other users appear within 1 second
- Mobile: pinch-zoom and drag-pan functional
- Credits display updates after placement
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| 2.5MB canvas download slow on mobile | Med | Med | Gzip reduces to ~1.5MB; show loading indicator |
| Canvas rendering performance at 40x zoom | Med | Med | Only render visible viewport tiles; use `drawImage` with source rect |
| 5-bit decoding bugs (off-by-one) | Med | High | Unit test decoder with known byte sequences |
| Touch event conflicts (pan vs place) | Med | Med | Long-press to place on mobile; short drag = pan |
## Failure Modes
1. **Canvas download fails** → Show error + retry button; cache last successful state in localStorage
2. **SSE disconnects** → EventSource auto-reconnects; on reconnect, fetch full canvas to resync
3. **Optimistic update wrong** → On POST error, revert pixel to previous color from ImageData backup
4. **Memory pressure (2048x2048 ImageData = 16MB RGBA)** → Acceptable for modern browsers; warn if <2GB RAM detected
## Rollback
Revert page.js to stub. Remove component files. Backend remains functional (testable via curl).
@@ -1,148 +0,0 @@
---
phase: 6
title: "Authentication"
status: pending
effort: 3h
priority: P2
blocked_by: [5]
---
# Phase 6 — Authentication
## Overview
Add optional Google/GitHub OAuth via NextAuth.js. Anonymous users continue working via IP-based identity. Authenticated users get stable identity (no credit reset on IP change).
## Key Insights
- NextAuth.js App Router integration uses route handler at `app/api/auth/[...nextauth]/route.js`
- JWT strategy (no database session) — stateless, Vercel-friendly
- Anonymous users work immediately — auth is opt-in enhancement
- Transition: when user logs in, optionally migrate credits from anon identity to auth identity
## Data Flow
```
Anonymous:
Request → getUserId() → hash(IP) → "anon:abc123"
Authenticated:
Request → NextAuth session → session.user.id → "auth:google-12345"
Login Flow:
1. User clicks "Sign in" → NextAuth OAuth flow
2. Redirect to Google/GitHub → consent → callback
3. NextAuth creates JWT session cookie
4. Subsequent requests include session → getUserId returns auth ID
5. Optional: migrate credits from anon key to auth key
```
## Architecture
### `src/lib/auth-options.js`
```js
// NextAuth configuration
// Providers: Google, GitHub
// Strategy: JWT (no database)
// Callbacks: include user ID in session
// Pages: custom sign-in page (optional, default works for MVP)
```
### `src/app/api/auth/[...nextauth]/route.js`
```js
// Standard NextAuth route handler
import NextAuth from 'next-auth';
import { authOptions } from '@/lib/auth-options';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
```
### Modify `src/lib/get-user-id.js`
```js
// Updated flow:
// 1. getServerSession(authOptions)
// 2. If session: return `auth:${session.user.id}`
// 3. Else: return `anon:${hash(ip)}`
```
## Related Code Files
### Create
- `src/lib/auth-options.js`
- `src/app/api/auth/[...nextauth]/route.js`
### Modify
- `src/lib/get-user-id.js` — add session check
- `src/app/components/user-info.js` — add login/logout buttons
- `src/app/layout.js` — wrap with SessionProvider (client-side)
## Implementation Steps
1. **Create `auth-options.js`**
- Configure Google and GitHub providers from env vars
- JWT strategy, no database adapter
- Add `session` callback to expose provider account ID
- Add `jwt` callback to persist user ID in token
2. **Create NextAuth route handler**
- Standard catch-all route at `api/auth/[...nextauth]`
3. **Update `get-user-id.js`**
- Import `getServerSession` from `next-auth`
- Check session first, fallback to IP hash
- Handle edge case: session exists but user.id missing
4. **Update `user-info.js` component**
- Import `useSession` from `next-auth/react`
- Show "Sign in" button when not authenticated
- Show user avatar/name + "Sign out" when authenticated
- Use `signIn()` and `signOut()` from next-auth/react
5. **Add SessionProvider wrapper**
- Create `src/app/providers.js` — client component wrapping `SessionProvider`
- Import in `layout.js`
6. **Credit migration (optional, nice-to-have)**
- On first authenticated request: check if anon key has credits
- If so: transfer credits from anon to auth key, delete anon key
- Skip if complexity not worth it for MVP
## Todo List
- [ ] Create `auth-options.js` with Google + GitHub providers
- [ ] Create NextAuth route handler
- [ ] Create `providers.js` with SessionProvider
- [ ] Update layout.js with providers wrapper
- [ ] Update `get-user-id.js` with session check
- [ ] Update `user-info.js` with login/logout UI
- [ ] Test Google OAuth flow end-to-end
- [ ] Test GitHub OAuth flow end-to-end
- [ ] Test anonymous fallback still works
- [ ] Test credits persist across sessions for authenticated users
## Success Criteria
- Anonymous users can place pixels without signing in
- Google OAuth login/logout works
- GitHub OAuth login/logout works
- Authenticated user has stable identity (credits persist)
- Session persists across page refreshes (JWT cookie)
- No regression in anonymous flow
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| OAuth provider setup complexity (client ID/secret) | Low | Med | Document setup steps in README |
| NextAuth version breaking changes | Low | Med | Pin version; test before upgrade |
| SessionProvider SSR hydration issues | Med | Med | Wrap in client component boundary |
## Security Considerations
- NEXTAUTH_SECRET must be strong random string (32+ chars)
- OAuth callback URLs must be registered with providers
- JWT tokens are httpOnly cookies — no XSS exposure
- Rate limit keys use provider user ID — no spoofing possible
- CSRF protection built into NextAuth
## Rollback
Remove auth-options.js, NextAuth route, providers.js. Revert get-user-id.js to IP-only. Revert user-info.js to remove login buttons. App works fully anonymous.
@@ -1,95 +0,0 @@
---
phase: 7
title: "Polish & Deploy"
status: pending
effort: 2.5h
priority: P2
blocked_by: [6]
---
# Phase 7 — Polish & Deploy
## Overview
Production hardening: Vercel deployment config, Upstash Redis provisioning, environment setup, performance optimization, error handling polish, and documentation.
## Implementation Steps
1. **Vercel Configuration**
- Create `vercel.json` if needed (usually not required for Next.js)
- Set Edge Runtime for SSE route
- Configure function regions (closest to Upstash Redis region)
- Set environment variables in Vercel dashboard
2. **Upstash Redis Setup**
- Create Upstash database (choose region matching Vercel)
- Enable eviction policy: noeviction (canvas data must persist)
- Set maxmemory appropriately (canvas = ~3MB + credit hashes + update queue)
- Copy REST URL and token to Vercel env vars
3. **Performance Optimization**
- Canvas API: verify gzip compression working (check Content-Encoding header)
- Add `Cache-Control` headers: canvas GET (short TTL), stream (no-cache)
- Consider canvas snapshot caching at edge (Vercel Edge Config or KV)
- Lazy load non-critical UI components
- Optimize binary decoder: use DataView for efficient 5-bit reads
4. **Error Handling Polish**
- Global error boundary for React components
- API routes: consistent error response format `{ error: string, code?: string }`
- SSE: graceful reconnection with exponential backoff (EventSource default)
- Canvas load failure: retry with backoff, show user-friendly error
- Redis connection failure: 503 response with retry header
5. **Canvas Initialization**
- Admin/setup script to initialize empty canvas in Redis
- Or: auto-initialize on first GET request if key missing
- Add `GET /api/canvas/info` endpoint: returns canvas dimensions, total pixels placed, etc.
6. **Meta & SEO**
- Open Graph tags
- Favicon
- Page title and description
- Mobile viewport meta
7. **Documentation**
- Update README.md with:
- Project description
- Setup instructions (local dev + Vercel deploy)
- Environment variables reference
- Architecture overview
- Update docs/ directory per documentation management rules
## Todo List
- [ ] Configure Vercel deployment settings
- [ ] Provision Upstash Redis database
- [ ] Set all environment variables
- [ ] Add error boundaries and consistent error responses
- [ ] Add canvas initialization logic
- [ ] Optimize gzip and caching headers
- [ ] Add meta tags and favicon
- [ ] Update README with setup instructions
- [ ] Update docs/ (architecture, code standards)
- [ ] Deploy to Vercel and smoke test
- [ ] Test full flow: load canvas → place pixels → see real-time updates
- [ ] Test OAuth flows in production
## Success Criteria
- App deployed to Vercel and accessible via URL
- Canvas loads within 3 seconds on broadband
- Pixel placement works end-to-end in production
- SSE updates work in production
- OAuth works with production callback URLs
- No console errors in production build
- Redis memory usage is predictable and bounded
## Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Vercel Edge Runtime SSE timeout | High | Med | Document 30s limit; client reconnects automatically |
| Upstash free tier limits (10K commands/day) | Med | High | Monitor usage; upgrade plan if needed; batch reads |
| Cold start latency | Med | Low | Edge Runtime eliminates cold starts for SSE; serverless routes accept ~200ms |
## Rollback
Vercel supports instant rollback to previous deployment. Redis data persists independently. Rollback = redeploy previous commit.
@@ -1,62 +0,0 @@
---
title: "rplace — Reddit r/place Clone"
description: "Full implementation plan for a 2048x2048 collaborative pixel canvas with real-time updates"
status: pending
priority: P1
effort: 20h
branch: main
tags: [nextjs, redis, sse, canvas, real-time]
created: 2026-04-16
---
# rplace Implementation Plan
## Architecture
```
Browser (HTML5 Canvas + SSE client)
| GET /api/canvas → full canvas binary (gzip)
| POST /api/canvas/place → batch pixel placement (up to 256px)
| GET /api/canvas/stream → SSE delta updates
| GET/POST /api/auth/* → NextAuth.js routes
v
Next.js App Router (Vercel serverless)
|
v
Upstash Redis
├── BITFIELD "canvas" (5-bit per pixel, 2048x2048 = 2.62MB)
├── HASH "credits:{id}" → {lastUpdate, credits}
└── Pub/Sub channel "canvas:updates"
```
## Phases
| # | Phase | Status | Effort | File |
|---|-------|--------|--------|------|
| 1 | Project Setup | Pending | 1.5h | [phase-01](./phase-01-project-setup.md) |
| 2 | Canvas Backend | Pending | 3h | [phase-02](./phase-02-canvas-backend.md) |
| 3 | Rate Limiting | Pending | 2h | [phase-03](./phase-03-rate-limiting.md) |
| 4 | Real-time Updates | Pending | 3h | [phase-04](./phase-04-real-time-updates.md) |
| 5 | Frontend Canvas | Pending | 5h | [phase-05](./phase-05-frontend-canvas.md) |
| 6 | Authentication | Pending | 3h | [phase-06](./phase-06-authentication.md) |
| 7 | Polish & Deploy | Pending | 2.5h | [phase-07](./phase-07-polish-and-deploy.md) |
## Dependencies
```
Phase 1 (Setup)
└─> Phase 2 (Canvas Backend)
├─> Phase 3 (Rate Limiting)
└─> Phase 4 (Real-time)
└─> Phase 5 (Frontend) ← also depends on Phase 2, 3
└─> Phase 6 (Auth)
└─> Phase 7 (Polish & Deploy)
```
## Key Decisions
- **JavaScript only** — no TypeScript (user preference)
- **SSE over WebSocket** — free on Vercel serverless, simpler
- **Redis BITFIELD** — 5-bit color encoding, single key for entire canvas
- **Stackable credits** — not fixed cooldown timer; allows batch placement
- **Anonymous-first** — IP-based identity, OAuth optional
-69
View File
@@ -1,69 +0,0 @@
# Plan: Review-Fix Sweep (2026-04-17)
Fixes for issues from `/ultrareview` reports:
- `plans/reports/code-review-260417-0926-backend.md`
- `plans/reports/code-review-260417-0926-frontend.md`
- `plans/reports/tester-260417-0927-test-suite.md`
## Status: COMPLETE — 76/76 unit tests pass; vite build OK.
## Backend fixes
| ID | File | Change |
|---|---|---|
| C1, C2 | `src/lib/rate-limiter.js` | Switch `lu` to ms precision. `retryAfter` now in seconds via `ceil(deficit * msPerCredit / 1000)`. Fractional regen handled (cap-aware lu advancement preserves sub-credit residue). |
| NH1 | `src/lib/redis-client.js` | `redisRaw` / `redisRawBinary` throw on Upstash 200-with-`error` envelope. `redisRaw` now returns `body.result` (was full envelope). |
| NC2 | `src/lib/constants.js` | `MAX_BATCH_SIZE = MAX_CREDITS = 256` (was 512 vs 256 mismatch). |
| NC2+L4 | `src/worker.js` | Early `Content-Length` reject (>16KB) before parsing JSON. |
| NH2 | `src/lib/canvas-storage.js` | `console.warn` on truncated canvas read instead of silent zero-pad. |
| H4 | `src/worker.js` | Bumped `s-maxage` 1→10, `stale-while-revalidate` 5→30. Manual gzip via `CompressionStream` when `Accept-Encoding: gzip`. `Vary: Accept-Encoding`. |
| H5 | `src/worker.js` | Broadcast moved to `c.executionCtx.waitUntil(broadcastPixels(...))` with `r.ok` check + error log. Resilient when `executionCtx` absent (test env). |
| H1, H2 | `src/lib/get-user-id.js` | SHA-256 (16 hex chars) replaces 32-bit string hash. Missing `cf-connecting-ip` → `anon:dev` shared bucket + `console.warn`. Function is now async — `worker.js` awaits. |
| NH4, N5 | `src/durable-objects/canvas-room.js` | Constructor now `(state, env)`. `webSocketClose` logs unclean disconnects. `webSocketError` logs error message. `webSocketMessage` defensively closes (1003) on unexpected client message. |
## Frontend fixes
| ID | File | Change |
|---|---|---|
| NC2 | `CanvasRenderer.svelte`, `App.svelte` | `addToStroke` blocks when `buffer.pixelCount + currentStrokeKeys.size >= MAX_BATCH_SIZE` (only if pixel isn't already buffered). New `onBufferFull` callback shows toast in App. |
| NC1 | `App.svelte` | `handleSubmit` shows toast on 429 (with `retryAfter`), 413, 400, 5xx, network error. `commitPending` only on `data.ok === true`. Single `toast` state with auto-dismiss. |
| NC3 | `CanvasRenderer.svelte` | `committedColors` allocated as zero-filled `Uint8Array` upfront (was `null` until fetch). Replaced after fetch. WS updates during initial fetch no longer null-deref. |
| C1, C2 | `CanvasRenderer.svelte` | `render(effZoom = zoom)` accepts explicit zoom. `handleWheel` always calls `render(newZoom)` after pan mutation, fixing the clamped-zoom no-render case. Touch pinch-zoom branch same. |
| C3 | `src/lib/canvas-decoder.js` | Throws on `buffer.byteLength < EXPECTED_BYTES` instead of silently `\|\| 0` reading past end. |
| C4 | `App.svelte`, `CanvasRenderer.svelte` | `isReconnect` flag in App; `canvasRenderer.refetchCanvas()` exported and called on reconnect `onopen`. Recovers pixels missed during disconnect. |
| NH1 | `src/lib/pixel-buffer.js` | Internal `Map<key,color>` cache. O(1) `getColorAt`, `pixelCount`, `getAffectedKeys`, `getAllPixels`. Invalidated on `addStroke/undo/redo/clear`. |
| NH3 | `CanvasRenderer.svelte` | `$effect(() => { mode; ... })` calls `cancelStroke()` (restores pixels, clears state) on mode change. No more dangling-stroke merge across modes. |
| NH5 | `CanvasRenderer.svelte` | `loadError` state + retry button overlay on initial fetch failure. |
| H1 | `CanvasRenderer.svelte` | DPR-aware sizing: `canvasEl.{width,height} = innerSize * dpr` + CSS sets logical size. `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` in render before pan/zoom. |
| H2 | `CanvasRenderer.svelte` | `onMount` is now sync; cleanup returns sync fn. `loadCanvas()` runs in background. Resize listener cleanup no longer leaks across HMR. |
| Touch | `CanvasRenderer.svelte` | Single-finger touch move now calls `onCursorMove` (was only mouse). |
| Defensive | `App.svelte` | `handleKeyDown` skips when target is `<input>/<textarea>/[contenteditable]`. |
## Test additions / updates
| File | Change |
|---|---|
| `test/lib/canvas-decoder.test.js` | Pad inputs to full canvas size. Added `throws on truncated buffer` test. |
| `test/lib/get-user-id.test.js` | Async/await all calls. Asserts `anon:dev` for missing header + 16-hex-char suffix shape. |
| `test/lib/redis-client.test.js` | Updated error message expectations to `Redis HTTP`. Added `Upstash 200 with error envelope` test (verifies NH1 fix). |
## Issues NOT addressed (intentionally deferred)
- **NC1 backend (wrangler `v1` migration tag reuse)** — left as-is; requires confirmation of whether DO has been deployed under `v1` with `new_classes` previously. If never deployed, current state is correct. If deployed, requires `v2` migration coordinated with Cloudflare (out-of-band decision).
- **M3 (BITFIELD u5 overflow guard inside setPixels)** — input already validated in worker; redundant guard skipped per YAGNI.
- **M5 (CORS / security headers)** — separate cross-cutting change; would prefer a Hono `secureHeaders` middleware in a follow-up.
- **M6 / NH4 nit (compatibility_date bump)** — leaving compat date `2025-04-01`; pre-auto-close path still works correctly.
- **Frontend tests** — no Svelte component test framework added in this sweep; core logic (`pixel-buffer`, `canvas-decoder`) covered by unit tests.
- Various M/L/N items from reports — KISS / scope.
## Verification
- `npm test` → 7 files / 76 tests pass.
- `npm run build` → vite production build succeeds (51.98 kB JS, 19.65 kB gzipped).
- Integration tests still skipped on Windows CI (Docker unavailable).
## Unresolved questions
1. NC1: confirm whether worker has ever been deployed (decides v1→v2 migration need).
2. Should `MAX_CREDITS` ever be raised (256 may be too restrictive for batch drawing UX)?
3. Add CI workflow file (`.github/workflows/test.yml`) to enforce tests on PR? (not done in this sweep)
@@ -1,74 +0,0 @@
# Phase 01 — Resize Controls
## Overview
- **Priority:** P0 (must-have, user requested)
- **Status:** Done
- Let the user change the output image size *before* it is palette-converted and uploaded, with a choice of resampling method so pixel art and photos both look good.
## Key Insights
- Canvas is 2048x2048 and images often don't fit. Forcing the user to pre-resize externally is friction.
- Resampling choice matters on our tiny palette: nearest preserves sharp pixel art; bilinear/box avoids aliasing on photos.
- Resize must happen *before* palette quantization — quantizing then scaling produces garbage.
- Aspect-lock prevents accidentally squishing logos; free mode supports deliberate stretch.
- HTMLCanvas `drawImage(scaled)` already provides nearest + bilinear cheaply. Box/median/dominant are pixel-art-friendly and need manual loops (see WPlace's `resampleBox`, `resampleMedian`, `resampleDominant`).
## Requirements
- Width + height number inputs, with a lock-aspect-ratio toggle.
- "Fit to canvas" helper that caps to `CANVAS_WIDTH`/`CANVAS_HEIGHT` minus current origin.
- Resampling dropdown: `nearest`, `bilinear`, `box`, `median`, `dominant` (ship at minimum `nearest` + `bilinear` in this phase; `box`/`median`/`dominant` optional).
- Reactive: changing any resize input re-runs pipeline and updates preview.
- Must keep preview snappy on 512x512 inputs (< 100ms).
## Architecture
New module `src/lib/image-resize.js` exports:
- `resizeRgba(rgba, srcW, srcH, dstW, dstH, method) → Uint8ClampedArray` — pure function operating on RGBA buffers so it's framework-free and CLI-usable.
`ImageImporter.svelte` adds resize state (`resizeW`, `resizeH`, `lockAspect`, `resampleMethod`) and inserts resize as the first pipeline step before `rgbaToPalette`.
```
srcRgba (srcW×srcH)
→ resizeRgba(rgba, srcW, srcH, resizeW, resizeH, method)
→ rgbaToPalette(resized, resizeW, resizeH, { dither })
```
## Related Code Files
**Modify:**
- `src/client/components/ImageImporter.svelte` — add UI + pipeline wiring
- `scripts/image-to-colors.js` — add `--width`, `--height`, `--method` flags (DRY with lib)
**Create:**
- `src/lib/image-resize.js` — resize function(s)
- `test/lib/image-resize.test.js` — unit tests
## Implementation Steps
1. Write `src/lib/image-resize.js` with `resampleNearest`, `resampleBilinear` (both via OffscreenCanvas `imageSmoothingEnabled`). Add `resampleBox` and `resampleMedian` only if time permits (optional stretch).
2. Export `resizeRgba(rgba, srcW, srcH, dstW, dstH, method = 'nearest')` — dispatches to the right resampler and returns a fresh `Uint8ClampedArray`.
3. Add unit tests: identity-resize (same dims) returns equivalent data; down/up scale by integer factors produce expected shape; unknown method falls back to nearest.
4. In `ImageImporter.svelte`:
- Add state `resizeW`, `resizeH`, `lockAspect`, `resampleMethod` (default source dims, lock on, nearest).
- When file loads, init `resizeW/resizeH` to source dims.
- Reactive `$derived` or `$effect` computes `workingRgba` = `resizeRgba(srcRgba, srcW, srcH, resizeW, resizeH, method)`.
- Feed `workingRgba` into `rgbaToPalette`. Preview canvas uses `resizeW/resizeH`.
- Aspect-lock updates the other dim when one changes.
- "Fit to canvas" button clamps to `CANVAS_WIDTH - originX` / `CANVAS_HEIGHT - originY` preserving aspect.
5. Update validation: overflow check uses `resizeW/resizeH`, not source dims.
6. CLI: add `--width`, `--height`, `--method` flags to `scripts/image-to-colors.js`, routing through the same `resizeRgba`. Keep defaults = source dims so behavior unchanged.
## Todo
- [x] `src/lib/image-resize.js` with `resizeRgba` + nearest/bilinear/box resamplers
- [x] Unit tests `test/lib/image-resize.test.js` (8 tests)
- [x] Wire into `ImageImporter.svelte` (state, pipeline, UI controls, aspect lock, "Fit to canvas", "1:1")
- [x] CLI flags in `scripts/image-to-colors.js` (`--width`, `--height`, `--method`)
- [x] `npm run build` + `npm test` green (84/84 tests)
## Success Criteria
- Upload a 512x512 photo, resize to 128x128 in-app, see palette preview update within one paint, upload fills a 128x128 area on canvas correctly.
- `node scripts/image-to-colors.js foo.png --width 128 --height 128 --method bilinear` produces same output pixel count (`128*128 = 16384`).
- All 76 existing tests still pass; new tests green.
## Risks
- Forgetting to rebase the pipeline on `workingRgba` everywhere → stale preview vs upload. Mitigation: single `$effect` owns the pipeline end-to-end; `buildPixels` reads the same `paletteIndices`.
- Very large upscales (e.g. 2000x2000) blow compute on preview re-render. Mitigation: cap resize inputs at `CANVAS_WIDTH`/`CANVAS_HEIGHT`.
## Next Steps
- Phase 2 builds on the final `resizeW/resizeH` to overlay the preview on the main canvas at `(originX, originY)`.
@@ -1,61 +0,0 @@
# Phase 02 — Overlay Preview on Canvas
## Overview
- **Priority:** P1 (high UX value)
- **Status:** Done
- Show a semi-transparent ghost of the palette-converted image on the main canvas at `(originX, originY)`, so the user can zoom/pan and confirm alignment *before* spending credits.
## Key Insights
- Today the user sees the preview in the panel only — they have to guess-and-upload, which wastes credits on misalignment.
- Ghost overlay must live at the canvas layer, not in the importer, because the user pans/zooms the canvas freely.
- Overlay must not interfere with manual drawing mode.
- Overlay opacity toggle (e.g. 50%) helps differentiate it from committed pixels.
## Requirements
- `CanvasRenderer` accepts an optional overlay: `{ x, y, width, height, indices }` (same `Int16Array` the importer already builds).
- Rendered as a second layer after committed + pending, at configurable alpha.
- Toggle on/off from the importer panel.
- Updates on importer changes (resize/dither/etc.) with no jank.
## Architecture
Extend `CanvasRenderer` with:
- A dedicated OffscreenCanvas for the overlay, same dims as the overlay's bounding box.
- `setOverlay({ x, y, width, height, indices, alpha } | null)` method.
- Render pipeline: commit/pending → offscreen → main canvas → `ctx.globalAlpha = alpha; ctx.drawImage(overlay, x, y);`.
`ImageImporter.svelte` holds a toggle `showOverlay` (default true); a reactive `$effect` calls `canvasRenderer.setOverlay(...)` whenever `paletteIndices` / `originX` / `originY` / `showOverlay` changes, and clears on close.
## Related Code Files
**Modify:**
- `src/client/components/CanvasRenderer.svelte` — overlay layer + method
- `src/client/components/ImageImporter.svelte` — show/hide toggle, wiring
- `src/client/App.svelte` — pass `canvasRenderer` ref to importer (already has bind, may need forwarding)
## Implementation Steps
1. In `CanvasRenderer`, allocate `overlayCanvas: OffscreenCanvas | null = null`. On `setOverlay(o)`:
- If `o == null`, drop the overlay and re-render.
- Else, build an `ImageData` from `paletteToRgba(o.indices, o.width, o.height)`, paint it on a fresh OffscreenCanvas, store `overlayState = { x, y, canvas, alpha }`.
2. Extend `render()` to draw the overlay after `offscreen`, respecting `globalAlpha`.
3. Expose `setOverlay` via `export function`.
4. In `ImageImporter`, add `showOverlay` checkbox (default true) and an opacity slider (default 0.5).
5. Wire a `$effect` that calls `setOverlay(showOverlay && paletteIndices ? { x: originX, y: originY, width: resizeW, height: resizeH, indices: paletteIndices, alpha } : null)` on relevant changes.
6. Clear overlay on panel close and when upload finishes successfully (optional; keep if user wants to re-align).
## Todo
- [x] `CanvasRenderer.setOverlay` + render integration
- [x] Importer toggle + alpha slider
- [x] Reactive wiring (importer ↔ renderer, via App `setOverlay` prop)
- [x] Overlay clears on close / unmount / toggle-off
## Success Criteria
- Toggle overlay on → preview appears on the canvas at the chosen position at 50% alpha.
- Move origin X/Y → overlay follows in real time.
- Disable overlay → canvas returns to normal appearance.
- No regression in existing draw/paint/undo/redo flows.
## Risks
- Performance on large overlays (up to canvas size): limit overlay dims to `resizeW*resizeH <= some cap` (e.g. 1M pixels) or always use OffscreenCanvas (GPU-accelerated).
- Race with WebSocket updates changing `committedColors` — overlay is drawn on top so it's fine.
## Next Steps
- Phase 3 (transforms) will rotate/flip the indices; overlay must re-render on transform toggle.
@@ -1,50 +0,0 @@
# Phase 03 — Transforms (Flip / Rotate)
## Overview
- **Priority:** P2 (medium)
- **Status:** Done
- Let the user flip horizontally, flip vertically, and rotate in 90° increments without re-exporting the source.
## Requirements
- Buttons: Flip H, Flip V, Rotate CW 90°, Rotate CCW 90°, Reset.
- Cumulative state (internal): `{ flipH: bool, flipV: bool, rotation: 0|90|180|270 }`.
- Rotations by 90 swap width/height; resize controls must reflect post-transform dims.
## Architecture
New module `src/lib/image-transform.js`:
- `transformRgba(rgba, w, h, { flipH, flipV, rotation }) → { rgba, width, height }`
- Pure function, reusable by CLI.
Applied in pipeline **before** resize so resize targets the post-transform orientation:
```
srcRgba → transformRgba → resizeRgba → rgbaToPalette
```
## Related Code Files
- `src/lib/image-transform.js` (new)
- `src/client/components/ImageImporter.svelte` (toolbar row + state)
- `scripts/image-to-colors.js` (`--rotate 90 --flip-h --flip-v` flags)
- `test/lib/image-transform.test.js` (new)
## Implementation Steps
1. Implement pure transform on flat RGBA: flips are in-row or inter-row swaps; rotations reindex `(x,y) → (y, w-1-x)` etc.
2. Unit-test for all combinations — a known 2x3 grid rotated/flipped and compared pixel-exact.
3. Wire transform state + buttons in importer; pipeline insertion before resize.
4. CLI flags + doc update.
## Todo
- [x] `image-transform.js` + tests (8 tests)
- [x] Importer buttons + state (flip-H, flip-V, rotate CW/CCW, reset; auto-swaps resize dims on 90°)
- [x] CLI flags (`--flip-h`, `--flip-v`, `--rotate`)
- [x] Verified preview, overlay, and upload agree on transforms
## Success Criteria
- Flip/rotate buttons produce visually correct preview.
- `npm test` green.
- CLI output byte-identical between rotating in-app and rotating via CLI.
## Risks
- Low. Pure-function transforms with tests cover correctness.
## Next Steps
- None blocking; proceed to Phase 4 or 5 independently.
@@ -1,55 +0,0 @@
# Phase 04 — More Dithering Algorithms
## Overview
- **Priority:** P2 (medium)
- **Status:** Done
- Add Atkinson, Jarvis, Stucki, Burkes, Sierra variants (error diffusion) and Bayer 2x2/4x4/8x8 ordered dithering so users can pick the visual style that fits their source.
## Key Insights
- Today we only ship Floyd-Steinberg. Atkinson produces softer output (good for photos). Bayer (ordered) creates the classic 8-bit texture (good for retro art).
- All error-diffusion kernels share the same inner loop — differ only in the kernel weights. Factor that out.
- Ordered dithering is a different algorithm: add a matrix-indexed bias before quantizing; no error propagation.
## Requirements
- Dropdown replaces the current dither checkbox: `none`, `floyd`, `atkinson`, `jarvis`, `stucki`, `burkes`, `sierra`, `sierra-lite`, `bayer-2`, `bayer-4`, `bayer-8`.
- Optional `strength` slider 0–1 for error-diffusion blends (default 1).
## Architecture
Refactor `src/lib/image-to-palette.js`:
- Extract a `runErrorDiffusion(rgba, w, h, alphaThreshold, kernel)` with the current Floyd-Steinberg loop generalized to take a kernel `[{ dx, dy, w }, …]`.
- Add `runOrderedDither(rgba, w, h, alphaThreshold, matrix)` using Bayer matrices.
- `rgbaToPalette(rgba, w, h, { alphaThreshold, method })` dispatches on `method`.
- Deprecate `dither: bool` option but keep it mapped to `method: 'floyd'` for back-compat with the CLI until the next breaking release.
## Related Code Files
- `src/lib/image-to-palette.js` (refactor)
- `src/client/components/ImageImporter.svelte` (dropdown + strength slider)
- `scripts/image-to-colors.js` (`--method` flag replaces `--dither`)
- `test/lib/image-to-palette.test.js` (extend)
## Implementation Steps
1. Extract shared kernel runner. Move FS kernel into a table.
2. Add Atkinson, Jarvis, Stucki, Burkes, Sierra, SierraLite kernels (copy weights from WPlace).
3. Add Bayer matrices (2x2, 4x4, 8x8) and the ordered-dither function.
4. Map UI dropdown → `method` option.
5. Extend unit tests: each method runs without throwing, returns correct length, transparent pixels preserved.
6. Visual regression: snapshot-test a fixed gradient across methods.
## Todo
- [x] Refactor FS into generalized error-diffusion runner (`runErrorDiffusion`)
- [x] Add Atkinson/Jarvis/Burkes/Sierra/SierraLite kernels (Stucki dropped; close to Jarvis)
- [x] Add Bayer 2/4/8 ordered dithering (`runOrderedDither`, SPREAD=48)
- [x] UI dropdown (strength slider dropped as YAGNI for now — add if users ask)
- [x] CLI `--dither-method`, kept `--dither` as floyd alias
- [x] Tests (13 cases including all-methods smoke, kernel-weight sanity)
## Success Criteria
- Each method produces distinct, visibly reasonable output on a test gradient.
- Existing `--dither` CLI flag still works (mapped to `method=floyd`).
- Build + tests green.
## Risks
- Kernel-weight typos produce wrong but still-plausible output. Mitigation: unit-test kernel sums equal 1.0.
## Next Steps
- None blocking.
@@ -1,60 +0,0 @@
# Phase 05 — Color Correction Sliders
## Overview
- **Priority:** P2 (medium)
- **Status:** Done
- Sliders to adjust brightness, contrast, saturation, and gamma so photos map better onto our 32-color palette.
## Key Insights
- The 32-color palette is narrow. A slightly dark photo can palette-quantize entirely to black+dark-grey. Bumping brightness/contrast recovers detail.
- Saturation matters because our palette has vivid primaries — desaturating a photo first avoids neon-looking output.
- Gamma interacts well with dithering: mid-gamma lets dithering distribute error over mid-tones.
## Requirements
- Sliders:
- Brightness: -100 to +100 (default 0)
- Contrast: -100 to +100 (default 0)
- Saturation: -100 to +100 (default 0)
- Gamma: 0.1 to 3.0 (default 1.0)
- Reset button.
- Reactive — moving a slider re-quantizes and re-renders preview.
## Architecture
New module `src/lib/image-color-correction.js`:
- `applyColorCorrection(rgba, w, h, { brightness, contrast, saturation, gamma }) → Uint8ClampedArray`
- Pure, framework-free.
Inserted in pipeline after transform, before resize (applies at source resolution, better fidelity):
```
srcRgba → transform → colorCorrect → resize → palette
```
(Order note: applying after resize is cheaper but loses precision on gamma — keep at source res unless preview lag becomes a problem. If so, switch to post-resize.)
## Related Code Files
- `src/lib/image-color-correction.js` (new)
- `src/client/components/ImageImporter.svelte` (slider group)
- `scripts/image-to-colors.js` (flags: `--brightness`, `--contrast`, `--saturation`, `--gamma`)
- `test/lib/image-color-correction.test.js` (new)
## Implementation Steps
1. Implement brightness/contrast/saturation/gamma per-pixel. Reference: WPlace `applyColorCorrection`. Convert RGB→HSV for saturation, adjust S, HSV→RGB.
2. Unit tests: brightness +100 saturates to 255; gamma 1 is identity; etc.
3. Add slider group in importer with live reactive updates (debounce ~50ms if jank).
4. CLI flags.
## Todo
- [x] `image-color-correction.js` + 9 tests
- [x] Collapsible section with 4 sliders + reset
- [x] CLI `--brightness`, `--contrast`, `--saturation`, `--gamma`
- [x] Inserted post-resize (before palette) for slider responsiveness on large sources
## Success Criteria
- Default sliders → output identical to previous pipeline (regression guard).
- Brightness/contrast/saturation/gamma each visibly change output in expected direction.
- Build + tests green.
## Risks
- Perf: re-running full pipeline per slider tick. Mitigation: debounce, or operate on post-resize buffer once resize is stable.
## Next Steps
- None blocking.
@@ -1,57 +0,0 @@
# Phase 06 — Skip-White / Paint-Transparent Toggles
## Overview
- **Priority:** P3 (low, easy win)
- **Status:** Done
- Two cheap toggles: treat near-white pixels as transparent (useful for logos on white backgrounds), and optionally paint pixels with alpha < threshold as white instead of skipping.
## Requirements
- Checkbox "Skip white pixels" + threshold (0–255, default 230).
- Checkbox "Paint transparent pixels as white" (default off).
- Reactive pipeline updates.
## Architecture
Extend `rgbaToPalette` options:
```js
rgbaToPalette(rgba, w, h, {
alphaThreshold,
method,
skipWhite: false,
whiteThreshold: 230,
paintTransparent: false,
});
```
Logic:
- `paintTransparent=true`: alpha < threshold → treat as `(255,255,255,255)` instead of `-1`.
- `skipWhite=true`: if `r,g,b >= whiteThreshold` → output `-1` (skip).
- These are mutually compatible and commute (transparent→white happens first; white-skip sees the filled-in whites).
## Related Code Files
- `src/lib/image-to-palette.js` (extend options)
- `src/client/components/ImageImporter.svelte` (checkboxes + threshold slider)
- `scripts/image-to-colors.js` (`--skip-white`, `--white-threshold`, `--paint-transparent`)
- `test/lib/image-to-palette.test.js` (extend)
## Implementation Steps
1. Extend the `rgbaToPalette` options object; defaults preserve current behavior.
2. Update both `quantizeNearest` and error-diffusion paths to check the new conditions per pixel.
3. UI checkboxes with a small threshold input next to skip-white.
4. CLI flags.
5. Tests: near-white pixel → -1 iff skipWhite on; low-alpha pixel → -1 iff paintTransparent off.
## Todo
- [x] Extend `rgbaToPalette` options (`skipWhite`, `whiteThreshold`, `paintTransparent`)
- [x] UI toggles + threshold slider
- [x] CLI `--skip-white`, `--white-threshold`, `--paint-transparent`
- [x] Tests (4 new cases in image-to-palette.test.js)
## Success Criteria
- With defaults, output matches pre-change output.
- With skipWhite on, a white-background logo uploads only the logo (no background pixels queued).
- Build + tests green.
## Risks
- Order of operations between skipWhite and paintTransparent — tests pin this down.
## Next Steps
- After Phase 6 ships, revisit parked items: multi-algorithm color distance (Lab/Oklab), Kuwahara, template save/load, repair mode.
@@ -1,43 +0,0 @@
# Image Importer Enhancements
Reference: [WPlace-AutoBOT image-processor.js](https://github.com/Wplace-AutoBot/WPlace-AutoBOT/blob/main/Extension/scripts/image-processor.js).
## Goal
Grow the in-app Image Import panel into a full pre-placement pipeline: resize, transforms, tune, quantize with multiple dither algorithms, overlay-preview on canvas, then upload.
## Principles
- YAGNI/KISS — only add the features with clear UX value on our 2048x2048, 32-color canvas.
- DRY — all pixel transforms live in `src/lib/image-*.js` and are reused by the CLI (`scripts/image-to-colors.js`).
- Keep reactivity driven by `$effect` over a cached source RGBA buffer; each toggle re-runs the pipeline without re-decoding the file.
- Never block manual drawing; uploader continues sharing the server-side rate limit.
## Phases
| # | Phase | Status | Value |
|---|---|---|---|
| 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Done | Medium — quick fixes without re-editing source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Done | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Done | Medium — photos benefit most |
| 6 | [Skip-white + paint-transparent toggles](phase-06-skip-white.md) | Done | Low — easy win, often useful for logos |
Later / parked: multi-algorithm color distance (Lab/Oklab), Kuwahara smoothing, template save/load, repair mode. Revisit after Phase 6.
## Dependencies
- Phase 1 (resize) blocks Phase 2 (overlay needs final dimensions) and Phase 3 (transforms sit before or after resize, but the pipeline shape is set by Phase 1).
- Phases 4, 5, 6 are independent of each other.
## Shared pipeline shape (locked after Phase 1)
```
File → decode → srcRgba
↓ (reactive on: resize/transforms/color-correction/dither/skip-white)
pipeline()
↓
Int16Array paletteIndices
↓
preview (palette → RGBA)
+ buildPixels(originX, originY, skipMatching) → upload
```
All steps operate on RGBA buffers so they compose. `rgbaToPalette` stays the final step.
+1 -17
View File
@@ -1,14 +1,12 @@
<script>
import { MAX_CREDITS, CREDIT_REGEN_RATE, MAX_BATCH_SIZE } from '../lib/constants.js';
import { MAX_BATCH_SIZE } from '../lib/constants.js';
import CanvasRenderer from './components/CanvasRenderer.svelte';
import ColorPicker from './components/ColorPicker.svelte';
import CanvasControls from './components/CanvasControls.svelte';
import DrawToolbar from './components/DrawToolbar.svelte';
import UserInfo from './components/UserInfo.svelte';
import ImageImporter from './components/ImageImporter.svelte';
let selectedColor = $state(27); // black
let credits = $state(MAX_CREDITS);
let cursorPos = $state({ x: 0, y: 0 });
let zoom = $state(1);
let mode = $state('paint');
@@ -27,16 +25,6 @@
toastTimer = setTimeout(() => { toast = null; }, ttlMs);
}
// Client-side credit regeneration (server corrects on submit)
$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;
let isReconnect = false;
@@ -113,7 +101,6 @@
if (!res.ok) {
if (res.status === 429) {
const retryAfter = data?.retryAfter ?? '?';
if (typeof data?.remaining === 'number') credits = data.remaining;
showToast('error', `Rate limited — try again in ${retryAfter}s.`, 6000);
} else if (res.status === 413) {
showToast('error', 'Request too large. Reduce batch size.');
@@ -126,7 +113,6 @@
}
if (data?.ok) {
credits = data.credits;
canvasRenderer.commitPending();
showToast('info', 'Submitted', 1500);
} else {
@@ -177,7 +163,6 @@
{submitting}
/>
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
<UserInfo {credits} />
{#if !importerOpen}
<button class="import-btn" onclick={() => importerOpen = true}
@@ -192,7 +177,6 @@
getCommittedColor={(x, y) => canvasRenderer?.getCommittedColor(x, y) ?? -1}
setOverlay={(o) => canvasRenderer?.setOverlay(o)}
onClose={() => importerOpen = false}
onCredits={(c) => credits = c}
/>
{#if toast}
+3 -4
View File
@@ -1,12 +1,12 @@
<script>
import { CANVAS_WIDTH, CANVAS_HEIGHT } from '../../lib/constants.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from '../../lib/constants.js';
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
let { open, cursorPos, getCommittedColor, setOverlay, onClose } = $props();
// Source image (decoded, palette-mapped)
let fileName = $state(null);
@@ -255,7 +255,6 @@
uploader = createImageUploader({
onProgress: (p) => { placed = p; },
onCredits: (c) => onCredits?.(c),
onStatus: (s) => { statusText = s; },
onError: (e) => { errorText = e.message || String(e); },
});
@@ -288,7 +287,7 @@
const pct = $derived(total > 0 ? (placed / total) * 100 : 0);
const etaSec = $derived(
status === 'running' && total > placed
? Math.ceil((total - placed)) // ~1 pixel/sec steady state
? Math.ceil((total - placed) / MAX_BATCH_SIZE) * REQUEST_COOLDOWN_SEC
: null,
);
</script>
-80
View File
@@ -1,80 +0,0 @@
<script>
import { MAX_CREDITS } from '../../lib/constants.js';
let { credits } = $props();
let fillPercent = $derived(Math.round((credits / MAX_CREDITS) * 100));
let isFull = $derived(credits >= MAX_CREDITS);
</script>
<div class="user-info">
<div class="credits">
<span class="label">Pixels</span>
<span class="value" class:full={isFull}>{credits}</span>
<span class="max">/ {MAX_CREDITS}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
class:full={isFull}
style="width: {fillPercent}%"
></div>
</div>
</div>
<style>
.user-info {
position: fixed;
top: 16px;
left: 16px;
padding: 8px 12px;
background: rgba(0, 0, 0, 0.85);
border-radius: 8px;
backdrop-filter: blur(8px);
z-index: 10;
min-width: 120px;
}
.credits {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 0.9rem;
margin-bottom: 6px;
}
.label { color: #888; }
.value {
font-weight: bold;
color: #fbbf24;
font-variant-numeric: tabular-nums;
transition: color 0.3s;
}
.value.full { color: #4ade80; }
.max {
color: #555;
font-size: 0.8rem;
}
.bar-track {
width: 100%;
height: 4px;
background: #333;
border-radius: 2px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: #fbbf24;
border-radius: 2px;
transition: width 0.3s ease, background-color 0.3s;
}
.bar-fill.full {
background: #4ade80;
}
</style>
+3 -5
View File
@@ -7,11 +7,9 @@ export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
export const BITS_PER_PIXEL = 5;
export const MAX_COLORS = 32;
/** Rate limiting — stackable credit system */
export const CREDIT_REGEN_RATE = 1; // credits per second
export const MAX_CREDITS = 256;
// Batch cannot exceed credits cap — anything larger is guaranteed-rejected by rate-limit.
export const MAX_BATCH_SIZE = MAX_CREDITS;
/** Rate limiting — one request per second per user, batch size independent. */
export const REQUEST_COOLDOWN_SEC = 1;
export const MAX_BATCH_SIZE = 2048;
/** Redis keys */
export const REDIS_KEY_PREFIX = 'rplace:';
+28 -40
View File
@@ -1,22 +1,22 @@
import { MAX_BATCH_SIZE, MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
import { MAX_BATCH_SIZE, REQUEST_COOLDOWN_SEC } from './constants.js';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const COOLDOWN_MS = REQUEST_COOLDOWN_SEC * 1000;
/**
* Browser-side throttled uploader for batches of pixels.
* Tracks credits locally (regen at CREDIT_REGEN_RATE/sec, capped at MAX_CREDITS),
* backs off on 429, and yields control between batches so the UI stays responsive.
* Browser-side uploader for batches of pixels.
* Server enforces 1 request per second per user (batch-size independent),
* so we send batches up to MAX_BATCH_SIZE spaced by COOLDOWN_MS. On 429 we
* back off using retryAfter. Pause/abort are cooperative.
*
* Lifecycle: create → await run(pixels) → resolves when finished or aborted.
* Call pause()/resume()/abort() from UI to steer.
*
* @param {Object} hooks
* @param {(placed: number, total: number) => void} [hooks.onProgress]
* @param {(credits: number) => void} [hooks.onCredits]
* @param {(message: string) => void} [hooks.onStatus] - human-readable status line
* @param {(error: Error) => void} [hooks.onError] - recoverable errors (uploader retries)
*/
export function createImageUploader({ onProgress, onCredits, onStatus, onError } = {}) {
export function createImageUploader({ onProgress, onStatus, onError } = {}) {
let aborted = false;
let paused = false;
@@ -24,37 +24,35 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
while (paused && !aborted) await sleep(150);
}
/** Interruptible sleep — wakes on abort; pause is handled by the outer loop. */
async function cooperativeSleep(ms) {
const end = Date.now() + ms;
while (Date.now() < end && !aborted && !paused) {
await sleep(Math.min(250, end - Date.now()));
}
}
async function run(pixels) {
aborted = false;
paused = false;
let credits = MAX_CREDITS; // optimistic; server corrects on first response
let lastUpdate = Date.now();
let placed = 0;
const total = pixels.length;
let nextSendAt = 0; // epoch ms; first send goes out immediately
while (placed < total && !aborted) {
await waitWhilePaused();
if (aborted) break;
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
// Refresh local credit estimate with elapsed regen.
const now = Date.now();
credits = Math.min(MAX_CREDITS, credits + ((now - lastUpdate) / 1000) * CREDIT_REGEN_RATE);
lastUpdate = now;
if (credits < batch.length) {
const waitSec = Math.ceil((batch.length - credits) / CREDIT_REGEN_RATE);
onStatus?.(`Waiting ${waitSec}s for credits…`);
// Split sleep so pause/abort feel responsive.
const end = Date.now() + waitSec * 1000;
while (Date.now() < end && !aborted && !paused) {
await sleep(Math.min(250, end - Date.now()));
}
continue; // re-check regen & pause on next loop
const waitMs = nextSendAt - Date.now();
if (waitMs > 0) {
onStatus?.(`Waiting ${Math.ceil(waitMs / 1000)}s…`);
await cooperativeSleep(waitMs);
continue;
}
const batch = pixels.slice(placed, placed + MAX_BATCH_SIZE);
onStatus?.(`Sending ${batch.length} pixels…`);
let res;
try {
res = await fetch('/api/place', {
@@ -65,18 +63,15 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
} catch (err) {
onError?.(err);
onStatus?.(`Network error, retrying in 3s…`);
await sleep(3000);
await cooperativeSleep(3000);
continue;
}
if (res.status === 429) {
const body = await res.json().catch(() => ({}));
const wait = Math.max(1, body.retryAfter ?? 2);
if (typeof body.remaining === 'number') onCredits?.(body.remaining);
credits = 0;
lastUpdate = Date.now();
onStatus?.(`Rate limited, waiting ${wait}s…`);
await sleep(wait * 1000);
const waitSec = Math.max(1, body.retryAfter ?? REQUEST_COOLDOWN_SEC);
onStatus?.(`Rate limited, waiting ${waitSec}s…`);
nextSendAt = Date.now() + waitSec * 1000;
continue;
}
@@ -84,19 +79,12 @@ export function createImageUploader({ onProgress, onCredits, onStatus, onError }
const text = await res.text().catch(() => '');
const err = new Error(`HTTP ${res.status}: ${text}`);
onError?.(err);
// Non-recoverable: stop here so user sees the error.
return { placed, total, aborted: false, error: err };
}
const body = await res.json().catch(() => ({}));
if (typeof body.credits === 'number') {
credits = body.credits;
onCredits?.(body.credits);
lastUpdate = Date.now();
}
placed += batch.length;
onProgress?.(placed, total);
nextSendAt = Date.now() + COOLDOWN_MS;
}
return { placed, total, aborted };
+15 -68
View File
@@ -1,76 +1,23 @@
import { getRedis } from './redis-client.js';
import { MAX_CREDITS, CREDIT_REGEN_RATE, REDIS_KEY_PREFIX } from './constants.js';
import { REQUEST_COOLDOWN_SEC, REDIS_KEY_PREFIX } from './constants.js';
/**
* Lua script for atomic check-and-deduct of stackable credits.
* Stores lastUpdate as ms (avoids fractional credit loss across calls).
* Returns: [allowed (0/1), remaining, retryAfterSeconds]
*/
const CREDIT_SCRIPT = `
local now = tonumber(ARGV[2])
local maxCredits = tonumber(ARGV[3])
local regen = tonumber(ARGV[4])
local count = tonumber(ARGV[1])
local lastUpdate = now
local credits = maxCredits
local data = redis.call('HGETALL', KEYS[1])
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'cr' then credits = tonumber(data[i+1]) end
end
end
local elapsedMs = now - lastUpdate
local msPerCredit = 1000 / regen
local accruedDelta = math.floor(elapsedMs / msPerCredit)
local accrued = math.min(maxCredits, credits + accruedDelta)
if accrued < count then
local deficit = count - accrued
local retryAfter = math.ceil(deficit * msPerCredit / 1000)
return {0, accrued, retryAfter}
end
-- Advance lastUpdate by exact ms used to accrue credits (preserves fractional residue).
-- When capped at maxCredits, discard residue (else lu drifts arbitrarily far back).
local newLastUpdate
if credits + accruedDelta > maxCredits then
newLastUpdate = now
else
newLastUpdate = lastUpdate + math.floor(accruedDelta * msPerCredit)
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lu', newLastUpdate, 'cr', remaining)
redis.call('EXPIRE', KEYS[1], 86400)
return {1, remaining, 0}
`;
/**
* Check and deduct credits for a user's pixel placement.
* One request per user per REQUEST_COOLDOWN_SEC.
* Batch size is independent of the cooldown — the caller validates it separately.
*
* Uses SET NX EX atomically: the first request in a window wins, subsequent
* requests return null until the key expires.
*
* @param {object} env
* @param {string} userId
* @param {number} count
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
* retryAfter is in seconds.
* @returns {Promise<{allowed: boolean, retryAfter: number}>}
*/
export async function checkAndDeductCredits(env, userId, count) {
export async function checkRateLimit(env, userId) {
const redis = getRedis(env);
const nowMs = Date.now();
const key = `${REDIS_KEY_PREFIX}credits:${userId}`;
const result = await redis.eval(
CREDIT_SCRIPT,
[key],
[count, nowMs, MAX_CREDITS, CREDIT_REGEN_RATE],
);
return {
allowed: result[0] === 1,
remaining: result[1],
retryAfter: result[2],
};
const key = `${REDIS_KEY_PREFIX}cooldown:${userId}`;
const result = await redis.set(key, '1', { nx: true, ex: REQUEST_COOLDOWN_SEC });
if (result === 'OK') {
return { allowed: true, retryAfter: 0 };
}
return { allowed: false, retryAfter: REQUEST_COOLDOWN_SEC };
}
+5 -7
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono';
import { getFullCanvas, setPixels } from './lib/canvas-storage.js';
import { getUserId } from './lib/get-user-id.js';
import { checkAndDeductCredits } from './lib/rate-limiter.js';
import { checkRateLimit } from './lib/rate-limiter.js';
import { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
export { CanvasRoom } from './durable-objects/canvas-room.js';
@@ -67,13 +67,11 @@ app.post('/api/place', async (c) => {
}
}
// Rate limiting
// Rate limiting — 1 request per second per user, regardless of batch size.
const userId = await getUserId(c.req.raw);
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
c.env, userId, pixels.length,
);
const { allowed, retryAfter } = await checkRateLimit(c.env, userId);
if (!allowed) {
return c.json({ error: 'rate_limited', remaining, retryAfter }, 429);
return c.json({ error: 'rate_limited', retryAfter }, 429);
}
// Persist pixels (must succeed before broadcast)
@@ -95,7 +93,7 @@ app.post('/api/place', async (c) => {
broadcastTask.catch((err) => console.error('Broadcast:', err));
}
return c.json({ ok: true, credits: remaining });
return c.json({ ok: true });
});
async function broadcastPixels(env, pixels) {
+18 -68
View File
@@ -153,82 +153,32 @@ describe('Redis BITFIELD canvas round-trip', () => {
});
});
describe('Redis rate limiter Lua script', () => {
const CREDIT_SCRIPT = `
local data = redis.call('HGETALL', KEYS[1])
local lastUpdate = 0
local credits = tonumber(ARGV[3])
describe('Redis rate limiter cooldown (SET NX EX)', () => {
const COOLDOWN_SEC = 1;
const key = 'rplace:cooldown:test-user';
if #data > 0 then
for i = 1, #data, 2 do
if data[i] == 'lu' then lastUpdate = tonumber(data[i+1]) end
if data[i] == 'cr' then credits = tonumber(data[i+1]) end
end
end
local elapsed = tonumber(ARGV[2]) - lastUpdate
local accrued = math.min(tonumber(ARGV[3]), credits + math.floor(elapsed * tonumber(ARGV[4])))
local count = tonumber(ARGV[1])
if accrued < count then
return {0, accrued, count - accrued}
end
local remaining = accrued - count
redis.call('HSET', KEYS[1], 'lu', ARGV[2], 'cr', remaining)
redis.call('EXPIRE', KEYS[1], 86400)
return {1, remaining, 0}
`;
const MAX_CREDITS = 256;
const REGEN_RATE = 1;
const key = 'rplace:credits:test-user';
async function checkCredits(count, now) {
return redis.eval(CREDIT_SCRIPT, 1, key, count, now, MAX_CREDITS, REGEN_RATE);
/** Mirrors checkRateLimit: SET key "1" NX EX <cooldown>. */
async function tryAcquire() {
const res = await redis.set(key, '1', 'EX', COOLDOWN_SEC, 'NX');
return res === 'OK';
}
it('grants full credits to new user', async () => {
it('allows first request for a fresh user', async () => {
await redis.del(key);
const result = await checkCredits(1, 1000);
expect(result[0]).toBe(1); // allowed
expect(result[1]).toBe(255); // remaining
expect(result[2]).toBe(0); // retryAfter
expect(await tryAcquire()).toBe(true);
});
it('deducts credits correctly', async () => {
it('rejects second request within cooldown window', async () => {
await redis.del(key);
await checkCredits(10, 1000);
// 256 - 10 = 246, no time passed so no regen
const result = await checkCredits(5, 1000);
expect(result[0]).toBe(1);
expect(result[1]).toBe(241); // 246 - 5
expect(await tryAcquire()).toBe(true);
expect(await tryAcquire()).toBe(false);
});
it('regenerates credits over time', async () => {
it('allows again after cooldown expires', async () => {
await redis.del(key);
await checkCredits(256, 1000); // spend all (remaining = 0)
// Wait 10 seconds → 10 credits regenerated
const result = await checkCredits(5, 1010);
expect(result[0]).toBe(1);
expect(result[1]).toBe(5); // 10 regen - 5 spent
});
it('rejects when insufficient credits', async () => {
await redis.del(key);
await checkCredits(256, 1000); // spend all
// No time passed, 0 credits
const result = await checkCredits(1, 1000);
expect(result[0]).toBe(0); // denied
expect(result[2]).toBe(1); // retryAfter = 1 credit needed
});
it('caps credits at MAX_CREDITS', async () => {
await redis.del(key);
await checkCredits(1, 1000); // remaining = 255
// Wait a very long time → should cap at 256
const result = await checkCredits(1, 2000);
expect(result[0]).toBe(1);
expect(result[1]).toBe(255); // 256 (capped) - 1
});
expect(await tryAcquire()).toBe(true);
// Wait slightly longer than cooldown for the EX key to expire.
await new Promise((r) => setTimeout(r, (COOLDOWN_SEC * 1000) + 100));
expect(await tryAcquire()).toBe(true);
}, 5000);
});
+8 -9
View File
@@ -7,16 +7,14 @@ vi.mock('../src/lib/canvas-storage.js', () => ({
setPixels: vi.fn(() => Promise.resolve()),
}));
vi.mock('../src/lib/rate-limiter.js', () => ({
checkAndDeductCredits: vi.fn(() =>
Promise.resolve({ allowed: true, remaining: 100, retryAfter: 0 }),
),
checkRateLimit: vi.fn(() => Promise.resolve({ allowed: true, retryAfter: 0 })),
}));
vi.mock('../src/durable-objects/canvas-room.js', () => ({
CanvasRoom: class {},
}));
import app from '../src/worker.js';
import { checkAndDeductCredits } from '../src/lib/rate-limiter.js';
import { checkRateLimit } from '../src/lib/rate-limiter.js';
/** Helper to create POST request */
function postPlace(body) {
@@ -127,23 +125,24 @@ describe('POST /api/place validation', () => {
});
it('returns 429 when rate limited', async () => {
checkAndDeductCredits.mockResolvedValue({ allowed: false, remaining: 0, retryAfter: 5 });
checkRateLimit.mockResolvedValue({ allowed: false, retryAfter: 1 });
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
expect(res.status).toBe(429);
expect((await res.json()).error).toBe('rate_limited');
const data = await res.json();
expect(data.error).toBe('rate_limited');
expect(data.retryAfter).toBe(1);
});
it('accepts valid pixel placement', async () => {
checkAndDeductCredits.mockResolvedValue({ allowed: true, remaining: 255, retryAfter: 0 });
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
const res = await app.fetch(postPlace({ pixels: [{ x: 0, y: 0, color: 0 }] }), env);
expect(res.status).toBe(200);
const data = await res.json();
expect(data.ok).toBe(true);
expect(data.credits).toBe(255);
});
it('accepts boundary pixel values', async () => {
checkAndDeductCredits.mockResolvedValue({ allowed: true, remaining: 255, retryAfter: 0 });
checkRateLimit.mockResolvedValue({ allowed: true, retryAfter: 0 });
const res = await app.fetch(postPlace({
pixels: [{ x: CANVAS_WIDTH - 1, y: CANVAS_HEIGHT - 1, color: MAX_COLORS - 1 }],
}), env);