ai-code-battle/web/src/router.ts
jedarden 273736a3f2 fix(web): fix replay viewer routing and embed fallback
Router: strip query string from hash path before route matching, and merge
hash query params (e.g. ?url=) into the params passed to route handlers.
Add /watch/replay route (without :id) so ?url= links work without a path ID.

Embed: fall back to demo replay when the match replay is not found in R2/B2
instead of showing "Failed to fetch" (handles test match IDs with no replay).

App: extend skeleton and PIP checks to match /watch/replay (with or without :id).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 13:07:13 -04:00

140 lines
3.5 KiB
TypeScript

// Simple hash-based router for single-page navigation
// §16.14: integrated with back-cache for instant back/forward navigation
export type RouteHandler = (params: Record<string, string>) => void | Promise<void>;
interface Route {
pattern: RegExp;
handler: RouteHandler;
paramNames: string[];
}
class Router {
private routes: Route[] = [];
private notFoundHandler: RouteHandler | null = null;
private beforeNavigateHooks: Array<(from: string, to: string) => void> = [];
private afterNavigateHooks: Array<(path: string) => void> = [];
/**
* Register a route with a pattern like "/leaderboard" or "/bot/:id"
*/
on(pattern: string, handler: RouteHandler): this {
const paramNames: string[] = [];
const regexPattern = pattern.replace(/:(\w+)/g, (_, name) => {
paramNames.push(name);
return '([^/]+)';
});
this.routes.push({
pattern: new RegExp(`^${regexPattern}$`),
handler,
paramNames,
});
return this;
}
/**
* Register a handler for unmatched routes
*/
notFound(handler: RouteHandler): this {
this.notFoundHandler = handler;
return this;
}
/**
* Register a hook called before each navigation (after hash changes).
* Receives (fromPath, toPath).
*/
beforeNavigate(hook: (from: string, to: string) => void): this {
this.beforeNavigateHooks.push(hook);
return this;
}
/**
* Register a hook called after each route handler completes.
* Receives the resolved path.
*/
afterNavigate(hook: (path: string) => void): this {
this.afterNavigateHooks.push(hook);
return this;
}
/**
* Navigate to a path
*/
navigate(path: string): void {
window.location.hash = path;
}
/**
* Get current path from hash (strips query string if present)
*/
getCurrentPath(): string {
const hash = window.location.hash.slice(1); // Remove #
const path = hash.split('?')[0];
return path || '/';
}
/**
* Get query params from the current hash URL
*/
private getHashQueryParams(): Record<string, string> {
const hash = window.location.hash.slice(1);
const qIdx = hash.indexOf('?');
if (qIdx < 0) return {};
const params: Record<string, string> = {};
new URLSearchParams(hash.slice(qIdx + 1)).forEach((v, k) => { params[k] = v; });
return params;
}
/**
* Start listening for hash changes
*/
start(): void {
window.addEventListener('hashchange', () => this.handleRoute());
this.handleRoute();
}
/**
* Handle the current route
*/
private async handleRoute(): Promise<void> {
const path = this.getCurrentPath();
const prevPath = (this as any)._lastPath as string | undefined;
(this as any)._lastPath = path;
for (const hook of this.beforeNavigateHooks) {
hook(prevPath ?? '/', path);
}
// Merge hash query params so handlers can read e.g. ?url= or ?id=
const queryParams = this.getHashQueryParams();
for (const route of this.routes) {
const match = path.match(route.pattern);
if (match) {
const params: Record<string, string> = { ...queryParams };
route.paramNames.forEach((name, idx) => {
params[name] = decodeURIComponent(match[idx + 1]);
});
await route.handler(params);
for (const hook of this.afterNavigateHooks) {
hook(path);
}
return;
}
}
if (this.notFoundHandler) {
await this.notFoundHandler({});
}
for (const hook of this.afterNavigateHooks) {
hook(path);
}
}
}
export const router = new Router();