@macula-io/ts 0.13.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/LICENSE +201 -0
- package/README.md +343 -0
- package/dist/binding.d.ts +47 -0
- package/dist/binding.js +133 -0
- package/dist/binding.js.map +1 -0
- package/dist/content.d.ts +14 -0
- package/dist/content.js +45 -0
- package/dist/content.js.map +1 -0
- package/dist/dht.d.ts +54 -0
- package/dist/dht.js +25 -0
- package/dist/dht.js.map +1 -0
- package/dist/directdial.d.ts +79 -0
- package/dist/directdial.js +76 -0
- package/dist/directdial.js.map +1 -0
- package/dist/identity.d.ts +43 -0
- package/dist/identity.js +83 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/pubsub.d.ts +55 -0
- package/dist/pubsub.js +8 -0
- package/dist/pubsub.js.map +1 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +47 -0
- package/dist/rpc.js.map +1 -0
- package/dist/session.d.ts +453 -0
- package/dist/session.js +807 -0
- package/dist/session.js.map +1 -0
- package/dist/ucan.d.ts +108 -0
- package/dist/ucan.js +142 -0
- package/dist/ucan.js.map +1 -0
- package/package.json +57 -0
- 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/session.js
ADDED
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
// A handshaked connection to a real macula-station: transport +
|
|
2
|
+
// CONNECT/HELLO, reached through cabi's FFI boundary exactly like
|
|
3
|
+
// Identity generation was -- connection.Connect on the Go side already
|
|
4
|
+
// does the real QUIC dial, the CBOR frame encoding, and the Ed25519
|
|
5
|
+
// sign/verify of the handshake; this file does not reimplement any of
|
|
6
|
+
// that, it exposes it.
|
|
7
|
+
//
|
|
8
|
+
// Scope of this slice: connect/close plus unary RPC, both roles (call
|
|
9
|
+
// as caller, serve as provider -- see rpc.ts for the shared payload/
|
|
10
|
+
// error shapes). No pubsub, no DHT, no content transfer, no streaming,
|
|
11
|
+
// no UCAN -- those build on top of a working Session and are separate
|
|
12
|
+
// work.
|
|
13
|
+
import { native } from "./binding.js";
|
|
14
|
+
import { ContentNotFoundError } from "./content.js";
|
|
15
|
+
import { DHT_DEFAULT_TTL_MS } from "./dht.js";
|
|
16
|
+
import { DEFAULT_CALL_TIMEOUT_MS, MaculaCallError, SERVE_POLL_MS } from "./rpc.js";
|
|
17
|
+
const REALM_HEX_PATTERN = /^[0-9a-fA-F]{64}$/;
|
|
18
|
+
/** Decodes CallOptions.realm/PublishOptions.realm/SubscribeOptions.realm's
|
|
19
|
+
* public hex-string convention into the 32-byte Uint8Array native.*
|
|
20
|
+
* already accepts for its own `realm` parameter on sessionCall/
|
|
21
|
+
* sessionCallWithUcan/sessionPublish/sessionSubscribeStart -- cabi/rpc.go,
|
|
22
|
+
* cabi/pubsub.go, and addon/binding.cc were, on inspection, already fully
|
|
23
|
+
* wired for an optional realm all the way through (ReadOptionalRealm in
|
|
24
|
+
* binding.cc, realm32OrZero in cabi/main.go); this class's own methods
|
|
25
|
+
* were the only place still hardcoding `undefined`. Kept as a hex string
|
|
26
|
+
* at the PUBLIC surface specifically to match DhtRecord's existing
|
|
27
|
+
* convention, while reusing that already-working raw-byte plumbing
|
|
28
|
+
* beneath it unchanged, rather than re-threading the FFI boundary itself
|
|
29
|
+
* as a second, redundant string convention alongside it.
|
|
30
|
+
* `undefined` in, `undefined` out -- the all-zero-realm default. */
|
|
31
|
+
function realmBytesFromHex(realm) {
|
|
32
|
+
if (realm === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (!REALM_HEX_PATTERN.test(realm)) {
|
|
35
|
+
throw new Error(`macula-ts: realm must be exactly 64 hex characters (32 bytes), got ${JSON.stringify(realm)}`);
|
|
36
|
+
}
|
|
37
|
+
return new Uint8Array(Buffer.from(realm, "hex"));
|
|
38
|
+
}
|
|
39
|
+
export class Session {
|
|
40
|
+
#handle;
|
|
41
|
+
// Retained since connect() purely so call()/serve() (added this
|
|
42
|
+
// slice) don't force every caller to re-pass the identity they just
|
|
43
|
+
// used to open the session -- connection.Session itself has no such
|
|
44
|
+
// field (every Go-side signing call takes identity.KeyPair
|
|
45
|
+
// explicitly, see connection.go), so this is a convenience this
|
|
46
|
+
// wrapper adds, not something mirrored from macula-go. close()
|
|
47
|
+
// deliberately still takes identity as an explicit parameter (see
|
|
48
|
+
// its own doc below) -- that contract predates this field and is
|
|
49
|
+
// unchanged, per this repo's own "extend, don't replace" rule.
|
|
50
|
+
#identity;
|
|
51
|
+
#activeServe = null;
|
|
52
|
+
#activeSubscription = null;
|
|
53
|
+
// Serializes every operation that reads this Session's shared
|
|
54
|
+
// control stream: call()/callWithUcan()/the DHT methods, plus
|
|
55
|
+
// serve()'s advertise + each poll tick + unadvertise, plus
|
|
56
|
+
// subscribe()'s start + stop. macula-go's connection/frame_stream.go
|
|
57
|
+
// RecvFrame mutates a shared buffer with no mutex of its own, so two
|
|
58
|
+
// reads racing it corrupt the stream -- verified live: `Promise.all`
|
|
59
|
+
// of 4 concurrent call()s on one Session left EVERY later read on
|
|
60
|
+
// that same Session permanently failing "claimed frame length ...
|
|
61
|
+
// exceeds the 16777215-byte cap" (a torn buffer), not just that one
|
|
62
|
+
// batch. The `#activeServe`/`#activeSubscription` checks above only
|
|
63
|
+
// ever protected call()-family operations from an ACTIVE serve()/
|
|
64
|
+
// subscribe() -- they did nothing to stop two ordinary call()s (or a
|
|
65
|
+
// call() racing a DHT method) from racing each other, since neither
|
|
66
|
+
// flag is set in that case. This queue is what actually closes that
|
|
67
|
+
// gap: every control-stream-reading native call funnels through
|
|
68
|
+
// #enqueue, so only one is ever in flight at a time, regardless of
|
|
69
|
+
// which method it came from. publish()/putContent()/getContent() do
|
|
70
|
+
// NOT go through this -- see their own docs for why (a pure write,
|
|
71
|
+
// and each own dedicated QUIC stream, respectively -- neither reads
|
|
72
|
+
// this shared stream at all).
|
|
73
|
+
#queue = Promise.resolve();
|
|
74
|
+
#enqueue(fn) {
|
|
75
|
+
const result = this.#queue.then(fn, fn);
|
|
76
|
+
// The chain itself must never become a rejected promise -- that
|
|
77
|
+
// would wedge every future caller behind a permanently-broken
|
|
78
|
+
// link. Each waiter still gets ITS OWN real result/rejection via
|
|
79
|
+
// the `result` returned below; only the internal sequencing link
|
|
80
|
+
// swallows the outcome.
|
|
81
|
+
this.#queue = result.then(() => undefined, () => undefined);
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
84
|
+
constructor(handle, identity) {
|
|
85
|
+
this.#handle = handle;
|
|
86
|
+
this.#identity = identity;
|
|
87
|
+
}
|
|
88
|
+
/** Dials host:port and completes the full CONNECT/HELLO handshake
|
|
89
|
+
* against a real macula-station, via macula-go's connection.Connect
|
|
90
|
+
* (WebPKI trust -- standard CA-bundle validation, matching what the
|
|
91
|
+
* production fleet actually presents; Pinned/Insecure trust modes
|
|
92
|
+
* aren't exposed yet).
|
|
93
|
+
*
|
|
94
|
+
* This is real network I/O -- a QUIC dial plus a signed round trip,
|
|
95
|
+
* bounded by macula-go's own ~30s handshake timeout -- so it's async
|
|
96
|
+
* on both sides of the FFI boundary (see addon/binding.cc's
|
|
97
|
+
* ConnectWorker): awaiting this never blocks Node's event loop.
|
|
98
|
+
*
|
|
99
|
+
* `identity` must stay non-disposed for the life of the returned
|
|
100
|
+
* Session -- close() needs it again to sign GOODBYE. */
|
|
101
|
+
static async connect(host, port, identity) {
|
|
102
|
+
const handle = await native.sessionConnect(host, port, identity.handleForFfi());
|
|
103
|
+
return new Session(handle, identity);
|
|
104
|
+
}
|
|
105
|
+
#requireHandle() {
|
|
106
|
+
if (this.#handle === null) {
|
|
107
|
+
throw new Error("macula-ts: Session used after close()");
|
|
108
|
+
}
|
|
109
|
+
return this.#handle;
|
|
110
|
+
}
|
|
111
|
+
/** call()'s own handle-plus-exclusivity guard, factored out since the
|
|
112
|
+
* DHT methods below (findRecord/findRecords/findRecordsByType/
|
|
113
|
+
* putRecord) all end up on this Session's same shared control stream
|
|
114
|
+
* too -- macula-go's dht.FindRecord et al. are themselves just a
|
|
115
|
+
* connection.Session.Call under the hood (see cabi/dht.go), so mixing
|
|
116
|
+
* one of these with an active serve() OR subscribe() on this Session
|
|
117
|
+
* races exactly the way call() itself would: serve()'s poll loop and
|
|
118
|
+
* subscribe()'s background reader both read frames off this same
|
|
119
|
+
* stream on their own schedule, same as call()'s own blocking read
|
|
120
|
+
* does. publish() is deliberately NOT guarded by this: it only ever
|
|
121
|
+
* WRITES a fire-and-forget frame (connection.Session.Publish), never
|
|
122
|
+
* reads, so it does not race a concurrent reader the way call()/
|
|
123
|
+
* serve()/subscribe()/the DHT methods do -- and the live pubsub round
|
|
124
|
+
* trip this SDK's own test performs (subscribe(), then publish() on
|
|
125
|
+
* the SAME Session while that subscription is active) depends on
|
|
126
|
+
* publish() staying unguarded here. */
|
|
127
|
+
#requireHandleNotServing(caller) {
|
|
128
|
+
const handle = this.#requireHandle();
|
|
129
|
+
if (this.#activeServe !== null) {
|
|
130
|
+
throw new Error(`macula-ts: Session.${caller}() while serve("${this.#activeServe.procedure}") is active on the same ` +
|
|
131
|
+
`Session races on the shared control stream -- open a second Session for the other role.`);
|
|
132
|
+
}
|
|
133
|
+
if (this.#activeSubscription !== null) {
|
|
134
|
+
throw new Error(`macula-ts: Session.${caller}() while subscribe("${this.#activeSubscription.topic}") is active on the ` +
|
|
135
|
+
`same Session races on the shared control stream -- open a second Session for the other role.`);
|
|
136
|
+
}
|
|
137
|
+
return handle;
|
|
138
|
+
}
|
|
139
|
+
/** The address this session's underlying QUIC connection is with. */
|
|
140
|
+
get remoteAddr() {
|
|
141
|
+
return native.sessionRemoteAddr(this.#requireHandle());
|
|
142
|
+
}
|
|
143
|
+
/** The station's HELLO-verified 32-byte NodeID (Ed25519 public key)
|
|
144
|
+
* -- proof, beyond "connect() didn't throw", that this is a real,
|
|
145
|
+
* application-layer-verified session and not just a QUIC/TLS
|
|
146
|
+
* handshake: frame.Verify already checked this NodeID's signature
|
|
147
|
+
* over the HELLO frame inside connect(), this just surfaces it. */
|
|
148
|
+
get stationNodeId() {
|
|
149
|
+
return native.sessionStationNodeId(this.#requireHandle());
|
|
150
|
+
}
|
|
151
|
+
/** Sends a signed GOODBYE and closes the connection. Safe to call
|
|
152
|
+
* more than once -- a second call is a no-op, matching Identity's
|
|
153
|
+
* dispose() convention. `identity` must be the same (non-disposed)
|
|
154
|
+
* identity used to open this session; Close needs it to sign
|
|
155
|
+
* GOODBYE. Like connect(), this is real network I/O (a drain sleep
|
|
156
|
+
* plus a write) and runs off the main thread on the native side.
|
|
157
|
+
*
|
|
158
|
+
* Stops an active subscribe() FIRST, if there is one, sending its
|
|
159
|
+
* UNSUBSCRIBE over the still-open connection before that connection
|
|
160
|
+
* goes away -- unlike every other resource this SDK hands out (an
|
|
161
|
+
* Identity, an unstopped serve() loop), an unstopped subscription
|
|
162
|
+
* left dangling here does not just leak memory: its background reader
|
|
163
|
+
* goroutine holds a live Napi::ThreadSafeFunction, which deliberately
|
|
164
|
+
* keeps Node's event loop alive on its own (see subscribe()'s own doc
|
|
165
|
+
* -- a program that does nothing but subscribe() and wait needs
|
|
166
|
+
* exactly this to stay alive for events to arrive at all). Verified
|
|
167
|
+
* live: closing a Session out from under an active subscription
|
|
168
|
+
* without this hung the process forever, not merely leaked a handle
|
|
169
|
+
* -- close() closing it first is what makes "forgot to call the
|
|
170
|
+
* returned stop()" fail safe instead of fail hung. */
|
|
171
|
+
async close(identity, reason = "") {
|
|
172
|
+
if (this.#handle === null)
|
|
173
|
+
return;
|
|
174
|
+
if (this.#activeSubscription !== null) {
|
|
175
|
+
// Swallowed, not awaited-and-propagated: a subscription whose
|
|
176
|
+
// connection already died (or whose stop() otherwise fails) must
|
|
177
|
+
// never prevent this Session from actually closing -- verified
|
|
178
|
+
// live that letting that rejection abort close() left the
|
|
179
|
+
// Session's handle un-nulled (leaked) and unclosable on every
|
|
180
|
+
// subsequent attempt. Whatever happened here is either already
|
|
181
|
+
// reported (subscribe()'s own onClosed, if that's why this
|
|
182
|
+
// failed) or genuinely not this method's problem to surface --
|
|
183
|
+
// close() closing the underlying session either way is the
|
|
184
|
+
// actual guarantee this method makes.
|
|
185
|
+
try {
|
|
186
|
+
await this.#activeSubscription.stop();
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
console.error(`macula-ts: session.close() couldn't cleanly stop an active subscription first (closing anyway):`, err);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const handle = this.#handle;
|
|
193
|
+
this.#handle = null;
|
|
194
|
+
await native.sessionClose(handle, identity.handleForFfi(), reason);
|
|
195
|
+
}
|
|
196
|
+
/** Caller role: sends a signed CALL for `procedure` and waits for the
|
|
197
|
+
* matching RESULT or ERROR (macula-go's connection.Session.Call).
|
|
198
|
+
* `payload` is JSON, converted to a cbor.Value on the Go side
|
|
199
|
+
* (cabi/wirevalue.go) -- see rpc.ts's JsonValue for the wire's own
|
|
200
|
+
* restrictions (no booleans, bytes as hex strings).
|
|
201
|
+
*
|
|
202
|
+
* Resolves with the RESULT's payload on success. Rejects with a
|
|
203
|
+
* MaculaCallError (rpc.ts) when a real BOLT#4 ERROR frame came back
|
|
204
|
+
* instead -- e.g. `unknown_next_peer` for a procedure nobody has
|
|
205
|
+
* advertised -- carrying its code/name/retryable triple rather than
|
|
206
|
+
* a generic message; rejects with a plain Error for everything that
|
|
207
|
+
* isn't a wire-level answer at all (a local timeout, a dead session,
|
|
208
|
+
* a payload the wire can't represent).
|
|
209
|
+
*
|
|
210
|
+
* Real network I/O -- a signed frame out and a wait for the reply,
|
|
211
|
+
* up to opts.deadlineMs -- so this runs off the main thread on the
|
|
212
|
+
* native side, like connect()/close(). Do not call this
|
|
213
|
+
* concurrently with an active serve() on the SAME Session: both read
|
|
214
|
+
* frames off the one shared control stream, and macula-go's own
|
|
215
|
+
* ServeOneCall/Call docs both warn that mixing roles on one
|
|
216
|
+
* connection races (an unrelated frame arriving first is discarded,
|
|
217
|
+
* not queued) -- open a second Session for the other role instead,
|
|
218
|
+
* exactly what this SDK's own live test does. */
|
|
219
|
+
async call(procedure, payload, opts = {}) {
|
|
220
|
+
const handle = this.#requireHandleNotServing("call");
|
|
221
|
+
const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
222
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
223
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
224
|
+
const envelopeJson = await this.#enqueue(() => native.sessionCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs));
|
|
225
|
+
const envelope = JSON.parse(envelopeJson);
|
|
226
|
+
if (envelope.ok)
|
|
227
|
+
return envelope.payload;
|
|
228
|
+
throw new MaculaCallError(envelope.bolt4);
|
|
229
|
+
}
|
|
230
|
+
/** Caller role: call(), attaching `ucanToken` to the outgoing CALL
|
|
231
|
+
* (macula-go's `connection.Session.CallWithUCAN`) -- for invoking a
|
|
232
|
+
* procedure a provider has gated behind a `ucan.Policy.Required`
|
|
233
|
+
* policy on its own side (this SDK does not implement that provider
|
|
234
|
+
* side itself -- see ucan.ts's own module doc). `ucanToken` may be a
|
|
235
|
+
* `Ucan` (as returned by `Ucan.mint()`/`Ucan.decode()` -- its `.token`
|
|
236
|
+
* is attached) or a raw token string directly.
|
|
237
|
+
*
|
|
238
|
+
* Same resolve/reject shape as `call()` in every other respect: the
|
|
239
|
+
* RESULT's payload on success, a `MaculaCallError` for a real BOLT#4
|
|
240
|
+
* ERROR frame (e.g. `unauthorized` for a token that fails the
|
|
241
|
+
* provider's policy check, or `unknown_next_peer` for a procedure
|
|
242
|
+
* nobody has advertised), a plain `Error` for anything that never got
|
|
243
|
+
* a wire-level answer at all.
|
|
244
|
+
*
|
|
245
|
+
* Deliberately attaches whatever token bytes it is given with NO local
|
|
246
|
+
* check relating this Session's own identity to `ucanToken`'s `aud`
|
|
247
|
+
* claim -- see ucan.ts's own module doc for why: macula's UCAN gate is
|
|
248
|
+
* a bearer-token check (signature + expiry against the token's
|
|
249
|
+
* issuer), and the real wire-level gate never looks at the caller's
|
|
250
|
+
* identity against `aud` either. A client-side guard here would both
|
|
251
|
+
* reject configurations the mesh accepts fine and misrepresent a
|
|
252
|
+
* security property that isn't actually enforced.
|
|
253
|
+
*
|
|
254
|
+
* Real network I/O, off the main thread on the native side, and
|
|
255
|
+
* subject to the identical same-Session exclusivity rule as `call()`
|
|
256
|
+
* (`#requireHandleNotServing`) -- both end up on the same shared
|
|
257
|
+
* control stream. */
|
|
258
|
+
async callWithUcan(procedure, payload, ucanToken, opts = {}) {
|
|
259
|
+
const handle = this.#requireHandleNotServing("callWithUcan");
|
|
260
|
+
const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
261
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
262
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
263
|
+
const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
|
|
264
|
+
const envelopeJson = await this.#enqueue(() => native.sessionCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token));
|
|
265
|
+
const envelope = JSON.parse(envelopeJson);
|
|
266
|
+
if (envelope.ok)
|
|
267
|
+
return envelope.payload;
|
|
268
|
+
throw new MaculaCallError(envelope.bolt4);
|
|
269
|
+
}
|
|
270
|
+
/** Provider role: advertises `procedure` (macula-go's
|
|
271
|
+
* connection.Session.Advertise) and answers inbound CALLs against it
|
|
272
|
+
* forever, invoking `handler` for each one (payload in, reply or
|
|
273
|
+
* thrown error out -- `handler` may be async), until the returned
|
|
274
|
+
* stop function is called. A thrown/rejected `handler` becomes a
|
|
275
|
+
* BOLT#4 UnknownError reply carrying the thrown value's message as
|
|
276
|
+
* detail (matching macula-go's connection/serve.go, which maps every
|
|
277
|
+
* handler error to that one code); a `handler` that panics on the Go
|
|
278
|
+
* side instead (not reachable from here -- there is no Go code
|
|
279
|
+
* between this and the JS handler) would map to
|
|
280
|
+
* TemporaryRelayFailure, per that same file.
|
|
281
|
+
*
|
|
282
|
+
* Only one serve() registration is allowed per Session at a time --
|
|
283
|
+
* see call()'s own doc on why mixing roles (or two server loops) on
|
|
284
|
+
* one connection is unsafe, not just inadvisable; open a second
|
|
285
|
+
* Session for a second procedure instead of trying to serve two
|
|
286
|
+
* procedures off one.
|
|
287
|
+
*
|
|
288
|
+
* The returned stop function is async: it unadvertises the
|
|
289
|
+
* procedure (a real network write) and waits for the current poll
|
|
290
|
+
* tick to finish (up to rpc.ts's SERVE_POLL_MS) before resolving --
|
|
291
|
+
* there is no way to interrupt a Go-side wait already in flight, the
|
|
292
|
+
* same bounded-latency shape macula-go's own ServeForever has
|
|
293
|
+
* internally. */
|
|
294
|
+
async serve(procedure, handler) {
|
|
295
|
+
if (this.#activeServe !== null) {
|
|
296
|
+
throw new Error(`macula-ts: Session is already serving "${this.#activeServe.procedure}" -- macula-go's ServeOneCall reads ` +
|
|
297
|
+
`one frame at a time off the shared control stream, so a second concurrent serve() (or a serve() ` +
|
|
298
|
+
`alongside call()) on the same Session races; open a second Session instead.`);
|
|
299
|
+
}
|
|
300
|
+
if (this.#activeSubscription !== null) {
|
|
301
|
+
throw new Error(`macula-ts: Session.serve() while subscribe("${this.#activeSubscription.topic}") is active on the same ` +
|
|
302
|
+
`Session races on the shared control stream -- open a second Session for the other role.`);
|
|
303
|
+
}
|
|
304
|
+
const handle = this.#requireHandle();
|
|
305
|
+
const identityHandle = this.#identity.handleForFfi();
|
|
306
|
+
// Marked BEFORE the advertise await below, not after -- closing the
|
|
307
|
+
// exact race window this repo's own live testing found: a
|
|
308
|
+
// concurrent call()/serve()/subscribe() checks #activeServe
|
|
309
|
+
// synchronously, so it must already be non-null by the time this
|
|
310
|
+
// function's first await yields control, not only once advertise
|
|
311
|
+
// has finished. The placeholder stop() is only reachable if
|
|
312
|
+
// something races this same synchronous tick (impossible in
|
|
313
|
+
// practice, single-threaded JS) or misuses the handle before
|
|
314
|
+
// startup finishes; rolled back to null below if advertise itself
|
|
315
|
+
// fails, so a failed serve() attempt doesn't leave the Session
|
|
316
|
+
// permanently (and incorrectly) marked as serving.
|
|
317
|
+
const placeholderStop = async () => {
|
|
318
|
+
throw new Error(`macula-ts: session.serve("${procedure}") has not finished starting yet`);
|
|
319
|
+
};
|
|
320
|
+
this.#activeServe = { procedure, stop: placeholderStop };
|
|
321
|
+
try {
|
|
322
|
+
await this.#enqueue(() => native.sessionAdvertise(handle, identityHandle, undefined, procedure));
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
this.#activeServe = null;
|
|
326
|
+
throw err;
|
|
327
|
+
}
|
|
328
|
+
let stopped = false;
|
|
329
|
+
const loopDone = (async () => {
|
|
330
|
+
while (!stopped) {
|
|
331
|
+
let pendingHandle;
|
|
332
|
+
try {
|
|
333
|
+
pendingHandle = await this.#enqueue(() => native.serveWaitForCall(handle, identityHandle, undefined, procedure, SERVE_POLL_MS));
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
if (!stopped) {
|
|
337
|
+
console.error(`macula-ts: session.serve("${procedure}") poll failed, stopping this server loop:`, err);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (pendingHandle === null)
|
|
342
|
+
continue; // nothing arrived this tick -- poll again
|
|
343
|
+
const payload = JSON.parse(native.pendingCallPayloadJson(pendingHandle));
|
|
344
|
+
try {
|
|
345
|
+
const reply = await handler(payload);
|
|
346
|
+
await native.pendingCallReplyResult(pendingHandle, JSON.stringify(reply ?? null));
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
350
|
+
try {
|
|
351
|
+
await native.pendingCallReplyError(pendingHandle, detail);
|
|
352
|
+
}
|
|
353
|
+
catch (replyErr) {
|
|
354
|
+
console.error(`macula-ts: session.serve("${procedure}") failed to send a reply:`, replyErr);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
})();
|
|
359
|
+
const stop = async () => {
|
|
360
|
+
stopped = true;
|
|
361
|
+
await loopDone;
|
|
362
|
+
this.#activeServe = null;
|
|
363
|
+
if (this.#handle !== null) {
|
|
364
|
+
await this.#enqueue(() => native.sessionUnadvertise(handle, identityHandle, undefined, procedure));
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
this.#activeServe = { procedure, stop };
|
|
368
|
+
return stop;
|
|
369
|
+
}
|
|
370
|
+
/** DHT: returns every record of `recordType` currently visible from
|
|
371
|
+
* the station this Session is connected to (macula-go's
|
|
372
|
+
* dht.FindRecordsByType, via a signed CALL to `_dht.find_records_by_type`
|
|
373
|
+
* under the DHT's own reserved realm -- threaded internally, this
|
|
374
|
+
* method never touches a realm itself). Coverage depends on that
|
|
375
|
+
* station's own view of the mesh, not a global guarantee. Neither
|
|
376
|
+
* this nor findRecord/findRecords verifies a returned record's
|
|
377
|
+
* signature or checks its expiry -- see DhtRecord's own doc (dht.ts).
|
|
378
|
+
*
|
|
379
|
+
* Real network I/O, off the main thread on the native side, exactly
|
|
380
|
+
* like call() -- and subject to the same same-Session exclusivity
|
|
381
|
+
* rule as call() (see #requireHandleNotServing's own doc): do not
|
|
382
|
+
* call this while serve() is active on the same Session. */
|
|
383
|
+
async findRecordsByType(recordType) {
|
|
384
|
+
const handle = this.#requireHandleNotServing("findRecordsByType");
|
|
385
|
+
const json = await this.#enqueue(() => native.dhtFindRecordsByType(handle, this.#identity.handleForFfi(), recordType));
|
|
386
|
+
return JSON.parse(json);
|
|
387
|
+
}
|
|
388
|
+
/** DHT: returns every record stored at `key` -- the full
|
|
389
|
+
* signer-deduped multiset at that storage key (macula-go's
|
|
390
|
+
* dht.FindRecords), e.g. every procedure_advertisement for one
|
|
391
|
+
* procedure, not just the first one found. `key` must be exactly 32
|
|
392
|
+
* bytes -- see dht/record.go's ProcedureKey/StationEndpointKey/
|
|
393
|
+
* ContentKey (macula-go) for how those are derived from the thing
|
|
394
|
+
* being looked up. Same I/O and exclusivity notes as
|
|
395
|
+
* findRecordsByType(). */
|
|
396
|
+
async findRecords(key) {
|
|
397
|
+
const handle = this.#requireHandleNotServing("findRecords");
|
|
398
|
+
requireKey32(key);
|
|
399
|
+
const json = await this.#enqueue(() => native.dhtFindRecords(handle, this.#identity.handleForFfi(), key));
|
|
400
|
+
return JSON.parse(json);
|
|
401
|
+
}
|
|
402
|
+
/** DHT: returns ONE record by storage key (macula-go's
|
|
403
|
+
* dht.FindRecord). Resolves `null` when none exists -- mirrors
|
|
404
|
+
* macula-go's own dht.ErrNotFound, translated to a value instead of a
|
|
405
|
+
* thrown error since "not found" is an expected, routine outcome
|
|
406
|
+
* here, not exceptional. Same I/O and exclusivity notes as
|
|
407
|
+
* findRecordsByType(). */
|
|
408
|
+
async findRecord(key) {
|
|
409
|
+
const handle = this.#requireHandleNotServing("findRecord");
|
|
410
|
+
requireKey32(key);
|
|
411
|
+
const json = await this.#enqueue(() => native.dhtFindRecord(handle, this.#identity.handleForFfi(), key));
|
|
412
|
+
return json === null ? null : JSON.parse(json);
|
|
413
|
+
}
|
|
414
|
+
/** DHT: builds the realm-qualified discovery URI (macula-go's
|
|
415
|
+
* dht.DiscoveryURI), then builds (dht.NewProcedureAdvertisement),
|
|
416
|
+
* signs, and stores a procedure_advertisement naming this Session's
|
|
417
|
+
* own Identity as `procedure`'s advertiser and `servingStation` (32
|
|
418
|
+
* bytes -- a station's NodeID, typically this Session's own
|
|
419
|
+
* `stationNodeId`) as the station that serves it.
|
|
420
|
+
*
|
|
421
|
+
* `realm` should be the SAME realm `procedure` is (or will be) served
|
|
422
|
+
* under via `serve()` -- defaults to the all-zero realm, matching
|
|
423
|
+
* `call()`'s own default (see CallOptions.realm's own doc: `call()`/
|
|
424
|
+
* `callWithUcan()`/`publish()`/`subscribe()` now all take an optional
|
|
425
|
+
* realm; `serve()`/`advertise` remain all-zero-realm-only for this
|
|
426
|
+
* slice, unchanged). This method
|
|
427
|
+
* builds the qualified URI itself (dht.DiscoveryURI) rather than
|
|
428
|
+
* taking a pre-qualified string, since NewProcedureAdvertisement's own
|
|
429
|
+
* doc is explicit that "the advertiser and the resolver must derive
|
|
430
|
+
* the identical URI or the DHT storage key will not agree" -- a
|
|
431
|
+
* caller-supplied pre-qualified string invites exactly that class of
|
|
432
|
+
* bug (verified directly: an earlier draft of this SDK's own live
|
|
433
|
+
* test got this wrong by hand-qualifying the URI itself, and its
|
|
434
|
+
* following findRecord() came back not-found until this method built
|
|
435
|
+
* the URI internally instead).
|
|
436
|
+
*
|
|
437
|
+
* Wraps macula-go's REAL constructor rather than a generic
|
|
438
|
+
* JSON-payload builder deliberately -- see cabi/dht.go's own doc on
|
|
439
|
+
* why: two of this record type's payload fields (advertiser_node,
|
|
440
|
+
* serving_station) are raw 32-byte pubkeys that must be actual CBOR
|
|
441
|
+
* byte strings for a real resolver to read, and this SDK's generic
|
|
442
|
+
* JSON<->cbor.Value conversion (rpc.ts's JsonValue, wirevalue.go) has
|
|
443
|
+
* no way to produce those going IN -- only OUT, as "0x"-prefixed hex
|
|
444
|
+
* (see DhtRecord's own doc). `ttlMs` defaults to DHT_DEFAULT_TTL_MS
|
|
445
|
+
* (48h). Resolves with the signed record actually stored. Same I/O
|
|
446
|
+
* and exclusivity notes as findRecordsByType(). */
|
|
447
|
+
async putProcedureAdvertisement(procedure, servingStation, opts = {}) {
|
|
448
|
+
const handle = this.#requireHandleNotServing("putProcedureAdvertisement");
|
|
449
|
+
requireKey32(servingStation);
|
|
450
|
+
if (opts.realm !== undefined)
|
|
451
|
+
requireKey32(opts.realm);
|
|
452
|
+
const json = await this.#enqueue(() => native.dhtPutProcedureAdvertisement(handle, this.#identity.handleForFfi(), opts.realm, procedure, servingStation, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
|
|
453
|
+
return JSON.parse(json);
|
|
454
|
+
}
|
|
455
|
+
/** DHT: builds (macula-go's dht.NewContentAnnouncement), signs, and
|
|
456
|
+
* stores a content_announcement naming this Session's own Identity as
|
|
457
|
+
* `mcid`'s (34 bytes) announcer, reachable at `endpoint` (a dialable
|
|
458
|
+
* seed URL, e.g. "https://host:4433" -- NOT a station_endpoint's
|
|
459
|
+
* split host/port). Same reasoning as putProcedureAdvertisement()'s
|
|
460
|
+
* own doc for wrapping macula-go's real constructor instead of a
|
|
461
|
+
* generic JSON-payload builder (announcer_node/mcid are the same kind
|
|
462
|
+
* of raw-byte field). `ttlMs` defaults to DHT_DEFAULT_TTL_MS (48h).
|
|
463
|
+
* Same I/O and exclusivity notes as findRecordsByType(). */
|
|
464
|
+
async putContentAnnouncement(mcid, endpoint, ttlMs = DHT_DEFAULT_TTL_MS) {
|
|
465
|
+
const handle = this.#requireHandleNotServing("putContentAnnouncement");
|
|
466
|
+
if (mcid.length !== 34) {
|
|
467
|
+
throw new Error(`macula-ts: content_announcement mcid must be exactly 34 bytes, got ${mcid.length}`);
|
|
468
|
+
}
|
|
469
|
+
const json = await this.#enqueue(() => native.dhtPutContentAnnouncement(handle, this.#identity.handleForFfi(), mcid, endpoint, ttlMs));
|
|
470
|
+
return JSON.parse(json);
|
|
471
|
+
}
|
|
472
|
+
/** Direct-dial (caller side): finds `procedure`'s currently-advertised
|
|
473
|
+
* serving station and its dialable host/port (macula-go's
|
|
474
|
+
* `directdial.Resolve`), via this Session used only to query the DHT
|
|
475
|
+
* -- it does not need to be connected to the station that ends up
|
|
476
|
+
* serving `procedure`. Retries past DHT propagation lag internally (up
|
|
477
|
+
* to ~5s, macula-go's own fixed schedule, not configurable here); a
|
|
478
|
+
* `procedure` nobody ever called `advertiseDirect()` for rejects
|
|
479
|
+
* cleanly after that window (a plain `Error` wrapping macula-go's
|
|
480
|
+
* `ErrProcedureNotAdvertised`), never a hang.
|
|
481
|
+
*
|
|
482
|
+
* `opts.realm` must match whatever realm `procedure` was
|
|
483
|
+
* `advertiseDirect()`d under, or the discovery URI the two sides
|
|
484
|
+
* derive disagrees and this rejects the same way as if nothing was
|
|
485
|
+
* ever advertised at all -- see `AdvertiseDirectOptions.realm`'s own
|
|
486
|
+
* doc (directdial.ts). `callDirect()`/`callDirectWithUcan()` call this
|
|
487
|
+
* internally; it's exposed on its own for callers that just want the
|
|
488
|
+
* resolved station/host/port without also dialing and calling it (e.g.
|
|
489
|
+
* diagnostics).
|
|
490
|
+
*
|
|
491
|
+
* Real network I/O, off the main thread on the native side, subject to
|
|
492
|
+
* the same same-Session exclusivity rule as `call()`/the DHT methods
|
|
493
|
+
* (`#requireHandleNotServing`). */
|
|
494
|
+
async resolveDirect(procedure, opts = {}) {
|
|
495
|
+
const handle = this.#requireHandleNotServing("resolveDirect");
|
|
496
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
497
|
+
const json = await this.#enqueue(() => native.directdialResolve(handle, this.#identity.handleForFfi(), realm, procedure));
|
|
498
|
+
return JSON.parse(json);
|
|
499
|
+
}
|
|
500
|
+
/** Direct-dial (caller side): resolves `procedure`'s provider (via
|
|
501
|
+
* `resolveDirect()`, through this Session) and calls it there, in one
|
|
502
|
+
* hop, in a SEPARATE connection macula-go opens, application-layer-pins
|
|
503
|
+
* against the resolved station identity, and closes again, entirely
|
|
504
|
+
* internally (macula-go's `directdial.Call`) -- this Session's own
|
|
505
|
+
* connection is never touched beyond the DHT lookup. The provider must
|
|
506
|
+
* have `advertiseDirect()`d `procedure` (or the Erlang/Rust/Go
|
|
507
|
+
* equivalent) -- a plain `serve()`-side `Advertise` alone publishes no
|
|
508
|
+
* discoverable DHT record, and this rejects with `ErrProcedureNotAdvertised`.
|
|
509
|
+
*
|
|
510
|
+
* Same resolve/reject shape as `call()`: the RESULT's payload on
|
|
511
|
+
* success, a `MaculaCallError` (rpc.ts) for a real BOLT#4 ERROR frame
|
|
512
|
+
* from the resolved provider, a plain `Error` for a resolve failure, a
|
|
513
|
+
* dial failure, an identity-trust violation (the dialed peer proved a
|
|
514
|
+
* DIFFERENT identity than the DHT chain resolved), or anything else
|
|
515
|
+
* that never got a wire-level answer at all.
|
|
516
|
+
*
|
|
517
|
+
* Real network I/O (DHT lookups, a fresh QUIC dial, then the CALL
|
|
518
|
+
* itself) -- off the main thread on the native side, subject to the
|
|
519
|
+
* same same-Session exclusivity rule as `call()` for THIS Session's own
|
|
520
|
+
* DHT-querying use (the separate dialed connection this opens
|
|
521
|
+
* internally is not this Session and is never exposed as one). */
|
|
522
|
+
async callDirect(procedure, payload, opts = {}) {
|
|
523
|
+
const handle = this.#requireHandleNotServing("callDirect");
|
|
524
|
+
const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
525
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
526
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
527
|
+
const envelopeJson = await this.#enqueue(() => native.directdialCall(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs));
|
|
528
|
+
const envelope = JSON.parse(envelopeJson);
|
|
529
|
+
if (envelope.ok)
|
|
530
|
+
return envelope.payload;
|
|
531
|
+
throw new MaculaCallError(envelope.bolt4);
|
|
532
|
+
}
|
|
533
|
+
/** Direct-dial (caller side): `callDirect()`, attaching `ucanToken` to
|
|
534
|
+
* the outgoing CALL (macula-go's `directdial.CallWithUCAN`) -- for
|
|
535
|
+
* reaching a direct-dial-advertised procedure a provider has gated
|
|
536
|
+
* behind a `ucan.Policy.Required` policy. Every hecate-om capability is
|
|
537
|
+
* advertised via `advertiseDirect()` specifically so it's reachable
|
|
538
|
+
* ONLY this way -- plain `callDirect()` cannot resolve or attach a
|
|
539
|
+
* token to it. Same `ucanToken` shape, no-audience-check, and
|
|
540
|
+
* resolve/reject conventions as `callWithUcan()` (see both that
|
|
541
|
+
* method's and ucan.ts's own doc for why: macula's UCAN gate is a
|
|
542
|
+
* bearer-token check, not an audience match).
|
|
543
|
+
*
|
|
544
|
+
* Same I/O and exclusivity notes as `callDirect()`. */
|
|
545
|
+
async callDirectWithUcan(procedure, payload, ucanToken, opts = {}) {
|
|
546
|
+
const handle = this.#requireHandleNotServing("callDirectWithUcan");
|
|
547
|
+
const timeoutMs = opts.deadlineMs ?? DEFAULT_CALL_TIMEOUT_MS;
|
|
548
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
549
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
550
|
+
const token = typeof ucanToken === "string" ? ucanToken : ucanToken.token;
|
|
551
|
+
const envelopeJson = await this.#enqueue(() => native.directdialCallWithUcan(handle, this.#identity.handleForFfi(), procedure, realm, payloadJson, timeoutMs, token));
|
|
552
|
+
const envelope = JSON.parse(envelopeJson);
|
|
553
|
+
if (envelope.ok)
|
|
554
|
+
return envelope.payload;
|
|
555
|
+
throw new MaculaCallError(envelope.bolt4);
|
|
556
|
+
}
|
|
557
|
+
/** Direct-dial (provider side): publishes `procedure` as
|
|
558
|
+
* direct-dial-reachable at THIS Session's own currently-connected
|
|
559
|
+
* station (macula-go's `directdial.AdvertiseDirect`) -- a plain
|
|
560
|
+
* ADVERTISE (so an inbound CALL routed here via the DHT-resolved path
|
|
561
|
+
* still has something to route to -- a real bug macula-go fixed live
|
|
562
|
+
* 2026-08-30: skipping this let resolve+dial complete cleanly against a
|
|
563
|
+
* station with nothing registered to answer, `ServeOneCall` never
|
|
564
|
+
* seeing it) plus a signed `procedure_advertisement` DHT record naming
|
|
565
|
+
* this Session's own station.
|
|
566
|
+
*
|
|
567
|
+
* Unlike `serve()`'s own internal advertise (still all-zero-realm-only
|
|
568
|
+
* in this SDK -- see `serve()`'s own doc), this method threads
|
|
569
|
+
* `opts.realm` all the way through, matching `directdial.AdvertiseDirect`
|
|
570
|
+
* itself: `resolveDirect()`/`callDirect()`/`callDirectWithUcan()` only
|
|
571
|
+
* ever reach a procedure `advertiseDirect()`d under the EXACT SAME
|
|
572
|
+
* realm.
|
|
573
|
+
*
|
|
574
|
+
* A station's registration for a procedure does not survive the
|
|
575
|
+
* connection that sent it being replaced -- a long-lived provider needs
|
|
576
|
+
* to call this again on its own schedule; see `keepAdvertisedDirect()`
|
|
577
|
+
* (directdial.ts) for that loop, built on top of this method rather
|
|
578
|
+
* than duplicating macula-go's own `KeepAdvertisedDirect` here.
|
|
579
|
+
*
|
|
580
|
+
* **Must NOT be called on a Session that is also actively
|
|
581
|
+
* `serve()`-ing** -- enforced by the same `#requireHandleNotServing`
|
|
582
|
+
* guard `call()`/the DHT methods use, for the identical reason: this
|
|
583
|
+
* method's own `PutRecord` CALL reads a RESULT off the same shared
|
|
584
|
+
* control stream `serve()`'s poll loop is also reading, and the two
|
|
585
|
+
* would race (matches `directdial.AdvertiseDirect`'s own doc). A
|
|
586
|
+
* provider that also serves `procedure` needs a SEPARATE Session (and
|
|
587
|
+
* identity -- this fleet enforces one connection per identity, kicking
|
|
588
|
+
* whichever connects second) to call this on.
|
|
589
|
+
*
|
|
590
|
+
* Real network I/O (a fire-and-forget ADVERTISE write plus a signed
|
|
591
|
+
* PutRecord CALL) -- off the main thread on the native side. */
|
|
592
|
+
async advertiseDirect(procedure, opts = {}) {
|
|
593
|
+
const handle = this.#requireHandleNotServing("advertiseDirect");
|
|
594
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
595
|
+
await this.#enqueue(() => native.directdialAdvertise(handle, this.#identity.handleForFfi(), realm, procedure, opts.ttlMs ?? DHT_DEFAULT_TTL_MS));
|
|
596
|
+
}
|
|
597
|
+
/** Pubsub: sends a signed PUBLISH for `topic` (macula-go's
|
|
598
|
+
* connection.Session.Publish, which also attaches the end-to-end
|
|
599
|
+
* publisher_sig a relayed EVENT needs to survive beyond one hop --
|
|
600
|
+
* see that method's own doc, not reimplemented here). `payload`
|
|
601
|
+
* follows the same JsonValue rules as call()'s payload (no boolean,
|
|
602
|
+
* embedded bytes as "0x"-prefixed hex). Fire-and-forget: Publish's
|
|
603
|
+
* own doc is explicit that no reply is expected on the wire, so the
|
|
604
|
+
* returned Promise resolving only means this Session's own frame was
|
|
605
|
+
* encoded, signed, and sent -- never that any subscriber received it
|
|
606
|
+
* (macula-go's own live test for this, TestLivePubSubRoundTrip,
|
|
607
|
+
* observes a subscriber's own publish arriving back at it rather than
|
|
608
|
+
* asserting it as a hard guarantee, for the same reason).
|
|
609
|
+
*
|
|
610
|
+
* Deliberately NOT guarded by the same-Session exclusivity rule
|
|
611
|
+
* call()/serve()/subscribe()/the DHT methods share (see
|
|
612
|
+
* #requireHandleNotServing's own doc) -- publish() only ever writes,
|
|
613
|
+
* never reads off the shared control stream, so it does not race a
|
|
614
|
+
* concurrent serve()/subscribe() the way those do, and can run safely
|
|
615
|
+
* on the SAME Session a subscribe() of its own is active on -- exactly
|
|
616
|
+
* what a subscriber publishing to (and receiving) its own topic needs.
|
|
617
|
+
*
|
|
618
|
+
* `opts.realm` (see CallOptions.realm's own doc for the hex-string
|
|
619
|
+
* format and exact-match semantics) scopes which realm this EVENT is
|
|
620
|
+
* published under -- omitted means the all-zero realm, this SDK's
|
|
621
|
+
* sole default before this option existed. A subscribe() only
|
|
622
|
+
* receives this event if its own realm matches exactly.
|
|
623
|
+
*
|
|
624
|
+
* Real network I/O (one signed frame write) -- runs off the main
|
|
625
|
+
* thread on the native side, like every other network-touching method
|
|
626
|
+
* here. */
|
|
627
|
+
async publish(topic, payload, opts = {}) {
|
|
628
|
+
const handle = this.#requireHandle();
|
|
629
|
+
const payloadJson = JSON.stringify(payload ?? null);
|
|
630
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
631
|
+
await native.sessionPublish(handle, this.#identity.handleForFfi(), realm, topic, payloadJson, opts.ttlMs ?? 0);
|
|
632
|
+
}
|
|
633
|
+
/** Pubsub: sends a signed SUBSCRIBE for `topic`, then delivers every
|
|
634
|
+
* inbound EVENT for it to `handler` -- macula-go's own
|
|
635
|
+
* connection.Session.RunSubscriber (connection/subscriber.go) drives
|
|
636
|
+
* the actual read loop on the Go side, in a background goroutine, NOT
|
|
637
|
+
* reimplemented on top of a hand-rolled poll here (see cabi/pubsub.go's
|
|
638
|
+
* own doc for why RunSubscriber specifically, over the lower-level
|
|
639
|
+
* RecvEvent). Delivery is Go-driven, not JS-driven: unlike serve()'s
|
|
640
|
+
* poll loop, nothing on this side calls into the native layer
|
|
641
|
+
* repeatedly to ask "did anything arrive yet" -- the addon calls INTO
|
|
642
|
+
* this handler asynchronously, via a Napi::ThreadSafeFunction wired to
|
|
643
|
+
* that background goroutine, whenever an EVENT actually shows up.
|
|
644
|
+
*
|
|
645
|
+
* Only one subscribe() (and no active serve()) is allowed per Session
|
|
646
|
+
* at a time -- same reasoning as serve()'s own one-at-a-time rule
|
|
647
|
+
* (#requireHandleNotServing's own doc): the background reader and any
|
|
648
|
+
* other read off this Session's shared control stream would race.
|
|
649
|
+
* Open a second Session for a second topic (or to serve/call
|
|
650
|
+
* concurrently) instead.
|
|
651
|
+
*
|
|
652
|
+
* Resolves with an async stop() function once the initial SUBSCRIBE
|
|
653
|
+
* has been sent and the background reader has started. stop() sends
|
|
654
|
+
* the matching UNSUBSCRIBE and does not resolve until the Go-side
|
|
655
|
+
* reader goroutine has genuinely exited -- calling it and awaiting the
|
|
656
|
+
* result is the actual guarantee that no further `handler` call can
|
|
657
|
+
* happen afterward, not just that one was requested.
|
|
658
|
+
*
|
|
659
|
+
* Real network I/O (the initial SUBSCRIBE send, and stop()'s
|
|
660
|
+
* UNSUBSCRIBE) -- both run off the main thread on the native side.
|
|
661
|
+
*
|
|
662
|
+
* `opts.realm` (see CallOptions.realm's own doc for the hex-string
|
|
663
|
+
* format and exact-match semantics) scopes which realm this SUBSCRIBE
|
|
664
|
+
* listens on -- omitted means the all-zero realm, this SDK's sole
|
|
665
|
+
* default before this option existed. Only an EVENT published under
|
|
666
|
+
* the SAME realm is ever delivered to `handler`.
|
|
667
|
+
*
|
|
668
|
+
* If the underlying connection dies (or any other transport error
|
|
669
|
+
* ends the background reader) rather than the returned stop() being
|
|
670
|
+
* called, this subscription tears itself down automatically -- the
|
|
671
|
+
* native handle is released and this Session is left closable and
|
|
672
|
+
* reusable for a fresh subscribe()/serve()/call() -- and, if
|
|
673
|
+
* provided, `opts.onClosed` is called once with the error. Verified
|
|
674
|
+
* live that, without this, such a subscription went silent forever
|
|
675
|
+
* (no further events, no error) and left this Session's handle
|
|
676
|
+
* permanently open even after close(). */
|
|
677
|
+
async subscribe(topic, handler, opts = {}) {
|
|
678
|
+
if (this.#activeServe !== null) {
|
|
679
|
+
throw new Error(`macula-ts: Session.subscribe() while serve("${this.#activeServe.procedure}") is active on the same ` +
|
|
680
|
+
`Session races on the shared control stream -- open a second Session for the other role.`);
|
|
681
|
+
}
|
|
682
|
+
if (this.#activeSubscription !== null) {
|
|
683
|
+
throw new Error(`macula-ts: Session is already subscribed to "${this.#activeSubscription.topic}" -- macula-go's ` +
|
|
684
|
+
`RunSubscriber reads one frame at a time off the shared control stream, so a second concurrent ` +
|
|
685
|
+
`subscribe() on the same Session races; open a second Session instead.`);
|
|
686
|
+
}
|
|
687
|
+
const handle = this.#requireHandle();
|
|
688
|
+
const identityHandle = this.#identity.handleForFfi();
|
|
689
|
+
const realm = realmBytesFromHex(opts.realm);
|
|
690
|
+
// Marked BEFORE the subscribe-start await below, not after -- same
|
|
691
|
+
// race-window fix as serve()'s own placeholder above, and for the
|
|
692
|
+
// identical reason (this repo's own live testing found the same
|
|
693
|
+
// class of race on both). Rolled back to null if starting the
|
|
694
|
+
// subscription itself fails.
|
|
695
|
+
const placeholderStop = async () => {
|
|
696
|
+
throw new Error(`macula-ts: session.subscribe("${topic}") has not finished starting yet`);
|
|
697
|
+
};
|
|
698
|
+
this.#activeSubscription = { topic, stop: placeholderStop };
|
|
699
|
+
// subscriptionHandle is assigned once (right after sessionSubscribeStart
|
|
700
|
+
// resolves, below) and only ever READ from here on -- realStop
|
|
701
|
+
// cannot run before that assignment, since nothing can call stop()
|
|
702
|
+
// (directly or via onClosed) before this function itself returns it.
|
|
703
|
+
let subscriptionHandle;
|
|
704
|
+
let stopPromise = null;
|
|
705
|
+
const realStop = async () => {
|
|
706
|
+
this.#activeSubscription = null;
|
|
707
|
+
await this.#enqueue(() => native.sessionSubscribeStop(subscriptionHandle));
|
|
708
|
+
};
|
|
709
|
+
// Memoized so it is safe to call more than once, from more than one
|
|
710
|
+
// place -- the caller's own returned stop(), AND onClosed's own
|
|
711
|
+
// internal call below when the reader exits on its own -- without
|
|
712
|
+
// either double-invoking the native stop (which would either be a
|
|
713
|
+
// wasted call or, worse, race a second subscribe() that had since
|
|
714
|
+
// reused this Session). Whichever caller gets here first actually
|
|
715
|
+
// runs realStop(); everyone else gets that same settled outcome.
|
|
716
|
+
const stop = () => {
|
|
717
|
+
if (stopPromise === null)
|
|
718
|
+
stopPromise = realStop();
|
|
719
|
+
return stopPromise;
|
|
720
|
+
};
|
|
721
|
+
const onClosed = (error) => {
|
|
722
|
+
console.error(`macula-ts: session.subscribe("${topic}") ended unexpectedly, tearing it down:`, error);
|
|
723
|
+
// This internal call's own rejection (the underlying transport
|
|
724
|
+
// error realStop's native call surfaces once more, tearing down
|
|
725
|
+
// an already-dead subscription) is not new information -- error
|
|
726
|
+
// is already being reported via onClosed itself, right below.
|
|
727
|
+
// A caller's OWN explicit call to the returned stop() afterward
|
|
728
|
+
// still resolves/rejects for real, from the same memoized promise.
|
|
729
|
+
stop().catch(() => { });
|
|
730
|
+
opts.onClosed?.(error);
|
|
731
|
+
};
|
|
732
|
+
try {
|
|
733
|
+
subscriptionHandle = await this.#enqueue(() => native.sessionSubscribeStart(handle, identityHandle, realm, topic, (msg) => {
|
|
734
|
+
if (msg.kind === "closed") {
|
|
735
|
+
onClosed(new Error(msg.error ?? "subscription closed"));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
handler({ payload: JSON.parse(msg.payloadJson), publisher: msg.publisher, seq: msg.seq });
|
|
739
|
+
}));
|
|
740
|
+
}
|
|
741
|
+
catch (err) {
|
|
742
|
+
this.#activeSubscription = null;
|
|
743
|
+
throw err;
|
|
744
|
+
}
|
|
745
|
+
this.#activeSubscription = { topic, stop };
|
|
746
|
+
return stop;
|
|
747
|
+
}
|
|
748
|
+
/** Content transfer: stores `data` (macula-go's content.Put, on this
|
|
749
|
+
* Session's own fresh dedicated QUIC stream -- Session.
|
|
750
|
+
* OpenDedicatedStream on the Go side, NOT the shared control stream
|
|
751
|
+
* call()/serve()/the DHT methods/subscribe() all read from), chunking
|
|
752
|
+
* automatically above manifest.DefaultChunkSize (256 KiB) and
|
|
753
|
+
* returning the hex-encoded mcid it's now addressable by. `name` is
|
|
754
|
+
* used ONLY on the chunked path (attached to the resulting manifest)
|
|
755
|
+
* -- a single-block put ignores it entirely, matching content.Put's
|
|
756
|
+
* own documented behavior; leave it unset for small blobs.
|
|
757
|
+
*
|
|
758
|
+
* NOT durable object storage -- see content.ts's own module doc: a
|
|
759
|
+
* station may forget this content later, and there is no list/delete
|
|
760
|
+
* operation. Treat this as "hand these bytes to a peer once."
|
|
761
|
+
*
|
|
762
|
+
* Because Put opens its own dedicated stream instead of reading the
|
|
763
|
+
* shared control stream, this is, unlike call()/serve()/the DHT
|
|
764
|
+
* methods/subscribe(), never subject to Session's same-Session
|
|
765
|
+
* exclusivity guard (#requireHandleNotServing) -- it can run
|
|
766
|
+
* concurrently with an active serve()/subscribe() (or another
|
|
767
|
+
* putContent()/getContent()) on the same Session without racing.
|
|
768
|
+
*
|
|
769
|
+
* Real network I/O (one or more signed CALLs on the new stream) --
|
|
770
|
+
* runs off the main thread on the native side, like every other
|
|
771
|
+
* network-touching method here. */
|
|
772
|
+
async putContent(data, name = "") {
|
|
773
|
+
const handle = this.#requireHandle();
|
|
774
|
+
const mcid = await native.contentPut(handle, this.#identity.handleForFfi(), data, name);
|
|
775
|
+
return { mcid };
|
|
776
|
+
}
|
|
777
|
+
/** Content transfer: fetches and verifies (macula-go's content.Get,
|
|
778
|
+
* on its own fresh dedicated QUIC stream, same reasoning as
|
|
779
|
+
* putContent() -- including content.Get's own client-side hash
|
|
780
|
+
* re-check against `mcid`: a station may only be relaying content it
|
|
781
|
+
* doesn't itself store, so its answer is never trusted blindly) the
|
|
782
|
+
* content addressed by `mcid` (the hex string putContent() returned).
|
|
783
|
+
*
|
|
784
|
+
* Rejects with ContentNotFoundError (content.ts) specifically when
|
|
785
|
+
* the station reports it doesn't know this mcid -- an expected,
|
|
786
|
+
* routine outcome for a one-time transfer mechanism with no
|
|
787
|
+
* durability guarantee, not a transport failure; every other failure
|
|
788
|
+
* (a bad session, a malformed mcid, a real transport error) rejects
|
|
789
|
+
* with a plain Error instead.
|
|
790
|
+
*
|
|
791
|
+
* Same dedicated-stream, no-exclusivity-guard reasoning as
|
|
792
|
+
* putContent() -- safe alongside an active serve()/subscribe() on the
|
|
793
|
+
* same Session. Real network I/O, runs off the main thread. */
|
|
794
|
+
async getContent(mcid) {
|
|
795
|
+
const handle = this.#requireHandle();
|
|
796
|
+
const data = await native.contentGet(handle, this.#identity.handleForFfi(), mcid);
|
|
797
|
+
if (data === null)
|
|
798
|
+
throw new ContentNotFoundError(mcid);
|
|
799
|
+
return data;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function requireKey32(key) {
|
|
803
|
+
if (key.length !== 32) {
|
|
804
|
+
throw new Error(`macula-ts: DHT key must be exactly 32 bytes, got ${key.length}`);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
//# sourceMappingURL=session.js.map
|