@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.
- package/dist/lib/chunk-edge-ws-muxer.mjs +322 -0
- package/dist/lib/chunk-edge-ws-muxer.mjs.map +1 -0
- package/dist/lib/cors-proxy.mjs +29 -0
- package/dist/lib/cors-proxy.mjs.map +1 -0
- package/dist/lib/edge-ws-muxer.mjs +2 -0
- package/dist/lib/index.mjs +1769 -0
- package/dist/lib/index.mjs.map +1 -0
- package/dist/lib/service.mjs +138 -0
- package/dist/lib/service.mjs.map +1 -0
- package/dist/lib/testing.mjs +160 -0
- package/dist/lib/testing.mjs.map +1 -0
- package/dist/types/src/base-http-client.d.ts +18 -0
- package/dist/types/src/base-http-client.d.ts.map +1 -1
- package/dist/types/src/browser-rendering.d.ts +0 -7
- package/dist/types/src/browser-rendering.d.ts.map +1 -1
- package/dist/types/src/edge-client.d.ts +22 -2
- package/dist/types/src/edge-client.d.ts.map +1 -1
- package/dist/types/src/edge-http-client.d.ts +86 -2
- package/dist/types/src/edge-http-client.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.d.ts +19 -0
- package/dist/types/src/edge-ws-connection.d.ts.map +1 -1
- package/dist/types/src/edge-ws-connection.test.d.ts +2 -0
- package/dist/types/src/edge-ws-connection.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/protocol.d.ts +2 -1
- package/dist/types/src/protocol.d.ts.map +1 -1
- package/dist/types/src/testing/test-utils.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/base-http-client.ts +71 -3
- package/src/browser-rendering.ts +0 -9
- package/src/edge-client.ts +21 -3
- package/src/edge-http-client.test.ts +121 -0
- package/src/edge-http-client.ts +156 -2
- package/src/edge-ws-connection.test.ts +219 -0
- package/src/edge-ws-connection.ts +97 -28
- package/src/index.ts +1 -0
- package/src/protocol.ts +11 -2
- package/src/testing/test-utils.ts +5 -1
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs +0 -10
- package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +0 -7
- package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs +0 -310
- package/dist/lib/neutral/chunk-L5ZHLJ4B.mjs.map +0 -7
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs +0 -30
- package/dist/lib/neutral/chunk-WQKMEZJR.mjs.map +0 -7
- package/dist/lib/neutral/cors-proxy.mjs +0 -8
- package/dist/lib/neutral/cors-proxy.mjs.map +0 -7
- package/dist/lib/neutral/edge-ws-muxer.mjs +0 -12
- package/dist/lib/neutral/edge-ws-muxer.mjs.map +0 -7
- package/dist/lib/neutral/index.mjs +0 -1524
- package/dist/lib/neutral/index.mjs.map +0 -7
- package/dist/lib/neutral/meta.json +0 -1
- package/dist/lib/neutral/service/index.mjs +0 -134
- package/dist/lib/neutral/service/index.mjs.map +0 -7
- package/dist/lib/neutral/testing/index.mjs +0 -161
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
182
|
-
? buf.fromBinary(MessageSchema, bytes)
|
|
183
|
-
: muxer.receiveData(bytes);
|
|
212
|
+
this._messagesReceived++;
|
|
184
213
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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.
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
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
|
|
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
|
|
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
|
});
|