/** * Keeps a drill-down page current while it is open, so a page left up on a * screen shows what the dashboard knows now rather than what it knew when the * page loaded. * * A route marked `live` serves a page built by `livePage`, which carries * `LIVE_PAGE_CLIENT` and keeps everything that changes inside its `
` * element, apart from the tab's favicon. The page opens an event stream naming itself, * `/events?page=`. On every serving tick the server sends * a heartbeat down that stream and renders the page again, and it sends the * new markup whenever that differs from what it sent before. The page brings * its `
` up to date with the one in the new markup (`updateMain`). Every * page event also names the version the server is serving, and a page built * by a different version reloads instead, so the styles and script outside * `
` follow a deployment too. A page whose subject has a status shows * it in the tab's favicon, which the page takes from each new rendering * (`updateIcon`). * * The page reopens its stream the way the dashboard does (`stream-client.ts`), * and its badge says whether it can hear the server. */ import { TICK_MS } from "./config.ts"; import { DETAIL_PAGE_STYLES } from "./detail-page.ts"; import { faviconHref } from "./favicon.ts"; import { DASHBOARD_THEME_CLIENT, DASHBOARD_THEME_HEAD, dashboardThemeToggle, statusLayer, } from "./theme.ts"; import { LIVE_PAGE_UPDATE, reconcileMain, updateIcon, updateMain, } from "./live-page-client.ts"; import { followUpdates, liveUpdateStream } from "./stream-client.ts"; import type { Route, Status } from "./types.ts"; import { SERVING_VERSION } from "./version.ts"; /** * How long a page goes without hearing the server before it replaces its * stream: three serving ticks, each of which sends a heartbeat. */ const SILENCE_MS = 3 * TICK_MS; /** The markup the server sends a page, as the event stream carries it. */ export interface LivePageEvent { version: string; html: string; } type Client = ReadableStreamDefaultController; /** A page at least one browser is showing. */ interface Watched { url: URL; route: Route; clients: Set; /** Clients sent nothing since they connected. */ joined: Set; /** The markup last sent, once there is some. */ html?: string; /** How many renderings have started, and which of them `html` came from. */ started: number; applied: number; } export interface LivePages { /** The event stream for the page `url` names in its `page` parameter. */ open(url: URL): Response; /** Sends a heartbeat to every open page, then renders each again. */ tick(): Promise; } /** Serves the event streams for the live pages among `routes`. */ export function livePages( routes: readonly Route[], version: string = SERVING_VERSION, ): LivePages { const live = routes.filter((route) => route.live); const watched = new Map(); const encoder = new TextEncoder(); let beats = 0; // A stream leaves its page as it is cancelled, which is before anything // could be sent down it closed. const deliver = (clients: Iterable, event: string) => { const bytes = encoder.encode(event); for (const client of clients) client.enqueue(bytes); }; // Renderings may overlap, so one that never finishes holds up none after // it, and one that finishes after a later one is dropped. const render = async (key: string, page: Watched): Promise => { const rendering = ++page.started; try { const response = await page.route.handler( new Request(page.url), page.url, ); const html = await response.text(); if (rendering < page.applied) return; page.applied = rendering; // Every client already holding this markup is left alone, and a client // that joined while it was being rendered is sent it. const recipients = html === page.html ? page.joined : page.clients; page.html = html; const event: LivePageEvent = { version, html }; deliver( recipients, `event: page\ndata: ${JSON.stringify(event)}\n\n`, ); page.joined.clear(); } catch (error) { console.error( `live page ${key}:`, error instanceof Error ? error.message : String(error), ); } }; return { open(url: URL): Response { const target = URL.parse(url.searchParams.get("page") ?? "", url.origin); const route = target?.origin === url.origin ? live.find((route) => route.path === target.pathname) : undefined; if (!target || !route) { return new Response("no live page there", { status: 404 }); } const key = target.pathname + target.search; const page = watched.get(key) ?? { url: target, route, clients: new Set(), joined: new Set(), started: 0, applied: 0, }; watched.set(key, page); let client: Client; const stream = new ReadableStream({ start(controller) { client = controller; page.clients.add(controller); page.joined.add(controller); controller.enqueue(encoder.encode(": connected\n\n")); void render(key, page); }, cancel() { page.clients.delete(client); page.joined.delete(client); if (page.clients.size === 0) watched.delete(key); }, }); return new Response(stream, { headers: { "content-type": "text/event-stream", "cache-control": "no-cache", }, }); }, async tick(): Promise { const beat = `event: ping\ndata: ${++beats}\n\n`; for (const page of watched.values()) deliver(page.clients, beat); await Promise.all([...watched].map(([key, page]) => render(key, page))); }, }; } /** The styles of the badge a live page carries. */ const LIVE_PAGE_STYLES = ` .live-badge{font-size:11px;color:var(--status-good-text);border:1px solid ${ statusLayer("good", 0.4) };border-radius:6px;padding:2px 8px} .live-badge.offline{color:var(--status-unknown-text);border-color:var(--status-unknown)}`; /** The badge saying whether a live page can hear the server. */ const LIVE_PAGE_BADGE = `
● LIVE
`; /** Keeps the page it is placed in current. It belongs after `
`. */ export const LIVE_PAGE_CLIENT = ``; /** What one rendering of a live page shows, which `livePage` frames. */ export interface LivePageContent { /** The page's name, in its tab and, unless `heading` names it, at its top. */ title: string; /** The name at the top of the page, when it is not the page's own. */ heading?: string; /** The styles the page needs beyond those every drill-down page has. */ styles: string; /** The markup beside the name: what the page shows, and its age. */ head: string; /** The markup of the rest of the page. */ body: string; /** The page's own script, as source, run before it follows updates. */ script?: string; /** * How the page's subject stands, which the tab's favicon shows as the * dashboard's does. A page with no status, or a gray one, shows an empty * favicon. */ status?: Status; } /** * The whole of a live page: the drill-down page's frame and theme, the name * and the badge at the top, and `content`, with everything that changes from * one rendering to the next inside `
` apart from the favicon. */ export function livePage(content: LivePageContent): string { const script = content.script === undefined ? "" : `\n`; const icon = content.status === undefined || content.status === "unknown" ? "data:," : faviconHref(content.status); return `${content.title} ${DASHBOARD_THEME_HEAD}
← dashboard${content.heading ?? content.title}${LIVE_PAGE_BADGE}${content.head}
${content.body}
${dashboardThemeToggle()} ${DASHBOARD_THEME_CLIENT} ${script}${LIVE_PAGE_CLIENT} `; } /** `livePage(content)` as the response a live route answers with. */ export function livePageResponse( content: LivePageContent, status = 200, ): Response { return new Response(livePage(content), { status, headers: { "content-type": "text/html; charset=utf-8" }, }); }