diff --git a/plans/260416-1513-rplace-implementation/phase-01-project-setup.md b/plans/260416-1513-rplace-implementation/phase-01-project-setup.md new file mode 100644 index 0000000..aa4dfa9 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-01-project-setup.md @@ -0,0 +1,140 @@ +--- +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. diff --git a/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md b/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md new file mode 100644 index 0000000..a52d5e5 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-02-canvas-backend.md @@ -0,0 +1,143 @@ +--- +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). diff --git a/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md b/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md new file mode 100644 index 0000000..9adaafa --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-03-rate-limiting.md @@ -0,0 +1,166 @@ +--- +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. diff --git a/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md b/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md new file mode 100644 index 0000000..9647d9c --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-04-real-time-updates.md @@ -0,0 +1,170 @@ +--- +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 +// - 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). diff --git a/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md b/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md new file mode 100644 index 0000000..bcaa904 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-05-frontend-canvas.md @@ -0,0 +1,190 @@ +--- +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). diff --git a/plans/260416-1513-rplace-implementation/phase-06-authentication.md b/plans/260416-1513-rplace-implementation/phase-06-authentication.md new file mode 100644 index 0000000..f7dcba0 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-06-authentication.md @@ -0,0 +1,148 @@ +--- +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. diff --git a/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md b/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md new file mode 100644 index 0000000..d265362 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/phase-07-polish-and-deploy.md @@ -0,0 +1,95 @@ +--- +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. diff --git a/plans/260416-1513-rplace-implementation/plan.md b/plans/260416-1513-rplace-implementation/plan.md new file mode 100644 index 0000000..3523696 --- /dev/null +++ b/plans/260416-1513-rplace-implementation/plan.md @@ -0,0 +1,62 @@ +--- +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