@kubun/plugin-p2p 0.11.0 → 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 (156) hide show
  1. package/lib/context/group.js +1073 -746
  2. package/lib/context/hub.js +2 -1
  3. package/lib/context/join.js +50 -15
  4. package/lib/context/peer.d.ts +92 -0
  5. package/lib/context/peer.js +1216 -0
  6. package/lib/context/require-admin.d.ts +57 -0
  7. package/lib/context/require-admin.js +57 -0
  8. package/lib/context/sync.d.ts +12 -0
  9. package/lib/context/sync.js +234 -1
  10. package/lib/context/types.d.ts +56 -20
  11. package/lib/context/types.js +23 -3
  12. package/lib/groups/access-default-token.d.ts +44 -0
  13. package/lib/groups/access-default-token.js +103 -0
  14. package/lib/groups/admin-roster.d.ts +42 -117
  15. package/lib/groups/admin-roster.js +53 -168
  16. package/lib/groups/anchor-store.d.ts +15 -0
  17. package/lib/groups/anchor-store.js +37 -0
  18. package/lib/groups/app-cursor-store.d.ts +17 -0
  19. package/lib/groups/app-cursor-store.js +34 -0
  20. package/lib/groups/broadcast.d.ts +199 -81
  21. package/lib/groups/broadcast.js +443 -368
  22. package/lib/groups/catalog-token.d.ts +50 -0
  23. package/lib/groups/catalog-token.js +96 -0
  24. package/lib/groups/circle-projection.d.ts +32 -64
  25. package/lib/groups/circle-projection.js +95 -181
  26. package/lib/groups/circle-reducers.d.ts +71 -110
  27. package/lib/groups/circle-reducers.js +57 -127
  28. package/lib/groups/commit-adoption.d.ts +77 -0
  29. package/lib/groups/commit-adoption.js +108 -0
  30. package/lib/groups/commit-journal.d.ts +14 -0
  31. package/lib/groups/commit-journal.js +65 -0
  32. package/lib/groups/control-request.d.ts +66 -0
  33. package/lib/groups/control-request.js +101 -0
  34. package/lib/groups/events.d.ts +14 -1
  35. package/lib/groups/group-anchor.d.ts +18 -57
  36. package/lib/groups/group-anchor.js +15 -100
  37. package/lib/groups/group-crypto.d.ts +36 -13
  38. package/lib/groups/group-crypto.js +124 -50
  39. package/lib/groups/group-handle-registry.d.ts +106 -36
  40. package/lib/groups/group-handle-registry.js +351 -61
  41. package/lib/groups/group-handlers.d.ts +5 -4
  42. package/lib/groups/group-handlers.js +155 -29
  43. package/lib/groups/group-health-monitor.d.ts +1 -1
  44. package/lib/groups/group-health-monitor.js +1 -1
  45. package/lib/groups/group-mls.d.ts +35 -4
  46. package/lib/groups/group-mls.js +594 -62
  47. package/lib/groups/group-peer-manager.d.ts +212 -19
  48. package/lib/groups/group-peer-manager.js +1061 -69
  49. package/lib/groups/group-protocols.d.ts +485 -28
  50. package/lib/groups/group-protocols.js +289 -15
  51. package/lib/groups/invite-payload.d.ts +48 -0
  52. package/lib/groups/join-request-identity.d.ts +21 -0
  53. package/lib/groups/join-request-identity.js +43 -0
  54. package/lib/groups/join-utils.d.ts +60 -0
  55. package/lib/groups/join-utils.js +262 -0
  56. package/lib/groups/ledger-affected-events.d.ts +11 -6
  57. package/lib/groups/ledger-affected-events.js +15 -26
  58. package/lib/groups/ledger-commit-fold.d.ts +107 -0
  59. package/lib/groups/ledger-commit-fold.js +167 -0
  60. package/lib/groups/ledger-ingest.d.ts +124 -0
  61. package/lib/groups/ledger-ingest.js +212 -0
  62. package/lib/groups/ledger.d.ts +44 -0
  63. package/lib/groups/ledger.js +30 -0
  64. package/lib/groups/manager.d.ts +202 -19
  65. package/lib/groups/manager.js +623 -106
  66. package/lib/groups/mls-codec.d.ts +27 -6
  67. package/lib/groups/mls-codec.js +30 -15
  68. package/lib/groups/mls-encryptor.js +9 -6
  69. package/lib/groups/mls-group-handle.d.ts +37 -2
  70. package/lib/groups/mls-group-handle.js +12 -4
  71. package/lib/groups/mls-receive-errors.d.ts +0 -2
  72. package/lib/groups/mls-receive-errors.js +3 -6
  73. package/lib/groups/mls-state.d.ts +5 -3
  74. package/lib/groups/mls-state.js +27 -4
  75. package/lib/groups/peer-presence.d.ts +90 -0
  76. package/lib/groups/peer-presence.js +295 -0
  77. package/lib/groups/peer-selection.d.ts +37 -0
  78. package/lib/groups/peer-selection.js +72 -0
  79. package/lib/groups/roster-projection.d.ts +53 -0
  80. package/lib/groups/roster-projection.js +65 -0
  81. package/lib/groups/store-received-grant.d.ts +4 -3
  82. package/lib/groups/store-received-grant.js +80 -35
  83. package/lib/groups/store-received-revocation.d.ts +16 -13
  84. package/lib/groups/store-received-revocation.js +23 -22
  85. package/lib/hub/http-client.d.ts +10 -27
  86. package/lib/hub/http-client.js +2 -19
  87. package/lib/hub/hub-like.d.ts +57 -6
  88. package/lib/hub/hub-like.js +539 -52
  89. package/lib/hub/loopback-log-hub.d.ts +20 -0
  90. package/lib/hub/loopback-log-hub.js +178 -0
  91. package/lib/hub/manager.d.ts +25 -40
  92. package/lib/hub/manager.js +51 -94
  93. package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
  94. package/lib/hub/peer-scoped-hub-view.js +92 -0
  95. package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
  96. package/lib/hub/sender-scoped-hub-view.js +59 -0
  97. package/lib/hub/server-did.d.ts +67 -0
  98. package/lib/hub/server-did.js +131 -0
  99. package/lib/hub/wiring.d.ts +71 -14
  100. package/lib/hub/wiring.js +80 -23
  101. package/lib/index.d.ts +48 -13
  102. package/lib/index.js +257 -86
  103. package/lib/peer/connection-registry.d.ts +45 -0
  104. package/lib/peer/connection-registry.js +52 -0
  105. package/lib/peer/handlers.d.ts +69 -0
  106. package/lib/peer/handlers.js +110 -0
  107. package/lib/protocol.d.ts +213 -0
  108. package/lib/protocol.js +268 -0
  109. package/lib/schema.d.ts +2 -1
  110. package/lib/schema.js +805 -131
  111. package/lib/sync/access-default-sender.d.ts +23 -0
  112. package/lib/sync/access-default-sender.js +80 -0
  113. package/lib/sync/authorize.d.ts +57 -3
  114. package/lib/sync/authorize.js +45 -4
  115. package/lib/sync/broadcast-queue.d.ts +3 -4
  116. package/lib/sync/broadcast-queue.js +2 -3
  117. package/lib/sync/catalog-scope.d.ts +8 -7
  118. package/lib/sync/catalog-scope.js +55 -39
  119. package/lib/sync/errors.d.ts +9 -0
  120. package/lib/sync/errors.js +10 -0
  121. package/lib/sync/forwarder.d.ts +13 -19
  122. package/lib/sync/forwarder.js +13 -19
  123. package/lib/sync/handlers.js +54 -14
  124. package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
  125. package/lib/sync/hub-tunnel-sync-listener.js +126 -37
  126. package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
  127. package/lib/sync/hub-tunnel-sync-provider.js +68 -19
  128. package/lib/sync/merkle-channel.d.ts +9 -1
  129. package/lib/sync/merkle-channel.js +0 -0
  130. package/lib/sync/peer-registry.d.ts +14 -10
  131. package/lib/sync/peer-registry.js +0 -25
  132. package/lib/sync/sync-client.d.ts +11 -1
  133. package/lib/sync/sync-client.js +72 -10
  134. package/lib/sync/sync-manager.d.ts +22 -4
  135. package/lib/sync/sync-manager.js +39 -10
  136. package/lib/sync/tunnel-listeners.d.ts +52 -0
  137. package/lib/sync/tunnel-listeners.js +175 -0
  138. package/lib/sync/tunnel-observability.d.ts +25 -0
  139. package/lib/sync/tunnel-observability.js +63 -0
  140. package/lib/sync/tunnel-topics.d.ts +20 -0
  141. package/lib/sync/tunnel-topics.js +28 -0
  142. package/lib/types.d.ts +699 -28
  143. package/lib/types.js +0 -1
  144. package/lib/util/handler-error.d.ts +16 -0
  145. package/lib/util/handler-error.js +64 -0
  146. package/lib/util/mutex.d.ts +14 -1
  147. package/lib/util/mutex.js +37 -3
  148. package/package.json +56 -52
  149. package/lib/groups/ledger-entry.d.ts +0 -37
  150. package/lib/groups/ledger-entry.js +0 -56
  151. package/lib/groups/ledger-fold.d.ts +0 -56
  152. package/lib/groups/ledger-fold.js +0 -53
  153. package/lib/hub/did-observing-transport.d.ts +0 -75
  154. package/lib/hub/did-observing-transport.js +0 -126
  155. package/lib/hub/errors.d.ts +0 -24
  156. package/lib/hub/errors.js +0 -33
