@react-native-harness/bridge 1.2.0 → 1.4.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/__tests__/client.test.d.ts +2 -0
  2. package/dist/__tests__/client.test.d.ts.map +1 -0
  3. package/dist/__tests__/client.test.js +81 -0
  4. package/dist/__tests__/heartbeat.test.d.ts +2 -0
  5. package/dist/__tests__/heartbeat.test.d.ts.map +1 -0
  6. package/dist/__tests__/heartbeat.test.js +37 -0
  7. package/dist/__tests__/protocol.test.d.ts +2 -0
  8. package/dist/__tests__/protocol.test.d.ts.map +1 -0
  9. package/dist/__tests__/protocol.test.js +37 -0
  10. package/dist/__tests__/rpc-peer.test.d.ts +2 -0
  11. package/dist/__tests__/rpc-peer.test.d.ts.map +1 -0
  12. package/dist/__tests__/rpc-peer.test.js +127 -0
  13. package/dist/client.d.ts +7 -14
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/client.js +96 -42
  16. package/dist/errors.d.ts +5 -0
  17. package/dist/errors.d.ts.map +1 -1
  18. package/dist/errors.js +23 -0
  19. package/dist/heartbeat.d.ts +13 -0
  20. package/dist/heartbeat.d.ts.map +1 -0
  21. package/dist/heartbeat.js +49 -0
  22. package/dist/index.d.ts +1 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -0
  25. package/dist/protocol.d.ts +51 -0
  26. package/dist/protocol.d.ts.map +1 -0
  27. package/dist/protocol.js +132 -0
  28. package/dist/rpc-peer.d.ts +28 -0
  29. package/dist/rpc-peer.d.ts.map +1 -0
  30. package/dist/rpc-peer.js +146 -0
  31. package/dist/server.d.ts +1 -20
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +133 -67
  34. package/dist/shared/test-collector.d.ts +7 -3
  35. package/dist/shared/test-collector.d.ts.map +1 -1
  36. package/dist/shared/test-context.d.ts +21 -0
  37. package/dist/shared/test-context.d.ts.map +1 -0
  38. package/dist/shared/test-context.js +1 -0
  39. package/dist/shared/test-runner.d.ts +13 -0
  40. package/dist/shared/test-runner.d.ts.map +1 -1
  41. package/dist/shared.d.ts +2 -3
  42. package/dist/shared.d.ts.map +1 -1
  43. package/dist/transport.d.ts +22 -0
  44. package/dist/transport.d.ts.map +1 -0
  45. package/dist/transport.js +22 -0
  46. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  47. package/dist/websocket-client-transport.d.ts +3 -0
  48. package/dist/websocket-client-transport.d.ts.map +1 -0
  49. package/dist/websocket-client-transport.js +45 -0
  50. package/dist/websocket-server-transport.d.ts +4 -0
  51. package/dist/websocket-server-transport.d.ts.map +1 -0
  52. package/dist/websocket-server-transport.js +43 -0
  53. package/eslint.config.mjs +5 -1
  54. package/package.json +6 -5
  55. package/src/__tests__/client.test.ts +99 -0
  56. package/src/__tests__/heartbeat.test.ts +47 -0
  57. package/src/__tests__/protocol.test.ts +51 -0
  58. package/src/__tests__/rpc-peer.test.ts +181 -0
  59. package/src/client.ts +146 -57
  60. package/src/errors.ts +32 -0
  61. package/src/heartbeat.ts +67 -0
  62. package/src/index.ts +1 -0
  63. package/src/protocol.ts +233 -0
  64. package/src/rpc-peer.ts +222 -0
  65. package/src/server.ts +191 -109
  66. package/src/shared/test-collector.ts +10 -3
  67. package/src/shared/test-context.ts +21 -0
  68. package/src/shared/test-runner.ts +14 -0
  69. package/src/shared.ts +6 -2
  70. package/src/transport.ts +47 -0
  71. package/src/websocket-client-transport.ts +85 -0
  72. package/src/websocket-server-transport.ts +54 -0
  73. package/vite.config.ts +18 -0
