Skip to content

Commit 6cdfc21

Browse files
committed
fix: apply feedback
1 parent bfe4d9f commit 6cdfc21

File tree

4 files changed

+146
-62
lines changed

4 files changed

+146
-62
lines changed

site/src/api/api.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@ const getMissingParameters = (
107107
* @returns An EventSource that emits agent metadata event objects
108108
* (ServerSentEvent)
109109
*/
110-
export const watchAgentMetadata = (agentId: string): OneWayWebSocket => {
110+
export const watchAgentMetadata = (
111+
agentId: string,
112+
): OneWayWebSocket<TypesGen.ServerSentEvent> => {
111113
return new OneWayWebSocket({
112114
apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`,
113115
});
@@ -117,7 +119,9 @@ export const watchAgentMetadata = (agentId: string): OneWayWebSocket => {
117119
* @returns {EventSource} An EventSource that emits workspace event objects
118120
* (ServerSentEvent)
119121
*/
120-
export const watchWorkspace = (workspaceId: string): OneWayWebSocket => {
122+
export const watchWorkspace = (
123+
workspaceId: string,
124+
): OneWayWebSocket<TypesGen.ServerSentEvent> => {
121125
return new OneWayWebSocket({
122126
apiRoute: `/api/v2/workspaces/${workspaceId}/watch-ws`,
123127
});

site/src/modules/resources/AgentMetadata.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
6464
}
6565

6666
let timeoutId: number | undefined = undefined;
67-
let activeSocket: OneWayWebSocket | undefined = undefined;
67+
let activeSocket: OneWayWebSocket<ServerSentEvent> | null = null;
6868
let retries = 0;
6969

7070
const createNewConnection = () => {
@@ -80,7 +80,7 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
8080
// would auto-close. Couldn't find a definitive answer on MDN,
8181
// though, so closing it manually just to be safe
8282
socket.close();
83-
activeSocket = undefined;
83+
activeSocket = null;
8484

8585
retries++;
8686
if (retries >= maxSocketErrorRetryCount) {
@@ -99,15 +99,16 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
9999
});
100100

101101
socket.addEventListener("message", (e) => {
102-
try {
103-
const payload = JSON.parse(e.data) as ServerSentEvent;
104-
if (payload.type === "data") {
105-
setActiveMetadata(payload.data as WorkspaceAgentMetadata[]);
106-
}
107-
} catch {
102+
if (e.parseError) {
108103
displayError(
109104
"Unable to process newest response from server. Please try refreshing the page.",
110105
);
106+
return;
107+
}
108+
109+
const msg = e.parsedMessage;
110+
if (msg.type === "data") {
111+
setActiveMetadata(msg.data as WorkspaceAgentMetadata[]);
111112
}
112113
});
113114
};

site/src/pages/WorkspacePage/WorkspacePage.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,15 @@ export const WorkspacePage: FC = () => {
8585

8686
const socket = watchWorkspace(workspaceId);
8787
socket.addEventListener("message", (event) => {
88-
try {
89-
const sse = JSON.parse(event.data);
90-
if (sse.type === "data") {
91-
updateWorkspaceData(sse.data as Workspace);
92-
}
93-
} catch {
88+
if (event.parseError) {
9489
displayError(
9590
"Unable to process latest data from the server. Please try refreshing the page.",
9691
);
92+
return;
93+
}
94+
95+
if (event.parsedMessage.type === "data") {
96+
updateWorkspaceData(event.parsedMessage.data as Workspace);
9797
}
9898
});
9999
socket.addEventListener("error", () => {

site/src/utils/OneWayWebSocket.ts

Lines changed: 125 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
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.
44
*
55
* This should ALWAYS be favored in favor of using Server-Sent Events and the
66
* built-in EventSource class for doing one-way communication. SSEs have a hard
@@ -12,76 +12,155 @@
1212
* without it being clear why.
1313
*
1414
* 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.
1816
*/
1917

2018
// 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
2221
type WebSocketEventType = "close" | "error" | "message" | "open";
2322

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> = {
2537
close: CloseEvent;
2638
error: Event;
27-
message: MessageEvent<string>;
39+
message: OneWayMessageEvent<TData>;
2840
open: Event;
2941
};
3042

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;
3844

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;
4648

4749
type OneWayWebSocketInit = Readonly<{
48-
apiRoute: `/${string}`;
50+
apiRoute: string;
4951
location?: Location;
5052
protocols?: string | string[];
5153
}>;
5254

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+
{
5772
#socket: WebSocket;
73+
#messageCallbackWrappers = new Map<
74+
OneWayEventCallback<TData, "message">,
75+
WebSocketMessageCallback
76+
>();
5877

5978
constructor(init: OneWayWebSocketInit) {
6079
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+
}
6183

6284
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
6385
const url = `${protocol}//${location.host}${apiRoute}`;
6486
this.#socket = new WebSocket(url, protocols);
6587
}
6688

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 {
85164
this.#socket.close(closeCode, reason);
86-
};
165+
}
87166
}

0 commit comments

Comments
 (0)