~james/core

core/sitemap/src/jetstream.ts -rw-r--r-- 5.8 KiB
452f17b7 — james chore(import): snapshot james.dad.town/core at f70c71c7696a 16 hours ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
import { jetstreamEventSchema, type JetstreamEvent } from '@atcute/jetstream';
import * as v from 'valibot';

// jetstream only roughly orders events by time_us, so every connection starts
// this far behind the newest event we've seen and `seen` drops the repeats
export const REWIND_US = 10_000_000;

export type Subscribe = (
  url: string,
  cursor: number,
  collections: string[],
  onClose: () => void,
) => AsyncIterable<JetstreamEvent>;

async function connect(url: URL): Promise<WebSocket> {
  const res = await fetch(url, { headers: { Upgrade: 'websocket' } });
  if (!res.webSocket) throw new Error(`jetstream upgrade failed: http ${res.status}`);
  res.webSocket.accept();
  return res.webSocket;
}

function parseEvent(data: string | ArrayBuffer): JetstreamEvent | undefined {
  try {
    const raw = JSON.parse(typeof data === 'string' ? data : new TextDecoder().decode(data));
    const result = v.safeParse(jetstreamEventSchema, raw);
    return result.success ? result.output : undefined;
  } catch {
    return undefined;
  }
}

// atcute's JetstreamSubscription rides on partysocket, which throws on every
// message in workerd because it clones events with their own constructor. so
// this is a plain socket using atcute's schema, and readJetstream reconnects
export const subscribeJetstream: Subscribe = (url, cursor, collections, onClose) => ({
  [Symbol.asyncIterator]() {
    const endpoint = new URL('/subscribe', url);
    endpoint.searchParams.set('cursor', String(cursor));
    for (const collection of collections) endpoint.searchParams.append('wantedCollections', collection);

    const queue: JetstreamEvent[] = [];
    let ended = false;
    let wake: (() => void) | undefined;
    let socket: WebSocket | undefined;

    const end = () => {
      ended = true;
      wake?.();
    };
    // onClose means the socket is really gone, so it fires after return() too
    const gone = () => {
      end();
      onClose();
    };

    const opened = connect(endpoint).then(
      (ws) => {
        socket = ws;
        ws.addEventListener('message', (msg) => {
          if (ended) return;
          const evt = parseEvent(msg.data);
          if (!evt) return;
          queue.push(evt);
          wake?.();
        });
        ws.addEventListener('close', gone);
        ws.addEventListener('error', gone);
      },
      (err) => {
        console.warn('[sitemap] jetstream connect failed:', err);
        gone();
      },
    );

    return {
      async next(): Promise<IteratorResult<JetstreamEvent>> {
        await opened;
        while (queue.length === 0 && !ended) {
          await new Promise<void>((resolve) => (wake = resolve));
        }
        const evt = queue.shift();
        return evt ? { value: evt, done: false } : { value: undefined, done: true };
      },
      async return(): Promise<IteratorResult<JetstreamEvent>> {
        end();
        socket?.close(1000, 'done');
        return { value: undefined, done: true };
      },
    };
  },
});

export interface ReadOptions {
  url: string;
  cursor: number;
  collections: string[];
  maxEvents: number;
  // quiet for this long means the replay reached the live tip
  idleMs: number;
  // anything newer happened after the run started, so the backlog is done
  untilUs: number;
  // how long to wait for a closed socket to actually go
  closeMs: number;
  maxReconnects: number;
  seen: Set<string>;
}

export interface ReadResult {
  events: JetstreamEvent[];
  cursor: number;
  full: boolean;
  error?: string;
}

type Outcome = 'dropped' | 'idle' | 'caught-up' | 'full';

function eventKey(evt: JetstreamEvent): string {
  const commit = evt.kind === 'commit' ? `${evt.commit.collection}/${evt.commit.rkey}@${evt.commit.rev}` : '';
  return `${evt.time_us}:${evt.did}:${evt.kind}:${commit}`;
}

export async function readJetstream(opts: ReadOptions, subscribe = subscribeJetstream): Promise<ReadResult> {
  const events: JetstreamEvent[] = [];
  let cursor = opts.cursor;
  let drops = 0;

  while (true) {
    let dropped!: () => void;
    const drop = new Promise<'dropped'>((resolve) => (dropped = () => resolve('dropped')));
    const from = Math.max(0, cursor - REWIND_US);
    const it = subscribe(opts.url, from, opts.collections, () => dropped())[Symbol.asyncIterator]();

    let outcome: Outcome;
    try {
      outcome = await drain(it, drop);
    } finally {
      // not awaited, a fake stream parked forever would never let return() finish
      it.return?.()?.catch(() => {});
    }

    if (outcome !== 'dropped') {
      // a worker gets six open connections, and a socket we closed holds one
      // until the server's close frame lands behind all the backlog it already
      // sent. so wait, or the d1 queries after a few batches queue up forever
      await Promise.race([drop, new Promise((resolve) => setTimeout(resolve, opts.closeMs))]);
      return { events, cursor, full: outcome === 'full' };
    }
    if (++drops > opts.maxReconnects) {
      return { events, cursor, full: false, error: `jetstream disconnected ${drops} times` };
    }
  }

  async function drain(it: AsyncIterator<JetstreamEvent>, drop: Promise<'dropped'>): Promise<Outcome> {
    while (true) {
      let timer: ReturnType<typeof setTimeout> | null = null;
      const idle = new Promise<'idle'>((resolve) => (timer = setTimeout(() => resolve('idle'), opts.idleMs)));
      const next = await Promise.race([it.next(), drop, idle]).finally(() => clearTimeout(timer));

      if (next === 'dropped' || next === 'idle') return next;
      if (next.done) return 'dropped';

      const evt = next.value;
      const key = eventKey(evt);
      if (opts.seen.has(key)) continue;
      opts.seen.add(key);
      events.push(evt);
      cursor = Math.max(cursor, evt.time_us);

      if (evt.time_us >= opts.untilUs) return 'caught-up';
      if (events.length >= opts.maxEvents) return 'full';
    }
  }
}