char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somport fetch extension to typescriptf2c35fd

main
11.4 KiB421 linesraw
1import {
2  ArrayPrototypePush as push,
3  getter,
4  MathMin as min,
5  NumberParseInt,
6  ObjectCreate,
7  ObjectDefineProperty as define,
8  ReflectApply as apply,
9  ReflectConstruct as construct,
10  RegExpPrototypeExec as exec,
11  String,
12  StringPrototypeSlice as slice,
13  TypedArrayPrototypeGetLength as length,
14  uncurryThis,
15  WeakMap,
16  WeakMapPrototypeSet as set,
17} from "ext:core/primordials";
18import {
19  markPromiseHandled,
20  promiseThen as then,
21  safePromise,
22} from "ext:core/promises";
23import { enqueueTask } from "ext:core/tasks";
24import { dictionary, required, state, usv } from "ext:web/idl.js";
25import { userAgent } from "ext:web/navigator.js";
26import {
27  addListener,
28  createException,
29  dispatch,
30  Event,
31  EventTarget,
32  preventDefault,
33  removeListener,
34} from "ext:web/events.js";
35import { registerUncloneable } from "ext:web/serialization.js";
36import { MessageEvent } from "ext:web/messaging.js";
37import { TextDecoder, TextEncoder } from "ext:encoding/main.js";
38import { URL } from "ext:url/main.js";
39import { clearTimeout, setTimeout } from "ext:timers/timers.js";
40import {
41  op_fetch_base,
42  op_fetch_cancel,
43  op_fetch_create,
44  op_fetch_read,
45  op_fetch_send,
46} from "ext:core/ops";
47import { extractMime } from "ext:http_types/mime.js";
48
49const CONNECTING = 0,
50  OPEN = 1,
51  CLOSED = 2;
52type ReadyState = typeof CONNECTING | typeof OPEN | typeof CLOSED;
53type EventType = "open" | "message" | "error";
54type EventHandler<E extends Event> = (this: EventSource, event: E) => unknown;
55interface Handler {
56  value: Function | null;
57  listener: ((event: Event) => void) | null;
58}
59interface SourceState {
60  self: EventSource;
61  url: string;
62  origin: string;
63  withCredentials: boolean;
64  state: ReadyState;
65  lastEventId: string;
66  delay: number;
67  resource: number | undefined;
68  timer: number | undefined;
69  handlers: Record<EventType, Handler>;
70  pending: string;
71  skipLF: boolean;
72  data: string;
73  type: string;
74  id: string;
75}
76
77const sources = new WeakMap<object, SourceState>();
78registerUncloneable(sources);
79const streaming = { __proto__: null, stream: true };
80const digits = /^[0-9]+$/;
81const decode = uncurryThis(TextDecoder.prototype.decode),
82  encode = uncurryThis(TextEncoder.prototype.encode),
83  urlHref = getter(URL.prototype, "href"),
84  urlOrigin = getter(URL.prototype, "origin"),
85  urlProtocol = getter(URL.prototype, "protocol");
86const encoder = new TextEncoder();
87const fromCode = String.fromCharCode,
88  codeAt = uncurryThis(String.prototype.charCodeAt),
89  indexOf = uncurryThis(String.prototype.indexOf);
90
91function syntaxError(message: string): Error {
92  return createException({ message, name: "SyntaxError" });
93}
94
95// Reconnect requests carry the raw UTF-8 bytes of the last event ID, which
96// op_fetch_send re-encodes as one byte per character.
97function byteHeaderValue(value: string): string | null {
98  const bytes = encode(encoder, value);
99  let result = "";
100  for (let i = 0; i < length(bytes); i++) {
101    const byte = bytes[i];
102    // Valid SSE IDs can contain controls that hyper cannot send in headers.
103    if ((byte < 32 && byte !== 9) || byte === 127) return null;
104    result += fromCode(byte);
105  }
106  return result;
107}
108
109function contentType(list: readonly [name: string, value: string][]): string {
110  let value: string | null = null;
111  for (let i = 0; i < list.length; i++) {
112    if (list[i][0] === "content-type") {
113      value = value === null ? list[i][1] : value + "," + list[i][1];
114    }
115  }
116  return extractMime(value)?.essence ?? "";
117}
118
119const finish = () => true;
120
121function failure(s: SourceState): void {
122  enqueueTask(() => {
123    if (s.state === CLOSED) return;
124    s.state = CONNECTING;
125    dispatch(s.self, new Event("error"));
126    // The handler can close the source synchronously.
127    if ((s.state as ReadyState) === CLOSED) return;
128    s.timer = setTimeout(() => {
129      s.timer = undefined;
130      if (s.state === CONNECTING) connect(s);
131    }, s.delay);
132  }, finish);
133}
134
135function queueEvent(
136  s: SourceState,
137  type: string,
138  data: string,
139  id: string,
140): void {
141  const origin = s.origin;
142  enqueueTask(() => {
143    if (s.state === CLOSED) return;
144    dispatch(
145      s.self,
146      new MessageEvent(type, {
147        __proto__: null,
148        data,
149        origin,
150        lastEventId: id,
151      }),
152    );
153  }, finish);
154}
155
156function feed(s: SourceState, text: string): void {
157  if (text === "") return;
158  let start = 0;
159  for (let i = 0; i < text.length; i++) {
160    const code = codeAt(text, i);
161    if (s.skipLF) {
162      s.skipLF = false;
163      // The lone LF after a chunk-split CR belongs to the same line break.
164      if (code === 10) {
165        start = i + 1;
166        continue;
167      }
168    }
169    if (code === 13 || code === 10) {
170      line(s, s.pending + slice(text, start, i));
171      s.pending = "";
172      start = i + 1;
173      s.skipLF = code === 13;
174    }
175  }
176  s.pending += slice(text, start);
177}
178
179function line(s: SourceState, value: string): void {
180  if (value === "") {
181    s.lastEventId = s.id;
182    if (s.data === "") {
183      s.type = "";
184      return;
185    }
186    const data = slice(s.data, 0, s.data.length - 1),
187      type = s.type === "" ? "message" : s.type,
188      id = s.lastEventId;
189    s.data = "";
190    s.type = "";
191    queueEvent(s, type, data, id);
192    return;
193  }
194  const colon = indexOf(value, ":");
195  let field, body;
196  if (colon === -1) {
197    field = value;
198    body = "";
199  } else {
200    field = slice(value, 0, colon);
201    body = slice(value, colon + 1);
202    if (codeAt(body, 0) === 32) body = slice(body, 1);
203  }
204  if (field === "data") s.data = s.data + body + "\n";
205  else if (field === "event") s.type = body;
206  else if (field === "id") {
207    if (indexOf(body, "\u0000") === -1) s.id = body;
208  } else if (field === "retry" && exec(digits, body)) {
209    s.delay = min(NumberParseInt(body, 10), 2147483647);
210  }
211}
212
213async function consume(
214  s: SourceState,
215  resource: number,
216  decoder: TextDecoder,
217): Promise<void> {
218  try {
219    while (s.state !== CLOSED) {
220      const bytes = await safePromise(op_fetch_read(resource));
221      // Closing can race the pending read.
222      if (bytes === null || (s.state as ReadyState) === CLOSED) break;
223      feed(s, decode(decoder, bytes, streaming));
224    }
225  } catch {
226    // Interrupted streams follow the same reconnection path as EOF.
227  } finally {
228    decode(decoder);
229    op_fetch_cancel(resource);
230    s.resource = undefined;
231  }
232  if (s.state !== CLOSED) failure(s);
233}
234
235function connect(s: SourceState): void {
236  const resource = op_fetch_create(null);
237  s.resource = resource;
238  // Preserve the resume cursor until the server replaces or clears it.
239  s.pending = "";
240  s.skipLF = false;
241  s.data = "";
242  s.type = "";
243  s.id = s.lastEventId;
244  const entries: [name: string, value: string][] = [
245    ["accept", "text/event-stream"],
246    ["cache-control", "no-cache"],
247    ["pragma", "no-cache"],
248    ["user-agent", userAgent],
249  ];
250  if (s.lastEventId !== "") {
251    const value = byteHeaderValue(s.lastEventId);
252    if (value !== null) push(entries, ["last-event-id", value]);
253  }
254  const pending = op_fetch_send([
255    resource,
256    s.url,
257    "GET",
258    entries,
259    null,
260    false,
261    "follow",
262  ]);
263  then(
264    pending,
265    (reply) => {
266      if (s.state === CLOSED) return;
267      const status = reply[0];
268      if (status !== 200 || contentType(reply[2]) !== "text/event-stream") {
269        op_fetch_cancel(resource);
270        s.resource = undefined;
271        enqueueTask(() => {
272          if (s.state !== CONNECTING) return;
273          s.state = CLOSED;
274          dispatch(s.self, new Event("error"));
275        }, finish);
276        return;
277      }
278      s.origin = urlOrigin(new URL(reply[3]));
279      enqueueTask(() => {
280        if (s.state !== CONNECTING) {
281          op_fetch_cancel(resource);
282          return;
283        }
284        s.state = OPEN;
285        dispatch(s.self, new Event("open"));
286      }, finish);
287      markPromiseHandled(consume(s, resource, new TextDecoder()));
288    },
289    () => {
290      op_fetch_cancel(resource);
291      s.resource = undefined;
292      if (s.state === CLOSED) return;
293      failure(s);
294    },
295  );
296}
297
298const eventTypes = ["open", "message", "error"] as const;
299
300function handler(type: EventType) {
301  return {
302    get(this: EventSource): Function | null {
303      return state(sources, this).handlers[type].value;
304    },
305    set(this: EventSource, rawValue: unknown): void {
306      const handler = state(sources, this).handlers[type];
307      const value = typeof rawValue === "function" ? rawValue : null;
308      handler.value = value;
309      if (value && !handler.listener) {
310        handler.listener = (event) => {
311          if (apply(handler.value!, this, [event]) === false) {
312            preventDefault(event);
313          }
314        };
315        addListener(this, type, handler.listener);
316      } else if (!value && handler.listener) {
317        removeListener(this, type, handler.listener);
318        handler.listener = null;
319      }
320    },
321    configurable: true,
322  };
323}
324
325interface EventSource {
326  readonly CONNECTING: typeof CONNECTING;
327  readonly OPEN: typeof OPEN;
328  readonly CLOSED: typeof CLOSED;
329  onopen: EventHandler<Event> | null;
330  onmessage: EventHandler<MessageEvent> | null;
331  onerror: EventHandler<Event> | null;
332}
333
334class EventSource extends EventTarget {
335  declare static readonly CONNECTING: typeof CONNECTING;
336  declare static readonly OPEN: typeof OPEN;
337  declare static readonly CLOSED: typeof CLOSED;
338
339  // @ts-expect-error Reflect.construct returns the instance without a guest-replaceable super() call.
340  constructor(rawUrl: unknown, init: unknown = {}) {
341    required(arguments.length, 1);
342    const url = usv(rawUrl);
343    const options = dictionary(init);
344    const withCredentials = !!options.withCredentials;
345    let record;
346    try {
347      record = new URL(url, op_fetch_base() || undefined);
348    } catch {
349      throw syntaxError("The URL is invalid");
350    }
351    const self = construct(EventTarget, [], new.target) as EventSource;
352    const handlers = ObjectCreate(null) as Record<EventType, Handler>;
353    for (let i = 0; i < eventTypes.length; i++) {
354      handlers[eventTypes[i]] = { value: null, listener: null };
355    }
356    const s: SourceState = {
357      self,
358      url: urlHref(record),
359      origin: urlOrigin(record),
360      withCredentials,
361      state: CONNECTING,
362      lastEventId: "",
363      delay: 3000,
364      resource: undefined,
365      timer: undefined,
366      handlers,
367      pending: "",
368      skipLF: false,
369      data: "",
370      type: "",
371      id: "",
372    };
373    set(sources, self, s);
374    const protocol = urlProtocol(record);
375    if (protocol === "http:" || protocol === "https:") connect(s);
376    else {
377      enqueueTask(() => {
378        if (s.state === CLOSED) return;
379        s.state = CLOSED;
380        dispatch(self, new Event("error"));
381      }, finish);
382    }
383    return self;
384  }
385  get url() {
386    return state(sources, this).url;
387  }
388  get withCredentials() {
389    return state(sources, this).withCredentials;
390  }
391  get readyState() {
392    return state(sources, this).state;
393  }
394  close(): void {
395    const s = state(sources, this);
396    if (s.state === CLOSED) return;
397    s.state = CLOSED;
398    if (s.resource !== undefined) op_fetch_cancel(s.resource);
399    s.resource = undefined;
400    if (s.timer !== undefined) {
401      clearTimeout(s.timer);
402      s.timer = undefined;
403    }
404  }
405}
406
407for (
408  const [name, value] of [
409    ["CONNECTING", 0],
410    ["OPEN", 1],
411    ["CLOSED", 2],
412  ] as const
413) {
414  define(EventSource, name, { value, enumerable: true });
415  define(EventSource.prototype, name, { value, enumerable: true });
416}
417for (const type of eventTypes) {
418  define(EventSource.prototype, `on${type}`, handler(type));
419}
420
421export { EventSource };