Skip to content
92 linesCodeBlameRaw
1/**
2 * Opening the site's live sockets from the browser. A page signed in by a
3 * session opens them at once, its cookie going along as always. A page
4 * opened with an access token has no cookie, and a socket cannot carry the
5 * token's header, so it first asks for a socket ticket and adds it to the
6 * address (lib/socket-ticket.ts). Browser-only.
7 */
8
9/** As lib/socket-ticket.ts's, kept apart so the page does not load its sealing code. */
10const TICKET_PARAM = "ticket";
11const TICKET_ROUTE = "/-/live/ticket";
12
13let viaToken = false;
14
15/** Set by the root as it renders: whether this page was opened with an access token. */
16export function setLiveViaToken(on: boolean): void {
17 viaToken = on;
18}
19
20/** A ticket a page's loader minted with the page, as routes/notify/ticket.ts would have. */
21export type OfferedTicket = { ticket: string; expires_at: string };
22
23const offered = new Map<string, OfferedTicket>();
24
25/**
26 * A ticket minted on the server with the page (the root loader's for the
27 * feed, an artifact's for its room), so the first socket opens without
28 * first asking `/-/live/ticket`: one round trip less before anything is
29 * live. Each is used once, and never past its minute.
30 */
31export function offerTicket(path: string, ticket: OfferedTicket | null | undefined): void {
32 if (ticket) offered.set(path, ticket);
33}
34
35/** The offered ticket for `path`, if one is left and still good. */
36export function takeOfferedTicket(path: string, now = Date.now()): string | null {
37 const found = offered.get(path);
38 if (!found) return null;
39 offered.delete(path);
40 return Date.parse(found.expires_at) - now > 5_000 ? found.ticket : null;
41}
42
43/** `wss://<this site><path>?<params>`, with a ticket when one was given. */
44export function liveAddress(path: string, params: Record<string, string | null | undefined>, ticket: string | null): string {
45 const url = new URL(path, location.href);
46 url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
47 for (const [name, value] of Object.entries(params)) if (value != null && value !== "") url.searchParams.set(name, value);
48 if (ticket) url.searchParams.set(TICKET_PARAM, ticket);
49 return url.toString();
50}
51
52async function ticketFor(path: string): Promise<string | null> {
53 try {
54 const answer = await fetch(`${TICKET_ROUTE}?path=${encodeURIComponent(path)}`, {
55 headers: { accept: "application/json" },
56 cache: "no-store",
57 credentials: "same-origin",
58 });
59 if (!answer.ok) return null;
60 const body = (await answer.json()) as { ticket?: unknown };
61 return typeof body.ticket === "string" ? body.ticket : null;
62 } catch {
63 return null;
64 }
65}
66
67/**
68 * Calls `open` with the address of the socket at `path`: at once for a
69 * session, after fetching a fresh ticket for a token's page (each attempt
70 * gets its own, as one lasts a minute). `params` is read when the address
71 * is made, so it sees any change meanwhile. Nothing is opened once
72 * `cancelled` says so.
73 */
74export function openLive(
75 path: string,
76 params: () => Record<string, string | null | undefined>,
77 open: (address: string) => void,
78 cancelled: () => boolean,
79): void {
80 if (!viaToken) {
81 open(liveAddress(path, params(), null));
82 return;
83 }
84 const minted = takeOfferedTicket(path);
85 if (minted) {
86 open(liveAddress(path, params(), minted));
87 return;
88 }
89 void ticketFor(path).then((ticket) => {
90 if (!cancelled()) open(liveAddress(path, params(), ticket));
91 });
92}