@@ -1,28 +1,97 @@
1
+ import { RequestError } from '@enkaku/client';
2
+ import { ErrorCodes } from '@enkaku/protocol';
3
+ import { hubErrorFromCode, RetentionExceededError } from '@kumiai/hub-protocol';
1
4
  import { fromB64, toB64 } from '@sozai/codec';
2
5
  import { EventEmitter } from '@sozai/event';
3
6
  import { AckBatch, abortableSleep, computeBackoff } from './ack-backoff.js';
4
7
  const DEFAULT_ACK_FLUSH_MS = 500;
5
8
  const DEFAULT_ACK_FLUSH_MAX = 10;
6
9
  const DEFAULT_BACKOFF_BASE_MS = 1000;
7
- const DEFAULT_BACKOFF_MAX_MS = 60000;
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;
8
15
  const DEFAULT_BACKOFF_JITTER = 0.25;
9
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
+ }
10
59
  /**
11
60
  * One independent receive subscriber. The underlying single hub/receive drain
12
61
  * fans every decoded message to every active sink; each sink buffers in its own
13
62
  * queue and parks a single waiter when drained. Sinks are long-lived across
14
63
  * reconnects — only the draining channel is swapped, never the sinks.
15
64
  *
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.
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.
18
75
  */ class ReceiveSink {
19
76
  #queue = [];
20
77
  #waiter = null;
21
78
  #stopped = false;
79
+ #senderDID;
80
+ #topicID;
81
+ constructor(options){
82
+ this.#senderDID = options?.senderDID;
83
+ this.#topicID = options?.topicID;
84
+ }
22
85
  push(message) {
23
86
  if (this.#stopped) {
24
87
  return;
25
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
+ }
26
95
  const waiter = this.#waiter;
27
96
  if (waiter != null) {
28
97
  this.#waiter = null;
@@ -93,6 +162,7 @@ const DISPOSED_SENTINEL = Symbol('disposed');
93
162
  */ class HubLikeAdapter {
94
163
  #createClient;
95
164
  #localDID;
165
+ #onAccessDenied;
96
166
  #logger;
97
167
  #ackFlushMs;
98
168
  #ackFlushMax;
@@ -101,18 +171,39 @@ const DISPOSED_SENTINEL = Symbol('disposed');
101
171
  #backoffJitter;
102
172
  #client = null;
103
173
  #channel = null;
174
+ // Why the current `hub/receive` call ended, when it ended by being refused.
175
+ #channelFailure = null;
104
176
  #ackBatch = null;
105
177
  #receiveTask = null;
106
178
  #controller = new AbortController();
107
- #activeTopics = new Set();
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();
108
184
  #reconnectAttempt = 0;
109
185
  // Non-null while a reconnect is in flight. publish/subscribe/unsubscribe wait
110
186
  // on it so they bind to the fresh client rather than racing the swap.
111
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 = [];
112
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;
113
205
  #openPromise = null;
114
206
  #disposed = false;
115
- #events = new EventEmitter();
116
207
  // Fan-out receive: one underlying hub/receive drain feeds N independent
117
208
  // subscriber sinks (group-peer muxes + sync transports share one device DID
118
209
  // drain). Each sink owns its queue + waiter; sinks survive reconnects, only
@@ -127,6 +218,7 @@ const DISPOSED_SENTINEL = Symbol('disposed');
127
218
  constructor(params){
128
219
  this.#createClient = params.createClient;
129
220
  this.#localDID = params.localDID;
221
+ this.#onAccessDenied = params.onAccessDenied;
130
222
  this.#logger = params.logger;
131
223
  this.#ackFlushMs = params.ackFlushMs ?? DEFAULT_ACK_FLUSH_MS;
132
224
  this.#ackFlushMax = params.ackFlushMax ?? DEFAULT_ACK_FLUSH_MAX;
@@ -135,17 +227,16 @@ const DISPOSED_SENTINEL = Symbol('disposed');
135
227
  this.#backoffJitter = params.backoffJitter ?? DEFAULT_BACKOFF_JITTER;
136
228
  }
137
229
  // --- lifecycle events ---
138
- events = {
139
- subscribe: (listener)=>{
140
- return this.#events.on('lifecycle', listener);
141
- }
142
- };
230
+ #events = new EventEmitter();
231
+ get events() {
232
+ return this.#events;
233
+ }
143
234
  // Subscriber rejections must never break the state machine: emit awaits all
144
235
  // listeners and swallows (logs) any that throw, mirroring the older
145
236
  // hub-connection lifecycle dispatch.
146
237
  async #emit(event) {
147
238
  try {
148
- await this.#events.emit('lifecycle', event);
239
+ await this.#events.emit('status', event);
149
240
  } catch (error) {
150
241
  this.#logger?.warn('hub-like lifecycle subscriber threw', {
151
242
  type: event.type,
@@ -166,19 +257,46 @@ const DISPOSED_SENTINEL = Symbol('disposed');
166
257
  await this.#emit({
167
258
  type: 'connected'
168
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();
169
264
  }).catch((error)=>{
170
265
  // Allow a later call to retry the first connect.
171
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();
172
274
  throw error;
173
275
  });
174
276
  this.#openPromise = promise;
175
277
  return promise;
176
278
  }
177
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() {
178
291
  // On the reconnect path dispose the prior client so its transport is freed
179
292
  // before a fresh one is acquired. dispose is idempotent.
180
293
  const previous = this.#client;
181
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;
182
300
  if (previous != null) {
183
301
  try {
184
302
  await previous.dispose();
@@ -188,16 +306,29 @@ const DISPOSED_SENTINEL = Symbol('disposed');
188
306
  });
189
307
  }
190
308
  }
191
- const client = await this.#createClient();
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
+ }
192
316
  this.#client = client;
