|
1 | 1 | /**
|
2 |
| - * @file A WebSocket that can only receive messages from the server, and cannot |
3 |
| - * ever send anything. |
| 2 | + * @file A wrapper over WebSockets that (1) enforces one-way communication, and |
| 3 | + * (2) supports automatically parsing JSON messages as they come in. |
4 | 4 | *
|
5 | 5 | * This should ALWAYS be favored in favor of using Server-Sent Events and the
|
6 | 6 | * built-in EventSource class for doing one-way communication. SSEs have a hard
|
|
12 | 12 | * without it being clear why.
|
13 | 13 | *
|
14 | 14 | * WebSockets do not have this limitation, even on HTTP/1.1 – all modern
|
15 |
| - * browsers implement at least some degree of multiplexing for them. This file |
16 |
| - * just provides a wrapper to make it harder to use WebSockets for two-way |
17 |
| - * communication by accident. |
| 15 | + * browsers implement at least some degree of multiplexing for them. |
18 | 16 | */
|
19 | 17 |
|
20 | 18 | // Not bothering with trying to borrow methods from the base WebSocket type
|
21 |
| -// because it's a mess of inheritance and generics. |
| 19 | +// because it's already a mess of inheritance and generics, and we're going to |
| 20 | +// have to add a few more |
22 | 21 | type WebSocketEventType = "close" | "error" | "message" | "open";
|
23 | 22 |
|
24 |
| -type WebSocketEventPayloadMap = { |
| 23 | +type OneWayMessageEvent<TData> = Readonly< |
| 24 | + | { |
| 25 | + sourceEvent: MessageEvent<string>; |
| 26 | + parsedMessage: TData; |
| 27 | + parseError: undefined; |
| 28 | + } |
| 29 | + | { |
| 30 | + sourceEvent: MessageEvent<string>; |
| 31 | + parsedMessage: undefined; |
| 32 | + parseError: Error; |
| 33 | + } |
| 34 | +>; |
| 35 | + |
| 36 | +type OneWayEventPayloadMap<TData> = { |
25 | 37 | close: CloseEvent;
|
26 | 38 | error: Event;
|
27 |
| - message: MessageEvent<string>; |
| 39 | + message: OneWayMessageEvent<TData>; |
28 | 40 | open: Event;
|
29 | 41 | };
|
30 | 42 |
|
31 |
| -// The generics need to apply on a per-method-invocation basis; they cannot be |
32 |
| -// generalized to the top-level interface definition |
33 |
| -interface OneWayWebSocketApi { |
34 |
| - addEventListener: <T extends WebSocketEventType>( |
35 |
| - eventType: T, |
36 |
| - callback: (payload: WebSocketEventPayloadMap[T]) => void, |
37 |
| - ) => void; |
| 43 | +type WebSocketMessageCallback = (payload: MessageEvent<string>) => void; |
38 | 44 |
|
39 |
| - removeEventListener: <T extends WebSocketEventType>( |
40 |
| - eventType: T, |
41 |
| - callback: (payload: WebSocketEventPayloadMap[T]) => void, |
42 |
| - ) => void; |
43 |
| - |
44 |
| - close: (closeCode?: number, reason?: string) => void; |
45 |
| -} |
| 45 | +type OneWayEventCallback<TData, TEvent extends WebSocketEventType> = ( |
| 46 | + payload: OneWayEventPayloadMap<TData>[TEvent], |
| 47 | +) => void; |
46 | 48 |
|
47 | 49 | type OneWayWebSocketInit = Readonly<{
|
48 |
| - apiRoute: `/${string}`; |
| 50 | + apiRoute: string; |
49 | 51 | location?: Location;
|
50 | 52 | protocols?: string | string[];
|
51 | 53 | }>;
|
52 | 54 |
|
53 |
| -// Implementing wrapper around the base WebSocket class instead of doing fancy |
54 |
| -// compile-time type-casts so that we have more runtime assurance that we won't |
55 |
| -// accidentally send a message from the client to the server |
56 |
| -export class OneWayWebSocket implements OneWayWebSocketApi { |
| 55 | +interface OneWayWebSocketApi<TData> { |
| 56 | + addEventListener: <TEvent extends WebSocketEventType>( |
| 57 | + eventType: TEvent, |
| 58 | + callback: OneWayEventCallback<TData, TEvent>, |
| 59 | + ) => void; |
| 60 | + |
| 61 | + removeEventListener: <TEvent extends WebSocketEventType>( |
| 62 | + eventType: TEvent, |
| 63 | + callback: OneWayEventCallback<TData, TEvent>, |
| 64 | + ) => void; |
| 65 | + |
| 66 | + close: (closeCode?: number, reason?: string) => void; |
| 67 | +} |
| 68 | + |
| 69 | +export class OneWayWebSocket<TData = unknown> |
| 70 | + implements OneWayWebSocketApi<TData> |
| 71 | +{ |
57 | 72 | #socket: WebSocket;
|
| 73 | + #messageCallbackWrappers = new Map< |
| 74 | + OneWayEventCallback<TData, "message">, |
| 75 | + WebSocketMessageCallback |
| 76 | + >(); |
58 | 77 |
|
59 | 78 | constructor(init: OneWayWebSocketInit) {
|
60 | 79 | const { apiRoute, protocols, location = window.location } = init;
|
| 80 | + if (apiRoute.at(0) !== "/") { |
| 81 | + throw new Error(`API route ${apiRoute} does not begin with a space`); |
| 82 | + } |
61 | 83 |
|
62 | 84 | const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
63 | 85 | const url = `${protocol}//${location.host}${apiRoute}`;
|
64 | 86 | this.#socket = new WebSocket(url, protocols);
|
65 | 87 | }
|
66 | 88 |
|
67 |
| - // Just because this is a React project, all public methods are defined as |
68 |
| - // arrow functions so they can be passed around as values without losing |
69 |
| - // their `this` context |
70 |
| - addEventListener = <T extends WebSocketEventType>( |
71 |
| - message: T, |
72 |
| - callback: (payload: WebSocketEventPayloadMap[T]) => void, |
73 |
| - ): void => { |
74 |
| - this.#socket.addEventListener(message, callback); |
75 |
| - }; |
76 |
| - |
77 |
| - removeEventListener = <T extends WebSocketEventType>( |
78 |
| - message: T, |
79 |
| - callback: (payload: WebSocketEventPayloadMap[T]) => void, |
80 |
| - ): void => { |
81 |
| - this.#socket.removeEventListener(message, callback); |
82 |
| - }; |
83 |
| - |
84 |
| - close = (closeCode?: number, reason?: string): void => { |
| 89 | + addEventListener<TEvent extends WebSocketEventType>( |
| 90 | + event: TEvent, |
| 91 | + callback: OneWayEventCallback<TData, TEvent>, |
| 92 | + ): void { |
| 93 | + // Not happy about all the type assertions, but there are some nasty |
| 94 | + // type contravariance issues if you try to resolve the function types |
| 95 | + // properly. This is actually the lesser of two evils |
| 96 | + const looseCallback = callback as OneWayEventCallback< |
| 97 | + TData, |
| 98 | + WebSocketEventType |
| 99 | + >; |
| 100 | + |
| 101 | + if (this.#messageCallbackWrappers.has(looseCallback)) { |
| 102 | + return; |
| 103 | + } |
| 104 | + if (event !== "message") { |
| 105 | + this.#socket.addEventListener(event, looseCallback); |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + const wrapped = (event: MessageEvent<string>): void => { |
| 110 | + const messageCallback = looseCallback as OneWayEventCallback< |
| 111 | + TData, |
| 112 | + "message" |
| 113 | + >; |
| 114 | + |
| 115 | + try { |
| 116 | + const message = JSON.parse(event.data) as TData; |
| 117 | + messageCallback({ |
| 118 | + sourceEvent: event, |
| 119 | + parseError: undefined, |
| 120 | + parsedMessage: message, |
| 121 | + }); |
| 122 | + } catch (err) { |
| 123 | + messageCallback({ |
| 124 | + sourceEvent: event, |
| 125 | + parseError: err as Error, |
| 126 | + parsedMessage: undefined, |
| 127 | + }); |
| 128 | + } |
| 129 | + }; |
| 130 | + |
| 131 | + this.#socket.addEventListener(event as "message", wrapped); |
| 132 | + this.#messageCallbackWrappers.set(looseCallback, wrapped); |
| 133 | + } |
| 134 | + |
| 135 | + removeEventListener<TEvent extends WebSocketEventType>( |
| 136 | + event: TEvent, |
| 137 | + callback: OneWayEventCallback<TData, TEvent>, |
| 138 | + ): void { |
| 139 | + const looseCallback = callback as OneWayEventCallback< |
| 140 | + TData, |
| 141 | + WebSocketEventType |
| 142 | + >; |
| 143 | + |
| 144 | + if (!this.#messageCallbackWrappers.has(looseCallback)) { |
| 145 | + return; |
| 146 | + } |
| 147 | + if (event !== "message") { |
| 148 | + this.#socket.removeEventListener(event, looseCallback); |
| 149 | + return; |
| 150 | + } |
| 151 | + |
| 152 | + const wrapper = this.#messageCallbackWrappers.get(looseCallback); |
| 153 | + if (wrapper === undefined) { |
| 154 | + throw new Error( |
| 155 | + `Cannot unregister callback for event ${event}. This is likely an issue with the browser itself.`, |
| 156 | + ); |
| 157 | + } |
| 158 | + |
| 159 | + this.#socket.removeEventListener(event as "message", wrapper); |
| 160 | + this.#messageCallbackWrappers.delete(looseCallback); |
| 161 | + } |
| 162 | + |
| 163 | + close(closeCode?: number, reason?: string): void { |
85 | 164 | this.#socket.close(closeCode, reason);
|
86 |
| - }; |
| 165 | + } |
87 | 166 | }
|
0 commit comments