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:
parent
6f90c59db9
commit
464ba0f64d
13 changed files with 413 additions and 42 deletions
72
apps/planner/app/lib/sessions.ts
Normal file
72
apps/planner/app/lib/sessions.ts
Normal 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());
|
||||
}
|
||||
78
apps/planner/app/lib/yjs-server.ts
Normal file
78
apps/planner/app/lib/yjs-server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
40
apps/planner/app/routes/api.sessions.ts
Normal file
40
apps/planner/app/routes/api.sessions.ts
Normal 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() });
|
||||
}
|
||||
45
apps/planner/app/routes/session.$id.tsx
Normal file
45
apps/planner/app/routes/session.$id.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue