/** * Maps module — real-time collaborative location sharing. * * Port of rmaps-online. Rooms are ephemeral (no DB). Proxies to * the sync server for WebSocket-based location sharing. Embeds * MapLibre GL for outdoor maps and c3nav for indoor maps. */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; const routes = new Hono(); const SYNC_SERVER = process.env.MAPS_SYNC_URL || "http://localhost:3001"; // ── Sync URL for client-side WebSocket connection ── routes.get("/api/sync-url", (c) => { const wsUrl = process.env.MAPS_SYNC_URL || "wss://sync.rmaps.online"; return c.json({ syncUrl: wsUrl }); }); // ── Proxy: sync server health ── routes.get("/api/health", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/health`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); return c.json({ status: "degraded", sync: false }); } catch { return c.json({ status: "degraded", sync: false }); } }); // ── Proxy: room stats ── routes.get("/api/stats", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/stats`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ rooms: {} }); }); // ── Proxy: push notification VAPID key ── routes.get("/api/push/vapid-public-key", async (c) => { try { const res = await fetch(`${SYNC_SERVER}/push/vapid-public-key`, { signal: AbortSignal.timeout(3000) }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ error: "Push not available" }, 503); }); // ── Proxy: push subscribe/unsubscribe ── routes.post("/api/push/subscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); routes.post("/api/push/unsubscribe", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/unsubscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: request location (ping) ── routes.post("/api/push/request-location", async (c) => { const body = await c.req.json(); const res = await fetch(`${SYNC_SERVER}/push/request-location`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return c.json(await res.json(), res.status as any); }); // ── Proxy: routing (OSRM + c3nav) ── routes.post("/api/routing", async (c) => { const body = await c.req.json(); const { from, to, mode = "walking" } = body; if (!from?.lat || !from?.lng || !to?.lat || !to?.lng) { return c.json({ error: "from and to with lat/lng required" }, 400); } // Use OSRM for outdoor routing const profile = mode === "driving" ? "car" : "foot"; try { const res = await fetch( `https://router.project-osrm.org/route/v1/${profile}/${from.lng},${from.lat};${to.lng},${to.lat}?overview=full&geometries=geojson&steps=true`, { signal: AbortSignal.timeout(10000) } ); if (res.ok) { const data = await res.json(); return c.json(data); } } catch {} return c.json({ error: "Routing failed" }, 502); }); // ── Proxy: c3nav API ── const VALID_C3NAV_EVENTS = ["39c3", "38c3", "37c3", "eh22", "eh2025", "camp2023"]; const ALLOWED_C3NAV_ENDPOINTS = ["map/settings", "map/bounds", "map/locations", "map/locations/full", "map/projection"]; routes.get("/api/c3nav/:event", async (c) => { const event = c.req.param("event"); const endpoint = c.req.query("endpoint") || "map/bounds"; if (!VALID_C3NAV_EVENTS.includes(event)) return c.json({ error: "Invalid event" }, 400); const isAllowed = ALLOWED_C3NAV_ENDPOINTS.some((a) => endpoint === a || endpoint.startsWith(a + "/")); if (!isAllowed && !endpoint.startsWith("map/locations/")) return c.json({ error: "Endpoint not allowed" }, 403); try { const res = await fetch(`https://${event}.c3nav.de/api/v2/${endpoint}/`, { headers: { "X-API-Key": "anonymous", Accept: "application/json", "User-Agent": "rMaps/1.0" }, signal: AbortSignal.timeout(5000), }); if (res.ok) return c.json(await res.json()); return c.json({ error: "c3nav API error" }, res.status as any); } catch { return c.json({ error: "c3nav unreachable" }, 502); } }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Maps | rSpace`, moduleId: "maps", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); // Room-specific page routes.get("/:room", (c) => { const space = c.req.param("space") || "demo"; const room = c.req.param("room"); return c.html(renderShell({ title: `${room} — Maps | rSpace`, moduleId: "maps", spaceSlug: space, modules: getModuleInfoList(), theme: "light", styles: ``, body: ``, scripts: ``, })); }); export const mapsModule: RSpaceModule = { id: "maps", name: "rMaps", icon: "\u{1F5FA}", description: "Real-time collaborative location sharing and indoor/outdoor maps", routes, standaloneDomain: "rmaps.online", };