// Tiny hash router - no dependency, matches the project's "no build step // for the client's actual routing logic" bias even now that there IS a // build step for TypeScript itself. Routes are registered with // route(pattern, handler); handler gets (root, params) where params come // from ":name" segments in the pattern. // // A handler may return a cleanup function. If it does, the router calls it // right before rendering whatever comes next - this is what stops a view // like the player (setInterval heartbeat, a realtime connection, an hls.js // instance) from leaking when the user navigates away some way other than // its own "Quitter" button (a sidebar link, browser back, ...). Before this // existed, `currentRoot.innerHTML = ''` only ever destroyed DOM nodes - // nothing told the outgoing view's JS to stop running. export type RouteParams = Record; export type CleanupFn = () => void; /** * A handler can be `async` (most views just fetch and render, with * nothing to clean up - the resulting `Promise` is simply not a * function, so render() below leaves `currentCleanup` at null) or * synchronous-returning-a-CleanupFn (a view that opens a socket/interval/ * hls.js instance and needs to release it - see views/player/Player.ts). * What's deliberately NOT allowed here is `Promise`: a route * that needs cleanup but fetches first has to return a real function * *synchronously* regardless (see Player.ts's own dispatcher for exactly * that pattern, and why - the type system won't stop a handler from doing * `async () => { await x; return cleanup; }` instead, but it also won't * paper over the mistake by pretending that shape is supported here. */ export type RouteHandler = (root: HTMLElement, params: RouteParams) => CleanupFn | void | Promise; interface CompiledRoute { regex: RegExp; keys: string[]; handler: RouteHandler; } const routes: CompiledRoute[] = []; let currentRoot: HTMLElement | null = null; let listenerAttached = false; let currentCleanup: CleanupFn | null = null; export function route(pattern: string, handler: RouteHandler): void { const keys: string[] = []; const regexSource = pattern .split('/') .map((segment) => { if (segment.startsWith(':')) { keys.push(segment.slice(1)); return '([^/]+)'; } return segment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }) .join('/'); routes.push({ regex: new RegExp('^' + regexSource + '$'), keys, handler }); } export function navigate(path: string): void { location.hash = '#' + path; } function render(): void { if (!currentRoot) return; if (typeof currentCleanup === 'function') { try { currentCleanup(); } catch (err) { console.error('[watchparty] view cleanup failed', err); } } currentCleanup = null; const [path, query] = (location.hash.slice(1) || '/').split('?'); const params: RouteParams = Object.fromEntries(new URLSearchParams(query || '')); for (const r of routes) { const match = (path || '').match(r.regex); if (match) { r.keys.forEach((key, i) => { params[key] = decodeURIComponent(match[i + 1] as string); }); currentRoot.innerHTML = ''; const result = r.handler(currentRoot, params); // A Promise (an async handler with nothing to clean up) is // correctly *not* a function - see RouteHandler's own comment for // why that's the deliberately-supported "no cleanup" shape. currentCleanup = typeof result === 'function' ? result : null; return; } } currentRoot.innerHTML = '

Page introuvable.

'; } /** * (Re)targets the router at `root` and renders the current route into it. * Safe to call more than once (e.g. after a logout/login cycle re-renders * the app shell) - the hashchange listener is only ever attached once, so * repeated calls don't pile up duplicate listeners. */ export function start(root: HTMLElement): void { currentRoot = root; if (!listenerAttached) { window.addEventListener('hashchange', render); listenerAttached = true; } render(); }