8000 chore: add support for one-way WebSockets to UI by Parkreiner · Pull Request #16855 · coder/coder · GitHub
[go: up one dir, main page]

Skip to content

chore: add support for one-way WebSockets to UI #16855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 24 commits into from
Mar 28, 2025
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
b8cfe76
feat: add support for one-way websockets to UI
Parkreiner Mar 7, 2025
367906d
fix: apply formatting
Parkreiner Mar 7, 2025
09f5e95
docs: remove outdated comment
Parkreiner Mar 7, 2025
04a3846
fix: add missing clear call
Parkreiner Mar 7, 2025
ca8e94f
fix: streamline biome fixes
Parkreiner Mar 7, 2025
81a723a
fix: resolve Storybook metadata setup bug
Parkreiner Mar 12, 2025
4364a3d
docs: make warning more obvious
Parkreiner Mar 12, 2025
ecb2940
fix: beef up socket retry logic
Parkreiner Mar 12, 2025
bfe4d9f
fix: make it harder to initialize OWWS
Parkreiner Mar 12, 2025
6cdfc21
fix: apply feedback
Parkreiner Mar 12, 2025
682e2f4
fix: update JSDoc
Parkreiner Mar 12, 2025
20ad778
Merge branch 'main' into mes/one-way-ws-02
Parkreiner Mar 18, 2025
9b19ceb
chore: add missing socket unit tests
Parkreiner Mar 18, 2025
423910f
fix: update notifications code to use OWWS
Parkreiner Mar 18, 2025
247dbb6
Merge branch 'mes/one-way-ws-01' into mes/one-way-ws-02
Parkreiner Mar 18, 2025
6e3e0d8
fix: remove comment about what to test
Parkreiner Mar 18, 2025
c422379
fix: make class fields readonly
Parkreiner Mar 19, 2025
0824dd4
fix: sort imports
Parkreiner Mar 19, 2025
db448d7
Merge branch 'mes/one-way-ws-01' into mes/one-way-ws-02
Parkreiner Mar 19, 2025
60bf505
Merge branch 'mes/one-way-ws-01' into mes/one-way-ws-02
Parkreiner Mar 25, 2025
c1cee57
refactor: make tests more maintainable
Parkreiner Mar 25, 2025
70b74e2
fix: remove unused type alias
Parkreiner Mar 25, 2025
8e34e91
fix: make mock publisher more robust
Parkreiner Mar 25, 2025
8db068a
Merge branch 'mes/one-way-ws-01' into mes/one-way-ws-02
Parkreiner Mar 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion site/package.json
8000
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,6 @@
"@vitejs/plugin-react": "4.3.4",
"autoprefixer": "10.4.20",
"chromatic": "11.25.2",
"eventsourcemock": "2.0.0",
"express": "4.21.2",
"jest": "29.7.0",
"jest-canvas-mock": "2.5.2",
Expand Down
9 changes: 1 addition & 8 deletions site/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion site/src/@types/eventsourcemock.d.ts

This file was deleted.

34 changes: 19 additions & 15 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
import globalAxios, { type AxiosInstance, isAxiosError } from "axios";
import type dayjs from "dayjs";
import userAgentParser from "ua-parser-js";
import { OneWayWebSocket } from "utils/OneWayWebSocket";
import { delay } from "../utils/delay";
import * as TypesGen from "./typesGenerated";
import type { PostWorkspaceUsageRequest } from "./typesGenerated";
import * as TypesGen from "./typesGenerated";

