@kubun/plugin-p2p 0.10.0 → 0.11.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.
- package/lib/context/delegation.js +130 -1
- package/lib/context/group.js +1319 -1
- package/lib/context/hub.js +150 -1
- package/lib/context/join.js +80 -1
- package/lib/context/sync.js +19 -1
- package/lib/context/types.d.ts +12 -3
- package/lib/context/types.js +28 -1
- package/lib/groups/admin-roster.d.ts +130 -0
- package/lib/groups/admin-roster.js +184 -0
- package/lib/groups/broadcast-codec.js +6 -1
- package/lib/groups/broadcast.d.ts +122 -38
- package/lib/groups/broadcast.js +627 -1
- package/lib/groups/circle-projection.d.ts +122 -0
- package/lib/groups/circle-projection.js +288 -0
- package/lib/groups/circle-reducers.d.ts +178 -0
- package/lib/groups/circle-reducers.js +198 -0
- package/lib/groups/events.d.ts +7 -2
- package/lib/groups/events.js +10 -1
- package/lib/groups/group-anchor.d.ts +63 -0
- package/lib/groups/group-anchor.js +107 -0
- package/lib/groups/group-crypto.d.ts +19 -0
- package/lib/groups/group-crypto.js +85 -0
- package/lib/groups/group-handle-registry.d.ts +69 -2
- package/lib/groups/group-handle-registry.js +243 -1
- package/lib/groups/group-handlers.d.ts +29 -0
- package/lib/groups/group-handlers.js +92 -0
- package/lib/groups/group-health-monitor.d.ts +56 -0
- package/lib/groups/group-health-monitor.js +139 -0
- package/lib/groups/group-mls.d.ts +35 -0
- package/lib/groups/group-mls.js +164 -0
- package/lib/groups/group-peer-manager.d.ts +108 -0
- package/lib/groups/group-peer-manager.js +344 -0
- package/lib/groups/group-protocols.d.ts +297 -0
- package/lib/groups/group-protocols.js +174 -0
- package/lib/groups/invite-payload.d.ts +1 -1
- package/lib/groups/invite-payload.js +26 -1
- package/lib/groups/join-utils.d.ts +1 -0
- package/lib/groups/join-utils.js +104 -1
- package/lib/groups/ledger-affected-events.d.ts +15 -0
- package/lib/groups/ledger-affected-events.js +96 -0
- package/lib/groups/ledger-entry.d.ts +37 -0
- package/lib/groups/ledger-entry.js +56 -0
- package/lib/groups/ledger-fold.d.ts +56 -0
- package/lib/groups/ledger-fold.js +53 -0
- package/lib/groups/manager.d.ts +29 -71
- package/lib/groups/manager.js +281 -1
- package/lib/groups/mls-codec.d.ts +1 -1
- package/lib/groups/mls-codec.js +18 -1
- package/lib/groups/mls-encryptor.d.ts +2 -2
- package/lib/groups/mls-encryptor.js +30 -1
- package/lib/groups/mls-group-handle.d.ts +1 -1
- package/lib/groups/mls-group-handle.js +10 -1
- package/lib/groups/mls-json.js +17 -1
- package/lib/groups/mls-receive-errors.d.ts +29 -0
- package/lib/groups/mls-receive-errors.js +42 -0
- package/lib/groups/mls-state.d.ts +1 -1
- package/lib/groups/mls-state.js +56 -1
- package/lib/groups/store-received-grant.d.ts +4 -1
- package/lib/groups/store-received-grant.js +116 -1
- package/lib/groups/store-received-revocation.d.ts +3 -3
- package/lib/groups/store-received-revocation.js +82 -1
- package/lib/hub/ack-backoff.d.ts +22 -0
- package/lib/hub/ack-backoff.js +88 -0
- package/lib/hub/did-observing-transport.d.ts +13 -2
- package/lib/hub/did-observing-transport.js +126 -1
- package/lib/hub/errors.d.ts +4 -8
- package/lib/hub/errors.js +33 -1
- package/lib/hub/http-client.d.ts +28 -3
- package/lib/hub/http-client.js +32 -1
- package/lib/hub/hub-like.d.ts +24 -0
- package/lib/hub/hub-like.js +535 -0
- package/lib/hub/manager.d.ts +5 -4
- package/lib/hub/manager.js +198 -1
- package/lib/hub/wiring.d.ts +28 -41
- package/lib/hub/wiring.js +132 -1
- package/lib/index.d.ts +29 -13
- package/lib/index.js +392 -1
- package/lib/protocol.d.ts +8 -1
- package/lib/protocol.js +251 -1
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +471 -2
- package/lib/sync/authorize.d.ts +25 -0
- package/lib/sync/authorize.js +73 -0
- package/lib/sync/broadcast-queue.js +183 -1
- package/lib/sync/broadcast-sender.js +179 -1
- package/lib/sync/catalog-scope.js +68 -1
- package/lib/sync/errors.d.ts +12 -0
- package/lib/sync/errors.js +19 -0
- package/lib/sync/forwarder.js +277 -1
- package/lib/sync/handlers.d.ts +3 -22
- package/lib/sync/handlers.js +160 -1
- package/lib/sync/hub-tunnel-sync-listener.d.ts +9 -3
- package/lib/sync/hub-tunnel-sync-listener.js +172 -1
- package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
- package/lib/sync/hub-tunnel-sync-provider.js +64 -1
- package/lib/sync/merkle-apply.d.ts +5 -0
- package/lib/sync/merkle-apply.js +194 -1
- package/lib/sync/merkle-channel.d.ts +19 -0
- package/lib/sync/merkle-channel.js +182 -1
- package/lib/sync/merkle-tree.js +120 -1
- package/lib/sync/peer-registry.d.ts +8 -1
- package/lib/sync/peer-registry.js +107 -1
- package/lib/sync/receive-access-gate.js +24 -1
- package/lib/sync/scope-resolver.js +41 -1
- package/lib/sync/sync-client.d.ts +5 -3
- package/lib/sync/sync-client.js +192 -1
- package/lib/sync/sync-manager.d.ts +2 -2
- package/lib/sync/sync-manager.js +272 -1
- package/lib/types.d.ts +41 -0
- package/lib/types.js +2 -1
- package/lib/util/mutex.js +10 -1
- package/lib/utils.d.ts +5 -0
- package/lib/utils.js +21 -0
- package/package.json +48 -41
- package/lib/groups/broadcast-service.d.ts +0 -189
- package/lib/groups/broadcast-service.js +0 -1
- package/lib/groups/wire-frame.d.ts +0 -32
- package/lib/groups/wire-frame.js +0 -1
- package/lib/hub/connection-pool.d.ts +0 -43
- package/lib/hub/connection-pool.js +0 -1
- package/lib/hub/forward-remote-broadcast.d.ts +0 -15
- package/lib/hub/forward-remote-broadcast.js +0 -1
- package/lib/hub/group-channel.d.ts +0 -67
- package/lib/hub/group-channel.js +0 -1
- package/lib/hub/hub-connection.d.ts +0 -96
- package/lib/hub/hub-connection.js +0 -1
- package/lib/hub/receive-handler.d.ts +0 -33
- package/lib/hub/receive-handler.js +0 -1
- package/lib/hub/relay-manager.d.ts +0 -127
- package/lib/hub/relay-manager.js +0 -1
- package/lib/hub/send-handler.d.ts +0 -40
- package/lib/hub/send-handler.js +0 -1
- package/lib/hub/tunnel-inbox.d.ts +0 -20
- package/lib/hub/tunnel-inbox.js +0 -1
- package/lib/hub/wait-for-gate.d.ts +0 -14
- package/lib/hub/wait-for-gate.js +0 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { fromB64, toB64 } from '@sozai/codec';
|
|
2
|
+
import { EventEmitter } from '@sozai/event';
|
|
3
|
+
import { AckBatch, abortableSleep, computeBackoff } from './ack-backoff.js';
|
|
4
|
+
const DEFAULT_ACK_FLUSH_MS = 500;
|
|
5
|
+
const DEFAULT_ACK_FLUSH_MAX = 10;
|
|
6
|
+
const DEFAULT_BACKOFF_BASE_MS = 1000;
|
|
7
|
+
const DEFAULT_BACKOFF_MAX_MS = 60000;
|
|
8
|
+
const DEFAULT_BACKOFF_JITTER = 0.25;
|
|
9
|
+
const DISPOSED_SENTINEL = Symbol('disposed');
|
|
10
|
+
/**
|
|
11
|
+
* One independent receive subscriber. The underlying single hub/receive drain
|
|
12
|
+
* fans every decoded message to every active sink; each sink buffers in its own
|
|
13
|
+
* queue and parks a single waiter when drained. Sinks are long-lived across
|
|
14
|
+
* reconnects — only the draining channel is swapped, never the sinks.
|
|
15
|
+
*
|
|
16
|
+
* A sink does NOT filter by topic: topic demux is the mux's job. A message for a
|
|
17
|
+
* topic the sink's mux has no listener for is simply never pulled/acked here.
|
|
18
|
+
*/ class ReceiveSink {
|
|
19
|
+
#queue = [];
|
|
20
|
+
#waiter = null;
|
|
21
|
+
#stopped = false;
|
|
22
|
+
push(message) {
|
|
23
|
+
if (this.#stopped) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const waiter = this.#waiter;
|
|
27
|
+
if (waiter != null) {
|
|
28
|
+
this.#waiter = null;
|
|
29
|
+
waiter({
|
|
30
|
+
value: message,
|
|
31
|
+
done: false
|
|
32
|
+
});
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
this.#queue.push(message);
|
|
36
|
+
}
|
|
37
|
+
next() {
|
|
38
|
+
if (this.#queue.length > 0) {
|
|
39
|
+
const value = this.#queue.shift();
|
|
40
|
+
return Promise.resolve({
|
|
41
|
+
value,
|
|
42
|
+
done: false
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
if (this.#stopped) {
|
|
46
|
+
return Promise.resolve({
|
|
47
|
+
value: undefined,
|
|
48
|
+
done: true
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return new Promise((resolve)=>{
|
|
52
|
+
this.#waiter = resolve;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
// Ends this sink only. Resolves a parked waiter as done and drops any buffer;
|
|
56
|
+
// the shared drain + connection keep running for the remaining sinks.
|
|
57
|
+
stop() {
|
|
58
|
+
if (this.#stopped) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.#stopped = true;
|
|
62
|
+
this.#queue = [];
|
|
63
|
+
const waiter = this.#waiter;
|
|
64
|
+
if (waiter != null) {
|
|
65
|
+
this.#waiter = null;
|
|
66
|
+
waiter({
|
|
67
|
+
value: undefined,
|
|
68
|
+
done: true
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A single reconnecting `HubLike` over one `Client<HubProtocol>`, one per
|
|
75
|
+
* (deviceDID, hubURL). It does NOT demultiplex topics or hold per-group
|
|
76
|
+
* mutexes — group-rpc's `createHubMux` already fans the single `hub.receive`
|
|
77
|
+
* drain across topics and refcounts subscribe/unsubscribe on top of this view.
|
|
78
|
+
*
|
|
79
|
+
* Connection is established lazily on the first `publish` / `subscribe` /
|
|
80
|
+
* `receive`. A mux calls `receive(localDID)` once at construction and
|
|
81
|
+
* `subscribe(localDID, topicID)` as topics are retained, so the first of those
|
|
82
|
+
* brings the client + `hub/receive` channel up.
|
|
83
|
+
*
|
|
84
|
+
* `receive()` may be called more than once: one device runs one GroupPeer per
|
|
85
|
+
* group plus directed sync transports, and the hub delivers each message to a
|
|
86
|
+
* DID exactly once, so there must be exactly one underlying `hub/receive` drain
|
|
87
|
+
* per device DID, demultiplexed client-side by topic. Each `receive()` returns
|
|
88
|
+
* its own independent subscription (sink); the single drain fans every decoded
|
|
89
|
+
* message to every active sink. Sinks are long-lived across reconnects while
|
|
90
|
+
* only the underlying channel is swapped, so every iterable keeps yielding
|
|
91
|
+
* through transport drops. Acks are deduped so the hub is acked at most once per
|
|
92
|
+
* sequenceID regardless of how many sinks ack it.
|
|
93
|
+
*/ class HubLikeAdapter {
|
|
94
|
+
#createClient;
|
|
95
|
+
#localDID;
|
|
96
|
+
#logger;
|
|
97
|
+
#ackFlushMs;
|
|
98
|
+
#ackFlushMax;
|
|
99
|
+
#backoffBaseMs;
|
|
100
|
+
#backoffMaxMs;
|
|
101
|
+
#backoffJitter;
|
|
102
|
+
#client = null;
|
|
103
|
+
#channel = null;
|
|
104
|
+
#ackBatch = null;
|
|
105
|
+
#receiveTask = null;
|
|
106
|
+
#controller = new AbortController();
|
|
107
|
+
#activeTopics = new Set();
|
|
108
|
+
#reconnectAttempt = 0;
|
|
109
|
+
// Non-null while a reconnect is in flight. publish/subscribe/unsubscribe wait
|
|
110
|
+
// on it so they bind to the fresh client rather than racing the swap.
|
|
111
|
+
#reconnectGate = null;
|
|
112
|
+
#opened = false;
|
|
113
|
+
#openPromise = null;
|
|
114
|
+
#disposed = false;
|
|
115
|
+
#events = new EventEmitter();
|
|
116
|
+
// Fan-out receive: one underlying hub/receive drain feeds N independent
|
|
117
|
+
// subscriber sinks (group-peer muxes + sync transports share one device DID
|
|
118
|
+
// drain). Each sink owns its queue + waiter; sinks survive reconnects, only
|
|
119
|
+
// the draining channel is replaced.
|
|
120
|
+
#sinks = new Set();
|
|
121
|
+
// Ack dedupe: ack the hub at most once per sequenceID across all sinks. Holds
|
|
122
|
+
// sequenceIDs fed to the current AckBatch but not yet flushed; pruned when the
|
|
123
|
+
// batch flushes (see the send wrapper in #openInner) and cleared whenever the
|
|
124
|
+
// AckBatch is reset on reconnect/dispose so redelivered messages can re-ack.
|
|
125
|
+
// Bounded by the in-flight (unflushed) ack count.
|
|
126
|
+
#ackedPending = new Set();
|
|
127
|
+
constructor(params){
|
|
128
|
+
this.#createClient = params.createClient;
|
|
129
|
+
this.#localDID = params.localDID;
|
|
130
|
+
this.#logger = params.logger;
|
|
131
|
+
this.#ackFlushMs = params.ackFlushMs ?? DEFAULT_ACK_FLUSH_MS;
|
|
132
|
+
this.#ackFlushMax = params.ackFlushMax ?? DEFAULT_ACK_FLUSH_MAX;
|
|
133
|
+
this.#backoffBaseMs = params.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
|
|
134
|
+
this.#backoffMaxMs = params.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
|
|
135
|
+
this.#backoffJitter = params.backoffJitter ?? DEFAULT_BACKOFF_JITTER;
|
|
136
|
+
}
|
|
137
|
+
// --- lifecycle events ---
|
|
138
|
+
events = {
|
|
139
|
+
subscribe: (listener)=>{
|
|
140
|
+
return this.#events.on('lifecycle', listener);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
// Subscriber rejections must never break the state machine: emit awaits all
|
|
144
|
+
// listeners and swallows (logs) any that throw, mirroring the older
|
|
145
|
+
// hub-connection lifecycle dispatch.
|
|
146
|
+
async #emit(event) {
|
|
147
|
+
try {
|
|
148
|
+
await this.#events.emit('lifecycle', event);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
this.#logger?.warn('hub-like lifecycle subscriber threw', {
|
|
151
|
+
type: event.type,
|
|
152
|
+
error
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// --- connection setup ---
|
|
157
|
+
#ensureOpen() {
|
|
158
|
+
if (this.#opened) {
|
|
159
|
+
return Promise.resolve();
|
|
160
|
+
}
|
|
161
|
+
if (this.#openPromise != null) {
|
|
162
|
+
return this.#openPromise;
|
|
163
|
+
}
|
|
164
|
+
const promise = this.#openInner().then(async ()=>{
|
|
165
|
+
this.#opened = true;
|
|
166
|
+
await this.#emit({
|
|
167
|
+
type: 'connected'
|
|
168
|
+
});
|
|
169
|
+
}).catch((error)=>{
|
|
170
|
+
// Allow a later call to retry the first connect.
|
|
171
|
+
this.#openPromise = null;
|
|
172
|
+
throw error;
|
|
173
|
+
});
|
|
174
|
+
this.#openPromise = promise;
|
|
175
|
+
return promise;
|
|
176
|
+
}
|
|
177
|
+
async #openInner() {
|
|
178
|
+
// On the reconnect path dispose the prior client so its transport is freed
|
|
179
|
+
// before a fresh one is acquired. dispose is idempotent.
|
|
180
|
+
const previous = this.#client;
|
|
181
|
+
this.#client = null;
|
|
182
|
+
if (previous != null) {
|
|
183
|
+
try {
|
|
184
|
+
await previous.dispose();
|
|
185
|
+
} catch (error) {
|
|
186
|
+
this.#logger?.debug('hub-like previous client dispose failed', {
|
|
187
|
+
error
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const client = await this.#createClient();
|
|
192
|
+
this.#client = client;
|
|
193
|
+
const channel = client.createChannel('hub/receive', {
|
|
194
|
+
param: {},
|
|
195
|
+
signal: this.#controller.signal
|
|
196
|
+
});
|
|
197
|
+
this.#channel = channel;
|
|
198
|
+
// The channel call is a promise; swallow its rejection so an aborted /
|
|
199
|
+
// dropped channel never surfaces as an unhandled rejection.
|
|
200
|
+
void channel.catch(()=>{});
|
|
201
|
+
// A fresh AckBatch means the prior batch's pending acks (if any) were
|
|
202
|
+
// dropped without flushing; clear the dedupe set so the hub's redelivered
|
|
203
|
+
// messages can be acked again on this connection.
|
|
204
|
+
this.#ackedPending.clear();
|
|
205
|
+
this.#ackBatch = new AckBatch({
|
|
206
|
+
flushMs: this.#ackFlushMs,
|
|
207
|
+
flushMax: this.#ackFlushMax,
|
|
208
|
+
send: async (ids)=>{
|
|
209
|
+
try {
|
|
210
|
+
await channel.send({
|
|
211
|
+
ack: ids
|
|
212
|
+
});
|
|
213
|
+
} finally{
|
|
214
|
+
// Forget flushed sequenceIDs so the dedupe set stays bounded; a future
|
|
215
|
+
// redelivery (after reconnect) is then free to ack again.
|
|
216
|
+
for (const id of ids){
|
|
217
|
+
this.#ackedPending.delete(id);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
logger: this.#logger
|
|
222
|
+
});
|
|
223
|
+
this.#receiveTask = this.#runReceiveLoop(client, channel).catch((error)=>{
|
|
224
|
+
this.#logger?.debug('hub-like receive task tail error', {
|
|
225
|
+
error
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
// Re-issue hub/subscribe for every active topic. Empty on the first open;
|
|
229
|
+
// on reconnect it re-registers the device's topics with the fresh client.
|
|
230
|
+
const topics = [
|
|
231
|
+
...this.#activeTopics
|
|
232
|
+
];
|
|
233
|
+
if (topics.length > 0) {
|
|
234
|
+
const results = await Promise.allSettled(topics.map((topicID)=>client.request('hub/subscribe', {
|
|
235
|
+
param: {
|
|
236
|
+
topicID
|
|
237
|
+
}
|
|
238
|
+
})));
|
|
239
|
+
for(let i = 0; i < results.length; i++){
|
|
240
|
+
const result = results[i];
|
|
241
|
+
if (result.status === 'rejected') {
|
|
242
|
+
// Keep the topic registered: the next reconnect retries it.
|
|
243
|
+
this.#logger?.warn('hub-like re-subscribe failed', {
|
|
244
|
+
topicID: topics[i],
|
|
245
|
+
error: result.reason
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
async #runReceiveLoop(client, channel) {
|
|
252
|
+
const reader = channel.readable.getReader();
|
|
253
|
+
let caughtError = null;
|
|
254
|
+
try {
|
|
255
|
+
while(!this.#controller.signal.aborted){
|
|
256
|
+
const readPromise = reader.read();
|
|
257
|
+
const disposedPromise = client.disposed.then(()=>DISPOSED_SENTINEL);
|
|
258
|
+
const raceResult = await Promise.race([
|
|
259
|
+
readPromise,
|
|
260
|
+
disposedPromise
|
|
261
|
+
]);
|
|
262
|
+
if (typeof raceResult === 'symbol') {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
const { done, value } = raceResult;
|
|
266
|
+
if (done) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
this.#pushMessage({
|
|
270
|
+
sequenceID: value.sequenceID,
|
|
271
|
+
senderDID: value.senderDID,
|
|
272
|
+
topicID: value.topicID,
|
|
273
|
+
payload: fromB64(value.payload)
|
|
274
|
+
});
|
|
275
|
+
// A successful read indicates a healthy connection.
|
|
276
|
+
this.#reconnectAttempt = 0;
|
|
277
|
+
}
|
|
278
|
+
} catch (error) {
|
|
279
|
+
caughtError = error;
|
|
280
|
+
this.#logger?.warn('hub-like receive loop error', {
|
|
281
|
+
error
|
|
282
|
+
});
|
|
283
|
+
} finally{
|
|
284
|
+
try {
|
|
285
|
+
reader.releaseLock();
|
|
286
|
+
} catch {
|
|
287
|
+
// ignore
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!this.#controller.signal.aborted && !this.#disposed) {
|
|
291
|
+
const reason = caughtError instanceof Error && caughtError.message ? caughtError.message : 'receive loop ended';
|
|
292
|
+
this.#logger?.debug('hub-like receive loop ended, scheduling reconnect', {
|
|
293
|
+
reason
|
|
294
|
+
});
|
|
295
|
+
// Order is load-bearing: subscribers observe `disconnected` before the
|
|
296
|
+
// reconnect loop's first `reconnecting`. `#emit` awaits subscribers, then
|
|
297
|
+
// `#scheduleReconnect` runs the loop whose first action is a backoff
|
|
298
|
+
// sleep, so there is no observable interleaving.
|
|
299
|
+
await this.#emit({
|
|
300
|
+
type: 'disconnected'
|
|
301
|
+
});
|
|
302
|
+
this.#scheduleReconnect();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
#scheduleReconnect() {
|
|
306
|
+
this.#receiveTask = this.#reconnectLoop().catch((error)=>{
|
|
307
|
+
this.#logger?.debug('hub-like reconnect task tail error', {
|
|
308
|
+
error
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
async #reconnectLoop() {
|
|
313
|
+
let resolveGate = ()=>{};
|
|
314
|
+
this.#reconnectGate = new Promise((resolve)=>{
|
|
315
|
+
resolveGate = resolve;
|
|
316
|
+
});
|
|
317
|
+
try {
|
|
318
|
+
while(!this.#controller.signal.aborted && !this.#disposed){
|
|
319
|
+
const delay = computeBackoff(this.#reconnectAttempt++, this.#backoffBaseMs, this.#backoffMaxMs, this.#backoffJitter);
|
|
320
|
+
try {
|
|
321
|
+
await abortableSleep(delay, this.#controller.signal);
|
|
322
|
+
} catch {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (this.#disposed) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
await this.#emit({
|
|
329
|
+
type: 'reconnecting'
|
|
330
|
+
});
|
|
331
|
+
try {
|
|
332
|
+
this.#ackBatch?.dispose();
|
|
333
|
+
this.#ackBatch = null;
|
|
334
|
+
this.#channel = null;
|
|
335
|
+
await this.#openInner();
|
|
336
|
+
this.#reconnectAttempt = 0;
|
|
337
|
+
// Emit before resolving the gate so awaiters observe `connected`
|
|
338
|
+
// before their queued publish/subscribe resumes.
|
|
339
|
+
await this.#emit({
|
|
340
|
+
type: 'connected'
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
this.#logger?.warn('hub-like reconnect attempt failed', {
|
|
345
|
+
attempt: this.#reconnectAttempt,
|
|
346
|
+
error
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
} finally{
|
|
351
|
+
this.#reconnectGate = null;
|
|
352
|
+
resolveGate();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
async #ready() {
|
|
356
|
+
if (this.#disposed) {
|
|
357
|
+
throw new Error('hub-like adapter disposed');
|
|
358
|
+
}
|
|
359
|
+
await this.#ensureOpen();
|
|
360
|
+
// Wait out any in-flight reconnect so the operation binds to the fresh
|
|
361
|
+
// client. A new reconnect may begin after this resolves; loop until clear.
|
|
362
|
+
while(this.#reconnectGate != null){
|
|
363
|
+
await this.#reconnectGate;
|
|
364
|
+
}
|
|
365
|
+
if (this.#disposed) {
|
|
366
|
+
throw new Error('hub-like adapter disposed');
|
|
367
|
+
}
|
|
368
|
+
const client = this.#client;
|
|
369
|
+
if (client == null) {
|
|
370
|
+
throw new Error('hub-like adapter not connected');
|
|
371
|
+
}
|
|
372
|
+
return client;
|
|
373
|
+
}
|
|
374
|
+
// --- HubLike surface ---
|
|
375
|
+
publish = async ({ senderDID, topicID, payload })=>{
|
|
376
|
+
if (senderDID !== this.#localDID) {
|
|
377
|
+
// The hub derives the sender from the signed `iss`; a mismatch here means
|
|
378
|
+
// a caller stamped the wrong DID. The publish still uses this device's
|
|
379
|
+
// identity, so surface it as a warning rather than a hard failure.
|
|
380
|
+
this.#logger?.warn('hub-like publish senderDID does not match localDID', {
|
|
381
|
+
senderDID,
|
|
382
|
+
localDID: this.#localDID
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
const client = await this.#ready();
|
|
386
|
+
const result = await client.request('hub/publish', {
|
|
387
|
+
param: {
|
|
388
|
+
topicID,
|
|
389
|
+
payload: toB64(payload)
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
return {
|
|
393
|
+
sequenceID: result.sequenceID
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
subscribe = async (_subscriberDID, topicID)=>{
|
|
397
|
+
const client = await this.#ready();
|
|
398
|
+
this.#activeTopics.add(topicID);
|
|
399
|
+
await client.request('hub/subscribe', {
|
|
400
|
+
param: {
|
|
401
|
+
topicID
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
};
|
|
405
|
+
unsubscribe = async (_subscriberDID, topicID)=>{
|
|
406
|
+
const client = await this.#ready();
|
|
407
|
+
this.#activeTopics.delete(topicID);
|
|
408
|
+
await client.request('hub/unsubscribe', {
|
|
409
|
+
param: {
|
|
410
|
+
topicID
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
};
|
|
414
|
+
receive = (_subscriberDID)=>{
|
|
415
|
+
if (this.#disposed) {
|
|
416
|
+
throw new Error('hub-like adapter disposed');
|
|
417
|
+
}
|
|
418
|
+
const sink = new ReceiveSink();
|
|
419
|
+
this.#sinks.add(sink);
|
|
420
|
+
// The first receive() brings the connection up so the drain has a channel to
|
|
421
|
+
// read from; later calls are idempotent. A late subscriber registers its
|
|
422
|
+
// sink before this resolves, so no in-flight message is lost to it. Failures
|
|
423
|
+
// surface through lifecycle events + retry.
|
|
424
|
+
void this.#ensureOpen().catch((error)=>{
|
|
425
|
+
this.#logger?.warn('hub-like open on receive failed', {
|
|
426
|
+
error
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
// Removing one sink ends only that subscription; the shared drain,
|
|
430
|
+
// connection and AckBatch persist while any other sink remains. Full
|
|
431
|
+
// teardown happens only via the adapter's dispose().
|
|
432
|
+
const removeSink = ()=>{
|
|
433
|
+
if (this.#sinks.delete(sink)) {
|
|
434
|
+
sink.stop();
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const iterator = {
|
|
438
|
+
next: ()=>sink.next(),
|
|
439
|
+
return: ()=>{
|
|
440
|
+
removeSink();
|
|
441
|
+
return Promise.resolve({
|
|
442
|
+
value: undefined,
|
|
443
|
+
done: true
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
return {
|
|
448
|
+
[Symbol.asyncIterator]: ()=>iterator,
|
|
449
|
+
return: ()=>{
|
|
450
|
+
removeSink();
|
|
451
|
+
},
|
|
452
|
+
ack: (sequenceID)=>{
|
|
453
|
+
// Dedupe across all sinks: the first ack for a sequenceID feeds the
|
|
454
|
+
// shared AckBatch; repeats (same or another sink) are no-ops until the
|
|
455
|
+
// batch flushes and prunes it. If no batch is live (mid-reconnect), skip
|
|
456
|
+
// recording so a post-reconnect re-ack still reaches the hub.
|
|
457
|
+
if (this.#ackedPending.has(sequenceID)) {
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const ackBatch = this.#ackBatch;
|
|
461
|
+
if (ackBatch == null) {
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
this.#ackedPending.add(sequenceID);
|
|
465
|
+
ackBatch.add(sequenceID);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
};
|
|
469
|
+
// Fan the decoded message out to every active sink. Sinks whose mux has no
|
|
470
|
+
// listener for the topic simply never pull/ack it; that is expected and the
|
|
471
|
+
// hub redelivers such messages on reconnect.
|
|
472
|
+
#pushMessage(message) {
|
|
473
|
+
if (this.#disposed) {
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
for (const sink of this.#sinks){
|
|
477
|
+
sink.push(message);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
// --- disposal ---
|
|
481
|
+
dispose = async ()=>{
|
|
482
|
+
if (this.#disposed) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.#disposed = true;
|
|
486
|
+
this.#controller.abort();
|
|
487
|
+
// End every subscription: stop each sink (resolving parked waiters as done)
|
|
488
|
+
// and drop them. New receive() calls are rejected once disposed.
|
|
489
|
+
for (const sink of this.#sinks){
|
|
490
|
+
sink.stop();
|
|
491
|
+
}
|
|
492
|
+
this.#sinks.clear();
|
|
493
|
+
const ackBatch = this.#ackBatch;
|
|
494
|
+
this.#ackBatch = null;
|
|
495
|
+
if (ackBatch != null) {
|
|
496
|
+
try {
|
|
497
|
+
await ackBatch.flush();
|
|
498
|
+
} catch (error) {
|
|
499
|
+
this.#logger?.debug('hub-like ack flush during dispose failed', {
|
|
500
|
+
error
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
ackBatch.dispose();
|
|
504
|
+
}
|
|
505
|
+
// Close the channel before awaiting the receive task so the reader unblocks.
|
|
506
|
+
try {
|
|
507
|
+
this.#channel?.close();
|
|
508
|
+
} catch {
|
|
509
|
+
// ignore
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await this.#receiveTask;
|
|
513
|
+
} catch {
|
|
514
|
+
// ignore
|
|
515
|
+
}
|
|
516
|
+
const client = this.#client;
|
|
517
|
+
this.#client = null;
|
|
518
|
+
if (client != null) {
|
|
519
|
+
try {
|
|
520
|
+
await client.dispose();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
this.#logger?.debug('hub-like client dispose failed', {
|
|
523
|
+
error
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
this.#channel = null;
|
|
528
|
+
this.#receiveTask = null;
|
|
529
|
+
this.#activeTopics.clear();
|
|
530
|
+
this.#ackedPending.clear();
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
export function createHubLike(params) {
|
|
534
|
+
return new HubLikeAdapter(params);
|
|
535
|
+
}
|
package/lib/hub/manager.d.ts
CHANGED
|
@@ -65,16 +65,17 @@ export type UpsertHubParams = {
|
|
|
65
65
|
serverDID?: string | null;
|
|
66
66
|
};
|
|
67
67
|
export declare class HubServerDIDConflictError extends Error {
|
|
68
|
-
|
|
69
|
-
readonly url: string;
|
|
70
|
-
readonly existingServerDID: string;
|
|
71
|
-
readonly providedServerDID: string;
|
|
68
|
+
#private;
|
|
72
69
|
constructor(params: {
|
|
73
70
|
hubID: string;
|
|
74
71
|
url: string;
|
|
75
72
|
existingServerDID: string;
|
|
76
73
|
providedServerDID: string;
|
|
77
74
|
});
|
|
75
|
+
get hubID(): string;
|
|
76
|
+
get url(): string;
|
|
77
|
+
get existingServerDID(): string;
|
|
78
|
+
get providedServerDID(): string;
|
|
78
79
|
}
|
|
79
80
|
export declare function createHub(params: CreateHubParams): Promise<Hub>;
|
|
80
81
|
/**
|