193
- const channel = client.createChannel('hub/receive', {
317
+ const channel = client.createChannel('hub/v1/receive', {
194
318
  param: {},
195
319
  signal: this.#controller.signal
196
320
  });
197
321
  this.#channel = channel;
198
322
  // 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(()=>{});
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
+ });
201
332
  // A fresh AckBatch means the prior batch's pending acks (if any) were
202
333
  // dropped without flushing; clear the dedupe set so the hub's redelivered
203
334
  // messages can be acked again on this connection.
@@ -228,24 +359,108 @@ const DISPOSED_SENTINEL = Symbol('disposed');
228
359
  // Re-issue hub/subscribe for every active topic. Empty on the first open;
229
360
  // on reconnect it re-registers the device's topics with the fresh client.
230
361
  const topics = [
231
- ...this.#activeTopics
362
+ ...this.#activeTopics.keys()
232
363
  ];
233
- if (topics.length > 0) {
234
- const results = await Promise.allSettled(topics.map((topicID)=>client.request('hub/subscribe', {
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', {
235
439
  param: {
236
440
  topicID
237
441
  }
238
442
  })));
443
+ const stillFailing = [];
239
444
  for(let i = 0; i < results.length; i++){
240
445
  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
- });
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);
247
454
  }
455
+ // A topic no longer registered was unsubscribed while this lane ran; it
456
+ // is dropped by falling through both branches.
248
457
  }
