mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
add implementation plan for rplace pixel canvas
7-phase plan: setup, canvas backend (Redis BITFIELD), rate limiting (stackable credits), SSE real-time updates, frontend canvas, auth, deploy.
This commit is contained in:
1 parent
62188d26d2
commit
1c6b2edcff
8 files changed
+1114
No files matched your search
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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<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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Reference in new issue
Block a user