@react-native-harness/bridge 1.3.0 → 1.4.0

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 (69) 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 +144 -0
  13. package/dist/client.d.ts +8 -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 +149 -0
  31. package/dist/server.d.ts +2 -20
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +123 -71
  34. package/dist/shared/test-collector.d.ts +2 -0
  35. package/dist/shared/test-collector.d.ts.map +1 -1
  36. package/dist/shared/test-runner.d.ts +23 -0
  37. package/dist/shared/test-runner.d.ts.map +1 -1
  38. package/dist/shared.d.ts +2 -2
  39. package/dist/shared.d.ts.map +1 -1
  40. package/dist/transport.d.ts +22 -0
  41. package/dist/transport.d.ts.map +1 -0
  42. package/dist/transport.js +22 -0
  43. package/dist/tsconfig.lib.tsbuildinfo +1 -1
  44. package/dist/websocket-client-transport.d.ts +3 -0
  45. package/dist/websocket-client-transport.d.ts.map +1 -0
  46. package/dist/websocket-client-transport.js +45 -0
  47. package/dist/websocket-server-transport.d.ts +4 -0
  48. package/dist/websocket-server-transport.d.ts.map +1 -0
  49. package/dist/websocket-server-transport.js +43 -0
  50. package/eslint.config.mjs +5 -1
  51. package/package.json +6 -5
  52. package/src/__tests__/client.test.ts +99 -0
  53. package/src/__tests__/heartbeat.test.ts +47 -0
  54. package/src/__tests__/protocol.test.ts +51 -0
  55. package/src/__tests__/rpc-peer.test.ts +206 -0
  56. package/src/client.ts +147 -57
  57. package/src/errors.ts +32 -0
  58. package/src/heartbeat.ts +67 -0
  59. package/src/index.ts +1 -0
  60. package/src/protocol.ts +233 -0
  61. package/src/rpc-peer.ts +227 -0
  62. package/src/server.ts +181 -114
  63. package/src/shared/test-collector.ts +3 -0
  64. package/src/shared/test-runner.ts +24 -0
  65. package/src/shared.ts +2 -2
  66. package/src/transport.ts +47 -0
  67. package/src/websocket-client-transport.ts +85 -0
  68. package/src/websocket-server-transport.ts +54 -0
  69. package/vite.config.ts +18 -0
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ deserializeBridgeError,
4
+ parseBridgeMessage,
5
+ serializeBridgeError,
6
+ serializeBridgeMessage,
7
+ } from '../protocol.js';
8
+
9
+ describe('bridge protocol', () => {
10
+ it('round-trips invoke messages', () => {
11
+ const raw = serializeBridgeMessage({
12
+ type: 'invoke',
13
+ id: 1,
14
+ method: 'runTests',
15
+ args: ['example.ts', { runner: '/runner.js' }],
16
+ });
17
+
18
+ expect(parseBridgeMessage(raw)).toEqual({
19
+ type: 'invoke',
20
+ id: 1,
21
+ method: 'runTests',
22
+ args: ['example.ts', { runner: '/runner.js' }],
23
+ });
24
+ });
25
+
26
+ it('serializes and restores errors with metadata', () => {
27
+ const cause = new Error('inner');
28
+ const error = new TypeError('boom', { cause });
29
+ error.stack = 'stack trace';
30
+
31
+ const restored = deserializeBridgeError(serializeBridgeError(error));
32
+
33
+ expect(restored.name).toBe('TypeError');
34
+ expect(restored.message).toBe('boom');
35
+ expect(restored.stack).toBe('stack trace');
36
+ expect(restored.cause).toBe(cause);
37
+ });
38
+
39
+ it('serializes non-Error thrown values explicitly', () => {
40
+ expect(serializeBridgeError('boom')).toEqual({
41
+ name: 'NonErrorThrown',
42
+ message: 'boom',
43
+ });
44
+ });
45
+
46
+ it('rejects malformed messages', () => {
47
+ expect(() => parseBridgeMessage('{"type":"invoke","id":"1"}')).toThrow(
48
+ 'Invalid bridge message: id must be a number',
49
+ );
50
+ });
51
+ });
@@ -0,0 +1,206 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { createRpcPeer } from '../rpc-peer.js';
3
+ import type { BridgeEvents } from '../shared.js';
4
+ import type { RpcTransport } from '../transport.js';
5
+
6
+ type LocalMethods = {
7
+ add: (left: number, right: number) => Promise<number>;
8
+ fail: () => Promise<never>;
9
+ };
10
+
11
+ const createMockTransport = (): RpcTransport & { messages: string[] } => {
12
+ const messages: string[] = [];
13
+
14
+ return {
15
+ state: 'open',
16
+ messages,
17
+ send: (message) => {
18
+ messages.push(message);
19
+ },
20
+ };
21
+ };
22
+
23
+ describe('rpc-peer', () => {
24
+ it('resolves successful RPC calls', async () => {
25
+ let serverHandleMessage: ((message: string) => Promise<unknown>) | null = null;
26
+
27
+ const client = createRpcPeer<Record<string, never>, { add: LocalMethods['add'] }, BridgeEvents>({
28
+ localMethods: {},
29
+ transport: {
30
+ state: 'open',
31
+ send: (message) => {
32
+ if (!serverHandleMessage) {
33
+ throw new Error('Server peer not initialized');
34
+ }
35
+
36
+ void serverHandleMessage(message);
37
+ },
38
+ },
39
+ });
40
+
41
+ const server = createRpcPeer<LocalMethods, Record<string, never>, BridgeEvents>({
42
+ localMethods: {
43
+ add: async (left, right) => left + right,
44
+ fail: async () => {
45
+ throw new Error('unused');
46
+ },
47
+ },
48
+ transport: {
49
+ state: 'open',
50
+ send: (message) => {
51
+ void client.handleMessage(message);
52
+ },
53
+ },
54
+ });
55
+
56
+ serverHandleMessage = server.handleMessage;
57
+
58
+ const result = await client.invoke('add', 2, 3);
59
+
60
+ expect(result).toBe(5);
61
+ });
62
+
63
+ it('rejects failed RPC calls with restored errors', async () => {
64
+ const client = createRpcPeer<Record<string, never>, { fail: () => Promise<void> }, BridgeEvents>({
65
+ localMethods: {},
66
+ transport: createMockTransport(),
67
+ });
68
+
69
+ const server = createRpcPeer<
70
+ { fail: () => Promise<void> },
71
+ Record<string, never>,
72
+ BridgeEvents
73
+ >({
74
+ localMethods: {
75
+ fail: async () => {
76
+ throw new TypeError('boom');
77
+ },
78
+ },
79
+ transport: {
80
+ state: 'open',
81
+ send: (message) => {
82
+ void client.handleMessage(message);
83
+ },
84
+ },
85
+ });
86
+
87
+ await server.handleMessage(
88
+ JSON.stringify({ type: 'invoke', id: 1, method: 'fail', args: [] }),
89
+ );
90
+
91
+ await expect(client.handleMessage('{"type":"return","id":1,"ok":false,"error":{"name":"TypeError","message":"boom"}}')).resolves.toBeNull();
92
+ });
93
+
94
+ it('rejects unknown methods', async () => {
95
+ let response = '';
96
+ const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
97
+ localMethods: {},
98
+ transport: {
99
+ state: 'open',
100
+ send: (message) => {
101
+ response = message;
102
+ },
103
+ },
104
+ });
105
+
106
+ await peer.handleMessage(
107
+ JSON.stringify({ type: 'invoke', id: 1, method: 'missing', args: [] }),
108
+ );
109
+
110
+ expect(JSON.parse(response)).toMatchObject({
111
+ type: 'return',
112
+ id: 1,
113
+ ok: false,
114
+ });
115
+ });
116
+
117
+ it('dispatches incoming events', async () => {
118
+ const onEvent = vi.fn();
119
+ const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
120
+ localMethods: {},
121
+ transport: createMockTransport(),
122
+ onEvent,
123
+ });
124
+
125
+ await peer.handleMessage(
126
+ JSON.stringify({
127
+ type: 'event',
128
+ event: { type: 'collection-started', file: 'example.ts' },
129
+ }),
130
+ );
131
+
132
+ expect(onEvent).toHaveBeenCalledWith({
133
+ type: 'collection-started',
134
+ file: 'example.ts',
135
+ });
136
+ });
137
+
138
+ it('rejects pending calls when closed', async () => {
139
+ const peer = createRpcPeer<
140
+ Record<string, never>,
141
+ { runTests: (path: string, options: { runner: string }) => Promise<void> },
142
+ BridgeEvents
143
+ >({
144
+ localMethods: {},
145
+ transport: createMockTransport(),
146
+ });
147
+
148
+ const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
149
+ peer.close(new Error('closed'));
150
+
151
+ await expect(pending).rejects.toThrow('closed');
152
+ });
153
+
154
+ it('rejects pending calls on timeout', async () => {
155
+ const peer = createRpcPeer<
156
+ Record<string, never>,
157
+ { runTests: (path: string, options: { runner: string }) => Promise<void> },
158
+ BridgeEvents
159
+ >({
160
+ localMethods: {},
161
+ transport: createMockTransport(),
162
+ callTimeoutMs: 10,
163
+ createTimeoutError: () => new Error('timed out'),
164
+ });
165
+
166
+ const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
167
+
168
+ await expect(pending).rejects.toThrow('timed out');
169
+ });
170
+
171
+ it('does not time out calls when method-specific timeout returns undefined', async () => {
172
+ const peer = createRpcPeer<
173
+ Record<string, never>,
174
+ { runTests: (path: string, options: { runner: string }) => Promise<void> },
175
+ BridgeEvents
176
+ >({
177
+ localMethods: {},
178
+ transport: createMockTransport(),
179
+ callTimeoutMs: () => undefined,
180
+ createTimeoutError: () => new Error('timed out'),
181
+ });
182
+
183
+ let rejected = false;
184
+ const pending = peer.invoke('runTests', 'example.ts', { runner: '/runner.js' });
185
+ pending.catch(() => {
186
+ rejected = true;
187
+ });
188
+
189
+ await new Promise((resolve) => setTimeout(resolve, 20));
190
+
191
+ expect(rejected).toBe(false);
192
+ peer.close(new Error('closed'));
193
+ await expect(pending).rejects.toThrow('closed');
194
+ });
195
+
196
+ it('throws on malformed messages', async () => {
197
+ const peer = createRpcPeer<Record<string, never>, Record<string, never>, BridgeEvents>({
198
+ localMethods: {},
199
+ transport: createMockTransport(),
200
+ });
201
+
202
+ await expect(peer.handleMessage('{"type":"invoke"}')).rejects.toThrow(
203
+ 'Invalid bridge message: id must be a number',
204
+ );
205
+ });
206
+ });
package/src/client.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { createBirpc } from 'birpc';
2
- import { deserialize, serialize } from './serializer.js';
3
1
  import { createBinaryFrame, generateTransferId } from './binary-transfer.js';
