-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Expand file tree
/
Copy pathOneWayWebSocket.ts
More file actions
226 lines (197 loc) · 6.57 KB
/
Copy pathOneWayWebSocket.ts
File metadata and controls
226 lines (197 loc) · 6.57 KB
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
/**
* @file A wrapper over WebSockets that (1) enforces one-way communication, and
* (2) supports automatically parsing JSON messages as they come in.
*
* This should ALWAYS be favored in favor of using Server-Sent Events and the
* built-in EventSource class for doing one-way communication. SSEs have a hard
* limitation on HTTP/1.1 and below where there is a maximum number of 6 ports
* that can ever be used for a domain (sometimes less depending on the browser).
* Not only is this limit shared with short-lived REST requests, but it also
* applies across tabs and windows. So if a user opens Coder in multiple tabs,
* there is a very real possibility that parts of the app will start to lock up
* without it being clear why.
*
* WebSockets do not have this limitation, even on HTTP/1.1 – all modern
* browsers implement at least some degree of multiplexing for them.
*/
// Not bothering with trying to borrow methods from the base WebSocket type
// because it's already a mess of inheritance and generics, and we're going to
// have to add a few more
export type WebSocketEventType = "close" | "error" | "message" | "open";
export type OneWayMessageEvent<TData> = Readonly<
| {
sourceEvent: MessageEvent<string>;
parsedMessage: TData;
parseError: undefined;
}
| {
sourceEvent: MessageEvent<string>;
parsedMessage: undefined;
parseError: Error;
}
>;
type OneWayEventPayloadMap<TData> = {
close: CloseEvent;
error: Event;
message: OneWayMessageEvent<TData>;
open: Event;
};
type WebSocketMessageCallback = (payload: MessageEvent<string>) => void;
type OneWayEventCallback<TData, TEvent extends WebSocketEventType> = (
payload: OneWayEventPayloadMap<TData>[TEvent],
) => void;
export interface OneWayWebSocketApi<TData> {
get url(): string;
addEventListener: <TEvent extends WebSocketEventType>(
eventType: TEvent,
callback: OneWayEventCallback<TData, TEvent>,
) => void;
removeEventListener: <TEvent extends WebSocketEventType>(
eventType: TEvent,
callback: OneWayEventCallback<TData, TEvent>,
) => void;
close: (closeCode?: number, reason?: string) => void;
}
type OneWayWebSocketInit = Readonly<{
apiRoute: string;
serverProtocols?: string | string[];
searchParams?: Record<string, string> | URLSearchParams;
binaryType?: BinaryType;
websocketInit?: (url: string, protocols?: string | string[]) => WebSocket;
location?: Readonly<{
protocol: string;
host: string;
}>;
}>;
function defaultInit(url: string, protocols?: string | string[]): WebSocket {
return new WebSocket(url, protocols);
}
export class OneWayWebSocket<TData = unknown>
implements OneWayWebSocketApi<TData>
{
readonly #socket: WebSocket;
readonly #errorListeners = new Set<(e: Event) => void>();
readonly #messageListenerWrappers = new Map<
OneWayEventCallback<TData, "message">,
WebSocketMessageCallback
>();
constructor(init: OneWayWebSocketInit) {
const {
apiRoute,
searchParams,
serverProtocols,
binaryType = "blob",
location = window.location,
websocketInit = defaultInit,
} = init;
if (
!apiRoute.startsWith("/api/v2/") &&
!apiRoute.startsWith("/api/experimental")
) {
throw new Error(
`API route '${apiRoute}' does not begin with '/api/v2/' or '/api/experimental'`,
);
}
const formattedParams =
searchParams instanceof URLSearchParams
? searchParams
: new URLSearchParams(searchParams);
const paramsString = formattedParams.toString();
const paramsSuffix = paramsString ? `?${paramsString}` : "";
const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${wsProtocol}//${location.host}${apiRoute}${paramsSuffix}`;
this.#socket = websocketInit(url, serverProtocols);
this.#socket.binaryType = binaryType;
}
get url(): string {
return this.#socket.url;
}
addEventListener<TEvent extends WebSocketEventType>(
event: TEvent,
callback: OneWayEventCallback<TData, TEvent>,
): void {
if (this.#socket.readyState === WebSocket.CLOSED) {
return;
}
// Not happy about all the type assertions, but there are some nasty
// type contravariance issues if you try to resolve the function types
// properly. This is actually the lesser of two evils
const looseCallback = callback as OneWayEventCallback<
TData,
WebSocketEventType
>;
// WebSockets automatically handle de-duping callbacks, but we have to
// do a separate check for the wrappers
if (this.#messageListenerWrappers.has(looseCallback)) {
return;
}
if (event !== "message") {
this.#socket.addEventListener(event, looseCallback);
if (event === "error") {
this.#errorListeners.add(looseCallback);
}
return;
}
const wrapped = (event: MessageEvent<string>): void => {
const messageCallback = looseCallback as OneWayEventCallback<
TData,
"message"
>;
try {
const message = JSON.parse(event.data) as TData;
messageCallback({
sourceEvent: event,
parseError: undefined,
parsedMessage: message,
});
} catch (err) {
messageCallback({
sourceEvent: event,
parseError: err as Error,
parsedMessage: undefined,
});
}
};
this.#socket.addEventListener(event as "message", wrapped);
this.#messageListenerWrappers.set(looseCallback, wrapped);
}
removeEventListener<TEvent extends WebSocketEventType>(
event: TEvent,
callback: OneWayEventCallback<TData, TEvent>,
): void {
const looseCallback = callback as OneWayEventCallback<
TData,
WebSocketEventType
>;
if (event !== "message") {
this.#socket.removeEventListener(event, looseCallback);
if (event === "error") {
this.#errorListeners.delete(looseCallback);
}
return;
}
if (!this.#messageListenerWrappers.has(looseCallback)) {
return;
}
const wrapper = this.#messageListenerWrappers.get(looseCallback);
if (wrapper === undefined) {
throw new Error(
`Cannot unregister callback for event ${event}. This is likely an issue with the browser itself.`,
);
}
this.#socket.removeEventListener(event as "message", wrapper);
this.#messageListenerWrappers.delete(looseCallback);
}
close(closeCode?: number, reason?: string): void {
// Eject all error event listeners, mainly for ergonomics in React dev
// mode. React's StrictMode will create additional connections to ensure
// there aren't any render bugs, but manually closing a connection via a
// cleanup function sometimes causes error events to get dispatched for
// a connection that is no longer wired up to the UI
for (const cb of this.#errorListeners) {
this.#socket.removeEventListener("error", cb);
this.#errorListeners.delete(cb);
}
this.#socket.close(closeCode, reason);
}
}