1013 lines
36 KiB
TypeScript
1013 lines
36 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,
|
|
} 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 { 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);
|
|
|
|
// ── Config ──
|
|
const PORT = Number(process.env.PORT) || 3000;
|
|
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
|
|
const DIST_DIR = resolve(import.meta.dir, "../dist");
|
|
|
|
// ── Hono app ──
|
|
const app = new Hono();
|
|
|
|
// CORS for API routes
|
|
app.use("/api/*", cors());
|
|
|
|
// ── .well-known/webauthn (WebAuthn Related Origins) ──
|
|
app.get("/.well-known/webauthn", (c) => {
|
|
return c.json(
|
|
{
|
|
origins: [
|
|
"https://rwallet.online",
|
|
"https://rvote.online",
|
|
"https://rmaps.online",
|
|
"https://rfiles.online",
|
|
"https://rnotes.online",
|
|
],
|
|
},
|
|
200,
|
|
{
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Cache-Control": "public, max-age=3600",
|
|
}
|
|
);
|
|
});
|
|
|
|
// ── Space registry API ──
|
|
app.route("/api/spaces", spaces);
|
|
|
|
// ── mi — AI assistant endpoint ──
|
|
const MI_MODEL = process.env.MI_MODEL || "llama3.2";
|
|
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
|
|
|
app.post("/api/mi/ask", async (c) => {
|
|
const { query, messages = [], space, module: currentModule } = 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<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
|
|
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<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() });
|
|
});
|
|
|
|
// ── 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<string, Map<string, ServerWebSocket<WSData>>>();
|
|
|
|
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;
|
|
}
|
|
|
|
// ── 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 → 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 → serve module 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 → landing page
|
|
if (pathSegments.length === 1) {
|
|
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<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 === "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;
|
|
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(", ")}`);
|