/** * rSpace Unified Server * * Hono-based HTTP router + Bun WebSocket handler. * Mounts module routes under /:space/:moduleId. * Preserves backward-compatible subdomain routing and /api/communities/* API. */ import { resolve } from "node:path"; import { Hono } from "hono"; import { cors } from "hono/cors"; import type { ServerWebSocket } from "bun"; import { addShapes, clearShapes, communityExists, createCommunity, forgetShape, rememberShape, generateSyncMessageForPeer, getDocumentData, loadCommunity, receiveSyncMessage, removePeerSyncState, updateShape, updateShapeFields, cascadePermissions, } from "./community-store"; import type { NestPermissions, SpaceRefFilter } from "./community-store"; import { ensureDemoCommunity } from "./seed-demo"; import { ensureCampaignDemo } from "./seed-campaign"; import type { SpaceVisibility } from "./community-store"; import { verifyEncryptIDToken, evaluateSpaceAccess, extractToken, authenticateWSUpgrade, } from "@encryptid/sdk/server"; import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server"; // ── Module system ── import { registerModule, getAllModules, getModuleInfoList } from "../shared/module"; import { canvasModule } from "../modules/canvas/mod"; import { booksModule } from "../modules/books/mod"; import { pubsModule } from "../modules/pubs/mod"; import { cartModule } from "../modules/cart/mod"; import { swagModule } from "../modules/swag/mod"; import { choicesModule } from "../modules/choices/mod"; import { fundsModule } from "../modules/funds/mod"; import { filesModule } from "../modules/files/mod"; import { forumModule } from "../modules/forum/mod"; import { walletModule } from "../modules/wallet/mod"; import { voteModule } from "../modules/vote/mod"; import { notesModule } from "../modules/notes/mod"; import { mapsModule } from "../modules/maps/mod"; import { workModule } from "../modules/work/mod"; import { tripsModule } from "../modules/trips/mod"; import { calModule } from "../modules/cal/mod"; import { networkModule } from "../modules/network/mod"; import { tubeModule } from "../modules/tube/mod"; import { inboxModule } from "../modules/inbox/mod"; import { dataModule } from "../modules/data/mod"; import { splatModule } from "../modules/splat/mod"; import { photosModule } from "../modules/photos/mod"; import { socialsModule } from "../modules/rsocials/mod"; import { spaces } from "./spaces"; import { renderShell, renderModuleLanding } from "./shell"; import { fetchLandingPage } from "./landing-proxy"; import { syncServer } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; // Register modules registerModule(canvasModule); registerModule(booksModule); registerModule(pubsModule); registerModule(cartModule); registerModule(swagModule); registerModule(choicesModule); registerModule(fundsModule); registerModule(filesModule); registerModule(forumModule); registerModule(walletModule); registerModule(voteModule); registerModule(notesModule); registerModule(mapsModule); registerModule(workModule); registerModule(tripsModule); registerModule(calModule); registerModule(networkModule); registerModule(tubeModule); registerModule(inboxModule); registerModule(dataModule); registerModule(splatModule); registerModule(photosModule); registerModule(socialsModule); // ── Config ── const PORT = Number(process.env.PORT) || 3000; const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || ""; const DIST_DIR = resolve(import.meta.dir, "../dist"); // ── Hono app ── const app = new Hono(); // CORS for API routes app.use("/api/*", cors()); // ── .well-known/webauthn (WebAuthn Related Origins) ── app.get("/.well-known/webauthn", (c) => { return c.json( { origins: [ "https://rwallet.online", "https://rvote.online", "https://rmaps.online", "https://rfiles.online", "https://rnotes.online", ], }, 200, { "Access-Control-Allow-Origin": "*", "Cache-Control": "public, max-age=3600", } ); }); // ── Space registry API ── app.route("/api/spaces", spaces); // ── mi — AI assistant endpoint ── const MI_MODEL = process.env.MI_MODEL || "llama3.2"; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; app.post("/api/mi/ask", async (c) => { const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json(); if (!query) return c.json({ error: "Query required" }, 400); // Build rApp context for the system prompt const moduleList = getModuleInfoList() .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`) .join("\n"); // Build extended context section from client-provided context let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`; if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`; if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`; if (context.openShapes?.length) { const shapeSummary = context.openShapes .slice(0, 10) .map((s: any) => ` - ${s.type}${s.title ? `: ${s.title}` : ""}${s.snippet ? ` (${s.snippet})` : ""}`) .join("\n"); contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`; } const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. You help users navigate, understand, and get the most out of the platform's apps (rApps). You understand the full context of what the user has open and can guide them through setup and usage. ## Available rApps ${moduleList} ## Current Context ${contextSection} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. - When suggesting actions, reference specific rApps by name and explain how they connect. - You can suggest navigating to /:space/:moduleId paths. - If the user has shapes open on their canvas, you can reference them and suggest connections. - Help with setup: guide users through creating spaces, adding content, configuring rApps. - If you don't know something specific about the user's data, say so honestly. - Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform.`; // Build conversation for Ollama const ollamaMessages = [ { role: "system", content: systemPrompt }, ...messages.slice(-8).map((m: any) => ({ role: m.role, content: m.content })), { role: "user", content: query }, ]; try { const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: MI_MODEL, messages: ollamaMessages, stream: true }), }); if (!ollamaRes.ok) { const errText = await ollamaRes.text().catch(() => ""); console.error("mi: Ollama error:", ollamaRes.status, errText); return c.json({ error: "AI service unavailable" }, 502); } // Stream Ollama's NDJSON response directly to client return new Response(ollamaRes.body, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", "Transfer-Encoding": "chunked", }, }); } catch (e: any) { console.error("mi: Failed to reach Ollama:", e.message); // Fallback: return a static helpful response const fallback = generateFallbackResponse(query, currentModule, space, getModuleInfoList()); return c.json({ response: fallback }); } }); function generateFallbackResponse( query: string, currentModule: string, space: string, modules: ReturnType, ): string { const q = query.toLowerCase(); // Simple keyword matching for common questions for (const m of modules) { if (q.includes(m.id) || q.includes(m.name.toLowerCase())) { return `**${m.name}** ${m.icon} — ${m.description}. You can access it at /${space || "personal"}/${m.id}.`; } } if (q.includes("help") || q.includes("what can")) { return `rSpace has ${modules.length} apps you can use. Some popular ones: **rSpace** (spatial canvas), **rNotes** (notes), **rChat** (messaging), **rFunds** (community funding), and **rVote** (governance). What would you like to explore?`; } if (q.includes("search") || q.includes("find")) { return `You can browse your content through the app switcher (top-left dropdown), or navigate directly to any rApp. Try **rNotes** for text content, **rFiles** for documents, or **rPhotos** for images.`; } return `I'm currently running in offline mode (AI service not connected). I can still help with basic navigation — ask me about any specific rApp or feature! There are ${modules.length} apps available in rSpace.`; } // ── Existing /api/communities/* routes (backward compatible) ── /** Resolve a community slug to SpaceAuthConfig for the SDK guard */ async function getSpaceConfig(slug: string): Promise { let doc = getDocumentData(slug); if (!doc) { await loadCommunity(slug); doc = getDocumentData(slug); } if (!doc) return null; return { spaceSlug: slug, visibility: (doc.meta.visibility || "public_read") as SpaceVisibility, ownerDID: doc.meta.ownerDID || undefined, app: "rspace", }; } // Demo reset rate limiter let lastDemoReset = 0; const DEMO_RESET_COOLDOWN = 5 * 60 * 1000; // POST /api/communities — create community app.post("/api/communities", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required to create a community" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid or expired authentication token" }, 401); } const body = await c.req.json<{ name?: string; slug?: string; visibility?: SpaceVisibility }>(); const { name, slug, visibility = "public_read" } = body; if (!name || !slug) return c.json({ error: "Name and slug are required" }, 400); if (!/^[a-z0-9-]+$/.test(slug)) return c.json({ error: "Slug must contain only lowercase letters, numbers, and hyphens" }, 400); const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"]; if (!validVisibilities.includes(visibility)) return c.json({ error: `Invalid visibility` }, 400); if (await communityExists(slug)) return c.json({ error: "Community already exists" }, 409); await createCommunity(name, slug, claims.sub, visibility); // Notify modules for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(slug); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); } } } return c.json({ url: `https://${slug}.rspace.online`, slug, name, visibility, ownerDID: claims.sub }, 201); }); // POST /api/internal/provision — auth-free, called by rSpace Registry app.post("/api/internal/provision", async (c) => { const body = await c.req.json<{ space?: string; description?: string; public?: boolean }>(); const space = body.space?.trim(); if (!space) return c.json({ error: "Missing space name" }, 400); if (await communityExists(space)) { return c.json({ status: "exists", slug: space }); } const visibility: SpaceVisibility = body.public ? "public" : "public_read"; await createCommunity( space.charAt(0).toUpperCase() + space.slice(1), space, `did:system:${space}`, visibility, ); for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(space); } catch (e) { console.error(`Module ${mod.id} onSpaceCreate:`, e); } } } return c.json({ status: "created", slug: space }, 201); }); // POST /api/communities/demo/reset app.post("/api/communities/demo/reset", async (c) => { const now = Date.now(); if (now - lastDemoReset < DEMO_RESET_COOLDOWN) { const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000); return c.json({ error: `Demo reset on cooldown. Try again in ${remaining}s` }, 429); } lastDemoReset = now; await loadCommunity("demo"); clearShapes("demo"); await ensureDemoCommunity(); broadcastAutomergeSync("demo"); broadcastJsonSnapshot("demo"); return c.json({ ok: true, message: "Demo community reset to seed data" }); }); // POST /api/communities/campaign-demo/reset app.post("/api/communities/campaign-demo/reset", async (c) => { const now = Date.now(); if (now - lastDemoReset < DEMO_RESET_COOLDOWN) { const remaining = Math.ceil((DEMO_RESET_COOLDOWN - (now - lastDemoReset)) / 1000); return c.json({ error: `Reset on cooldown. Try again in ${remaining}s` }, 429); } lastDemoReset = now; await loadCommunity("campaign-demo"); clearShapes("campaign-demo"); await ensureCampaignDemo(); broadcastAutomergeSync("campaign-demo"); broadcastJsonSnapshot("campaign-demo"); return c.json({ ok: true, message: "Campaign demo reset to seed data" }); }); // GET /api/communities/:slug/shapes app.get("/api/communities/:slug/shapes", async (c) => { const slug = c.req.param("slug"); const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Community not found" }, 404); return c.json({ shapes: data.shapes || {} }); }); // POST /api/communities/:slug/shapes app.post("/api/communities/:slug/shapes", async (c) => { const slug = c.req.param("slug"); const internalKey = c.req.header("X-Internal-Key"); const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY; if (!isInternalCall) { const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "POST", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); if (access.readOnly) return c.json({ error: "Write access required to add shapes" }, 403); } await loadCommunity(slug); const data = getDocumentData(slug); if (!data) return c.json({ error: "Community not found" }, 404); const body = await c.req.json<{ shapes?: Record[] }>(); if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) { return c.json({ error: "shapes array is required and must not be empty" }, 400); } const ids = addShapes(slug, body.shapes); broadcastAutomergeSync(slug); broadcastJsonSnapshot(slug); return c.json({ ok: true, ids }, 201); }); // PATCH /api/communities/:slug/shapes/:shapeId app.patch("/api/communities/:slug/shapes/:shapeId", async (c) => { const slug = c.req.param("slug"); const shapeId = c.req.param("shapeId"); const internalKey = c.req.header("X-Internal-Key"); const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY; if (!isInternalCall) { const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "PATCH", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); } await loadCommunity(slug); const body = await c.req.json>(); const updated = updateShapeFields(slug, shapeId, body); if (!updated) return c.json({ error: "Shape not found" }, 404); broadcastAutomergeSync(slug); broadcastJsonSnapshot(slug); return c.json({ ok: true }); }); // GET /api/communities/:slug — community info app.get("/api/communities/:slug", async (c) => { const slug = c.req.param("slug"); const token = extractToken(c.req.raw.headers); const access = await evaluateSpaceAccess(slug, token, "GET", { getSpaceConfig }); if (!access.allowed) return c.json({ error: access.reason }, access.claims ? 403 : 401); let data = getDocumentData(slug); if (!data) { await loadCommunity(slug); data = getDocumentData(slug); } if (!data) return c.json({ error: "Community not found" }, 404); return c.json({ meta: data.meta, readOnly: access.readOnly }); }); // ── Module info API (for app switcher) ── app.get("/api/modules", (c) => { return c.json({ modules: getModuleInfoList() }); }); // ── Creative tools API endpoints ── const FAL_KEY = process.env.FAL_KEY || ""; // Image generation via fal.ai Flux Pro app.post("/api/image-gen", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { prompt, style } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const stylePrompts: Record = { illustration: "digital illustration style, ", photorealistic: "photorealistic, high detail, ", painting: "oil painting style, artistic, ", sketch: "pencil sketch style, hand-drawn, ", "punk-zine": "punk zine aesthetic, cut-and-paste collage, bold contrast, ", }; const styledPrompt = (stylePrompts[style] || "") + prompt; const res = await fetch("https://queue.fal.run/fal-ai/flux-pro/v1.1", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt: styledPrompt, image_size: "landscape_4_3", num_images: 1, safety_tolerance: "2", }), }); if (!res.ok) { const err = await res.text(); console.error("[image-gen] fal.ai error:", err); return c.json({ error: "Image generation failed" }, 502); } const data = await res.json(); const imageUrl = data.images?.[0]?.url || data.output?.url; if (!imageUrl) return c.json({ error: "No image returned" }, 502); return c.json({ url: imageUrl, image_url: imageUrl }); }); // Text-to-video via fal.ai WAN 2.1 app.post("/api/video-gen/t2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { prompt, duration } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); const res = await fetch("https://queue.fal.run/fal-ai/wan/v2.1", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ prompt, num_frames: duration === "5s" ? 81 : 49, resolution: "480p", }), }); if (!res.ok) { const err = await res.text(); console.error("[video-gen/t2v] fal.ai error:", err); return c.json({ error: "Video generation failed" }, 502); } const data = await res.json(); const videoUrl = data.video?.url || data.output?.url; if (!videoUrl) return c.json({ error: "No video returned" }, 502); return c.json({ url: videoUrl, video_url: videoUrl }); }); // Image-to-video via fal.ai Kling app.post("/api/video-gen/i2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); const { image, prompt, duration } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); const res = await fetch("https://queue.fal.run/fal-ai/kling-video/v1/standard/image-to-video", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ image_url: image, prompt: prompt || "", duration: duration === "5s" ? "5" : "5", }), }); if (!res.ok) { const err = await res.text(); console.error("[video-gen/i2v] fal.ai error:", err); return c.json({ error: "Video generation failed" }, 502); } const data = await res.json(); const videoUrl = data.video?.url || data.output?.url; if (!videoUrl) return c.json({ error: "No video returned" }, 502); return c.json({ url: videoUrl, video_url: videoUrl }); }); // Blender 3D generation via LLM + RunPod const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; app.post("/api/blender-gen", async (c) => { if (!RUNPOD_API_KEY) return c.json({ error: "RUNPOD_API_KEY not configured" }, 503); const { prompt } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); // Step 1: Generate Blender Python script via LLM const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; let script = ""; try { const llmRes = await fetch(`${OLLAMA_URL}/api/generate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: process.env.OLLAMA_MODEL || "llama3.1", prompt: `Generate a Blender Python script that creates: ${prompt}\n\nThe script should:\n- Import bpy\n- Clear the default scene\n- Create the described objects with materials\n- Set up basic lighting and camera\n- Render to /tmp/render.png at 1024x1024\n\nOnly output the Python code, no explanations.`, stream: false, }), }); if (llmRes.ok) { const llmData = await llmRes.json(); script = llmData.response || ""; // Extract code block if wrapped in markdown const codeMatch = script.match(/```python\n([\s\S]*?)```/); if (codeMatch) script = codeMatch[1]; } } catch (e) { console.error("[blender-gen] LLM error:", e); } if (!script) { return c.json({ error: "Failed to generate Blender script" }, 502); } // Step 2: Execute on RunPod (headless Blender) try { const runpodRes = await fetch("https://api.runpod.ai/v2/blender/runsync", { method: "POST", headers: { Authorization: `Bearer ${RUNPOD_API_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ input: { script, render: true, }, }), }); if (!runpodRes.ok) { // Return just the script if RunPod fails return c.json({ script, error_detail: "RunPod execution failed" }); } const runpodData = await runpodRes.json(); return c.json({ render_url: runpodData.output?.render_url || null, script, blend_url: runpodData.output?.blend_url || null, }); } catch (e) { // Return the script even if RunPod is unavailable return c.json({ script, error_detail: "RunPod unavailable" }); } }); // KiCAD PCB design — REST-to-MCP bridge const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://localhost:3001"; app.post("/api/kicad/:action", async (c) => { const action = c.req.param("action"); const body = await c.req.json(); const validActions = [ "create_project", "add_schematic_component", "add_schematic_connection", "export_svg", "run_drc", "export_gerber", "export_bom", "export_pdf", "search_symbols", "search_footprints", "place_component", ]; if (!validActions.includes(action)) { return c.json({ error: `Unknown action: ${action}` }, 400); } try { const mcpRes = await fetch(`${KICAD_MCP_URL}/call-tool`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: action, arguments: body, }), }); if (!mcpRes.ok) { const err = await mcpRes.text(); console.error(`[kicad/${action}] MCP error:`, err); return c.json({ error: `KiCAD action failed: ${action}` }, 502); } const data = await mcpRes.json(); return c.json(data); } catch (e) { console.error(`[kicad/${action}] Connection error:`, e); return c.json({ error: "KiCAD MCP server not available" }, 503); } }); // FreeCAD parametric CAD — REST-to-MCP bridge const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://localhost:3002"; app.post("/api/freecad/:action", async (c) => { const action = c.req.param("action"); const body = await c.req.json(); const validActions = [ "generate", "export_step", "export_stl", "update_parameters", ]; if (!validActions.includes(action)) { return c.json({ error: `Unknown action: ${action}` }, 400); } try { const mcpRes = await fetch(`${FREECAD_MCP_URL}/call-tool`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: action, arguments: body, }), }); if (!mcpRes.ok) { const err = await mcpRes.text(); console.error(`[freecad/${action}] MCP error:`, err); return c.json({ error: `FreeCAD action failed: ${action}` }, 502); } const data = await mcpRes.json(); return c.json(data); } catch (e) { console.error(`[freecad/${action}] Connection error:`, e); return c.json({ error: "FreeCAD MCP server not available" }, 503); } }); // ── Auto-provision personal space ── app.post("/api/spaces/auto-provision", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims: EncryptIDClaims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid or expired token" }, 401); } const username = claims.username?.toLowerCase(); if (!username || !/^[a-z0-9][a-z0-9-]*$/.test(username)) { return c.json({ error: "Username not suitable for space slug" }, 400); } if (await communityExists(username)) { return c.json({ status: "exists", slug: username }); } await createCommunity( `${claims.username}'s Space`, username, claims.sub, "authenticated", ); for (const mod of getAllModules()) { if (mod.onSpaceCreate) { try { await mod.onSpaceCreate(username); } catch (e) { console.error(`[AutoProvision] Module ${mod.id} onSpaceCreate:`, e); } } } console.log(`[AutoProvision] Created personal space: ${username}`); return c.json({ status: "created", slug: username }, 201); }); // ── Mount module routes under /:space/:moduleId ── for (const mod of getAllModules()) { app.route(`/:space/${mod.id}`, mod.routes); } // ── Page routes ── // Landing page: rspace.online/ → serve marketing/info page app.get("/", async (c) => { const file = Bun.file(resolve(DIST_DIR, "index.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("rSpace", 200); }); // About/info page (full landing content) app.get("/about", async (c) => { const file = Bun.file(resolve(DIST_DIR, "index.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("rSpace", 200); }); // Create new space page app.get("/create-space", async (c) => { const file = Bun.file(resolve(DIST_DIR, "create-space.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("Create space", 200); }); // Legacy redirect app.get("/new", (c) => c.redirect("/create-space", 301)); // Admin dashboard app.get("/admin", async (c) => { const file = Bun.file(resolve(DIST_DIR, "admin.html")); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": "text/html" } }); } return c.text("Admin", 200); }); // Space root: /:space → redirect to /:space/rspace app.get("/:space", (c) => { const space = c.req.param("space"); // Don't redirect for static file paths if (space.includes(".")) return c.notFound(); return c.redirect(`/${space}/rspace`); }); // ── WebSocket types ── interface WSData { communitySlug: string; peerId: string; claims: EncryptIDClaims | null; readOnly: boolean; mode: "automerge" | "json"; // Nest context: set when a folk-canvas shape connects to a nested space nestFrom?: string; // slug of the parent space that contains the nest nestPermissions?: NestPermissions; // effective permissions for this nested view nestFilter?: SpaceRefFilter; // shape filter applied to this nested view } // Track connected clients per community const communityClients = new Map>>(); function generatePeerId(): string { return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } function getClient(slug: string, peerId: string): ServerWebSocket | undefined { return communityClients.get(slug)?.get(peerId); } function broadcastJsonSnapshot(slug: string, excludePeerId?: string): void { const clients = communityClients.get(slug); if (!clients) return; const docData = getDocumentData(slug); if (!docData) return; const allShapes = docData.shapes || {}; // Pre-build the unfiltered message (most clients use this) let unfilteredMsg: string | null = null; for (const [clientPeerId, client] of clients) { if (client.data.mode === "json" && clientPeerId !== excludePeerId && client.readyState === WebSocket.OPEN) { // Apply nest filter for nested connections if (client.data.nestFilter) { const filtered: typeof allShapes = {}; for (const [id, shape] of Object.entries(allShapes)) { if (client.data.nestFilter.shapeTypes && !client.data.nestFilter.shapeTypes.includes(shape.type)) continue; if (client.data.nestFilter.shapeIds && !client.data.nestFilter.shapeIds.includes(id)) continue; filtered[id] = shape; } client.send(JSON.stringify({ type: "snapshot", shapes: filtered })); } else { if (!unfilteredMsg) unfilteredMsg = JSON.stringify({ type: "snapshot", shapes: allShapes }); client.send(unfilteredMsg); } } } } 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) })); } } } } // ── Subdomain parsing (backward compat) ── const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start", "auth"]; function getSubdomain(host: string | null): string | null { if (!host) return null; if (host.includes("localhost") || host.includes("127.0.0.1")) return null; const parts = host.split("."); if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") { const sub = parts[0]; if (!RESERVED_SUBDOMAINS.includes(sub)) return sub; } return null; } // ── Static file serving ── function getContentType(path: string): string { if (path.endsWith(".html")) return "text/html"; if (path.endsWith(".js")) return "application/javascript"; if (path.endsWith(".css")) return "text/css"; if (path.endsWith(".json")) return "application/json"; if (path.endsWith(".svg")) return "image/svg+xml"; if (path.endsWith(".wasm")) return "application/wasm"; if (path.endsWith(".png")) return "image/png"; if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg"; if (path.endsWith(".gif")) return "image/gif"; if (path.endsWith(".ico")) return "image/x-icon"; return "application/octet-stream"; } async function serveStatic(path: string): Promise { const filePath = resolve(DIST_DIR, path); const file = Bun.file(filePath); if (await file.exists()) { return new Response(file, { headers: { "Content-Type": getContentType(path) } }); } return null; } // ── Standalone domain → module lookup ── const domainToModule = new Map(); for (const mod of getAllModules()) { if (mod.standaloneDomain) { domainToModule.set(mod.standaloneDomain, mod.id); } } // ── Bun.serve: WebSocket + fetch delegation ── const server = Bun.serve({ port: PORT, async fetch(req, server) { const url = new URL(req.url); const host = req.headers.get("host"); const hostClean = host?.split(":")[0] || ""; const subdomain = getSubdomain(host); // ── Standalone domain → internal rewrite to module routes ── const standaloneModuleId = domainToModule.get(hostClean); if (standaloneModuleId) { // Self-fetch detection: landing proxy uses this User-Agent; // return 404 to break circular fetch so the generic fallback is used if (req.headers.get("user-agent") === "rSpace-Proxy/1.0") { return new Response("Not found", { status: 404 }); } // Static assets pass through if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) { const assetPath = url.pathname.slice(1); if (assetPath.includes(".")) { const staticResponse = await serveStatic(assetPath); if (staticResponse) return staticResponse; } } // Root path → redirect to rspace.online/{moduleId} landing page if (url.pathname === "/") { return Response.redirect(`https://rspace.online/${standaloneModuleId}`, 302); } // Sub-paths: rewrite internally → /{space}/{moduleId}/... const pathParts = url.pathname.split("/").filter(Boolean); let space = "demo"; let suffix = ""; if ( pathParts.length > 0 && !pathParts[0].includes(".") && pathParts[0] !== "api" && pathParts[0] !== "ws" ) { space = pathParts[0]; suffix = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : ""; } else if (url.pathname !== "/") { suffix = url.pathname; } const rewrittenPath = `/${space}/${standaloneModuleId}${suffix}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); } // ── WebSocket upgrade ── if (url.pathname.startsWith("/ws/")) { const communitySlug = url.pathname.split("/")[2]; if (communitySlug) { const spaceConfig = await getSpaceConfig(communitySlug); const claims = await authenticateWSUpgrade(req); let readOnly = false; if (spaceConfig) { const vis = spaceConfig.visibility; if (vis === "authenticated" || vis === "members_only") { if (!claims) return new Response("Authentication required", { status: 401 }); } else if (vis === "public_read") { readOnly = !claims; } } const peerId = generatePeerId(); const mode = url.searchParams.get("mode") === "json" ? "json" : "automerge"; // Nest context: if connecting from a parent space via folk-canvas const nestFrom = url.searchParams.get("nest-from") || undefined; let nestPermissions: NestPermissions | undefined; let nestFilter: SpaceRefFilter | undefined; if (nestFrom) { await loadCommunity(nestFrom); const parentData = getDocumentData(nestFrom); if (parentData?.nestedSpaces) { // Find the SpaceRef in the parent that points to this space const matchingRef = Object.values(parentData.nestedSpaces) .find(ref => ref.sourceSlug === communitySlug); if (matchingRef) { nestPermissions = matchingRef.permissions; nestFilter = matchingRef.filter; // If nest doesn't allow writes, force readOnly if (!matchingRef.permissions.write) { readOnly = true; } } } } const upgraded = server.upgrade(req, { data: { communitySlug, peerId, claims, readOnly, mode, nestFrom, nestPermissions, nestFilter } as WSData, }); if (upgraded) return undefined; } return new Response("WebSocket upgrade failed", { status: 400 }); } // ── Explicit page routes (before Hono, to avoid /:space catch-all) ── if (url.pathname === "/admin") { const adminHtml = await serveStatic("admin.html"); if (adminHtml) return adminHtml; } // ── Static assets (before Hono routing) ── if (url.pathname !== "/" && !url.pathname.startsWith("/api/") && !url.pathname.startsWith("/ws/")) { const assetPath = url.pathname.slice(1); // Serve files with extensions directly if (assetPath.includes(".")) { const staticResponse = await serveStatic(assetPath); if (staticResponse) return staticResponse; } } // ── Subdomain routing: {space}.rspace.online/{moduleId}/... ── if (subdomain) { const pathSegments = url.pathname.split("/").filter(Boolean); // Root: redirect to default module (rspace) if (pathSegments.length === 0) { return Response.redirect(`${url.protocol}//${host}/rspace`, 302); } // Global routes pass through without subdomain prefix if ( url.pathname.startsWith("/api/") || url.pathname.startsWith("/.well-known/") || url.pathname === "/about" || url.pathname === "/admin" || url.pathname === "/create-space" || url.pathname === "/new" ) { return app.fetch(req); } // Static assets (paths with file extensions) pass through if (pathSegments[0].includes(".")) { return app.fetch(req); } // Rewrite: /{moduleId}/... → /{space}/{moduleId}/... // e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls const rewrittenPath = `/${subdomain}${url.pathname}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); } // ── Bare-domain module routes: rspace.online/{moduleId}[/...] ── // Exact module path → landing page; sub-paths rewrite to /demo/... if (!subdomain && hostClean.includes("rspace.online")) { const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length >= 1) { const firstSegment = pathSegments[0]; const allModules = getAllModules(); const mod = allModules.find((m) => m.id === firstSegment); if (mod) { if (pathSegments.length === 1) { // Try proxying the rich standalone landing page const proxyHtml = await fetchLandingPage(mod, getModuleInfoList()); if (proxyHtml) { return new Response(proxyHtml, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } // Fallback to generic landing page const html = renderModuleLanding({ module: mod, modules: getModuleInfoList(), }); return new Response(html, { headers: { "Content-Type": "text/html" } }); } // Sub-paths → rewrite to /demo/{moduleId}/... const rewrittenPath = `/demo${url.pathname}`; const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`); const rewrittenReq = new Request(rewrittenUrl, req); return app.fetch(rewrittenReq); } } } // ── Hono handles everything else ── const response = await app.fetch(req); // If Hono returns 404, try serving canvas.html as SPA fallback // But only for paths that don't match a known module route if (response.status === 404 && !url.pathname.startsWith("/api/")) { const parts = url.pathname.split("/").filter(Boolean); // Check if this is under a known module — if so, the module's 404 is authoritative const knownModuleIds = getAllModules().map((m) => m.id); const isModulePath = parts.length >= 2 && knownModuleIds.includes(parts[1]); if (!isModulePath && parts.length >= 1 && !parts[0].includes(".")) { // Not a module path — could be a canvas SPA route, try fallback const canvasHtml = await serveStatic("canvas.html"); if (canvasHtml) return canvasHtml; const indexHtml = await serveStatic("index.html"); if (indexHtml) return indexHtml; } } return response; }, // ── WebSocket handlers (unchanged) ── websocket: { open(ws: ServerWebSocket) { const { communitySlug, peerId, mode, nestFrom, nestFilter, claims } = ws.data; if (!communityClients.has(communitySlug)) { communityClients.set(communitySlug, new Map()); } communityClients.get(communitySlug)!.set(peerId, ws); // Register with DocSyncManager for multi-doc sync syncServer.addPeer(peerId, ws, claims ? { sub: claims.sub, username: claims.username } : undefined); const nestLabel = nestFrom ? ` (nested from ${nestFrom})` : ""; console.log(`[WS] Client ${peerId} connected to ${communitySlug} (mode: ${mode})${nestLabel}`); loadCommunity(communitySlug).then((doc) => { if (!doc) return; if (mode === "json") { const docData = getDocumentData(communitySlug); if (docData) { let shapes = docData.shapes || {}; // Apply nest filter if this is a nested connection if (nestFilter) { const filtered: typeof shapes = {}; for (const [id, shape] of Object.entries(shapes)) { if (nestFilter.shapeTypes && !nestFilter.shapeTypes.includes(shape.type)) continue; if (nestFilter.shapeIds && !nestFilter.shapeIds.includes(id)) continue; filtered[id] = shape; } shapes = filtered; } ws.send(JSON.stringify({ type: "snapshot", shapes })); } } else { const syncMessage = generateSyncMessageForPeer(communitySlug, peerId); if (syncMessage) { ws.send(JSON.stringify({ type: "sync", data: Array.from(syncMessage) })); } } }); }, message(ws: ServerWebSocket, message: string | Buffer) { const { communitySlug, peerId } = ws.data; try { const msg = JSON.parse(message.toString()); // ── DocSyncManager protocol (messages with docId) ── if (msg.type === "subscribe" || msg.type === "unsubscribe" || msg.type === "awareness") { if (msg.docIds || msg.docId) { syncServer.handleMessage(peerId, message.toString()); return; } } if (msg.type === "sync" && msg.docId) { // New protocol: sync with docId → route to SyncServer syncServer.handleMessage(peerId, message.toString()); return; } // ── Legacy canvas protocol (no docId) ── if (msg.type === "sync" && Array.isArray(msg.data)) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit this space" })); return; } const syncMessage = new Uint8Array(msg.data); const result = receiveSyncMessage(communitySlug, peerId, syncMessage); if (result.response) { ws.send(JSON.stringify({ type: "sync", data: Array.from(result.response) })); } for (const [targetPeerId, targetMessage] of result.broadcastToPeers) { const targetClient = getClient(communitySlug, targetPeerId); if (targetClient && targetClient.data.mode === "automerge" && targetClient.readyState === WebSocket.OPEN) { targetClient.send(JSON.stringify({ type: "sync", data: Array.from(targetMessage) })); } } if (result.broadcastToPeers.size > 0) { broadcastJsonSnapshot(communitySlug, peerId); } } else if (msg.type === "ping") { ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp })); } else if (msg.type === "presence") { const clients = communityClients.get(communitySlug); if (clients) { const presenceMsg = JSON.stringify({ type: "presence", peerId, ...msg }); for (const [clientPeerId, client] of clients) { if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) { client.send(presenceMsg); } } } } 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; } // Nest-level: check addShapes for new shapes if (ws.data.nestPermissions) { const existingDoc = getDocumentData(communitySlug); const isNewShape = existingDoc && !existingDoc.shapes?.[msg.id]; if (isNewShape && !ws.data.nestPermissions.addShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow adding shapes" })); return; } } updateShape(communitySlug, msg.id, msg.data); broadcastJsonSnapshot(communitySlug, peerId); 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; } if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow deleting shapes" })); return; } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } else if (msg.type === "forget" && msg.id) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to forget" })); return; } if (ws.data.nestPermissions && !ws.data.nestPermissions.deleteShapes) { ws.send(JSON.stringify({ type: "error", message: "Nest permissions do not allow forgetting shapes" })); return; } forgetShape(communitySlug, msg.id, ws.data.claims?.sub); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } else if (msg.type === "remember" && msg.id) { if (ws.data.readOnly) { ws.send(JSON.stringify({ type: "error", message: "Authentication required to remember" })); return; } rememberShape(communitySlug, msg.id); broadcastJsonSnapshot(communitySlug, peerId); broadcastAutomergeSync(communitySlug, peerId); } } catch (e) { console.error("[WS] Failed to parse message:", e); } }, close(ws: ServerWebSocket) { const { communitySlug, peerId } = ws.data; const clients = communityClients.get(communitySlug); if (clients) { clients.delete(peerId); if (clients.size === 0) communityClients.delete(communitySlug); } removePeerSyncState(communitySlug, peerId); syncServer.removePeer(peerId); console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`); }, }, }); // ── Startup ── ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e)); ensureCampaignDemo().then(() => console.log("[Campaign] Campaign demo ready")).catch((e) => console.error("[Campaign] Failed:", e)); loadAllDocs(syncServer).catch((e) => console.error("[DocStore] Startup load failed:", e)); console.log(`rSpace unified server running on http://localhost:${PORT}`); console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);