458
+ pending = stillFailing;
459
+ }
460
+ if (pending.length === 0) {
461
+ this.#logger?.debug('hub-like re-subscribe recovered', {
462
+ topics: topics.length
463
+ });
249
464
  }
250
465
  }
251
466
  async #runReceiveLoop(client, channel) {
@@ -270,9 +485,16 @@ const DISPOSED_SENTINEL = Symbol('disposed');
270
485
  sequenceID: value.sequenceID,
271
486
  senderDID: value.senderDID,
272
487
  topicID: value.topicID,
273
- payload: fromB64(value.payload)
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
+ } : {}
274
495
  });
275
496
  // A successful read indicates a healthy connection.
497
+ this.#proven = true;
276
498
  this.#reconnectAttempt = 0;
277
499
  }
278
500
  } catch (error) {
@@ -287,11 +509,32 @@ const DISPOSED_SENTINEL = Symbol('disposed');
287
509
  // ignore
288
510
  }
289
511
  }
290
- if (!this.#controller.signal.aborted && !this.#disposed) {
291
- const reason = caughtError instanceof Error && caughtError.message ? caughtError.message : 'receive loop ended';
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';
292
523
  this.#logger?.debug('hub-like receive loop ended, scheduling reconnect', {
293
524
  reason
294
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
+ }
295
538
  // Order is load-bearing: subscribers observe `disconnected` before the
