@kubun/plugin-p2p 0.10.1 → 0.12.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 (201) hide show
  1. package/lib/context/delegation.js +130 -1
  2. package/lib/context/group.js +1646 -1
  3. package/lib/context/hub.js +151 -1
  4. package/lib/context/join.js +115 -1
  5. package/lib/context/peer.d.ts +92 -0
  6. package/lib/context/peer.js +1216 -0
  7. package/lib/context/require-admin.d.ts +57 -0
  8. package/lib/context/require-admin.js +57 -0
  9. package/lib/context/sync.d.ts +12 -0
  10. package/lib/context/sync.js +252 -1
  11. package/lib/context/types.d.ts +58 -22
  12. package/lib/context/types.js +48 -1
  13. package/lib/groups/access-default-token.d.ts +44 -0
  14. package/lib/groups/access-default-token.js +103 -0
  15. package/lib/groups/admin-roster.d.ts +55 -0
  16. package/lib/groups/admin-roster.js +69 -0
  17. package/lib/groups/anchor-store.d.ts +15 -0
  18. package/lib/groups/anchor-store.js +37 -0
  19. package/lib/groups/app-cursor-store.d.ts +17 -0
  20. package/lib/groups/app-cursor-store.js +34 -0
  21. package/lib/groups/broadcast-codec.js +6 -1
  22. package/lib/groups/broadcast.d.ts +238 -106
  23. package/lib/groups/broadcast.js +702 -1
  24. package/lib/groups/catalog-token.d.ts +50 -0
  25. package/lib/groups/catalog-token.js +96 -0
  26. package/lib/groups/circle-projection.d.ts +90 -0
  27. package/lib/groups/circle-projection.js +202 -0
  28. package/lib/groups/circle-reducers.d.ts +139 -0
  29. package/lib/groups/circle-reducers.js +128 -0
  30. package/lib/groups/commit-adoption.d.ts +77 -0
  31. package/lib/groups/commit-adoption.js +108 -0
  32. package/lib/groups/commit-journal.d.ts +14 -0
  33. package/lib/groups/commit-journal.js +65 -0
  34. package/lib/groups/control-request.d.ts +66 -0
  35. package/lib/groups/control-request.js +101 -0
  36. package/lib/groups/events.d.ts +19 -2
  37. package/lib/groups/events.js +10 -1
  38. package/lib/groups/group-anchor.d.ts +24 -0
  39. package/lib/groups/group-anchor.js +22 -0
  40. package/lib/groups/group-crypto.d.ts +42 -0
  41. package/lib/groups/group-crypto.js +159 -0
  42. package/lib/groups/group-handle-registry.d.ts +138 -9
  43. package/lib/groups/group-handle-registry.js +533 -1
  44. package/lib/groups/group-handlers.d.ts +30 -0
  45. package/lib/groups/group-handlers.js +218 -0
  46. package/lib/groups/group-health-monitor.d.ts +11 -0
  47. package/lib/groups/group-health-monitor.js +139 -1
  48. package/lib/groups/group-mls.d.ts +66 -0
  49. package/lib/groups/group-mls.js +696 -0
  50. package/lib/groups/group-peer-manager.d.ts +301 -0
  51. package/lib/groups/group-peer-manager.js +1336 -0
  52. package/lib/groups/group-protocols.d.ts +754 -0
  53. package/lib/groups/group-protocols.js +448 -0
  54. package/lib/groups/invite-payload.d.ts +49 -1
  55. package/lib/groups/invite-payload.js +26 -1
  56. package/lib/groups/join-request-identity.d.ts +21 -0
  57. package/lib/groups/join-request-identity.js +43 -0
  58. package/lib/groups/join-utils.d.ts +61 -0
  59. package/lib/groups/join-utils.js +366 -1
  60. package/lib/groups/ledger-affected-events.d.ts +20 -0
  61. package/lib/groups/ledger-affected-events.js +85 -0
  62. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  63. package/lib/groups/ledger-commit-fold.js +167 -0
  64. package/lib/groups/ledger-ingest.d.ts +124 -0
  65. package/lib/groups/ledger-ingest.js +212 -0
  66. package/lib/groups/ledger.d.ts +44 -0
  67. package/lib/groups/ledger.js +30 -0
  68. package/lib/groups/manager.d.ts +217 -85
  69. package/lib/groups/manager.js +798 -1
  70. package/lib/groups/mls-codec.d.ts +28 -7
  71. package/lib/groups/mls-codec.js +33 -1
  72. package/lib/groups/mls-encryptor.d.ts +2 -2
  73. package/lib/groups/mls-encryptor.js +33 -1
  74. package/lib/groups/mls-group-handle.d.ts +37 -2
  75. package/lib/groups/mls-group-handle.js +18 -1
  76. package/lib/groups/mls-json.js +17 -1
  77. package/lib/groups/mls-receive-errors.d.ts +27 -0
  78. package/lib/groups/mls-receive-errors.js +39 -0
  79. package/lib/groups/mls-state.d.ts +6 -4
  80. package/lib/groups/mls-state.js +79 -1
  81. package/lib/groups/peer-presence.d.ts +90 -0
  82. package/lib/groups/peer-presence.js +295 -0
  83. package/lib/groups/peer-selection.d.ts +37 -0
  84. package/lib/groups/peer-selection.js +72 -0
  85. package/lib/groups/roster-projection.d.ts +53 -0
  86. package/lib/groups/roster-projection.js +65 -0
  87. package/lib/groups/store-received-grant.d.ts +7 -3
  88. package/lib/groups/store-received-grant.js +161 -1
  89. package/lib/groups/store-received-revocation.d.ts +19 -16
  90. package/lib/groups/store-received-revocation.js +83 -1
  91. package/lib/hub/ack-backoff.d.ts +22 -0
  92. package/lib/hub/ack-backoff.js +88 -0
  93. package/lib/hub/http-client.d.ts +18 -10
  94. package/lib/hub/http-client.js +15 -1
  95. package/lib/hub/hub-like.d.ts +75 -0
  96. package/lib/hub/hub-like.js +1022 -0
  97. package/lib/hub/loopback-log-hub.d.ts +20 -0
  98. package/lib/hub/loopback-log-hub.js +178 -0
  99. package/lib/hub/manager.d.ts +25 -40
  100. package/lib/hub/manager.js +155 -1
  101. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  102. package/lib/hub/peer-scoped-hub-view.js +92 -0
  103. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  104. package/lib/hub/sender-scoped-hub-view.js +59 -0
  105. package/lib/hub/server-did.d.ts +67 -0
  106. package/lib/hub/server-did.js +131 -0
  107. package/lib/hub/wiring.d.ts +77 -51
  108. package/lib/hub/wiring.js +189 -1
  109. package/lib/index.d.ts +58 -22
  110. package/lib/index.js +563 -1
  111. package/lib/peer/connection-registry.d.ts +45 -0
  112. package/lib/peer/connection-registry.js +52 -0
  113. package/lib/peer/handlers.d.ts +69 -0
  114. package/lib/peer/handlers.js +110 -0
  115. package/lib/protocol.d.ts +214 -1
  116. package/lib/protocol.js +519 -1
  117. package/lib/schema.d.ts +3 -1
  118. package/lib/schema.js +1150 -39
  119. package/lib/sync/access-default-sender.d.ts +23 -0
  120. package/lib/sync/access-default-sender.js +80 -0
  121. package/lib/sync/authorize.d.ts +58 -4
  122. package/lib/sync/authorize.js +114 -1
  123. package/lib/sync/broadcast-queue.d.ts +3 -4
  124. package/lib/sync/broadcast-queue.js +182 -1
  125. package/lib/sync/broadcast-sender.js +179 -1
  126. package/lib/sync/catalog-scope.d.ts +8 -7
  127. package/lib/sync/catalog-scope.js +84 -1
  128. package/lib/sync/errors.d.ts +12 -2
  129. package/lib/sync/errors.js +29 -1
  130. package/lib/sync/forwarder.d.ts +13 -19
  131. package/lib/sync/forwarder.js +271 -1
  132. package/lib/sync/handlers.js +200 -1
  133. package/lib/sync/hub-tunnel-sync-listener.d.ts +19 -5
  134. package/lib/sync/hub-tunnel-sync-listener.js +261 -1
  135. package/lib/sync/hub-tunnel-sync-provider.d.ts +34 -14
  136. package/lib/sync/hub-tunnel-sync-provider.js +113 -1
  137. package/lib/sync/merkle-apply.d.ts +5 -0
  138. package/lib/sync/merkle-apply.js +194 -1
  139. package/lib/sync/merkle-channel.d.ts +11 -3
  140. package/lib/sync/merkle-channel.js +0 -0
  141. package/lib/sync/merkle-tree.js +120 -1
  142. package/lib/sync/peer-registry.d.ts +15 -11
  143. package/lib/sync/peer-registry.js +82 -1
  144. package/lib/sync/receive-access-gate.js +24 -1
  145. package/lib/sync/scope-resolver.js +41 -1
  146. package/lib/sync/sync-client.d.ts +14 -4
  147. package/lib/sync/sync-client.js +254 -1
  148. package/lib/sync/sync-manager.d.ts +24 -6
  149. package/lib/sync/sync-manager.js +301 -1
  150. package/lib/sync/tunnel-listeners.d.ts +52 -0
  151. package/lib/sync/tunnel-listeners.js +175 -0
  152. package/lib/sync/tunnel-observability.d.ts +25 -0
  153. package/lib/sync/tunnel-observability.js +63 -0
  154. package/lib/sync/tunnel-topics.d.ts +20 -0
  155. package/lib/sync/tunnel-topics.js +28 -0
  156. package/lib/types.d.ts +704 -22
  157. package/lib/types.js +1 -1
  158. package/lib/util/handler-error.d.ts +16 -0
  159. package/lib/util/handler-error.js +64 -0
  160. package/lib/util/mutex.d.ts +14 -1
  161. package/lib/util/mutex.js +44 -1
  162. package/lib/utils.js +21 -1
  163. package/package.json +58 -47
  164. package/lib/groups/broadcast-service.d.ts +0 -263
  165. package/lib/groups/broadcast-service.js +0 -1
  166. package/lib/groups/rejoin-codec.d.ts +0 -14
  167. package/lib/groups/rejoin-codec.js +0 -1
  168. package/lib/groups/wire-frame.d.ts +0 -34
  169. package/lib/groups/wire-frame.js +0 -1
  170. package/lib/hub/circle-catchup-requester.d.ts +0 -58
  171. package/lib/hub/circle-catchup-requester.js +0 -1
  172. package/lib/hub/circle-catchup-responder.d.ts +0 -50
  173. package/lib/hub/circle-catchup-responder.js +0 -1
  174. package/lib/hub/connection-pool.d.ts +0 -43
  175. package/lib/hub/connection-pool.js +0 -1
  176. package/lib/hub/did-observing-transport.d.ts +0 -64
  177. package/lib/hub/did-observing-transport.js +0 -1
  178. package/lib/hub/epoch-stale-detector.d.ts +0 -18
  179. package/lib/hub/epoch-stale-detector.js +0 -1
  180. package/lib/hub/errors.d.ts +0 -30
  181. package/lib/hub/errors.js +0 -1
  182. package/lib/hub/forward-remote-broadcast.d.ts +0 -15
  183. package/lib/hub/forward-remote-broadcast.js +0 -1
  184. package/lib/hub/group-channel.d.ts +0 -89
  185. package/lib/hub/group-channel.js +0 -1
  186. package/lib/hub/hub-connection.d.ts +0 -96
  187. package/lib/hub/hub-connection.js +0 -1
  188. package/lib/hub/receive-handler.d.ts +0 -51
  189. package/lib/hub/receive-handler.js +0 -1
  190. package/lib/hub/rejoin-manager.d.ts +0 -78
  191. package/lib/hub/rejoin-manager.js +0 -1
  192. package/lib/hub/rejoin-responder.d.ts +0 -32
  193. package/lib/hub/rejoin-responder.js +0 -1
  194. package/lib/hub/relay-manager.d.ts +0 -142
  195. package/lib/hub/relay-manager.js +0 -1
  196. package/lib/hub/send-handler.d.ts +0 -40
  197. package/lib/hub/send-handler.js +0 -1
  198. package/lib/hub/tunnel-inbox.d.ts +0 -20
  199. package/lib/hub/tunnel-inbox.js +0 -1
  200. package/lib/hub/wait-for-gate.d.ts +0 -14
  201. package/lib/hub/wait-for-gate.js +0 -1
