@dxos/edge-client 0.10.0 → 0.11.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 (57) hide show
  1. package/dist/lib/chunk-edge-ws-muxer.mjs +322 -0
  2. package/dist/lib/chunk-edge-ws-muxer.mjs.map +1 -0
  3. package/dist/lib/cors-proxy.mjs +29 -0
  4. package/dist/lib/cors-proxy.mjs.map +1 -0
  5. package/dist/lib/edge-ws-muxer.mjs +2 -0
  6. package/dist/lib/index.mjs +1769 -0
  7. package/dist/lib/index.mjs.map +1 -0
  8. package/dist/lib/service.mjs +138 -0
  9. package/dist/lib/service.mjs.map +1 -0
  10. package/dist/lib/testing.mjs +160 -0
  11. package/dist/lib/testing.mjs.map +1 -0
  12. package/dist/types/src/base-http-client.d.ts +18 -0
  13. package/dist/types/src/base-http-client.d.ts.map +1 -1
  14. package/dist/types/src/browser-rendering.d.ts +0 -7
  15. package/dist/types/src/browser-rendering.d.ts.map +1 -1
  16. package/dist/types/src/edge-client.d.ts +22 -2
  17. package/dist/types/src/edge-client.d.ts.map +1 -1
  18. package/dist/types/src/edge-http-client.d.ts +86 -2
  19. package/dist/types/src/edge-http-client.d.ts.map +1 -1
  20. package/dist/types/src/edge-ws-connection.d.ts +19 -0
  21. package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
  22. package/dist/types/src/edge-ws-connection.test.d.ts +2 -0
  23. package/dist/types/src/edge-ws-connection.test.d.ts.map +1 -0
  24. package/dist/types/src/index.d.ts +1 -0
  25. package/dist/types/src/index.d.ts.map +1 -1
  26. package/dist/types/src/protocol.d.ts +2 -1
  27. package/dist/types/src/protocol.d.ts.map +1 -1
  28. package/dist/types/src/testing/test-utils.d.ts.map +1 -1
  29. package/dist/types/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +20 -20
  31. package/src/base-http-client.ts +71 -3
  32. package/src/browser-rendering.ts +0 -9
  33. package/src/edge-client.ts +21 -3
  34. package/src/edge-http-client.test.ts +121 -0
  35. package/src/edge-http-client.ts +156 -2
  36. package/src/edge-ws-connection.test.ts +219 -0
  37. package/src/edge-ws-connection.ts +97 -28
  38. package/src/index.ts +1 -0
  39. package/src/protocol.ts +11 -2
  40. package/src/testing/test-utils.ts +5 -1
  41. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
  42. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
  43. package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs +0 -310
  44. package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs.map +0 -7
  45. package/dist/lib/neutral/chunk-WQKMEZJR.mjs +0 -30
  46. package/dist/lib/neutral/chunk-WQKMEZJR.mjs.map +0 -7
  47. package/dist/lib/neutral/cors-proxy.mjs +0 -8
  48. package/dist/lib/neutral/cors-proxy.mjs.map +0 -7
  49. package/dist/lib/neutral/edge-ws-muxer.mjs +0 -12
  50. package/dist/lib/neutral/edge-ws-muxer.mjs.map +0 -7
  51. package/dist/lib/neutral/index.mjs +0 -1524
  52. package/dist/lib/neutral/index.mjs.map +0 -7
  53. package/dist/lib/neutral/meta.json +0 -1
  54. package/dist/lib/neutral/service/index.mjs +0 -134
  55. package/dist/lib/neutral/service/index.mjs.map +0 -7
  56. package/dist/lib/neutral/testing/index.mjs +0 -161
  57. package/dist/lib/neutral/testing/index.mjs.map +0 -7
