@macula-io/ts 0.17.0 → 0.19.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/README.md +106 -272
- package/dist/binding.d.ts +50 -45
- package/dist/binding.js +8 -125
- package/dist/binding.js.map +1 -1
- package/dist/content.d.ts +32 -13
- package/dist/content.js +43 -41
- package/dist/content.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.js +6 -8
- package/dist/index.js.map +1 -1
- package/dist/key.d.ts +31 -0
- package/dist/key.js +72 -0
- package/dist/key.js.map +1 -0
- package/dist/pool.d.ts +185 -131
- package/dist/pool.js +223 -576
- package/dist/pool.js.map +1 -1
- package/dist/stream.d.ts +63 -0
- package/dist/stream.js +93 -0
- package/dist/stream.js.map +1 -0
- package/dist/wire.d.ts +53 -0
- package/dist/wire.js +79 -0
- package/dist/wire.js.map +1 -0
- package/package.json +7 -5
- package/prebuilds/darwin-arm64/@macula-io+ts.node +0 -0
- package/prebuilds/darwin-x64/@macula-io+ts.node +0 -0
- package/prebuilds/linux-arm64/@macula-io+ts.node +0 -0
- package/prebuilds/linux-x64/@macula-io+ts.node +0 -0
- package/prebuilds/win32-x64/@macula-io+ts.node +0 -0
- package/dist/dht.d.ts +0 -55
- package/dist/dht.js +0 -25
- package/dist/dht.js.map +0 -1
- package/dist/directdial.d.ts +0 -79
- package/dist/directdial.js +0 -76
- package/dist/directdial.js.map +0 -1
- package/dist/identity.d.ts +0 -43
- package/dist/identity.js +0 -83
- package/dist/identity.js.map +0 -1
- package/dist/pubsub.d.ts +0 -60
- package/dist/pubsub.js +0 -8
- package/dist/pubsub.js.map +0 -1
- package/dist/rpc.d.ts +0 -86
- package/dist/rpc.js +0 -57
- package/dist/rpc.js.map +0 -1
- package/dist/session.d.ts +0 -481
- package/dist/session.js +0 -810
- package/dist/session.js.map +0 -1
- package/dist/ucan.d.ts +0 -110
- package/dist/ucan.js +0 -140
- package/dist/ucan.js.map +0 -1
package/dist/pool.js
CHANGED
|
@@ -1,614 +1,261 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// control session per station is enough regardless of call volume.
|
|
36
|
-
// - one additional session PER CURRENTLY-TRACKED TOPIC, under an identity
|
|
37
|
-
// derived from that topic (shared across every station's copy of that
|
|
38
|
-
// topic's session -- reused across stations is safe, since a station's
|
|
39
|
-
// own per-identity dedupe only kicks a SECOND connection to THAT SAME
|
|
40
|
-
// station, never across different stations). Each does exactly one
|
|
41
|
-
// subscribe() and nothing else.
|
|
42
|
-
// Every one of these sessions is independently monitored and respawned with
|
|
43
|
-
// backoff; a topic session dying just re-subscribes its own one topic on
|
|
44
|
-
// respawn (no "replay N subscriptions onto a survivor" step needed, since
|
|
45
|
-
// each session only ever carried one). A control link can't also carry a
|
|
46
|
-
// liveness-only subscribe without breaking the one-role rule it exists to
|
|
47
|
-
// keep, and a failed call() genuinely does surface a dead
|
|
48
|
-
// connection -- but publish() does not: found live 2026-09-04 that a
|
|
49
|
-
// publish() attempt on a session the station had already kicked can still
|
|
50
|
-
// locally "succeed" (the frame is handed off before the QUIC stack notices
|
|
51
|
-
// its own connection is gone), so a publish-only workload could go
|
|
52
|
-
// arbitrarily long without ever discovering a dead control link. Every
|
|
53
|
-
// live control link is therefore also health-checked on a timer
|
|
54
|
-
// (#armHealthCheck's own doc has the mechanism) -- a periodic call() to a
|
|
55
|
-
// procedure name guaranteed never to be advertised, whose failure is
|
|
56
|
-
// inspected to tell "a real BOLT#4 unknown_next_peer came back" (alive)
|
|
57
|
-
// apart from "this never reached anyone at all" (dead, reconnect).
|
|
58
|
-
//
|
|
59
|
-
// == Deliberate deviation from the Erlang reference ==
|
|
60
|
-
//
|
|
61
|
-
// macula_client's own inbound-event dedup keys on (Realm, Publisher, Seq)
|
|
62
|
-
// alone, with no Topic. That is the exact shape of a real bug found and
|
|
63
|
-
// fixed on the station side 2026-09-04 (macula-station's event_dedup): an
|
|
64
|
-
// identity's own auto-published facts and its application-level publishes
|
|
65
|
-
// can share one seq-counter space, and two different topics publishing the
|
|
66
|
-
// same seq collide under a topic-blind key. This pool's own dedup includes
|
|
67
|
-
// Topic from the start.
|
|
68
|
-
import { Identity } from "./identity.js";
|
|
69
|
-
import { bytesModeFor, MaculaCallError } from "./rpc.js";
|
|
70
|
-
import { realmBytesFromHex, Session } from "./session.js";
|
|
71
|
-
/** Thrown by publish()/call() when the pool has zero live control links
|
|
72
|
-
* to try -- a distinct, identifiable condition from any one link's own
|
|
73
|
-
* transient error, matching macula_client:publish/5's own `{error,
|
|
74
|
-
* {transient, no_healthy_station}}`. */
|
|
75
|
-
export class NoHealthyStationError extends Error {
|
|
76
|
-
constructor() {
|
|
77
|
-
super("macula-ts: pool has no currently-live links");
|
|
78
|
-
this.name = "NoHealthyStationError";
|
|
1
|
+
// A node's pool of station links on the macula 12 mesh, as macula-go's pool
|
|
2
|
+
// keeps it: every seed pinned by its node_id, the realms whose keys the node
|
|
3
|
+
// trusts, and one identity for every link. Calls and streams reach a provider
|
|
4
|
+
// by direct dial: its advertisements from the DHT, trusted only when the
|
|
5
|
+
// realm's key authorizes them (or, in a node's own namespace `~<node_id>/`,
|
|
6
|
+
// only when that node signed them), and the station it serves from dialed
|
|
7
|
+
// pinned.
|
|
8
|
+
import { native } from "./binding.js";
|
|
9
|
+
import { Stream } from "./stream.js";
|
|
10
|
+
import { DEFAULT_CONTENT_TIMEOUT_MS, contentError, mcid50 } from "./content.js";
|
|
11
|
+
import { DEFAULT_CALL_TIMEOUT_MS, bytesModeFor, callError, hex, id32, } from "./wire.js";
|
|
12
|
+
/** macula 12's record types. */
|
|
13
|
+
export var RecordType;
|
|
14
|
+
(function (RecordType) {
|
|
15
|
+
RecordType[RecordType["NodeRecord"] = 1] = "NodeRecord";
|
|
16
|
+
RecordType[RecordType["ProcedureAdvertisement"] = 6] = "ProcedureAdvertisement";
|
|
17
|
+
RecordType[RecordType["Tombstone"] = 12] = "Tombstone";
|
|
18
|
+
RecordType[RecordType["ContentAnnouncement"] = 17] = "ContentAnnouncement";
|
|
19
|
+
RecordType[RecordType["StationEndpoint"] = 18] = "StationEndpoint";
|
|
20
|
+
RecordType[RecordType["OrgDirectory"] = 21] = "OrgDirectory";
|
|
21
|
+
RecordType[RecordType["ProcedureDelegation"] = 22] = "ProcedureDelegation";
|
|
22
|
+
})(RecordType || (RecordType = {}));
|
|
23
|
+
/** A subscription, until stop() or the pool closes. */
|
|
24
|
+
export class Subscription {
|
|
25
|
+
handle;
|
|
26
|
+
closed;
|
|
27
|
+
/** @internal */
|
|
28
|
+
constructor(handle, closed) {
|
|
29
|
+
this.handle = handle;
|
|
30
|
+
this.closed = closed;
|
|
31
|
+
}
|
|
32
|
+
/** Ends the subscription on every link. */
|
|
33
|
+
async stop() {
|
|
34
|
+
await native.subscriptionStop(this.handle);
|
|
79
35
|
}
|
|
80
36
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
37
|
+
/** A served procedure, until stop(). */
|
|
38
|
+
export class Served {
|
|
39
|
+
handle;
|
|
40
|
+
stopped = false;
|
|
41
|
+
/** @internal */
|
|
42
|
+
constructor(handle) {
|
|
43
|
+
this.handle = handle;
|
|
44
|
+
}
|
|
45
|
+
/** Withdraws the procedure on every link. */
|
|
46
|
+
async stop() {
|
|
47
|
+
if (this.stopped)
|
|
48
|
+
return;
|
|
49
|
+
this.stopped = true;
|
|
50
|
+
await native.servedStop(this.handle);
|
|
51
|
+
}
|
|
93
52
|
}
|
|
94
|
-
/**
|
|
95
|
-
* A resilient multi-station client: live connections to every configured
|
|
96
|
-
* seed held concurrently, each independently monitored and respawned
|
|
97
|
-
* with backoff on disconnect, every tracked subscription re-established
|
|
98
|
-
* automatically when its own link reconnects. See this module's own
|
|
99
|
-
* header doc for the full design, why a "link" is a small role-scoped
|
|
100
|
-
* session set rather than one Session, and its one deliberate deviation
|
|
101
|
-
* from the Erlang reference (topic-scoped dedup).
|
|
102
|
-
*/
|
|
103
53
|
export class Pool {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#dedup = new Map();
|
|
109
|
-
#sweepTimer;
|
|
110
|
-
#replicationFactor;
|
|
111
|
-
#dedupWindowMs;
|
|
112
|
-
#healthCheckIntervalMs;
|
|
113
|
-
#closed = false;
|
|
114
|
-
constructor(controlIdentity, seeds, opts) {
|
|
115
|
-
this.#controlIdentity = controlIdentity;
|
|
116
|
-
this.#seeds = seeds;
|
|
117
|
-
const requestedReplication = opts.replicationFactor ?? 1;
|
|
118
|
-
if (requestedReplication !== 1) {
|
|
119
|
-
console.error(`macula-ts pool: replicationFactor ${requestedReplication} is not yet supported (each replica would publish ` +
|
|
120
|
-
"a distinct seq, so a receiver's dedup can't collapse them into one event) -- clamping to 1.");
|
|
121
|
-
}
|
|
122
|
-
this.#replicationFactor = 1;
|
|
123
|
-
this.#dedupWindowMs = opts.dedupWindowMs ?? 60_000;
|
|
124
|
-
this.#healthCheckIntervalMs = opts.healthCheckIntervalMs ?? HEALTH_CHECK_INTERVAL_MS;
|
|
125
|
-
this.#controlLinks = seeds.map((seed) => this.#newRoleLink(seed, controlIdentity, undefined));
|
|
126
|
-
const sweepMs = opts.dedupSweepMs ?? 30_000;
|
|
127
|
-
this.#sweepTimer = setInterval(() => this.#sweepDedup(), sweepMs);
|
|
128
|
-
this.#sweepTimer.unref?.();
|
|
54
|
+
handle;
|
|
55
|
+
closed = false;
|
|
56
|
+
constructor(handle) {
|
|
57
|
+
this.handle = handle;
|
|
129
58
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
closedDuringConnect: false,
|
|
59
|
+
/** Links the key's node to every seed, and resolves once one link is up. */
|
|
60
|
+
static async connect(key, seeds, options = {}) {
|
|
61
|
+
const seedJson = seeds.map((s) => ({ host: s.host, port: s.port, node_id: hex(id32(s.nodeId, "a seed's nodeId")) }));
|
|
62
|
+
const realmTrust = {};
|
|
63
|
+
for (const t of options.realmTrust ?? []) {
|
|
64
|
+
realmTrust[hex(id32(t.realm, "a realm id"))] = typeof t.key === "string" ? t.key : hex(t.key);
|
|
65
|
+
}
|
|
66
|
+
const opts = {
|
|
67
|
+
realm_trust: realmTrust,
|
|
68
|
+
replication_factor: options.replicationFactor ?? 0,
|
|
69
|
+
max_direct_links: options.maxDirectLinks ?? 0,
|
|
70
|
+
respawn_delay_ms: options.respawnDelayMs ?? 0,
|
|
71
|
+
timeout_ms: options.timeoutMs ?? 0,
|
|
144
72
|
};
|
|
73
|
+
return new Pool(await native.poolConnect(key.live(), JSON.stringify(seedJson), JSON.stringify(opts)));
|
|
145
74
|
}
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
* FIRST connect attempt has settled (success or a logged failure
|
|
150
|
-
* headed into backoff) -- matches tapRoom()'s own "await the first
|
|
151
|
-
* attempt, not the eventual outcome" reasoning (macula-mcp's
|
|
152
|
-
* lobby_observer.ts): a caller that publishes/calls immediately
|
|
153
|
-
* after connect() must not race links still mid-handshake. */
|
|
154
|
-
static async connect(seeds, controlIdentity, opts = {}) {
|
|
155
|
-
if (seeds.length === 0)
|
|
156
|
-
throw new Error("macula-ts: Pool.connect() needs at least one seed");
|
|
157
|
-
const seen = new Set();
|
|
158
|
-
for (const seed of seeds) {
|
|
159
|
-
const seedKey = `${seed.host}:${seed.port}`;
|
|
160
|
-
if (seen.has(seedKey))
|
|
161
|
-
throw new Error(`macula-ts: Pool.connect() given duplicate seed ${seedKey} -- each seed must be a distinct station`);
|
|
162
|
-
seen.add(seedKey);
|
|
163
|
-
}
|
|
164
|
-
const pool = new Pool(controlIdentity, seeds, opts);
|
|
165
|
-
await Promise.all(pool.#controlLinks.map((link) => pool.#attach(link)));
|
|
166
|
-
return pool;
|
|
75
|
+
/** The node_id the pool links as. */
|
|
76
|
+
nodeId() {
|
|
77
|
+
return hex(native.poolNodeId(this.live()));
|
|
167
78
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
});
|
|
174
|
-
await link.inFlight;
|
|
79
|
+
/** name in this node's own namespace, `~<node_id>/<name>`: a procedure it
|
|
80
|
+
* serves with no org and no realm key, authorized by its advertisement's
|
|
81
|
+
* signature alone, and that any node calls with no realm key pinned. */
|
|
82
|
+
ownProcedure(name) {
|
|
83
|
+
return `~${this.nodeId()}/${name}`;
|
|
175
84
|
}
|
|
176
|
-
/**
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// overwrite its session with a second connection under the same
|
|
185
|
-
// identity to the same station (exactly the collision this whole
|
|
186
|
-
// design exists to avoid). #scheduleReconnect's own idempotency
|
|
187
|
-
// guard is what should prevent this from ever being reachable in
|
|
188
|
-
// practice; this is the belt-and-suspenders backstop.
|
|
189
|
-
if (link.session)
|
|
190
|
-
return;
|
|
191
|
-
link.status = "connecting";
|
|
192
|
-
let session;
|
|
85
|
+
/** Every link the pool holds. */
|
|
86
|
+
status() {
|
|
87
|
+
return JSON.parse(native.poolStatus(this.live())) ?? [];
|
|
88
|
+
}
|
|
89
|
+
/** Calls procedure in realm at a provider (any trusted one unless
|
|
90
|
+
* `provider` names one) by direct dial. A provider's ERROR is thrown as a
|
|
91
|
+
* ProviderError, a station's relay error as a RelayError. */
|
|
92
|
+
async call(realm, procedure, payload = {}, options = {}) {
|
|
193
93
|
try {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
catch (err) {
|
|
197
|
-
console.error(`macula-ts pool: connect to ${link.seed.host}:${link.seed.port} failed:`, err);
|
|
198
|
-
this.#scheduleReconnect(link);
|
|
199
|
-
return;
|
|
94
|
+
const result = await native.poolCall(this.live(), id32(realm, "realm"), procedure, JSON.stringify(payload), options.provider === undefined ? null : id32(options.provider, "provider"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes));
|
|
95
|
+
return JSON.parse(result);
|
|
200
96
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
return;
|
|
97
|
+
catch (e) {
|
|
98
|
+
throw callError(e);
|
|
204
99
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
100
|
+
}
|
|
101
|
+
/** The procedure's trusted providers, freshest first. */
|
|
102
|
+
async providers(realm, procedure, options = {}) {
|
|
103
|
+
return JSON.parse(await native.poolProviders(this.live(), id32(realm, "realm"), procedure, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS)) ?? [];
|
|
104
|
+
}
|
|
105
|
+
/** Publishes payload on topic in realm. Topics name a kind of fact; ids go
|
|
106
|
+
* in the payload. */
|
|
107
|
+
async publish(realm, topic, payload, options = {}) {
|
|
108
|
+
await native.poolPublish(this.live(), id32(realm, "realm"), topic, JSON.stringify(payload), options.ttlMs ?? 0);
|
|
109
|
+
}
|
|
110
|
+
/** Subscribes to topic in realm: onEvent hears each verified event once,
|
|
111
|
+
* however many links deliver it. `closed` resolves when the subscription
|
|
112
|
+
* ends, with why or null. */
|
|
113
|
+
async subscribe(realm, topic, onEvent, options = {}) {
|
|
114
|
+
let settle = () => { };
|
|
115
|
+
const closed = new Promise((resolve) => (settle = resolve));
|
|
116
|
+
const handle = await native.poolSubscribe(this.live(), id32(realm, "realm"), topic, bytesModeFor(options.bytes), (d) => {
|
|
117
|
+
if (d.kind === "closed") {
|
|
118
|
+
settle(d.json === "" ? null : d.json);
|
|
214
119
|
return;
|
|
215
120
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
121
|
+
const e = JSON.parse(d.json);
|
|
122
|
+
onEvent({ publisher: e.publisher, realm: e.realm, topic: e.topic, seq: e.seq, publishedAt: e.published_at,
|
|
123
|
+
payload: e.payload, deliveredVia: e.delivered_via });
|
|
124
|
+
});
|
|
125
|
+
return new Subscription(handle, closed);
|
|
126
|
+
}
|
|
127
|
+
/** Serves procedure in realm: handler answers each call, and its thrown
|
|
128
|
+
* error goes back as a handler_error with its message. An org procedure
|
|
129
|
+
* needs the realm's key pinned and the org's delegation to this node in the
|
|
130
|
+
* DHT; a procedure in this node's own namespace (ownProcedure) needs
|
|
131
|
+
* neither, and another node's namespace is refused. */
|
|
132
|
+
async serve(realm, procedure, handler, options = {}) {
|
|
133
|
+
const handle = await native.poolServe(this.live(), id32(realm, "realm"), procedure, bytesModeFor(options.bytes), (d) => {
|
|
134
|
+
if (d.kind !== "request")
|
|
220
135
|
return;
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
link.status = "live";
|
|
225
|
-
link.reconnectAttempt = 0;
|
|
226
|
-
if (!link.onConnected)
|
|
227
|
-
this.#armHealthCheck(link);
|
|
136
|
+
void answer(d, handler);
|
|
137
|
+
});
|
|
138
|
+
return new Served(handle);
|
|
228
139
|
}
|
|
229
|
-
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
* publish-only caller could go arbitrarily long without this pool
|
|
236
|
-
* ever discovering a dead control link. Every HEALTH_CHECK_INTERVAL_MS
|
|
237
|
-
* while the link is live, this calls a procedure name that is
|
|
238
|
-
* guaranteed never to be advertised (a fresh UUID-shaped string) and
|
|
239
|
-
* inspects the failure: a clean MaculaCallError means a real BOLT#4
|
|
240
|
-
* response came back over the wire (unknown_next_peer, as expected) --
|
|
241
|
-
* the connection is genuinely alive, nothing to do. Any OTHER
|
|
242
|
-
* thrown error means the call never got a wire-level answer at all --
|
|
243
|
-
* the same signal call()'s own doc uses to distinguish a real BOLT#4
|
|
244
|
-
* answer from "this never reached anyone" -- so it's treated exactly
|
|
245
|
-
* like a real operation failure and schedules a reconnect. */
|
|
246
|
-
#armHealthCheck(link) {
|
|
247
|
-
if (link.healthCheckTimer)
|
|
248
|
-
clearInterval(link.healthCheckTimer);
|
|
249
|
-
const timer = setInterval(() => {
|
|
250
|
-
const session = link.session;
|
|
251
|
-
if (!session)
|
|
140
|
+
/** Serves procedure in realm as a stream of mode: handler drives each
|
|
141
|
+
* session. The stream is closed when the handler returns without ending
|
|
142
|
+
* it, aborted with code error when it throws, and released either way. */
|
|
143
|
+
async serveStream(realm, procedure, mode, handler, options = {}) {
|
|
144
|
+
const handle = await native.poolServeStream(this.live(), id32(realm, "realm"), procedure, mode, bytesModeFor(options.bytes), (d) => {
|
|
145
|
+
if (d.kind !== "request")
|
|
252
146
|
return;
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (link.session !== session)
|
|
257
|
-
return; // already superseded by a reconnect
|
|
258
|
-
console.error(`macula-ts pool: health check on ${link.seed.host}:${link.seed.port} failed`);
|
|
259
|
-
this.#scheduleReconnect(link);
|
|
260
|
-
});
|
|
261
|
-
}, this.#healthCheckIntervalMs);
|
|
262
|
-
timer.unref?.();
|
|
263
|
-
link.healthCheckTimer = timer;
|
|
147
|
+
void runStream(new Stream(d.handle, options.bytes), handler);
|
|
148
|
+
});
|
|
149
|
+
return new Served(handle);
|
|
264
150
|
}
|
|
265
|
-
/**
|
|
266
|
-
*
|
|
267
|
-
|
|
268
|
-
* if the call never got a wire-level answer at all (the connection is
|
|
269
|
-
* genuinely dead). Shared by #armHealthCheck's own timer and call()'s
|
|
270
|
-
* handling of an ambiguous failure (see call()'s own doc) -- a plain
|
|
271
|
-
* Error from session.call() means "no wire answer", but that is also
|
|
272
|
-
* exactly what a timed-out call against an otherwise-healthy but
|
|
273
|
-
* momentarily slow provider looks like. Found live 2026-09-05: without
|
|
274
|
-
* this second opinion, call() tore down every live control link in
|
|
275
|
-
* turn on nothing more than one slow provider response. */
|
|
276
|
-
async #probeLiveness(session) {
|
|
277
|
-
const probe = `_pool.healthcheck.${Math.random().toString(36).slice(2)}`;
|
|
151
|
+
/** Opens a stream of mode on procedure in realm at a provider, by direct
|
|
152
|
+
* dial. A refusal arrives on its first recv(). */
|
|
153
|
+
async openStream(realm, procedure, mode, payload = {}, options = {}) {
|
|
278
154
|
try {
|
|
279
|
-
await
|
|
280
|
-
return
|
|
155
|
+
const handle = await native.poolOpenStream(this.live(), id32(realm, "realm"), procedure, mode, JSON.stringify(payload), options.provider === undefined ? null : id32(options.provider, "provider"), options.deadlineMs ?? 0, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
156
|
+
return new Stream(handle, options.bytes);
|
|
281
157
|
}
|
|
282
|
-
catch (
|
|
283
|
-
|
|
158
|
+
catch (e) {
|
|
159
|
+
throw callError(e);
|
|
284
160
|
}
|
|
285
161
|
}
|
|
286
|
-
/**
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
* (publish()/call() throwing for a reason that doesn't mean the
|
|
291
|
-
* connection itself already died, unlike a subscribe onClosed) is
|
|
292
|
-
* never simply orphaned. Found live 2026-09-04: without this, a
|
|
293
|
-
* publish/call failure left the old session's native handle both
|
|
294
|
-
* leaked AND, worse, still open under this link's identity -- the
|
|
295
|
-
* NEXT connect attempt for this SAME station under that SAME
|
|
296
|
-
* identity then races the still-live old one for the station's own
|
|
297
|
-
* per-identity dedupe kick, with no guarantee which one loses. The
|
|
298
|
-
* close is fired here, before scheduling, so it has the full backoff
|
|
299
|
-
* delay (at least RECONNECT_BASE_MS) to land before a new connect
|
|
300
|
-
* attempt begins.
|
|
301
|
-
*
|
|
302
|
-
* Idempotent per backoff episode: a no-op once `link.status` is
|
|
303
|
-
* already "backoff". Found live in review 2026-09-05: concurrent
|
|
304
|
-
* operations queued on one session (e.g. several call()s serialised
|
|
305
|
-
* by Session's own internal queue) can ALL fail once that session
|
|
306
|
-
* dies, and each one's catch handler calls this -- without this
|
|
307
|
-
* guard, every failure past the first would re-close an
|
|
308
|
-
* already-`undefined` `link.session` (harmless) but ALSO arm a
|
|
309
|
-
* second, third, ... retryTimer, each bumping reconnectAttempt
|
|
310
|
-
* independently, leaving multiple redundant reconnects racing each
|
|
311
|
-
* other. Deliberately NOT guarded against "connecting" -- this is
|
|
312
|
-
* also called from `#attachOnce`'s own failure paths, while status
|
|
313
|
-
* is still "connecting" from the top of that same function, and
|
|
314
|
-
* that path must proceed normally. */
|
|
315
|
-
#scheduleReconnect(link) {
|
|
316
|
-
if (link.closing)
|
|
317
|
-
return;
|
|
318
|
-
if (link.status === "backoff")
|
|
319
|
-
return;
|
|
320
|
-
if (link.healthCheckTimer) {
|
|
321
|
-
clearInterval(link.healthCheckTimer);
|
|
322
|
-
link.healthCheckTimer = undefined;
|
|
162
|
+
/** The verified record under key, or null when there is none. */
|
|
163
|
+
async findRecord(key, options = {}) {
|
|
164
|
+
try {
|
|
165
|
+
return toRecord(JSON.parse(await native.poolFindRecord(this.live(), id32(key, "key"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
|
|
323
166
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const closing = stale.close(link.identity).catch(() => { });
|
|
329
|
-
link.pendingClose = closing;
|
|
330
|
-
void closing.finally(() => {
|
|
331
|
-
if (link.pendingClose === closing)
|
|
332
|
-
link.pendingClose = undefined;
|
|
333
|
-
});
|
|
167
|
+
catch (e) {
|
|
168
|
+
if (e instanceof Error && e.message === "not_found")
|
|
169
|
+
return null;
|
|
170
|
+
throw e;
|
|
334
171
|
}
|
|
335
|
-
const delay = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * 2 ** link.reconnectAttempt);
|
|
336
|
-
link.reconnectAttempt += 1;
|
|
337
|
-
const timer = setTimeout(() => {
|
|
338
|
-
const attempt = this.#attachOnce(link);
|
|
339
|
-
link.inFlight = attempt.finally(() => {
|
|
340
|
-
if (link.inFlight === attempt)
|
|
341
|
-
link.inFlight = undefined;
|
|
342
|
-
});
|
|
343
|
-
}, delay);
|
|
344
|
-
// Deliberately NOT unref()'d, unlike healthCheckTimer/sweepTimer --
|
|
345
|
-
// this pool's own documented contract is "retry forever" for a
|
|
346
|
-
// link in backoff. Found live in review 2026-09-05: a pure
|
|
347
|
-
// subscribe()-only process (no other keep-alive handle: no HTTP
|
|
348
|
-
// listener, no stdio transport) would otherwise exit cleanly the
|
|
349
|
-
// moment its last live connection drops, silently breaking that
|
|
350
|
-
// promise instead of actually retrying through the outage.
|
|
351
|
-
link.retryTimer = timer;
|
|
352
172
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
173
|
+
/** Every verified record under key, and how many did not verify. */
|
|
174
|
+
async findRecords(key, options = {}) {
|
|
175
|
+
return toRecords(JSON.parse(await native.poolFindRecords(this.live(), id32(key, "key"), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
|
|
176
|
+
}
|
|
177
|
+
/** Every verified record of type the station holds, and how many did not
|
|
178
|
+
* verify. */
|
|
179
|
+
async findRecordsByType(type, options = {}) {
|
|
180
|
+
return toRecords(JSON.parse(await native.poolFindRecordsByType(this.live(), type, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS, bytesModeFor(options.bytes))));
|
|
181
|
+
}
|
|
182
|
+
/** Shares data in realm: this node keeps it, serves it on its own
|
|
183
|
+
* `~<node_id>/content_v1` and announces it, renewing the announcement until
|
|
184
|
+
* unshareContent or close. Data of at most 256 KiB is one raw block; larger
|
|
185
|
+
* data a manifest over 256 KiB chunks, named name. Resolves to the content
|
|
186
|
+
* id as hex. Serving needs stations that admit a node's own namespace. */
|
|
187
|
+
async shareContent(realm, data, name = "", options = {}) {
|
|
188
|
+
return hex(await native.poolShareContent(this.live(), id32(realm, "realm"), data, name, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS));
|
|
359
189
|
}
|
|
360
|
-
|
|
361
|
-
|
|
190
|
+
/** Stops sharing mcid in realm and withdraws its announcement. */
|
|
191
|
+
async unshareContent(realm, mcid, options = {}) {
|
|
192
|
+
await native.poolUnshareContent(this.live(), id32(realm, "realm"), mcid50(mcid), options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
362
193
|
}
|
|
363
|
-
/**
|
|
364
|
-
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const nodeIdHex = Buffer.from(identity.nodeId).toString("hex");
|
|
372
|
-
if (nodeIdHex === Buffer.from(this.#controlIdentity.nodeId).toString("hex")) {
|
|
373
|
-
throw new Error("macula-ts pool: this identity is already the pool's own control identity -- reusing it for a subscription would double-connect one identity to one station");
|
|
194
|
+
/** Fetches the content mcid names in realm from a node that shares it,
|
|
195
|
+
* checked against mcid; no realm key is needed. Content nobody announces is
|
|
196
|
+
* a NotSharedError, content every sharer failed to give a
|
|
197
|
+
* ContentUnavailableError. */
|
|
198
|
+
async getContent(realm, mcid, options = {}) {
|
|
199
|
+
const asked = mcid50(mcid);
|
|
200
|
+
try {
|
|
201
|
+
return await native.poolGetContent(this.live(), id32(realm, "realm"), asked, options.maxBytes ?? 0, options.maxChunks ?? 0, options.parallel ?? 0, options.chunkTimeoutMs ?? 0, options.timeoutMs ?? DEFAULT_CONTENT_TIMEOUT_MS);
|
|
374
202
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
throw new Error(`macula-ts pool: this identity is already used by the "${sub.topic}" subscription -- reusing it here would double-connect one identity to one station`);
|
|
378
|
-
}
|
|
203
|
+
catch (e) {
|
|
204
|
+
throw contentError(e);
|
|
379
205
|
}
|
|
380
206
|
}
|
|
381
|
-
/**
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
* this v1's only liveness signal for the control role, since it
|
|
385
|
-
* cannot also carry a liveness-only subscribe (see this module's own
|
|
386
|
-
* header doc). Throws NoHealthyStationError if zero links are live.
|
|
387
|
-
*
|
|
388
|
-
* `realm`/`payload` are validated before any link is touched. Found
|
|
389
|
-
* live 2026-09-05: a malformed realm or an unserializable payload
|
|
390
|
-
* throws inside session.publish() itself, well before any wire I/O --
|
|
391
|
-
* treating that throw as evidence of a dead connection (the pre-fix
|
|
392
|
-
* behavior) tore down a perfectly healthy link over a caller-side
|
|
393
|
-
* argument bug. */
|
|
394
|
-
async publish(realm, topic, payload, opts = {}) {
|
|
395
|
-
if (this.#closed)
|
|
396
|
-
throw new Error("macula-ts pool: used after close()");
|
|
397
|
-
realmBytesFromHex(realm);
|
|
398
|
-
JSON.stringify(payload ?? null);
|
|
399
|
-
const targets = this.#liveControlLinks().slice(0, this.#replicationFactor);
|
|
400
|
-
if (targets.length === 0)
|
|
401
|
-
throw new NoHealthyStationError();
|
|
402
|
-
const results = await Promise.allSettled(targets.map(async (link) => {
|
|
403
|
-
const session = link.session;
|
|
404
|
-
if (link.status !== "live" || !session)
|
|
405
|
-
throw new NoHealthyStationError(); // superseded since targets was captured
|
|
406
|
-
try {
|
|
407
|
-
await session.publish(topic, payload, { realm, ttlMs: opts.ttlMs });
|
|
408
|
-
}
|
|
409
|
-
catch (err) {
|
|
410
|
-
if (link.session === session)
|
|
411
|
-
this.#scheduleReconnect(link);
|
|
412
|
-
throw err;
|
|
413
|
-
}
|
|
414
|
-
}));
|
|
415
|
-
if (!results.some((r) => r.status === "fulfilled")) {
|
|
416
|
-
const first = results.find((r) => r.status === "rejected");
|
|
417
|
-
throw first ? first.reason : new Error("macula-ts pool: publish failed on every targeted link");
|
|
418
|
-
}
|
|
207
|
+
/** Puts a signed record's wire bytes in the DHT. */
|
|
208
|
+
async putRecord(wire, options = {}) {
|
|
209
|
+
await native.poolPutRecord(this.live(), wire, options.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS);
|
|
419
210
|
}
|
|
420
|
-
/**
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
* fresh probe is scheduled for reconnect. Each link's own `session`
|
|
448
|
-
* reference is re-checked both before probing and before scheduling a
|
|
449
|
-
* reconnect, in case a concurrent operation already superseded it. */
|
|
450
|
-
async call(realm, procedure, payload, opts = {}) {
|
|
451
|
-
if (this.#closed)
|
|
452
|
-
throw new Error("macula-ts pool: used after close()");
|
|
453
|
-
realmBytesFromHex(realm);
|
|
454
|
-
JSON.stringify(payload ?? null);
|
|
455
|
-
bytesModeFor(opts.bytes);
|
|
456
|
-
const targets = this.#liveControlLinks();
|
|
457
|
-
if (targets.length === 0)
|
|
458
|
-
throw new NoHealthyStationError();
|
|
459
|
-
let lastErr;
|
|
460
|
-
for (const link of targets) {
|
|
461
|
-
const session = link.session;
|
|
462
|
-
if (link.status !== "live" || !session)
|
|
463
|
-
continue; // superseded since `targets` was captured
|
|
464
|
-
try {
|
|
465
|
-
return await session.call(procedure, payload, { realm, deadlineMs: opts.deadlineMs, bytes: opts.bytes });
|
|
466
|
-
}
|
|
467
|
-
catch (err) {
|
|
468
|
-
lastErr = err;
|
|
469
|
-
if (!(err instanceof MaculaCallError) && link.session === session && !(await this.#probeLiveness(session))) {
|
|
470
|
-
if (link.session === session)
|
|
471
|
-
this.#scheduleReconnect(link);
|
|
472
|
-
}
|
|
473
|
-
}
|
|
211
|
+
/** Closes every link and subscription. */
|
|
212
|
+
async close() {
|
|
213
|
+
if (this.closed)
|
|
214
|
+
return;
|
|
215
|
+
this.closed = true;
|
|
216
|
+
await native.poolClose(this.handle);
|
|
217
|
+
}
|
|
218
|
+
live() {
|
|
219
|
+
if (this.closed)
|
|
220
|
+
throw new Error("macula-ts: this Pool is closed");
|
|
221
|
+
return this.handle;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Answers one served call with the handler's result or its error, once. */
|
|
225
|
+
async function answer(d, handler) {
|
|
226
|
+
const r = JSON.parse(d.json);
|
|
227
|
+
const request = { caller: r.caller, realm: r.realm, procedure: r.procedure, payload: r.payload,
|
|
228
|
+
deadlineMs: r.deadline_ms };
|
|
229
|
+
try {
|
|
230
|
+
native.pendingReply(d.handle, JSON.stringify(await handler(request)));
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
try {
|
|
234
|
+
native.pendingError(d.handle, e instanceof Error ? e.message : String(e));
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Answered already, or its deadline passed: nothing is waiting.
|
|
474
238
|
}
|
|
475
|
-
throw lastErr;
|
|
476
239
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
* pattern) -- the pool never disposes an identity it didn't mint.
|
|
484
|
-
* `opts.bytes` picks how bytes in each event's payload reach `handler`
|
|
485
|
-
* (rpc.ts's BytesOutput), on every seed and after every respawn.
|
|
486
|
-
* Returns an unsubscribe function. */
|
|
487
|
-
async subscribe(realm, topic, handler, identity, opts = {}) {
|
|
488
|
-
if (this.#closed)
|
|
489
|
-
throw new Error("macula-ts pool: used after close()");
|
|
490
|
-
bytesModeFor(opts.bytes);
|
|
491
|
-
const key = subKey(realm, topic);
|
|
492
|
-
if (this.#subscriptions.has(key))
|
|
493
|
-
throw new Error(`macula-ts pool: already subscribed to ${topic}${realm ? ` (realm ${realm})` : ""}`);
|
|
494
|
-
if (identity)
|
|
495
|
-
this.#assertIdentityNotAlreadyUsed(identity);
|
|
496
|
-
const ownsIdentity = identity === undefined;
|
|
497
|
-
const subIdentity = identity ?? Identity.generate();
|
|
498
|
-
const sub = { realm, topic, handler, identity: subIdentity, ownsIdentity, links: [] };
|
|
499
|
-
const onConnected = async (link, session) => {
|
|
500
|
-
await session.subscribe(topic, (evt) => {
|
|
501
|
-
const dkey = dedupKey(realm, evt.publisher, evt.seq, topic);
|
|
502
|
-
if (this.#dedup.has(dkey))
|
|
503
|
-
return;
|
|
504
|
-
this.#dedup.set(dkey, Date.now());
|
|
505
|
-
// A caller's handler throwing must not take down the native
|
|
506
|
-
// callback that invoked it -- found live 2026-09-05: an
|
|
507
|
-
// uncaught exception here crosses back into the N-API
|
|
508
|
-
// callback with no pending-exception handling on the addon
|
|
509
|
-
// side, which Node only warns about today (DEP0168) but is
|
|
510
|
-
// documented to become a fatal, unrecoverable crash under
|
|
511
|
-
// --force-node-api-uncaught-exceptions-policy once that
|
|
512
|
-
// policy's default flips.
|
|
513
|
-
try {
|
|
514
|
-
sub.handler(evt);
|
|
515
|
-
}
|
|
516
|
-
catch (err) {
|
|
517
|
-
console.error(`macula-ts pool: subscription handler for ${topic} threw:`, err);
|
|
518
|
-
}
|
|
519
|
-
}, {
|
|
520
|
-
realm,
|
|
521
|
-
bytes: opts.bytes,
|
|
522
|
-
onClosed: (err) => {
|
|
523
|
-
if (link.closing)
|
|
524
|
-
return; // already tearing down -- #scheduleReconnect would bail anyway, don't log a misleading "reconnecting"
|
|
525
|
-
if (link.session === session) {
|
|
526
|
-
console.error(`macula-ts pool: subscription to ${topic} on ${link.seed.host}:${link.seed.port} dropped (${err.message}) -- reconnecting`);
|
|
527
|
-
this.#scheduleReconnect(link);
|
|
528
|
-
}
|
|
529
|
-
else {
|
|
530
|
-
// subscribe-start hasn't resolved on this side yet (or
|
|
531
|
-
// this link has already moved on) -- flag it so
|
|
532
|
-
// #attachOnce notices once `onConnected` itself returns,
|
|
533
|
-
// rather than marking a link live with a subscription
|
|
534
|
-
// that already silently died. See closedDuringConnect's
|
|
535
|
-
// own doc.
|
|
536
|
-
link.closedDuringConnect = true;
|
|
537
|
-
}
|
|
538
|
-
},
|
|
539
|
-
});
|
|
540
|
-
};
|
|
541
|
-
sub.links = this.#seeds.map((seed) => this.#newRoleLink(seed, subIdentity, onConnected));
|
|
542
|
-
this.#subscriptions.set(key, sub);
|
|
543
|
-
await Promise.all(sub.links.map((link) => this.#attach(link)));
|
|
544
|
-
return async () => {
|
|
545
|
-
// Guards against a stale unsubscribe() firing (possibly a second
|
|
546
|
-
// time, which this SDK's own convention elsewhere treats as safe)
|
|
547
|
-
// after a newer subscription has since taken this same (realm,
|
|
548
|
-
// topic) key -- found live 2026-09-05: without this check, an
|
|
549
|
-
// old unsubscribe() tore down a DIFFERENT, newer subscription's
|
|
550
|
-
// links and deleted it from #subscriptions, silently stopping its
|
|
551
|
-
// handler with no error anywhere.
|
|
552
|
-
if (this.#subscriptions.get(key) !== sub)
|
|
553
|
-
return;
|
|
554
|
-
const tracked = sub;
|
|
555
|
-
this.#subscriptions.delete(key);
|
|
556
|
-
await Promise.all(tracked.links.map(async (link) => {
|
|
557
|
-
link.closing = true;
|
|
558
|
-
if (link.retryTimer)
|
|
559
|
-
clearTimeout(link.retryTimer);
|
|
560
|
-
if (link.inFlight)
|
|
561
|
-
await link.inFlight.catch(() => { });
|
|
562
|
-
if (link.pendingClose)
|
|
563
|
-
await link.pendingClose;
|
|
564
|
-
if (link.session)
|
|
565
|
-
await link.session.close(link.identity).catch(() => { });
|
|
566
|
-
}));
|
|
567
|
-
if (tracked.ownsIdentity)
|
|
568
|
-
tracked.identity.dispose();
|
|
569
|
-
};
|
|
240
|
+
}
|
|
241
|
+
/** Runs a stream handler, ends the stream as it leaves it, and releases it. */
|
|
242
|
+
async function runStream(stream, handler) {
|
|
243
|
+
try {
|
|
244
|
+
await handler(stream, stream.request());
|
|
245
|
+
await stream.close().catch(() => { });
|
|
570
246
|
}
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
* subscription link health is not reflected here -- inspect a
|
|
574
|
-
* specific subscription's own behavior (events arriving or not)
|
|
575
|
-
* instead; exposing N independent per-topic health vectors here
|
|
576
|
-
* would not simplify what a caller actually needs to know. */
|
|
577
|
-
status() {
|
|
578
|
-
const healthy = this.#liveControlLinks().length;
|
|
579
|
-
return { healthyLinks: healthy, failedLinks: this.#controlLinks.length - healthy };
|
|
247
|
+
catch (e) {
|
|
248
|
+
await stream.abort("error", e instanceof Error ? e.message : String(e)).catch(() => { });
|
|
580
249
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
* included). Awaits each link's in-flight connect/reconnect first,
|
|
584
|
-
* so a still-connecting link never has its identity yanked out from
|
|
585
|
-
* under it. */
|
|
586
|
-
async close() {
|
|
587
|
-
if (this.#closed)
|
|
588
|
-
return;
|
|
589
|
-
this.#closed = true;
|
|
590
|
-
clearInterval(this.#sweepTimer);
|
|
591
|
-
const allLinks = [...this.#controlLinks, ...[...this.#subscriptions.values()].flatMap((s) => s.links)];
|
|
592
|
-
await Promise.all(allLinks.map(async (link) => {
|
|
593
|
-
link.closing = true;
|
|
594
|
-
if (link.retryTimer)
|
|
595
|
-
clearTimeout(link.retryTimer);
|
|
596
|
-
if (link.healthCheckTimer)
|
|
597
|
-
clearInterval(link.healthCheckTimer);
|
|
598
|
-
if (link.inFlight)
|
|
599
|
-
await link.inFlight.catch(() => { });
|
|
600
|
-
if (link.pendingClose)
|
|
601
|
-
await link.pendingClose;
|
|
602
|
-
if (link.session)
|
|
603
|
-
await link.session.close(link.identity).catch(() => { });
|
|
604
|
-
link.session = undefined;
|
|
605
|
-
link.status = "backoff"; // reflects reality for status()/#liveControlLinks() if either is called after close()
|
|
606
|
-
}));
|
|
607
|
-
for (const sub of this.#subscriptions.values())
|
|
608
|
-
if (sub.ownsIdentity)
|
|
609
|
-
sub.identity.dispose();
|
|
610
|
-
this.#subscriptions.clear();
|
|
611
|
-
this.#controlIdentity.dispose();
|
|
250
|
+
finally {
|
|
251
|
+
await stream.free();
|
|
612
252
|
}
|
|
613
253
|
}
|
|
254
|
+
function toRecord(r) {
|
|
255
|
+
return { type: r.type, keyId: r.key_id, createdAt: r.created_at, expiresAt: r.expires_at, payload: r.payload,
|
|
256
|
+
wire: r.wire };
|
|
257
|
+
}
|
|
258
|
+
function toRecords(out) {
|
|
259
|
+
return { records: (out.records ?? []).map(toRecord), dropped: out.dropped ?? 0 };
|
|
260
|
+
}
|
|
614
261
|
//# sourceMappingURL=pool.js.map
|