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:
tiennm99 committed 2026-04-16 16:23:27 +07:00
1 parent 3beaa9d43f
commit 2ccf1c9779
40 files changed
+2653 -5630

No files matched your search

-6
View File
@@ -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=
-16
View File
@@ -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;
-7
View File
@@ -1,7 +0,0 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}
-6
View File
@@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
/* config options here */
};
export default nextConfig;
+1747 -4760
View File
File diff suppressed because it is too large. Load diff
+13 -12
View File
@@ -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/)
-72
View File
@@ -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 });
}
-19
View File
@@ -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',
},
});
}
-66
View File
@@ -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',
},
});
}
-22
View File
@@ -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>
);
}
-194
View File
@@ -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>
);
}
-23
View File
@@ -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>
);
}
-30
View File
@@ -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

-159
View File
@@ -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;
}
-30
View File
@@ -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,
};
}
-54
View File
@@ -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);
};
}, []);
}
-14
View File
@@ -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>
);
}
-50
View File
@@ -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>
);
}
+66
View File
@@ -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>
+18
View File
@@ -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>
+195
View File
@@ -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>
+55
View File
@@ -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>
+46
View File
@@ -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>
+7
View File
@@ -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;
+46
View File
@@ -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 });
}
}
+13
View File
@@ -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>
+3 -4
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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 = [
+6 -6
View File
@@ -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;
+5 -8
View File
@@ -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
View File
@@ -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; }
-30
View File
@@ -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));
}
+89
View File
@@ -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;
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};
+16
View File
@@ -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',
},
},
});
+26
View File
@@ -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"
}
}