@@ -6,17 +6,18 @@ import {
66} from "axios" ;
77import { Api } from "coder/site/src/api/api" ;
88import {
9+ type ServerSentEvent ,
910 type GetInboxNotificationResponse ,
1011 type ProvisionerJobLog ,
11- type ServerSentEvent ,
1212 type Workspace ,
1313 type WorkspaceAgent ,
1414} from "coder/site/src/api/typesGenerated" ;
1515import * as vscode from "vscode" ;
16- import { type ClientOptions } from "ws" ;
16+ import { type ClientOptions , type CloseEvent , type ErrorEvent } from "ws" ;
1717
1818import { CertificateError } from "../error" ;
1919import { getHeaderCommand , getHeaders } from "../headers" ;
20+ import { EventStreamLogger } from "../logging/eventStreamLogger" ;
2021import {
2122 createRequestMeta ,
2223 logRequest ,
@@ -29,11 +30,12 @@ import {
2930 HttpClientLogLevel ,
3031} from "../logging/types" ;
3132import { sizeOf } from "../logging/utils" ;
32- import { WsLogger } from "../logging/wsLogger " ;
33+ import { type UnidirectionalStream } from "../websocket/eventStreamConnection " ;
3334import {
3435 OneWayWebSocket ,
3536 type OneWayWebSocketInit ,
3637} from "../websocket/oneWayWebSocket" ;
38+ import { SseConnection } from "../websocket/sseConnection" ;
3739
3840import { createHttpAgent } from "./utils" ;
3941
@@ -84,8 +86,9 @@ export class CoderApi extends Api {
8486 } ;
8587
8688 watchWorkspace = async ( workspace : Workspace , options ?: ClientOptions ) => {
87- return this . createWebSocket < ServerSentEvent > ( {
89+ return this . createWebSocketWithFallback < ServerSentEvent > ( {
8890 apiRoute : `/api/v2/workspaces/${ workspace . id } /watch-ws` ,
91+ fallbackApiRoute : `/api/v2/workspaces/${ workspace . id } /watch` ,
8992 options,
9093 } ) ;
9194 } ;
@@ -94,8 +97,9 @@ export class CoderApi extends Api {
9497 agentId : WorkspaceAgent [ "id" ] ,
9598 options ?: ClientOptions ,
9699 ) => {
97- return this . createWebSocket < ServerSentEvent > ( {
100+ return this . createWebSocketWithFallback < ServerSentEvent > ( {
98101 apiRoute : `/api/v2/workspaceagents/${ agentId } /watch-metadata-ws` ,
102+ fallbackApiRoute : `/api/v2/workspaceagents/${ agentId } /watch-metadata` ,
99103 options,
100104 } ) ;
101105 } ;
@@ -137,6 +141,7 @@ export class CoderApi extends Api {
137141 const httpAgent = await createHttpAgent (
138142 vscode . workspace . getConfiguration ( ) ,
139143 ) ;
144+
140145 const webSocket = new OneWayWebSocket < TData > ( {
141146 location : baseUrl ,
142147 ...configs ,
@@ -152,28 +157,123 @@ export class CoderApi extends Api {
152157 } ,
153158 } ) ;
154159
155- const wsUrl = new URL ( webSocket . url ) ;
156- const pathWithQuery = wsUrl . pathname + wsUrl . search ;
157- const wsLogger = new WsLogger ( this . output , pathWithQuery ) ;
158- wsLogger . logConnecting ( ) ;
160+ this . attachStreamLogger ( webSocket ) ;
161+ return webSocket ;
162+ }
159163
160- webSocket . addEventListener ( "open" , ( ) => {
161- wsLogger . logOpen ( ) ;
162- } ) ;
164+ private attachStreamLogger < TData > (
165+ connection : UnidirectionalStream < TData > ,
166+ ) : void {
167+ const url = new URL ( connection . url ) ;
168+ const logger = new EventStreamLogger (
169+ this . output ,
170+ url . pathname + url . search ,
171+ url . protocol . startsWith ( "http" ) ? "SSE" : "WS" ,
172+ ) ;
173+ logger . logConnecting ( ) ;
163174
164- webSocket . addEventListener ( "message" , ( event ) => {
165- wsLogger . logMessage ( event . sourceEvent . data ) ;
166- } ) ;
175+ connection . addEventListener ( "open" , ( ) => logger . logOpen ( ) ) ;
176+ connection . addEventListener ( "close" , ( event : CloseEvent ) =>
177+ logger . logClose ( event . code , event . reason ) ,
178+ ) ;
179+ connection . addEventListener ( "error" , ( event : ErrorEvent ) =>
180+ logger . logError ( event . error , event . message ) ,
181+ ) ;
182+ connection . addEventListener ( "message" , ( event ) =>
183+ logger . logMessage ( event . sourceEvent . data ) ,
184+ ) ;
185+ }
186+
187+ /**
188+ * Create a WebSocket connection with SSE fallback on 404
189+ */
190+ private async createWebSocketWithFallback < TData = unknown > ( configs : {
191+ apiRoute : string ;
192+ fallbackApiRoute : string ;
193+ searchParams ?: Record < string , string > | URLSearchParams ;
194+ options ?: ClientOptions ;
195+ } ) : Promise < UnidirectionalStream < TData > > {
196+ let webSocket : OneWayWebSocket < TData > ;
197+ try {
198+ webSocket = await this . createWebSocket < TData > ( {
199+ apiRoute : configs . apiRoute ,
200+ searchParams : configs . searchParams ,
201+ options : configs . options ,
202+ } ) ;
203+ } catch {
204+ // Failed to create WebSocket, use SSE fallback
205+ return this . createSseFallback < TData > (
206+ configs . fallbackApiRoute ,
207+ configs . searchParams ,
208+ ) ;
209+ }
167210
168- webSocket . addEventListener ( "close" , ( event ) => {
169- wsLogger . logClose ( event . code , event . reason ) ;
211+ return this . waitForConnection ( webSocket , ( ) =>
212+ this . createSseFallback < TData > (
213+ configs . fallbackApiRoute ,
214+ configs . searchParams ,
215+ ) ,
216+ ) ;
217+ }
218+
219+ private waitForConnection < TData > (
220+ connection : UnidirectionalStream < TData > ,
221+ onNotFound ?: ( ) => Promise < UnidirectionalStream < TData > > ,
222+ ) : Promise < UnidirectionalStream < TData > > {
223+ return new Promise ( ( resolve , reject ) => {
224+ const cleanup = ( ) => {
225+ connection . removeEventListener ( "open" , handleOpen ) ;
226+ connection . removeEventListener ( "error" , handleError ) ;
227+ } ;
228+
229+ const handleOpen = ( ) => {
230+ cleanup ( ) ;
231+ resolve ( connection ) ;
232+ } ;
233+
234+ const handleError = ( event : ErrorEvent ) => {
235+ cleanup ( ) ;
236+ const is404 =
237+ event . message ?. includes ( "404" ) ||
238+ event . error ?. message ?. includes ( "404" ) ;
239+
240+ if ( is404 && onNotFound ) {
241+ connection . close ( ) ;
242+ onNotFound ( ) . then ( resolve ) . catch ( reject ) ;
243+ } else {
244+ reject ( event . error || new Error ( event . message ) ) ;
245+ }
246+ } ;
247+
248+ connection . addEventListener ( "open" , handleOpen ) ;
249+ connection . addEventListener ( "error" , handleError ) ;
170250 } ) ;
251+ }
171252
172- webSocket . addEventListener ( "error" , ( event ) => {
173- wsLogger . logError ( event . error , event . message ) ;
253+ /**
254+ * Create SSE fallback connection
255+ */
256+ private async createSseFallback < TData = unknown > (
257+ apiRoute : string ,
258+ searchParams ?: Record < string , string > | URLSearchParams ,
259+ ) : Promise < UnidirectionalStream < TData > > {
260+ this . output . warn ( `WebSocket failed, using SSE fallback: ${ apiRoute } ` ) ;
261+
262+ const baseUrlRaw = this . getAxiosInstance ( ) . defaults . baseURL ;
263+ if ( ! baseUrlRaw ) {
264+ throw new Error ( "No base URL set on REST client" ) ;
265+ }
266+
267+ const baseUrl = new URL ( baseUrlRaw ) ;
268+ const sseConnection = new SseConnection ( {
269+ location : baseUrl ,
270+ apiRoute,
271+ searchParams,
272+ axiosInstance : this . getAxiosInstance ( ) ,
174273 } ) ;
175274
176- return webSocket ;
275+ this . attachStreamLogger ( sseConnection ) ;
276+ return this . waitForConnection ( sseConnection ) ;
177277 }
178278}
179279
0 commit comments