@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.
- package/lib/context/group.js +1073 -746
- package/lib/context/hub.js +2 -1
- package/lib/context/join.js +50 -15
- package/lib/context/peer.d.ts +92 -0
- package/lib/context/peer.js +1216 -0
- package/lib/context/require-admin.d.ts +57 -0
- package/lib/context/require-admin.js +57 -0
- package/lib/context/sync.d.ts +12 -0
- package/lib/context/sync.js +234 -1
- package/lib/context/types.d.ts +56 -20
- package/lib/context/types.js +23 -3
- package/lib/groups/access-default-token.d.ts +44 -0
- package/lib/groups/access-default-token.js +103 -0
- package/lib/groups/admin-roster.d.ts +42 -117
- package/lib/groups/admin-roster.js +53 -168
- package/lib/groups/anchor-store.d.ts +15 -0
- package/lib/groups/anchor-store.js +37 -0
- package/lib/groups/app-cursor-store.d.ts +17 -0
- package/lib/groups/app-cursor-store.js +34 -0
- package/lib/groups/broadcast.d.ts +199 -81
- package/lib/groups/broadcast.js +443 -368
- package/lib/groups/catalog-token.d.ts +50 -0
- package/lib/groups/catalog-token.js +96 -0
- package/lib/groups/circle-projection.d.ts +32 -64
- package/lib/groups/circle-projection.js +95 -181
- package/lib/groups/circle-reducers.d.ts +71 -110
- package/lib/groups/circle-reducers.js +57 -127
- package/lib/groups/commit-adoption.d.ts +77 -0
- package/lib/groups/commit-adoption.js +108 -0
- package/lib/groups/commit-journal.d.ts +14 -0
- package/lib/groups/commit-journal.js +65 -0
- package/lib/groups/control-request.d.ts +66 -0
- package/lib/groups/control-request.js +101 -0
- package/lib/groups/events.d.ts +14 -1
- package/lib/groups/group-anchor.d.ts +18 -57
- package/lib/groups/group-anchor.js +15 -100
- package/lib/groups/group-crypto.d.ts +36 -13
- package/lib/groups/group-crypto.js +124 -50
- package/lib/groups/group-handle-registry.d.ts +106 -36
- package/lib/groups/group-handle-registry.js +351 -61
- package/lib/groups/group-handlers.d.ts +5 -4
- package/lib/groups/group-handlers.js +155 -29
- package/lib/groups/group-health-monitor.d.ts +1 -1
- package/lib/groups/group-health-monitor.js +1 -1
- package/lib/groups/group-mls.d.ts +35 -4
- package/lib/groups/group-mls.js +594 -62
- package/lib/groups/group-peer-manager.d.ts +212 -19
- package/lib/groups/group-peer-manager.js +1061 -69
- package/lib/groups/group-protocols.d.ts +485 -28
- package/lib/groups/group-protocols.js +289 -15
- package/lib/groups/invite-payload.d.ts +48 -0
- package/lib/groups/join-request-identity.d.ts +21 -0
- package/lib/groups/join-request-identity.js +43 -0
- package/lib/groups/join-utils.d.ts +60 -0
- package/lib/groups/join-utils.js +262 -0
- package/lib/groups/ledger-affected-events.d.ts +11 -6
- package/lib/groups/ledger-affected-events.js +15 -26
- package/lib/groups/ledger-commit-fold.d.ts +107 -0
- package/lib/groups/ledger-commit-fold.js +167 -0
- package/lib/groups/ledger-ingest.d.ts +124 -0
- package/lib/groups/ledger-ingest.js +212 -0
- package/lib/groups/ledger.d.ts +44 -0
- package/lib/groups/ledger.js +30 -0
- package/lib/groups/manager.d.ts +202 -19
- package/lib/groups/manager.js +623 -106
- package/lib/groups/mls-codec.d.ts +27 -6
- package/lib/groups/mls-codec.js +30 -15
- package/lib/groups/mls-encryptor.js +9 -6
- package/lib/groups/mls-group-handle.d.ts +37 -2
- package/lib/groups/mls-group-handle.js +12 -4
- package/lib/groups/mls-receive-errors.d.ts +0 -2
- package/lib/groups/mls-receive-errors.js +3 -6
- package/lib/groups/mls-state.d.ts +5 -3
- package/lib/groups/mls-state.js +27 -4
- package/lib/groups/peer-presence.d.ts +90 -0
- package/lib/groups/peer-presence.js +295 -0
- package/lib/groups/peer-selection.d.ts +37 -0
- package/lib/groups/peer-selection.js +72 -0
- package/lib/groups/roster-projection.d.ts +53 -0
- package/lib/groups/roster-projection.js +65 -0
- package/lib/groups/store-received-grant.d.ts +4 -3
- package/lib/groups/store-received-grant.js +80 -35
- package/lib/groups/store-received-revocation.d.ts +16 -13
- package/lib/groups/store-received-revocation.js +23 -22
- package/lib/hub/http-client.d.ts +10 -27
- package/lib/hub/http-client.js +2 -19
- package/lib/hub/hub-like.d.ts +57 -6
- package/lib/hub/hub-like.js +539 -52
- package/lib/hub/loopback-log-hub.d.ts +20 -0
- package/lib/hub/loopback-log-hub.js +178 -0
- package/lib/hub/manager.d.ts +25 -40
- package/lib/hub/manager.js +51 -94
- package/lib/hub/peer-scoped-hub-view.d.ts +35 -0
- package/lib/hub/peer-scoped-hub-view.js +92 -0
- package/lib/hub/sender-scoped-hub-view.d.ts +41 -0
- package/lib/hub/sender-scoped-hub-view.js +59 -0
- package/lib/hub/server-did.d.ts +67 -0
- package/lib/hub/server-did.js +131 -0
- package/lib/hub/wiring.d.ts +71 -14
- package/lib/hub/wiring.js +80 -23
- package/lib/index.d.ts +48 -13
- package/lib/index.js +257 -86
- package/lib/peer/connection-registry.d.ts +45 -0
- package/lib/peer/connection-registry.js +52 -0
- package/lib/peer/handlers.d.ts +69 -0
- package/lib/peer/handlers.js +110 -0
- package/lib/protocol.d.ts +213 -0
- package/lib/protocol.js +268 -0
- package/lib/schema.d.ts +2 -1
- package/lib/schema.js +805 -131
- package/lib/sync/access-default-sender.d.ts +23 -0
- package/lib/sync/access-default-sender.js +80 -0
- package/lib/sync/authorize.d.ts +57 -3
- package/lib/sync/authorize.js +45 -4
- package/lib/sync/broadcast-queue.d.ts +3 -4
- package/lib/sync/broadcast-queue.js +2 -3
- package/lib/sync/catalog-scope.d.ts +8 -7
- package/lib/sync/catalog-scope.js +55 -39
- package/lib/sync/errors.d.ts +9 -0
- package/lib/sync/errors.js +10 -0
- package/lib/sync/forwarder.d.ts +13 -19
- package/lib/sync/forwarder.js +13 -19
- package/lib/sync/handlers.js +54 -14
- package/lib/sync/hub-tunnel-sync-listener.d.ts +16 -6
- package/lib/sync/hub-tunnel-sync-listener.js +126 -37
- package/lib/sync/hub-tunnel-sync-provider.d.ts +31 -11
- package/lib/sync/hub-tunnel-sync-provider.js +68 -19
- package/lib/sync/merkle-channel.d.ts +9 -1
- package/lib/sync/merkle-channel.js +0 -0
- package/lib/sync/peer-registry.d.ts +14 -10
- package/lib/sync/peer-registry.js +0 -25
- package/lib/sync/sync-client.d.ts +11 -1
- package/lib/sync/sync-client.js +72 -10
- package/lib/sync/sync-manager.d.ts +22 -4
- package/lib/sync/sync-manager.js +39 -10
- package/lib/sync/tunnel-listeners.d.ts +52 -0
- package/lib/sync/tunnel-listeners.js +175 -0
- package/lib/sync/tunnel-observability.d.ts +25 -0
- package/lib/sync/tunnel-observability.js +63 -0
- package/lib/sync/tunnel-topics.d.ts +20 -0
- package/lib/sync/tunnel-topics.js +28 -0
- package/lib/types.d.ts +699 -28
- package/lib/types.js +0 -1
- package/lib/util/handler-error.d.ts +16 -0
- package/lib/util/handler-error.js +64 -0
- package/lib/util/mutex.d.ts +14 -1
- package/lib/util/mutex.js +37 -3
- package/package.json +56 -52
- package/lib/groups/ledger-entry.d.ts +0 -37
- package/lib/groups/ledger-entry.js +0 -56
- package/lib/groups/ledger-fold.d.ts +0 -56
- package/lib/groups/ledger-fold.js +0 -53
- package/lib/hub/did-observing-transport.d.ts +0 -75
- package/lib/hub/did-observing-transport.js +0 -126
- package/lib/hub/errors.d.ts +0 -24
- package/lib/hub/errors.js +0 -33
package/lib/hub/hub-like.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
17
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
|
234
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
361
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
//
|
|
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();
|