2
+ import { serializeBridgeMessage } from './protocol.js';
3
+ import { createRpcPeer } from './rpc-peer.js';
4
+ import {
5
+ createRpcTransport,
6
+ type BridgeTransport,
7
+ } from './transport.js';
8
+ import { createWebSocketClientTransport } from './websocket-client-transport.js';
4
9
  import type {
5
10
  BridgeClientFunctions,
6
11
  BridgeServerFunctions,
@@ -12,27 +17,18 @@ import type {
12
17
  TestSuiteResult,
13
18
  } from './shared.js';
14
19
 
15
- // ---------------------------------------------------------------------------
16
- // Public types
17
- // ---------------------------------------------------------------------------
18
-
19
- /** Handlers the app must implement for the CLI to call into. */
20
20
  export type HarnessCallbacks = {
21
21
  runTests: (path: string, options: TestExecutionOptions) => Promise<TestSuiteResult>;
22
+ resetEnvironment: () => Promise<void>;
22
23
  };
23
24
 
24
- /** The app-side handle returned by connectToHarness. */
25
25
  export type HarnessHandle = {
26
- /** Call once when the app is initialised and ready to run tests. */
27
26
  reportReady: (device: DeviceDescriptor) => void;
28
- /** Forward a test or bundler event to the CLI. */
29
27
  emitEvent: (event: BridgeEvents) => void;
30
- /** Send a screenshot to the CLI and receive a file reference for snapshot comparison. */
31
28
  transferScreenshot: (
32
29
  data: Uint8Array,
33
30
  metadata: { width: number; height: number },
34
31
  ) => Promise<FileReference>;
35
- /** Request an image snapshot comparison on the CLI. */
36
32
  matchImageSnapshot: (
37
33
  screenshot: FileReference,
38
34
  testPath: string,
@@ -42,83 +38,173 @@ export type HarnessHandle = {
42
38
  disconnect: () => void;
43
39
  };
44
40
 
45
- // ---------------------------------------------------------------------------
46
- // Factory
47
- // ---------------------------------------------------------------------------
48
-
49
- /**
50
- * Connect the app to the CLI harness bridge.
51
- *
52
- * Pass the handlers the CLI can call (runTests). Returns a HarnessHandle
53
- * exposing the operations the app needs to drive a test run. The binary
54
- * transfer protocol and RPC wiring are fully encapsulated.
55
- */
41
+ export type ConnectToHarnessOptions = {
42
+ transport?: BridgeTransport;
43
+ };
44
+
45
+ export { createWebSocketClientTransport };
46
+
47
+ const noop = (): void => undefined;
48
+
56
49
  export const connectToHarness = (
57
50
  url: string,
58
51
  callbacks: HarnessCallbacks,
52
+ options: ConnectToHarnessOptions = {},
59
53
  ): Promise<HarnessHandle> =>
60
54
  new Promise((resolve, reject) => {
61
- const ws = new WebSocket(url);
62
- ws.binaryType = 'arraybuffer';
55
+ const transport = options.transport ?? createWebSocketClientTransport(url);
63
56
  let settled = false;
57
+ let peerClosed = false;
58
+ let offOpen: () => void = noop;
59
+ let offError: () => void = noop;
60
+ let offClose: () => void = noop;
64
61
 
65
62
  const cleanup = () => {
66
- ws.removeEventListener('open', handleOpen);
67
- ws.removeEventListener('error', handleError);
68
- ws.removeEventListener('close', handleClose);
63
+ offOpen();
64
+ offError();
65
+ offClose();
69
66
  };
70
67
 
71
68
  const fail = (message: string) => {
72
- if (settled) return;
69
+ if (settled) {
70
+ return;
71
+ }
72
+
73
73
  settled = true;
74
74
  cleanup();
75
75
  reject(new Error(message));
76
76
  };
77
77
 
78
78
  const handleOpen = () => {
79
+ if (settled) {
80
+ return;
81
+ }
82
+
79
83
  settled = true;
80
84
  cleanup();
81
85
 
82
- const rpc = createBirpc<BridgeServerFunctions, BridgeClientFunctions>(
83
- callbacks,
84
- {
85
- post: (data) => ws.send(data),
86
- on: (handler) => {
87
- ws.addEventListener('message', (event: MessageEvent<string | ArrayBuffer>) => {
88
- if (typeof event.data === 'string') handler(event.data);
89
- });
90
- },
91
- serialize,
92
- deserialize,
93
- },
94
- );
86
+ const getTransportNotOpenError = () => {
87
+ return new Error('Harness bridge transport is not open');
88
+ };
89
+
90
+ const rpc = createRpcPeer<
91
+ BridgeClientFunctions,
92
+ BridgeServerFunctions,
93
+ BridgeEvents
94
+ >({
95
+ localMethods: callbacks,
96
+ transport: createRpcTransport(transport),
97
+ });
98
+
99
+ let offMessage: () => void = noop;
100
+ let offRuntimeClose: () => void = noop;
101
+ let offRuntimeError: () => void = noop;
102
+
103
+ const closePeer = (reason: Error) => {
104
+ if (peerClosed) {
105
+ return;
106
+ }
107
+
108
+ peerClosed = true;
109
+ offMessage();
110
+ offRuntimeClose();
111
+ offRuntimeError();
112
+ rpc.close(reason);
113
+ };
114
+
115
+ const handleMessage = async (data: string) => {
116
+ const controlMessage = await rpc.handleMessage(data);
117
+
118
+ if (!controlMessage) {
119
+ return;
120
+ }
121
+
122
+ if (controlMessage.type === 'ping') {
123
+ transport.send(
124
+ serializeBridgeMessage({ type: 'pong', id: controlMessage.id }),
125
+ );
126
+ }
127
+ };
128
+
129
+ offMessage = transport.onMessage((message) => {
130
+ if (typeof message !== 'string') {
131
+ return;
132
+ }
133
+
134
+ void handleMessage(message).catch((error) => {
135
+ closePeer(
136
+ error instanceof Error
137
+ ? error
138
+ : new Error('Received invalid Harness bridge message'),
139
+ );
140
+
141
+ if (transport.state === 'open') {
142
+ transport.close(1002, 'Invalid message');
143
+ }
144
+ });
145
+ });
146
+
147
+ offRuntimeClose = transport.onClose((event) => {
148
+ closePeer(
149
+ new Error(
150
+ `Harness connection closed (code ${event.code}${
151
+ event.reason ? `, reason: ${event.reason}` : ''
152
+ })`,
153
+ ),
154
+ );
155
+ });
156
+
157
+ offRuntimeError = transport.onError((error) => {
158
+ closePeer(error);
159
+ });
95
160
 
96
161
  resolve({
97
- reportReady: (device) => void rpc.reportReady(device),
98
- emitEvent: (event) => void rpc.emitEvent(event.type, event),
162
+ reportReady: (device) => {
163
+ try {
164
+ if (transport.state !== 'open') {
165
+ throw getTransportNotOpenError();
166
+ }
167
+
168
+ transport.send(serializeBridgeMessage({ type: 'ready', device }));
169
+ } catch (error) {
170
+ closePeer(
171
+ error instanceof Error ? error : getTransportNotOpenError(),
172
+ );
173
+ }
174
+ },
175
+ emitEvent: (event) => {
176
+ rpc.sendEvent(event);
177
+ },
99
178
  transferScreenshot: async (data, metadata) => {
100
179
  const transferId = generateTransferId();
101
- ws.send(createBinaryFrame(transferId, data));
102
- return rpc['device.screenshot.receive'](
180
+ transport.send(createBinaryFrame(transferId, data));
181
+ return rpc.invoke(
182
+ 'device.screenshot.receive',
103
183
  { type: 'binary', transferId, size: data.length, mimeType: 'image/png' },
104
184
  metadata,
105
185
  );
106
186
  },
107
187
  matchImageSnapshot: (screenshot, testPath, options, runner) =>
108
- rpc['test.matchImageSnapshot'](screenshot, testPath, options, runner),
109
- disconnect: () => ws.close(),
188
+ rpc.invoke(
189
+ 'test.matchImageSnapshot',
190
+ screenshot,
191
+ testPath,
192
+ options,
193
+ runner,
194
+ ),
195
+ disconnect: () => {
196
+ closePeer(new Error('Harness connection closed by client'));
197
+ transport.close();
198
+ },
110
199
  });
111
200
  };
112
201
 
113
- const handleError = (event: Event & { message?: string }) => {
114
- const detail =
115
- typeof event.message === 'string' && event.message
116
- ? `: ${event.message}`
117
- : '';
202
+ const handleError = (error: Error) => {
203
+ const detail = error.message ? `: ${error.message}` : '';
118
204
  fail(`Failed to connect to Harness at ${url}${detail}`);
119
205
  };
120
206
 
121
- const handleClose = (event: CloseEvent) => {
207
+ const handleClose = (event: { code: number; reason: string }) => {
122
208
  fail(
123
209
  `Harness connection at ${url} closed before becoming ready (code ${event.code}${
124
210
  event.reason ? `, reason: ${event.reason}` : ''
@@ -126,7 +212,11 @@ export const connectToHarness = (
126
212
  );
127
213
  };
128
214
 
129
- ws.addEventListener('open', handleOpen);
130
- ws.addEventListener('error', handleError);
131
- ws.addEventListener('close', handleClose);
215
+ offOpen = transport.onOpen(handleOpen);
216
+ offError = transport.onError(handleError);
217
+ offClose = transport.onClose(handleClose);
218
+
219
+ if (transport.state === 'open') {
220
+ handleOpen();
221
+ }
132
222
  });
package/src/errors.ts CHANGED
@@ -9,3 +9,35 @@ export class DeviceNotRespondingError extends HarnessError {
9
9
  this.name = 'DeviceNotRespondingError';
10
10
  }
11
11
  }
12
+
13
+ export type AppBridgeDisconnectedReason =
14
+ | 'app-disconnected'
15
+ | 'app-replaced'
16
+ | 'heartbeat-timeout'
17
+ | 'socket-error'
18
+ | 'bridge-disposed';
19
+
20
+ const appBridgeDisconnectedMessage = (
21
+ reason: AppBridgeDisconnectedReason,
22
+ ): string => {
23
+ switch (reason) {
24
+ case 'app-replaced':
25
+ return 'The app bridge was replaced by a newer app connection. This can happen when the app reloads, restarts, or reconnects while a test file is still running.';
26
+ case 'heartbeat-timeout':
27
+ return 'The app bridge stopped responding during test execution. This can happen if the app was killed, crashed, became unresponsive, or lost its WebSocket connection.';
28
+ case 'socket-error':
29
+ return 'The app bridge connection failed during test execution. This can happen if the app was killed, crashed, or the underlying WebSocket connection closed unexpectedly.';
30
+ case 'bridge-disposed':
31
+ return 'The app bridge was disposed before the test file finished running.';
32
+ case 'app-disconnected':
33
+ return 'The app bridge disconnected during test execution. This can happen if the app was killed, crashed, reloaded, or restarted while the test file was running.';
34
+ }
35
+ };
36
+
37
+ export class AppBridgeDisconnectedError extends HarnessError {
38
+ constructor(public readonly reason: AppBridgeDisconnectedReason) {
39
+ super(appBridgeDisconnectedMessage(reason));
40
+ this.name = 'AppBridgeDisconnectedError';
41
+ this.stack = `${this.name}: ${this.message}`;
42
+ }
43
+ }
@@ -0,0 +1,67 @@
1
+ export const DEFAULT_HEARTBEAT_INTERVAL_MS = 5_000;
2
+ export const DEFAULT_HEARTBEAT_TIMEOUT_MS = 20_000;
3
+
4
+ export type BridgeHeartbeat = {
5
+ notifyPong: (id: number) => void;
6
+ dispose: () => void;
7
+ };
8
+
9
+ export const createHeartbeat = (options: {
10
+ sendPing: (id: number) => void;
11
+ onTimeout: () => void;
12
+ intervalMs?: number;
13
+ timeoutMs?: number;
14
+ }): BridgeHeartbeat => {
15
+ const intervalMs = options.intervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS;
16
+ const timeoutMs = options.timeoutMs ?? DEFAULT_HEARTBEAT_TIMEOUT_MS;
17
+ let nextPingId = 1;
18
+ let pendingPingId: number | null = null;
19
+ let disposed = false;
20
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
21
+
22
+ const clearPendingTimeout = () => {
23
+ if (timeoutHandle) {
24
+ clearTimeout(timeoutHandle);
25
+ timeoutHandle = null;
26
+ }
27
+ };
28
+
29
+ const intervalHandle = setInterval(() => {
30
+ if (disposed || pendingPingId !== null) {
31
+ return;
32
+ }
33
+
34
+ const pingId = nextPingId++;
35
+ pendingPingId = pingId;
36
+ options.sendPing(pingId);
37
+ timeoutHandle = setTimeout(() => {
38
+ if (disposed || pendingPingId !== pingId) {
39
+ return;
40
+ }
41
+
42
+ pendingPingId = null;
43
+ timeoutHandle = null;
44
+ options.onTimeout();
45
+ }, timeoutMs);
46
+ }, intervalMs);
47
+
48
+ return {
49
+ notifyPong: (id) => {
50
+ if (id !== pendingPingId) {
51
+ return;
52
+ }
53
+
54
+ pendingPingId = null;
55
+ clearPendingTimeout();
56
+ },
57
+ dispose: () => {
58
+ if (disposed) {
59
+ return;
60
+ }
61
+
62
+ disposed = true;
63
+ clearInterval(intervalHandle);
64
+ clearPendingTimeout();
65
+ },
66
+ };
67
+ };
package/src/index.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './shared.js';
2
2
  export * from './binary-transfer.js';
3
+ export * from './transport.js';