@@ -0,0 +1,219 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, onTestFinished, test, vi } from 'vitest';
6
+
7
+ import { Trigger } from '@dxos/async';
8
+ import { invariant } from '@dxos/invariant';
9
+ import { bufWkt } from '@dxos/protocols/buf';
10
+ import { type Message, TextMessageSchema } from '@dxos/protocols/buf/dxos/edge/messenger_pb';
11
+
12
+ import { protocol } from './defs';
13
+ import { type EdgeIdentity } from './edge-identity';
14
+ import { WebSocketMuxer } from './edge-ws-muxer';
15
+
16
+ // Segmented-message chunk count depends on the protobuf envelope overhead, which is
17
+ // determined empirically (see chunk-count assertions below) rather than assumed.
18
+ const MAX_CHUNK_LENGTH = 64;
19
+ const MESSAGE_A_CONTENT = 'a'.repeat(20);
20
+ const MESSAGE_B_CONTENT = 'b'.repeat(120);
21
+
22
+ // Replace isomorphic-ws's default export with a controllable fake so tests can drive
23
+ // the connection's onmessage handler directly and inspect what it sends.
24
+ const { FakeWebSocket } = vi.hoisted(() => {
25
+ class FakeWebSocket {
26
+ static instances: FakeWebSocket[] = [];
27
+
28
+ readyState = 1;
29
+ protocol = '';
30
+ binaryType = 'nodebuffer';
31
+ onopen: (() => void) | null = null;
32
+ onclose: ((event: { code?: number; reason?: string }) => void) | null = null;
33
+ onerror: ((event: { error?: unknown; message?: string }) => void) | null = null;
34
+ onmessage: ((event: { data: unknown; type: string }) => void) | null = null;
35
+ readonly sent: unknown[] = [];
36
+
37
+ constructor(
38
+ public readonly url: string,
39
+ public readonly protocols?: string[],
40
+ public readonly options?: unknown,
41
+ ) {
42
+ FakeWebSocket.instances.push(this);
43
+ }
44
+
45
+ send(data: unknown): void {
46
+ this.sent.push(data);
47
+ }
48
+
49
+ close(): void {}
50
+ }
51
+
52
+ return { FakeWebSocket };
53
+ });
54
+
55
+ vi.mock('isomorphic-ws', () => ({ default: FakeWebSocket }));
56
+
57
+ const { EdgeWsConnection } = await import('./edge-ws-connection');
58
+
59
+ const testIdentity: EdgeIdentity = {
60
+ peerKey: 'test-peer-key',
61
+ identityDid: 'did:halo:test',
62
+ presentCredentials: async () => {
63
+ throw new Error('not implemented');
64
+ },
65
+ };
66
+
67
+ describe('EdgeWsConnection', () => {
68
+ test('reassembles segmented messages delivered as Blobs out of order', async ({ expect }) => {
69
+ const [chunksA, chunksB] = await buildSegmentedChunks([MESSAGE_A_CONTENT, MESSAGE_B_CONTENT]);
70
+ expect(chunksA).toHaveLength(2);
71
+ expect(chunksB).toHaveLength(4);
72
+
73
+ const { ws, received, allReceived } = await openTestConnection(2);
74
+
75
+ const deferredA = chunksA.map(deferredChunk);
76
+ const deferredB = chunksB.map(deferredChunk);
77
+
78
+ // Dispatch every chunk in correct wire order, as a real WebSocket would.
79
+ for (const { blob } of [...deferredA, ...deferredB]) {
80
+ ws.onmessage?.({ data: blob, type: 'message' });
81
+ }
82
+
83
+ // Resolve the underlying `arrayBuffer()` reads out of order, simulating the browser
84
+ // race where concurrent Blob reads do not complete in arrival order.
85
+ deferredA[0].resolve();
86
+ deferredB[0].resolve();
87
+ deferredB[1].resolve();
88
+ deferredA[1].resolve();
89
+ deferredB[2].resolve();
90
+ deferredB[3].resolve();
91
+
92
+ await allReceived.wait();
93
+ expect(received).toHaveLength(2);
94
+
95
+ const [messageA, messageB] = received;
96
+ invariant(messageA.payload);
97
+ invariant(messageB.payload);
98
+ expect(bufWkt.anyUnpack(messageA.payload, TextMessageSchema)?.message).toStrictEqual(MESSAGE_A_CONTENT);
99
+ expect(bufWkt.anyUnpack(messageB.payload, TextMessageSchema)?.message).toStrictEqual(MESSAGE_B_CONTENT);
100
+ });
101
+
102
+ test('reassembles segmented messages delivered as ArrayBuffers', async ({ expect }) => {
103
+ const [chunksA, chunksB] = await buildSegmentedChunks([MESSAGE_A_CONTENT, MESSAGE_B_CONTENT]);
104
+
105
+ const { ws, received, allReceived } = await openTestConnection(2);
106
+
107
+ for (const chunk of [...chunksA, ...chunksB]) {
108
+ ws.onmessage?.({ data: toArrayBuffer(chunk), type: 'message' });
109
+ }
110
+
111
+ await allReceived.wait();
112
+ expect(received).toHaveLength(2);
113
+
114
+ const [messageA, messageB] = received;
115
+ invariant(messageA.payload);
116
+ invariant(messageB.payload);
117
+ expect(bufWkt.anyUnpack(messageA.payload, TextMessageSchema)?.message).toStrictEqual(MESSAGE_A_CONTENT);
118
+ expect(bufWkt.anyUnpack(messageB.payload, TextMessageSchema)?.message).toStrictEqual(MESSAGE_B_CONTENT);
119
+ });
120
+ });
121
+
122
+ /**
123
+ * A Blob whose `arrayBuffer()` resolves only when `resolve()` is called, so tests can
124
+ * control the order in which concurrent `blob.arrayBuffer()` reads complete.
125
+ */
126
+ class DeferredBlob extends Blob {
127
+ readonly #result: Promise<ArrayBuffer>;
128
+
129
+ constructor(result: Promise<ArrayBuffer>) {
130
+ super();
131
+ this.#result = result;
132
+ }
133
+
134
+ override async arrayBuffer(): Promise<ArrayBuffer> {
135
+ // `await` (not a bare `return`) so the listener attaches to `#result` synchronously,
136
+ // during dispatch, rather than one microtask later — otherwise resolving out of
137
+ // dispatch order has no effect once every deferred promise is already settled.
138
+ return await this.#result;
139
+ }
140
+ }
141
+
142
+ type DeferredChunk = {
143
+ blob: DeferredBlob;
144
+ resolve: () => void;
145
+ };
146
+
147
+ const deferredChunk = (bytes: Uint8Array): DeferredChunk => {
148
+ let resolveResult: (buffer: ArrayBuffer) => void = () => {};
149
+ const result = new Promise<ArrayBuffer>((res) => {
150
+ resolveResult = res;
151
+ });
152
+ // Copy so the returned ArrayBuffer starts at offset 0 and is independent of the source view.
153
+ const copy = new Uint8Array(bytes);
154
+ return { blob: new DeferredBlob(result), resolve: () => resolveResult(copy.buffer) };
155
+ };
156
+
157
+ const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer => new Uint8Array(bytes).buffer;
158
+
159
+ const textMessage = (message: string) =>
160
+ protocol.createMessage(TextMessageSchema, {
161
+ serviceId: 'test-service',
162
+ payload: { message },
163
+ });
164
+
165
+ /**
166
+ * Sends each content string as a message on the same muxer/channel and returns the wire
167
+ * chunks grouped by message, mirroring how `WebSocketMuxer` splits segmented messages.
168
+ */
169
+ const buildSegmentedChunks = async (contents: string[]): Promise<Uint8Array[][]> => {
170
+ const sentMessages: Uint8Array[] = [];
171
+ const muxer = new WebSocketMuxer(
172
+ { readyState: 1, send: (message: string) => sentMessages.push(Buffer.from(message)) },
173
+ { maxChunkLength: MAX_CHUNK_LENGTH },
174
+ );
175
+
176
+ const chunkCounts: number[] = [];
177
+ for (const content of contents) {
178
+ const before = sentMessages.length;
179
+ await muxer.send(textMessage(content));
180
+ chunkCounts.push(sentMessages.length - before);
181
+ }
182
+
183
+ const chunksByMessage: Uint8Array[][] = [];
184
+ let offset = 0;
185
+ for (const count of chunkCounts) {
186
+ chunksByMessage.push(sentMessages.slice(offset, offset + count));
187
+ offset += count;
188
+ }
189
+ return chunksByMessage;
190
+ };
191
+
192
+ const openTestConnection = async (expectedMessages: number) => {
193
+ const received: Message[] = [];
194
+ const allReceived = new Trigger();
195
+ const connection = new EdgeWsConnection(
196
+ testIdentity,
197
+ { url: new URL('ws://localhost:1234') },
198
+ {
199
+ onConnected: () => {},
200
+ onMessage: (message) => {
201
+ received.push(message);
202
+ if (received.length === expectedMessages) {
203
+ allReceived.wake();
204
+ }
205
+ },
206
+ onRestartRequired: () => {},
207
+ },
208
+ );
209
+ await connection.open();
210
+ onTestFinished(async () => {
211
+ await connection.close();
212
+ });
213
+
214
+ const ws = FakeWebSocket.instances.at(-1);
215
+ invariant(ws, 'FakeWebSocket instance not created');
216
+ ws.onopen?.();
217
+
218
+ return { connection, ws, received, allReceived };
219
+ };
@@ -4,7 +4,7 @@
4
4
 
