From 11ddf3a0c2e2f1147313685949f20ff9bae89225 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:54:15 +0200 Subject: [PATCH] fix(core): open Bun WebSockets without permessage-deflate --- packages/core/src/browser/cdp.ts | 3 ++- packages/core/src/client.ts | 15 +++++++++--- packages/core/src/group/relay.ts | 3 ++- packages/core/test/browser/fixture.ts | 4 ++-- packages/core/test/socket-options.test.ts | 28 +++++++++++++++++++++++ tests/e2e/group-devices.test.ts | 4 ++-- tests/e2e/lib/cdp.ts | 3 ++- 7 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 packages/core/test/socket-options.test.ts diff --git a/packages/core/src/browser/cdp.ts b/packages/core/src/browser/cdp.ts index c955eadd6..b3185cd6c 100644 --- a/packages/core/src/browser/cdp.ts +++ b/packages/core/src/browser/cdp.ts @@ -10,6 +10,7 @@ */ import { fstatSync } from 'node:fs'; +import { SOCKET_OPTIONS } from '../client.ts'; type Listener = (params: Record, sessionId: string | undefined) => void; interface Waiting { resolve(value: Record): void; reject(error: Error): void; timer: ReturnType; method: string } @@ -94,7 +95,7 @@ export class Cdp { /** The Windows transport: the browser's WebSocket endpoint on 127.0.0.1. */ static connect(url: string, timeoutMs = 10_000): Promise { return new Promise((resolve, reject) => { - const socket = new WebSocket(url); + const socket = new WebSocket(url, SOCKET_OPTIONS as unknown as string[]); const timer = setTimeout(() => { socket.close(); reject(new CdpError(`the browser did not accept a DevTools connection within ${timeoutMs / 1000} seconds`)); }, timeoutMs); socket.addEventListener('error', () => { clearTimeout(timer); reject(new CdpError(`could not connect to the browser's DevTools at ${url}`)); }, { once: true }); socket.addEventListener('open', () => { diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 8b2567b58..286c05930 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -81,12 +81,21 @@ export class SocketClosedError extends Error { } } +/** + * What every WebSocket this code opens with Bun's client passes: no + * `permessage-deflate` offer. With the extension on, Bun 1.4.2 and 1.4.3 + * sometimes close a sound stream with 1002 ("control frame is fragmented", + * "unsupported control frame", "unexpected opcode"): against the core's own + * server and against Chrome's DevTools, whose bytes a relay checked frame by + * frame and found well-formed. Uncompressed, a message is larger on the wire; + * compressed, the connection drops. + */ +export const SOCKET_OPTIONS = { perMessageDeflate: false } as const; + export async function connect(url: string, token: string, options: ConnectOptions = {}): Promise { const timeoutMs = options.timeoutMs ?? 5000; const deadline = Date.now() + timeoutMs; - const socket = options.headers === undefined - ? new WebSocket(socketUrl(url)) - : new WebSocket(socketUrl(url), { headers: options.headers } as unknown as string[]); + const socket = new WebSocket(socketUrl(url), { ...SOCKET_OPTIONS, ...(options.headers === undefined ? {} : { headers: options.headers }) } as unknown as string[]); const pending = new Map(); const listeners = new Map void>>(); let nextId = 1; diff --git a/packages/core/src/group/relay.ts b/packages/core/src/group/relay.ts index afd1b2c9f..203fc27c2 100644 --- a/packages/core/src/group/relay.ts +++ b/packages/core/src/group/relay.ts @@ -29,6 +29,7 @@ import type { ServerWebSocket } from 'bun'; import { FILE_ROUTE, GROUP_RELAY_ROUTE, PROTOCOL_VERSION, RPC_PATH, RpcErrorCode, VIEW_ROUTE } from '@boite/contracts'; +import { SOCKET_OPTIONS } from '../client.ts'; import type { Core } from '../core.ts'; import { messageOf } from '../errors.ts'; import { OUTBOUND_CONGESTION_TIMEOUT_MS, OUTBOUND_MAX_BUFFERED_BYTES } from '../server/connection.ts'; @@ -388,7 +389,7 @@ export class RelayPipe { const opened = await new Promise((resolve) => { let socket: Upstream; try { - socket = new WebSocket(`${target.replace(/^http/, 'ws')}${RPC_PATH}`); + socket = new WebSocket(`${target.replace(/^http/, 'ws')}${RPC_PATH}`, SOCKET_OPTIONS as unknown as string[]); } catch { resolve(null); return; diff --git a/packages/core/test/browser/fixture.ts b/packages/core/test/browser/fixture.ts index 03d59b68b..8bd8a837c 100644 --- a/packages/core/test/browser/fixture.ts +++ b/packages/core/test/browser/fixture.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { RemoteBrowserFrame, RemoteBrowserUnchanged } from '@boite/contracts'; -import { connect, type CoreClient } from '../../src/client.ts'; +import { connect, SOCKET_OPTIONS, type CoreClient } from '../../src/client.ts'; import { chromiumArgs, findChromium, waitForEndpoint } from '../../src/browser/chromium.ts'; import { echoThread, startTestCore, type TestCore } from '../harness.ts'; @@ -148,7 +148,7 @@ export function createBrowserFixture() { try { const args = chromiumArgs(dir, 'linux').filter(arg => arg !== '--remote-debugging-pipe'); browser = Bun.spawn([findChromium().path!, ...args, '--remote-debugging-port=0', '--remote-debugging-address=127.0.0.1', ...extraArgs], { stdout: 'ignore', stderr: 'ignore' }); - const connectedSocket = socket = new WebSocket(await waitForEndpoint(dir, browser.exited)); + const connectedSocket = socket = new WebSocket(await waitForEndpoint(dir, browser.exited), SOCKET_OPTIONS as unknown as string[]); await new Promise((resolve, reject) => { const finish = (error?: Error) => { clearTimeout(timer); diff --git a/packages/core/test/socket-options.test.ts b/packages/core/test/socket-options.test.ts new file mode 100644 index 000000000..10fa3fa75 --- /dev/null +++ b/packages/core/test/socket-options.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'bun:test'; +import { Cdp } from '../src/browser/cdp.ts'; +import { connect } from '../src/client.ts'; + +// Bun's client with permessage-deflate on sometimes closes a sound stream with +// 1002 (see SOCKET_OPTIONS): no socket this code opens may offer it. +test('the core client and the DevTools socket never offer permessage-deflate', async () => { + const offers: Array = []; + const server = Bun.serve({ + hostname: '127.0.0.1', port: 0, + fetch(request, self) { + offers.push(request.headers.get('sec-websocket-extensions')); + return self.upgrade(request) ? undefined : new Response('upgrade required'); + }, + websocket: { perMessageDeflate: true, message() {} }, + }); + try { + const url = `http://127.0.0.1:${server.port}`; + for (const headers of [undefined, { origin: url }]) { + await expect(connect(url, 'token', { timeoutMs: 200, headers })).rejects.toThrow(); + } + const cdp = await Cdp.connect(`ws://127.0.0.1:${server.port}/devtools/browser/test`); + cdp.close(); + expect(offers).toEqual([null, null, null]); + } finally { + server.stop(true); + } +}); diff --git a/tests/e2e/group-devices.test.ts b/tests/e2e/group-devices.test.ts index e978aca31..2c28d5968 100644 --- a/tests/e2e/group-devices.test.ts +++ b/tests/e2e/group-devices.test.ts @@ -8,7 +8,7 @@ import { startUi } from './lib/ui.ts'; import { startCore, type RunningCore } from './lib/core.ts'; import { mobileAction } from './lib/mobile.ts'; import { codeVideo } from './lib/qr-video.ts'; -import { connect, type CoreClient } from '../../packages/core/src/client.ts'; +import { connect, SOCKET_OPTIONS, type CoreClient } from '../../packages/core/src/client.ts'; /** * A phone is a device of the group, not of one machine. @@ -71,7 +71,7 @@ function proxy(core: RunningCore): ReturnType { websocket: { open(socket: ServerWebSocket) { const headers: Record = { host: socket.data.host, ...(socket.data.origin === null ? {} : { origin: socket.data.origin }) }; - const upstream = new WebSocket(`${core.url.replace(/^http/, 'ws')}${socket.data.path}`, { headers } as unknown as string[]); + const upstream = new WebSocket(`${core.url.replace(/^http/, 'ws')}${socket.data.path}`, { ...SOCKET_OPTIONS, headers } as unknown as string[]); upstream.binaryType = 'arraybuffer'; socket.data.upstream = upstream; upstream.onopen = () => { diff --git a/tests/e2e/lib/cdp.ts b/tests/e2e/lib/cdp.ts index f03659017..3bb2883c8 100644 --- a/tests/e2e/lib/cdp.ts +++ b/tests/e2e/lib/cdp.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { ONBOARDING_STORAGE_KEY, ONBOARDING_VERSION } from '../../../packages/ui/src/lib/onboarding.ts'; import type { Subprocess } from 'bun'; +import { SOCKET_OPTIONS } from '../../../packages/core/src/client.ts'; import { E2E_DIR_PREFIX, killProcessTreeAsync, removeDirectory, watchParentDeath, type ParentDeathWatch } from './cleanup.ts'; /** @@ -710,7 +711,7 @@ function openSocket(target: TargetInfo): Promise { const url = target.webSocketDebuggerUrl; if (url === undefined) throw new Error('the page target has no debugger url'); return new Promise((resolve, reject) => { - const socket = new WebSocket(url); + const socket = new WebSocket(url, SOCKET_OPTIONS as unknown as string[]); // A socket that never opened is closed with its refusal: nothing else // holds it, and a late open would keep its connection for the whole run. const fail = (message: string) => {