@@ -0,0 +1,1022 @@
1
+ import { RequestError } from '@enkaku/client';
2
+ import { ErrorCodes } from '@enkaku/protocol';
3
+ import { hubErrorFromCode, RetentionExceededError } from '@kumiai/hub-protocol';
4
+ import { fromB64, toB64 } from '@sozai/codec';
5
+ import { EventEmitter } from '@sozai/event';
6
+ import { AckBatch, abortableSleep, computeBackoff } from './ack-backoff.js';
7
+ const DEFAULT_ACK_FLUSH_MS = 500;
8
+ const DEFAULT_ACK_FLUSH_MAX = 10;
9
+ const DEFAULT_BACKOFF_BASE_MS = 1000;
10
+ // Half a minute, not a minute. This is the longest a device sits doing nothing
11
+ // after a hub comes back — and on a phone the person is usually watching, so a
12
+ // ceiling they can wait out beats one that saves a few dials. `retryNow` exists
13
+ // for when even this is too long.
14
+ const DEFAULT_BACKOFF_MAX_MS = 30000;
15
+ const DEFAULT_BACKOFF_JITTER = 0.25;
16
+ const DISPOSED_SENTINEL = Symbol('disposed');
17
+ /**
18
+ * Rebuild a named hub error from the wire code its reply carried, so callers
19
+ * branch on the class: a lost compare-and-set is rebased and retried, a retention
20
+ * refusal never is. A transport failure carries no hub code and passes through
21
+ * untouched — that distinction is the point.
22
+ */ function asHubError(error) {
23
+ if (error instanceof RequestError) {
24
+ return hubErrorFromCode(error.code, error.message) ?? error;
25
+ }
26
+ return error;
27
+ }
28
+ function rethrowHubError(error) {
29
+ throw asHubError(error);
30
+ }
31
+ /**
32
+ * The hub answered — with a result or with a refusal. Either way it is reachable,
33
+ * which is the only thing this predicate is asked about. A transport failure
34
+ * carries no reply and answers `false`.
35
+ */ function hubAnswered(result) {
36
+ return result.status === 'fulfilled' || result.reason instanceof RequestError;
37
+ }
38
+ /**
39
+ * Permanent means the hub ANSWERED, not that the call failed: a retention above the
40
+ * operator's cap is a settled fact about the request, and no reconnect changes it.
41
+ * Anything else — a dropped socket, a hub mid-restart — is transient, because the
42
+ * cost of retrying a settled refusal is a busy loop while the cost of not retrying a
43
+ * transient one is a peer that never comes back.
44
+ *
45
+ * Mirrors `isPermanentSubscribeFailure` in `@kumiai/rpc`'s hub mux, which is internal
46
+ * to that package. Name as well as instance, for the same reason it gives: a host
47
+ * bundling two copies of hub-protocol would break `instanceof` alone and silently turn
48
+ * a permanent refusal back into a retry loop.
49
+ */ function isPermanentSubscribeFailure(error) {
50
+ return error instanceof RetentionExceededError || error instanceof Error && error.name === 'RetentionExceededError';
51
+ }
52
+ /**
53
+ * The hub answered "no" on authorization. Keyed on the code, never the message:
54
+ * a misaddressed token and a caller outside the allowlist arrive under the same
55
+ * code with different text, and the text is not part of the wire contract.
56
+ */ function isAccessDenied(error) {
57
+ return error instanceof RequestError && error.code === ErrorCodes.ACCESS_DENIED;
58
+ }
59
+ /**
60
+ * One independent receive subscriber. The underlying single hub/receive drain
61
+ * fans every decoded message to every active sink; each sink buffers in its own
62
+ * queue and parks a single waiter when drained. Sinks are long-lived across
63
+ * reconnects — only the draining channel is swapped, never the sinks.
64
+ *
65
+ * A sink filters by sender and by topic, and both have to happen here rather
66
+ * than in a wrapper around the iterator. A sink parks exactly one waiter, so a
67
+ * layer that filters by calling `next()` on its behalf takes that slot from the
68
+ * consumer — which is why an iterator-wrapping view starves a directed session
69
+ * even when it drops nothing.
70
+ *
71
+ * A subscriber that asks for neither still gets everything: the group-peer mux
72
+ * calls `receive(localDID)` unscoped and demuxes topics itself, after the frames
73
+ * it holds have been decrypted for it. A directed tunnel cannot, which is what
74
+ * both filters exist for.
75
+ */ class ReceiveSink {
76
+ #queue = [];
77
+ #waiter = null;
78
+ #stopped = false;
79
+ #senderDID;
80
+ #topicID;
81
+ constructor(options){
82
+ this.#senderDID = options?.senderDID;
83
+ this.#topicID = options?.topicID;
84
+ }
85
+ push(message) {
86
+ if (this.#stopped) {
87
+ return;
88
+ }
89
+ if (this.#senderDID != null && message.senderDID !== this.#senderDID) {
90
+ return;
91
+ }
92
+ if (this.#topicID != null && message.topicID !== this.#topicID) {
93
+ return;
94
+ }
95
+ const waiter = this.#waiter;
96
+ if (waiter != null) {
97
+ this.#waiter = null;
98
+ waiter({
99
+ value: message,
100
+ done: false
101
+ });
102
+ return;
103
+ }
104
+ this.#queue.push(message);
105
+ }
106
+ next() {
107
+ if (this.#queue.length > 0) {
108
+ const value = this.#queue.shift();
109
+ return Promise.resolve({
110
+ value,
111
+ done: false
112
+ });
113
+ }
114
+ if (this.#stopped) {
115
+ return Promise.resolve({
116
+ value: undefined,
117
+ done: true
118
+ });
119
+ }
120
+ return new Promise((resolve)=>{
121
+ this.#waiter = resolve;
122
+ });
123
+ }
124
+ // Ends this sink only. Resolves a parked waiter as done and drops any buffer;
125
+ // the shared drain + connection keep running for the remaining sinks.
126
+ stop() {
127
+ if (this.#stopped) {
128
+ return;
129
+ }
130
+ this.#stopped = true;
131
+ this.#queue = [];
132
+ const waiter = this.#waiter;
133
+ if (waiter != null) {
134
+ this.#waiter = null;
135
+ waiter({
136
+ value: undefined,
137
+ done: true
138
+ });
139
+ }
140
+ }
141
+ }
142
+ /**
143
+ * A single reconnecting `HubLike` over one `Client<HubProtocol>`, one per
144
+ * (deviceDID, hubURL). It does NOT demultiplex topics or hold per-group
145
+ * mutexes — group-rpc's `createHubMux` already fans the single `hub.receive`
146
+ * drain across topics and refcounts subscribe/unsubscribe on top of this view.
147
+ *
148
+ * Connection is established lazily on the first `publish` / `subscribe` /
149
+ * `receive`. A mux calls `receive(localDID)` once at construction and
150
+ * `subscribe(localDID, topicID)` as topics are retained, so the first of those
151
+ * brings the client + `hub/receive` channel up.
152
+ *
153
+ * `receive()` may be called more than once: one device runs one GroupPeer per
154
+ * group plus directed sync transports, and the hub delivers each message to a
155
+ * DID exactly once, so there must be exactly one underlying `hub/receive` drain
156
+ * per device DID, demultiplexed client-side by topic. Each `receive()` returns
157
+ * its own independent subscription (sink); the single drain fans every decoded
158
+ * message to every active sink. Sinks are long-lived across reconnects while
159
+ * only the underlying channel is swapped, so every iterable keeps yielding
160
+ * through transport drops. Acks are deduped so the hub is acked at most once per
161
+ * sequenceID regardless of how many sinks ack it.
162
+ */ class HubLikeAdapter {
163
+ #createClient;
164
+ #localDID;
165
+ #onAccessDenied;
166
+ #logger;
167
+ #ackFlushMs;
168
+ #ackFlushMax;
169
+ #backoffBaseMs;
170
+ #backoffMaxMs;
171
+ #backoffJitter;
172
+ #client = null;
173
+ #channel = null;
174
+ // Why the current `hub/receive` call ended, when it ended by being refused.
175
+ #channelFailure = null;
176
+ #ackBatch = null;
177
+ #receiveTask = null;
178
+ #controller = new AbortController();
179
+ // Refcounted, not a set: one device drain is shared by the group peer's mux
180
+ // and by every tunnel session, and several of them hold the same topic at
181
+ // once. Dropping the topic on the first unsubscribe deafens every other
182
+ // holder, and the hub has no way to tell them.
183
+ #activeTopics = new Map();
184
+ #reconnectAttempt = 0;
185
+ // Non-null while a reconnect is in flight. publish/subscribe/unsubscribe wait
186
+ // on it so they bind to the fresh client rather than racing the swap.
187
+ #reconnectGate = null;
188
+ // True for the duration of one #openInner, on either the first-connect or the
189
+ // reconnect path — the mutual exclusion #scheduleReconnect enforces.
190
+ #opening = false;
191
+ // A reconnect asked for while one was already in flight, replayed once it ends.
192
+ #reconnectPending = false;
193
+ // Cuts the current backoff sleep short. Non-null only while the loop is
194
+ // sleeping, which is also the only time cutting it short means anything.
195
+ #wakeReconnect = null;
196
+ // Callers of `retryNow` waiting to hear how the next attempt went.
197
+ #attemptWaiters = [];
198
+ #opened = false;
199
+ // Whether the CURRENT connection has evidence behind it: something the hub
200
+ // answered, or a frame it delivered. Building a client and creating a channel
201
+ // prove nothing — an HTTP client does no I/O until it writes — so an open with
202
+ // no evidence must not reset the backoff, or an unreachable hub is redialled at
203
+ // the base delay for as long as the app runs.
204
+ #proven = false;
205
+ #openPromise = null;
206
+ #disposed = false;
207
+ // Fan-out receive: one underlying hub/receive drain feeds N independent
208
+ // subscriber sinks (group-peer muxes + sync transports share one device DID
209
+ // drain). Each sink owns its queue + waiter; sinks survive reconnects, only
210
+ // the draining channel is replaced.
211
+ #sinks = new Set();
212
+ // Ack dedupe: ack the hub at most once per sequenceID across all sinks. Holds
213
+ // sequenceIDs fed to the current AckBatch but not yet flushed; pruned when the
214
+ // batch flushes (see the send wrapper in #openInner) and cleared whenever the
215
+ // AckBatch is reset on reconnect/dispose so redelivered messages can re-ack.
216
+ // Bounded by the in-flight (unflushed) ack count.
217
+ #ackedPending = new Set();
218
+ constructor(params){
219
+ this.#createClient = params.createClient;
220
+ this.#localDID = params.localDID;
221
+ this.#onAccessDenied = params.onAccessDenied;
222
+ this.#logger = params.logger;
223
+ this.#ackFlushMs = params.ackFlushMs ?? DEFAULT_ACK_FLUSH_MS;
224
+ this.#ackFlushMax = params.ackFlushMax ?? DEFAULT_ACK_FLUSH_MAX;
225
+ this.#backoffBaseMs = params.backoffBaseMs ?? DEFAULT_BACKOFF_BASE_MS;
226
+ this.#backoffMaxMs = params.backoffMaxMs ?? DEFAULT_BACKOFF_MAX_MS;
227
+ this.#backoffJitter = params.backoffJitter ?? DEFAULT_BACKOFF_JITTER;
228
+ }
229
+ // --- lifecycle events ---
230
+ #events = new EventEmitter();
231
+ get events() {
232
+ return this.#events;
233
+ }
234
+ // Subscriber rejections must never break the state machine: emit awaits all
235
+ // listeners and swallows (logs) any that throw, mirroring the older
236
+ // hub-connection lifecycle dispatch.
237
+ async #emit(event) {
238
+ try {
239
+ await this.#events.emit('status', event);
240
+ } catch (error) {
241
+ this.#logger?.warn('hub-like lifecycle subscriber threw', {
242
+ type: event.type,
243
+ error
244
+ });
245
+ }
246
+ }
247
+ // --- connection setup ---
248
+ #ensureOpen() {
249
+ if (this.#opened) {
250
+ return Promise.resolve();
251
+ }
252
+ if (this.#openPromise != null) {
253
+ return this.#openPromise;
254
+ }
255
+ const promise = this.#openInner().then(async ()=>{
256
+ this.#opened = true;
257
+ await this.#emit({
258
+ type: 'connected'
259
+ });
260
+ // The channel this open built ended while it was still being built, so
261
+ // the connection now reported is already dead. Nothing else retries it:
262
+ // its drain asked while the open held the exclusion.
263
+ this.#replayPendingReconnect();
264
+ }).catch((error)=>{
265
+ // Allow a later call to retry the first connect.
266
+ this.#openPromise = null;
267
+ // And retry it without one. The reconnect loop otherwise only ever
268
+ // starts from the receive loop's tail, which requires a channel to have
269
+ // existed — so a device whose FIRST connect fails has no drain and
270
+ // nothing retrying. A passive co-member booted during a hub outage is
271
+ // that device: it never publishes, so nothing re-attempts the open on
272
+ // its behalf, and the bug hides behind any device that also writes.
273
+ this.#scheduleReconnect();
274
+ throw error;
275
+ });
276
+ this.#openPromise = promise;
277
+ return promise;
278
+ }
279
+ async #openInner() {
280
+ this.#opening = true;
281
+ // Any reconnect asked for before this attempt is answered BY this attempt;
282
+ // only a request raised from here on outlives it.
283
+ this.#reconnectPending = false;
284
+ try {
285
+ await this.#openConnection();
286
+ } finally{
287
+ this.#opening = false;
288
+ }
289
+ }
290
+ async #openConnection() {
291
+ // On the reconnect path dispose the prior client so its transport is freed
292
+ // before a fresh one is acquired. dispose is idempotent.
293
+ const previous = this.#client;
294
+ this.#client = null;
295
+ this.#proven = false;
296
+ // Detach BEFORE the dispose: disposing a live client ends its drain, and a
297
+ // drain that still reads as current schedules a reconnect against the
298
+ // connection replacing it.
299
+ this.#channel = null;
300
+ if (previous != null) {
301
+ try {
302
+ await previous.dispose();
303
+ } catch (error) {
304
+ this.#logger?.debug('hub-like previous client dispose failed', {
305
+ error
306
+ });
307
+ }
308
+ }
309
+ const client = await this.#createClient(this.#controller.signal);
310
+ if (this.#disposed) {
311
+ // Disposed while the factory ran: adopting this client would hand the
312
+ // adapter a live connection nothing will ever close.
313
+ await client.dispose().catch(()=>{});
314
+ throw new Error('hub-like adapter disposed');
315
+ }
316
+ this.#client = client;
317
+ const channel = client.createChannel('hub/v1/receive', {
318
+ param: {},
319
+ signal: this.#controller.signal
320
+ });
321
+ this.#channel = channel;
322
+ // The channel call is a promise; swallow its rejection so an aborted /
323
+ // dropped channel never surfaces as an unhandled rejection — and keep the
324
+ // reason, because a refusal is visible ONLY here: an error reply rejects the
325
+ // call and closes the readable, so the drain below sees a clean end. The
326
+ // rejection is queued before that close, so it is recorded by the time the
327
+ // drain exits.
328
+ this.#channelFailure = null;
329
+ void channel.catch((error)=>{
330
+ this.#channelFailure = error;
331
+ });
332
+ // A fresh AckBatch means the prior batch's pending acks (if any) were
333
+ // dropped without flushing; clear the dedupe set so the hub's redelivered
334
+ // messages can be acked again on this connection.
335
+ this.#ackedPending.clear();
336
+ this.#ackBatch = new AckBatch({
337
+ flushMs: this.#ackFlushMs,
338
+ flushMax: this.#ackFlushMax,
339
+ send: async (ids)=>{
340
+ try {
341
+ await channel.send({
342
+ ack: ids
343
+ });
344
+ } finally{
345
+ // Forget flushed sequenceIDs so the dedupe set stays bounded; a future
346
+ // redelivery (after reconnect) is then free to ack again.
347
+ for (const id of ids){
348
+ this.#ackedPending.delete(id);
349
+ }
350
+ }
351
+ },
352
+ logger: this.#logger
353
+ });
354
+ this.#receiveTask = this.#runReceiveLoop(client, channel).catch((error)=>{
355
+ this.#logger?.debug('hub-like receive task tail error', {
356
+ error
357
+ });
358
+ });
359
+ // Re-issue hub/subscribe for every active topic. Empty on the first open;
360
+ // on reconnect it re-registers the device's topics with the fresh client.
361
+ const topics = [
362
+ ...this.#activeTopics.keys()
363
+ ];
364
+ if (topics.length === 0) {
365
+ return;
366
+ }
367
+ const results = await Promise.allSettled(topics.map((topicID)=>client.request('hub/v1/subscribe', {
368
+ param: {
369
+ topicID
370
+ }
371
+ })));
372
+ // Not one of them reached the hub, so this connection is a socket to nowhere.
373
+ // Reporting it open is what used to make an unreachable hub cost a full
374
+ // connect cycle per base delay: every listener acts on `connected`, every
375
+ // publish that follows fails, and the drain dies a moment later asking for the
376
+ // next one. A refusal is NOT this case — the hub answered, which is all the
377
+ // open needs to know.
378
+ if (!results.some(hubAnswered)) {
379
+ const [first] = results;
380
+ // One line, at debug: this is the hub being away, which the reconnect below
381
+ // reports once. Warning per topic here is how an unreachable hub used to
382
+ // cost a warning per topic per attempt.
383
+ this.#logger?.debug('hub-like open reached no hub', {
384
+ topics: topics.length
385
+ });
386
+ throw first.reason ?? new Error('hub did not answer any subscribe');
387
+ }
388
+ this.#proven = true;
389
+ const failed = [];
390
+ for(let i = 0; i < results.length; i++){
391
+ const result = results[i];
392
+ if (result.status !== 'rejected') {
393
+ continue;
394
+ }
395
+ const topicID = topics[i];
396
+ const error = asHubError(result.reason);
397
+ this.#logger?.warn('hub-like re-subscribe failed', {
398
+ topicID,
399
+ // An abort carries its reason on the signal, and React Native's
400
+ // AbortController polyfill has no `reason` — so a torn-down client
401
+ // rejects with `undefined` and the log says only that it was torn down.
402
+ error: error ?? 'aborted (no reason)'
403
+ });
404
+ if (isPermanentSubscribeFailure(error)) {
405
+ // Same rule `subscribe` applies: a settled refusal must stop being asked,
406
+ // or every reconnect for the device's life re-issues it.
407
+ this.#activeTopics.delete(topicID);
408
+ } else {
409
+ failed.push(topicID);
410
+ }
411
+ }
412
+ if (failed.length > 0) {
413
+ // Nothing re-issues these until the next drop, and a connection can stay up
414
+ // for hours — so leaving it here is a device that reports itself connected
415
+ // and receives nothing on those topics. Retried on the live connection
416
+ // rather than by cycling it: publish and subscribe wait on the reconnect
417
+ // gate, so a device whose subscribes the hub keeps refusing would go from
418
+ // deaf to wedged.
419
+ void this.#retryTopics(client, failed);
420
+ }
421
+ }
422
+ /**
423
+ * Re-issue subscribes the open could not land, on the connection that failed
424
+ * them, until they land or that connection is gone. Bounded by the client: a
425
+ * drop ends this lane, and the reconnect's own re-subscribe takes over.
426
+ */ async #retryTopics(client, topics) {
427
+ let pending = topics;
428
+ let attempt = 0;
429
+ while(pending.length > 0 && this.#client === client && !this.#disposed){
430
+ try {
431
+ await abortableSleep(computeBackoff(attempt++, this.#backoffBaseMs, this.#backoffMaxMs, this.#backoffJitter), this.#controller.signal);
432
+ } catch {
433
+ return;
434
+ }
435
+ if (this.#client !== client || this.#disposed) {
436
+ return;
437
+ }
438
+ const results = await Promise.allSettled(pending.map((topicID)=>client.request('hub/v1/subscribe', {
439
+ param: {
440
+ topicID
441
+ }
442
+ })));
443
+ const stillFailing = [];
444
+ for(let i = 0; i < results.length; i++){
445
+ const result = results[i];
446
+ const topicID = pending[i];
447
+ if (result.status !== 'rejected') {
448
+ continue;
449
+ }
450
+ if (isPermanentSubscribeFailure(asHubError(result.reason))) {
451
+ this.#activeTopics.delete(topicID);
452
+ } else if (this.#activeTopics.has(topicID)) {
453
+ stillFailing.push(topicID);
454
+ }
455
+ // A topic no longer registered was unsubscribed while this lane ran; it
456
+ // is dropped by falling through both branches.
457
+ }
458
+ pending = stillFailing;
459
+ }
460
+ if (pending.length === 0) {
461
+ this.#logger?.debug('hub-like re-subscribe recovered', {
462
+ topics: topics.length
463
+ });
464
+ }
465
+ }
466
+ async #runReceiveLoop(client, channel) {
467
+ const reader = channel.readable.getReader();
468
+ let caughtError = null;
469
+ try {
470
+ while(!this.#controller.signal.aborted){
471
+ const readPromise = reader.read();
472
+ const disposedPromise = client.disposed.then(()=>DISPOSED_SENTINEL);
473
+ const raceResult = await Promise.race([
474
+ readPromise,
475
+ disposedPromise
476
+ ]);
477
+ if (typeof raceResult === 'symbol') {
478
+ break;
479
+ }
480
+ const { done, value } = raceResult;
481
+ if (done) {
482
+ break;
483
+ }
484
+ this.#pushMessage({
485
+ sequenceID: value.sequenceID,
486
+ senderDID: value.senderDID,
487
+ topicID: value.topicID,
488
+ payload: fromB64(value.payload),
489
+ // Spread, never assigned: `logPosition: undefined` is a present key, and a
490
+ // consumer told the frame HAS a position reads a falsy one and moves its
491
+ // durable cursor there — past every log frame below it.
492
+ ...value.logPosition != null ? {
493
+ logPosition: value.logPosition
494
+ } : {}
495
+ });
496
+ // A successful read indicates a healthy connection.
497
+ this.#proven = true;
498
+ this.#reconnectAttempt = 0;
499
+ }
500
+ } catch (error) {
501
+ caughtError = error;
502
+ this.#logger?.warn('hub-like receive loop error', {
503
+ error
504
+ });
505
+ } finally{
506
+ try {
507
+ reader.releaseLock();
508
+ } catch {
509
+ // ignore
510
+ }
511
+ }
512
+ // A superseded drain is not a disconnect: its channel was replaced by a live
513
+ // one, and reporting it would emit `disconnected` over a healthy connection
514
+ // and ask for a reconnect that tears that connection down — whose own drain
515
+ // would then ask again. Unreachable while `#scheduleReconnect`'s exclusion
516
+ // holds (only a dispose replaces a live client, and that skips this branch);
517
+ // kept because the failure it prevents is an endless reconnect, not a bug
518
+ // report.
519
+ const superseded = this.#channel !== channel;
520
+ if (!this.#controller.signal.aborted && !this.#disposed && !superseded) {
521
+ const refusal = caughtError ?? this.#channelFailure;
522
+ const reason = refusal instanceof Error && refusal.message ? refusal.message : 'receive loop ended';
523
+ this.#logger?.debug('hub-like receive loop ended, scheduling reconnect', {
524
+ reason
525
+ });
526
+ // Every connection opens this drain, so a refusal always reaches here.
527
+ // Awaited before the reconnect is scheduled: the handler may correct the
528
+ // audience the next dial signs with.
529
+ if (this.#onAccessDenied != null && isAccessDenied(refusal)) {
530
+ try {
531
+ await this.#onAccessDenied();
532
+ } catch (error) {
533
+ this.#logger?.warn('hub-like access-denied handler failed', {
534
+ error
535
+ });
536
+ }
537
+ }
538
+ // Order is load-bearing: subscribers observe `disconnected` before the
539
+ // reconnect loop's first `reconnecting`. `#emit` awaits subscribers, then
540
+ // `#scheduleReconnect` runs the loop whose first action is a backoff
541
+ // sleep, so there is no observable interleaving.
542
+ await this.#emit({
543
+ type: 'disconnected'
544
+ });
545
+ this.#scheduleReconnect();
546
+ }
547
+ }
548
+ #scheduleReconnect() {
549
+ if (this.#disposed || this.#controller.signal.aborted) {
550
+ return;
551
+ }
552
+ // One open at a time. Two overlapping ones each dispose the client the other
553
+ // just built, which aborts that client's in-flight `hub/v1/subscribe` calls —
554
+ // and every disposed client's drain asks for another reconnect, so the overlap
555
+ // sustains itself instead of settling. Recorded rather than dropped: the asking
556
+ // drain may be the one belonging to the connection being built right now, and
557
+ // dropping that leaves the adapter reporting a connection whose channel is gone.
558
+ if (this.#reconnectGate != null || this.#opening) {
559
+ this.#reconnectPending = true;
560
+ return;
561
+ }
562
+ this.#receiveTask = this.#reconnectLoop().catch((error)=>{
563
+ this.#logger?.debug('hub-like reconnect task tail error', {
564
+ error
565
+ });
566
+ });
567
+ }
568
+ /** Settle everyone waiting on the outcome of the attempt that just finished. */ #settleAttempt(connected) {
569
+ const waiters = this.#attemptWaiters.splice(0);
570
+ for (const waiter of waiters){
571
+ waiter(connected);
572
+ }
573
+ }
574
+ async #reconnectLoop() {
575
+ let resolveGate = ()=>{};
576
+ this.#reconnectGate = new Promise((resolve)=>{
577
+ resolveGate = resolve;
578
+ });
579
+ try {
580
+ while(!this.#controller.signal.aborted && !this.#disposed){
581
+ const delay = computeBackoff(this.#reconnectAttempt++, this.#backoffBaseMs, this.#backoffMaxMs, this.#backoffJitter);
582
+ // Raced against a wake rather than simply slept: a person who has just
583
+ // started their hub should not wait out a backoff that has grown to
584
+ // half a minute, and `retryNow` is how they say so.
585
+ const wake = new Promise((resolve)=>{
586
+ this.#wakeReconnect = resolve;
587
+ });
588
+ try {
589
+ await Promise.race([
590
+ abortableSleep(delay, this.#controller.signal),
591
+ wake
592
+ ]);
593
+ } catch {
594
+ this.#settleAttempt(false);
595
+ return;
596
+ } finally{
597
+ this.#wakeReconnect = null;
598
+ }
599
+ if (this.#disposed) {
600
+ return;
601
+ }
602
+ await this.#emit({
603
+ type: 'reconnecting'
604
+ });
605
+ try {
606
+ this.#ackBatch?.dispose();
607
+ this.#ackBatch = null;
608
+ this.#channel = null;
609
+ await this.#openInner();
610
+ // A reconnect that follows a failed FIRST connect is the open: without
611
+ // this the adapter still reads as never-opened, and the next
612
+ // `#ensureOpen` would tear this live connection down to build another.
613
+ this.#opened = true;
614
+ // Only a connection the hub answered starts the next outage's backoff
615
+ // from scratch. An open with nothing behind it (a device holding no
616
+ // topics yet) leaves the counter climbing, so the redials keep spacing
617
+ // out even though each one "succeeds".
618
+ if (this.#proven) {
619
+ this.#reconnectAttempt = 0;
620
+ }
621
+ // Emit before resolving the gate so awaiters observe `connected`
622
+ // before their queued publish/subscribe resumes.
623
+ await this.#emit({
624
+ type: 'connected'
625
+ });
626
+ return;
627
+ } catch (error) {
628
+ // The first failure of an outage is news; the rest are the backoff
629
+ // working, and a hub left off overnight would otherwise fill the log
630
+ // with the same line. Reported again the moment it reconnects.
631
+ const context = {
632
+ attempt: this.#reconnectAttempt,
633
+ error
634
+ };
635
+ if (this.#reconnectAttempt === 1) {
636
+ this.#logger?.warn('hub-like reconnect attempt failed', context);
637
+ } else {
638
+ this.#logger?.debug('hub-like reconnect attempt failed', context);
639
+ }
640
+ this.#settleAttempt(false);
641
+ }
642
+ }
643
+ } finally{
644
+ this.#reconnectGate = null;
645
+ resolveGate();
646
+ // Settled here rather than at the moment the open succeeded: a caller told
647
+ // "connected" while the gate is still up would have its very next publish
648
+ // refused as "a reconnect is in progress". Also catches waiters parked past
649
+ // the end of the loop, whose promise no attempt would otherwise settle.
650
+ this.#settleAttempt(this.#opened && this.#client != null);
651
+ this.#replayPendingReconnect();
652
+ }
653
+ }
654
+ /**
655
+ * Answer a reconnect asked for while an open held the exclusion — typically by
656
+ * the drain of the connection that open was building, which ended before it
657
+ * finished. Deferred a turn rather than started here: `publish`/`subscribe` park
658
+ * on the gate this loop just resolved, and starting the next loop synchronously
659
+ * would take a fresh gate before any of them resumed.
660
+ */ #replayPendingReconnect() {
661
+ if (!this.#reconnectPending || this.#disposed || this.#controller.signal.aborted) {
662
+ return;
663
+ }
664
+ setTimeout(()=>{
665
+ this.#scheduleReconnect();
666
+ }, 0);
667
+ }
668
+ /**
669
+ * The live client, or an error — never a connection this call opened while a
670
+ * reconnect was already in flight.
671
+ *
672
+ * That loop owns the dialling and paces it on a backoff. An operation that
673
+ * opens on its own account is paced by nothing, so a device whose hub is not
674
+ * answering re-dials once per publish and logs each one, which is the entire
675
+ * cost the backoff exists to bound.
676
+ *
677
+ * What a caller gets while the loop is running depends on whether this adapter
678
+ * has ever been connected, and the two cases are genuinely different:
679
+ *
680
+ * - **Connected before.** A live setup is being repaired, so the operation
681
+ * WAITS. That is what makes a commit made while the hub is away park and land
682
+ * when it returns — the invite lane is built on it.
683
+ * - **Never connected.** There is no setup to repair and no reason to believe
684
+ * the next attempt is the one that works. Waiting here hangs whoever is
685
+ * bringing a lane up — a device starting while its hub is off — for as long
686
+ * as the hub stays away, so the answer is "not now" and the loop keeps trying.
687
+ */ async #ready() {
688
+ if (this.#disposed) {
689
+ throw new Error('hub-like adapter disposed');
690
+ }
691
+ if (this.#reconnectGate != null && !this.#opened) {
692
+ throw new Error('hub not connected: a reconnect is in progress');
693
+ }
694
+ while(this.#reconnectGate != null){
695
+ await this.#reconnectGate;
696
+ }
697
+ if (this.#disposed) {
698
+ throw new Error('hub-like adapter disposed');
699
+ }
700
+ // Only now, with nothing else retrying: this is the caller that starts the
701
+ // first dial after a connection is lost, and the loop it schedules is what
702
+ // paces every caller behind it.
703
+ await this.#ensureOpen();
704
+ // Wait out a reconnect that began while the open ran, so the operation binds
705
+ // to the fresh client rather than to one being torn down.
706
+ while(this.#reconnectGate != null){
707
+ await this.#reconnectGate;
708
+ }
709
+ if (this.#disposed) {
710
+ throw new Error('hub-like adapter disposed');
711
+ }
712
+ const client = this.#client;
713
+ if (client == null) {
714
+ throw new Error('hub-like adapter not connected');
715
+ }
716
+ return client;
717
+ }
718
+ /**
719
+ * Try the hub now instead of when the backoff says so, and answer with whether
720
+ * that attempt connected.
721
+ *
722
+ * For the person who just started the hub the app has been failing to reach:
723
+ * the backoff has grown to tens of seconds by then, and waiting it out looks
724
+ * exactly like the app being broken. The attempt counter resets too, so a
725
+ * manual retry that fails leaves the automatic ones starting small again
726
+ * rather than resuming at the ceiling.
727
+ */ retryNow = async ()=>{
728
+ if (this.#disposed) {
729
+ throw new Error('hub-like adapter disposed');
730
+ }
731
+ if (this.#reconnectGate == null && this.#opened && this.#client != null) {
732
+ return true;
733
+ }
734
+ const attempt = new Promise((resolve)=>{
735
+ this.#attemptWaiters.push(resolve);
736
+ });
737
+ this.#reconnectAttempt = 0;
738
+ if (this.#reconnectGate == null) {
739
+ // Nothing is retrying — a first connect that failed and left no loop, or a
740
+ // device that has never dialled. Start one; its first sleep is the base
741
+ // delay, which the wake below cuts short.
742
+ this.#scheduleReconnect();
743
+ }
744
+ this.#wakeReconnect?.();
745
+ return await attempt;
746
+ };
747
+ // --- HubLike surface ---
748
+ publish = async ({ senderDID, topicID, payload, retain, expectedHead, publishID })=>{
749
+ if (senderDID !== this.#localDID) {
750
+ // The hub derives the sender from the signed `iss`; a mismatch here means
751
+ // a caller stamped the wrong DID. The publish still uses this device's
752
+ // identity, so surface it as a warning rather than a hard failure.
753
+ this.#logger?.warn('hub-like publish senderDID does not match localDID', {
754
+ senderDID,
755
+ localDID: this.#localDID
756
+ });
757
+ }
758
+ const client = await this.#ready();
759
+ // The log-class fields (`retain`, `expectedHead`, `publishID`) ride through to
760
+ // the wire so a caller can drive the CAS commit lane; a mailbox publish simply
761
+ // leaves them undefined.
762
+ const result = await client.request('hub/v1/publish', {
763
+ param: {
764
+ topicID,
765
+ payload: toB64(payload),
766
+ retain,
767
+ expectedHead,
768
+ publishID
769
+ }
770
+ }).catch(rethrowHubError);
771
+ return {
772
+ sequenceID: result.sequenceID
773
+ };
774
+ };
775
+ fetchTopic = async ({ topicID, after, limit })=>{
776
+ // The hub gates the read on subscription and derives the caller from the
777
+ // signed `iss`, so `subscriberDID` is never a wire field.
778
+ const client = await this.#ready();
779
+ const result = await client.request('hub/v1/topic/fetch', {
780
+ param: {
781
+ topicID,
782
+ after,
783
+ limit
784
+ }
785
+ }).catch(rethrowHubError);
786
+ return {
787
+ messages: result.messages.map((message)=>({
788
+ sequenceID: message.sequenceID,
789
+ senderDID: message.senderDID,
790
+ topicID: message.topicID,
791
+ payload: fromB64(message.payload),
792
+ // Derived, not carried: the fetch reply has no such field and needs none. A
793
+ // topic's log holds log-class frames and nothing else, and the position this
794
+ // reply serves a frame at IS the sequenceID it serves it under — the value
795
+ // `after`, `head` and `oldest` are compared against.
796
+ logPosition: message.sequenceID
797
+ })),
798
+ head: result.head,
799
+ oldest: result.oldest
800
+ };
801
+ };
802
+ subscribe = async (_subscriberDID, topicID, options)=>{
803
+ // A subscription is a DECLARATION, not an operation: the adapter re-issues
804
+ // every registered topic on the next connection, so registering it is the
805
+ // whole of what a caller needs. Parking here instead — as a publish does,
806
+ // because a publish has to actually reach the hub — would hang the caller
807
+ // standing a group's lane up for as long as the hub stays away, and that
808
+ // caller is usually a device starting while its hub is off.
809
+ if (this.#reconnectGate != null) {
810
+ this.#retainTopic(topicID);
811
+ return;
812
+ }
813
+ const client = await this.#ready();
814
+ try {
815
+ await client.request('hub/v1/subscribe', {
816
+ param: {
817
+ topicID,
818
+ retention: options?.retention
819
+ }
820
+ });
821
+ } catch (error) {
822
+ // Registration follows ACCEPTANCE, with one exception. The reconnect path
823
+ // re-issues every registered topic, so a transient failure has to stay
824
+ // registered or the reconnect that would heal it never asks again. A permanent
825
+ // refusal must not: it would be re-issued on every reconnect for the peer's
826
+ // whole life, each time answered the same way and logged as a warning nobody
827
+ // reads. Classified on the REBUILT error, since the refusal crosses the wire as
828
+ // a code and the classifier matches the class.
829
+ const hubError = asHubError(error);
830
+ // The hub never answered, so there is nothing for the caller to learn and
831
+ // nothing for it to do: the reconnect re-issues every registered topic, and
832
+ // this one is now registered. Throwing here reaches a mux that floats the
833
+ // rejection, and one dropped transport turns into one unhandled rejection
834
+ // per topic the device holds.
835
+ if (!(error instanceof RequestError)) {
836
+ this.#armTopic(topicID);
837
+ this.#logger?.debug('hub-like subscribe deferred to the next connection', {
838
+ topicID,
839
+ error
840
+ });
841
+ return;
842
+ }
843
+ if (!isPermanentSubscribeFailure(hubError)) {
844
+ // Registered at zero holders, NOT retained: this caller is being told the
845
+ // subscribe failed, so it holds nothing and will not unsubscribe. Counting
846
+ // it as a holder here and again when it retries leaves a count no
847
+ // unsubscribe can drain, and "only the last holder tells the hub" then
848
+ // means the hub is never told at all.
849
+ this.#armTopic(topicID);
850
+ }
851
+ throw hubError;
852
+ }
853
+ this.#retainTopic(topicID);
854
+ };
855
+ unsubscribe = async (_subscriberDID, topicID)=>{
856
+ // Released locally while disconnected, for the same reason: the hub holds no
857
+ // subscription to forget — the reconnect re-issues only what is registered
858
+ // when it lands, and this topic will not be.
859
+ if (this.#reconnectGate != null) {
860
+ const held = (this.#activeTopics.get(topicID) ?? 0) - 1;
861
+ if (held > 0) {
862
+ this.#activeTopics.set(topicID, held);
863
+ } else {
864
+ this.#activeTopics.delete(topicID);
865
+ }
866
+ return;
867
+ }
868
+ const client = await this.#ready();
869
+ // Only the last holder tells the hub. An untracked topic is released rather
870
+ // than ignored: a caller unsubscribing something this adapter never armed is
871
+ // asking the hub to forget a subscription it may still hold from a previous
872
+ // process, and swallowing that would leave the device drained by nobody.
873
+ const remaining = (this.#activeTopics.get(topicID) ?? 0) - 1;
874
+ if (remaining > 0) {
875
+ this.#activeTopics.set(topicID, remaining);
876
+ return;
877
+ }
878
+ this.#activeTopics.delete(topicID);
879
+ await client.request('hub/v1/unsubscribe', {
880
+ param: {
881
+ topicID
882
+ }
883
+ }).catch(rethrowHubError);
884
+ };
885
+ #retainTopic(topicID) {
886
+ this.#activeTopics.set(topicID, (this.#activeTopics.get(topicID) ?? 0) + 1);
887
+ }
888
+ /**
889
+ * Register a topic for re-issue on the next connection without counting a
890
+ * holder for it. A zero-count entry is re-subscribed like any other — the
891
+ * reconnect reads the keys — and is released by the first `unsubscribe`.
892
+ */ #armTopic(topicID) {
893
+ if (!this.#activeTopics.has(topicID)) {
894
+ this.#activeTopics.set(topicID, 0);
895
+ }
896
+ }
897
+ receive = (_subscriberDID, options)=>{
898
+ if (this.#disposed) {
899
+ throw new Error('hub-like adapter disposed');
900
+ }
901
+ const sink = new ReceiveSink(options);
902
+ this.#sinks.add(sink);
903
+ // The first receive() brings the connection up so the drain has a channel to
904
+ // read from; later calls are idempotent. A late subscriber registers its
905
+ // sink before this resolves, so no in-flight message is lost to it. Failures
906
+ // surface through lifecycle events + retry.
907
+ void this.#ensureOpen().catch((error)=>{
908
+ this.#logger?.warn('hub-like open on receive failed', {
909
+ error
910
+ });
911
+ });
912
+ // Removing one sink ends only that subscription; the shared drain,
913
+ // connection and AckBatch persist while any other sink remains. Full
914
+ // teardown happens only via the adapter's dispose().
915
+ const removeSink = ()=>{
916
+ if (this.#sinks.delete(sink)) {
917
+ sink.stop();
918
+ }
919
+ };
920
+ const iterator = {
921
+ next: ()=>sink.next(),
922
+ return: ()=>{
923
+ removeSink();
924
+ return Promise.resolve({
925
+ value: undefined,
926
+ done: true
927
+ });
928
+ }
929
+ };
930
+ return {
931
+ [Symbol.asyncIterator]: ()=>iterator,
932
+ return: ()=>{
933
+ removeSink();
934
+ },
935
+ ack: (sequenceID)=>{
936
+ // Dedupe across all sinks: the first ack for a sequenceID feeds the
937
+ // shared AckBatch; repeats (same or another sink) are no-ops until the
938
+ // batch flushes and prunes it. If no batch is live (mid-reconnect), skip
939
+ // recording so a post-reconnect re-ack still reaches the hub.
940
+ if (this.#ackedPending.has(sequenceID)) {
941
+ return;
942
+ }
943
+ const ackBatch = this.#ackBatch;
944
+ if (ackBatch == null) {
945
+ return;
946
+ }
947
+ this.#ackedPending.add(sequenceID);
948
+ ackBatch.add(sequenceID);
949
+ }
950
+ };
951
+ };
952
+ // Fan the decoded message out to every active sink. Sinks whose mux has no
953
+ // listener for the topic simply never pull/ack it; that is expected and the
954
+ // hub redelivers such messages on reconnect.
955
+ #pushMessage(message) {
956
+ if (this.#disposed) {
957
+ return;
958
+ }
959
+ for (const sink of this.#sinks){
960
+ sink.push(message);
961
+ }
962
+ }
963
+ // --- disposal ---
964
+ dispose = async ()=>{
965
+ if (this.#disposed) {
966
+ return;
967
+ }
968
+ this.#disposed = true;
969
+ this.#controller.abort();
970
+ // End every subscription: stop each sink (resolving parked waiters as done)
971
+ // and drop them. New receive() calls are rejected once disposed.
972
+ for (const sink of this.#sinks){
973
+ sink.stop();
974
+ }
975
+ this.#sinks.clear();
976
+ const ackBatch = this.#ackBatch;
977
+ this.#ackBatch = null;
978
+ if (ackBatch != null) {
979
+ try {
980
+ await ackBatch.flush();
981
+ } catch (error) {
982
+ this.#logger?.debug('hub-like ack flush during dispose failed', {
983
+ error
984
+ });
985
+ }
986
+ ackBatch.dispose();
987
+ }
988
+ // Close the channel before awaiting the receive task so the reader unblocks.
989
+ try {
990
+ this.#channel?.close();
991
+ } catch {
992
+ // ignore
993
+ }
994
+ // And drop the client BEFORE that await, not after. The task may be the
995
+ // reconnect loop rather than a drain, and a loop parked inside an open is
996
+ // waiting on requests that settle only when their client is disposed — so
997
+ // awaiting the task first is a dispose that waits on itself.
998
+ const client = this.#client;
999
+ this.#client = null;
1000
+ if (client != null) {
1001
+ try {
1002
+ await client.dispose();
1003
+ } catch (error) {
1004
+ this.#logger?.debug('hub-like client dispose failed', {
1005
+ error
1006
+ });
1007
+ }
1008
+ }
1009
+ try {
1010
+ await this.#receiveTask;
1011
+ } catch {
1012
+ // ignore
1013
+ }
1014
+ this.#channel = null;
1015
+ this.#receiveTask = null;
1016
+ this.#activeTopics.clear();
1017
+ this.#ackedPending.clear();
1018
+ };
1019
+ }
1020
+ export function createHubLike(params) {
1021
+ return new HubLikeAdapter(params);
1022
+ }