mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
1 parent
2d61225ee4
commit
f59e55a852
29 files changed
+119
-1918
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user