diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f130d8b606..b39237432e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -4599,6 +4599,12 @@ const docTemplate = `{ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "type": "boolean", + "description": "Disable compression for WebSocket connection", + "name": "no_compression", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 4049028734..2449c7fbb9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -4049,6 +4049,12 @@ "description": "Follow log stream", "name": "follow", "in": "query" + }, + { + "type": "boolean", + "description": "Disable compression for WebSocket connection", + "name": "no_compression", + "in": "query" } ], "responses": { diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 9a5453192c..2c8d894868 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -405,6 +405,7 @@ func (api *API) patchWorkspaceAgentStartupLogs(rw http.ResponseWriter, r *http.R // @Param before query int false "Before log id" // @Param after query int false "After log id" // @Param follow query bool false "Follow log stream" +// @Param no_compression query bool false "Disable compression for WebSocket connection" // @Success 200 {array} codersdk.WorkspaceAgentStartupLog // @Router /workspaceagents/{workspaceagent}/startup-logs [get] func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Request) { @@ -415,6 +416,7 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques logger = api.Logger.With(slog.F("workspace_agent_id", workspaceAgent.ID)) follow = r.URL.Query().Has("follow") afterRaw = r.URL.Query().Get("after") + noCompression = r.URL.Query().Has("no_compression") ) var after int64 @@ -460,7 +462,21 @@ func (api *API) workspaceAgentStartupLogs(rw http.ResponseWriter, r *http.Reques api.WebsocketWaitGroup.Add(1) api.WebsocketWaitMutex.Unlock() defer api.WebsocketWaitGroup.Done() - conn, err := websocket.Accept(rw, r, nil) + + opts := &websocket.AcceptOptions{} + + // Allow client to request no compression. This is useful for buggy + // clients or if there's a client/server incompatibility. This is + // needed with e.g. nhooyr/websocket and Safari (confirmed in 16.5). + // + // See: + // * https://github.com/nhooyr/websocket/issues/218 + // * https://github.com/gobwas/ws/issues/169 + if noCompression { + opts.CompressionMode = websocket.CompressionDisabled + } + + conn, err := websocket.Accept(rw, r, opts) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to accept websocket.", diff --git a/docs/api/agents.md b/docs/api/agents.md index e251737826..d1fa59cdff 100644 --- a/docs/api/agents.md +++ b/docs/api/agents.md @@ -689,12 +689,13 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/sta ### Parameters -| Name | In | Type | Required | Description | -| ---------------- | ----- | ------------ | -------- | ------------------ | -| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | -| `before` | query | integer | false | Before log id | -| `after` | query | integer | false | After log id | -| `follow` | query | boolean | false | Follow log stream | +| Name | In | Type | Required | Description | +| ---------------- | ----- | ------------ | -------- | -------------------------------------------- | +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `before` | query | integer | false | Before log id | +| `after` | query | integer | false | After log id | +| `follow` | query | boolean | false | Follow log stream | +| `no_compression` | query | boolean | false | Disable compression for WebSocket connection | ### Example responses diff --git a/site/src/api/api.ts b/site/src/api/api.ts index f9404d0853..9b0af8f942 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -4,6 +4,7 @@ import * as Types from "./types" import { DeploymentConfig } from "./types" import * as TypesGen from "./typesGenerated" import { delay } from "utils/delay" +import userAgentParser from "ua-parser-js" // Adds 304 for the default axios validateStatus function // https://github.com/axios/axios#handling-errors Check status here @@ -1231,9 +1232,19 @@ export const watchStartupLogs = ( agentId: string, { after, onMessage, onDone, onError }: WatchStartupLogsOptions, ) => { + // WebSocket compression in Safari (confirmed in 16.5) is broken when + // the server sends large messages. The following error is seen: + // + // WebSocket connection to 'wss://.../startup-logs?follow&after=0' failed: The operation couldn’t be completed. Protocol error + // + const noCompression = + userAgentParser(navigator.userAgent).browser.name === "Safari" + ? "&no_compression" + : "" + const proto = location.protocol === "https:" ? "wss:" : "ws:" const socket = new WebSocket( - `${proto}//${location.host}/api/v2/workspaceagents/${agentId}/startup-logs?follow&after=${after}`, + `${proto}//${location.host}/api/v2/workspaceagents/${agentId}/startup-logs?follow&after=${after}${noCompression}`, ) socket.binaryType = "blob" socket.addEventListener("message", (event) => {