benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 9965609c46dc660e7b5b52f91658bb8a67c37294
93 lines · 2.9 KB · ~861 tok Raw History
1
/**
2
 * Realtime without a framework. Actions `emit` what changed; `GET /events`
3
 * streams it to every open page as server-sent events, numbered, with the
4
 * last hundred kept so a client that reconnects with `Last-Event-ID` (or
5
 * `?since=`) hears what it missed. `static/live.js` turns the stream into
6
 * DOM events. The bus is this process's: the terminal is another process,
7
 * and its writes show on the next load rather than live.
8
 *
9
 * A vault is private, so an event can be addressed: `emit(type, data, to)`
10
 * reaches only the streams opened by the person whose id is `to`. An event
11
 * with no `to` reaches everyone.
12
 */
13
14
import type { Who } from "./who.ts";
15
16
export interface LiveEvent {
17
  readonly id: number;
18
  readonly type: string;
19
  readonly data: unknown;
20
  /** Whose streams hear it; everyone's when absent. */
21
  readonly to?: string;
22
}
23
24
const KEEP = 100;
25
const HEARTBEAT_MS = 25_000;
26
const kept: LiveEvent[] = [];
27
const listeners = new Set<(event: LiveEvent) => void>();
28
let last = 0;
29
30
export function emit(type: string, data: unknown, to?: string): LiveEvent {
31
  const event: LiveEvent = { id: ++last, type, data, ...(to === undefined ? {} : { to }) };
32
  kept.push(event);
33
  if (kept.length > KEEP) kept.shift();
34
  for (const listen of listeners) listen(event);
35
  return event;
36
}
37
38
export function subscribe(listen: (event: LiveEvent) => void): () => void {
39
  listeners.add(listen);
40
  return () => listeners.delete(listen);
41
}
42
43
/** `GET /events`: a stream that replays what `since` missed, then follows. */
44
export function events(request: Request, who: Who): Response {
45
  const hears = (event: LiveEvent): boolean =>
46
    event.to === undefined || (who.role !== "anon" && event.to === who.id);
47
  const since = Number(
48
    request.headers.get("last-event-id") ??
49
      new URL(request.url).searchParams.get("since") ??
50
      0,
51
  );
52
  const encoder = new TextEncoder();
53
  let stop = (): void => {};
54
  const stream = new ReadableStream<Uint8Array>({
55
    start(controller) {
56
      const write = (text: string): void => {
57
        try {
58
          controller.enqueue(encoder.encode(text));
59
        } catch {
60
          stop();
61
        }
62
      };
63
      const send = (event: LiveEvent): void => {
64
        if (!hears(event)) return;
65
        write(
66
          `id: ${event.id}\ndata: ${JSON.stringify({ type: event.type, data: event.data })}\n\n`,
67
        );
68
      };
69
      const heartbeat = setInterval(() => write(":\n\n"), HEARTBEAT_MS);
70
      const unsubscribe = subscribe(send);
71
      stop = () => {
72
        clearInterval(heartbeat);
73
        unsubscribe();
74
        try {
75
          controller.close();
76
        } catch {}
77
      };
78
      request.signal.addEventListener("abort", stop);
79
      write("retry: 2000\n\n");
80
      for (const event of kept) if (event.id > since) send(event);
81
    },
82
    cancel() {
83
      stop();
84
    },
85
  });
86
  return new Response(stream, {
87
    headers: {
88
      "content-type": "text/event-stream",
89
      "cache-control": "no-store",
90
      "x-accel-buffering": "no",
91
    },
92
  });
93
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts