feat: add JSON WebSocket mode, demo seed data, and useDemoSync hook

Add lightweight JSON WebSocket protocol (?mode=json) that bridges
Automerge to JSON for demo pages, avoiding the ~500KB Automerge bundle.
Includes GET /api/communities/:slug/shapes endpoint, POST demo reset
with rate limiting, Alpine Explorer 2026 seed data (~40 shapes), and
the useDemoSync React hook for real-time demo page connectivity.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:38:59 -07:00
parent 89fba95e40
commit 0a32944243
4 changed files with 821 additions and 45 deletions

View File

@ -412,3 +412,17 @@ export function deleteShape(slug: string, shapeId: string): void {
saveCommunity(slug);
}
}
/**
* Clear all shapes from a community (for demo reset)
*/
export function clearShapes(slug: string): void {
const doc = communities.get(slug);
if (doc) {
const newDoc = Automerge.change(doc, "Clear all shapes", (d) => {
d.shapes = {};
});
communities.set(slug, newDoc);
saveCommunity(slug);
}
}

View File

@ -2,6 +2,7 @@ import { resolve } from "node:path";
import type { ServerWebSocket } from "bun";
import {
addShapes,
clearShapes,
communityExists,
createCommunity,
deleteShape,
@ -12,6 +13,7 @@ import {
removePeerSyncState,
updateShape,
} from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import type { SpaceVisibility } from "./community-store";
import {
verifyEncryptIDToken,
@ -47,6 +49,7 @@ interface WSData {
peerId: string;
claims: EncryptIDClaims | null;
readOnly: boolean;
mode: "automerge" | "json";
}
// Track connected clients per community (for broadcasting)
@ -62,6 +65,38 @@ function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | unde
return communityClients.get(slug)?.get(peerId);
}
// Broadcast a JSON snapshot of all shapes to json-mode clients in a community
function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
const docData = getDocumentData(slug);
if (!docData) return;
const snapshotMsg = JSON.stringify({ type: "snapshot", shapes: docData.shapes || {} });
for (const [clientPeerId, client] of clients) {
if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
client.send(snapshotMsg);
}
}
}
// Broadcast Automerge sync messages to automerge-mode clients in a community
function broadcastAutomergeSync(slug: string, excludePeerId?: string): void {
const clients = communityClients.get(slug);
if (!clients) return;
for (const [clientPeerId, client] of clients) {
if (client.data.mode === "automerge" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) {
const syncMsg = generateSyncMessageForPeer(slug, clientPeerId);
if (syncMsg) {
client.send(JSON.stringify({ type: "sync", data: Array.from(syncMsg) }));
}
}
}
}
// Demo reset rate limiter
let lastDemoReset = 0;
const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; // 5 minutes
// Special subdomains that should show the creation form instead of canvas
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start"];
@ -146,8 +181,9 @@ const server = Bun.serve<WSData>({
}
const peerId = generatePeerId();
const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge";
const upgraded = server.upgrade(req, {
data: { communitySlug, peerId, claims, readOnly },
data: { communitySlug, peerId, claims, readOnly, mode } as WSData,
});
if (upgraded) return undefined;
}
@ -200,7 +236,7 @@ const server = Bun.serve<WSData>({
websocket: {
open(ws: ServerWebSocket<WSData>) {
const { communitySlug, peerId } = ws.data;
const { communitySlug, peerId, mode } = ws.data;
// Add to clients map
if (!communityClients.has(communitySlug)) {
@ -208,11 +244,23 @@ const server = Bun.serve<WSData>({
}
communityClients.get(communitySlug)!.set(peerId, ws);
console.log(`[WS] Client ${peerId} connected to ${communitySlug}`);
console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})`);
// Load community and send initial sync message
// Load community and send initial data
loadCommunity(communitySlug).then((doc) => {
if (doc) {
if (!doc) return;
if (mode === "json") {
// JSON mode: send full shapes snapshot
const docData = getDocumentData(communitySlug);
if (docData) {
ws.send(JSON.stringify({
type: "snapshot",
shapes: docData.shapes || {},
}));
}
} else {
// Automerge mode: send sync message
const syncMessage = generateSyncMessageForPeer(communitySlug, peerId);
if (syncMessage) {
ws.send(
@ -255,10 +303,10 @@ const server = Bun.serve<WSData>({
);
}
// Broadcast to other peers
// Broadcast to other Automerge peers
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
const targetClient = getClient(communitySlug, targetPeerId);
if (targetClient && targetClient.readyState === WebSocket.OPEN) {
if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) {
targetClient.send(
JSON.stringify({
type: "sync",
@ -267,6 +315,11 @@ const server = Bun.serve<WSData>({
);
}
}
// Also broadcast JSON snapshot to json-mode clients
if (result.broadcastToPeers.size > 0) {
broadcastJsonSnapshot(communitySlug, peerId);
}
} else if (msg.type === "ping") {
// Handle keep-alive ping
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
@ -286,39 +339,27 @@ const server = Bun.serve<WSData>({
}
}
}
// Legacy message handling for backward compatibility
// Legacy/JSON-mode message handling
else if (msg.type === "update" && msg.id && msg.data) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
return;
}
updateShape(communitySlug, msg.id, msg.data);
// Broadcast to other clients
const clients = communityClients.get(communitySlug);
if (clients) {
const updateMsg = JSON.stringify(msg);
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(updateMsg);
}
}
}
// Broadcast JSON update to other json-mode clients
broadcastJsonSnapshot(communitySlug, peerId);
// Broadcast Automerge sync to automerge-mode clients
broadcastAutomergeSync(communitySlug, peerId);
} else if (msg.type === "delete" && msg.id) {
if (ws.data.readOnly) {
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
return;
}
deleteShape(communitySlug, msg.id);
// Broadcast to other clients
const clients = communityClients.get(communitySlug);
if (clients) {
const deleteMsg = JSON.stringify(msg);
for (const [clientPeerId, client] of clients) {
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(deleteMsg);
}
}
}
// Broadcast JSON snapshot to other json-mode clients
broadcastJsonSnapshot(communitySlug, peerId);
// Broadcast Automerge sync to automerge-mode clients
broadcastAutomergeSync(communitySlug, peerId);
}
} catch (e) {
console.error("[WS] Failed to parse message:", e);
@ -441,6 +482,74 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
}
}
// GET /api/communities/:slug/shapes - Get all shapes as JSON
if (
url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) &&
req.method === "GET"
) {
const slug = url.pathname.split("/")[3];
const token = extractToken(req.headers);
const access = await evaluateSpaceAccess(slug, token, req.method, {
getSpaceConfig,
});
if (!access.allowed) {
return Response.json(
{ error: access.reason },
{ status: access.claims ? 403 : 401, headers: corsHeaders }
);
}
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) {
return Response.json(
{ error: "Community not found" },
{ status: 404, headers: corsHeaders }
);
}
return Response.json(
{ shapes: data.shapes || {} },
{ headers: corsHeaders }
);
}
// POST /api/communities/demo/reset - Reset demo community to seed data
if (url.pathname === "/api/communities/demo/reset" && req.method === "POST") {
const now = Date.now();
if (now - lastDemoReset < DEMO_RESET_COOLDOWN) {
const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000);
return Response.json(
{ error: `Demo reset on cooldown. Try again in ${remaining}s` },
{ status: 429, headers: corsHeaders }
);
}
try {
lastDemoReset = now;
await loadCommunity("demo");
clearShapes("demo");
await ensureDemoCommunity();
// Broadcast new state to all connected clients
broadcastAutomergeSync("demo");
broadcastJsonSnapshot("demo");
return Response.json(
{ ok: true, message: "Demo community reset to seed data" },
{ headers: corsHeaders }
);
} catch (e) {
console.error("Failed to reset demo:", e);
return Response.json(
{ error: "Failed to reset demo community" },
{ status: 500, headers: corsHeaders }
);
}
}
// GET /api/communities/:slug - Get community info (respects visibility)
if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
const slug = url.pathname.split("/")[3];
@ -535,23 +644,9 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
// Add shapes to the Automerge document
const ids = addShapes(slug, body.shapes);
// Broadcast sync messages to all connected WebSocket clients
const clients = communityClients.get(slug);
if (clients) {
for (const [peerId, client] of clients) {
if (client.readyState === WebSocket.OPEN) {
const syncMsg = generateSyncMessageForPeer(slug, peerId);
if (syncMsg) {
client.send(
JSON.stringify({
type: "sync",
data: Array.from(syncMsg),
})
);
}
}
}
}
// Broadcast to all connected WebSocket clients
broadcastAutomergeSync(slug);
broadcastJsonSnapshot(slug);
return Response.json(
{ ok: true, ids },
@ -569,4 +664,11 @@ async function handleAPI(req: Request, url: URL): Promise<Response> {
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
}
// Ensure demo community exists on startup
ensureDemoCommunity().then(() => {
console.log("[Demo] Demo community ready");
}).catch((e) => {
console.error("[Demo] Failed to initialize demo community:", e);
});
console.log(`rSpace server running on http://localhost:${PORT}`);

439
server/seed-demo.ts Normal file
View File

@ -0,0 +1,439 @@
/**
* Demo community seeder for the r* ecosystem
*
* Creates/ensures a "demo" community with visibility: "public" (anon read+write)
* and populates it with the "Alpine Explorer 2026" cross-app scenario.
*
* Shape IDs are deterministic so re-running is idempotent.
*/
import {
addShapes,
communityExists,
createCommunity,
getDocumentData,
loadCommunity,
} from "./community-store";
// ── Alpine Explorer 2026 — Demo Scenario ──────────────────────────
const DEMO_SHAPES: Record<string, unknown>[] = [
// ─── rTrips: Itinerary ──────────────────────────────────────
{
id: "demo-itinerary-alpine",
type: "folk-itinerary",
x: 50, y: 50, width: 400, height: 300, rotation: 0,
tripTitle: "Alpine Explorer 2026",
startDate: "2026-07-06",
endDate: "2026-07-20",
travelers: ["Maya", "Liam", "Priya", "Omar"],
items: [
{ date: "Jul 6", activity: "Fly Geneva → Chamonix shuttle", category: "travel" },
{ date: "Jul 7", activity: "Acclimatization hike — Lac Blanc", category: "hike" },
{ date: "Jul 8", activity: "Via Ferrata — Aiguille du Midi", category: "adventure" },
{ date: "Jul 9", activity: "Rest day / Chamonix town", category: "rest" },
{ date: "Jul 10", activity: "Train to Zermatt", category: "travel" },
{ date: "Jul 11", activity: "Gornergrat sunrise hike", category: "hike" },
{ date: "Jul 12", activity: "Matterhorn base camp trek", category: "hike" },
{ date: "Jul 13", activity: "Paragliding over Zermatt", category: "adventure" },
{ date: "Jul 14", activity: "Transfer to Dolomites", category: "travel" },
{ date: "Jul 15", activity: "Tre Cime di Lavaredo loop", category: "hike" },
{ date: "Jul 16", activity: "Lago di Braies kayaking", category: "adventure" },
{ date: "Jul 17", activity: "Seceda ridgeline hike", category: "hike" },
{ date: "Jul 18", activity: "Cooking class in Bolzano", category: "culture" },
{ date: "Jul 19", activity: "Free day — shopping & packing", category: "rest" },
{ date: "Jul 20", activity: "Fly home from Innsbruck", category: "travel" },
],
},
// ─── rTrips: Destinations ───────────────────────────────────
{
id: "demo-dest-chamonix",
type: "folk-destination",
x: 500, y: 50, width: 300, height: 200, rotation: 0,
destName: "Chamonix",
country: "France",
lat: 45.9237,
lng: 6.8694,
arrivalDate: "2026-07-06",
departureDate: "2026-07-10",
notes: "Base for Mont Blanc region. Book Aiguille du Midi tickets in advance.",
},
{
id: "demo-dest-zermatt",
type: "folk-destination",
x: 500, y: 280, width: 300, height: 200, rotation: 0,
destName: "Zermatt",
country: "Switzerland",
lat: 46.0207,
lng: 7.7491,
arrivalDate: "2026-07-10",
departureDate: "2026-07-14",
notes: "Car-free village. Take the Glacier Express for the scenic route.",
},
{
id: "demo-dest-dolomites",
type: "folk-destination",
x: 500, y: 510, width: 300, height: 200, rotation: 0,
destName: "Dolomites",
country: "Italy",
lat: 46.4102,
lng: 11.8440,
arrivalDate: "2026-07-14",
departureDate: "2026-07-20",
notes: "Stay in Val Gardena. Rifugio Locatelli for Tre Cime views.",
},
// ─── rNotes: Notebook ───────────────────────────────────────
{
id: "demo-notebook-trip",
type: "folk-notebook",
x: 850, y: 50, width: 300, height: 180, rotation: 0,
notebookTitle: "Alpine Explorer Planning",
description: "Shared knowledge base for our July 2026 trip across France, Switzerland, and Italy",
noteCount: 5,
collaborators: ["Maya", "Liam", "Priya", "Omar"],
},
// ─── rNotes: Notes ──────────────────────────────────────────
{
id: "demo-note-packing",
type: "folk-note",
x: 850, y: 260, width: 300, height: 250, rotation: 0,
noteTitle: "Packing Checklist",
content: "## Essential Gear\n- [ ] Hiking boots (broken in!)\n- [x] Rain jacket\n- [x] Headlamp + batteries\n- [ ] Trekking poles\n- [x] First aid kit\n- [ ] Sunscreen SPF 50\n- [ ] Water filter\n\n## Documents\n- [x] Passports\n- [ ] Travel insurance\n- [x] Hut reservations printout",
tags: ["packing", "gear", "checklist"],
editor: "Maya",
editedAt: "2 hours ago",
},
{
id: "demo-note-gear",
type: "folk-note",
x: 1180, y: 50, width: 300, height: 200, rotation: 0,
noteTitle: "Gear Research",
content: "## Via Ferrata Kit\nNeed harness + lanyard + helmet. Can rent in Chamonix for ~€25/day.\n\n## Paragliding\nTandem flights in Zermatt: ~€180pp. Book at Paragliding Zermatt (best reviews).\n\n## Camera\nBring the drone for Tre Cime. Check Italian drone regulations!",
tags: ["gear", "research", "equipment"],
editor: "Liam",
editedAt: "5 hours ago",
},
{
id: "demo-note-huts",
type: "folk-note",
x: 1180, y: 280, width: 300, height: 200, rotation: 0,
noteTitle: "Mountain Hut Reservations",
content: "## Confirmed\n- **Rifugio Locatelli** (Jul 15): 4 beds, conf #TRE2026-089\n- **Refuge du Lac Blanc** (Jul 7): 4 beds, conf #LB2026-234\n\n## Pending\n- Hörnlihütte (Matterhorn base): Waitlisted for Jul 12",
tags: ["accommodation", "reservations", "logistics"],
editor: "Priya",
editedAt: "Yesterday",
},
{
id: "demo-note-emergency",
type: "folk-note",
x: 1180, y: 510, width: 300, height: 180, rotation: 0,
noteTitle: "Emergency Contacts",
content: "## Emergency Numbers\n- **France**: 112 (EU), PGHM: +33 4 50 53 16 89\n- **Switzerland**: 1414 (REGA air rescue)\n- **Italy**: 118 (medical), 112 (general)\n\n## Insurance\nPolicy #: WA-2026-7891\nEmergency line: +1-800-555-0199",
tags: ["safety", "emergency", "contacts"],
editor: "Omar",
editedAt: "3 days ago",
},
{
id: "demo-note-photos",
type: "folk-note",
x: 850, y: 540, width: 300, height: 180, rotation: 0,
noteTitle: "Photo Spots",
content: "## Must-Capture Locations\n1. Lac Blanc reflection of Mont Blanc (sunrise)\n2. Gornergrat panorama with Matterhorn\n3. Tre Cime from Rifugio Locatelli (golden hour)\n4. Seceda ridgeline drone shots\n5. Lago di Braies turquoise water\n\nBring ND filters for long exposure water shots.",
tags: ["photography", "planning", "creative"],
editor: "Liam",
editedAt: "2 days ago",
},
// ─── rVote: Polls ───────────────────────────────────────────
{
id: "demo-poll-day5",
type: "demo-poll",
x: 50, y: 400, width: 350, height: 200, rotation: 0,
question: "Day 5 Activity — What should we do in Chamonix?",
options: [
{ label: "Via Ferrata at Aiguille du Midi", votes: 7 },
{ label: "Kayaking on Lac d'Annecy", votes: 3 },
{ label: "Rest day in town", votes: 2 },
],
totalVoters: 4,
status: "active",
endsAt: "2026-07-01",
},
{
id: "demo-poll-dinner",
type: "demo-poll",
x: 50, y: 630, width: 350, height: 200, rotation: 0,
question: "First night dinner in Zermatt?",
options: [
{ label: "Traditional fondue at Chez Vrony", votes: 5 },
{ label: "Pizza at Grampi's", votes: 4 },
{ label: "Cook at the Airbnb", votes: 1 },
],
totalVoters: 4,
status: "active",
endsAt: "2026-07-05",
},
// ─── rCart: Group Shopping Items ─────────────────────────────
{
id: "demo-cart-firstaid",
type: "demo-cart-item",
x: 50, y: 870, width: 320, height: 60, rotation: 0,
name: "Adventure First-Aid Kit",
price: 85.00,
funded: 85.00,
status: "Funded",
requestedBy: "Omar",
store: "REI",
},
{
id: "demo-cart-waterfilter",
type: "demo-cart-item",
x: 50, y: 940, width: 320, height: 60, rotation: 0,
name: "Portable Water Filter (Sawyer Squeeze)",
price: 45.00,
funded: 45.00,
status: "Funded",
requestedBy: "Maya",
store: "Amazon",
},
{
id: "demo-cart-bearcan",
type: "demo-cart-item",
x: 50, y: 1010, width: 320, height: 60, rotation: 0,
name: "Bear Canister 2x (BV500)",
price: 120.00,
funded: 80.00,
status: "In Cart",
requestedBy: "Liam",
store: "REI",
},
{
id: "demo-cart-stove",
type: "demo-cart-item",
x: 50, y: 1080, width: 320, height: 60, rotation: 0,
name: "Camp Stove + Fuel Canister",
price: 65.00,
funded: 65.00,
status: "Funded",
requestedBy: "Priya",
store: "Decathlon",
},
{
id: "demo-cart-drone",
type: "demo-cart-item",
x: 50, y: 1150, width: 320, height: 60, rotation: 0,
name: "DJI Mini 4 Pro Rental (2 weeks)",
price: 350.00,
funded: 100.00,
status: "Needs Funding",
requestedBy: "Liam",
store: "LensRentals",
},
{
id: "demo-cart-starlink",
type: "demo-cart-item",
x: 50, y: 1220, width: 320, height: 60, rotation: 0,
name: "Starlink Mini Rental (2 weeks)",
price: 200.00,
funded: 50.00,
status: "Needs Funding",
requestedBy: "Omar",
store: "StarRent.eu",
},
// ─── rFunds: Expenses ───────────────────────────────────────
{
id: "demo-expense-shuttle",
type: "demo-expense",
x: 400, y: 870, width: 320, height: 60, rotation: 0,
description: "Geneva → Chamonix shuttle (4 pax)",
amount: 186.00,
currency: "EUR",
paidBy: "Maya",
split: "equal",
category: "transport",
date: "2026-07-06",
},
{
id: "demo-expense-hut",
type: "demo-expense",
x: 400, y: 940, width: 320, height: 60, rotation: 0,
description: "Mountain hut reservations (3 nights total)",
amount: 420.00,
currency: "EUR",
paidBy: "Priya",
split: "equal",
category: "accommodation",
date: "2026-07-07",
},
{
id: "demo-expense-viaferrata",
type: "demo-expense",
x: 400, y: 1010, width: 320, height: 60, rotation: 0,
description: "Via Ferrata gear rental (4 sets)",
amount: 144.00,
currency: "EUR",
paidBy: "Liam",
split: "equal",
category: "activity",
date: "2026-07-08",
},
{
id: "demo-expense-groceries",
type: "demo-expense",
x: 400, y: 1080, width: 320, height: 60, rotation: 0,
description: "Groceries — Chamonix Carrefour",
amount: 93.00,
currency: "EUR",
paidBy: "Omar",
split: "equal",
category: "food",
date: "2026-07-06",
},
{
id: "demo-expense-paragliding",
type: "demo-expense",
x: 400, y: 1150, width: 320, height: 60, rotation: 0,
description: "Paragliding deposit (2 of 4 booked)",
amount: 360.00,
currency: "CHF",
paidBy: "Maya",
split: "custom",
category: "activity",
date: "2026-07-13",
},
// ─── rFunds: Budget ─────────────────────────────────────────
{
id: "demo-budget-trip",
type: "folk-budget",
x: 400, y: 400, width: 350, height: 250, rotation: 0,
budgetTitle: "Trip Budget",
currency: "EUR",
budgetTotal: 4000,
spent: 1203,
categories: [
{ name: "Transport", budget: 800, spent: 186 },
{ name: "Accommodation", budget: 1200, spent: 420 },
{ name: "Activities", budget: 1000, spent: 504 },
{ name: "Food", budget: 600, spent: 93 },
{ name: "Gear", budget: 400, spent: 0 },
],
},
// ─── rMaps: Location Markers ────────────────────────────────
{
id: "demo-map-chamonix",
type: "demo-map-marker",
x: 750, y: 750, width: 40, height: 40, rotation: 0,
name: "Chamonix",
lat: 45.9237,
lng: 6.8694,
emoji: "🏔️",
category: "destination",
status: "Jul 6-10",
},
{
id: "demo-map-zermatt",
type: "demo-map-marker",
x: 800, y: 750, width: 40, height: 40, rotation: 0,
name: "Zermatt",
lat: 46.0207,
lng: 7.7491,
emoji: "⛷️",
category: "destination",
status: "Jul 10-14",
},
{
id: "demo-map-dolomites",
type: "demo-map-marker",
x: 850, y: 750, width: 40, height: 40, rotation: 0,
name: "Dolomites",
lat: 46.4102,
lng: 11.8440,
emoji: "🏞️",
category: "destination",
status: "Jul 14-20",
},
{
id: "demo-map-lacblanc",
type: "demo-map-marker",
x: 900, y: 750, width: 40, height: 40, rotation: 0,
name: "Lac Blanc Hike",
lat: 45.9785,
lng: 6.8891,
emoji: "🥾",
category: "activity",
status: "Jul 7",
},
{
id: "demo-map-viaferrata",
type: "demo-map-marker",
x: 950, y: 750, width: 40, height: 40, rotation: 0,
name: "Via Ferrata",
lat: 45.8786,
lng: 6.8874,
emoji: "🧗",
category: "activity",
status: "Jul 8",
},
{
id: "demo-map-trecime",
type: "demo-map-marker",
x: 1000, y: 750, width: 40, height: 40, rotation: 0,
name: "Tre Cime Loop",
lat: 46.6190,
lng: 12.3018,
emoji: "🏔️",
category: "activity",
status: "Jul 15",
},
// ─── rNotes: Packing List (shared with rTrips) ──────────────
{
id: "demo-packing-alpine",
type: "folk-packing-list",
x: 1200, y: 750, width: 300, height: 300, rotation: 0,
listTitle: "Alpine Explorer Packing",
items: [
{ name: "Hiking boots (broken in)", packed: true, category: "footwear" },
{ name: "Rain jacket", packed: true, category: "clothing" },
{ name: "Trekking poles", packed: false, category: "gear" },
{ name: "Headlamp + batteries", packed: true, category: "gear" },
{ name: "Sunscreen SPF 50", packed: false, category: "personal" },
{ name: "Water filter", packed: false, category: "gear" },
{ name: "First aid kit", packed: true, category: "safety" },
{ name: "Passport + insurance", packed: true, category: "documents" },
],
},
];
/**
* Ensure the demo community exists and is seeded with data.
* Called on server startup and after demo reset.
*/
export async function ensureDemoCommunity(): Promise<void> {
const exists = await communityExists("demo");
if (!exists) {
await createCommunity("r* Ecosystem Demo", "demo", null, "public");
console.log("[Demo] Created demo community with visibility: public");
} else {
await loadCommunity("demo");
}
// Check if already seeded (has shapes)
const data = getDocumentData("demo");
const shapeCount = data ? Object.keys(data.shapes || {}).length : 0;
if (shapeCount === 0) {
addShapes("demo", DEMO_SHAPES);
console.log(`[Demo] Seeded ${DEMO_SHAPES.length} shapes into demo community`);
} else {
console.log(`[Demo] Demo community already has ${shapeCount} shapes`);
}
}

221
src/lib/demo-sync.ts Normal file
View File

@ -0,0 +1,221 @@
/**
* useDemoSync lightweight React hook for real-time demo data via rSpace
*
* Connects to rSpace WebSocket in JSON mode (no Automerge bundle needed).
* All demo pages share the "demo" community, so changes in one app
* propagate to every other app viewing the same shapes.
*
* Usage:
* const { shapes, updateShape, deleteShape, connected, resetDemo } = useDemoSync({
* filter: ['folk-note', 'folk-notebook'], // optional: only these shape types
* });
*/
import { useEffect, useRef, useState, useCallback } from 'react';
export interface DemoShape {
type: string;
id: string;
x: number;
y: number;
width: number;
height: number;
rotation: number;
[key: string]: unknown;
}
interface UseDemoSyncOptions {
/** Community slug (default: 'demo') */
slug?: string;
/** Only subscribe to these shape types */
filter?: string[];
/** rSpace server URL (default: auto-detect based on environment) */
serverUrl?: string;
}
interface UseDemoSyncReturn {
/** Current shapes (filtered if filter option set) */
shapes: Record<string, DemoShape>;
/** Update a shape by ID (partial update merged with existing) */
updateShape: (id: string, data: Partial<DemoShape>) => void;
/** Delete a shape by ID */
deleteShape: (id: string) => void;
/** Whether WebSocket is connected */
connected: boolean;
/** Reset demo to seed state */
resetDemo: () => Promise<void>;
}
const DEFAULT_SLUG = 'demo';
const RECONNECT_BASE_MS = 1000;
const RECONNECT_MAX_MS = 30000;
const PING_INTERVAL_MS = 30000;
function getDefaultServerUrl(): string {
if (typeof window === 'undefined') return 'https://rspace.online';
// In development, use localhost
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
return `http://${window.location.hostname}:3000`;
}
return 'https://rspace.online';
}
export function useDemoSync(options?: UseDemoSyncOptions): UseDemoSyncReturn {
const slug = options?.slug ?? DEFAULT_SLUG;
const filter = options?.filter;
const serverUrl = options?.serverUrl ?? getDefaultServerUrl();
const [shapes, setShapes] = useState<Record<string, DemoShape>>({});
const [connected, setConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
const reconnectAttemptRef = useRef(0);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const mountedRef = useRef(true);
// Stable filter reference for use in callbacks
const filterRef = useRef(filter);
filterRef.current = filter;
const applyFilter = useCallback((allShapes: Record<string, DemoShape>): Record<string, DemoShape> => {
const f = filterRef.current;
if (!f || f.length === 0) return allShapes;
const filtered: Record<string, DemoShape> = {};
for (const [id, shape] of Object.entries(allShapes)) {
if (f.includes(shape.type)) {
filtered[id] = shape;
}
}
return filtered;
}, []);
const connect = useCallback(() => {
if (!mountedRef.current) return;
// Build WebSocket URL
const wsProtocol = serverUrl.startsWith('https') ? 'wss' : 'ws';
const host = serverUrl.replace(/^https?:\/\//, '');
const wsUrl = `${wsProtocol}://${host}/ws/${slug}?mode=json`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!mountedRef.current) return;
setConnected(true);
reconnectAttemptRef.current = 0;
// Start ping keepalive
pingTimerRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
}
}, PING_INTERVAL_MS);
};
ws.onmessage = (event) => {
if (!mountedRef.current) return;
try {
const msg = JSON.parse(event.data);
if (msg.type === 'snapshot' && msg.shapes) {
setShapes(applyFilter(msg.shapes));
}
// pong and error messages are silently handled
} catch {
// ignore parse errors
}
};
ws.onclose = () => {
if (!mountedRef.current) return;
setConnected(false);
cleanup();
scheduleReconnect();
};
ws.onerror = () => {
// onclose will fire after onerror, so reconnect is handled there
};
}, [slug, serverUrl, applyFilter]);
const cleanup = useCallback(() => {
if (pingTimerRef.current) {
clearInterval(pingTimerRef.current);
pingTimerRef.current = null;
}
}, []);
const scheduleReconnect = useCallback(() => {
if (!mountedRef.current) return;
const attempt = reconnectAttemptRef.current;
const delay = Math.min(RECONNECT_BASE_MS * Math.pow(2, attempt), RECONNECT_MAX_MS);
reconnectAttemptRef.current = attempt + 1;
reconnectTimerRef.current = setTimeout(() => {
if (mountedRef.current) connect();
}, delay);
}, [connect]);
// Connect on mount
useEffect(() => {
mountedRef.current = true;
connect();
return () => {
mountedRef.current = false;
cleanup();
if (reconnectTimerRef.current) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null; // prevent reconnect on unmount
wsRef.current.close();
wsRef.current = null;
}
};
}, [connect, cleanup]);
const updateShape = useCallback((id: string, data: Partial<DemoShape>) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local update
setShapes((prev) => {
const existing = prev[id];
if (!existing) return prev;
const updated = { ...existing, ...data, id };
const f = filterRef.current;
if (f && f.length > 0 && !f.includes(updated.type)) return prev;
return { ...prev, [id]: updated };
});
// Send to server
ws.send(JSON.stringify({ type: 'update', id, data: { ...data, id } }));
}, []);
const deleteShape = useCallback((id: string) => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Optimistic local delete
setShapes((prev) => {
const { [id]: _, ...rest } = prev;
return rest;
});
ws.send(JSON.stringify({ type: 'delete', id }));
}, []);
const resetDemo = useCallback(async () => {
const res = await fetch(`${serverUrl}/api/communities/demo/reset`, { method: 'POST' });
if (!res.ok) {
const body = await res.text();
throw new Error(`Reset failed: ${res.status} ${body}`);
}
// The server will broadcast new snapshot via WebSocket
}, [serverUrl]);
return { shapes, updateShape, deleteShape, connected, resetDemo };
}