rspace-online/modules/maps/mod.ts

166 lines
5.5 KiB
TypeScript

/**
* 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";
// ── 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: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
body: `<folk-map-viewer space="${space}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
}));
});
// 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: `<link rel="stylesheet" href="/modules/maps/maps.css">`,
body: `<folk-map-viewer space="${space}" room="${room}"></folk-map-viewer>`,
scripts: `<script type="module" src="/modules/maps/folk-map-viewer.js"></script>`,
}));
});
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",
};