573 lines
16 KiB
TypeScript
573 lines
16 KiB
TypeScript
import { resolve } from "node:path";
|
|
import type { ServerWebSocket } from "bun";
|
|
import {
|
|
addShapes,
|
|
communityExists,
|
|
createCommunity,
|
|
deleteShape,
|
|
generateSyncMessageForPeer,
|
|
getDocumentData,
|
|
loadCommunity,
|
|
receiveSyncMessage,
|
|
removePeerSyncState,
|
|
updateShape,
|
|
} from "./community-store";
|
|
import type { SpaceVisibility } from "./community-store";
|
|
import {
|
|
verifyEncryptIDToken,
|
|
evaluateSpaceAccess,
|
|
extractToken,
|
|
authenticateWSUpgrade,
|
|
} from "@encryptid/sdk/server";
|
|
import type { EncryptIDClaims, SpaceAuthConfig } from "@encryptid/sdk/server";
|
|
|
|
/** Resolve a community slug to its 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",
|
|
};
|
|
}
|
|
|
|
const PORT = Number(process.env.PORT) || 3000;
|
|
const INTERNAL_API_KEY = process.env.INTERNAL_API_KEY || "";
|
|
const DIST_DIR = resolve(import.meta.dir, "../dist");
|
|
|
|
// WebSocket data type
|
|
interface WSData {
|
|
communitySlug: string;
|
|
peerId: string;
|
|
claims: EncryptIDClaims | null;
|
|
readOnly: boolean;
|
|
}
|
|
|
|
// Track connected clients per community (for broadcasting)
|
|
const communityClients = new Map<string, Map<string, ServerWebSocket<WSData>>>();
|
|
|
|
// Generate unique peer ID
|
|
function generatePeerId(): string {
|
|
return `peer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
}
|
|
|
|
// Helper to get client by peer ID
|
|
function getClient(slug: string, peerId: string): ServerWebSocket<WSData> | undefined {
|
|
return communityClients.get(slug)?.get(peerId);
|
|
}
|
|
|
|
// Special subdomains that should show the creation form instead of canvas
|
|
const RESERVED_SUBDOMAINS = ["www", "rspace", "create", "new", "start"];
|
|
|
|
// Parse subdomain from host header
|
|
function getSubdomain(host: string | null): string | null {
|
|
if (!host) return null;
|
|
|
|
// Handle localhost for development
|
|
if (host.includes("localhost") || host.includes("127.0.0.1")) {
|
|
return null;
|
|
}
|
|
|
|
// Extract subdomain from *.rspace.online
|
|
const parts = host.split(".");
|
|
if (parts.length >= 3 && parts.slice(-2).join(".") === "rspace.online") {
|
|
const subdomain = parts[0];
|
|
// Reserved subdomains show the creation form
|
|
if (!RESERVED_SUBDOMAINS.includes(subdomain)) {
|
|
return subdomain;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
// Serve static files
|
|
async function serveStatic(path: string): Promise<Response | null> {
|
|
const filePath = resolve(DIST_DIR, path);
|
|
const file = Bun.file(filePath);
|
|
|
|
if (await file.exists()) {
|
|
const contentType = getContentType(path);
|
|
return new Response(file, {
|
|
headers: { "Content-Type": contentType },
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
// Main server
|
|
const server = Bun.serve<WSData>({
|
|
port: PORT,
|
|
|
|
async fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
const host = req.headers.get("host");
|
|
const subdomain = getSubdomain(host);
|
|
|
|
// Handle WebSocket upgrade (with auth for non-public communities)
|
|
if (url.pathname.startsWith("/ws/")) {
|
|
const communitySlug = url.pathname.split("/")[2];
|
|
if (communitySlug) {
|
|
// Check space visibility — authenticate if needed
|
|
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 to join this space", { status: 401 });
|
|
}
|
|
} else if (vis === "public_read") {
|
|
readOnly = !claims;
|
|
}
|
|
}
|
|
|
|
const peerId = generatePeerId();
|
|
const upgraded = server.upgrade(req, {
|
|
data: { communitySlug, peerId, claims, readOnly },
|
|
});
|
|
if (upgraded) return undefined;
|
|
}
|
|
return new Response("WebSocket upgrade failed", { status: 400 });
|
|
}
|
|
|
|
// API routes
|
|
if (url.pathname.startsWith("/api/")) {
|
|
return handleAPI(req, url);
|
|
}
|
|
|
|
// Static files (serve these first, before subdomain routing)
|
|
let filePath = url.pathname;
|
|
|
|
// Try to serve static assets first (js, css, wasm, etc.)
|
|
if (filePath !== "/" && filePath !== "/canvas") {
|
|
const assetPath = filePath.slice(1); // Remove leading slash
|
|
const staticResponse = await serveStatic(assetPath);
|
|
if (staticResponse) return staticResponse;
|
|
}
|
|
|
|
// Community canvas route (subdomain detected)
|
|
if (subdomain) {
|
|
const community = await loadCommunity(subdomain);
|
|
if (!community) {
|
|
return new Response("Community not found", { status: 404 });
|
|
}
|
|
|
|
// Serve canvas.html for community
|
|
const canvasHtml = await serveStatic("canvas.html");
|
|
if (canvasHtml) return canvasHtml;
|
|
}
|
|
|
|
// Handle root paths
|
|
if (filePath === "/") filePath = "/index.html";
|
|
if (filePath === "/canvas") filePath = "/canvas.html";
|
|
|
|
// Remove leading slash
|
|
filePath = filePath.slice(1);
|
|
|
|
const staticResponse = await serveStatic(filePath);
|
|
if (staticResponse) return staticResponse;
|
|
|
|
// Fallback to index.html for SPA routing
|
|
const indexResponse = await serveStatic("index.html");
|
|
if (indexResponse) return indexResponse;
|
|
|
|
return new Response("Not Found", { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws: ServerWebSocket<WSData>) {
|
|
const { communitySlug, peerId } = ws.data;
|
|
|
|
// Add to clients map
|
|
if (!communityClients.has(communitySlug)) {
|
|
communityClients.set(communitySlug, new Map());
|
|
}
|
|
communityClients.get(communitySlug)!.set(peerId, ws);
|
|
|
|
console.log(`[WS] Client ${peerId} connected to ${communitySlug}`);
|
|
|
|
// Load community and send initial sync message
|
|
loadCommunity(communitySlug).then((doc) => {
|
|
if (doc) {
|
|
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());
|
|
|
|
if (msg.type === "sync" && Array.isArray(msg.data)) {
|
|
// Block sync writes from read-only connections
|
|
if (ws.data.readOnly) {
|
|
ws.send(JSON.stringify({
|
|
type: "error",
|
|
message: "Authentication required to edit this space",
|
|
}));
|
|
return;
|
|
}
|
|
// Handle Automerge sync message
|
|
const syncMessage = new Uint8Array(msg.data);
|
|
const result = receiveSyncMessage(communitySlug, peerId, syncMessage);
|
|
|
|
// Send response to this peer
|
|
if (result.response) {
|
|
ws.send(
|
|
JSON.stringify({
|
|
type: "sync",
|
|
data: Array.from(result.response),
|
|
})
|
|
);
|
|
}
|
|
|
|
// Broadcast to other peers
|
|
for (const [targetPeerId, targetMessage] of result.broadcastToPeers) {
|
|
const targetClient = getClient(communitySlug, targetPeerId);
|
|
if (targetClient && targetClient.readyState === WebSocket.OPEN) {
|
|
targetClient.send(
|
|
JSON.stringify({
|
|
type: "sync",
|
|
data: Array.from(targetMessage),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
} else if (msg.type === "ping") {
|
|
// Handle keep-alive ping
|
|
ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp }));
|
|
} else if (msg.type === "presence") {
|
|
// Broadcast presence to other clients
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Legacy message handling for backward compatibility
|
|
else if (msg.type === "update" && msg.id && msg.data) {
|
|
if (ws.data.readOnly) {
|
|
ws.send(JSON.stringify({ type: "error", message: "Authentication required to edit" }));
|
|
return;
|
|
}
|
|
updateShape(communitySlug, msg.id, msg.data);
|
|
// Broadcast to other clients
|
|
const clients = communityClients.get(communitySlug);
|
|
if (clients) {
|
|
const updateMsg = JSON.stringify(msg);
|
|
for (const [clientPeerId, client] of clients) {
|
|
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
|
client.send(updateMsg);
|
|
}
|
|
}
|
|
}
|
|
} else if (msg.type === "delete" && msg.id) {
|
|
if (ws.data.readOnly) {
|
|
ws.send(JSON.stringify({ type: "error", message: "Authentication required to delete" }));
|
|
return;
|
|
}
|
|
deleteShape(communitySlug, msg.id);
|
|
// Broadcast to other clients
|
|
const clients = communityClients.get(communitySlug);
|
|
if (clients) {
|
|
const deleteMsg = JSON.stringify(msg);
|
|
for (const [clientPeerId, client] of clients) {
|
|
if (clientPeerId !== peerId && client.readyState === WebSocket.OPEN) {
|
|
client.send(deleteMsg);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("[WS] Failed to parse message:", e);
|
|
}
|
|
},
|
|
|
|
close(ws: ServerWebSocket<WSData>) {
|
|
const { communitySlug, peerId } = ws.data;
|
|
|
|
// Remove from clients map
|
|
const clients = communityClients.get(communitySlug);
|
|
if (clients) {
|
|
clients.delete(peerId);
|
|
if (clients.size === 0) {
|
|
communityClients.delete(communitySlug);
|
|
}
|
|
}
|
|
|
|
// Clean up peer sync state
|
|
removePeerSyncState(communitySlug, peerId);
|
|
|
|
console.log(`[WS] Client ${peerId} disconnected from ${communitySlug}`);
|
|
},
|
|
},
|
|
});
|
|
|
|
// API handler
|
|
async function handleAPI(req: Request, url: URL): Promise<Response> {
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
|
|
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
};
|
|
|
|
if (req.method === "OPTIONS") {
|
|
return new Response(null, { headers: corsHeaders });
|
|
}
|
|
|
|
// POST /api/communities - Create new community (requires auth)
|
|
if (url.pathname === "/api/communities" && req.method === "POST") {
|
|
try {
|
|
// Require EncryptID authentication to create a community
|
|
const token = extractToken(req.headers);
|
|
if (!token) {
|
|
return Response.json(
|
|
{ error: "Authentication required to create a community" },
|
|
{ status: 401, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
let claims: EncryptIDClaims;
|
|
try {
|
|
claims = await verifyEncryptIDToken(token);
|
|
} catch {
|
|
return Response.json(
|
|
{ error: "Invalid or expired authentication token" },
|
|
{ status: 401, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
const body = (await req.json()) as {
|
|
name?: string;
|
|
slug?: string;
|
|
visibility?: SpaceVisibility;
|
|
};
|
|
const { name, slug, visibility = "public_read" } = body;
|
|
|
|
if (!name || !slug) {
|
|
return Response.json(
|
|
{ error: "Name and slug are required" },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// Validate slug format
|
|
if (!/^[a-z0-9-]+$/.test(slug)) {
|
|
return Response.json(
|
|
{ error: "Slug must contain only lowercase letters, numbers, and hyphens" },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// Validate visibility
|
|
const validVisibilities = ["public", "public_read", "authenticated", "members_only"];
|
|
if (!validVisibilities.includes(visibility)) {
|
|
return Response.json(
|
|
{ error: `Invalid visibility. Must be one of: ${validVisibilities.join(", ")}` },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// Check if exists
|
|
if (await communityExists(slug)) {
|
|
return Response.json(
|
|
{ error: "Community already exists" },
|
|
{ status: 409, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// Create community with owner and visibility
|
|
await createCommunity(name, slug, claims.sub, visibility);
|
|
|
|
// Return URL to new community
|
|
return Response.json(
|
|
{
|
|
url: `https://${slug}.rspace.online`,
|
|
slug,
|
|
name,
|
|
visibility,
|
|
ownerDID: claims.sub,
|
|
},
|
|
{ headers: corsHeaders }
|
|
);
|
|
} catch (e) {
|
|
console.error("Failed to create community:", e);
|
|
return Response.json(
|
|
{ error: "Failed to create community" },
|
|
{ status: 500, headers: corsHeaders }
|
|
);
|
|
}
|
|
}
|
|
|
|
// GET /api/communities/:slug - Get community info (respects visibility)
|
|
if (url.pathname.startsWith("/api/communities/") && req.method === "GET") {
|
|
const slug = url.pathname.split("/")[3];
|
|
|
|
// Check space access using SDK guard
|
|
const token = extractToken(req.headers);
|
|
const access = await evaluateSpaceAccess(slug, token, req.method, {
|
|
getSpaceConfig,
|
|
});
|
|
|
|
if (!access.allowed) {
|
|
return Response.json(
|
|
{ error: access.reason },
|
|
{ status: access.claims ? 403 : 401, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
const data = getDocumentData(slug);
|
|
if (!data) {
|
|
await loadCommunity(slug);
|
|
const loadedData = getDocumentData(slug);
|
|
if (!loadedData) {
|
|
return Response.json(
|
|
{ error: "Community not found" },
|
|
{ status: 404, headers: corsHeaders }
|
|
);
|
|
}
|
|
return Response.json(
|
|
{ meta: loadedData.meta, readOnly: access.readOnly },
|
|
{ headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
return Response.json(
|
|
{ meta: data.meta, readOnly: access.readOnly },
|
|
{ headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// POST /api/communities/:slug/shapes - Add shapes to a community canvas
|
|
if (
|
|
url.pathname.match(/^\/api\/communities\/[^/]+\/shapes$/) &&
|
|
req.method === "POST"
|
|
) {
|
|
const slug = url.pathname.split("/")[3];
|
|
|
|
// Allow internal service-to-service calls with shared key
|
|
const internalKey = req.headers.get("X-Internal-Key");
|
|
const isInternalCall = INTERNAL_API_KEY && internalKey === INTERNAL_API_KEY;
|
|
|
|
if (!isInternalCall) {
|
|
// Check space access (write required) for external calls
|
|
const token = extractToken(req.headers);
|
|
const access = await evaluateSpaceAccess(slug, token, req.method, {
|
|
getSpaceConfig,
|
|
});
|
|
|
|
if (!access.allowed) {
|
|
return Response.json(
|
|
{ error: access.reason },
|
|
{ status: access.claims ? 403 : 401, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
if (access.readOnly) {
|
|
return Response.json(
|
|
{ error: "Write access required to add shapes" },
|
|
{ status: 403, headers: corsHeaders }
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
// Ensure community is loaded
|
|
await loadCommunity(slug);
|
|
const data = getDocumentData(slug);
|
|
if (!data) {
|
|
return Response.json(
|
|
{ error: "Community not found" },
|
|
{ status: 404, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
const body = (await req.json()) as { shapes?: Record<string, unknown>[] };
|
|
if (!body.shapes || !Array.isArray(body.shapes) || body.shapes.length === 0) {
|
|
return Response.json(
|
|
{ error: "shapes array is required and must not be empty" },
|
|
{ status: 400, headers: corsHeaders }
|
|
);
|
|
}
|
|
|
|
// Add shapes to the Automerge document
|
|
const ids = addShapes(slug, body.shapes);
|
|
|
|
// Broadcast sync messages to all connected WebSocket clients
|
|
const clients = communityClients.get(slug);
|
|
if (clients) {
|
|
for (const [peerId, client] of clients) {
|
|
if (client.readyState === WebSocket.OPEN) {
|
|
const syncMsg = generateSyncMessageForPeer(slug, peerId);
|
|
if (syncMsg) {
|
|
client.send(
|
|
JSON.stringify({
|
|
type: "sync",
|
|
data: Array.from(syncMsg),
|
|
})
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return Response.json(
|
|
{ ok: true, ids },
|
|
{ status: 201, headers: corsHeaders }
|
|
);
|
|
} catch (e) {
|
|
console.error("Failed to add shapes:", e);
|
|
return Response.json(
|
|
{ error: "Failed to add shapes" },
|
|
{ status: 500, headers: corsHeaders }
|
|
);
|
|
}
|
|
}
|
|
|
|
return Response.json({ error: "Not found" }, { status: 404, headers: corsHeaders });
|
|
}
|
|
|
|
console.log(`rSpace server running on http://localhost:${PORT}`);
|