All checks were successful
Publier l'image Docker / publish (push) Successful in 18s
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>
105 lines
4 KiB
TypeScript
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();
|
|
}
|