Add Planner session management with Yjs WebSocket (#1)

- Yjs WebSocket server at /sync/:sessionId for real-time collaboration
- Session creation API (POST /api/sessions) with optional GPX initialization
- Session join page with Planner layout (header, map area, sidebar)
- In-memory session store (PostgreSQL persistence deferred to infra setup)
- Custom production server entry with WebSocket upgrade handling
- Vite path aliases (~/) for clean imports
- Fix tsconfig for apps: noEmit, exclude build dir

Tasks completed: 4.1, 4.3, 4.4, 4.5
Deferred: 4.2 (PostgreSQL), 4.6-4.8 (need client-side Yjs from Group 6)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ullrich Schäfer 2026-03-22 13:26:03 +01:00 committed by GitHub
parent 6f90c59db9
commit 464ba0f64d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 413 additions and 42 deletions

View file

@ -5,9 +5,13 @@
"**/*.tsx",
".react-router/types/**/*"
],
"exclude": ["build", "node_modules"],
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"],
"outDir": "build",
"types": ["vite/client"]
"noEmit": true,
"types": ["vite/client"],
"paths": {
"~/*": ["./app/*"]
}
}
}

View file

@ -0,0 +1,72 @@
import { randomUUID } from "node:crypto";
import * as Y from "yjs";
import { getOrCreateDoc, deleteDoc } from "./yjs-server";
export interface SessionMetadata {
id: string;
createdAt: Date;
lastActivity: Date;
callbackUrl?: string;
callbackToken?: string;
}
const sessions = new Map<string, SessionMetadata>();
export function createSession(options?: {
callbackUrl?: string;
callbackToken?: string;
}): SessionMetadata {
const id = randomUUID();
const now = new Date();
const session: SessionMetadata = {
id,
createdAt: now,
lastActivity: now,
callbackUrl: options?.callbackUrl,
callbackToken: options?.callbackToken,
};
sessions.set(id, session);
getOrCreateDoc(id); // Initialize Yjs doc
return session;
}
export function getSession(id: string): SessionMetadata | undefined {
return sessions.get(id);
}
export function touchSession(id: string): void {
const session = sessions.get(id);
if (session) {
session.lastActivity = new Date();
}
}
export function closeSession(id: string): boolean {
const existed = sessions.delete(id);
deleteDoc(id);
return existed;
}
export function initializeSessionWithWaypoints(
sessionId: string,
waypoints: Array<{ lat: number; lon: number; name?: string }>,
): void {
const doc = getOrCreateDoc(sessionId);
const yWaypoints = doc.getArray("waypoints");
doc.transact(() => {
for (const wp of waypoints) {
const yMap = new Y.Map();
yMap.set("lat", wp.lat);
yMap.set("lon", wp.lon);
if (wp.name) yMap.set("name", wp.name);
yWaypoints.push([yMap]);
}
});
}
export function listSessions(): SessionMetadata[] {
return Array.from(sessions.values());
}

View file

@ -0,0 +1,78 @@
import { WebSocketServer, type WebSocket } from "ws";
import * as Y from "yjs";
import type { IncomingMessage, Server } from "node:http";
const docs = new Map<string, Y.Doc>();
export function getOrCreateDoc(sessionId: string): Y.Doc {
let doc = docs.get(sessionId);
if (!doc) {
doc = new Y.Doc();
docs.set(sessionId, doc);
}
return doc;
}
export function deleteDoc(sessionId: string): boolean {
const doc = docs.get(sessionId);
if (doc) {
doc.destroy();
docs.delete(sessionId);
return true;
}
return false;
}
export function getDocCount(): number {
return docs.size;
}
export function setupYjsWebSocket(server: Server): WebSocketServer {
const wss = new WebSocketServer({ noServer: true });
server.on("upgrade", (request, socket, head) => {
const url = new URL(request.url ?? "", `http://${request.headers.host}`);
const match = url.pathname.match(/^\/sync\/(.+)$/);
if (!match) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit("connection", ws, request, match[1]);
});
});
wss.on("connection", (ws: WebSocket, _request: IncomingMessage, sessionId: string) => {
const doc = getOrCreateDoc(sessionId);
// Send current state to new client
const state = Y.encodeStateAsUpdate(doc);
ws.send(state);
// Listen for updates from this client
ws.on("message", (data: Buffer) => {
try {
const update = new Uint8Array(data);
Y.applyUpdate(doc, update);
// Broadcast to all other clients in this session
for (const client of wss.clients) {
if (client !== ws && client.readyState === ws.OPEN) {
client.send(data);
}
}
} catch (_e) {
// Ignore malformed updates
}
});
ws.on("close", () => {
// Clean up empty sessions
// (In production, this would check participant count)
});
});
return wss;
}

View file

@ -1,3 +1,7 @@
import { type RouteConfig, index } from "@react-router/dev/routes";
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [index("routes/home.tsx")] satisfies RouteConfig;
export default [
index("routes/home.tsx"),
route("api/sessions", "routes/api.sessions.ts"),
route("session/:id", "routes/session.$id.tsx"),
] satisfies RouteConfig;

