@kubun/plugin-p2p 0.10.1 → 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.
Files changed (145) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1319 -1
  3. package/lib/context/hub.js +150 -1
  4. package/lib/context/join.js +80 -1
  5. package/lib/context/sync.js +19 -1
  6. package/lib/context/types.d.ts +3 -3
  7. package/lib/context/types.js +28 -1
  8. package/lib/groups/admin-roster.d.ts +130 -0
  9. package/lib/groups/admin-roster.js +184 -0
  10. package/lib/groups/broadcast-codec.js +6 -1
  11. package/lib/groups/broadcast.d.ts +105 -91
  12. package/lib/groups/broadcast.js +627 -1
  13. package/lib/groups/circle-projection.d.ts +122 -0
  14. package/lib/groups/circle-projection.js +288 -0
  15. package/lib/groups/circle-reducers.d.ts +178 -0
  16. package/lib/groups/circle-reducers.js +198 -0
  17. package/lib/groups/events.d.ts +5 -1
  18. package/lib/groups/events.js +10 -1
  19. package/lib/groups/group-anchor.d.ts +63 -0
  20. package/lib/groups/group-anchor.js +107 -0
  21. package/lib/groups/group-crypto.d.ts +19 -0
  22. package/lib/groups/group-crypto.js +85 -0
  23. package/lib/groups/group-handle-registry.d.ts +61 -2
  24. package/lib/groups/group-handle-registry.js +243 -1
  25. package/lib/groups/group-handlers.d.ts +29 -0
  26. package/lib/groups/group-handlers.js +92 -0
  27. package/lib/groups/group-health-monitor.d.ts +11 -0
  28. package/lib/groups/group-health-monitor.js +139 -1
  29. package/lib/groups/group-mls.d.ts +35 -0
  30. package/lib/groups/group-mls.js +164 -0
  31. package/lib/groups/group-peer-manager.d.ts +108 -0
  32. package/lib/groups/group-peer-manager.js +344 -0
  33. package/lib/groups/group-protocols.d.ts +297 -0
  34. package/lib/groups/group-protocols.js +174 -0
  35. package/lib/groups/invite-payload.d.ts +1 -1
  36. package/lib/groups/invite-payload.js +26 -1
  37. package/lib/groups/join-utils.d.ts +1 -0
  38. package/lib/groups/join-utils.js +104 -1
  39. package/lib/groups/ledger-affected-events.d.ts +15 -0
  40. package/lib/groups/ledger-affected-events.js +96 -0
  41. package/lib/groups/ledger-entry.d.ts +37 -0
  42. package/lib/groups/ledger-entry.js +56 -0
  43. package/lib/groups/ledger-fold.d.ts +56 -0
  44. package/lib/groups/ledger-fold.js +53 -0
  45. package/lib/groups/manager.d.ts +20 -71
  46. package/lib/groups/manager.js +281 -1
  47. package/lib/groups/mls-codec.d.ts +1 -1
  48. package/lib/groups/mls-codec.js +18 -1
  49. package/lib/groups/mls-encryptor.d.ts +2 -2
  50. package/lib/groups/mls-encryptor.js +30 -1
  51. package/lib/groups/mls-group-handle.d.ts +1 -1
  52. package/lib/groups/mls-group-handle.js +10 -1
  53. package/lib/groups/mls-json.js +17 -1
  54. package/lib/groups/mls-receive-errors.d.ts +29 -0
  55. package/lib/groups/mls-receive-errors.js +42 -0
  56. package/lib/groups/mls-state.d.ts +1 -1
  57. package/lib/groups/mls-state.js +56 -1
  58. package/lib/groups/store-received-grant.d.ts +4 -1
  59. package/lib/groups/store-received-grant.js +116 -1
  60. package/lib/groups/store-received-revocation.d.ts +3 -3
  61. package/lib/groups/store-received-revocation.js +82 -1
  62. package/lib/hub/ack-backoff.d.ts +22 -0
  63. package/lib/hub/ack-backoff.js +88 -0
  64. package/lib/hub/did-observing-transport.d.ts +13 -2
  65. package/lib/hub/did-observing-transport.js +126 -1
  66. package/lib/hub/errors.d.ts +0 -6
  67. package/lib/hub/errors.js +33 -1
  68. package/lib/hub/http-client.d.ts +28 -3
  69. package/lib/hub/http-client.js +32 -1
  70. package/lib/hub/hub-like.d.ts +24 -0
  71. package/lib/hub/hub-like.js +535 -0
  72. package/lib/hub/manager.js +198 -1
  73. package/lib/hub/wiring.d.ts +17 -48
  74. package/lib/hub/wiring.js +132 -1
  75. package/lib/index.d.ts +17 -16
  76. package/lib/index.js +392 -1
  77. package/lib/protocol.d.ts +1 -1
  78. package/lib/protocol.js +251 -1
  79. package/lib/schema.d.ts +2 -1
  80. package/lib/schema.js +439 -2
  81. package/lib/sync/authorize.d.ts +1 -1
  82. package/lib/sync/authorize.js +73 -1
  83. package/lib/sync/broadcast-queue.js +183 -1
  84. package/lib/sync/broadcast-sender.js +179 -1
  85. package/lib/sync/catalog-scope.js +68 -1
  86. package/lib/sync/errors.d.ts +3 -2
  87. package/lib/sync/errors.js +19 -1
  88. package/lib/sync/forwarder.js +277 -1
  89. package/lib/sync/handlers.js +160 -1
  90. package/lib/sync/hub-tunnel-sync-listener.d.ts +8 -4
  91. package/lib/sync/hub-tunnel-sync-listener.js +172 -1
  92. package/lib/sync/hub-tunnel-sync-provider.d.ts +14 -14
  93. package/lib/sync/hub-tunnel-sync-provider.js +64 -1
  94. package/lib/sync/merkle-apply.d.ts +5 -0
  95. package/lib/sync/merkle-apply.js +194 -1
  96. package/lib/sync/merkle-channel.d.ts +2 -2
  97. package/lib/sync/merkle-channel.js +182 -1
  98. package/lib/sync/merkle-tree.js +120 -1
  99. package/lib/sync/peer-registry.d.ts +1 -1
  100. package/lib/sync/peer-registry.js +107 -1
  101. package/lib/sync/receive-access-gate.js +24 -1
  102. package/lib/sync/scope-resolver.js +41 -1
  103. package/lib/sync/sync-client.d.ts +3 -3
  104. package/lib/sync/sync-client.js +192 -1
  105. package/lib/sync/sync-manager.d.ts +2 -2
  106. package/lib/sync/sync-manager.js +272 -1
  107. package/lib/types.d.ts +11 -0
  108. package/lib/types.js +2 -1
  109. package/lib/util/mutex.js +10 -1
  110. package/lib/utils.js +21 -1
  111. package/package.json +47 -40
  112. package/lib/groups/broadcast-service.d.ts +0 -263
  113. package/lib/groups/broadcast-service.js +0 -1
  114. package/lib/groups/rejoin-codec.d.ts +0 -14
  115. package/lib/groups/rejoin-codec.js +0 -1
  116. package/lib/groups/wire-frame.d.ts +0 -34
  117. package/lib/groups/wire-frame.js +0 -1
  118. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  119. package/lib/hub/circle-catchup-requester.js +0 -1
  120. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  121. package/lib/hub/circle-catchup-responder.js +0 -1
  122. package/lib/hub/connection-pool.d.ts +0 -43
  123. package/lib/hub/connection-pool.js +0 -1
  124. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  125. package/lib/hub/epoch-stale-detector.js +0 -1
  126. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  127. package/lib/hub/forward-remote-broadcast.js +0 -1
  128. package/lib/hub/group-channel.d.ts +0 -89
  129. package/lib/hub/group-channel.js +0 -1
  130. package/lib/hub/hub-connection.d.ts +0 -96
  131. package/lib/hub/hub-connection.js +0 -1
  132. package/lib/hub/receive-handler.d.ts +0 -51
  133. package/lib/hub/receive-handler.js +0 -1
  134. package/lib/hub/rejoin-manager.d.ts +0 -78
  135. package/lib/hub/rejoin-manager.js +0 -1
  136. package/lib/hub/rejoin-responder.d.ts +0 -32
  137. package/lib/hub/rejoin-responder.js +0 -1
  138. package/lib/hub/relay-manager.d.ts +0 -142
  139. package/lib/hub/relay-manager.js +0 -1
  140. package/lib/hub/send-handler.d.ts +0 -40
  141. package/lib/hub/send-handler.js +0 -1
  142. package/lib/hub/tunnel-inbox.d.ts +0 -20
  143. package/lib/hub/tunnel-inbox.js +0 -1
  144. package/lib/hub/wait-for-gate.d.ts +0 -14
  145. 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
+ }