watchparty/client/src/router.ts
Nicolas 4a31ee741e
All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
Rewrite server + client in TypeScript, service-oriented server architecture
Full rewrite, requested directly. Two independent decisions since
TypeScript can't compile itself away at runtime - "no build step" and
"TypeScript" are mutually exclusive, not a small addition to the existing
setup:
- Server: plain tsc (no bundling need - Node resolves its own requires).
  server/dist mirrors server/src 1:1.
- Client: esbuild (does need bundling) - picked over a heavier tool
  (Vite, webpack) since this app's whole client is a couple thousand
  lines; its built-in CSS-from-JS bundling and a `text` loader for
  importing an HTML template as a string cover everything needed via a
  ~30-line hand-written build script (client/build.mjs), not a config
  file for something built for much bigger apps.
Both projects compile independently (no shared-types package - considered
and rejected as more structural commitment than this app's size
justifies; server/src/types.ts and client/src/types.ts have their own
overlapping DTO copies instead). server/'s `npm run dev` uses tsx for a
compile-free dev loop; `npm run build` (tsc) is what server/Dockerfile's
build stage actually runs. client/'s `npm run watch` is esbuild's own
watch mode.

Server: one singleton service class per feature, generalizing the
PartyManager/MusicQueueService pattern that already existed, rather than
route handlers with logic inline:
- AuthService (from embyAuth.js), CatalogService (NEW - previously ~15
  route handlers' worth of Emby queries/DTO shaping inlined directly in
  index.js), PartyService (renamed from PartyManager), MusicQueueService
  (ported as-is, already service-shaped), SocketGateway (from
  socketHandler.js), EmbyClient (from embyProxy.js - low-level plumbing,
  not called a "service" since it has no state/feature of its own).
- index.ts is now thin wiring only: every route reads the session, calls
  one service method, returns the result.
- Real error-handling pass, not just a port: Express 4 doesn't catch a
  rejected Promise from an async handler on its own - a new asyncRoute()
  wrapper + a single error-handling middleware replace what used to be
  zero handling for a request that outright throws. CatalogService
  methods throw a CatalogFetchError (own HTTP status - getRawItem now
  preserves Emby's real 404 instead of a blanket 502) rather than
  touching res directly.

Client: one folder per view (src/views/<name>/<Name>.ts + .html + .css)
instead of one .js file with the markup inline as a template literal. A
view's static shell moved to its .html (esbuild's text loader); per-item
dynamic content (tile grids, episode/track lists) stays as template
-literal-returning functions in the .ts, same as before. Genuinely shared
CSS (tiles, the presence avatar, the series/album detail-header pattern,
track-list rows) lives under src/styles/, imported by whichever views
need it; src/styles/base.css holds the global primitives, imported once
by app.ts. Deliberately not pushed to zero duplication/renaming shared
classes across views for tidiness alone - not worth the risk in a change
already this large. Player.ts keeps its pre-existing internal shape (the
/party/:id dispatcher + the video renderer in one file, importing
renderMusicPlayer from the sibling folder) - that pattern predates this
rewrite and only needed types, not restructuring.

Two real things the port itself caught, not just carried over:
- A type bug: porting SocketGateway's "members" broadcast, reusing
  PartyService's PublicMember interface would have type-checked fine but
  was the wrong (larger) shape - the socket broadcast never included
  isPaused/positionTicks, only the REST response did. Caught by
  comparing against the original JS line by line, fixed with a dedicated
  SocketMemberDto type.
- A real Express+TypeScript friction point: noUncheckedIndexedAccess
  (enabled in both tsconfigs - genuinely useful elsewhere, e.g.
  PartyService.advanceQueue's array indexing) makes every req.params.xxx
  read a type error, since Express's index-signature typing doesn't know
  a matched route's required :name segment is always present. Resolved
  once with a param(req, name) helper rather than 15 routes' worth of
  scattered assertions.

Verified: both projects type-check cleanly in strict mode (tsc --noEmit -
not just "esbuild didn't complain", which it wouldn't even with type
errors present, since it transpiles without checking). Server: every
unit/integration test from the JS version (party lifecycle logging, the
music queue's timer/vote/pause behavior incl. real setTimeout
auto-advance, the socket integration through SocketGateway+PartyService+
MusicQueueService together, the library-type filter, the playback
-override forcing) re-run against the compiled server/dist output -
identical results; the compiled server was actually started and confirmed
listening cleanly. Client: since esbuild bundles everything into one
app.js (no more individually-importable view modules), testing moved to
driving the real compiled bundle through its actual boot -> login ->
router -> view chain (mocked fetch/Socket.IO/hls.js, real clicks/hash
navigation) - arguably more realistic than the old per-module approach.
Confirmed this way: login, the sidebar, home's carousels, a video party
(presence, host controls, leave), the full music hierarchy, a music party
(presence, vote-skip, pause toggle, the inline browse-and-add panel), and
the series page - all through client/dist's actual output, no thrown
errors anywhere.

Not verified: an actual `docker build` of the new multi-stage Dockerfile -
this dev environment has no Docker at all. The build stage runs the exact
npm run build commands already confirmed working locally, just inside
node:20-alpine - the main remaining risk is something environment
-specific, not the build commands themselves, but a real build is still
the first thing to try after this lands.

docs/architecture.md has the full writeup (new "TypeScript and a
service-oriented architecture" section, plus a corrected top-level
"Components" overview that had gone stale even before this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 09:38:14 +02:00

105 lines
4 KiB
TypeScript

// 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<string, string>;
export type CleanupFn = () => void;
/**
* A handler can be `async` (most views just fetch and render, with
* nothing to clean up - the resulting `Promise<void>` 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<CleanupFn>`: 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<void>;
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 = '<main class="wrap"><p>Page introuvable.</p></main>';
}
/**
* (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();
}