@persistica/flux-agent 0.0.28 → 0.0.35
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/README.md +21 -15
- package/index.js +210 -57
- package/package.json +1 -1
- package/src/lib/connector/low-level-com/web-rtc/ice-connection.d.ts +17 -1
- package/src/lib/flux-agent.class.d.ts +8 -0
package/README.md
CHANGED
|
@@ -1,22 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @persistica/flux-agent
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
import { FluxAgent, FluxAgentNetworkConnection } from '@persistica/flux-agent';
|
|
3
|
+
Connect an **Agent** to a Flux network, join channels, and publish/subscribe over the Flux mesh.
|
|
5
4
|
|
|
6
|
-
|
|
5
|
+
```bash
|
|
6
|
+
bun add @persistica/flux-agent # or: npm i @persistica/flux-agent
|
|
7
|
+
```
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
```ts
|
|
10
|
+
import { FluxAgent } from '@persistica/flux-agent';
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
const fluxNetworkConnection: FluxAgentNetworkConnection = await fluxAgent
|
|
12
|
-
.connect(
|
|
13
|
-
'CODE_TO_ACCESS_NETWORK',
|
|
14
|
-
);
|
|
12
|
+
const agent = new FluxAgent('my-network-id');
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
// The argument is your claim — whatever your Authority accepts.
|
|
15
|
+
const connection = await agent.connect({ user: 'alice' });
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
const channel = await connection.joinChannel('connected-agents');
|
|
18
|
+
channel.onPublish((msg) => console.log('received', msg));
|
|
19
|
+
channel.publish({ text: 'hello mesh' });
|
|
20
|
+
```
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
> A network needs an **Authority** ([`@persistica/flux-authority`](../authority/README.md)) running
|
|
23
|
+
> before any Agent can join. With none present, `connect()` stays in the `waiting-for-authority`
|
|
24
|
+
> state and keeps retrying. Attach `agent.onNetworkState(...)` and wrap `connect()` in a `try/catch`
|
|
25
|
+
> to observe this.
|
|
26
|
+
|
|
27
|
+
Full reference (options, connection states, error handling, troubleshooting):
|
|
28
|
+
[docs/sdk/flux-agent.md](../../../docs/sdk/flux-agent.md).
|
package/index.js
CHANGED
|
@@ -43,6 +43,8 @@ var UNSUBSCRIBE_NETWORK_CHANNEL_NAME = "unsubscribe-nc-name";
|
|
|
43
43
|
var UNSUBSCRIBED_NETWORK_CHANNEL_NAME = "unsubscribed-nc-name";
|
|
44
44
|
var AUTHORITY_CHANNEL_SUBSCRIBE = "acs";
|
|
45
45
|
var AUTHORITY_DISCONNECT_AGENT = "ada";
|
|
46
|
+
var HEARTBEAT_PING = "hb-ping";
|
|
47
|
+
var HEARTBEAT_PONG = "hb-pong";
|
|
46
48
|
var ERROR = "e";
|
|
47
49
|
|
|
48
50
|
// libs/flux/shared/types/src/lib/client.type.ts
|
|
@@ -147,11 +149,21 @@ var WebSocketClient = class extends RPCServer {
|
|
|
147
149
|
ws;
|
|
148
150
|
isOpen = false;
|
|
149
151
|
// Is the connection open
|
|
152
|
+
// * Heartbeat (see #488): a half-open socket (1006, no close frame) never fires
|
|
153
|
+
// `onclose`, so the client pings on an interval and treats a missed pong as the
|
|
154
|
+
// disconnect signal itself — it cannot rely on `ws.close()` producing an event.
|
|
155
|
+
heartbeatTimer;
|
|
156
|
+
awaitingPong = false;
|
|
157
|
+
pongSeen = false;
|
|
158
|
+
// The server proved it answers pings on this connection
|
|
159
|
+
closeHandled = false;
|
|
160
|
+
// Guards against running the close path twice (heartbeat + late onclose)
|
|
150
161
|
constructor(options) {
|
|
151
162
|
super();
|
|
152
163
|
this.options = {
|
|
153
164
|
autoReconnect: true,
|
|
154
165
|
reconnectDelay: 1e3,
|
|
166
|
+
maxReconnectDelay: 3e4,
|
|
155
167
|
heartbeatInterval: 6e4,
|
|
156
168
|
connectionTimeout: 5e3,
|
|
157
169
|
...options
|
|
@@ -162,50 +174,48 @@ var WebSocketClient = class extends RPCServer {
|
|
|
162
174
|
*/
|
|
163
175
|
connect() {
|
|
164
176
|
return new Promise((resolve, reject) => {
|
|
177
|
+
this.closeHandled = false;
|
|
165
178
|
this.emit("connecting", this.reconnectAttempts);
|
|
166
|
-
|
|
179
|
+
const socket = new WebSocket(
|
|
167
180
|
this.options.url
|
|
168
181
|
);
|
|
182
|
+
this.ws = socket;
|
|
169
183
|
const timeout = setTimeout(() => {
|
|
170
|
-
if (
|
|
171
|
-
|
|
184
|
+
if (socket.readyState !== WebSocket.OPEN) {
|
|
185
|
+
socket.close();
|
|
172
186
|
reject(new Error("Connection timeout"));
|
|
173
187
|
}
|
|
174
188
|
}, this.options.connectionTimeout);
|
|
175
|
-
|
|
189
|
+
socket.onopen = () => {
|
|
176
190
|
clearTimeout(timeout);
|
|
191
|
+
if (this.ws !== socket) {
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
177
194
|
this.reconnectAttempts = 0;
|
|
178
195
|
this.isOpen = true;
|
|
196
|
+
this.startHeartbeat();
|
|
179
197
|
this.emit("open");
|
|
180
198
|
resolve();
|
|
181
199
|
};
|
|
182
|
-
|
|
200
|
+
socket.onmessage = (event) => {
|
|
201
|
+
if (this.ws !== socket) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (event.data === HEARTBEAT_PONG) {
|
|
205
|
+
this.awaitingPong = false;
|
|
206
|
+
this.pongSeen = true;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
183
209
|
this.emit("message", event.data);
|
|
184
210
|
};
|
|
185
|
-
|
|
186
|
-
const wasKicked = closeEvent.reason === "Kicked by process";
|
|
211
|
+
socket.onclose = (closeEvent) => {
|
|
187
212
|
clearTimeout(timeout);
|
|
188
|
-
if (this.
|
|
189
|
-
|
|
190
|
-
"close",
|
|
191
|
-
wasKicked ? "kicked" : void 0
|
|
192
|
-
);
|
|
193
|
-
this.isOpen = false;
|
|
194
|
-
}
|
|
195
|
-
if (this.options.autoReconnect && (this.options.retries === void 0 || this.reconnectAttempts < this.options.retries)) {
|
|
196
|
-
const delay = wasKicked ? RECONNECTION_DELAY_ON_KICK_MS : Math.min(
|
|
197
|
-
this.options.reconnectDelay ?? Number.POSITIVE_INFINITY,
|
|
198
|
-
(this.options.reconnectDelay ?? 0) * (this.reconnectAttempts || 1)
|
|
199
|
-
);
|
|
200
|
-
setTimeout(() => {
|
|
201
|
-
this.reconnectAttempts++;
|
|
202
|
-
void this.connect();
|
|
203
|
-
}, delay);
|
|
204
|
-
} else if (this.options.autoReconnect) {
|
|
205
|
-
this.emit("error", new Error("Connection failed: retries exhausted"));
|
|
213
|
+
if (this.ws !== socket) {
|
|
214
|
+
return;
|
|
206
215
|
}
|
|
216
|
+
this.handleConnectionClosed(closeEvent.reason === "Kicked by process");
|
|
207
217
|
};
|
|
208
|
-
|
|
218
|
+
socket.onerror = (event) => {
|
|
209
219
|
if (event?.message?.includes("Failed to connect")) {
|
|
210
220
|
} else {
|
|
211
221
|
console.log("\u274C Error", event.message);
|
|
@@ -213,6 +223,84 @@ var WebSocketClient = class extends RPCServer {
|
|
|
213
223
|
};
|
|
214
224
|
});
|
|
215
225
|
}
|
|
226
|
+
/**
|
|
227
|
+
* The single close path, reachable from `onclose` AND from a missed heartbeat
|
|
228
|
+
* pong (where `onclose` never fires): emit `close` once, then schedule the
|
|
229
|
+
* reconnect per the retry policy.
|
|
230
|
+
*/
|
|
231
|
+
handleConnectionClosed(wasKicked) {
|
|
232
|
+
if (this.closeHandled) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
this.closeHandled = true;
|
|
236
|
+
this.stopHeartbeat();
|
|
237
|
+
if (this.isOpen) {
|
|
238
|
+
this.emit(
|
|
239
|
+
"close",
|
|
240
|
+
wasKicked ? "kicked" : void 0
|
|
241
|
+
);
|
|
242
|
+
this.isOpen = false;
|
|
243
|
+
}
|
|
244
|
+
if (this.options.autoReconnect && (this.options.retries === void 0 || this.reconnectAttempts < this.options.retries)) {
|
|
245
|
+
const delay = wasKicked ? RECONNECTION_DELAY_ON_KICK_MS : Math.min(
|
|
246
|
+
this.options.maxReconnectDelay ?? Number.POSITIVE_INFINITY,
|
|
247
|
+
(this.options.reconnectDelay ?? 0) * 2 ** this.reconnectAttempts
|
|
248
|
+
);
|
|
249
|
+
setTimeout(() => {
|
|
250
|
+
this.reconnectAttempts++;
|
|
251
|
+
this.connect().catch((error) => {
|
|
252
|
+
this.emit(
|
|
253
|
+
"error",
|
|
254
|
+
error instanceof Error ? error : new Error(String(error))
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}, delay);
|
|
258
|
+
} else if (this.options.autoReconnect) {
|
|
259
|
+
this.emit("error", new Error("Connection failed: retries exhausted"));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Starts the keepalive: every `heartbeatInterval` send an app-level ping (the
|
|
264
|
+
* browser/Bun `WebSocket` exposes no ping frame) and require the mesh's pong
|
|
265
|
+
* before the next tick. A missed pong means the socket is dead — force the
|
|
266
|
+
* close path directly, since a half-open socket produces no `onclose`.
|
|
267
|
+
*/
|
|
268
|
+
startHeartbeat() {
|
|
269
|
+
this.stopHeartbeat();
|
|
270
|
+
this.pongSeen = false;
|
|
271
|
+
const interval = this.options.heartbeatInterval;
|
|
272
|
+
if (interval === void 0 || interval <= 0) {
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.heartbeatTimer = setInterval(() => {
|
|
276
|
+
if (this.awaitingPong) {
|
|
277
|
+
if (!this.pongSeen) {
|
|
278
|
+
console.warn("\u{1F49B} Server does not answer heartbeat pings \u2014 dead-connection detection disabled for this connection");
|
|
279
|
+
this.stopHeartbeat();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
console.warn(`\u{1F494} No heartbeat pong within ${interval}ms \u2014 treating the connection as dead`);
|
|
283
|
+
this.stopHeartbeat();
|
|
284
|
+
try {
|
|
285
|
+
this.ws?.close(4e3, "Heartbeat timeout");
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
this.handleConnectionClosed(false);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
292
|
+
this.awaitingPong = true;
|
|
293
|
+
this.ws.send(HEARTBEAT_PING);
|
|
294
|
+
}
|
|
295
|
+
}, interval);
|
|
296
|
+
}
|
|
297
|
+
stopHeartbeat() {
|
|
298
|
+
if (this.heartbeatTimer !== void 0) {
|
|
299
|
+
clearInterval(this.heartbeatTimer);
|
|
300
|
+
this.heartbeatTimer = void 0;
|
|
301
|
+
}
|
|
302
|
+
this.awaitingPong = false;
|
|
303
|
+
}
|
|
216
304
|
/**
|
|
217
305
|
* Emits a WebSocket event to all registered listeners.
|
|
218
306
|
*/
|
|
@@ -253,6 +341,7 @@ var WebSocketClient = class extends RPCServer {
|
|
|
253
341
|
*/
|
|
254
342
|
close() {
|
|
255
343
|
this.options.autoReconnect = false;
|
|
344
|
+
this.stopHeartbeat();
|
|
256
345
|
this.ws?.close();
|
|
257
346
|
this.clearEventSubscribers();
|
|
258
347
|
}
|
|
@@ -290,22 +379,12 @@ var peerConnectionConfig = {
|
|
|
290
379
|
]
|
|
291
380
|
};
|
|
292
381
|
var ICEConnection = class {
|
|
293
|
-
constructor(_fluxWebSocketClientConnection, _stateChange) {
|
|
382
|
+
constructor(_fluxWebSocketClientConnection, _stateChange, _onMessage) {
|
|
294
383
|
this._fluxWebSocketClientConnection = _fluxWebSocketClientConnection;
|
|
295
384
|
this._stateChange = _stateChange;
|
|
385
|
+
this._onMessage = _onMessage;
|
|
296
386
|
this.peerConnection.ondatachannel = (event) => {
|
|
297
|
-
|
|
298
|
-
this.dataChannel = event.channel;
|
|
299
|
-
this.dataChannel.onopen = () => console.log("ReceiveChannel opened");
|
|
300
|
-
this.dataChannel.onmessage = (event2) => {
|
|
301
|
-
console.log("Receiving ICE event", event2);
|
|
302
|
-
};
|
|
303
|
-
this.dataChannel.onopen = () => {
|
|
304
|
-
console.log("Opened");
|
|
305
|
-
};
|
|
306
|
-
this.dataChannel.onclose = () => {
|
|
307
|
-
console.log("Opened");
|
|
308
|
-
};
|
|
387
|
+
this._wireDataChannel(event.channel);
|
|
309
388
|
};
|
|
310
389
|
this.peerConnection.onicecandidate = (event) => {
|
|
311
390
|
if (event.candidate) {
|
|
@@ -323,10 +402,10 @@ var ICEConnection = class {
|
|
|
323
402
|
}
|
|
324
403
|
_fluxWebSocketClientConnection;
|
|
325
404
|
_stateChange;
|
|
405
|
+
_onMessage;
|
|
326
406
|
//extends RPCServer<'createOffer' | 'acceptOffer' | 'acceptAnswer'> {
|
|
327
407
|
peerConnection = new RTCPeerConnection(peerConnectionConfig);
|
|
328
408
|
dataChannel;
|
|
329
|
-
// = this.peerConnection.createDataChannel('flux-channel');
|
|
330
409
|
offerSDP;
|
|
331
410
|
/**
|
|
332
411
|
* This offer must be sent to the other peer.
|
|
@@ -335,7 +414,7 @@ var ICEConnection = class {
|
|
|
335
414
|
*/
|
|
336
415
|
async createOffer() {
|
|
337
416
|
console.log("creating offer and data channel");
|
|
338
|
-
this.
|
|
417
|
+
this._wireDataChannel(this.peerConnection.createDataChannel("flux-channel"));
|
|
339
418
|
this._stateChange("creating-offer");
|
|
340
419
|
const offer = await this.peerConnection.createOffer();
|
|
341
420
|
await this.peerConnection.setLocalDescription(offer);
|
|
@@ -380,9 +459,28 @@ var ICEConnection = class {
|
|
|
380
459
|
answerAcceptedByInitiator(answerAccepted) {
|
|
381
460
|
this._stateChange(answerAccepted ? "connected" : "failed");
|
|
382
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* Wires up a data channel's lifecycle handlers. Used for both the initiator's
|
|
464
|
+
* locally-created channel and the receiver's incoming channel so inbound
|
|
465
|
+
* messages are delivered the same way on both ends.
|
|
466
|
+
*
|
|
467
|
+
* @param { RTCDataChannel } channel - The data channel to wire up
|
|
468
|
+
*/
|
|
469
|
+
_wireDataChannel(channel) {
|
|
470
|
+
this.dataChannel = channel;
|
|
471
|
+
channel.onopen = () => {
|
|
472
|
+
console.log("Data channel opened");
|
|
473
|
+
};
|
|
474
|
+
channel.onclose = () => {
|
|
475
|
+
console.log("Data channel closed");
|
|
476
|
+
};
|
|
477
|
+
channel.onmessage = (event) => {
|
|
478
|
+
this._onMessage(event.data);
|
|
479
|
+
};
|
|
480
|
+
}
|
|
383
481
|
/**
|
|
384
482
|
* Sends a message to the connected peer via the WebRTC data channel.
|
|
385
|
-
*
|
|
483
|
+
*
|
|
386
484
|
* @param { string } message - The message to send
|
|
387
485
|
*/
|
|
388
486
|
sendMessage(message) {
|
|
@@ -397,14 +495,16 @@ var ICEConnection = class {
|
|
|
397
495
|
|
|
398
496
|
// libs/flux/shared/connection/src/lib/agent/flux-agent-network.class.ts
|
|
399
497
|
var FluxAgentNetworkConnection = class {
|
|
400
|
-
constructor(_fluxWebSocketConnection, _fluxWebSocketClientConnection, _webRTCStateChange) {
|
|
498
|
+
constructor(_fluxWebSocketConnection, _fluxWebSocketClientConnection, _webRTCStateChange, _onDirectMessage) {
|
|
401
499
|
this._fluxWebSocketConnection = _fluxWebSocketConnection;
|
|
402
500
|
this._fluxWebSocketClientConnection = _fluxWebSocketClientConnection;
|
|
403
501
|
this._webRTCStateChange = _webRTCStateChange;
|
|
502
|
+
this._onDirectMessage = _onDirectMessage;
|
|
404
503
|
if (typeof RTCPeerConnection !== "undefined") {
|
|
405
504
|
this.iceConnection = new ICEConnection(
|
|
406
505
|
this._fluxWebSocketClientConnection,
|
|
407
|
-
this._webRTCStateChange
|
|
506
|
+
this._webRTCStateChange,
|
|
507
|
+
this._onDirectMessage
|
|
408
508
|
);
|
|
409
509
|
console.log(`RTCPeerConnection is available`);
|
|
410
510
|
} else {
|
|
@@ -414,6 +514,7 @@ var FluxAgentNetworkConnection = class {
|
|
|
414
514
|
_fluxWebSocketConnection;
|
|
415
515
|
_fluxWebSocketClientConnection;
|
|
416
516
|
_webRTCStateChange;
|
|
517
|
+
_onDirectMessage;
|
|
417
518
|
iceConnection;
|
|
418
519
|
connectedChannelSet = /* @__PURE__ */ new Set();
|
|
419
520
|
/**
|
|
@@ -514,8 +615,10 @@ var FluxNetworkChannel = class {
|
|
|
514
615
|
onPublish(fn) {
|
|
515
616
|
this._fluxWebSocketConnection.onPublish(
|
|
516
617
|
this._channelName,
|
|
618
|
+
// The transport types payloads as `string` (TMessageCallback), but
|
|
619
|
+
// `o:`-prefixed messages are JSON-parsed before delivery, so the
|
|
620
|
+
// callback actually receives `T`. Bridge the two deliberately.
|
|
517
621
|
fn
|
|
518
|
-
// TODO
|
|
519
622
|
);
|
|
520
623
|
}
|
|
521
624
|
};
|
|
@@ -734,7 +837,7 @@ var FluxWebSocketConnection = class {
|
|
|
734
837
|
});
|
|
735
838
|
this.socket.clearEventSubscribers();
|
|
736
839
|
this.socket.on("message", this.socketMessageHandler).on("close", this.socketCloseHandler).on("connecting", this.socketConnectingHandler).on("error", this.socketErrorHandler);
|
|
737
|
-
|
|
840
|
+
this.socket.connect().catch(this.socketErrorHandler);
|
|
738
841
|
return this.connectPromise;
|
|
739
842
|
}
|
|
740
843
|
/**
|
|
@@ -768,7 +871,8 @@ var FluxWebSocketConnection = class {
|
|
|
768
871
|
return Promise.resolve(new FluxAgentNetworkConnection(
|
|
769
872
|
this,
|
|
770
873
|
webSocketClient,
|
|
771
|
-
this.stateManager.emitWebRTCState.bind(this.stateManager)
|
|
874
|
+
this.stateManager.emitWebRTCState.bind(this.stateManager),
|
|
875
|
+
this.stateManager.emitDirectMessage.bind(this.stateManager)
|
|
772
876
|
));
|
|
773
877
|
}
|
|
774
878
|
/**
|
|
@@ -796,7 +900,7 @@ var FluxWebSocketConnection = class {
|
|
|
796
900
|
*/
|
|
797
901
|
publish(channelName, message) {
|
|
798
902
|
if (this.webSocketClient) {
|
|
799
|
-
const messageString = typeof message === "string" ? message : `o:${JSON.stringify(message)}`;
|
|
903
|
+
const messageString = typeof message === "string" ? `s:${message}` : `o:${JSON.stringify(message)}`;
|
|
800
904
|
this.webSocketClient.send(`${NETWORK_CHANNEL_PUBLISH}:${channelName}:${messageString}`);
|
|
801
905
|
}
|
|
802
906
|
}
|
|
@@ -909,13 +1013,18 @@ var FluxWebSocketConnection = class {
|
|
|
909
1013
|
if (validateChannelNameOrThrow(channelName)) {
|
|
910
1014
|
const topicCallbacks = this.channelCallbacks.get(channelName);
|
|
911
1015
|
if (topicCallbacks) {
|
|
912
|
-
|
|
913
|
-
|
|
1016
|
+
const raw = message_.slice(thirdColon + 1);
|
|
1017
|
+
let data;
|
|
1018
|
+
if (raw.startsWith("o:")) {
|
|
914
1019
|
try {
|
|
915
|
-
data = JSON.parse(
|
|
1020
|
+
data = JSON.parse(raw.substring(2));
|
|
916
1021
|
} catch {
|
|
917
|
-
data =
|
|
1022
|
+
data = raw.substring(2);
|
|
918
1023
|
}
|
|
1024
|
+
} else if (raw.startsWith("s:")) {
|
|
1025
|
+
data = raw.substring(2);
|
|
1026
|
+
} else {
|
|
1027
|
+
data = raw;
|
|
919
1028
|
}
|
|
920
1029
|
for (const cb of topicCallbacks) {
|
|
921
1030
|
cb(data);
|
|
@@ -1002,6 +1111,7 @@ async function encrypt(data, password) {
|
|
|
1002
1111
|
var StateManager = class {
|
|
1003
1112
|
networkStateListeners = /* @__PURE__ */ new Set();
|
|
1004
1113
|
webRTCStateListeners = /* @__PURE__ */ new Set();
|
|
1114
|
+
directMessageListeners = /* @__PURE__ */ new Set();
|
|
1005
1115
|
/**
|
|
1006
1116
|
* Attaches a listener for network connection state changes.
|
|
1007
1117
|
*/
|
|
@@ -1026,6 +1136,28 @@ var StateManager = class {
|
|
|
1026
1136
|
detachWebRTCStateListener(fn) {
|
|
1027
1137
|
this.webRTCStateListeners.delete(fn);
|
|
1028
1138
|
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Attaches a listener for messages received directly from a peer over a
|
|
1141
|
+
* WebRTC data channel (i.e. off-Mesh, peer-to-peer).
|
|
1142
|
+
*/
|
|
1143
|
+
attachDirectMessageListener(fn) {
|
|
1144
|
+
this.directMessageListeners.add(fn);
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Detaches a previously registered direct-message listener.
|
|
1148
|
+
*/
|
|
1149
|
+
detachDirectMessageListener(fn) {
|
|
1150
|
+
this.directMessageListeners.delete(fn);
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Emits a message received over a peer-to-peer WebRTC data channel to all
|
|
1154
|
+
* registered listeners.
|
|
1155
|
+
*/
|
|
1156
|
+
emitDirectMessage(message) {
|
|
1157
|
+
for (const fn of this.directMessageListeners) {
|
|
1158
|
+
fn(message);
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1029
1161
|
/**
|
|
1030
1162
|
* Emits a network connection state change to all registered listeners.
|
|
1031
1163
|
*/
|
|
@@ -1123,12 +1255,12 @@ var getMachineUID = async () => {
|
|
|
1123
1255
|
if (typeof globalThis !== "object") {
|
|
1124
1256
|
throw new Error("Flux can only be used in an environment with globalThis");
|
|
1125
1257
|
}
|
|
1126
|
-
if (globalThis.__flux_client_id !== null) {
|
|
1258
|
+
if (globalThis.__flux_client_id !== null && globalThis.__flux_client_id !== void 0) {
|
|
1127
1259
|
console.log("[flux-client] Reusing existing id");
|
|
1128
1260
|
return globalThis.__flux_client_id;
|
|
1129
1261
|
}
|
|
1130
1262
|
globalThis.__flux_client_id = null;
|
|
1131
|
-
return
|
|
1263
|
+
return null;
|
|
1132
1264
|
};
|
|
1133
1265
|
|
|
1134
1266
|
// packages/flux/agent/src/lib/connector/auth/register-client.auth.ts
|
|
@@ -1206,6 +1338,7 @@ var FluxClientData = class {
|
|
|
1206
1338
|
|
|
1207
1339
|
// packages/flux/agent/src/lib/flux-agent.class.ts
|
|
1208
1340
|
var DEFAULT_FLUX_DOMAIN = "https://mesh.persistica.io";
|
|
1341
|
+
var MAX_CONNECT_ATTEMPTS = 100;
|
|
1209
1342
|
var FluxAgent = class {
|
|
1210
1343
|
id = nanoid();
|
|
1211
1344
|
networkId;
|
|
@@ -1235,6 +1368,7 @@ var FluxAgent = class {
|
|
|
1235
1368
|
if (clientUId && !validateAgentUIDOrThrow(clientUId)) {
|
|
1236
1369
|
throw new Error("Will never be thrown");
|
|
1237
1370
|
}
|
|
1371
|
+
let warnedWaitingForAuthority = false;
|
|
1238
1372
|
const ticket = await retryOrThrow(
|
|
1239
1373
|
async () => {
|
|
1240
1374
|
return authenticateAgentOrThrow(
|
|
@@ -1250,10 +1384,21 @@ var FluxAgent = class {
|
|
|
1250
1384
|
// Retry if no authority was found yet
|
|
1251
1385
|
(error) => error instanceof NetworkAuthorityNotFoundError,
|
|
1252
1386
|
{
|
|
1253
|
-
|
|
1387
|
+
// Fixed by the SDK, not the consuming app: Agents run on untrusted client
|
|
1388
|
+
// machines, so the mesh operator — not the app — controls how long an Agent
|
|
1389
|
+
// waits for an Authority to appear.
|
|
1390
|
+
retries: MAX_CONNECT_ATTEMPTS,
|
|
1254
1391
|
delayMs: 500,
|
|
1255
|
-
|
|
1256
|
-
|
|
1392
|
+
onRetry: (attempt, retries) => {
|
|
1393
|
+
this.stateManager.emitNetworkState("waiting-for-authority");
|
|
1394
|
+
if (!warnedWaitingForAuthority) {
|
|
1395
|
+
warnedWaitingForAuthority = true;
|
|
1396
|
+
console.warn(
|
|
1397
|
+
`[flux-agent] No Authority is registered on network '${this.networkId}' yet. An Authority must be running before Agents can join; retrying up to ${retries} times until one appears. If this never resolves, start your Authority process (or check its Network Access Token).`
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
return Math.min(3e3, 500 + attempt * 200);
|
|
1401
|
+
}
|
|
1257
1402
|
}
|
|
1258
1403
|
);
|
|
1259
1404
|
this.fluxWebSocketConnection = createWSConnection(
|
|
@@ -1288,6 +1433,14 @@ var FluxAgent = class {
|
|
|
1288
1433
|
* Attaches a listener for WebRTC connection state changes.
|
|
1289
1434
|
*/
|
|
1290
1435
|
onWebRTConnectionState = this.stateManager.attachWebRTCStateListener.bind(this.stateManager);
|
|
1436
|
+
/**
|
|
1437
|
+
* Attaches a listener for messages received directly from a peer over a
|
|
1438
|
+
* WebRTC data channel (off-Mesh, peer-to-peer).
|
|
1439
|
+
*
|
|
1440
|
+
* NB: unlike channel messages, the sender identity is NOT Mesh-stamped
|
|
1441
|
+
* (see ADR-0004) — the message is whatever arrived on the peer connection.
|
|
1442
|
+
*/
|
|
1443
|
+
onDirectPublish = this.stateManager.attachDirectMessageListener.bind(this.stateManager);
|
|
1291
1444
|
/**
|
|
1292
1445
|
* Attaches a listener for network connection state changes.
|
|
1293
1446
|
*/
|
package/package.json
CHANGED
|
@@ -9,10 +9,18 @@ import type { FluxWebSocketClientConnection } from '@flux/shared/ws';
|
|
|
9
9
|
export declare class ICEConnection {
|
|
10
10
|
private readonly _fluxWebSocketClientConnection;
|
|
11
11
|
private readonly _stateChange;
|
|
12
|
+
/**
|
|
13
|
+
* Called with every message received from the peer over the data channel.
|
|
14
|
+
*/
|
|
15
|
+
private readonly _onMessage;
|
|
12
16
|
private readonly peerConnection;
|
|
13
17
|
private dataChannel;
|
|
14
18
|
private offerSDP;
|
|
15
|
-
constructor(_fluxWebSocketClientConnection: FluxWebSocketClientConnection, _stateChange: (state: TRTCState) => void
|
|
19
|
+
constructor(_fluxWebSocketClientConnection: FluxWebSocketClientConnection, _stateChange: (state: TRTCState) => void,
|
|
20
|
+
/**
|
|
21
|
+
* Called with every message received from the peer over the data channel.
|
|
22
|
+
*/
|
|
23
|
+
_onMessage: (message: string) => void);
|
|
16
24
|
/**
|
|
17
25
|
* This offer must be sent to the other peer.
|
|
18
26
|
*
|
|
@@ -41,6 +49,14 @@ export declare class ICEConnection {
|
|
|
41
49
|
* @param { boolean } answerAccepted - Whether the answer was accepted
|
|
42
50
|
*/
|
|
43
51
|
private answerAcceptedByInitiator;
|
|
52
|
+
/**
|
|
53
|
+
* Wires up a data channel's lifecycle handlers. Used for both the initiator's
|
|
54
|
+
* locally-created channel and the receiver's incoming channel so inbound
|
|
55
|
+
* messages are delivered the same way on both ends.
|
|
56
|
+
*
|
|
57
|
+
* @param { RTCDataChannel } channel - The data channel to wire up
|
|
58
|
+
*/
|
|
59
|
+
private _wireDataChannel;
|
|
44
60
|
/**
|
|
45
61
|
* Sends a message to the connected peer via the WebRTC data channel.
|
|
46
62
|
*
|
|
@@ -30,6 +30,14 @@ export declare class FluxAgent {
|
|
|
30
30
|
* Attaches a listener for WebRTC connection state changes.
|
|
31
31
|
*/
|
|
32
32
|
onWebRTConnectionState: (fn: (rtcState: import("@flux/shared/utils").TRTCState) => void) => void;
|
|
33
|
+
/**
|
|
34
|
+
* Attaches a listener for messages received directly from a peer over a
|
|
35
|
+
* WebRTC data channel (off-Mesh, peer-to-peer).
|
|
36
|
+
*
|
|
37
|
+
* NB: unlike channel messages, the sender identity is NOT Mesh-stamped
|
|
38
|
+
* (see ADR-0004) — the message is whatever arrived on the peer connection.
|
|
39
|
+
*/
|
|
40
|
+
onDirectPublish: (fn: (message: string) => void) => void;
|
|
33
41
|
/**
|
|
34
42
|
* Attaches a listener for network connection state changes.
|
|
35
43
|
*/
|