5
5
  import WebSocket from 'isomorphic-ws';
6
6
 
7
- import { scheduleTask, scheduleTaskInterval } from '@dxos/async';
7
+ import { Mutex, scheduleTask, scheduleTaskInterval } from '@dxos/async';
8
8
  import { Context, Resource } from '@dxos/context';
9
9
  import { invariant } from '@dxos/invariant';
10
10
  import { log, logInfo } from '@dxos/log';
@@ -19,6 +19,13 @@ import { toUint8Array } from './protocol';
19
19
 
20
20
  const SIGNAL_KEEPALIVE_INTERVAL = 4_000;
21
21
  const SIGNAL_KEEPALIVE_TIMEOUT = 12_000;
22
+ /**
23
+ * Watchdog self-check: if the inactivity timer fires later than its schedule by more than this,
24
+ * the local event loop was starved (heavy WASM sync compute pins it for seconds at a time) —
25
+ * our pings were not being sent and inbound pongs were not being processed, so the silence says
26
+ * nothing about the connection. Probe and re-arm instead of restarting.
27
+ */
28
+ const KEEPALIVE_WATCHDOG_LATE_TOLERANCE = 3_000;
22
29
 
23
30
  export type EdgeWsConnectionCallbacks = {
24
31
  onConnected: () => void;
@@ -36,6 +43,7 @@ export class EdgeWsConnection extends Resource {
36
43
 
37
44
  // Latency tracking.
38
45
  private _pingTimestamp: number | undefined;
46
+ private _lastPingSentTimestamp = 0;
39
47
  private _rtt = 0;
40
48
 
41
49
  // Rate tracking with sliding window.
@@ -48,6 +56,15 @@ export class EdgeWsConnection extends Resource {
48
56
  private _messagesSent = 0;
49
57
  private _messagesReceived = 0;
50
58
 
59
+ /**
60
+ * WebSocket frames arrive in order, but converting frame data to bytes is async
61
+ * (the `Blob` fallback path awaits `blob.arrayBuffer()`), and concurrent conversions
62
+ * are not guaranteed to complete in arrival order. Segmented-message reassembly in
63
+ * `WebSocketMuxer` requires chunks to reach `receiveData` in arrival order, so message
64
+ * processing is serialized through this lock.
65
+ */
66
+ private readonly _receiveMutex = new Mutex();
67
+
51
68
  constructor(
52
69
  private readonly _identity: EdgeIdentity,
53
70
  private readonly _connectionInfo: { url: URL; protocolHeader?: string; headers?: Record<string, string> },
@@ -123,6 +140,10 @@ export class EdgeWsConnection extends Resource {
123
140
  : [...baseProtocols],
124
141
  this._connectionInfo.headers ? { headers: this._connectionInfo.headers } : undefined,
125
142
  );
143
+ // Deliver frame data as `ArrayBuffer` rather than `Blob` so bytes are available
144
+ // synchronously; avoids the async `blob.arrayBuffer()` reads that can otherwise
145
+ // complete out of arrival order (see `_receiveChain`).
146
+ this._ws.binaryType = 'arraybuffer';
126
147
  const muxer = new WebSocketMuxer(this._ws);
127
148
  this._wsMuxer = muxer;
128
149
 
@@ -155,7 +176,7 @@ export class EdgeWsConnection extends Resource {
155
176
  /**
156
177
  * https://developer.mozilla.org/en-US/docs/Web/API/MessageEvent/data
157
178
  */
158
- this._ws.onmessage = async (event: WebSocket.MessageEvent) => {
179
+ this._ws.onmessage = (event: WebSocket.MessageEvent) => {
159
180
  if (!this.isOpen) {
160
181
  log.verbose('message ignored on closed connection', { event: event.type });
161
182
  return;
@@ -170,23 +191,34 @@ export class EdgeWsConnection extends Resource {
170
191
  this._rescheduleHeartbeatTimeout();
171
192
  return;
172
193
  }
173
- const bytes = await toUint8Array(event.data);
174
- this._recordBytes(0, bytes.byteLength);
175
- if (!this.isOpen) {
176
- return;
177
- }
178
194
 
179
- this._messagesReceived++;
195
+ // `_receiveMessage` serializes on `_receiveMutex`; `acquire` enqueues synchronously,
196
+ // so locks are taken in arrival order regardless of async conversion timing.
197
+ void this._receiveMessage(event.data, muxer).catch((err) => log.catch(err));
198
+ };
199
+ }
200
+
201
+ private async _receiveMessage(data: WebSocket.Data, muxer: WebSocketMuxer): Promise<void> {
202
+ // Serialize processing so bytes reach `muxer.receiveData` in arrival order. The guard
203
+ // releases on scope exit even if processing throws, so a single bad message is logged
204
+ // and dropped instead of stalling every message queued after it.
205
+ using _guard = await this._receiveMutex.acquire();
206
+ const bytes = await toUint8Array(data);
207
+ this._recordBytes(0, bytes.byteLength);
208
+ if (!this.isOpen) {
209
+ return;
210
+ }
180
211
 
181
- const message = this._ws?.protocol?.includes(EdgeWebsocketProtocol.V0)
182
- ? buf.fromBinary(MessageSchema, bytes)
183
- : muxer.receiveData(bytes);
212
+ this._messagesReceived++;
184
213
 
185
- if (message) {
186
- log('received', { from: message.source, payload: protocol.getPayloadType(message) });
187
- this._callbacks.onMessage(message);
188
- }
189
- };
214
+ const message = this._ws?.protocol?.includes(EdgeWebsocketProtocol.V0)
215
+ ? buf.fromBinary(MessageSchema, bytes)
216
+ : muxer.receiveData(bytes);
217
+
218
+ if (message) {
219
+ log('received', { from: message.source, payload: protocol.getPayloadType(message) });
220
+ this._callbacks.onMessage(message);
221
+ }
190
222
  }
191
223
 
192
224
  protected override async _close(): Promise<void> {
@@ -212,35 +244,72 @@ export class EdgeWsConnection extends Resource {
212
244
  async () => {
213
245
  // TODO(mykola): use RFC6455 ping/pong once implemented in the browser?
214
246
  // Cloudflare's worker responds to this `without interrupting hibernation`. https://developers.cloudflare.com/durable-objects/api/websockets/#setwebsocketautoresponse
215
- this._pingTimestamp = Date.now();
216
- this._ws?.send('__ping__');
247
+ this._sendPing();
217
248
  },
218
249
  SIGNAL_KEEPALIVE_INTERVAL,
219
250
  );
251
+ this._sendPing();
252
+ this._rescheduleHeartbeatTimeout();
253
+ }
254
+
255
+ private _sendPing(): void {
256
+ if (!this._ws) {
257
+ return;
258
+ }
220
259
  this._pingTimestamp = Date.now();
260
+ this._lastPingSentTimestamp = Date.now();
221
261
  this._ws.send('__ping__');
222
- this._rescheduleHeartbeatTimeout();
223
262
  }
224
263
 
264
+ /**
265
+ * Inactivity watchdog. Restarts the connection only after a fair trial: pings were actually
266
+ * flowing (a recent send), the timer fired on schedule (the local event loop was alive to
267
+ * process an answer), and still nothing was received for the full window. Wall-clock silence
268
+ * alone is not evidence — sync compute can pin the event loop for seconds, during which the
269
+ * ping sender does not run and arrived pongs are not processed; restarting a healthy
270
+ * connection on that basis costs a re-handshake and fails in-flight sync rounds.
271
+ */
225
272
  private _rescheduleHeartbeatTimeout(): void {
226
273
  if (!this.isOpen) {
227
274
  return;
228
275
  }
229
276
  void this._inactivityTimeoutCtx?.dispose();
230
277
  this._inactivityTimeoutCtx = new Context();
278
+ const armedAt = Date.now();
231
279
  scheduleTask(
232
280
  this._inactivityTimeoutCtx,
233
281
  () => {
234
- if (this.isOpen) {
235
- if (Date.now() - this._lastReceivedMessageTimestamp > SIGNAL_KEEPALIVE_TIMEOUT) {
236
- log.warn('restart due to inactivity timeout', {
237
- lastReceivedMessageTimestamp: this._lastReceivedMessageTimestamp,
238
- });
239
- this._callbacks.onRestartRequired();
240
- } else {
241
- this._rescheduleHeartbeatTimeout();
242
- }
282
+ if (!this.isOpen) {
283
+ return;
284
+ }
285
+ const now = Date.now();
286
+ const silenceMs = now - this._lastReceivedMessageTimestamp;
287
+ if (silenceMs <= SIGNAL_KEEPALIVE_TIMEOUT) {
288
+ this._rescheduleHeartbeatTimeout();
289
+ return;
243
290
  }
291
+ const pingAgeMs = this._lastPingSentTimestamp ? now - this._lastPingSentTimestamp : Number.POSITIVE_INFINITY;
292
+ const firedLateByMs = now - armedAt - SIGNAL_KEEPALIVE_TIMEOUT;
293
+ const pingsWereFlowing = pingAgeMs <= SIGNAL_KEEPALIVE_INTERVAL * 2;
294
+ const loopWasLive = firedLateByMs < KEEPALIVE_WATCHDOG_LATE_TOLERANCE;
295
+ if (pingsWereFlowing && loopWasLive) {
296
+ log.warn('restart due to inactivity timeout', {
297
+ silenceMs,
298
+ pingAgeMs,
299
+ lastReceivedMessageTimestamp: this._lastReceivedMessageTimestamp,
300
+ });
301
+ this._callbacks.onRestartRequired();
302
+ return;
303
+ }
304
+ // The silence is self-inflicted (starved event loop stopped our pings and delayed this
305
+ // timer). Probe immediately and give the connection a fresh full window to answer.
306
+ log.verbose('keepalive starved by event loop; probing instead of restarting', {
307
+ silenceMs,
308
+ pingAgeMs,
309
+ firedLateByMs,
310
+ });
311
+ this._sendPing();
312
+ this._rescheduleHeartbeatTimeout();
244
313
  },
245
314
  SIGNAL_KEEPALIVE_TIMEOUT,
246
315
  );
package/src/index.ts CHANGED
@@ -17,3 +17,4 @@ export * from './hub-http-client';
17
17
  export * from './edge-identity';
18
18
  export * from './edge-ws-muxer';
19
19
  export * from './http-client';
20
+ export * from './browser-rendering';
package/src/protocol.ts CHANGED
@@ -69,11 +69,14 @@ export class Protocol {
69
69
  {
70
70
  source,
71
71
  target,
72
+ tags,
72
73
  payload,
73
74
  serviceId,
74
75
  }: {
75
76
  source?: PeerData;
76
77
  target?: PeerData[];
78
+ // Broadcast tags (DX-1125). Set with no resolvable `target` to publish to the swarm.
79
+ tags?: string[];
77
80
  payload?: buf.MessageInitShape<Desc>;
78
81
  serviceId?: string;
79
82
  },
@@ -82,6 +85,7 @@ export class Protocol {
82
85
  timestamp: new Date().toISOString(),
83
86
  source,
84
87
  target,
88
+ tags,
85
89
  serviceId,
86
90
  payload: payload ? bufWkt.anyPack(type, buf.create(type, payload)) : undefined,
87
91
  });
@@ -97,9 +101,14 @@ export const toUint8Array = async (data: any): Promise<Uint8Array> => {
97
101
  return bufferToArray(data);
98
102
  }
99
103
 
100
- // Browser.
104
+ // Browser with `binaryType = 'arraybuffer'`.
105
+ if (data instanceof ArrayBuffer) {
106
+ return new Uint8Array(data);
107
+ }
108
+
109
+ // Browser fallback (`binaryType = 'blob'`, the WebSocket default).
101
110
  if (data instanceof Blob) {
102
- return new Uint8Array(await (data as Blob).arrayBuffer());
111
+ return new Uint8Array(await data.arrayBuffer());
103
112
  }
104
113
 
105
114
  throw new Error(`Unexpected datatype: ${data}`);
@@ -62,7 +62,11 @@ export const createTestEdgeWsServer = async (port = DEFAULT_PORT, params?: TestE
62
62
  });
63
63
 
64
64
  ws.on('close', () => {
65
- connection = undefined;
65
+ // During a reconnect the new connection may be admitted before the old
66
+ // socket's close event fires; only clear if this socket is still current.
67
+ if (connection?.ws === ws) {
68
+ connection = undefined;
69
+ }
66
70
  closeTrigger.wake();
67
71
  });
68
72
  });
@@ -1,10 +0,0 @@
1
- var __defProp = Object.defineProperty;
2
- var __export = (target, all) => {
3
- for (var name in all)
4
- __defProp(target, name, { get: all[name], enumerable: true });
5
- };
6
-
7
- export {
8
- __export
9
- };
10
- //# sourceMappingURL=chunk-J5LGTIGS.mjs.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": [],
4
- "sourcesContent": [],
5
- "mappings": "",
6
- "names": []
7
- }