package/src/server.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { WebSocketServer, type WebSocket } from 'ws';
2
- import { createBirpc, type BirpcReturn } from 'birpc';
3
2
  import { EventEmitter } from 'node:events';
4
3
  import type { Server as HttpServer } from 'node:http';
5
4
  import type { Server as HttpsServer } from 'node:https';
@@ -9,9 +8,19 @@ import path from 'node:path';
9
8
  import { randomUUID } from 'node:crypto';
10
9
  import { logger } from '@react-native-harness/tools';
11
10
  import { BinaryStore, parseBinaryFrame } from './binary-transfer.js';
12
- import { deserialize, serialize } from './serializer.js';
13
- import { DeviceNotRespondingError } from './errors.js';
11
+ import {
12
+ AppBridgeDisconnectedError,
13
+ DeviceNotRespondingError,
14
+ } from './errors.js';
15
+ import { createHeartbeat } from './heartbeat.js';
14
16
  import { matchImageSnapshot } from './image-snapshot.js';
17
+ import { serializeBridgeMessage } from './protocol.js';
18
+ import { createRpcPeer } from './rpc-peer.js';
19
+ import {
20
+ createRpcTransport,
21
+ type BridgeTransport,
22
+ } from './transport.js';
23
+ import { createNodeWebSocketTransport } from './websocket-server-transport.js';
15
24
  import type {
16
25
  BridgeServerFunctions,
17
26
  BridgeClientFunctions,
@@ -24,29 +33,22 @@ import type {
24
33
  TestSuiteResult,
25
34
  } from './shared.js';
26
35
 
27
- export { DeviceNotRespondingError } from './errors.js';
36
+ export {
37
+ AppBridgeDisconnectedError,
38
+ DeviceNotRespondingError,
39
+ } from './errors.js';
28
40
 
29
41
  const bridgeLogger = logger.child('bridge');
42
+ const noop = (): void => undefined;
30
43
 
31
- // ---------------------------------------------------------------------------
32
- // Public types
33
- // ---------------------------------------------------------------------------
34
-
35
- /**
36
- * Represents a single app session — one app launch to the next restart.
37
- * Obtained via HarnessBridge.nextConnection().
38
- */
39
44
  export type AppConnection = {
40
45
  readonly device: DeviceDescriptor;
41
46
  runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
42
47
  };
43
48
 
44
49
  export type HarnessBridgeEvents = {
45
- /** Fired when the app connects and calls reportReady. */
46
50
  connected: (connection: AppConnection) => void;
47
- /** Fired when the app's WebSocket closes. */
48
51
  disconnected: () => void;
49
- /** Fired for every test/bundler event the app emits. */
50
52
  event: (event: BridgeEvents) => void;
51
53
  };
52
54
 
@@ -60,31 +62,15 @@ export type HarnessBridgeOptions = TransportOptions & {
60
62
  context: HarnessContext;
61
63
  };
62
64
 
63
- /**
64
- * The persistent CLI-side bridge. Spans the full test run regardless of how
65
- * many times the app is restarted. Each restart produces a new AppConnection
66
- * via nextConnection().
67
- */
68
65
  export type HarnessBridge = {
69
- /** The underlying WebSocket server, used to attach to Metro's HTTP server. */
70
66
  readonly ws: WebSocketServer;
71
- /** The currently active app connection, null if the app is not connected. */
72
67
  readonly connection: AppConnection | null;
73
- /**
74
- * Resolves with the next AppConnection once the app connects and reports
75
- * ready. Register this waiter before restarting the app so no ready signal
76
- * is missed. Rejects if the supplied signal is aborted.
77
- */
78
68
  nextConnection: (signal?: AbortSignal) => Promise<AppConnection>;
79
69
  on: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
80
70
  off: <T extends keyof HarnessBridgeEvents>(event: T, listener: HarnessBridgeEvents[T]) => void;
81
71
  dispose: () => void;
82
72
  };
83
73
 
84
- // ---------------------------------------------------------------------------
85
- // Helpers
86
- // ---------------------------------------------------------------------------
87
-
88
74
  const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
89
75
  if ('port' in transport) {
90
76
  return new Promise<WebSocketServer>((resolve) => {
@@ -94,6 +80,7 @@ const createWss = (transport: TransportOptions): Promise<WebSocketServer> => {
94
80
  );
95
81
  });
96
82
  }
83
+
97
84
  return Promise.resolve<WebSocketServer>(
98
85
  new WebSocketServer(
99
86
  'server' in transport
@@ -113,107 +100,193 @@ const receiveScreenshot = async (
113
100
  `Binary data for transfer ${reference.transferId} not found or expired`,
114
101
  );
115
102
  }
103
+
116
104
  binaryStore.delete(reference.transferId);
117
105
  const file = path.join(os.tmpdir(), `harness-screenshot-${randomUUID()}.png`);
118
106
  await fs.writeFile(file, data);
119
107
  return { path: file };
120
108
  };
121
109
 
122
- // ---------------------------------------------------------------------------
123
- // Factory
124
- // ---------------------------------------------------------------------------
125
-
126
110
  export const createHarnessBridge = async (
127
111
  options: HarnessBridgeOptions,
128
112
  ): Promise<HarnessBridge> => {
129
- const { timeout, context, ...transport } = options;
130
- const wss = await createWss(transport);
113
+ const { timeout, context, ...transportOptions } = options;
114
+ const wss = await createWss(transportOptions);
131
115
  bridgeLogger.debug('bridge server ready');
132
116
 
133
117
  const emitter = new EventEmitter();
134
118
  let currentConnection: AppConnection | null = null;
119
+ let activeSession: { disconnect: (reason?: Error) => void; transport: BridgeTransport } | null =
120
+ null;
135
121
  const connectionWaiters: Array<{
136
122
  resolve: (c: AppConnection) => void;
137
123
  reject: (e: unknown) => void;
138
124
  }> = [];
139
125
 
140
126
  wss.on('connection', (ws: WebSocket) => {
127
+ if (activeSession) {
128
+ bridgeLogger.info('replacing existing app connection with a newer client');
129
+ activeSession.disconnect(new AppBridgeDisconnectedError('app-replaced'));
130
+ }
131
+
141
132
  bridgeLogger.debug('app connected');
133
+ const transport = createNodeWebSocketTransport(ws);
142
134
  const binaryStore = new BinaryStore();
135
+ let readyConnection: AppConnection | null = null;
136
+ let disconnected = false;
137
+ let offMessage: () => void = noop;
138
+ let offClose: () => void = noop;
139
+ let offError: () => void = noop;
143
140
 
144
- const serverFunctions: BridgeServerFunctions = {
145
- reportReady: (device) => {
146
- const conn: AppConnection = {
147
- device,
148
- runTests: (testPath, opts) => rpc.runTests(testPath, opts),
149
- };
150
- currentConnection = conn;
151
- bridgeLogger.debug(
152
- 'app ready: platform=%s model=%s',
153
- device.platform,
154
- device.model,
155
- );
156
- emitter.emit('connected', conn);
157
- for (const { resolve } of connectionWaiters.splice(0)) resolve(conn);
141
+ const closeTransport = () => {
142
+ if (transport.state === 'closing' || transport.state === 'closed') {
143
+ return;
144
+ }
145
+
146
+ transport.close(1012);
147
+ };
148
+
149
+ const rpc = createRpcPeer<
150
+ BridgeServerFunctions,
151
+ BridgeClientFunctions,
152
+ BridgeEvents
153
+ >({
154
+ localMethods: {
155
+ 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
156
+ 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
157
+ matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
158
158
  },
159
- emitEvent: (_, data) => {
160
- emitter.emit('event', data);
159
+ transport: createRpcTransport(transport),
160
+ onEvent: (event) => {
161
+ emitter.emit('event', event);
161
162
  },
162
- 'device.screenshot.receive': (ref) => receiveScreenshot(binaryStore, ref),
163
- 'test.matchImageSnapshot': (screenshot, testPath, opts) =>
164
- matchImageSnapshot(screenshot, testPath, opts, context.platform.name),
165
- };
163
+ callTimeoutMs: timeout,
164
+ createTimeoutError: (functionName, args) => {
165
+ return new DeviceNotRespondingError(functionName, args) as unknown as Error;
166
+ },
167
+ });
166
168
 
167
- const rpc: BirpcReturn<BridgeClientFunctions, BridgeServerFunctions> = createBirpc<BridgeClientFunctions, BridgeServerFunctions>(
168
- serverFunctions,
169
- {
170
- post: (data) => ws.send(data),
171
- on: (handler) => {
172
- ws.on(
173
- 'message',
174
- (msg: Buffer | ArrayBuffer | Buffer[], isBinary: boolean) => {
175
- if (isBinary) {
176
- try {
177
- const messageBuffer = Array.isArray(msg)
178
- ? Buffer.concat(msg)
179
- : Buffer.isBuffer(msg)
180
- ? msg
181
- : Buffer.from(msg);
182
- const { transferId, data } = parseBinaryFrame(
183
- new Uint8Array(messageBuffer),
184
- );
185
- binaryStore.add(transferId, data);
186
- } catch (err) {
187
- bridgeLogger.warn('failed to parse binary frame: %s', err);
188
- }
189
- } else {
190
- handler(msg.toString());
191
- }
192
- },
193
- );
194
- },
195
- serialize,
196
- deserialize,
197
- timeout,
198
- onFunctionError: (error, functionName, args) => {
199
- bridgeLogger.error(
200
- 'rpc function failed: %s args=%o',
201
- functionName,
202
- args,
203
- );
204
- throw error;
205
- },
206
- onTimeoutError: (fn, args) => {
207
- throw new DeviceNotRespondingError(fn, args);
208
- },
169
+ const heartbeat = createHeartbeat({
170
+ sendPing: (id) => {
171
+ transport.send(serializeBridgeMessage({ type: 'ping', id }));
209
172
  },
210
- );
173
+ onTimeout: () => {
174
+ bridgeLogger.warn('app heartbeat timed out');
175
+ disconnect(new AppBridgeDisconnectedError('heartbeat-timeout'));
176
+ },
177
+ });
178
+
179
+ const disconnect = (reason?: Error) => {
180
+ if (disconnected) {
181
+ return;
182
+ }
211
183
 
212
- ws.on('close', () => {
184
+ disconnected = true;
185
+ offMessage();
186
+ offClose();
187
+ offError();
213
188
  bridgeLogger.debug('app disconnected');
189
+ heartbeat.dispose();
214
190
  binaryStore.dispose();
215
- currentConnection = null;
216
- emitter.emit('disconnected');
191
+
192
+ if (activeSession?.transport === transport) {
193
+ activeSession = null;
194
+ }
195
+
196
+ if (currentConnection === readyConnection) {
197
+ currentConnection = null;
198
+ }
199
+
200
+ rpc.close(reason ?? new AppBridgeDisconnectedError('app-disconnected'));
201
+ closeTransport();
202
+
203
+ if (readyConnection) {
204
+ emitter.emit('disconnected');
205
+ }
206
+ };
207
+
208
+ activeSession = { disconnect, transport };
209
+
210
+ const handleControlMessage = async (message: string) => {
211
+ const controlMessage = await rpc.handleMessage(message);
212
+
213
+ if (!controlMessage) {
214
+ return;
215
+ }
216
+
217
+ switch (controlMessage.type) {
218
+ case 'ready': {
219
+ if (readyConnection) {
220
+ return;
221
+ }
222
+
223
+ const conn: AppConnection = {
224
+ device: controlMessage.device,
225
+ runTests: (testPath, opts) => rpc.invoke('runTests', testPath, opts),
226
+ };
227
+
228
+ readyConnection = conn;
229
+ currentConnection = conn;
230
+ bridgeLogger.debug(
231
+ 'app ready: platform=%s model=%s',
232
+ controlMessage.device.platform,
233
+ controlMessage.device.model,
234
+ );
235
+ emitter.emit('connected', conn);
236
+
237
+ for (const { resolve } of connectionWaiters.splice(0)) {
238
+ resolve(conn);
239
+ }
240
+ return;
241
+ }
242
+ case 'ping': {
243
+ transport.send(
244
+ serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
245
+ );
246
+ return;
247
+ }
248
+ case 'pong': {
249
+ heartbeat.notifyPong(controlMessage.id);
250
+ return;
251
+ }
252
+ }
253
+ };
254
+
255
+ const handleBinaryMessage = (message: Uint8Array) => {
256
+ try {
257
+ const { transferId, data } = parseBinaryFrame(message);
258
+ binaryStore.add(transferId, data);
259
+ } catch (error) {
260
+ bridgeLogger.warn('failed to parse binary frame: %s', error);
261
+ }
262
+ };
263
+
264
+ offMessage = transport.onMessage((message) => {
265
+ if (typeof message !== 'string') {
266
+ handleBinaryMessage(message);
267
+ return;
268
+ }
269
+
270
+ void handleControlMessage(message).catch((error) => {
271
+ bridgeLogger.warn('failed to handle bridge message: %s', error);
272
+ disconnect(
273
+ error instanceof Error
274
+ ? error
275
+ : new Error('Received invalid app bridge message'),
276
+ );
277
+ });
278
+ });
279
+
280
+ offClose = transport.onClose(() => {
281
+ disconnect();
282
+ });
283
+
284
+ offError = transport.onError((error) => {
285
+ disconnect(
286
+ error instanceof Error
287
+ ? error
288
+ : new AppBridgeDisconnectedError('socket-error'),
289
+ );
217
290
  });
218
291
  });
219
292
 
@@ -230,12 +303,11 @@ export const createHarnessBridge = async (
230
303
  signal.reason ?? new DOMException('Aborted', 'AbortError'),
231
304
  );
232
305
  }
233
- // If the app already connected before this call (e.g. fast simulator
234
- // startup between startAttempt and waitForReady), return it immediately
235
- // rather than waiting for a second reportReady that will never come.
306
+
236
307
  if (currentConnection) {
237
308
  return Promise.resolve(currentConnection);
238
309
  }
310
+
239
311
  return new Promise((resolve, reject) => {
240
312
  const entry = { resolve, reject };
241
313
  connectionWaiters.push(entry);
@@ -243,7 +315,10 @@ export const createHarnessBridge = async (
243
315
  'abort',
244
316
  () => {
245
317
  const idx = connectionWaiters.indexOf(entry);
246
- if (idx !== -1) connectionWaiters.splice(idx, 1);
318
+ if (idx !== -1) {
319
+ connectionWaiters.splice(idx, 1);
320
+ }
321
+
247
322
  reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
248
323
  },
249
324
  { once: true },
@@ -254,10 +329,17 @@ export const createHarnessBridge = async (
254
329
  off: (event, listener) => emitter.off(event, listener),
255
330
  dispose: () => {
256
331
  bridgeLogger.debug('disposing bridge');
332
+
257
333
  for (const { reject } of connectionWaiters.splice(0)) {
258
- reject(new Error('Bridge disposed'));
334
+ reject(new AppBridgeDisconnectedError('bridge-disposed'));
259
335
  }
260
- for (const client of wss.clients) client.terminate();
336
+
337
+ activeSession?.disconnect(new AppBridgeDisconnectedError('bridge-disposed'));
338
+
339
+ for (const client of wss.clients) {
340
+ client.terminate();
341
+ }
342
+
261
343
  wss.close();
262
344
  emitter.removeAllListeners();
263
345
  },
@@ -1,11 +1,18 @@
1
+ import type { HarnessTestContext } from './test-context.js';
2
+
1
3
  export type TestStatus = 'active' | 'skipped' | 'todo';
2
4
 
3
- export type TestFn = () => void | Promise<void>;
5
+ export type TestDeclarationMode = 'only' | 'skip' | 'todo';
6
+
7
+ export type TestFn = (context: HarnessTestContext) => void | Promise<void>;
8
+
9
+ export type SuiteHookFn = () => void | Promise<void>;
4
10
 
5
11
  export type TestCase = {
6
12
  name: string;
7
13
  fn: TestFn;
8
14
  status: TestStatus;
15
+ declarationMode?: TestDeclarationMode;
9
16
  };
10
17
 
11
18
  export type TestSuite = {
@@ -13,8 +20,8 @@ export type TestSuite = {
13
20
  tests: TestCase[];
14
21
  suites: TestSuite[];
15
22
  parent?: TestSuite;
16
- beforeAll: TestFn[];
17
- afterAll: TestFn[];
23
+ beforeAll: SuiteHookFn[];
24
+ afterAll: SuiteHookFn[];
18
25
  beforeEach: TestFn[];
19
26
  afterEach: TestFn[];
20
27
  status?: TestStatus;
@@ -0,0 +1,21 @@
1
+ export type HarnessTaskContext = {
2
+ name: string;
3
+ type: 'test';
4
+ mode: 'run' | 'skip' | 'todo';
5
+ file: {
6
+ name: string;
7
+ };
8
+ suite: {
9
+ name: string;
10
+ };
11
+ };
12
+
13
+ export type HarnessTestContext = {
14
+ task: HarnessTaskContext;
15
+ onTestFailed: (fn: () => void | Promise<void>) => void;
16
+ onTestFinished: (fn: () => void | Promise<void>) => void;
17
+ skip: {
18
+ (note?: string): never;
19
+ (condition: boolean, note?: string): void;
20
+ };
21
+ };
@@ -1,3 +1,5 @@
1
+ import type { TestDeclarationMode } from './test-collector.js';
2
+
1
3
  export type CodeFrame = {
2
4
  content: string;
3
5
  location?: {
@@ -37,6 +39,10 @@ export type TestRunnerTestStartedEvent = {
37
39
  name: string;
38
40
  suite: string;
39
41
  file: string;
42
+ ancestorTitles: string[];
43
+ fullName: string;
44
+ startedAt: number;
45
+ declarationMode?: TestDeclarationMode;
40
46
  };
41
47
 
42
48
  export type TestRunnerTestFinishedEvent = {
@@ -44,6 +50,10 @@ export type TestRunnerTestFinishedEvent = {
44
50
  name: string;
45
51
  suite: string;
46
52
  file: string;
53
+ ancestorTitles: string[];
54
+ fullName: string;
55
+ startedAt: number;
56
+ declarationMode?: TestDeclarationMode;
47
57
  duration: number;
48
58
  error?: SerializedError;
49
59
  status: TestResultStatus;
@@ -71,6 +81,10 @@ export type TestResult = {
71
81
  status: TestResultStatus;
72
82
  error?: SerializedError;
73
83
  duration: number;
84
+ ancestorTitles?: string[];
85
+ fullName?: string;
86
+ startedAt?: number;
87
+ declarationMode?: TestDeclarationMode;
74
88
  };
75
89
 
76
90
  export type TestSuiteResult = {
package/src/shared.ts CHANGED
@@ -81,7 +81,13 @@ export type {
81
81
  TestSuite,
82
82
  TestCase,
83
83
  CollectionResult,
84
+ TestFn,
85
+ SuiteHookFn,
84
86
  } from './shared/test-collector.js';
87
+ export type {
88
+ HarnessTaskContext,
89
+ HarnessTestContext,
90
+ } from './shared/test-context.js';
85
91
  export type {
86
92
  TestRunnerEvents,
87
93
  TestRunnerFileStartedEvent,
@@ -148,8 +154,6 @@ export type BinaryDataReference = {
148
154
  export type ScreenshotData = BinaryDataReference;
149
155
 
150
156
  export type BridgeServerFunctions = {
151
- reportReady: (device: DeviceDescriptor) => void;
152
- emitEvent: (event: BridgeEvents['type'], data: BridgeEvents) => void;
153
157
  'device.screenshot.receive': (
154
158
  reference: BinaryDataReference,
155
159
  metadata: { width: number; height: number }
@@ -0,0 +1,47 @@
1
+ export type BridgeTransportState = 'connecting' | 'open' | 'closing' | 'closed';
2
+
3
+ export type BridgeTransportMessage = string | Uint8Array;
4
+
5
+ export type BridgeTransportCloseEvent = {
6
+ code: number;
7
+ reason: string;
8
+ };
9
+
10
+ export type BridgeTransport = {
11
+ readonly state: BridgeTransportState;
12
+ send: (message: BridgeTransportMessage) => void;
13
+ close: (code?: number, reason?: string) => void;
14
+ onOpen: (listener: () => void) => () => void;
15
+ onMessage: (listener: (message: BridgeTransportMessage) => void) => () => void;
16
+ onClose: (listener: (event: BridgeTransportCloseEvent) => void) => () => void;
17
+ onError: (listener: (error: Error) => void) => () => void;
18
+ };
19
+
20
+ export type RpcTransport = {
21
+ readonly state: BridgeTransportState;
22
+ send: (message: string) => void;
23
+ };
24
+
25
+ export const toTransportState = (readyState: number): BridgeTransportState => {
26
+ switch (readyState) {
27
+ case 0:
28
+ return 'connecting';
29
+ case 1:
30
+ return 'open';
31
+ case 2:
32
+ return 'closing';
33
+ default:
34
+ return 'closed';
35
+ }
36
+ };
37
+
38
+ export const createRpcTransport = (transport: BridgeTransport): RpcTransport => {
39
+ return {
40
+ get state() {
41
+ return transport.state;
42
+ },
43
+ send: (message) => {
44
+ transport.send(message);
45
+ },
46
+ };
47
+ };
@@ -0,0 +1,85 @@
1
+ import {
2
+ toTransportState,
3
+ type BridgeTransport,
4
+ } from './transport.js';
5
+
6
+ type BrowserWebSocketLike = {
7
+ readonly readyState: number;
8
+ binaryType: BinaryType;
9
+ send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void;
10
+ close: (code?: number, reason?: string) => void;
11
+ addEventListener: {
12
+ (type: 'open', listener: () => void): void;
13
+ (
14
+ type: 'message',
15
+ listener: (event: MessageEvent<string | ArrayBuffer>) => void,
16
+ ): void;
17
+ (type: 'close', listener: (event: CloseEvent) => void): void;
18
+ (
19
+ type: 'error',
20
+ listener: (event: Event & { message?: string }) => void,
21
+ ): void;
22
+ };
23
+ removeEventListener: {
24
+ (type: 'open', listener: () => void): void;
25
+ (
26
+ type: 'message',
27
+ listener: (event: MessageEvent<string | ArrayBuffer>) => void,
28
+ ): void;
29
+ (type: 'close', listener: (event: CloseEvent) => void): void;
30
+ (
31
+ type: 'error',
32
+ listener: (event: Event & { message?: string }) => void,
33
+ ): void;
34
+ };
35
+ };
36
+
37
+ export const createWebSocketClientTransport = (url: string): BridgeTransport => {
38
+ const socket = new WebSocket(url) as BrowserWebSocketLike;
39
+ socket.binaryType = 'arraybuffer';
40
+
41
+ return {
42
+ get state() {
43
+ return toTransportState(socket.readyState);
44
+ },
45
+ send: (message) => {
46
+ socket.send(message);
47
+ },
48
+ close: (code, reason) => {
49
+ socket.close(code, reason);
50
+ },
51
+ onOpen: (listener) => {
52
+ socket.addEventListener('open', listener);
53
+ return () => socket.removeEventListener('open', listener);
54
+ },
55
+ onMessage: (listener) => {
56
+ const handleMessage = (event: MessageEvent<string | ArrayBuffer>) => {
57
+ if (typeof event.data === 'string') {
58
+ listener(event.data);
59
+ return;
60
+ }
61
+
62
+ listener(new Uint8Array(event.data));
63
+ };
64
+
65
+ socket.addEventListener('message', handleMessage);
66
+ return () => socket.removeEventListener('message', handleMessage);
67
+ },
68
+ onClose: (listener) => {
69
+ const handleClose = (event: CloseEvent) => {
70
+ listener({ code: event.code, reason: event.reason });
71
+ };
72
+
73
+ socket.addEventListener('close', handleClose);
74
+ return () => socket.removeEventListener('close', handleClose);
75
+ },
76
+ onError: (listener) => {
77
+ const handleError = (event: Event & { message?: string }) => {
78
+ listener(new Error(event.message || 'Harness connection error'));
79
+ };
80
+
81
+ socket.addEventListener('error', handleError);
82
+ return () => socket.removeEventListener('error', handleError);
83
+ },
84
+ };
85
+ };