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