Path: blob/main/src/vs/platform/agentHost/browser/webSocketClientTransport.ts
13394 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45// WebSocket client transport for connecting to remote agent host processes.6// Uses plain JSON serialization — URIs are string-typed in the protocol.78import { Emitter } from '../../../base/common/event.js';9import { Disposable } from '../../../base/common/lifecycle.js';10import { connectionTokenQueryName } from '../../../base/common/network.js';11import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../common/state/sessionProtocol.js';12import type { IClientTransport } from '../common/state/sessionTransport.js';13import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../common/transportConstants.js';1415// ---- Client transport -------------------------------------------------------1617/**18* A WebSocket client transport that connects to a remote agent host server.19* Uses the native browser WebSocket API (available in Electron renderer).20* Implements {@link IClientTransport} with JSON serialization and URI revival.21*/22export class WebSocketClientTransport extends Disposable implements IClientTransport {2324private readonly _onMessage = this._register(new Emitter<ProtocolMessage>());25readonly onMessage = this._onMessage.event;2627private readonly _onClose = this._register(new Emitter<void>());28readonly onClose = this._onClose.event;2930private readonly _onOpen = this._register(new Emitter<void>());31readonly onOpen = this._onOpen.event;3233private _ws: WebSocket | undefined;34private _malformedFrames = 0;3536/** Guards against firing onClose more than once. */37private _closeFired = false;3839get isOpen(): boolean {40return this._ws?.readyState === WebSocket.OPEN;41}4243constructor(44private readonly _address: string,45private readonly _connectionToken?: string,46) {47// TODO: @osortega remove console.logs48super();49}5051/**52* Initiate the WebSocket connection. Resolves when the connection53* is open, or rejects on error/timeout.54*/55connect(): Promise<void> {56return new Promise<void>((resolve, reject) => {57if (this._store.isDisposed) {58reject(new Error('Transport is disposed'));59return;60}6162let url = this._address.startsWith('ws://') || this._address.startsWith('wss://')63? this._address64: `ws://${this._address}`;6566if (this._connectionToken) {67const separator = url.includes('?') ? '&' : '?';68url += `${separator}${connectionTokenQueryName}=${encodeURIComponent(this._connectionToken)}`;69}7071const ws = new WebSocket(url);72this._ws = ws;7374const onOpen = () => {75cleanup();76this._onOpen.fire();77resolve();78};7980const onError = () => {81cleanup();82reject(new Error(`WebSocket connection failed: ${this._address}`));83};8485const onClose = () => {86cleanup();87reject(new Error(`WebSocket closed before connection was established: ${this._address}`));88};8990const cleanup = () => {91ws.removeEventListener('open', onOpen);92ws.removeEventListener('error', onError);93ws.removeEventListener('close', onClose);94};9596ws.addEventListener('open', onOpen);97ws.addEventListener('error', onError);98ws.addEventListener('close', onClose);99100// Wire up long-lived listeners after connection101ws.addEventListener('message', (event: MessageEvent) => {102if (typeof event.data !== 'string') {103this._malformedFrames++;104if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {105const dataType = event.data instanceof ArrayBuffer ? 'ArrayBuffer' : event.data instanceof Blob ? 'Blob' : typeof event.data;106const byteLen = event.data instanceof ArrayBuffer ? event.data.byteLength : event.data instanceof Blob ? event.data.size : 0;107console.warn(108`[WebSocketClientTransport] Non-string frame #${this._malformedFrames} (type=${dataType}, bytes=${byteLen})`109);110}111if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {112console.warn(113`[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.`114);115this._ws?.close(4002, 'malformed-frames');116}117return;118}119const text = event.data;120let message: ProtocolMessage;121try {122message = JSON.parse(text) as ProtocolMessage;123} catch (err) {124this._malformedFrames++;125if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) {126const preview = text.length > 80 ? text.slice(0, 80) + '…' : text;127console.warn(128`[WebSocketClientTransport] Malformed frame #${this._malformedFrames} (len=${text.length}): ${preview}`,129err instanceof Error ? err.message : String(err)130);131}132if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) {133console.warn(134`[WebSocketClientTransport] Malformed frame threshold exceeded; forcing close of ${this._address}.`135);136this._ws?.close(4002, 'malformed-frames');137}138return;139}140this._onMessage.fire(message);141});142143ws.addEventListener('close', () => {144if (!this._closeFired) {145this._closeFired = true;146this._onClose.fire();147}148});149150ws.addEventListener('error', () => {151// Error always precedes close - closing is handled in the close handler.152// Only fire if close hasn't already been fired (e.g. from send failure).153if (!this._closeFired) {154this._closeFired = true;155this._onClose.fire();156}157});158});159}160161/**162* Send a message to the remote end. Returns `true` if the message was163* sent, `false` if it was dropped (socket not open). On failure, the164* transport is force-closed so reconnection is triggered immediately165* rather than silently losing messages.166*/167send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): boolean {168if (this._ws?.readyState === WebSocket.OPEN) {169this._ws.send(JSON.stringify(message));170return true;171}172console.warn(173`[WebSocketClientTransport] Message dropped: readyState=${this._ws?.readyState ?? 'no-socket'}`174);175// Force-close and fire onClose exactly once to trigger reconnection176this._ws?.close(4001, 'send-on-dead-socket');177if (!this._closeFired) {178this._closeFired = true;179this._onClose.fire();180}181return false;182}183184override dispose(): void {185this._ws?.close();186super.dispose();187}188}189190191