char/avril

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

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

Charlotte Somport websocket extension to typescriptec3de1c

main
14.0 KiB481 linesraw
1import {
2  ArrayBuffer,
3  ArrayBufferPrototypeGetResizable as resizable,
4  ArrayPrototypePush as push,
5  getter,
6  ObjectCreate,
7  ObjectDefineProperty as define,
8  ReflectApply as apply,
9  ReflectConstruct as construct,
10  RegExp,
11  RegExpPrototypeExec as exec,
12  Set,
13  SetPrototypeAdd as add,
14  SetPrototypeHas as has,
15  StringPrototypeSlice as slice,
16  SymbolIterator,
17  TypedArrayPrototypeGetBuffer as buffer,
18  TypedArrayPrototypeGetLength as length,
19  TypedArrayPrototypeGetTag as tag,
20  TypeError,
21  uncurryThis,
22  WeakMap,
23  WeakMapPrototypeSet as set,
24} from "ext:core/primordials";
25import {
26  markPromiseHandled,
27  promiseThen as then,
28  safePromise,
29} from "ext:core/promises";
30import {
31  dictionary,
32  isObject,
33  required,
34  state,
35  string,
36  usv,
37} from "ext:web/idl.js";
38import { blobBody, fromBytes, snapshot } from "ext:blob/blob.js";
39import { MessageEvent } from "ext:web/messaging.js";
40import { userAgent } from "ext:web/navigator.js";
41import {
42  addListener,
43  createException,
44  dispatch,
45  ErrorEvent,
46  Event,
47  EventTarget,
48  preventDefault,
49  removeListener,
50  reportErrorValue,
51} from "ext:web/events.js";
52import { TextEncoder } from "ext:encoding/main.js";
53import { URL } from "ext:url/main.js";
54import {
55  op_ws_close,
56  op_ws_create,
57  op_ws_fail,
58  op_ws_retire,
59  op_ws_send,
60  op_ws_wait,
61} from "ext:core/ops";
62
63import type { WebSocketEvent } from "ext:core/ops";
64
65import { byteLength as sharedLength } from "ext:core/shared_array_buffer";
66
67const CONNECTING = 0,
68  OPEN = 1,
69  CLOSING = 2,
70  CLOSED = 3;
71type ReadyState =
72  | typeof CONNECTING
73  | typeof OPEN
74  | typeof CLOSING
75  | typeof CLOSED;
76type EventType = "open" | "message" | "error" | "close";
77type EventHandler<E extends Event> = (this: WebSocket, event: E) => unknown;
78interface Handler {
79  value: Function | null;
80  listener: ((event: Event) => void) | null;
81}
82interface SocketState {
83  id: number;
84  href: string;
85  origin: string;
86  state: ReadyState;
87  protocol: string;
88  extensions: string;
89  binaryType: "blob" | "arraybuffer";
90  buffered: number;
91  handlers: Record<EventType, Handler>;
92}
93interface CloseState {
94  wasClean: boolean;
95  code: number;
96  reason: string;
97}
98const sockets = new WeakMap<object, SocketState>(),
99  closeEvents = new WeakMap<object, CloseState>();
100const encoder = new TextEncoder(),
101  encode = uncurryThis(TextEncoder.prototype.encode);
102const invalidToken = RegExp("[^A-Za-z0-9!#$%&'*+\\-.^_`|~]");
103const floor = Math.floor,
104  includes = uncurryThis(String.prototype.includes),
105  urlHref = getter(URL.prototype, "href"),
106  urlProtocol = getter(URL.prototype, "protocol"),
107  urlOrigin = getter(URL.prototype, "origin");
108// As in blob.js: the byteLength getter throws for anything but an ArrayBuffer.
109const isView = ArrayBuffer.isView,
110  byteLength = getter(ArrayBuffer.prototype, "byteLength"),
111  dataViewBuffer = getter(DataView.prototype, "buffer");
112
113function isBufferSource(value: unknown): boolean {
114  if (isView(value)) return true;
115  try {
116    byteLength(value);
117    return true;
118  } catch {}
119  try {
120    sharedLength(value);
121    return true;
122  } catch {
123    return false;
124  }
125}
126
127function syntaxError(message: string): Error {
128  return createException({ message, name: "SyntaxError" });
129}
130
131function toUint16(value: unknown): number {
132  return (+(value as number) >>> 0) % 65536;
133}
134
135function clamp(value: unknown): number {
136  const number = +(value as number);
137  if (number !== number || number <= 0) return 0;
138  if (number >= 65535) return 65535;
139  const base = floor(number),
140    fraction = number - base;
141  return fraction < 0.5 || (fraction === 0.5 && base % 2 === 0)
142    ? base
143    : base + 1;
144}
145
146function convertProtocols(value: unknown): string[] {
147  const list: string[] = [];
148  if (value === undefined) return list;
149  const method = isObject(value) ? value[SymbolIterator] : undefined;
150  if (method === undefined || method === null) {
151    push(list, string(value));
152  } else {
153    if (typeof method !== "function") {
154      throw new TypeError("Invalid protocol iterator");
155    }
156    const iterator = apply(method, value, []);
157    if (!isObject(iterator)) throw new TypeError("Invalid protocol iterator");
158    const next = iterator.next;
159    if (typeof next !== "function") {
160      throw new TypeError("Invalid protocol iterator");
161    }
162    while (true) {
163      const step = apply(next, iterator, []);
164      if (!isObject(step)) {
165        throw new TypeError("Invalid protocol iterator result");
166      }
167      if (step.done) break;
168      push(list, string(step.value));
169    }
170  }
171  return list;
172}
173
174// The pump holds the socket strongly until the close event, which is what keeps
175// an OPEN socket with listeners from being collected.
176async function pump(id: number, socket: WebSocket): Promise<void> {
177  let failure: string | undefined;
178  try {
179    while (true) {
180      let event: WebSocketEvent;
181      try {
182        event = await safePromise(op_ws_wait(id));
183      } catch (error) {
184        failure = usv(error);
185        event = { kind: "close", clean: false, code: 1006, reason: "" };
186      }
187      const s = state(sockets, socket);
188      try {
189        if (event.kind === "open") {
190          // close() may have been called while the connection was still establishing.
191          if (s.state === CONNECTING) {
192            s.state = OPEN;
193            if (event.protocol !== "") s.protocol = event.protocol;
194            dispatch(socket, new Event("open"));
195          }
196        } else if (event.kind === "text" || event.kind === "binary") {
197          if (s.state === OPEN) {
198            const data = event.kind === "text"
199              ? event.data
200              : s.binaryType === "blob"
201              ? fromBytes(event.data, "")
202              : buffer(event.data);
203            dispatch(
204              socket,
205              new MessageEvent("message", { data, origin: s.origin }),
206            );
207          }
208        } else if (event.kind === "closing") {
209          if (s.state === OPEN) s.state = CLOSING;
210        } else if (event.kind === "error") {
211          failure = event.message;
212        } else {
213          s.state = CLOSED;
214          if (failure !== undefined) {
215            dispatch(socket, new ErrorEvent("error", { message: failure }));
216          }
217          dispatch(
218            socket,
219            new CloseEvent("close", {
220              wasClean: event.clean,
221              code: event.code,
222              reason: event.reason,
223            }),
224          );
225          return;
226        }
227      } catch (error) {
228        reportErrorValue(error);
229        if (s.state === CLOSED) return;
230      }
231    }
232  } finally {
233    op_ws_retire(id);
234  }
235}
236
237const eventTypes: EventType[] = ["open", "message", "error", "close"];
238
239function initializeSocket(
240  socket: WebSocket,
241  id: number,
242  record: URL,
243): WebSocket {
244  const handlers = ObjectCreate(null) as Record<EventType, Handler>;
245  for (let i = 0; i < eventTypes.length; i++) {
246    handlers[eventTypes[i]] = { value: null, listener: null };
247  }
248  set(sockets, socket, {
249    id,
250    href: urlHref(record),
251    origin: urlOrigin(record),
252    state: CONNECTING,
253    protocol: "",
254    extensions: "",
255    binaryType: "blob",
256    buffered: 0,
257    handlers,
258  });
259  markPromiseHandled(pump(id, socket));
260  return socket;
261}
262
263function adoptWebSocket(id: number, href: string): WebSocket {
264  const socket = construct(EventTarget, [], WebSocket) as WebSocket;
265  return initializeSocket(socket, id, new URL(`ws${slice(href, 4)}`));
266}
267
268function handler(type: EventType) {
269  return {
270    get(this: WebSocket): Function | null {
271      return state(sockets, this).handlers[type].value;
272    },
273    set(this: WebSocket, rawValue: unknown): void {
274      const handler = state(sockets, this).handlers[type];
275      const value = typeof rawValue === "function" ? rawValue : null;
276      handler.value = value;
277      if (value && !handler.listener) {
278        handler.listener = (event) => {
279          if (apply(handler.value!, this, [event]) === false) {
280            preventDefault(event);
281          }
282        };
283        addListener(this, type, handler.listener);
284      } else if (!value && handler.listener) {
285        removeListener(this, type, handler.listener);
286        handler.listener = null;
287      }
288    },
289    configurable: true,
290  };
291}
292
293class CloseEvent extends Event {
294  // @ts-expect-error Reflect.construct preserves new.target without a guest-replaceable super() call.
295  constructor(type: unknown, init: unknown = {}) {
296    required(arguments.length, 1);
297    type = string(type);
298    const options = dictionary(init);
299    const self = construct(Event, [type, options], new.target) as CloseEvent;
300    const rawCode = options.code,
301      reason = options.reason;
302    set(closeEvents, self, {
303      wasClean: !!options.wasClean,
304      code: rawCode === undefined ? 0 : toUint16(rawCode),
305      reason: reason === undefined ? "" : usv(reason),
306    });
307    return self;
308  }
309  get wasClean() {
310    return state(closeEvents, this).wasClean;
311  }
312  get code() {
313    return state(closeEvents, this).code;
314  }
315  get reason() {
316    return state(closeEvents, this).reason;
317  }
318}
319
320interface WebSocket {
321  readonly CONNECTING: typeof CONNECTING;
322  readonly OPEN: typeof OPEN;
323  readonly CLOSING: typeof CLOSING;
324  readonly CLOSED: typeof CLOSED;
325  onopen: EventHandler<Event> | null;
326  onmessage: EventHandler<MessageEvent> | null;
327  onerror: EventHandler<Event> | null;
328  onclose: EventHandler<CloseEvent> | null;
329}
330class WebSocket extends EventTarget {
331  declare static readonly CONNECTING: typeof CONNECTING;
332  declare static readonly OPEN: typeof OPEN;
333  declare static readonly CLOSING: typeof CLOSING;
334  declare static readonly CLOSED: typeof CLOSED;
335
336  // @ts-expect-error Reflect.construct preserves new.target without a guest-replaceable super() call.
337  constructor(url: unknown, protocols: unknown = undefined) {
338    required(arguments.length, 1);
339    const self = construct(EventTarget, [], new.target) as WebSocket;
340    url = usv(url);
341    const list = convertProtocols(protocols);
342    let record;
343    try {
344      record = new URL(url);
345    } catch {
346      throw syntaxError("The URL is invalid");
347    }
348    const protocol = urlProtocol(record);
349    if (protocol === "http:") {
350      record = new URL(`ws${slice(urlHref(record), 4)}`);
351    } else if (protocol === "https:") {
352      record = new URL(`wss${slice(urlHref(record), 5)}`);
353    } else if (protocol !== "ws:" && protocol !== "wss:") {
354      throw syntaxError("The URL's scheme must be ws, wss, http, or https");
355    }
356    const href = urlHref(record);
357    if (includes(href, "#")) {
358      throw syntaxError("The URL contains a fragment");
359    }
360    const seen = new Set<string>();
361    for (let i = 0; i < list.length; i++) {
362      const item = list[i];
363      if (item === "" || exec(invalidToken, item) !== null) {
364        throw syntaxError("Invalid WebSocket subprotocol");
365      }
366      if (has(seen, item)) {
367        throw syntaxError("Duplicate WebSocket subprotocol");
368      }
369      add(seen, item);
370    }
371    // Permission failures surface synchronously, as in fetch.
372    const id = op_ws_create([href, list, userAgent]);
373    return initializeSocket(self, id, record);
374  }
375  get url() {
376    return state(sockets, this).href;
377  }
378  get readyState() {
379    return state(sockets, this).state;
380  }
381  get bufferedAmount() {
382    return state(sockets, this).buffered;
383  }
384  get extensions() {
385    return state(sockets, this).extensions;
386  }
387  get protocol() {
388    return state(sockets, this).protocol;
389  }
390  get binaryType(): "blob" | "arraybuffer" {
391    return state(sockets, this).binaryType;
392  }
393  set binaryType(value: unknown) {
394    const s = state(sockets, this), binaryType = string(value);
395    if (binaryType === "blob" || binaryType === "arraybuffer") {
396      s.binaryType = binaryType;
397    }
398  }
399  close(code: unknown = undefined, reason: unknown = undefined): void {
400    const s = state(sockets, this);
401    let closeCode: number | null = null;
402    if (code !== undefined) closeCode = clamp(code);
403    if (reason !== undefined) reason = usv(reason);
404    if (
405      closeCode !== null && closeCode !== 1000 &&
406      (closeCode < 3000 || closeCode > 4999)
407    ) {
408      throw createException({
409        message: "Invalid WebSocket close code",
410        name: "InvalidAccessError",
411      });
412    }
413    if (reason !== undefined && length(encode(encoder, reason)) > 123) {
414      throw syntaxError("The WebSocket close reason is too long");
415    }
416    if (s.state === CLOSING || s.state === CLOSED) return;
417    if (s.state === CONNECTING) {
418      // Failing the connection instead of starting the closing handshake.
419      s.state = CLOSING;
420      op_ws_fail(s.id);
421      return;
422    }
423    s.state = CLOSING;
424    op_ws_close([s.id, closeCode, (reason as string | undefined) ?? null]);
425  }
426  send(data: unknown): void {
427    const s = state(sockets, this);
428    required(arguments.length, 1);
429    let text = true,
430      bytes: Uint8Array<ArrayBuffer>;
431    const blob = blobBody(data);
432    if (blob !== null) {
433      bytes = blob.bytes;
434      text = false;
435    } else if (isBufferSource(data)) {
436      const b = isView(data)
437        ? tag(data) === undefined ? dataViewBuffer(data) : buffer(data)
438        : data;
439      // The getter's brand check also rejects SharedArrayBuffers, which BufferSource excludes.
440      if (resizable(b)) {
441        throw new TypeError("WebSocket data must have a fixed buffer");
442      }
443      bytes = snapshot(data);
444      text = false;
445    } else {
446      // The IDL union's last member: everything else becomes a USVString.
447      bytes = encode(encoder, usv(data));
448    }
449    if (s.state === CONNECTING) {
450      throw createException({
451        message: "WebSocket is not connected",
452        name: "InvalidStateError",
453      });
454    }
455    const size = length(bytes);
456    s.buffered += size;
457    if (s.state !== OPEN) return;
458    markPromiseHandled(
459      then(op_ws_send([s.id, text, bytes]), (sent) => {
460        if (sent && s.state !== CLOSED) s.buffered -= size;
461      }),
462    );
463  }
464}
465
466for (
467  const [name, value] of [
468    ["CONNECTING", 0],
469    ["OPEN", 1],
470    ["CLOSING", 2],
471    ["CLOSED", 3],
472  ] as const
473) {
474  define(WebSocket, name, { value, enumerable: true });
475  define(WebSocket.prototype, name, { value, enumerable: true });
476}
477for (const type of eventTypes) {
478  define(WebSocket.prototype, `on${type}`, handler(type));
479}
480
481export { adoptWebSocket, CloseEvent, WebSocket };