const getMissingParameters = (
oldBuildParameters: TypesGen.WorkspaceBuildParameter[],
Expand Down Expand Up @@ -106,21 +107,21 @@ const getMissingParameters = (
* @returns An EventSource that emits agent metadata event objects
* (ServerSentEvent)
*/
export const watchAgentMetadata = (agentId: string): EventSource => {
return new EventSource(
`${location.protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata`,
{ withCredentials: true },
export const watchAgentMetadata = (agentId: string): OneWayWebSocket => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return new OneWayWebSocket(
`${protocol}//${location.host}/api/v2/workspaceagents/${agentId}/watch-metadata-ws`,
);
};

/**
* @returns {EventSource} An EventSource that emits workspace event objects
* (ServerSentEvent)
*/
export const watchWorkspace = (workspaceId: string): EventSource => {
return new EventSource(
`${location.protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch`,
{ withCredentials: true },
export const watchWorkspace = (workspaceId: string): OneWayWebSocket => {
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
return new OneWayWebSocket(
`${protocol}//${location.host}/api/v2/workspaces/${workspaceId}/watch-ws`,
);
};

Expand Down Expand Up @@ -1080,25 +1081,27 @@ class ApiMethods {
};

getWorkspaceByOwnerAndName = async (
username = "me",
username: string | undefined,
workspaceName: string,
params?: TypesGen.WorkspaceOptions,
): Promise<TypesGen.Workspace> => {
const user = username || "me";
const response = await this.axios.get<TypesGen.Workspace>(
`/api/v2/users/${username}/workspace/${workspaceName}`,
`/api/v2/users/${user}/workspace/${workspaceName}`,
{ params },
);

return response.data;
};

getWorkspaceBuildByNumber = async (
username = "me",
username 8000 : string | undefined,
workspaceName: string,
buildNumber: number,
): Promise<TypesGen.WorkspaceBuild> => {
const name = username || "me";
const response = await this.axios.get<TypesGen.WorkspaceBuild>(
`/api/v2/users/${username}/workspace/${workspaceName}/builds/${buildNumber}`,
`/api/v2/users/${name}/workspace/${workspaceName}/builds/${buildNumber}`,
);

return response.data;
Expand Down Expand Up @@ -1279,11 +1282,12 @@ class ApiMethods {
};

createWorkspace = async (
userId = "me",
userId: string | undefined,
workspace: TypesGen.CreateWorkspaceRequest,
): Promise<TypesGen.Workspace> => {
const id = userId || "me";
const response = await this.axios.post<TypesGen.Workspace>(
`/api/v2/users/${userId}/workspaces`,
`/api/v2/users/${id}/workspaces`,
workspace,
);

Expand Down
59 changes: 33 additions & 26 deletions site/src/modules/resources/AgentMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import Skeleton from "@mui/material/Skeleton";
import Tooltip from "@mui/material/Tooltip";
import { watchAgentMetadata } from "api/api";
import type {
ServerSentEvent,
WorkspaceAgent,
WorkspaceAgentMetadata,
} from "api/typesGenerated";
import { displayError } from "components/GlobalSnackbar/utils";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import {
Expand All @@ -17,6 +19,7 @@ import {
useState,
} from "react";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import type { OneWayWebSocket } from "utils/OneWayWebSocket";

type ItemStatus = "stale" | "valid" | "loading";

Expand Down Expand Up @@ -46,43 +49,47 @@ export const AgentMetadata: FC<AgentMetadataProps> = ({
agent,
storybookMetadata,
}) => {
const [metadata, setMetadata] = useState<
WorkspaceAgentMetadata[] | undefined
>(undefined);

const [metadata, setMetadata] = useState<WorkspaceAgentMetadata[]>();
useEffect(() => {
if (storybookMetadata !== undefined) {
setMetadata(storybookMetadata);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was removed because it introduces unnecessary renders – better to just use storybookMetadata as the initial value for the state and avoid a bunch of state syncs

return;
}

let timeout: ReturnType<typeof setTimeout> | undefined = undefined;
let timeoutId: number | undefined = undefined;
let latestSocket: OneWayWebSocket | undefined = undefined;

const connect = (): (() => void) => {
const source = watchAgentMetadata(agent.id);
const createNewConnection = () => {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous create function was super weird because it was responsible for three things

  • Creating a new WebSocket connection from the outside effect
  • Being able to create new connections by calling itself recursively
  • Also acting as a useEffect cleanup function

Figured it'd be better to split up those responsibilities

const socket = watchAgentMetadata(agent.id);
latestSocket = socket;

source.onerror = (e) => {
console.error("received error in watch stream", e);
socket.addEventListener("error", () => {
displayError("Socket closed unexpectedly. Creating new connection...");
setMetadata(undefined);
source.close();

timeout = setTimeout(() => {
connect();
}, 3000);
};

source.addEventListener("data", (e) => {
const data = JSON.parse(e.data);
setMetadata(data);
window.clearTimeout(timeoutId);
timeoutId = window.setTimeout(() => {
createNewConnection();
}, 3_000);
});
return () => {
if (timeout !== undefined) {
clearTimeout(timeout);

socket.addEventListener("message", (e) => {
try {
const payload = JSON.parse(e.data) as ServerSentEvent;
if (payload.type === "data") {
setMetadata(payload.data as WorkspaceAgentMetadata[]);
}
} catch {
displayError(
"Unable to process newest response from server. Please try refreshing the page.",
);
}
source.close();
};
});
};

createNewConnection();
return () => {
window.clearTimeout(timeoutId);
latestSocket?.close();
};
return connect();
}, [agent.id, storybookMetadata]);

if (metadata === undefined) {
Expand Down
42 changes: 17 additions & 25 deletions site/src/modules/templates/useWatchVersionLogs.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,38 @@
import { watchBuildLogsByTemplateVersionId } from "api/api";
import type { ProvisionerJobLog, TemplateVersion } from "api/typesGenerated";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useEffect, useState } from "react";

export const useWatchVersionLogs = (
templateVersion: TemplateVersion | undefined,
options?: { onDone: () => Promise<unknown> },
) => {
const [logs, setLogs] = useState<ProvisionerJobLog[] | undefined>();
const [logs, setLogs] = useState<ProvisionerJobLog[]>();
const templateVersionId = templateVersion?.id;
const templateVersionStatus = templateVersion?.job.status;
const [cachedVersionId, setCachedVersionId] = useState(templateVersionId);
if (cachedVersionId !== templateVersionId) {
setCachedVersionId(templateVersionId);
setLogs([]);
}
Comment on lines +12 to +16
Copy link
Member Author
@Parkreiner Parkreiner Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed to remove Biome's useExhaustiveDependencies warning without introducing unnecessary renders

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is in regards to useEffect? I think if applicable it'd be better to use a biome comment to ignore linting on the line with an explanation why

	 // biome-ignore lint/correctness/useExhaustiveDependencies: reason
	 useEffect(() => {
	 if (cachedVersionId !== templateVersionId) {
	 	setCachedVersionId(templateVersionId);
	 	setLogs([]);
	 }
	 	
	 }, [...]);

Copy link
Member Author
@Parkreiner Parkreiner Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, the linter warning is actually valid – the useEffect approach introduces additional renders and has a risk of screen flickering, while the if statement approach avoids all of that

Was mainly calling it out, because while it's the "correct" way to do things (and is shouted out in the React docs), I do think it's an ugly pattern, and you need a lot of context for how React works under the hood to know why it's better than the useEffect approach


// biome-ignore lint/correctness/useExhaustiveDependencies: consider refactoring
const stableOnDone = useEffectEvent(() => options?.onDone());
const status = templateVersion?.job.status;
const canWatch = status === "running" || status === "pending";
useEffect(() => {
setLogs(undefined);
}, [templateVersionId]);

useEffect(() => {
if (!templateVersionId || !templateVersionStatus) {
return;
}

if (
templateVersionStatus !== "running" &&
templateVersionStatus !== "pending"
) {
if (!templateVersionId || !canWatch) {
return;
}

const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
onMessage: (log) => {
setLogs((logs) => (logs ? [...logs, log] : [log]));
},
onDone: options?.onDone,
onError: (error) => {
console.error(error);
onError: (error) => console.error(error),
onDone: stableOnDone,
onMessage: (newLog) => {
setLogs((current) => [...(current ?? []), newLog]);
Copy link
Member Author
@Parkreiner Parkreiner Mar 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really wanted to just make sure that the logs is always an array, and get rid of the undefined case to make the state updates cleaner, but a bunch of our other components currently depends on that setup

},
});

return () => {
socket.close();
};
}, [options?.onDone, templateVersionId, templateVersionStatus]);
return () => socket.close();
}, [stableOnDone, canWatch, templateVersionId]);
Copy link
Member Author
@Parkreiner Parkreiner Mar 7, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous version of the effect was re-synchronizing way too often, because we put a function without a stable memory reference and templateVersionStatus directly into the dependency array:

  • Function: Every render would create a new reference and destroy the existing connection for no good reason
  • Status: The value can be a lot of things that indicate that we shouldn't watch (including undefined). Plus, if the status ever switches from pending to running, that's a new value for the array, and triggers a re-sync, destroying a perfectly good connection

Best option seemed to be to wrap the callback, and also collapse the status to a boolean


return logs;
};
20 changes: 4 additions & 16 deletions site/src/pages/WorkspacePage/WorkspacePage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import * as apiModule from "api/api";
import type { TemplateVersionParameter, Workspace } from "api/typesGenerated";
import EventSourceMock from "eventsourcemock";
import MockServerSocket from "jest-websocket-mock";
import {
DashboardContext,
type DashboardProvider,
Expand Down Expand Up @@ -84,23 +84,11 @@ const testButton = async (

const user = userEvent.setup();
await user.click(button);
expect(actionMock).toBeCalled();
expect(actionMock).toHaveBeenCalled();
};

let originalEventSource: typeof window.EventSource;

beforeAll(() => {
originalEventSource = window.EventSource;
// mocking out EventSource for SSE
window.EventSource = EventSourceMock;
});

beforeEach(() => {
jest.resetAllMocks();
});

afterAll(() => {
window.EventSource = originalEventSource;
afterEach(() => {
MockServerSocket.clean();
});

describe("WorkspacePage", () => {
Expand Down
29 changes: 18 additions & 11 deletions site/src/pages/WorkspacePage/WorkspacePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { workspaceBuildsKey } from "api/queries/workspaceBuilds";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import type { Workspace } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { displayError } from "components/GlobalSnackbar/utils";
import { Loader } from "components/Loader/Loader";
import { Margins } from "components/Margins/Margins";
import { useEffectEvent } from "hooks/hookPolyfills";
Expand Down Expand Up @@ -82,20 +83,26 @@ export const WorkspacePage: FC = () => {
return;
}

const eventSource = watchWorkspace(workspaceId);

eventSource.addEventListener("data", async (event) => {
const newWorkspaceData = JSON.parse(event.data) as Workspace;
await updateWorkspaceData(newWorkspaceData);
const socket = watchWorkspace(workspaceId);
socket.addEventListener("message", (event) => {
try {
const sse = JSON.parse(event.data);
if (sse.type === "data") {
updateWorkspaceData(sse.data as Workspace);
}
} catch {
displayError(
"Unable to process latest data from the server. Please try refreshing the page.",
);
}
});

eventSource.addEventListener("error", (event) => {
console.error("Error on getting workspace changes.", event);
socket.addEventListener("error", () => {
displayError(
"Unable to get workspace changes. Connection has been closed.",
);
});

return () => {
eventSource.close();
};
return () => socket.close();
}, [updateWorkspaceData, workspaceId]);

// Page statuses
Expand Down
Loading
Loading
0