mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
refactor: migrate from Next.js/Vercel to Svelte/Hono/Cloudflare Workers
Replace Next.js + SSE with Svelte frontend + Hono API + Durable Objects WebSocket on Cloudflare Workers. Single worker serves static assets and API routes. Native WebSocket replaces SSE polling for real-time updates. 17kb gzipped client bundle vs 90kb+ React runtime.
This commit is contained in:
1 parent
3beaa9d43f
commit
2ccf1c9779
40 files changed
+2653
-5630
No files matched your search
@@ -1,8 +1,2 @@
|
|||||||
UPSTASH_REDIS_REST_URL=
|
UPSTASH_REDIS_REST_URL=
|
||||||
UPSTASH_REDIS_REST_TOKEN=
|
UPSTASH_REDIS_REST_TOKEN=
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
|
||||||
NEXTAUTH_SECRET=
|
|
||||||
GOOGLE_CLIENT_ID=
|
|
||||||
GOOGLE_CLIENT_SECRET=
|
|
||||||
GITHUB_CLIENT_ID=
|
|
||||||
GITHUB_CLIENT_SECRET=
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { defineConfig, globalIgnores } from "eslint/config";
|
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
|
||||||
...nextVitals,
|
|
||||||
// Override default ignores of eslint-config-next.
|
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
|
|
||||||
export default eslintConfig;
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
/* config options here */
|
|
||||||
};
|
|
||||||
|
|
||||||
export default nextConfig;
|
|
||||||
Generated
+1747
-4760
File diff suppressed because it is too large.
Load diff
+13
-12
@@ -1,21 +1,22 @@
|
|||||||
{
|
{
|
||||||
"name": "rplace-init",
|
"name": "rplace",
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "wrangler dev",
|
||||||
"build": "next build",
|
"dev:client": "vite dev",
|
||||||
"start": "next start",
|
"build": "vite build",
|
||||||
"lint": "eslint"
|
"deploy": "vite build && wrangler deploy",
|
||||||
|
"preview": "vite build && wrangler dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@upstash/redis": "^1.37.0",
|
"@upstash/redis": "^1.34.3",
|
||||||
"next": "16.2.4",
|
"hono": "^4.7.6"
|
||||||
"react": "19.2.4",
|
|
||||||
"react-dom": "19.2.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^9",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"eslint-config-next": "16.2.4"
|
"svelte": "^5.28.2",
|
||||||
|
"vite": "^6.3.2",
|
||||||
|
"wrangler": "^4.14.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Hosting Platform Comparison: Vercel vs Netlify vs Cloudflare for r/place Clone
|
||||||
|
|
||||||
|
**Date:** 2026-04-16
|
||||||
|
**Scope:** Real-time collaborative pixel canvas (r/place clone) with Next.js, Upstash Redis, SSE, and potential viral traffic spikes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison Matrix
|
||||||
|
|
||||||
|
| Criterion | Vercel Hobby | Netlify Free | Cloudflare Workers Free |
|
||||||
|
|-----------|--------------|--------------|-------------------------|
|
||||||
|
| **Free Tier Invocations/Month** | 1M | 125k | 3M (100k/day) |
|
||||||
|
| **Function Timeout** | 10s (60s with config) | 10s (26s on Pro) | 10ms CPU per request |
|
||||||
|
| **SSE/Streaming Support** | ✅ 60s (serverless) / 300s (edge) | ✅ 20 MB payload | ✅ 30s per WebSocket msg |
|
||||||
|
| **WebSocket Support** | ❌ No | ❌ No | ✅ Yes (Durable Objects) |
|
||||||
|
| **Cold Start Latency** | ~1s | 3+ seconds | <5ms (V8 isolates) |
|
||||||
|
| **Bandwidth Included** | 100GB | 100GB | Unlimited |
|
||||||
|
| **SSE Max Duration** | 60s base, 300s with Edge | 10s (26s Pro) | No limit (WebSocket) |
|
||||||
|
| **Concurrent Connections** | Framework-limited | High | 6 simultaneous fetches |
|
||||||
|
| **Upstash Redis** | ✅ REST API | ✅ REST API | ✅ REST API + native SDK |
|
||||||
|
| **Edge Runtime** | ✅ (300s) | ❌ Via Edge Functions | ✅ (V8 isolate, default) |
|
||||||
|
| **Durable Objects / Stateful** | ❌ | ❌ | ✅ (SQLite-backed on free) |
|
||||||
|
| **Cost at 10M Req/Month** | ~$6 (1M free) | $2/100k invocations | $0.30/M (sub-$5) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Free Tier Breakdown
|
||||||
|
|
||||||
|
### Vercel Hobby
|
||||||
|
- **Invocations:** 1M/month, 100 deployments/day
|
||||||
|
- **Bandwidth:** 100GB included
|
||||||
|
- **SSE Support:** Serverless (10s default, 60s configurable) or Edge Functions (25s to first byte, then 300s total)
|
||||||
|
- **Pain Point:** Timeout too short for long-lived SSE without upgrade. WebSocket not supported.
|
||||||
|
- **Recommendation:** Viable only with aggressive client-side batching or Edge Functions.
|
||||||
|
|
||||||
|
### Netlify Free
|
||||||
|
- **Invocations:** 125k/month
|
||||||
|
- **Bandwidth:** 100GB included
|
||||||
|
- **SSE Support:** 10s timeout by default, extendable to 26s on Pro
|
||||||
|
- **Edge Functions:** 50ms CPU time (idle time doesn't count, better for SSE)
|
||||||
|
- **Pain Point:** Lowest free invocation quota; shared 50MB memory default
|
||||||
|
- **Recommendation:** Weakest choice for real-time pixel canvas due to invocation limits and timeout constraints.
|
||||||
|
|
||||||
|
### Cloudflare Workers Free
|
||||||
|
- **Requests:** 100k/day (3M/month), unlimited after paid
|
||||||
|
- **CPU Time:** 10ms per request (idle time excluded for WebSocket)
|
||||||
|
- **WebSocket Support:** Native via Durable Objects; 32 MiB message size
|
||||||
|
- **Durable Objects:** 5 GB free storage (SQLite-backed)
|
||||||
|
- **Cold Starts:** <5ms globally
|
||||||
|
- **Pain Point:** 6 concurrent fetch limit; eventually-consistent KV; 1 write/sec/key limit
|
||||||
|
- **Recommendation:** Best architectural fit for real-time apps; native WebSocket preferred over SSE.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSE vs WebSocket: Technical Tradeoffs
|
||||||
|
|
||||||
|
**SSE (Vercel, Netlify):**
|
||||||
|
- Simpler to implement (HTTP-based)
|
||||||
|
- Max duration: 60–300s depending on platform
|
||||||
|
- Client reconnection logic required on timeout
|
||||||
|
- ~3–5KB overhead per connection
|
||||||
|
- Scaling: 2.5MB canvas = ~833 concurrent viewers at 3KB/sec
|
||||||
|
|
||||||
|
**WebSocket (Cloudflare):**
|
||||||
|
- Bi-directional communication (publish-subscribe patterns)
|
||||||
|
- No timeout; runs until connection closed
|
||||||
|
- Lower per-message overhead (~200 bytes)
|
||||||
|
- Durable Objects provide room-scoped state (excellent for isolated pixel groups)
|
||||||
|
- Scaling: Single Durable Object handles ~50–100 WebSocket connections
|
||||||
|
|
||||||
|
**Recommendation for r/place:** WebSocket + Durable Objects (Cloudflare) is architecturally superior. Eliminates timeout pain, enables efficient room-based scaling, and handles viral traffic spikes better.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Redis vs Alternatives
|
||||||
|
|
||||||
|
### Upstash Redis (BITFIELD)
|
||||||
|
- **All platforms:** Compatible via REST API
|
||||||
|
- **Performance:** Handles BITFIELD operations (critical for pixel state as bitmaps)
|
||||||
|
- **Latency:** ~50–100ms globally
|
||||||
|
- **r/place fit:** Essential for efficient canvas storage (1M pixels = 125KB bitfield)
|
||||||
|
- **Cost:** Free tier (10k commands), then $0.0001/command
|
||||||
|
|
||||||
|
### Cloudflare Workers KV (Alternative)
|
||||||
|
- **Write Limit:** 1 write/sec/key (dealbreaker for high-frequency updates)
|
||||||
|
- **No BITFIELD:** Cannot efficiently store pixel bitmaps
|
||||||
|
- **Eventual Consistency:** Unacceptable for pixel canvas state
|
||||||
|
- **Verdict:** Not suitable; stick with Upstash Redis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Adoption Risk & Maturity
|
||||||
|
|
||||||
|
**Vercel (Lowest Risk)**
|
||||||
|
- De facto Next.js standard
|
||||||
|
- Excellent DX, tight framework integration
|
||||||
|
- Proven at scale (millions of sites)
|
||||||
|
- **Risk:** SSE timeout constraints; may need Edge Functions workaround for long streams
|
||||||
|
|
||||||
|
**Netlify (Medium Risk)**
|
||||||
|
- Mature platform but slower cold starts
|
||||||
|
- Free tier invocation quota is limiting for real-time workloads
|
||||||
|
- **Risk:** Function timeout + low quota = poor scaling for spikes
|
||||||
|
|
||||||
|
**Cloudflare Workers (Low Risk, High Reward)**
|
||||||
|
- Production-ready; powers millions of requests
|
||||||
|
- OpenNext adapter (1.0-beta) adds Next.js 14/15/16 support
|
||||||
|
- **Risk:** OpenNext is newer than @vercel/next; Node.js runtime adds ~50ms overhead vs V8 isolates. Adoption of Durable Objects is lower than serverless.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scaling to Viral Traffic
|
||||||
|
|
||||||
|
**Scenario:** r/place unexpectedly spikes from 100 users to 100k users in 1 hour.
|
||||||
|
|
||||||
|
| Platform | Behavior | Cost Impact |
|
||||||
|
|----------|----------|------------|
|
||||||
|
| **Vercel** | Scales function invocations; may hit free tier quota ($6 overage) | Moderate |
|
||||||
|
| **Netlify** | Hits 125k invocation cap within minutes; service degradation | Severe (quota suspension) |
|
||||||
|
| **Cloudflare** | Handles surge gracefully at $0.30/M cost | Minimal ($30 for 100M req) |
|
||||||
|
|
||||||
|
**Winner:** Cloudflare. Auto-scaling, low marginal cost, no quota walls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Next.js App Router on Cloudflare
|
||||||
|
|
||||||
|
**OpenNext Cloudflare Adapter (1.0-beta)**
|
||||||
|
- ✅ Supports Next.js 14, 15, 16
|
||||||
|
- ✅ App Router fully supported
|
||||||
|
- ✅ Incremental Static Regeneration (ISR) works
|
||||||
|
- ❌ Node.js runtime (slower than V8 isolates by ~50ms)
|
||||||
|
- ⚠️ Beta status; breaking changes possible
|
||||||
|
|
||||||
|
**Setup:** `npm install -D @opennextjs/cloudflare` + `wrangler.toml` configuration.
|
||||||
|
|
||||||
|
**Gotcha:** SSE doesn't natively work in serverless. Use **Durable Objects WebSocket** instead for real-time updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment DX
|
||||||
|
|
||||||
|
| Platform | Preview Deploys | Git Integration | Local Dev | CI/CD |
|
||||||
|
|----------|-----------------|-----------------|-----------|-------|
|
||||||
|
| **Vercel** | Instant per PR | Native; auto-deploy | `vercel dev` | First-class |
|
||||||
|
| **Netlify** | Instant per PR | Native; auto-deploy | `netlify dev` | Good |
|
||||||
|
| **Cloudflare** | Via `wrangler publish` | Requires setup | `wrangler dev` | Requires scripts |
|
||||||
|
|
||||||
|
**DX Winner:** Vercel (tightest Next.js integration), but Cloudflare's `wrangler` is improving rapidly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendation
|
||||||
|
|
||||||
|
### For Hobby/MVP (Simplicity First)
|
||||||
|
**Vercel Hobby** — Start here. Easy Next.js deployment, sufficient free tier for initial testing. **Accept:** SSE timeout constraints; use client-side reconnection logic or split long streams.
|
||||||
|
|
||||||
|
### For Scale-Ready Production (Real-Time First)
|
||||||
|
**Cloudflare Workers + Durable Objects** — Best architecture for r/place:
|
||||||
|
- ✅ Native WebSocket support (no timeout)
|
||||||
|
- ✅ Durable Objects for room-based pixel groups
|
||||||
|
- ✅ Sub-5ms cold starts
|
||||||
|
- ✅ Scales to viral traffic at <$30/month
|
||||||
|
- ✅ Upstash Redis compatibility (REST API)
|
||||||
|
- ⚠️ Requires OpenNext adapter (beta); monitor releases
|
||||||
|
|
||||||
|
**Hybrid Approach (Safe):**
|
||||||
|
Deploy on **Vercel** initially. When traffic patterns stabilize and WebSocket needs are confirmed, migrate API layer to **Cloudflare Workers** while keeping Next.js frontend on Vercel (edge functions for canvas downloads, Workers for real-time WebSocket).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Unresolved Questions
|
||||||
|
|
||||||
|
1. **Durable Objects namespace limits:** How many rooms (pixel groups) can a single namespace handle before rate-limiting?
|
||||||
|
2. **OpenNext adapter stability:** Expected timeline for 1.0 release; any known breaking changes between beta and GA?
|
||||||
|
3. **Upstash Redis + Durable Objects:** Can Durable Objects efficiently call Upstash REST API, or should canvas state be mirrored in both KV and Redis?
|
||||||
|
4. **Client payload size:** 2.5MB canvas download on initial load—confirmed acceptable on mobile?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
- [Vercel Limits Documentation](https://vercel.com/docs/limits)
|
||||||
|
- [Vercel Functions Limitations](https://vercel.com/docs/functions/limitations)
|
||||||
|
- [Vercel Edge Functions Streaming](https://vercel.com/blog/streaming-for-serverless-node-js-and-edge-runtimes-with-vercel-functions)
|
||||||
|
- [Netlify Pricing](https://www.netlify.com/pricing/)
|
||||||
|
- [Netlify Functions Overview](https://docs.netlify.com/build/functions/overview/)
|
||||||
|
- [Netlify SSE Support](https://edge-functions-examples.netlify.app/example/server-sent-events)
|
||||||
|
- [Cloudflare Workers Limits](https://developers.cloudflare.com/workers/platform/limits/)
|
||||||
|
- [Cloudflare Durable Objects Pricing](https://developers.cloudflare.com/durable-objects/platform/pricing/)
|
||||||
|
- [Cloudflare Durable Objects Limits](https://developers.cloudflare.com/durable-objects/platform/limits/)
|
||||||
|
- [OpenNext Cloudflare Adapter](https://opennext.js.org/cloudflare)
|
||||||
|
- [Deploying Next.js with OpenNext on Cloudflare](https://blog.cloudflare.com/deploying-nextjs-apps-to-cloudflare-workers-with-the-opennext-adapter/)
|
||||||
|
- [Upstash Redis Compatibility](https://upstash.com/docs/redis/sdks/ts/deployment)
|
||||||
|
- [Upstash + Cloudflare Workers Integration](https://upstash.com/blog/cloudflare-upstash-integration)
|
||||||
|
- [Cloudflare KV vs Redis Benchmark](https://upstash.com/blog/edgecaching-benchmark)
|
||||||
|
- [Cloudflare Workers vs Vercel Cold Start Comparison](https://dev.to/dataformathub/cloudflare-vs-vercel-vs-netlify-the-truth-about-edge-performance-2026-50h0)
|
||||||
|
- [Cloudflare Workers WebSocket Documentation](https://developers.cloudflare.com/workers/runtime-apis/websockets/)
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { NextResponse } from 'next/server';
|
|
||||||
import { setPixels } from '@/lib/canvas-storage';
|
|
||||||
import { getUserId } from '@/lib/get-user-id';
|
|
||||||
import { checkAndDeductCredits } from '@/lib/rate-limiter';
|
|
||||||
import { publishPixelUpdates } from '@/lib/sse-broadcaster';
|
|
||||||
import {
|
|
||||||
CANVAS_WIDTH,
|
|
||||||
CANVAS_HEIGHT,
|
|
||||||
MAX_COLORS,
|
|
||||||
MAX_BATCH_SIZE,
|
|
||||||
} from '@/lib/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/canvas/place — batch pixel placement.
|
|
||||||
* Body: { pixels: [{x, y, color}] }
|
|
||||||
* Returns: { ok, credits } or error with 400/429.
|
|
||||||
*/
|
|
||||||
export async function POST(request) {
|
|
||||||
let body;
|
|
||||||
try {
|
|
||||||
body = await request.json();
|
|
||||||
} catch {
|
|
||||||
return NextResponse.json({ error: 'invalid_json' }, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pixels } = body;
|
|
||||||
if (!Array.isArray(pixels) || pixels.length === 0) {
|
|
||||||
return NextResponse.json({ error: 'pixels_required' }, { status: 400 });
|
|
||||||
}
|
|
||||||
if (pixels.length > MAX_BATCH_SIZE) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'batch_too_large', max: MAX_BATCH_SIZE },
|
|
||||||
{ status: 400 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each pixel
|
|
||||||
for (const p of pixels) {
|
|
||||||
if (
|
|
||||||
typeof p.x !== 'number' || typeof p.y !== 'number' ||
|
|
||||||
typeof p.color !== 'number' ||
|
|
||||||
p.x < 0 || p.x >= CANVAS_WIDTH ||
|
|
||||||
p.y < 0 || p.y >= CANVAS_HEIGHT ||
|
|
||||||
p.color < 0 || p.color >= MAX_COLORS ||
|
|
||||||
!Number.isInteger(p.x) || !Number.isInteger(p.y) ||
|
|
||||||
!Number.isInteger(p.color)
|
|
||||||
) {
|
|
||||||
return NextResponse.json({ error: 'invalid_pixel', pixel: p }, { status: 400 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rate limiting
|
|
||||||
const userId = getUserId(request);
|
|
||||||
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
|
|
||||||
userId,
|
|
||||||
pixels.length,
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'rate_limited', remaining, retryAfter },
|
|
||||||
{ status: 429 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write pixels to canvas
|
|
||||||
await setPixels(pixels);
|
|
||||||
|
|
||||||
// Broadcast updates for SSE consumers
|
|
||||||
await publishPixelUpdates(pixels);
|
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, credits: remaining });
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { gzipSync } from 'zlib';
|
|
||||||
import { getFullCanvas } from '@/lib/canvas-storage';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/canvas — returns the full canvas as gzipped binary.
|
|
||||||
* Client decodes 5-bit packed pixels from the raw bytes.
|
|
||||||
*/
|
|
||||||
export async function GET() {
|
|
||||||
const buffer = await getFullCanvas();
|
|
||||||
const gzipped = gzipSync(buffer);
|
|
||||||
|
|
||||||
return new Response(gzipped, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/octet-stream',
|
|
||||||
'Content-Encoding': 'gzip',
|
|
||||||
'Cache-Control': 'public, max-age=1, s-maxage=1, stale-while-revalidate=5',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import { getUpdatesSince } from '@/lib/sse-broadcaster';
|
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
|
||||||
export const dynamic = 'force-dynamic';
|
|
||||||
|
|
||||||
/** SSE polling interval in ms */
|
|
||||||
const POLL_INTERVAL = 500;
|
|
||||||
|
|
||||||
/** Max SSE connection duration before client must reconnect (25s for Vercel) */
|
|
||||||
const MAX_DURATION = 25000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/canvas/stream?since=<timestamp>
|
|
||||||
* Server-Sent Events endpoint for real-time pixel updates.
|
|
||||||
* Polls Redis sorted set and streams new entries to clients.
|
|
||||||
*/
|
|
||||||
export async function GET(request) {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
let since = parseInt(url.searchParams.get('since') || '0', 10);
|
|
||||||
|
|
||||||
const encoder = new TextEncoder();
|
|
||||||
const stream = new ReadableStream({
|
|
||||||
async start(controller) {
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
const poll = async () => {
|
|
||||||
// Check if client disconnected or max duration reached
|
|
||||||
if (request.signal.aborted || Date.now() - startTime > MAX_DURATION) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updates = await getUpdatesSince(since + 1);
|
|
||||||
if (updates.length > 0) {
|
|
||||||
// Update the since cursor to latest timestamp
|
|
||||||
since = updates[updates.length - 1].ts;
|
|
||||||
const data = JSON.stringify(updates);
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(`data: ${data}\n\n`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Send error event, don't crash the stream
|
|
||||||
controller.enqueue(
|
|
||||||
encoder.encode(`event: error\ndata: ${err.message}\n\n`),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(poll, POLL_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send initial connection event
|
|
||||||
controller.enqueue(encoder.encode(`data: {"connected":true}\n\n`));
|
|
||||||
poll();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(stream, {
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Zoom controls and coordinate display.
|
|
||||||
*/
|
|
||||||
export default function CanvasControls({
|
|
||||||
zoom, onZoomIn, onZoomOut, onResetZoom, cursorPos,
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="canvas-controls">
|
|
||||||
<div className="zoom-controls">
|
|
||||||
<button onClick={onZoomOut} title="Zoom out">−</button>
|
|
||||||
<span className="zoom-level">{zoom >= 1 ? `${zoom}x` : `1/${1 / zoom}x`}</span>
|
|
||||||
<button onClick={onZoomIn} title="Zoom in">+</button>
|
|
||||||
<button onClick={onResetZoom} title="Reset zoom">⟲</button>
|
|
||||||
</div>
|
|
||||||
<div className="coords">
|
|
||||||
({cursorPos.x}, {cursorPos.y})
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,194 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useEffect, useCallback, useState } from 'react';
|
|
||||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '@/lib/constants';
|
|
||||||
import { decodeCanvas, indicesToRgba } from '@/lib/canvas-decoder';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main canvas component with zoom/pan and pixel placement.
|
|
||||||
*/
|
|
||||||
export default function CanvasRenderer({
|
|
||||||
selectedColor, credits, setCredits,
|
|
||||||
zoom, setZoom, setCursorPos,
|
|
||||||
}) {
|
|
||||||
const canvasRef = useRef(null);
|
|
||||||
const imageDataRef = useRef(null);
|
|
||||||
const panRef = useRef({ x: 0, y: 0 });
|
|
||||||
const draggingRef = useRef(false);
|
|
||||||
const lastMouseRef = useRef({ x: 0, y: 0 });
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
/** Render the ImageData onto the visible canvas with current zoom/pan */
|
|
||||||
const render = useCallback(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
const imageData = imageDataRef.current;
|
|
||||||
if (!canvas || !imageData) return;
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const { width, height } = canvas;
|
|
||||||
ctx.imageSmoothingEnabled = false;
|
|
||||||
|
|
||||||
ctx.fillStyle = '#1a1a1a';
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
// Create offscreen canvas with the pixel data
|
|
||||||
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
||||||
const offCtx = offscreen.getContext('2d');
|
|
||||||
offCtx.putImageData(imageData, 0, 0);
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.translate(panRef.current.x, panRef.current.y);
|
|
||||||
ctx.scale(zoom, zoom);
|
|
||||||
ctx.drawImage(offscreen, 0, 0);
|
|
||||||
ctx.restore();
|
|
||||||
}, [zoom]);
|
|
||||||
|
|
||||||
/** Load full canvas from API on mount */
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadCanvas() {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/canvas');
|
|
||||||
const buffer = await res.arrayBuffer();
|
|
||||||
const indices = decodeCanvas(buffer);
|
|
||||||
const rgba = indicesToRgba(indices);
|
|
||||||
imageDataRef.current = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
|
||||||
render();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to load canvas:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadCanvas();
|
|
||||||
}, [render]);
|
|
||||||
|
|
||||||
/** Re-render when zoom changes */
|
|
||||||
useEffect(() => { render(); }, [render]);
|
|
||||||
|
|
||||||
/** Resize canvas to fill viewport */
|
|
||||||
useEffect(() => {
|
|
||||||
function resize() {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas) return;
|
|
||||||
canvas.width = window.innerWidth;
|
|
||||||
canvas.height = window.innerHeight;
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
resize();
|
|
||||||
window.addEventListener('resize', resize);
|
|
||||||
return () => window.removeEventListener('resize', resize);
|
|
||||||
}, [render]);
|
|
||||||
|
|
||||||
/** Convert screen coords to canvas pixel coords */
|
|
||||||
const screenToCanvas = useCallback((clientX, clientY) => {
|
|
||||||
const x = Math.floor((clientX - panRef.current.x) / zoom);
|
|
||||||
const y = Math.floor((clientY - panRef.current.y) / zoom);
|
|
||||||
return { x, y };
|
|
||||||
}, [zoom]);
|
|
||||||
|
|
||||||
/** Update a single pixel in the local ImageData */
|
|
||||||
const updateLocalPixel = useCallback((x, y, colorIndex) => {
|
|
||||||
if (!imageDataRef.current) return;
|
|
||||||
const color = COLORS_RGBA[colorIndex];
|
|
||||||
const offset = (y * CANVAS_WIDTH + x) * 4;
|
|
||||||
imageDataRef.current.data[offset] = color[0];
|
|
||||||
imageDataRef.current.data[offset + 1] = color[1];
|
|
||||||
imageDataRef.current.data[offset + 2] = color[2];
|
|
||||||
imageDataRef.current.data[offset + 3] = color[3];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
/** Place a pixel via API */
|
|
||||||
const placePixel = useCallback(async (x, y) => {
|
|
||||||
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
|
|
||||||
if (credits <= 0) return;
|
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
updateLocalPixel(x, y, selectedColor);
|
|
||||||
render();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/canvas/place', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (data.ok) {
|
|
||||||
setCredits(data.credits);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to place pixel:', err);
|
|
||||||
}
|
|
||||||
}, [selectedColor, credits, setCredits, updateLocalPixel, render]);
|
|
||||||
|
|
||||||
/** Mouse handlers for pan and place */
|
|
||||||
const handleMouseDown = useCallback((e) => {
|
|
||||||
if (e.button === 0) {
|
|
||||||
draggingRef.current = false;
|
|
||||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleMouseMove = useCallback((e) => {
|
|
||||||
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
||||||
setCursorPos({ x: Math.max(0, pos.x), y: Math.max(0, pos.y) });
|
|
||||||
|
|
||||||
if (e.buttons === 1) {
|
|
||||||
const dx = e.clientX - lastMouseRef.current.x;
|
|
||||||
const dy = e.clientY - lastMouseRef.current.y;
|
|
||||||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) {
|
|
||||||
draggingRef.current = true;
|
|
||||||
}
|
|
||||||
panRef.current.x += dx;
|
|
||||||
panRef.current.y += dy;
|
|
||||||
lastMouseRef.current = { x: e.clientX, y: e.clientY };
|
|
||||||
render();
|
|
||||||
}
|
|
||||||
}, [screenToCanvas, setCursorPos, render]);
|
|
||||||
|
|
||||||
const handleMouseUp = useCallback((e) => {
|
|
||||||
if (e.button === 0 && !draggingRef.current) {
|
|
||||||
const pos = screenToCanvas(e.clientX, e.clientY);
|
|
||||||
placePixel(pos.x, pos.y);
|
|
||||||
}
|
|
||||||
draggingRef.current = false;
|
|
||||||
}, [screenToCanvas, placePixel]);
|
|
||||||
|
|
||||||
/** Scroll wheel zoom centered on cursor */
|
|
||||||
const handleWheel = useCallback((e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
const factor = e.deltaY < 0 ? 2 : 0.5;
|
|
||||||
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
|
|
||||||
|
|
||||||
// Zoom toward cursor position
|
|
||||||
const cx = e.clientX;
|
|
||||||
const cy = e.clientY;
|
|
||||||
panRef.current.x = cx - (cx - panRef.current.x) * (newZoom / zoom);
|
|
||||||
panRef.current.y = cy - (cy - panRef.current.y) * (newZoom / zoom);
|
|
||||||
|
|
||||||
setZoom(newZoom);
|
|
||||||
}, [zoom, setZoom]);
|
|
||||||
|
|
||||||
/** Expose updateLocalPixel for SSE updates */
|
|
||||||
useEffect(() => {
|
|
||||||
window.__rplaceUpdatePixel = (x, y, color) => {
|
|
||||||
updateLocalPixel(x, y, color);
|
|
||||||
render();
|
|
||||||
};
|
|
||||||
return () => { delete window.__rplaceUpdatePixel; };
|
|
||||||
}, [updateLocalPixel, render]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="canvas-container">
|
|
||||||
{loading && <div className="loading">Loading canvas...</div>}
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseMove={handleMouseMove}
|
|
||||||
onMouseUp={handleMouseUp}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
style={{ cursor: draggingRef.current ? 'grabbing' : 'crosshair' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { COLORS } from '@/lib/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 32-color palette grid. Click to select a color.
|
|
||||||
*/
|
|
||||||
export default function ColorPicker({ selectedColor, onSelect }) {
|
|
||||||
return (
|
|
||||||
<div className="color-picker">
|
|
||||||
{COLORS.map((hex, i) => (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
className={`color-swatch ${i === selectedColor ? 'selected' : ''}`}
|
|
||||||
style={{ backgroundColor: hex }}
|
|
||||||
onClick={() => onSelect(i)}
|
|
||||||
title={`Color ${i}: ${hex}`}
|
|
||||||
aria-label={`Select color ${hex}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { MAX_CREDITS } from '@/lib/constants';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Displays credit count with client-side regeneration animation.
|
|
||||||
*/
|
|
||||||
export default function UserInfo({ credits, setCredits }) {
|
|
||||||
// Animate credit regeneration client-side (1 per second)
|
|
||||||
useEffect(() => {
|
|
||||||
if (credits >= MAX_CREDITS) return;
|
|
||||||
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCredits((c) => Math.min(c + 1, MAX_CREDITS));
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [credits >= MAX_CREDITS, setCredits]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="user-info">
|
|
||||||
<div className="credits">
|
|
||||||
<span className="credits-label">Pixels</span>
|
|
||||||
<span className="credits-value">{credits}</span>
|
|
||||||
<span className="credits-max">/ {MAX_CREDITS}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
@@ -1,159 +0,0 @@
|
|||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background: #1a1a1a;
|
|
||||||
color: #fff;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas */
|
|
||||||
.canvas-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-container canvas {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Color Picker */
|
|
||||||
.color-picker {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 16px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(8, 1fr);
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
border-radius: 8px;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch:hover {
|
|
||||||
transform: scale(1.2);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.color-swatch.selected {
|
|
||||||
border-color: #fff;
|
|
||||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
|
||||||
transform: scale(1.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Canvas Controls */
|
|
||||||
.canvas-controls {
|
|
||||||
position: fixed;
|
|
||||||
top: 16px;
|
|
||||||
right: 16px;
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px 12px;
|
|
||||||
background: rgba(0, 0, 0, 0.85);
|
|
||||||
border-radius: 8px;
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-controls button {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid #555;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: #333;
|
|
||||||
color: #fff;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 1rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-controls button:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zoom-level {
|
|
||||||
min-width: 40px;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.coords {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #aaa;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User Info */
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credits {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credits-label {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credits-value {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #4ade80;
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.credits-max {
|
|
||||||
color: #555;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared canvas UI state: selected color, credits, coordinates.
|
|
||||||
*/
|
|
||||||
export function useCanvasState() {
|
|
||||||
const [selectedColor, setSelectedColor] = useState(27); // black
|
|
||||||
const [credits, setCredits] = useState(256);
|
|
||||||
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 });
|
|
||||||
const [zoom, setZoom] = useState(1);
|
|
||||||
|
|
||||||
const zoomIn = useCallback(() => {
|
|
||||||
setZoom((z) => Math.min(z * 2, 64));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const zoomOut = useCallback(() => {
|
|
||||||
setZoom((z) => Math.max(z / 2, 0.25));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetZoom = useCallback(() => setZoom(1), []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedColor, setSelectedColor,
|
|
||||||
credits, setCredits,
|
|
||||||
cursorPos, setCursorPos,
|
|
||||||
zoom, setZoom, zoomIn, zoomOut, resetZoom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook for SSE pixel updates.
|
|
||||||
* Auto-reconnects when the stream closes (Vercel 25s limit).
|
|
||||||
* @param {function} onBatch - callback receiving array of {pixels, ts}
|
|
||||||
*/
|
|
||||||
export function useSSEUpdates(onBatch) {
|
|
||||||
const sinceRef = useRef(Date.now());
|
|
||||||
const onBatchRef = useRef(onBatch);
|
|
||||||
onBatchRef.current = onBatch;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let es;
|
|
||||||
let reconnectTimer;
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
es = new EventSource(`/api/canvas/stream?since=${sinceRef.current}`);
|
|
||||||
|
|
||||||
es.onmessage = (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
// Skip connection confirmation
|
|
||||||
if (data.connected) return;
|
|
||||||
|
|
||||||
// data is an array of {pixels, ts}
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
for (const batch of data) {
|
|
||||||
if (batch.ts) sinceRef.current = batch.ts;
|
|
||||||
onBatchRef.current(batch.pixels);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore parse errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
es.onerror = () => {
|
|
||||||
es.close();
|
|
||||||
// Reconnect after a short delay
|
|
||||||
reconnectTimer = setTimeout(connect, 1000);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
es?.close();
|
|
||||||
clearTimeout(reconnectTimer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import "./globals.css";
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: "rplace — Collaborative Pixel Canvas",
|
|
||||||
description: "A Reddit r/place clone. Place pixels, create art together.",
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RootLayout({ children }) {
|
|
||||||
return (
|
|
||||||
<html lang="en">
|
|
||||||
<body>{children}</body>
|
|
||||||
</html>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import CanvasRenderer from './components/canvas-renderer';
|
|
||||||
import ColorPicker from './components/color-picker';
|
|
||||||
import CanvasControls from './components/canvas-controls';
|
|
||||||
import UserInfo from './components/user-info';
|
|
||||||
import { useCanvasState } from './hooks/use-canvas-state';
|
|
||||||
import { useSSEUpdates } from './hooks/use-sse-updates';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const {
|
|
||||||
selectedColor, setSelectedColor,
|
|
||||||
credits, setCredits,
|
|
||||||
cursorPos, setCursorPos,
|
|
||||||
zoom, setZoom, zoomIn, zoomOut, resetZoom,
|
|
||||||
} = useCanvasState();
|
|
||||||
|
|
||||||
// Apply SSE pixel updates to the canvas
|
|
||||||
useSSEUpdates((pixels) => {
|
|
||||||
if (!window.__rplaceUpdatePixel) return;
|
|
||||||
for (const { x, y, color } of pixels) {
|
|
||||||
window.__rplaceUpdatePixel(x, y, color);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="app">
|
|
||||||
<CanvasRenderer
|
|
||||||
selectedColor={selectedColor}
|
|
||||||
credits={credits}
|
|
||||||
setCredits={setCredits}
|
|
||||||
zoom={zoom}
|
|
||||||
setZoom={setZoom}
|
|
||||||
setCursorPos={setCursorPos}
|
|
||||||
/>
|
|
||||||
<CanvasControls
|
|
||||||
zoom={zoom}
|
|
||||||
onZoomIn={zoomIn}
|
|
||||||
onZoomOut={zoomOut}
|
|
||||||
onResetZoom={resetZoom}
|
|
||||||
cursorPos={cursorPos}
|
|
||||||
/>
|
|
||||||
<ColorPicker
|
|
||||||
selectedColor={selectedColor}
|
|
||||||
onSelect={setSelectedColor}
|
|
||||||
/>
|
|
||||||
<UserInfo credits={credits} setCredits={setCredits} />
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script>
|
||||||
|
import { MAX_CREDITS } from '../lib/constants.js';
|
||||||
|
import CanvasRenderer from './components/CanvasRenderer.svelte';
|
||||||
|
import ColorPicker from './components/ColorPicker.svelte';
|
||||||
|
import CanvasControls from './components/CanvasControls.svelte';
|
||||||
|
import UserInfo from './components/UserInfo.svelte';
|
||||||
|
|
||||||
|
let selectedColor = $state(27); // black
|
||||||
|
let credits = $state(MAX_CREDITS);
|
||||||
|
let cursorPos = $state({ x: 0, y: 0 });
|
||||||
|
let zoom = $state(1);
|
||||||
|
|
||||||
|
/** @type {CanvasRenderer} */
|
||||||
|
let canvasRenderer;
|
||||||
|
|
||||||
|
// WebSocket connection for real-time updates
|
||||||
|
$effect(() => {
|
||||||
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${protocol}//${location.host}/api/ws`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === 'pixels' && canvasRenderer) {
|
||||||
|
canvasRenderer.applyUpdates(data.pixels);
|
||||||
|
}
|
||||||
|
} catch { /* ignore parse errors */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
// Reconnect after 1s
|
||||||
|
setTimeout(() => { /* effect re-runs on reactive dep change */ }, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => ws.close();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<CanvasRenderer
|
||||||
|
bind:this={canvasRenderer}
|
||||||
|
{selectedColor}
|
||||||
|
{credits}
|
||||||
|
onCreditsChange={(c) => credits = c}
|
||||||
|
{zoom}
|
||||||
|
onZoomChange={(z) => zoom = z}
|
||||||
|
onCursorMove={(pos) => cursorPos = pos}
|
||||||
|
/>
|
||||||
|
<CanvasControls
|
||||||
|
{zoom}
|
||||||
|
onZoomIn={() => zoom = Math.min(zoom * 2, 64)}
|
||||||
|
onZoomOut={() => zoom = Math.max(zoom / 2, 0.25)}
|
||||||
|
onResetZoom={() => zoom = 1}
|
||||||
|
{cursorPos}
|
||||||
|
/>
|
||||||
|
<ColorPicker {selectedColor} onSelect={(i) => selectedColor = i} />
|
||||||
|
<UserInfo {credits} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
main {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
color: #fff;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script>
|
||||||
|
let { zoom, onZoomIn, onZoomOut, onResetZoom, cursorPos } = $props();
|
||||||
|
|
||||||
|
let zoomLabel = $derived(
|
||||||
|
zoom >= 1 ? `${zoom}x` : `1/${1 / zoom}x`
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<div class="zoom">
|
||||||
|
<button onclick={onZoomOut} title="Zoom out">−</button>
|
||||||
|
<span class="level">{zoomLabel}</span>
|
||||||
|
<button onclick={onZoomIn} title="Zoom in">+</button>
|
||||||
|
<button onclick={onResetZoom} title="Reset zoom">⟲</button>
|
||||||
|
</div>
|
||||||
|
<div class="coords">({cursorPos.x}, {cursorPos.y})</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.controls {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom button {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #333;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zoom button:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level {
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from '../../lib/constants.js';
|
||||||
|
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
|
||||||
|
|
||||||
|
let { selectedColor, credits, onCreditsChange, zoom, onZoomChange, onCursorMove } = $props();
|
||||||
|
|
||||||
|
let canvasEl;
|
||||||
|
let imageData = null;
|
||||||
|
let pan = { x: 0, y: 0 };
|
||||||
|
let dragging = $state(false);
|
||||||
|
let lastMouse = { x: 0, y: 0 };
|
||||||
|
let loading = $state(true);
|
||||||
|
|
||||||
|
/** Render ImageData onto visible canvas with current zoom/pan */
|
||||||
|
function render() {
|
||||||
|
if (!canvasEl || !imageData) return;
|
||||||
|
const ctx = canvasEl.getContext('2d');
|
||||||
|
ctx.imageSmoothingEnabled = false;
|
||||||
|
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.fillRect(0, 0, canvasEl.width, canvasEl.height);
|
||||||
|
|
||||||
|
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
offscreen.getContext('2d').putImageData(imageData, 0, 0);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(pan.x, pan.y);
|
||||||
|
ctx.scale(zoom, zoom);
|
||||||
|
ctx.drawImage(offscreen, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert screen coords to canvas pixel coords */
|
||||||
|
function screenToCanvas(clientX, clientY) {
|
||||||
|
return {
|
||||||
|
x: Math.floor((clientX - pan.x) / zoom),
|
||||||
|
y: Math.floor((clientY - pan.y) / zoom),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single pixel locally */
|
||||||
|
function updatePixel(x, y, colorIndex) {
|
||||||
|
if (!imageData) return;
|
||||||
|
const color = COLORS_RGBA[colorIndex];
|
||||||
|
const offset = (y * CANVAS_WIDTH + x) * 4;
|
||||||
|
imageData.data[offset] = color[0];
|
||||||
|
imageData.data[offset + 1] = color[1];
|
||||||
|
imageData.data[offset + 2] = color[2];
|
||||||
|
imageData.data[offset + 3] = color[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place a pixel via API */
|
||||||
|
async function placePixel(x, y) {
|
||||||
|
if (x < 0 || x >= CANVAS_WIDTH || y < 0 || y >= CANVAS_HEIGHT) return;
|
||||||
|
if (credits <= 0) return;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
updatePixel(x, y, selectedColor);
|
||||||
|
render();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/place', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pixels: [{ x, y, color: selectedColor }] }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
onCreditsChange(data.credits);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to place pixel:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply pixel updates from WebSocket */
|
||||||
|
export function applyUpdates(pixels) {
|
||||||
|
for (const { x, y, color } of pixels) {
|
||||||
|
updatePixel(x, y, color);
|
||||||
|
}
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseDown(e) {
|
||||||
|
if (e.button === 0) {
|
||||||
|
dragging = false;
|
||||||
|
lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove(e) {
|
||||||
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
onCursorMove({
|
||||||
|
x: Math.max(0, Math.min(pos.x, CANVAS_WIDTH - 1)),
|
||||||
|
y: Math.max(0, Math.min(pos.y, CANVAS_HEIGHT - 1)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (e.buttons === 1) {
|
||||||
|
const dx = e.clientX - lastMouse.x;
|
||||||
|
const dy = e.clientY - lastMouse.y;
|
||||||
|
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) dragging = true;
|
||||||
|
pan.x += dx;
|
||||||
|
pan.y += dy;
|
||||||
|
lastMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseUp(e) {
|
||||||
|
if (e.button === 0 && !dragging) {
|
||||||
|
const pos = screenToCanvas(e.clientX, e.clientY);
|
||||||
|
placePixel(pos.x, pos.y);
|
||||||
|
}
|
||||||
|
dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWheel(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const factor = e.deltaY < 0 ? 2 : 0.5;
|
||||||
|
const newZoom = Math.max(0.25, Math.min(64, zoom * factor));
|
||||||
|
|
||||||
|
const cx = e.clientX;
|
||||||
|
const cy = e.clientY;
|
||||||
|
pan.x = cx - (cx - pan.x) * (newZoom / zoom);
|
||||||
|
pan.y = cy - (cy - pan.y) * (newZoom / zoom);
|
||||||
|
|
||||||
|
onZoomChange(newZoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-render when zoom changes
|
||||||
|
$effect(() => { zoom; render(); });
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
// Size canvas to viewport
|
||||||
|
function resize() {
|
||||||
|
canvasEl.width = window.innerWidth;
|
||||||
|
canvasEl.height = window.innerHeight;
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
resize();
|
||||||
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
|
// Load full canvas
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/canvas');
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
const indices = decodeCanvas(buffer);
|
||||||
|
const rgba = indicesToRgba(indices);
|
||||||
|
imageData = new ImageData(rgba, CANVAS_WIDTH, CANVAS_HEIGHT);
|
||||||
|
render();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load canvas:', err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => window.removeEventListener('resize', resize);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
{#if loading}
|
||||||
|
<div class="loading">Loading canvas...</div>
|
||||||
|
{/if}
|
||||||
|
<canvas
|
||||||
|
bind:this={canvasEl}
|
||||||
|
onmousedown={handleMouseDown}
|
||||||
|
onmousemove={handleMouseMove}
|
||||||
|
onmouseup={handleMouseUp}
|
||||||
|
onwheel={handleWheel}
|
||||||
|
style="cursor: {dragging ? 'grabbing' : 'crosshair'}"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.canvas-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #888;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<script>
|
||||||
|
import { COLORS } from '../../lib/constants.js';
|
||||||
|
|
||||||
|
let { selectedColor, onSelect } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="color-picker">
|
||||||
|
{#each COLORS as hex, i}
|
||||||
|
<button
|
||||||
|
class="swatch"
|
||||||
|
class:selected={i === selectedColor}
|
||||||
|
style="background-color: {hex}"
|
||||||
|
onclick={() => onSelect(i)}
|
||||||
|
title="Color {i}: {hex}"
|
||||||
|
aria-label="Select color {hex}"
|
||||||
|
></button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.color-picker {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(8, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
border-radius: 8px;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swatch.selected {
|
||||||
|
border-color: #fff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script>
|
||||||
|
import { MAX_CREDITS } from '../../lib/constants.js';
|
||||||
|
|
||||||
|
let { credits } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="user-info">
|
||||||
|
<div class="credits">
|
||||||
|
<span class="label">Pixels</span>
|
||||||
|
<span class="value">{credits}</span>
|
||||||
|
<span class="max">/ {MAX_CREDITS}</span>
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label { color: #888; }
|
||||||
|
|
||||||
|
.value {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #4ade80;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max {
|
||||||
|
color: #555;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { mount } from 'svelte';
|
||||||
|
import App from './App.svelte';
|
||||||
|
import './app.css';
|
||||||
|
|
||||||
|
const app = mount(App, { target: document.getElementById('app') });
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Durable Object for WebSocket broadcast room.
|
||||||
|
* All connected clients receive pixel updates in real-time.
|
||||||
|
*/
|
||||||
|
export class CanvasRoom {
|
||||||
|
constructor(state) {
|
||||||
|
this.state = state;
|
||||||
|
/** @type {Set<WebSocket>} */
|
||||||
|
this.sessions = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Internal broadcast from worker
|
||||||
|
if (url.pathname === '/broadcast' && request.method === 'POST') {
|
||||||
|
const pixels = await request.json();
|
||||||
|
const message = JSON.stringify({ type: 'pixels', pixels });
|
||||||
|
for (const ws of this.sessions) {
|
||||||
|
try {
|
||||||
|
ws.send(message);
|
||||||
|
} catch {
|
||||||
|
this.sessions.delete(ws);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Response('ok');
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket upgrade
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
const [client, server] = Object.values(pair);
|
||||||
|
|
||||||
|
server.accept();
|
||||||
|
this.sessions.add(server);
|
||||||
|
|
||||||
|
server.addEventListener('close', () => {
|
||||||
|
this.sessions.delete(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addEventListener('error', () => {
|
||||||
|
this.sessions.delete(server);
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(null, { status: 101, webSocket: client });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>rplace — Collaborative Pixel Canvas</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/client/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants';
|
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA } from './constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode 5-bit packed canvas buffer into an array of color indices.
|
* Decode 5-bit packed canvas buffer into an array of color indices.
|
||||||
* @param {ArrayBuffer} buffer - raw canvas bytes
|
* @param {ArrayBuffer} buffer - raw canvas bytes
|
||||||
* @returns {Uint8Array} color index per pixel (length = CANVAS_WIDTH * CANVAS_HEIGHT)
|
* @returns {Uint8Array} color index per pixel
|
||||||
*/
|
*/
|
||||||
export function decodeCanvas(buffer) {
|
export function decodeCanvas(buffer) {
|
||||||
const bytes = new Uint8Array(buffer);
|
const bytes = new Uint8Array(buffer);
|
||||||
@@ -14,7 +14,6 @@ export function decodeCanvas(buffer) {
|
|||||||
for (let i = 0; i < totalPixels; i++) {
|
for (let i = 0; i < totalPixels; i++) {
|
||||||
const byteIndex = bitPos >> 3;
|
const byteIndex = bitPos >> 3;
|
||||||
const bitOffset = bitPos & 7;
|
const bitOffset = bitPos & 7;
|
||||||
// Read 5 bits spanning at most 2 bytes
|
|
||||||
const value =
|
const value =
|
||||||
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
|
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
|
||||||
indices[i] = value;
|
indices[i] = value;
|
||||||
@@ -26,7 +25,7 @@ export function decodeCanvas(buffer) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert color indices array to RGBA ImageData pixels.
|
* Convert color indices array to RGBA ImageData pixels.
|
||||||
* @param {Uint8Array} indices - color index per pixel
|
* @param {Uint8Array} indices
|
||||||
* @returns {Uint8ClampedArray} RGBA data for ImageData
|
* @returns {Uint8ClampedArray} RGBA data for ImageData
|
||||||
*/
|
*/
|
||||||
export function indicesToRgba(indices) {
|
export function indicesToRgba(indices) {
|
||||||
|
|||||||
+18
-31
@@ -1,40 +1,41 @@
|
|||||||
import redis from './redis-client.js';
|
import { getRedis } from './redis-client.js';
|
||||||
import {
|
import { CANVAS_WIDTH, TOTAL_PIXELS, BITS_PER_PIXEL, REDIS_CANVAS_KEY } from './constants.js';
|
||||||
CANVAS_WIDTH,
|
|
||||||
CANVAS_HEIGHT,
|
|
||||||
TOTAL_PIXELS,
|
|
||||||
BITS_PER_PIXEL,
|
|
||||||
REDIS_CANVAS_KEY,
|
|
||||||
} from './constants.js';
|
|
||||||
|
|
||||||
/** Total bytes needed for the canvas bitfield */
|
/** Total bytes needed for the canvas bitfield */
|
||||||
const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
|
const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full canvas as a Buffer of raw bytes.
|
* Get the full canvas as a Uint8Array of raw bytes.
|
||||||
* Each pixel is 5 bits at offset (y * CANVAS_WIDTH + x).
|
|
||||||
* Returns a zero-filled buffer if canvas doesn't exist yet.
|
* Returns a zero-filled buffer if canvas doesn't exist yet.
|
||||||
|
* @param {object} env
|
||||||
|
* @returns {Promise<Uint8Array>}
|
||||||
*/
|
*/
|
||||||
export async function getFullCanvas() {
|
export async function getFullCanvas(env) {
|
||||||
|
const redis = getRedis(env);
|
||||||
const data = await redis.get(REDIS_CANVAS_KEY);
|
const data = await redis.get(REDIS_CANVAS_KEY);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return Buffer.alloc(CANVAS_BYTES);
|
return new Uint8Array(CANVAS_BYTES);
|
||||||
}
|
}
|
||||||
// Upstash returns base64-encoded string for binary data
|
|
||||||
if (typeof data === 'string') {
|
if (typeof data === 'string') {
|
||||||
return Buffer.from(data, 'base64');
|
const binary = atob(data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
}
|
}
|
||||||
return Buffer.from(data);
|
return new Uint8Array(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set multiple pixels in a single atomic BITFIELD command.
|
* Set multiple pixels in a single atomic BITFIELD command.
|
||||||
|
* @param {object} env
|
||||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
* @param {Array<{x: number, y: number, color: number}>} pixels
|
||||||
*/
|
*/
|
||||||
export async function setPixels(pixels) {
|
export async function setPixels(env, pixels) {
|
||||||
if (!pixels.length) return;
|
if (!pixels.length) return;
|
||||||
|
const redis = getRedis(env);
|
||||||
|
|
||||||
// Build BITFIELD subcommands: SET u5 #offset value
|
|
||||||
const commands = [];
|
const commands = [];
|
||||||
for (const { x, y, color } of pixels) {
|
for (const { x, y, color } of pixels) {
|
||||||
const offset = y * CANVAS_WIDTH + x;
|
const offset = y * CANVAS_WIDTH + x;
|
||||||
@@ -43,17 +44,3 @@ export async function setPixels(pixels) {
|
|||||||
|
|
||||||
await redis.bitfield(REDIS_CANVAS_KEY, commands);
|
await redis.bitfield(REDIS_CANVAS_KEY, commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a single pixel's color index.
|
|
||||||
* @param {number} x
|
|
||||||
* @param {number} y
|
|
||||||
* @returns {Promise<number>} color index (0-31)
|
|
||||||
*/
|
|
||||||
export async function getPixel(x, y) {
|
|
||||||
const offset = y * CANVAS_WIDTH + x;
|
|
||||||
const result = await redis.bitfield(REDIS_CANVAS_KEY, [
|
|
||||||
'GET', 'u5', `#${offset}`,
|
|
||||||
]);
|
|
||||||
return result?.[0] ?? 0;
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Canvas dimensions (configurable) */
|
/** Canvas dimensions (configurable via wrangler.json vars) */
|
||||||
export const CANVAS_WIDTH = 2048;
|
export const CANVAS_WIDTH = 2048;
|
||||||
export const CANVAS_HEIGHT = 2048;
|
export const CANVAS_HEIGHT = 2048;
|
||||||
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||||
@@ -14,7 +14,6 @@ export const MAX_CREDITS = 256;
|
|||||||
|
|
||||||
/** Redis keys */
|
/** Redis keys */
|
||||||
export const REDIS_CANVAS_KEY = 'canvas';
|
export const REDIS_CANVAS_KEY = 'canvas';
|
||||||
export const REDIS_UPDATES_KEY = 'canvas:updates';
|
|
||||||
|
|
||||||
/** 32-color palette from rplace.live (hex values) */
|
/** 32-color palette from rplace.live (hex values) */
|
||||||
export const COLORS = [
|
export const COLORS = [
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Extract a user identifier from the request.
|
* Extract a user identifier from the request.
|
||||||
* Uses IP address hash for anonymous users.
|
* Uses CF-Connecting-IP header (provided by Cloudflare).
|
||||||
* @param {Request} request
|
* @param {Request} request
|
||||||
* @returns {string} user id prefixed with "anon:" or "auth:"
|
* @returns {string} user id prefixed with "anon:"
|
||||||
*/
|
*/
|
||||||
export function getUserId(request) {
|
export function getUserId(request) {
|
||||||
// Extract IP from Vercel/proxy headers
|
const ip = request.headers.get('cf-connecting-ip')
|
||||||
const forwarded = request.headers.get('x-forwarded-for');
|
|| request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||||
const ip = forwarded ? forwarded.split(',')[0].trim() : '127.0.0.1';
|
|| '127.0.0.1';
|
||||||
|
|
||||||
// Simple hash for privacy (not crypto-grade, just for keying)
|
// Simple hash for privacy
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < ip.length; i++) {
|
for (let i = 0; i < ip.length; i++) {
|
||||||
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
|
hash = ((hash << 5) - hash + ip.charCodeAt(i)) | 0;
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import redis from './redis-client.js';
|
import { getRedis } from './redis-client.js';
|
||||||
import { MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
|
import { MAX_CREDITS, CREDIT_REGEN_RATE } from './constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lua script for atomic check-and-deduct of stackable credits.
|
* Lua script for atomic check-and-deduct of stackable credits.
|
||||||
* 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)
|
|
||||||
* Returns: [allowed (0/1), remaining, retryAfter]
|
* Returns: [allowed (0/1), remaining, retryAfter]
|
||||||
*/
|
*/
|
||||||
const CREDIT_SCRIPT = `
|
const CREDIT_SCRIPT = `
|
||||||
@@ -38,11 +33,13 @@ return {1, remaining, 0}
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check and deduct credits for a user's pixel placement.
|
* Check and deduct credits for a user's pixel placement.
|
||||||
|
* @param {object} env
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {number} count - number of pixels to place
|
* @param {number} count
|
||||||
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
|
* @returns {Promise<{allowed: boolean, remaining: number, retryAfter: number}>}
|
||||||
*/
|
*/
|
||||||
export async function checkAndDeductCredits(userId, count) {
|
export async function checkAndDeductCredits(env, userId, count) {
|
||||||
|
const redis = getRedis(env);
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const key = `credits:${userId}`;
|
const key = `credits:${userId}`;
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -1,12 +1,13 @@
|
|||||||
import { Redis } from '@upstash/redis';
|
import { Redis } from '@upstash/redis/cloudflare';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Singleton Upstash Redis client.
|
* Create an Upstash Redis client from CF Worker env bindings.
|
||||||
* Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from env.
|
* @param {object} env - Cloudflare Worker env (UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN)
|
||||||
|
* @returns {Redis}
|
||||||
*/
|
*/
|
||||||
const redis = new Redis({
|
export function getRedis(env) {
|
||||||
url: process.env.UPSTASH_REDIS_REST_URL,
|
return new Redis({
|
||||||
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
url: env.UPSTASH_REDIS_REST_URL,
|
||||||
});
|
token: env.UPSTASH_REDIS_REST_TOKEN,
|
||||||
|
});
|
||||||
export default redis;
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import redis from './redis-client.js';
|
|
||||||
import { REDIS_UPDATES_KEY } from './constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Publish a batch of pixel updates to Redis for SSE consumers.
|
|
||||||
* Uses a sorted set with timestamp scores for ordered retrieval.
|
|
||||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
|
||||||
*/
|
|
||||||
export async function publishPixelUpdates(pixels) {
|
|
||||||
const now = Date.now();
|
|
||||||
const entry = JSON.stringify({ pixels, ts: now });
|
|
||||||
// Store with timestamp score, auto-expire old entries
|
|
||||||
await redis.zadd(REDIS_UPDATES_KEY, { score: now, member: entry });
|
|
||||||
// Trim entries older than 60 seconds
|
|
||||||
await redis.zremrangebyscore(REDIS_UPDATES_KEY, 0, now - 60000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all pixel updates since a given timestamp.
|
|
||||||
* @param {number} since - timestamp in ms
|
|
||||||
* @returns {Promise<Array<{pixels: Array, ts: number}>>}
|
|
||||||
*/
|
|
||||||
export async function getUpdatesSince(since) {
|
|
||||||
const entries = await redis.zrangebyscore(
|
|
||||||
REDIS_UPDATES_KEY,
|
|
||||||
since,
|
|
||||||
'+inf',
|
|
||||||
);
|
|
||||||
return entries.map((e) => (typeof e === 'string' ? JSON.parse(e) : e));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
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 { CANVAS_WIDTH, CANVAS_HEIGHT, MAX_COLORS, MAX_BATCH_SIZE } from './lib/constants.js';
|
||||||
|
|
||||||
|
export { CanvasRoom } from './durable-objects/canvas-room.js';
|
||||||
|
|
||||||
|
const app = new Hono();
|
||||||
|
|
||||||
|
/** GET /api/canvas — full canvas as binary */
|
||||||
|
app.get('/api/canvas', async (c) => {
|
||||||
|
const buffer = await getFullCanvas(c.env);
|
||||||
|
return new Response(buffer, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/octet-stream',
|
||||||
|
'Cache-Control': 'public, max-age=1, s-maxage=1, stale-while-revalidate=5',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/place — batch pixel placement */
|
||||||
|
app.post('/api/place', async (c) => {
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return c.json({ error: 'invalid_json' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pixels } = body;
|
||||||
|
if (!Array.isArray(pixels) || pixels.length === 0) {
|
||||||
|
return c.json({ error: 'pixels_required' }, 400);
|
||||||
|
}
|
||||||
|
if (pixels.length > MAX_BATCH_SIZE) {
|
||||||
|
return c.json({ error: 'batch_too_large', max: MAX_BATCH_SIZE }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each pixel
|
||||||
|
for (const p of pixels) {
|
||||||
|
if (
|
||||||
|
typeof p.x !== 'number' || typeof p.y !== 'number' ||
|
||||||
|
typeof p.color !== 'number' ||
|
||||||
|
p.x < 0 || p.x >= CANVAS_WIDTH ||
|
||||||
|
p.y < 0 || p.y >= CANVAS_HEIGHT ||
|
||||||
|
p.color < 0 || p.color >= MAX_COLORS ||
|
||||||
|
!Number.isInteger(p.x) || !Number.isInteger(p.y) ||
|
||||||
|
!Number.isInteger(p.color)
|
||||||
|
) {
|
||||||
|
return c.json({ error: 'invalid_pixel', pixel: p }, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const userId = getUserId(c.req.raw);
|
||||||
|
const { allowed, remaining, retryAfter } = await checkAndDeductCredits(
|
||||||
|
c.env, userId, pixels.length,
|
||||||
|
);
|
||||||
|
if (!allowed) {
|
||||||
|
return c.json({ error: 'rate_limited', remaining, retryAfter }, 429);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write pixels to canvas
|
||||||
|
await setPixels(c.env, pixels);
|
||||||
|
|
||||||
|
// Broadcast to all connected WebSocket clients
|
||||||
|
const roomId = c.env.CANVAS_ROOM.idFromName('main');
|
||||||
|
const room = c.env.CANVAS_ROOM.get(roomId);
|
||||||
|
await room.fetch(new Request('http://internal/broadcast', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(pixels),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return c.json({ ok: true, credits: remaining });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** WebSocket upgrade — delegate to Durable Object */
|
||||||
|
app.get('/api/ws', async (c) => {
|
||||||
|
const upgradeHeader = c.req.header('Upgrade');
|
||||||
|
if (upgradeHeader !== 'websocket') {
|
||||||
|
return c.text('Expected WebSocket', 426);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = c.env.CANVAS_ROOM.idFromName('main');
|
||||||
|
const room = c.env.CANVAS_ROOM.get(roomId);
|
||||||
|
return room.fetch(c.req.raw);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [svelte()],
|
||||||
|
root: 'src',
|
||||||
|
build: {
|
||||||
|
outDir: '../dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8787',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "rplace",
|
||||||
|
"main": "src/worker.js",
|
||||||
|
"compatibility_date": "2025-04-01",
|
||||||
|
"assets": {
|
||||||
|
"directory": "dist"
|
||||||
|
},
|
||||||
|
"durable_objects": {
|
||||||
|
"bindings": [
|
||||||
|
{
|
||||||
|
"name": "CANVAS_ROOM",
|
||||||
|
"class_name": "CanvasRoom"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"migrations": [
|
||||||
|
{
|
||||||
|
"tag": "v1",
|
||||||
|
"new_classes": ["CanvasRoom"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vars": {
|
||||||
|
"CANVAS_WIDTH": "2048",
|
||||||
|
"CANVAS_HEIGHT": "2048"
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user