296
539
  // reconnect loop's first `reconnecting`. `#emit` awaits subscribers, then
297
540
  // `#scheduleReconnect` runs the loop whose first action is a backoff
@@ -303,12 +546,31 @@ const DISPOSED_SENTINEL = Symbol('disposed');
303
546
  }
304
547
  }
305
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
+ }
306
562
  this.#receiveTask = this.#reconnectLoop().catch((error)=>{
307
563
  this.#logger?.debug('hub-like reconnect task tail error', {
308
564
  error
309
565
  });
310
566
  });
311
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
+ }
312
574
  async #reconnectLoop() {
313
575
  let resolveGate = ()=>{};
314
576
  this.#reconnectGate = new Promise((resolve)=>{
@@ -317,10 +579,22 @@ const DISPOSED_SENTINEL = Symbol('disposed');
317
579
  try {
318
580
  while(!this.#controller.signal.aborted && !this.#disposed){
319
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
+ });
320
588
  try {
321
- await abortableSleep(delay, this.#controller.signal);
589
+ await Promise.race([
590
+ abortableSleep(delay, this.#controller.signal),
591
+ wake
592
+ ]);
322
593
  } catch {
594
+ this.#settleAttempt(false);
323
595
  return;
596
+ } finally{
597
+ this.#wakeReconnect = null;
324
598
  }
325
599
  if (this.#disposed) {
326
600
  return;
@@ -333,7 +607,17 @@ const DISPOSED_SENTINEL = Symbol('disposed');
333
607
  this.#ackBatch = null;
334
608
  this.#channel = null;
335
609
  await this.#openInner();
336
- this.#reconnectAttempt = 0;
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
+ }
337
621
  // Emit before resolving the gate so awaiters observe `connected`
338
622
  // before their queued publish/subscribe resumes.
339
623
  await this.#emit({
@@ -341,24 +625,84 @@ const DISPOSED_SENTINEL = Symbol('disposed');
341
625
  });
