Skip to content

Commit bb35d99

Browse files
committed
Add WebSocket handler for Browser and Node
1 parent a4897a6 commit bb35d99

15 files changed

+961
-10
lines changed

config/tsconfig.base.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"es2020",
1010
"esnext.WeakRef",
1111
],
12-
"module": "ES2015",
12+
"module": "ES2020",
1313
"moduleResolution": "node",
1414
"resolveJsonModule": true,
1515
"esModuleInterop": true,

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@
8686
"@types/sinon-chai": "3.2.12",
8787
"@types/tmp": "0.2.6",
8888
"@types/trusted-types": "2.0.7",
89+
"@types/ws": "8.18.1",
8990
"@types/yargs": "17.0.33",
9091
"@typescript-eslint/eslint-plugin": "7.18.0",
9192
"@typescript-eslint/eslint-plugin-tslint": "7.0.2",

packages/ai/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
"test:ci": "yarn testsetup && node ../../scripts/run_tests_in_ci.js -s test",
4040
"test:skip-clone": "karma start",
4141
"test:browser": "yarn testsetup && karma start",
42+
"test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' mocha --require ts-node/register --require src/index.node.ts src/**/*.test.ts --config ../../config/mocharc.node.js",
4243
"test:integration": "karma start --integration",
4344
"api-report": "api-extractor run --local --verbose",
4445
"typings:public": "node ../../scripts/build/use_typings.js ./dist/ai-public.d.ts",
@@ -62,7 +63,8 @@
6263
"rollup": "2.79.2",
6364
"rollup-plugin-replace": "2.2.0",
6465
"rollup-plugin-typescript2": "0.36.0",
65-
"typescript": "5.5.4"
66+
"typescript": "5.5.4",
67+
"ws": "8.18.3"
6668
},
6769
"repository": {
6870
"directory": "packages/ai",

packages/ai/rollup.config.js

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import typescript from 'typescript';
2222
import pkg from './package.json';
2323
import tsconfig from './tsconfig.json';
2424
import { generateBuildTargetReplaceConfig } from '../../scripts/build/rollup_replace_build_target';
25+
import { getEnvironmentReplacements } from '../../scripts/build/rollup_get_environment_replacements';
2526
import { emitModulePackageFile } from '../../scripts/build/rollup_emit_module_package_file';
2627

2728
const deps = Object.keys(
@@ -57,12 +58,14 @@ const browserBuilds = [
5758
plugins: [
5859
...buildPlugins,
5960
replace({
61+
...getEnvironmentReplacements('browser'),
6062
...generateBuildTargetReplaceConfig('esm', 2020),
61-
__PACKAGE_VERSION__: pkg.version
63+
'__PACKAGE_VERSION__': pkg.version
6264
}),
6365
emitModulePackageFile()
6466
],
65-
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
67+
external: id =>
68+
id === 'ws' || deps.some(dep => id === dep || id.startsWith(`${dep}/`))
6669
},
6770
{
6871
input: 'src/index.ts',
@@ -74,11 +77,13 @@ const browserBuilds = [
7477
plugins: [
7578
...buildPlugins,
7679
replace({
80+
...getEnvironmentReplacements('browser'),
7781
...generateBuildTargetReplaceConfig('cjs', 2020),
78-
__PACKAGE_VERSION__: pkg.version
82+
'__PACKAGE_VERSION__': pkg.version
7983
})
8084
],
81-
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
85+
external: id =>
86+
id === 'ws' || deps.some(dep => id === dep || id.startsWith(`${dep}/`))
8287
}
8388
];
8489

@@ -93,10 +98,12 @@ const nodeBuilds = [
9398
plugins: [
9499
...buildPlugins,
95100
replace({
101+
...getEnvironmentReplacements('node'),
96102
...generateBuildTargetReplaceConfig('esm', 2020)
97103
})
98104
],
99-
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
105+
external: id =>
106+
id === 'ws' || deps.some(dep => id === dep || id.startsWith(`${dep}/`))
100107
},
101108
{
102109
input: 'src/index.node.ts',
@@ -108,10 +115,12 @@ const nodeBuilds = [
108115
plugins: [
109116
...buildPlugins,
110117
replace({
118+
...getEnvironmentReplacements('node'),
111119
...generateBuildTargetReplaceConfig('cjs', 2020)
112120
})
113121
],
114-
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
122+
external: id =>
123+
id === 'ws' || deps.some(dep => id === dep || id.startsWith(`${dep}/`))
115124
}
116125
];
117126

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import { expect, use } from 'chai';
19+
import sinon, { SinonFakeTimers, SinonStub } from 'sinon';
20+
import sinonChai from 'sinon-chai';
21+
import chaiAsPromised from 'chai-as-promised';
22+
import { BrowserWebSocketHandler } from './browser-websocket-handler';
23+
import { AIError } from '../errors';
24+
import { isBrowser } from '@firebase/util';
25+
26+
use(sinonChai);
27+
use(chaiAsPromised);
28+
29+
class MockBrowserWebSocket {
30+
static CONNECTING = 0;
31+
static OPEN = 1;
32+
static CLOSING = 2;
33+
static CLOSED = 3;
34+
35+
readyState: number = MockBrowserWebSocket.CONNECTING;
36+
sentMessages: Array<string | ArrayBuffer> = [];
37+
url: string;
38+
private listeners: Map<string, Set<EventListener>> = new Map();
39+
40+
constructor(url: string) {
41+
this.url = url;
42+
}
43+
44+
send(data: string | ArrayBuffer): void {
45+
if (this.readyState !== MockBrowserWebSocket.OPEN) {
46+
throw new Error('WebSocket is not in OPEN state');
47+
}
48+
this.sentMessages.push(data);
49+
}
50+
51+
close(): void {
52+
if (
53+
this.readyState === MockBrowserWebSocket.CLOSED ||
54+
this.readyState === MockBrowserWebSocket.CLOSING
55+
) {
56+
return;
57+
}
58+
this.readyState = MockBrowserWebSocket.CLOSING;
59+
setTimeout(() => {
60+
this.readyState = MockBrowserWebSocket.CLOSED;
61+
this.dispatchEvent(new Event('close'));
62+
}, 10);
63+
}
64+
65+
addEventListener(type: string, listener: EventListener): void {
66+
if (!this.listeners.has(type)) {
67+
this.listeners.set(type, new Set());
68+
}
69+
this.listeners.get(type)!.add(listener);
70+
}
71+
72+
removeEventListener(type: string, listener: EventListener): void {
73+
this.listeners.get(type)?.delete(listener);
74+
}
75+
76+
dispatchEvent(event: Event): void {
77+
this.listeners.get(event.type)?.forEach(listener => listener(event));
78+
}
79+
80+
triggerOpen(): void {
81+
this.readyState = MockBrowserWebSocket.OPEN;
82+
this.dispatchEvent(new Event('open'));
83+
}
84+
85+
triggerMessage(data: any): void {
86+
this.dispatchEvent(new MessageEvent('message', { data }));
87+
}
88+
89+
triggerError(): void {
90+
this.dispatchEvent(new Event('error'));
91+
}
92+
}
93+
94+
describe('BrowserWebSocketHandler', () => {
95+
let handler: BrowserWebSocketHandler;
96+
let mockWebSocket: MockBrowserWebSocket;
97+
let clock: SinonFakeTimers;
98+
let webSocketStub: SinonStub;
99+
100+
// Only run these tests in a browser environment
101+
if (!isBrowser()) {
102+
return;
103+
}
104+
105+
beforeEach(() => {
106+
webSocketStub = sinon.stub(window, 'WebSocket').callsFake((url: string) => {
107+
mockWebSocket = new MockBrowserWebSocket(url);
108+
return mockWebSocket as any;
109+
});
110+
clock = sinon.useFakeTimers();
111+
handler = new BrowserWebSocketHandler();
112+
});
113+
114+
afterEach(() => {
115+
sinon.restore();
116+
clock.restore();
117+
});
118+
119+
describe('connect()', () => {
120+
it('should resolve on open event', async () => {
121+
const connectPromise = handler.connect('ws://test-url');
122+
expect(webSocketStub).to.have.been.calledWith('ws://test-url');
123+
124+
await clock.tickAsync(1);
125+
mockWebSocket.triggerOpen();
126+
127+
await expect(connectPromise).to.be.fulfilled;
128+
});
129+
130+
it('should reject on error event', async () => {
131+
const connectPromise = handler.connect('ws://test-url');
132+
await clock.tickAsync(1);
133+
mockWebSocket.triggerError();
134+
135+
await expect(connectPromise).to.be.rejectedWith(
136+
AIError,
137+
/Failed to establish WebSocket connection/
138+
);
139+
});
140+
});
141+
142+
describe('listen()', () => {
143+
beforeEach(async () => {
144+
const connectPromise = handler.connect('ws://test');
145+
mockWebSocket.triggerOpen();
146+
await connectPromise;
147+
});
148+
149+
it('should yield multiple messages as they arrive', async () => {
150+
const generator = handler.listen();
151+
152+
const received: unknown[] = [];
153+
const listenPromise = (async () => {
154+
for await (const msg of generator) {
155+
received.push(msg);
156+
}
157+
})();
158+
159+
// Use tickAsync to allow the consumer to start listening
160+
await clock.tickAsync(1);
161+
mockWebSocket.triggerMessage(new Blob([JSON.stringify({ foo: 1 })]));
162+
163+
await clock.tickAsync(10);
164+
mockWebSocket.triggerMessage(new Blob([JSON.stringify({ foo: 2 })]));
165+
166+
await clock.tickAsync(5);
167+
mockWebSocket.close();
168+
await clock.runAllAsync(); // Let timers finish
169+
170+
await listenPromise; // Wait for the consumer to finish
171+
172+
expect(received).to.deep.equal([
173+
{
174+
foo: 1
175+
},
176+
{
177+
foo: 2
178+
}
179+
]);
180+
});
181+
182+
it('should buffer messages that arrive before the consumer calls .next()', async () => {
183+
const generator = handler.listen();
184+
185+
// Create a promise that will consume the generator in a separate async context
186+
const received: unknown[] = [];
187+
const consumptionPromise = (async () => {
188+
for await (const message of generator) {
189+
received.push(message);
190+
}
191+
})();
192+
193+
await clock.tickAsync(1);
194+
195+
mockWebSocket.triggerMessage(new Blob([JSON.stringify({ foo: 1 })]));
196+
mockWebSocket.triggerMessage(new Blob([JSON.stringify({ foo: 2 })]));
197+
198+
await clock.tickAsync(1);
199+
mockWebSocket.close();
200+
await clock.runAllAsync();
201+
202+
await consumptionPromise;
203+
204+
expect(received).to.deep.equal([
205+
{
206+
foo: 1
207+
},
208+
{
209+
foo: 2
210+
}
211+
]);
212+
});
213+
});
214+
215+
describe('close()', () => {
216+
it('should be idempotent and not throw if called multiple times', async () => {
217+
const connectPromise = handler.connect('ws://test');
218+
mockWebSocket.triggerOpen();
219+
await connectPromise;
220+
221+
const closePromise1 = handler.close();
222+
await clock.runAllAsync();
223+
await closePromise1;
224+
225+
await expect(handler.close()).to.be.fulfilled;
226+
});
227+
228+
it('should wait for the onclose event before resolving', async () => {
229+
const connectPromise = handler.connect('ws://test');
230+
mockWebSocket.triggerOpen();
231+
await connectPromise;
232+
233+
let closed = false;
234+
const closePromise = handler.close().then(() => {
235+
closed = true;
236+
});
237+
238+
// The promise should not have resolved yet
239+
await clock.tickAsync(5);
240+
expect(closed).to.be.false;
241+
242+
// Now, let the mock's setTimeout for closing run, which triggers onclose
243+
await clock.tickAsync(10);
244+
245+
await expect(closePromise).to.be.fulfilled;
246+
expect(closed).to.be.true;
247+
});
248+
});
249+
250+
describe('Interaction between listen() and close()', () => {
251+
it('should allow close() to take precedence and resolve correctly, while also terminating the listener', async () => {
252+
const connectPromise = handler.connect('ws://test');
253+
mockWebSocket.triggerOpen();
254+
await connectPromise;
255+
256+
const generator = handler.listen();
257+
const listenPromise = (async () => {
258+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
259+
for await (const _ of generator) {
260+
}
261+
})();
262+
263+
const closePromise = handler.close();
264+
265+
await clock.runAllAsync();
266+
267+
await expect(closePromise).to.be.fulfilled;
268+
await expect(listenPromise).to.be.fulfilled;
269+
270+
expect(mockWebSocket.readyState).to.equal(MockBrowserWebSocket.CLOSED);
271+
});
272+
});
273+
});

0 commit comments

Comments
 (0)