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
+2655
-5632
No files matched your search
@@ -1,8 +1,2 @@
|
||||
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=
|
||||
@@ -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
+1749
-4762
File diff suppressed because it is too large.
Load diff
+13
-12
@@ -1,21 +1,22 @@
|
||||
{
|
||||
"name": "rplace-init",
|
||||
"version": "0.1.0",
|
||||
"name": "rplace",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"dev": "wrangler dev",
|
||||
"dev:client": "vite dev",
|
||||
"build": "vite build",
|
||||
"deploy": "vite build && wrangler deploy",
|
||||
"preview": "vite build && wrangler dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@upstash/redis": "^1.37.0",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"@upstash/redis": "^1.34.3",
|
||||
"hono": "^4.7.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.4"
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"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.
|
||||
* @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) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
@@ -14,7 +14,6 @@ export function decodeCanvas(buffer) {
|
||||
for (let i = 0; i < totalPixels; i++) {
|
||||
const byteIndex = bitPos >> 3;
|
||||
const bitOffset = bitPos & 7;
|
||||
// Read 5 bits spanning at most 2 bytes
|
||||
const value =
|
||||
((bytes[byteIndex] << 8 | (bytes[byteIndex + 1] || 0)) >> (11 - bitOffset)) & 0x1f;
|
||||
indices[i] = value;
|
||||
@@ -26,7 +25,7 @@ export function decodeCanvas(buffer) {
|
||||
|
||||
/**
|
||||
* Convert color indices array to RGBA ImageData pixels.
|
||||
* @param {Uint8Array} indices - color index per pixel
|
||||
* @param {Uint8Array} indices
|
||||
* @returns {Uint8ClampedArray} RGBA data for ImageData
|
||||
*/
|
||||
export function indicesToRgba(indices) {
|
||||
|
||||
+18
-31
@@ -1,40 +1,41 @@
|
||||
import redis from './redis-client.js';
|
||||
import {
|
||||
CANVAS_WIDTH,
|
||||
CANVAS_HEIGHT,
|
||||
TOTAL_PIXELS,
|
||||
BITS_PER_PIXEL,
|
||||
REDIS_CANVAS_KEY,
|
||||
} from './constants.js';
|
||||
import { getRedis } from './redis-client.js';
|
||||
import { CANVAS_WIDTH, TOTAL_PIXELS, BITS_PER_PIXEL, REDIS_CANVAS_KEY } from './constants.js';
|
||||
|
||||
/** Total bytes needed for the canvas bitfield */
|
||||
const CANVAS_BYTES = Math.ceil((TOTAL_PIXELS * BITS_PER_PIXEL) / 8);
|
||||
|
||||
/**
|
||||
* Get the full canvas as a Buffer of raw bytes.
|
||||
* Each pixel is 5 bits at offset (y * CANVAS_WIDTH + x).
|
||||
* Get the full canvas as a Uint8Array of raw bytes.
|
||||
* 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);
|
||||
if (!data) {
|
||||
return Buffer.alloc(CANVAS_BYTES);
|
||||
return new Uint8Array(CANVAS_BYTES);
|
||||
}
|
||||
// Upstash returns base64-encoded string for binary data
|
||||
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 Buffer.from(data);
|
||||
return bytes;
|
||||
}
|
||||
return new Uint8Array(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set multiple pixels in a single atomic BITFIELD command.
|
||||
* @param {object} env
|
||||
* @param {Array<{x: number, y: number, color: number}>} pixels
|
||||
*/
|
||||
export async function setPixels(pixels) {
|
||||
export async function setPixels(env, pixels) {
|
||||
if (!pixels.length) return;
|
||||
const redis = getRedis(env);
|
||||
|
||||
// Build BITFIELD subcommands: SET u5 #offset value
|
||||
const commands = [];
|
||||
for (const { x, y, color } of pixels) {
|
||||
const offset = y * CANVAS_WIDTH + x;
|
||||
@@ -43,17 +44,3 @@ export async function setPixels(pixels) {
|
||||
|
||||
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_HEIGHT = 2048;
|
||||
export const TOTAL_PIXELS = CANVAS_WIDTH * CANVAS_HEIGHT;
|
||||
@@ -14,7 +14,6 @@ export const MAX_CREDITS = 256;
|
||||
|
||||
/** Redis keys */
|
||||
export const REDIS_CANVAS_KEY = 'canvas';
|
||||
export const REDIS_UPDATES_KEY = 'canvas:updates';
|
||||
|
||||
/** 32-color palette from rplace.live (hex values) */
|
||||
export const COLORS = [
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* 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
|
||||
* @returns {string} user id prefixed with "anon:" or "auth:"
|
||||
* @returns {string} user id prefixed with "anon:"
|
||||
*/
|
||||
export function getUserId(request) {
|
||||
// Extract IP from Vercel/proxy headers
|
||||
const forwarded = request.headers.get('x-forwarded-for');
|
||||
const ip = forwarded ? forwarded.split(',')[0].trim() : '127.0.0.1';
|
||||
const ip = request.headers.get('cf-connecting-ip')
|
||||
|| request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
||||
|| '127.0.0.1';
|
||||
|
||||
// Simple hash for privacy (not crypto-grade, just for keying)
|
||||
// Simple hash for privacy
|
||||
let hash = 0;
|
||||
for (let i = 0; i < ip.length; i++) {
|
||||
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';
|
||||
|
||||
/**
|
||||
* 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]
|
||||
*/
|
||||
const CREDIT_SCRIPT = `
|
||||
@@ -38,11 +33,13 @@ return {1, remaining, 0}
|
||||
|
||||
/**
|
||||
* Check and deduct credits for a user's pixel placement.
|
||||
* @param {object} env
|
||||
* @param {string} userId
|
||||
* @param {number} count - number of pixels to place
|
||||
* @param {number} count
|
||||
* @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 key = `credits:${userId}`;
|
||||
|
||||
|
||||
+10
-9
@@ -1,12 +1,13 @@
|
||||
import { Redis } from '@upstash/redis';
|
||||
import { Redis } from '@upstash/redis/cloudflare';
|
||||
|
||||
/**
|
||||
* Singleton Upstash Redis client.
|
||||
* Reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from env.
|
||||
* Create an Upstash Redis client from CF Worker env bindings.
|
||||
* @param {object} env - Cloudflare Worker env (UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN)
|
||||
* @returns {Redis}
|
||||
*/
|
||||
const redis = new Redis({
|
||||
url: process.env.UPSTASH_REDIS_REST_URL,
|
||||
token: process.env.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
|
||||
export default redis;
|
||||
export function getRedis(env) {
|
||||
return new Redis({
|
||||
url: env.UPSTASH_REDIS_REST_URL,
|
||||
token: env.UPSTASH_REDIS_REST_TOKEN,
|
||||
});
|
||||
}
|
||||
@@ -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