342
626
  return;
343
627
  } catch (error) {
344
- this.#logger?.warn('hub-like reconnect attempt failed', {
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 = {
345
632
  attempt: this.#reconnectAttempt,
346
633
  error
347
- });
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);
348
641
  }
349
642
  }
350
643
  } finally{
351
644
  this.#reconnectGate = null;
352
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;
353
663
  }
664
+ setTimeout(()=>{
665
+ this.#scheduleReconnect();
666
+ }, 0);
354
667
  }
355
- async #ready() {
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
+ }
356
697
  if (this.#disposed) {
357
698
  throw new Error('hub-like adapter disposed');
358
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.
359
703
  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.
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.
362
706
  while(this.#reconnectGate != null){
363
707
  await this.#reconnectGate;
364
708
  }
@@ -371,8 +715,37 @@ const DISPOSED_SENTINEL = Symbol('disposed');
371
715
  }
372
716
  return client;
373
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
+ };
374
747
  // --- HubLike surface ---
375
- publish = async ({ senderDID, topicID, payload })=>{
748
+ publish = async ({ senderDID, topicID, payload, retain, expectedHead, publishID })=>{
376
749
  if (senderDID !== this.#localDID) {
377
750
  // The hub derives the sender from the signed `iss`; a mismatch here means
378
751
  // a caller stamped the wrong DID. The publish still uses this device's
@@ -383,39 +756,149 @@ const DISPOSED_SENTINEL = Symbol('disposed');
383
756
  });
384
757
  }
385
758
  const client = await this.#ready();
386
- const result = await client.request('hub/publish', {
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', {
387
763
  param: {
388
764
  topicID,
389
- payload: toB64(payload)
765
+ payload: toB64(payload),
766
+ retain,
767
+ expectedHead,
768
+ publishID
390
769
  }
391
- });
770
+ }).catch(rethrowHubError);
392
771
  return {
393
772
  sequenceID: result.sequenceID
394
773
  };
395
774
  };
396
- subscribe = async (_subscriberDID, topicID)=>{
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.
397
778
  const client = await this.#ready();
398
- this.#activeTopics.add(topicID);
399
- await client.request('hub/subscribe', {
779
+ const result = await client.request('hub/v1/topic/fetch', {
400
780
  param: {
401
- topicID
781
+ topicID,
782
+ after,
783
+ limit
402
784
  }
403
- });
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);
404
854
  };
405
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
+ }
406
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
+ }
407
878
  this.#activeTopics.delete(topicID);
408
- await client.request('hub/unsubscribe', {
879
+ await client.request('hub/v1/unsubscribe', {
409
880
  param: {
410
881
  topicID
411
882
  }
412
- });
883
+ }).catch(rethrowHubError);
413
884
  };
414
- receive = (_subscriberDID)=>{
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)=>{
415
898
  if (this.#disposed) {
416
899
  throw new Error('hub-like adapter disposed');
417
900
  }
418
- const sink = new ReceiveSink();
901
+ const sink = new ReceiveSink(options);
419
902
  this.#sinks.add(sink);
420
903
  // The first receive() brings the connection up so the drain has a channel to
421
904
  // read from; later calls are idempotent. A late subscriber registers its
@@ -508,11 +991,10 @@ const DISPOSED_SENTINEL = Symbol('disposed');
508
991
  } catch {
509
992
  // ignore
510
993
  }
511
- try {
512
- await this.#receiveTask;
513
- } catch {
514
- // ignore
515
- }
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.
516
998
  const client = this.#client;
517
999
  this.#client = null;
518
1000
  if (client != null) {
@@ -524,6 +1006,11 @@ const DISPOSED_SENTINEL = Symbol('disposed');
524
1006
  });
525
1007
  }
526
1008
  }
1009
+ try {
1010
+ await this.#receiveTask;
1011
+ } catch {
1012
+ // ignore
1013
+ }
527
1014
  this.#channel = null;
528
1015
  this.#receiveTask = null;
529
1016
  this.#activeTopics.clear();