rspace-online/server/index.ts

2129 lines
76 KiB
TypeScript

/**
* 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,
listCommunities,
deleteCommunity,
} from "./community-store";
import type { NestPermissions, SpaceRefFilter } from "./community-store";
import { ensureDemoCommunity } from "./seed-demo";
import { seedTemplateShapes, ensureTemplateSeeding } from "./seed-template";
// Campaign demo moved to rsocials module — see modules/rsocials/campaign-data.ts
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/rspace/mod";
import { booksModule } from "../modules/rbooks/mod";
import { pubsModule } from "../modules/rpubs/mod";
import { cartModule } from "../modules/rcart/mod";
import { swagModule } from "../modules/rswag/mod";
import { choicesModule } from "../modules/rchoices/mod";
import { fundsModule } from "../modules/rfunds/mod";
import { filesModule } from "../modules/rfiles/mod";
import { forumModule } from "../modules/rforum/mod";
import { walletModule } from "../modules/rwallet/mod";
import { voteModule } from "../modules/rvote/mod";
import { notesModule } from "../modules/rnotes/mod";
import { mapsModule } from "../modules/rmaps/mod";
import { workModule } from "../modules/rwork/mod";
import { tripsModule } from "../modules/rtrips/mod";
import { calModule } from "../modules/rcal/mod";
import { networkModule } from "../modules/rnetwork/mod";
import { tubeModule } from "../modules/rtube/mod";
import { inboxModule } from "../modules/rinbox/mod";
import { dataModule } from "../modules/rdata/mod";
import { splatModule } from "../modules/rsplat/mod";
import { photosModule } from "../modules/rphotos/mod";
import { socialsModule } from "../modules/rsocials/mod";
import { docsModule } from "../modules/rdocs/mod";
import { designModule } from "../modules/rdesign/mod";
import { spaces, createSpace } from "./spaces";
import { renderShell, renderModuleLanding } from "./shell";
import { renderOutputListPage } from "./output-list";
import { renderMainLanding, renderSpaceDashboard } from "./landing";
import { fetchLandingPage } from "./landing-proxy";
import { syncServer } from "./sync-instance";
import { loadAllDocs } from "./local-first/doc-persistence";
import { backupRouter } from "./local-first/backup-routes";
// 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);
registerModule(docsModule);
registerModule(designModule);
// ── 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",
}
);
});
// ── Serve generated files from /data/files/generated/ ──
app.get("/data/files/generated/:filename", async (c) => {
const filename = c.req.param("filename");
if (!filename || filename.includes("..") || filename.includes("/")) {
return c.json({ error: "Invalid filename" }, 400);
}
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
const filePath = resolve(dir, filename);
const file = Bun.file(filePath);
if (!(await file.exists())) return c.notFound();
const ext = filename.split(".").pop() || "";
const mimeMap: Record<string, string> = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
return new Response(file, { headers: { "Content-Type": mimeMap[ext] || "application/octet-stream", "Cache-Control": "public, max-age=86400" } });
});
// ── Space registry API ──
app.route("/api/spaces", spaces);
// ── Backup API (encrypted blob storage) ──
app.route("/api/backup", backupRouter);
// ── 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, 15)
.map((s: any) => {
let desc = ` - ${s.type} (id: ${s.id})`;
if (s.title) desc += `: ${s.title}`;
if (s.snippet) desc += ` — "${s.snippet}"`;
if (s.x != null) desc += ` at (${s.x}, ${s.y})`;
return desc;
})
.join("\n");
contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`;
}
if (context.selectedShapes?.length) {
const selSummary = context.selectedShapes
.map((s: any) => ` - ${s.type} (id: ${s.id})${s.title ? `: ${s.title}` : ""}${s.snippet ? ` — "${s.snippet}"` : ""}`)
.join("\n");
contextSection += `\n- The user currently has selected:\n${selSummary}`;
}
if (context.connections?.length) {
const connSummary = context.connections
.slice(0, 15)
.map((c: any) => ` - ${c.sourceId}${c.targetId}`)
.join("\n");
contextSection += `\n- Connected shapes:\n${connSummary}`;
}
if (context.viewport) {
contextSection += `\n- Viewport: zoom ${context.viewport.scale?.toFixed?.(2) || context.viewport.scale}, pan (${Math.round(context.viewport.x)}, ${Math.round(context.viewport.y)})`;
}
if (context.shapeGroups?.length) {
contextSection += `\n- ${context.shapeGroups.length} group(s) of connected shapes`;
}
if (context.shapeCountByType && Object.keys(context.shapeCountByType).length) {
const typeCounts = Object.entries(context.shapeCountByType)
.map(([t, n]) => `${t}: ${n}`)
.join(", ");
contextSection += `\n- Shape types: ${typeCounts}`;
}
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 by id 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.
## Actions
When the user asks you to create, modify, delete, connect, move, or arrange shapes on the canvas,
include action markers in your response. Each marker is on its own line:
[MI_ACTION:{"type":"create-shape","tagName":"folk-markdown","props":{"content":"# Hello"},"ref":"$1"}]
[MI_ACTION:{"type":"connect","sourceId":"$1","targetId":"shape-123"}]
[MI_ACTION:{"type":"update-shape","shapeId":"shape-123","fields":{"content":"Updated text"}}]
[MI_ACTION:{"type":"delete-shape","shapeId":"shape-123"}]
[MI_ACTION:{"type":"move-shape","shapeId":"shape-123","x":400,"y":200}]
[MI_ACTION:{"type":"navigate","path":"/myspace/canvas"}]
Use "$1", "$2", etc. as ref values when creating shapes, then reference them in subsequent connect actions.
Available shape types: folk-markdown, folk-wrapper, folk-image-gen, folk-video-gen, folk-prompt,
folk-embed, folk-calendar, folk-map, folk-chat, folk-slide, folk-obs-note, folk-workflow-block,
folk-social-post, folk-splat, folk-drawfast, folk-rapp, folk-feed.
## Transforms
When the user asks to align, distribute, or arrange selected shapes:
[MI_ACTION:{"type":"transform","transform":"align-left","shapeIds":["shape-1","shape-2"]}]
Available transforms: align-left, align-right, align-center-h, align-top, align-bottom, align-center-v,
distribute-h, distribute-v, arrange-row, arrange-column, arrange-grid, arrange-circle,
match-width, match-height, match-size.`;
// 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<typeof getModuleInfoList>,
): 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<SpaceAuthConfig | null> {
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 (deprecated, use POST /api/spaces)
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;
const validVisibilities: SpaceVisibility[] = ["public", "public_read", "authenticated", "members_only"];
if (visibility && !validVisibilities.includes(visibility)) return c.json({ error: "Invalid visibility" }, 400);
const result = await createSpace({
name: name || "", slug: slug || "", ownerDID: claims.sub, visibility, source: 'api',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
c.header("Deprecation", "true");
c.header("Link", "</api/spaces>; rel=\"successor-version\"");
return c.json({ url: `https://${result.slug}.rspace.online`, slug: result.slug, name: result.name, visibility: result.visibility, ownerDID: result.ownerDID }, 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 result = await createSpace({
name: space.charAt(0).toUpperCase() + space.slice(1),
slug: space,
ownerDID: `did:system:${space}`,
visibility: body.public ? "public" : "public_read",
source: 'internal',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
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" });
});
// Campaign demo reset removed — campaign is now at /:space/rsocials/campaign
// 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<string, unknown>[] }>();
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<Record<string, unknown>>();
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() });
});
// ── x402 test endpoint (no auth, payment-gated only) ──
import { setupX402FromEnv } from "../shared/x402/hono-middleware";
const x402Test = setupX402FromEnv({ description: "x402 test endpoint", resource: "/api/x402-test" });
app.post("/api/x402-test", async (c) => {
if (x402Test) {
const result = await new Promise<Response | null>((resolve) => {
x402Test(c, async () => { resolve(null); }).then((res) => {
if (res instanceof Response) resolve(res);
});
});
if (result) return result;
}
return c.json({ ok: true, message: "Payment received!", timestamp: new Date().toISOString() });
});
// ── Creative tools API endpoints ──
const FAL_KEY = process.env.FAL_KEY || "";
const GEMINI_API_KEY = process.env.GEMINI_API_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<string, string> = {
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);
}
});
// ── Multi-provider prompt endpoint ──
const GEMINI_MODELS: Record<string, string> = {
"gemini-flash": "gemini-2.0-flash-exp",
"gemini-pro": "gemini-1.5-pro",
};
const OLLAMA_MODELS: Record<string, string> = {
"llama3.2": "llama3.2:3b",
"llama3.1": "llama3.1:8b",
"qwen2.5-coder": "qwen2.5-coder:7b",
"mistral-small": "mistral-small:24b",
};
app.post("/api/prompt", async (c) => {
const { messages, model = "gemini-flash" } = await c.req.json();
if (!messages?.length) return c.json({ error: "messages required" }, 400);
// Determine provider
if (GEMINI_MODELS[model]) {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const geminiModel = genAI.getGenerativeModel({ model: GEMINI_MODELS[model] });
// Convert chat messages to Gemini contents format
const contents = messages.map((m: { role: string; content: string }) => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }],
}));
try {
const result = await geminiModel.generateContent({ contents });
const text = result.response.text();
return c.json({ content: text });
} catch (e: any) {
console.error("[prompt] Gemini error:", e.message);
return c.json({ error: "Gemini request failed" }, 502);
}
}
if (OLLAMA_MODELS[model]) {
try {
const ollamaRes = await fetch(`${OLLAMA_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: OLLAMA_MODELS[model],
messages: messages.map((m: { role: string; content: string }) => ({
role: m.role,
content: m.content,
})),
stream: false,
}),
});
if (!ollamaRes.ok) {
const err = await ollamaRes.text();
console.error("[prompt] Ollama error:", err);
return c.json({ error: "Ollama request failed" }, 502);
}
const data = await ollamaRes.json();
return c.json({ content: data.message?.content || "" });
} catch (e: any) {
console.error("[prompt] Ollama unreachable:", e.message);
return c.json({ error: "Ollama unreachable" }, 503);
}
}
return c.json({ error: `Unknown model: ${model}` }, 400);
});
// ── Gemini image generation ──
app.post("/api/gemini/image", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { prompt, style, aspect_ratio } = await c.req.json();
if (!prompt) return c.json({ error: "prompt required" }, 400);
const styleHints: Record<string, string> = {
photorealistic: "photorealistic, high detail, natural lighting, ",
illustration: "digital illustration, clean lines, vibrant colors, ",
painting: "oil painting style, brushstrokes visible, painterly, ",
sketch: "pencil sketch, hand-drawn, line art, ",
"punk-zine": "punk zine aesthetic, xerox texture, high contrast, DIY, rough edges, ",
collage: "cut-and-paste collage, mixed media, layered paper textures, ",
vintage: "vintage aesthetic, retro colors, aged paper texture, ",
minimalist: "minimalist design, simple shapes, limited color palette, ",
};
const enhancedPrompt = (styleHints[style] || "") + prompt;
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const models = ["gemini-2.0-flash-exp", "imagen-3.0-generate-002"];
for (const modelName of models) {
try {
if (modelName.startsWith("gemini")) {
const result = await ai.models.generateContent({
model: modelName,
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `gemini-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} else {
// Imagen API
const result = await ai.models.generateImages({
model: modelName,
prompt: enhancedPrompt,
config: {
numberOfImages: 1,
aspectRatio: aspect_ratio || "3:4",
},
});
const img = (result as any).generatedImages?.[0];
if (img?.image?.imageBytes) {
const filename = `imagen-${Date.now()}.png`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(img.image.imageBytes, "base64"));
const url = `/data/files/generated/${filename}`;
return c.json({ url, image_url: url });
}
}
} catch (e: any) {
console.error(`[gemini/image] ${modelName} error:`, e.message);
continue;
}
}
return c.json({ error: "All Gemini image models failed" }, 502);
});
// ── Zine generation endpoints ──
const ZINE_STYLES: Record<string, string> = {
"punk-zine": "Xerox texture, high contrast B&W, DIY collage, hand-drawn typography",
mycelial: "Organic networks, spore patterns, earth tones, fungal textures, interconnected webs",
minimal: "Clean lines, white space, modern sans-serif, subtle gradients",
collage: "Layered imagery, mixed media textures, vintage photographs",
retro: "1970s aesthetic, earth tones, groovy typography, halftone patterns",
academic: "Diagram-heavy, annotated illustrations, infographic elements",
};
const ZINE_TONES: Record<string, string> = {
rebellious: "Defiant, anti-establishment, punk energy",
regenerative: "Hopeful, nature-inspired, healing, systems-thinking",
playful: "Fun, whimsical, approachable",
informative: "Educational, clear, accessible",
poetic: "Lyrical, metaphorical, evocative",
};
app.post("/api/zine/outline", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { topic, style = "punk-zine", tone = "informative", pageCount = 8 } = await c.req.json();
if (!topic) return c.json({ error: "topic required" }, 400);
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-1.5-pro" });
const systemPrompt = `You are a zine designer creating a ${pageCount}-page MycroZine.
Style: ${ZINE_STYLES[style] || style}
Tone: ${ZINE_TONES[tone] || tone}
Create a structured outline for a zine about: "${topic}"
Each page has sections that will be generated independently. Return ONLY valid JSON (no markdown fences):
{
"pages": [
{
"pageNumber": 1,
"type": "cover",
"title": "...",
"sections": [
{ "id": "headline", "type": "text", "content": "Main headline text" },
{ "id": "subhead", "type": "text", "content": "Subtitle or tagline" },
{ "id": "hero", "type": "image", "imagePrompt": "Detailed image description for cover art" }
],
"hashtags": ["#tag1"]
},
{
"pageNumber": 2,
"type": "content",
"title": "...",
"sections": [
{ "id": "heading", "type": "text", "content": "Section heading" },
{ "id": "body", "type": "text", "content": "Main body text (2-3 paragraphs)" },
{ "id": "pullquote", "type": "text", "content": "A punchy pull quote" },
{ "id": "visual", "type": "image", "imagePrompt": "Detailed description for page illustration" }
],
"hashtags": ["#tag"]
}
]
}
Rules:
- Page 1 is always "cover" type with headline, subhead, hero image
- Pages 2-${pageCount - 1} are "content" with heading, body, pullquote, visual
- Page ${pageCount} is "cta" (call-to-action) with heading, body, action, visual
- Each image prompt should be highly specific, matching the ${style} style
- Text content should match the ${tone} tone
- Make body text substantive (2-3 short paragraphs each)`;
try {
const result = await model.generateContent(systemPrompt);
const text = result.response.text();
// Parse JSON (strip markdown fences if present)
const jsonStr = text.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim();
const outline = JSON.parse(jsonStr);
return c.json(outline);
} catch (e: any) {
console.error("[zine/outline] error:", e.message);
return c.json({ error: "Failed to generate outline" }, 502);
}
});
app.post("/api/zine/page", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { outline, style = "punk-zine", tone = "informative" } = await c.req.json();
if (!outline?.pageNumber) return c.json({ error: "outline required with pageNumber" }, 400);
// Find all image sections and generate them
const imageSections = (outline.sections || []).filter((s: any) => s.type === "image");
const generatedImages: Record<string, string> = {};
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
for (const section of imageSections) {
const styleDesc = ZINE_STYLES[style] || style;
const toneDesc = ZINE_TONES[tone] || tone;
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${outline.title}".`;
try {
const result = await ai.models.generateContent({
model: "gemini-2.0-flash-exp",
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `zine-p${outline.pageNumber}-${section.id}-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
generatedImages[section.id] = `/data/files/generated/${filename}`;
break;
}
}
} catch (e: any) {
console.error(`[zine/page] Image gen failed for ${section.id}:`, e.message);
}
}
return c.json({
pageNumber: outline.pageNumber,
images: generatedImages,
outline,
});
});
app.post("/api/zine/regenerate-section", async (c) => {
if (!GEMINI_API_KEY) return c.json({ error: "GEMINI_API_KEY not configured" }, 503);
const { section, pageTitle, style = "punk-zine", tone = "informative", feedback = "" } = await c.req.json();
if (!section?.id) return c.json({ error: "section with id required" }, 400);
if (section.type === "image") {
// Regenerate image with feedback
const { GoogleGenAI } = await import("@google/genai");
const ai = new GoogleGenAI({ apiKey: GEMINI_API_KEY });
const styleDesc = ZINE_STYLES[style] || style;
const toneDesc = ZINE_TONES[tone] || tone;
const enhancedPrompt = `${styleDesc} style, ${toneDesc} mood. ${section.imagePrompt}. For a zine page about "${pageTitle}".${feedback ? ` User feedback: ${feedback}` : ""}`;
try {
const result = await ai.models.generateContent({
model: "gemini-2.0-flash-exp",
contents: enhancedPrompt,
config: { responseModalities: ["Text", "Image"] },
});
const parts = result.candidates?.[0]?.content?.parts || [];
for (const part of parts) {
if ((part as any).inlineData) {
const { data: b64, mimeType } = (part as any).inlineData;
const ext = mimeType?.includes("png") ? "png" : "jpg";
const filename = `zine-regen-${section.id}-${Date.now()}.${ext}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, filename), Buffer.from(b64, "base64"));
return c.json({ sectionId: section.id, type: "image", url: `/data/files/generated/${filename}` });
}
}
return c.json({ error: "No image generated" }, 502);
} catch (e: any) {
console.error("[zine/regenerate-section] image error:", e.message);
return c.json({ error: "Image regeneration failed" }, 502);
}
}
if (section.type === "text") {
// Regenerate text via Gemini
const { GoogleGenerativeAI } = await import("@google/generative-ai");
const genAI = new GoogleGenerativeAI(GEMINI_API_KEY);
const model = genAI.getGenerativeModel({ model: "gemini-2.0-flash-exp" });
const toneDesc = ZINE_TONES[tone] || tone;
const prompt = `Rewrite this zine text section. Section type: "${section.id}". Page title: "${pageTitle}". Tone: ${toneDesc}.
Current text: "${section.content}"
${feedback ? `User feedback: ${feedback}` : "Make it punchier and more engaging."}
Return ONLY the new text, no quotes or explanation.`;
try {
const result = await model.generateContent(prompt);
return c.json({ sectionId: section.id, type: "text", content: result.response.text().trim() });
} catch (e: any) {
console.error("[zine/regenerate-section] text error:", e.message);
return c.json({ error: "Text regeneration failed" }, 502);
}
}
return c.json({ error: `Unknown section type: ${section.type}` }, 400);
});
// ── 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 });
}
const result = await createSpace({
name: `${claims.username}'s Space`,
slug: username,
ownerDID: claims.sub,
visibility: "members_only",
source: 'auto-provision',
});
if (!result.ok) return c.json({ error: result.error }, result.status);
return c.json({ status: "created", slug: username }, 201);
});
// ── Inject space visibility into HTML responses ──
// Replaces the default data-space-visibility="public_read" rendered by renderShell
// with the actual visibility from the space config, so the client-side access gate
// can block content for members_only spaces when no session exists.
app.use("/:space/*", async (c, next) => {
await next();
const ct = c.res.headers.get("content-type");
if (!ct?.includes("text/html")) return;
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return;
const config = await getSpaceConfig(space);
const vis = config?.visibility || "public_read";
if (vis === "public_read" || vis === "public") return;
const html = await c.res.text();
c.res = new Response(
html.replace(
'data-space-visibility="public_read"',
`data-space-visibility="${vis}"`,
),
{ status: c.res.status, headers: c.res.headers },
);
});
// ── Template seeding route: /:space/:moduleId/template ──
// Triggers template seeding for empty spaces, then redirects to the module page.
app.get("/:space/:moduleId/template", async (c) => {
const space = c.req.param("space");
const moduleId = c.req.param("moduleId");
if (!space || space === "api" || space.includes(".")) return c.notFound();
try {
await loadCommunity(space);
const seeded = seedTemplateShapes(space);
if (seeded) {
broadcastAutomergeSync(space);
broadcastJsonSnapshot(space);
}
} catch (e) {
console.error(`[Template] On-demand seed failed for "${space}":`, e);
}
return c.redirect(`/${space}/${moduleId}`, 302);
});
// ── Mount module routes under /:space/:moduleId ──
// Enforce enabledModules: if a space has an explicit list, only those modules route.
// The 'rspace' (canvas) module is always allowed as the core module.
for (const mod of getAllModules()) {
app.use(`/:space/${mod.id}/*`, async (c, next) => {
if (mod.id === "rspace") return next();
const space = c.req.param("space");
if (!space || space === "api" || space.includes(".")) return next();
const doc = getDocumentData(space);
if (!doc?.meta?.enabledModules) return next(); // null = all enabled
if (doc.meta.enabledModules.includes(mod.id)) return next();
return c.json({ error: "Module not enabled for this space" }, 404);
});
app.route(`/:space/${mod.id}`, mod.routes);
// Auto-mount browsable output list pages
if (mod.outputPaths) {
for (const op of mod.outputPaths) {
app.get(`/:space/${mod.id}/${op.path}`, (c) => {
return c.html(renderOutputListPage(
c.req.param("space"), mod, op, getModuleInfoList()
));
});
}
}
}
// ── Page routes ──
// Landing page: rspace.online/ → server-rendered main landing
app.get("/", (c) => {
return c.html(renderMainLanding(getModuleInfoList()));
});
// 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 & API ──
const ADMIN_DIDS = (process.env.ADMIN_DIDS || "").split(",").filter(Boolean);
function isAdminDID(did: string | undefined): boolean {
return !!did && ADMIN_DIDS.includes(did);
}
// Serve admin HTML page
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);
});
// Admin data endpoint (GET) — returns spaces list + modules
app.get("/admin-data", 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 token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const STORAGE_DIR = process.env.STORAGE_DIR || "./data/communities";
const slugs = await listCommunities();
const spacesList = [];
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data?.meta) continue;
const shapes = data.shapes || {};
const members = data.members || {};
const shapeCount = Object.keys(shapes).length;
const memberCount = Object.keys(members).length;
let fileSizeBytes = 0;
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.automerge`);
fileSizeBytes = s.size;
} catch {
try {
const { stat } = await import("node:fs/promises");
const s = await stat(`${STORAGE_DIR}/${slug}.json`);
fileSizeBytes = s.size;
} catch { /* not on disk yet */ }
}
const shapeTypes: Record<string, number> = {};
for (const shape of Object.values(shapes)) {
const t = (shape as Record<string, unknown>).type as string || "unknown";
shapeTypes[t] = (shapeTypes[t] || 0) + 1;
}
spacesList.push({
slug: data.meta.slug,
name: data.meta.name,
visibility: data.meta.visibility || "public_read",
createdAt: data.meta.createdAt,
ownerDID: data.meta.ownerDID,
shapeCount,
memberCount,
fileSizeBytes,
shapeTypes,
});
}
spacesList.sort((a, b) => {
const da = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const db = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return db - da;
});
return c.json({
spaces: spacesList,
total: spacesList.length,
modules: getModuleInfoList(),
});
});
// Admin action endpoint (POST) — mutations (delete space, etc.)
app.post("/admin-action", 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 token" }, 401); }
if (!isAdminDID(claims.sub)) return c.json({ error: "Admin access required" }, 403);
const body = await c.req.json();
const { action, slug } = body;
if (action === "delete-space" && slug) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (!data) return c.json({ error: "Space not found" }, 404);
const deleteCtx = {
spaceSlug: slug,
ownerDID: data.meta?.ownerDID ?? null,
enabledModules: data.meta?.enabledModules || getAllModules().map(m => m.id),
syncServer,
};
for (const mod of getAllModules()) {
if (mod.onSpaceDelete) {
try { await mod.onSpaceDelete(deleteCtx); } catch (e) {
console.error(`[Admin] Module ${mod.id} onSpaceDelete failed:`, e);
}
}
}
// Clean up EncryptID space_members
try {
await fetch(`https://auth.rspace.online/api/admin/spaces/${slug}/members`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
} catch (e) {
console.error(`[Admin] Failed to clean EncryptID space_members for ${slug}:`, e);
}
await deleteCommunity(slug);
return c.json({ ok: true, message: `Space "${slug}" deleted by admin` });
}
return c.json({ error: "Unknown action" }, 400);
});
// Space root: /:space → space dashboard
app.get("/:space", (c) => {
const space = c.req.param("space");
// Don't serve dashboard for static file paths
if (space.includes(".")) return c.notFound();
return c.html(renderSpaceDashboard(space, getModuleInfoList()));
});
// ── 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<string, Map<string, ServerWebSocket<WSData>>>();
// Track announced peer info per community: slug → serverPeerId → { clientPeerId, username, color }
const peerAnnouncements = new Map<string, Map<string, { clientPeerId: string; username: string; color: string }>>();
function generatePeerId(): string {
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | 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;
}
// ── Auto-provision helpers ──
/** Extract JWT from Authorization header, then eid_token cookie, then encryptid_token cookie */
function extractTokenFromRequest(req: Request): string | null {
const auth = req.headers.get("authorization");
if (auth?.startsWith("Bearer ")) return auth.slice(7);
const cookie = req.headers.get("cookie");
if (cookie) {
const eidMatch = cookie.match(/(?:^|;\s*)eid_token=([^;]*)/);
if (eidMatch) return decodeURIComponent(eidMatch[1]);
const encMatch = cookie.match(/(?:^|;\s*)encryptid_token=([^;]*)/);
if (encMatch) return decodeURIComponent(encMatch[1]);
}
return null;
}
/** Base64-decode JWT payload to extract username (cheap pre-check, no crypto) */
function decodeJWTUsername(token: string): string | null {
try {
const parts = token.split(".");
if (parts.length < 2) return null;
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = "=".repeat((4 - (b64.length % 4)) % 4);
const payload = JSON.parse(atob(b64 + pad));
return payload.username?.toLowerCase() || null;
} catch {
return null;
}
}
/** Tracks slugs currently being provisioned to prevent concurrent double-creation */
const provisioningInProgress = new Set<string>();
// ── 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<Response | null> {
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<string, string>();
for (const mod of getAllModules()) {
if (mod.standaloneDomain) {
domainToModule.set(mod.standaloneDomain, mod.id);
}
}
// ── Bun.serve: WebSocket + fetch delegation ──
const server = Bun.serve<WSData>({
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 → 301 redirect to rspace.online ──
// Check both bare domain and subdomain variants (e.g. rnotes.online, alice.rnotes.online)
let standaloneModuleId = domainToModule.get(hostClean);
let standaloneSub: string | null = null;
if (!standaloneModuleId) {
// Check if this is a subdomain of a standalone domain (e.g. alice.rnotes.online)
const hostParts = hostClean.split(".");
if (hostParts.length >= 3) {
const baseDomain = hostParts.slice(-2).join(".");
const candidate = domainToModule.get(baseDomain);
if (candidate) {
standaloneModuleId = candidate;
standaloneSub = hostParts.slice(0, -2).join(".");
}
}
}
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 });
}
// Determine the space and remaining path
const pathParts = url.pathname.split("/").filter(Boolean);
let space = standaloneSub || null; // subdomain on standalone domain = space
let remainingPath = "";
if (pathParts.length > 0 && !pathParts[0].includes(".") &&
pathParts[0] !== "api" && pathParts[0] !== "ws") {
// First path segment is the space (if no subdomain already set it)
if (!space) {
space = pathParts[0];
remainingPath = pathParts.length > 1 ? "/" + pathParts.slice(1).join("/") : "";
} else {
remainingPath = url.pathname;
}
} else {
remainingPath = url.pathname === "/" ? "" : url.pathname;
}
// Build redirect URL
let redirectUrl: string;
if (space) {
// Space-qualified: alice.rnotes.online/path or rnotes.online/alice/path
// → alice.rspace.online/rnotes/path
redirectUrl = `https://${space}.rspace.online/${standaloneModuleId}${remainingPath}`;
} else {
// No space: rnotes.online/ or rnotes.online/api/...
// → rspace.online/rnotes or rspace.online/rnotes/api/...
redirectUrl = `https://rspace.online/${standaloneModuleId}${remainingPath}`;
}
if (url.search) redirectUrl += url.search;
return Response.redirect(redirectUrl, 301);
}
// ── 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) {
// ── Auto-provision personal space on first visit ──
// Fast path: communityExists is an in-memory Map check for cached spaces.
if (!(await communityExists(subdomain))) {
const token = extractTokenFromRequest(req);
if (token) {
const jwtUsername = decodeJWTUsername(token);
if (jwtUsername === subdomain && !provisioningInProgress.has(subdomain)) {
provisioningInProgress.add(subdomain);
try {
const claims = await verifyEncryptIDToken(token);
const username = claims.username?.toLowerCase();
if (username === subdomain && !(await communityExists(subdomain))) {
await createSpace({
name: `${claims.username}'s Space`,
slug: subdomain,
ownerDID: claims.sub,
visibility: "members_only",
source: 'subdomain',
});
}
} catch (e) {
console.error(`[AutoProvision] Token verification failed for ${subdomain}:`, e);
} finally {
provisioningInProgress.delete(subdomain);
}
}
}
}
const pathSegments = url.pathname.split("/").filter(Boolean);
// Root: show space dashboard
if (pathSegments.length === 0) {
return new Response(renderSpaceDashboard(subdomain, getModuleInfoList()), {
headers: { "Content-Type": "text/html" },
});
}
// 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);
}
// Template seeding: /{moduleId}/template → seed + redirect
if (pathSegments.length >= 2 && pathSegments[pathSegments.length - 1] === "template") {
try {
await loadCommunity(subdomain);
const seeded = seedTemplateShapes(subdomain);
if (seeded) {
broadcastAutomergeSync(subdomain);
broadcastJsonSnapshot(subdomain);
}
} catch (e) {
console.error(`[Template] On-demand seed failed for "${subdomain}":`, e);
}
const withoutTemplate = pathSegments.slice(0, -1).join("/");
return Response.redirect(`${url.protocol}//${url.host}/${withoutTemplate}`, 302);
}
// Normalize module ID to lowercase (rTrips → rtrips)
const normalizedPath = "/" + pathSegments.map((seg, i) =>
i === 0 ? seg.toLowerCase() : seg
).join("/");
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
// e.g. demo.rspace.online/vote/api/polls → /demo/vote/api/polls
const rewrittenPath = `/${subdomain}${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
// ── Bare-domain routing: rspace.online/{...} ──
if (!subdomain && hostClean.includes("rspace.online")) {
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length >= 1) {
const firstSegment = pathSegments[0].toLowerCase();
const allModules = getAllModules();
const knownModuleIds = new Set(allModules.map((m) => m.id));
const mod = allModules.find((m) => m.id === firstSegment);
if (mod) {
// rspace.online/{moduleId} → landing page
if (pathSegments.length === 1) {
// 1. Check for inline rich landing page
if (mod.landingPage) {
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),
bodyHTML: mod.landingPage(),
});
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
}
// 2. 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" },
});
}
// 3. Fallback to generic landing page
const html = renderModuleLanding({
module: mod,
modules: getModuleInfoList(),
});
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
// rspace.online/{moduleId}/sub-path → rewrite to demo space internally
const normalizedPath = "/" + [firstSegment, ...pathSegments.slice(1)].join("/");
const rewrittenPath = `/demo${normalizedPath}`;
const rewrittenUrl = new URL(rewrittenPath + url.search, `http://localhost:${PORT}`);
const rewrittenReq = new Request(rewrittenUrl, req);
return app.fetch(rewrittenReq);
}
// rspace.online/{space}/{...} → redirect to {space}.rspace.online/{...}
// (space is not a module ID — it's a space slug, canonicalize to subdomain)
// Skip redirect for known server paths (api, admin, etc.)
const serverPaths = new Set(["api", "admin", "admin-data", "admin-action", ".well-known"]);
if (!knownModuleIds.has(firstSegment) && !serverPaths.has(firstSegment) && pathSegments.length >= 2) {
const space = firstSegment;
const rest = "/" + pathSegments.slice(1).join("/");
const baseDomain = hostClean.replace(/^www\./, "");
return Response.redirect(
`${url.protocol}//${space}.${baseDomain}${rest}${url.search}`, 301
);
}
}
}
// ── 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].toLowerCase());
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<WSData>) {
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<WSData>, 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 === "announce") {
// Client announcing its identity for the people-online panel
const { peerId: clientPeerId, username, color } = msg;
if (!clientPeerId || !username) return;
let slugAnnouncements = peerAnnouncements.get(communitySlug);
if (!slugAnnouncements) {
slugAnnouncements = new Map();
peerAnnouncements.set(communitySlug, slugAnnouncements);
}
slugAnnouncements.set(peerId, { clientPeerId, username, color });
// Send full peer list back to the announcing client
const peerList = Array.from(slugAnnouncements.values());
ws.send(JSON.stringify({ type: "peer-list", peers: peerList }));
// Broadcast peer-joined to all other clients
const joinedMsg = JSON.stringify({ type: "peer-joined", peerId: clientPeerId, username, color });
const clients2 = communityClients.get(communitySlug);
if (clients2) {
for (const [cPeerId, client] of clients2) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(joinedMsg);
}
}
}
} else if (msg.type === "ping-user") {
// Relay a "come here" ping to a specific peer
const { targetPeerId: targetClientPeerId, viewport } = msg;
const slugAnnouncements = peerAnnouncements.get(communitySlug);
const senderInfo = slugAnnouncements?.get(peerId);
if (!slugAnnouncements || !senderInfo) return;
// Find the server peerId that matches the target client peerId
const clients3 = communityClients.get(communitySlug);
if (clients3) {
for (const [serverPid, _client] of clients3) {
const ann = slugAnnouncements.get(serverPid);
if (ann && ann.clientPeerId === targetClientPeerId && _client.readyState === WebSocket.OPEN) {
_client.send(JSON.stringify({
type: "ping-user",
fromPeerId: senderInfo.clientPeerId,
fromUsername: senderInfo.username,
viewport,
}));
break;
}
}
}
} 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<WSData>) {
const { communitySlug, peerId } = ws.data;
// Broadcast peer-left before cleanup
const slugAnnouncements = peerAnnouncements.get(communitySlug);
if (slugAnnouncements) {
const ann = slugAnnouncements.get(peerId);
if (ann) {
const leftMsg = JSON.stringify({ type: "peer-left", peerId: ann.clientPeerId });
const clients = communityClients.get(communitySlug);
if (clients) {
for (const [cPeerId, client] of clients) {
if (cPeerId !== peerId && client.readyState === WebSocket.OPEN) {
client.send(leftMsg);
}
}
}
slugAnnouncements.delete(peerId);
if (slugAnnouncements.size === 0) peerAnnouncements.delete(communitySlug);
}
}
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 ──
// Call onInit for each module that defines it (schema registration, DB init, etc.)
(async () => {
for (const mod of getAllModules()) {
if (mod.onInit) {
try {
await mod.onInit({ syncServer });
console.log(`[Init] ${mod.name} initialized`);
} catch (e) {
console.error(`[Init] ${mod.name} failed:`, e);
}
}
}
})();
// Ensure generated files directory exists
import { mkdirSync } from "node:fs";
try { mkdirSync(resolve(process.env.FILES_DIR || "./data/files", "generated"), { recursive: true }); } catch {}
ensureDemoCommunity().then(() => console.log("[Demo] Demo community ready")).catch((e) => console.error("[Demo] Failed:", e));
loadAllDocs(syncServer)
.then(() => ensureTemplateSeeding())
.catch((e) => console.error("[DocStore] Startup load failed:", e));
// Restore relay mode for encrypted spaces
(async () => {
try {
const slugs = await listCommunities();
let relayCount = 0;
for (const slug of slugs) {
await loadCommunity(slug);
const data = getDocumentData(slug);
if (data?.meta?.encrypted) {
syncServer.setRelayOnly(slug, true);
relayCount++;
}
}
if (relayCount > 0) {
console.log(`[Encryption] ${relayCount} space(s) set to relay mode (encrypted)`);
}
} catch (e) {
console.error("[Encryption] Failed to restore relay modes:", e);
}
})();
console.log(`rSpace unified server running on http://localhost:${PORT}`);
console.log(`Modules: ${getAllModules().map((m) => `${m.icon} ${m.name}`).join(", ")}`);