/** * 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 { providersModule } from "../modules/providers/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 { rsocialsModule } from "../modules/rsocials/mod"; import { spaces } from "./spaces"; import { renderShell, renderModuleLanding } from "./shell"; import { syncServer } from "./sync-instance"; import { loadAllDocs } from "./local-first/doc-persistence"; // Register modules registerModule(canvasModule); registerModule(booksModule); registerModule(pubsModule); registerModule(cartModule); registerModule(providersModule); 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(rsocialsModule); // ── 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"); // ── Standalone landing page proxy cache ── const landingCache = new Map(); const LANDING_TTL = 5 * 60 * 1000; // 5 minutes async function fetchStandaloneLanding(domain: string): Promise { const cached = landingCache.get(domain); if (cached && Date.now() - cached.at < LANDING_TTL) return cached.html; try { const resp = await fetch(`https://${domain}/`, { headers: { "Accept": "text/html" }, redirect: "follow", }); if (!resp.ok) return null; let html = await resp.text(); // Inject so relative assets (CSS, JS, images) resolve to the standalone domain html = html.replace(/]*)>/i, `\n`); landingCache.set(domain, { html, at: Date.now() }); return html; } catch { return null; } } // ── 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 } = 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"); const systemPrompt = `You are mi, 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). ## Available rApps ${moduleList} ## Current Context - Space: ${space || "none selected"} - Active rApp: ${currentModule || "none"} ## 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 you don't know something specific about the user's data, say so honestly. - Use a warm, knowledgeable tone. You're a guide, not a search engine.`; // 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() }); }); // ── 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) { // 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; } } // Rewrite path internally: / → /demo/{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 → proxy the standalone domain's landing page. // Sub-paths (API, assets) → rewrite to /demo/{moduleId}/... for backward compat. if (!subdomain && hostClean.includes("rspace.online")) { const pathSegments = url.pathname.split("/").filter(Boolean); if (pathSegments.length >= 1) { const firstSegment = pathSegments[0]; const knownModuleIds = new Set(getAllModules().map((m) => m.id)); if (knownModuleIds.has(firstSegment)) { // Exact module path → proxy from standalone domain if (pathSegments.length === 1) { const mod = getAllModules().find((m) => m.id === firstSegment); if (mod?.standaloneDomain) { const html = await fetchStandaloneLanding(mod.standaloneDomain); if (html) { return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" }, }); } } // Fallback: generated landing page (no standalone domain or fetch failed) const modInfo = getModuleInfoList().find((m) => m.id === firstSegment); if (modInfo) { return new Response( renderModuleLanding({ module: modInfo, modules: getModuleInfoList(), }), { headers: { "Content-Type": "text/html; charset=utf-8" } }, ); } } // Sub-paths → rewrite to demo space 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(", ")}`);