View file

@ -0,0 +1,40 @@
import { data } from "react-router";
import type { Route } from "./+types/api.sessions";
import { createSession, initializeSessionWithWaypoints, listSessions } from "~/lib/sessions";
import { parseGpx } from "@trails-cool/gpx";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
return data({ error: "Method not allowed" }, { status: 405 });
}
const body = await request.json();
const { callbackUrl, callbackToken, gpx } = body as {
callbackUrl?: string;
callbackToken?: string;
gpx?: string;
};
const session = createSession({ callbackUrl, callbackToken });
if (gpx) {
try {
const gpxData = parseGpx(gpx);
initializeSessionWithWaypoints(session.id, gpxData.waypoints);
} catch (_e) {
// Continue with empty session if GPX is invalid
}
}
return data(
{
sessionId: session.id,
url: `/session/${session.id}`,
},
{ status: 201 },
);
}
export async function loader(_args: Route.LoaderArgs) {
return data({ sessions: listSessions() });
}

View file

@ -0,0 +1,45 @@
import { useParams } from "react-router";
import type { Route } from "./+types/session.$id";
import { getSession } from "~/lib/sessions";
import { data } from "react-router";
export function meta(_args: Route.MetaArgs) {
return [{ title: "trails.cool Planner — Session" }];
}
export async function loader({ params }: Route.LoaderArgs) {
const session = getSession(params.id);
if (!session) {
throw data({ error: "Session not found" }, { status: 404 });
}
return data({
sessionId: session.id,
hasCallback: !!session.callbackUrl,
});
}
export default function SessionPage() {
const { id } = useParams();
return (
<div className="flex h-full flex-col">
<header className="flex items-center justify-between border-b border-gray-200 px-4 py-2">
<h1 className="text-lg font-semibold text-gray-900">trails.cool Planner</h1>
<span className="text-sm text-gray-500">Session: {id?.slice(0, 8)}...</span>
</header>
<div className="flex flex-1">
<main className="flex-1 bg-gray-100">
<div className="flex h-full items-center justify-center text-gray-500">
Map will be rendered here (task 6.1)
</div>
</main>
<aside className="w-80 border-l border-gray-200 bg-white p-4">
<h2 className="text-sm font-medium text-gray-700">Waypoints</h2>
<p className="mt-2 text-sm text-gray-500">
Waypoint list will be rendered here (task 6.5)
</p>
</aside>
</div>
</div>
);
}

View file

@ -6,28 +6,32 @@
"scripts": {
"dev": "react-router dev",
"build": "react-router build",
"start": "react-router-serve ./build/server/index.js",
"start": "node server.ts",
"typecheck": "react-router typegen && tsc",
"lint": "eslint ."
},
"dependencies": {
"@trails-cool/ui": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/map": "workspace:*",
"@react-router/node": "catalog:",
"@react-router/serve": "catalog:",
"@trails-cool/gpx": "workspace:*",
"@trails-cool/i18n": "workspace:*",
"@trails-cool/map": "workspace:*",
"@trails-cool/types": "workspace:*",
"@trails-cool/ui": "workspace:*",
"isbot": "^5.1.0",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:",
"@react-router/node": "catalog:",
"@react-router/serve": "catalog:",
"isbot": "^5.1.0"
"ws": "^8.20.0",
"y-websocket": "^3.0.0",
"yjs": "^13.6.30"
},
"devDependencies": {
"@react-router/dev": "catalog:",
"@tailwindcss/vite": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/ws": "^8.18.1",
"tailwindcss": "catalog:",
"typescript": "catalog:",
"vite": "catalog:"

21
apps/planner/server.ts Normal file
View file

@ -0,0 +1,21 @@
import { createRequestHandler } from "@react-router/node";
import { createServer } from "node:http";
import { setupYjsWebSocket } from "./app/lib/yjs-server";
const port = Number(process.env.PORT ?? 3001);
const handler = createRequestHandler(
// @ts-expect-error - build output types
await import("./build/server/index.js"),
);
const server = createServer((req, res) => {
handler(req, res);
});
setupYjsWebSocket(server);
server.listen(port, () => {
console.log(`Planner server listening on http://localhost:${port}`);
console.log(`Yjs WebSocket available at ws://localhost:${port}/sync/:sessionId`);
});

View file

@ -5,9 +5,13 @@
"**/*.tsx",
".react-router/types/**/*"
],
"exclude": ["build", "node_modules", "server.ts"],
"compilerOptions": {
"rootDirs": [".", "./.react-router/types"],
"outDir": "build",
"types": ["vite/client"]
"noEmit": true,
"types": ["vite/client"],
"paths": {
"~/*": ["./app/*"]
}
}
}

View file

@ -1,9 +1,15 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import path from "node:path";
export default defineConfig({
plugins: [tailwindcss(), reactRouter()],
resolve: {
alias: {
"~": path.resolve(__dirname, "./app"),
},
},
server: {
port: 3001,
},