@nanobpm/urban-agent-client 0.1.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 +98 -0
- package/dist/client.d.ts +211 -0
- package/dist/client.js +706 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +37 -0
- package/dist/protocol.d.ts +16 -0
- package/dist/protocol.js +15 -0
- package/dist/ring.d.ts +103 -0
- package/dist/ring.js +166 -0
- package/dist/testkit.d.ts +53 -0
- package/dist/testkit.js +95 -0
- package/dist/transport.d.ts +62 -0
- package/dist/transport.js +80 -0
- package/package.json +54 -0
- package/src/client.test.ts +829 -0
- package/src/client.ts +829 -0
- package/src/conformance.test.ts +151 -0
- package/src/index.ts +77 -0
- package/src/protocol.ts +46 -0
- package/src/ring.test.ts +155 -0
- package/src/ring.ts +228 -0
- package/src/testkit.ts +112 -0
- package/src/transport.test.ts +77 -0
- package/src/transport.ts +123 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { OutboundRing } from "./ring.js";
|
|
2
|
+
import { MAX_SEQ, decodeFrame, encodeFrame, isMessageFamily, validatePayload, } from "./protocol.js";
|
|
3
|
+
import { websocketTransport } from "./transport.js";
|
|
4
|
+
/**
|
|
5
|
+
* The lane each outbound family rides. Presence, coordination and vocab are
|
|
6
|
+
* control/facts; relay bytes are bulk. This is the client's contribution to
|
|
7
|
+
* invariant #5 — a relay storm on the bulk lane can never head-of-line-block a
|
|
8
|
+
* heartbeat or deregister on the control lane.
|
|
9
|
+
*/
|
|
10
|
+
const OUTBOUND_LANE = {
|
|
11
|
+
register: "control",
|
|
12
|
+
heartbeat: "control",
|
|
13
|
+
deregister: "control",
|
|
14
|
+
relay: "bulk",
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_BUFFER_CAPACITY = 1024;
|
|
17
|
+
const DEFAULT_SERVE_TIMEOUT_MS = 30_000;
|
|
18
|
+
const DEFAULT_RECONNECT = {
|
|
19
|
+
enabled: true,
|
|
20
|
+
initialDelayMs: 250,
|
|
21
|
+
maxDelayMs: 10_000,
|
|
22
|
+
factor: 2,
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Guard a resolved timing/backoff option. Node's setTimeout/setInterval coerce a
|
|
26
|
+
* negative or NaN delay to 0, which would turn a misconfigured heartbeat or
|
|
27
|
+
* reconnect backoff into a tight, event-loop-saturating loop. Reject such values
|
|
28
|
+
* at construction — the same fail-fast contract OutboundRing applies to capacity —
|
|
29
|
+
* instead of silently degrading into a hot loop at runtime.
|
|
30
|
+
*/
|
|
31
|
+
function assertFiniteAtLeast(name, value, min) {
|
|
32
|
+
if (!Number.isFinite(value) || value < min) {
|
|
33
|
+
throw new RangeError(`${name} must be a finite number >= ${min}, got ${value}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Worker-side client for the Nano agentic channel (S9).
|
|
38
|
+
*
|
|
39
|
+
* Speaks the S0 wire contract on a connection SEPARATE from the C8 job protocol
|
|
40
|
+
* (invariants #1/#2): it registers a capability, receives its resolved `SERVE`
|
|
41
|
+
* tokens, heartbeats/deregisters, and produces relay bytes. Everything the
|
|
42
|
+
* worker produces goes through a bounded {@link OutboundRing}, so the worker
|
|
43
|
+
* keeps producing across a hub outage and drains — in strict QoS order — on
|
|
44
|
+
* reconnect (invariants #5/#6). Capability is an enrolment attribute, never a
|
|
45
|
+
* routing token (invariant #3).
|
|
46
|
+
*/
|
|
47
|
+
export class AgenticClient {
|
|
48
|
+
instance;
|
|
49
|
+
url;
|
|
50
|
+
transportFactory;
|
|
51
|
+
ring;
|
|
52
|
+
heartbeatIntervalMs;
|
|
53
|
+
serveTimeoutMs;
|
|
54
|
+
reconnectPolicy;
|
|
55
|
+
schedule;
|
|
56
|
+
capability;
|
|
57
|
+
transport;
|
|
58
|
+
state = "idle";
|
|
59
|
+
seq = 0;
|
|
60
|
+
reconnectDelay;
|
|
61
|
+
reconnecting = false;
|
|
62
|
+
closedByCaller = false;
|
|
63
|
+
closeHandled = false;
|
|
64
|
+
heartbeatTimer;
|
|
65
|
+
pendingServe;
|
|
66
|
+
lastServe = [];
|
|
67
|
+
relayOffsets = new Map();
|
|
68
|
+
serveListeners = new Set();
|
|
69
|
+
frameListeners = new Set();
|
|
70
|
+
openListeners = new Set();
|
|
71
|
+
closeListeners = new Set();
|
|
72
|
+
errorListeners = new Set();
|
|
73
|
+
drainListeners = new Set();
|
|
74
|
+
constructor(options) {
|
|
75
|
+
this.url = options.url;
|
|
76
|
+
this.instance = options.instance ?? crypto.randomUUID();
|
|
77
|
+
this.capability = options.capability;
|
|
78
|
+
this.transportFactory = options.transport ?? websocketTransport;
|
|
79
|
+
this.ring = new OutboundRing({ capacity: options.bufferCapacity ?? DEFAULT_BUFFER_CAPACITY });
|
|
80
|
+
this.heartbeatIntervalMs = options.heartbeatIntervalMs ?? 0;
|
|
81
|
+
this.serveTimeoutMs = options.serveTimeoutMs ?? DEFAULT_SERVE_TIMEOUT_MS;
|
|
82
|
+
this.reconnectPolicy = {
|
|
83
|
+
enabled: options.reconnect?.enabled ?? DEFAULT_RECONNECT.enabled,
|
|
84
|
+
initialDelayMs: options.reconnect?.initialDelayMs ?? DEFAULT_RECONNECT.initialDelayMs,
|
|
85
|
+
maxDelayMs: options.reconnect?.maxDelayMs ?? DEFAULT_RECONNECT.maxDelayMs,
|
|
86
|
+
factor: options.reconnect?.factor ?? DEFAULT_RECONNECT.factor,
|
|
87
|
+
};
|
|
88
|
+
this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
|
|
89
|
+
// Reject timing/backoff options that Node would coerce into a 0ms hot loop.
|
|
90
|
+
// heartbeat/serveTimeout accept 0 as a "disabled" sentinel (guarded with > 0
|
|
91
|
+
// at use), but reconnect delays have no such sentinel — enabled:false disables
|
|
92
|
+
// reconnect — so a 0ms initial/max delay is only ever a hot loop (0 * factor
|
|
93
|
+
// stays 0, and maxDelayMs clamps every backoff back to 0): require >= 1.
|
|
94
|
+
assertFiniteAtLeast("heartbeatIntervalMs", this.heartbeatIntervalMs, 0);
|
|
95
|
+
assertFiniteAtLeast("serveTimeoutMs", this.serveTimeoutMs, 0);
|
|
96
|
+
assertFiniteAtLeast("reconnect.initialDelayMs", this.reconnectPolicy.initialDelayMs, 1);
|
|
97
|
+
assertFiniteAtLeast("reconnect.maxDelayMs", this.reconnectPolicy.maxDelayMs, 1);
|
|
98
|
+
assertFiniteAtLeast("reconnect.factor", this.reconnectPolicy.factor, 1);
|
|
99
|
+
this.schedule =
|
|
100
|
+
options.schedule ??
|
|
101
|
+
((fn, ms) => {
|
|
102
|
+
// Match the serve-timeout and heartbeat timers: an auto-reconnect backoff
|
|
103
|
+
// timer must not keep the Node event loop alive on its own.
|
|
104
|
+
const timer = setTimeout(fn, ms);
|
|
105
|
+
if (typeof timer.unref === "function") {
|
|
106
|
+
timer.unref();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/** Current connection state. */
|
|
111
|
+
get connectionState() {
|
|
112
|
+
return this.state;
|
|
113
|
+
}
|
|
114
|
+
/** True when the transport is open and draining live. */
|
|
115
|
+
get connected() {
|
|
116
|
+
return this.state === "open";
|
|
117
|
+
}
|
|
118
|
+
/** Number of frames currently buffered awaiting a live channel. */
|
|
119
|
+
get buffered() {
|
|
120
|
+
return this.ring.size;
|
|
121
|
+
}
|
|
122
|
+
/** The most recently resolved SERVE token set (empty until the first SERVE). */
|
|
123
|
+
get serve() {
|
|
124
|
+
return this.lastServe;
|
|
125
|
+
}
|
|
126
|
+
/** Open the transport. Safe to call once; reconnects are automatic. A no-op after close() (terminal). */
|
|
127
|
+
connect() {
|
|
128
|
+
// A caller-initiated close() is terminal: once closed, connect() is a no-op
|
|
129
|
+
// so a shut-down client never silently reopens (and never re-drains frames
|
|
130
|
+
// buffered before the shutdown). Manual reconnect after a passive drop is
|
|
131
|
+
// still available from the "idle" state (reconnect disabled).
|
|
132
|
+
if (this.state === "connecting" || this.state === "open" || this.state === "closed") {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
this.closedByCaller = false;
|
|
136
|
+
this.openTransport();
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Declare a capability and await the resolved SERVE tokens.
|
|
140
|
+
*
|
|
141
|
+
* The REGISTER frame is buffered like any other outbound frame, so calling
|
|
142
|
+
* `register` while the hub is down does not fail — it enqueues and resolves
|
|
143
|
+
* once the channel comes back and the hub answers with SERVE. Capability is an
|
|
144
|
+
* enrolment attribute; it never becomes part of a routing token (invariant #3).
|
|
145
|
+
*/
|
|
146
|
+
register(input) {
|
|
147
|
+
// A closed client is terminal: its buffer is released and the transport can
|
|
148
|
+
// never reopen, so a register here could only create a pending promise that
|
|
149
|
+
// never resolves and buffer a frame that never drains. Fail fast instead.
|
|
150
|
+
if (this.isClosed) {
|
|
151
|
+
return Promise.reject(new Error("register on a closed client"));
|
|
152
|
+
}
|
|
153
|
+
const capability = input?.capability ?? this.capability;
|
|
154
|
+
if (capability === undefined) {
|
|
155
|
+
return Promise.reject(new Error("register requires a capability (pass one, or set it in the client options)"));
|
|
156
|
+
}
|
|
157
|
+
this.capability = capability;
|
|
158
|
+
// A fresh register supersedes any in-flight one: reject the prior promise
|
|
159
|
+
// AND drop any REGISTER still buffered in the ring, so a reconnect drains
|
|
160
|
+
// only the newest capability (and never a stale duplicate ahead of it).
|
|
161
|
+
this.rejectPendingServe(new Error("superseded by a newer register"));
|
|
162
|
+
this.removeBuffered("register");
|
|
163
|
+
const promise = new Promise((resolve, reject) => {
|
|
164
|
+
const pending = { resolve, reject };
|
|
165
|
+
this.pendingServe = pending;
|
|
166
|
+
// Route the serve-timeout through the injectable scheduler (which unref()s
|
|
167
|
+
// the underlying timer by default, so a lingering register never keeps a
|
|
168
|
+
// process alive). A raw unref()'d setTimeout was the sole event-loop keeper
|
|
169
|
+
// during a caller's `await client.register()`, so under Node's test runner
|
|
170
|
+
// the loop could drain and cancel the awaited promise before the timer
|
|
171
|
+
// fired ("Promise resolution is still pending but the event loop has
|
|
172
|
+
// already resolved"). The scheduler is cancellation-free, so a stale fire
|
|
173
|
+
// after the pending was resolved/superseded is a guarded no-op.
|
|
174
|
+
if (this.serveTimeoutMs > 0) {
|
|
175
|
+
this.schedule(() => {
|
|
176
|
+
if (this.pendingServe !== pending) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
this.pendingServe = undefined;
|
|
180
|
+
reject(new Error(`SERVE not received within ${this.serveTimeoutMs}ms`));
|
|
181
|
+
}, this.serveTimeoutMs);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
this.enqueueRegister(capability);
|
|
185
|
+
if (this.state === "idle") {
|
|
186
|
+
this.connect();
|
|
187
|
+
}
|
|
188
|
+
if (this.heartbeatIntervalMs > 0) {
|
|
189
|
+
this.startHeartbeatTimer();
|
|
190
|
+
}
|
|
191
|
+
return promise;
|
|
192
|
+
}
|
|
193
|
+
/** Produce a single liveness heartbeat (control lane). Ages out on TTL if it stops (S2). */
|
|
194
|
+
heartbeat() {
|
|
195
|
+
if (this.refuseWhenClosed("heartbeat")) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// A heartbeat is point-in-time liveness, not durable state: only the newest
|
|
199
|
+
// one matters. Coalesce any heartbeat still buffered from a prior tick before
|
|
200
|
+
// enqueuing this one, so at most a single heartbeat is ever buffered. Without
|
|
201
|
+
// this, an auto-heartbeat timer running through a long outage would pile
|
|
202
|
+
// never-evicted control-lane heartbeats into the bounded ring and shed the
|
|
203
|
+
// buffered bulk relay (worker output) via the QoS overflow policy.
|
|
204
|
+
this.removeBuffered("heartbeat");
|
|
205
|
+
const payload = { instance: this.instance };
|
|
206
|
+
this.enqueue("heartbeat", OUTBOUND_LANE.heartbeat, payload);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Produce relay bytes on the bulk lane. `chunk` is the terminal/command output
|
|
210
|
+
* for `stream`; the client tracks a monotonic per-stream byte offset so the
|
|
211
|
+
* hub-side ring can resume-from-offset after a consumer reconnect (S5). Bytes
|
|
212
|
+
* are UTF-8-encoded on the wire as the payload's `chunk` string.
|
|
213
|
+
*/
|
|
214
|
+
relay(stream, chunk) {
|
|
215
|
+
// Terminal client: refuse before touching the per-stream offset map (which
|
|
216
|
+
// close() already released) so a post-close relay neither re-grows that map
|
|
217
|
+
// nor buffers a frame that can never drain.
|
|
218
|
+
if (this.refuseWhenClosed("relay")) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const offset = this.relayOffsets.get(stream) ?? 0;
|
|
222
|
+
const payload = { stream, offset, chunk };
|
|
223
|
+
// Advance the per-stream offset only if the frame was actually accepted
|
|
224
|
+
// for sending. A relay rejected for invalid payload, refused because the
|
|
225
|
+
// client is closed, OR dropped by the QoS overflow policy (ring full of
|
|
226
|
+
// higher-priority traffic) must not consume offset space, or every
|
|
227
|
+
// subsequent relay's offset would be inconsistent with the bytes the hub
|
|
228
|
+
// actually received.
|
|
229
|
+
if (this.enqueue("relay", OUTBOUND_LANE.relay, payload)) {
|
|
230
|
+
this.relayOffsets.set(stream, offset + byteLength(chunk));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Deregister and close. Sends a deregister frame best-effort — only when the
|
|
235
|
+
* channel is currently open. `close()` is terminal and releases the buffer, so
|
|
236
|
+
* a deregister enqueued while disconnected could never drain; enqueuing it then
|
|
237
|
+
* would just pin an unsendable frame until close() drops it. When the channel
|
|
238
|
+
* is down we therefore skip the frame and tear down directly, without
|
|
239
|
+
* reconnecting.
|
|
240
|
+
*/
|
|
241
|
+
deregister(reason) {
|
|
242
|
+
if (this.state === "open") {
|
|
243
|
+
const payload = reason === undefined ? { instance: this.instance } : { instance: this.instance, reason };
|
|
244
|
+
this.enqueue("deregister", OUTBOUND_LANE.deregister, payload);
|
|
245
|
+
}
|
|
246
|
+
this.close();
|
|
247
|
+
}
|
|
248
|
+
/** Tear down the client: stop the heartbeat, close the transport, stop reconnecting. */
|
|
249
|
+
close() {
|
|
250
|
+
this.closedByCaller = true;
|
|
251
|
+
this.stopHeartbeatTimer();
|
|
252
|
+
this.rejectPendingServe(new Error("client closed"));
|
|
253
|
+
// Best-effort transport close, then own the state transition + close event
|
|
254
|
+
// ourselves. A real transport's close is asynchronous (a WebSocket fires its
|
|
255
|
+
// own onClose on a later tick), and it may never surface a close at all, so
|
|
256
|
+
// we drive handleClose directly to guarantee onClose fires. handleClose is
|
|
257
|
+
// idempotent per connection attempt, so it de-duplicates against any onClose
|
|
258
|
+
// the transport also fires.
|
|
259
|
+
// Terminal: the outbound ring and per-stream relay offsets can never be
|
|
260
|
+
// drained again, so release them here rather than pinning a large outage
|
|
261
|
+
// backlog (buffered frames, many relay streams) in memory for the lifetime
|
|
262
|
+
// of the now-dead client. Clear BEFORE anything can fire the close event —
|
|
263
|
+
// both the transport (an injectable seam that may legally fire onClose
|
|
264
|
+
// synchronously from close(), as FakeTransport does when open) and our own
|
|
265
|
+
// handleClose below emit onClose synchronously. A subscriber that reads
|
|
266
|
+
// `buffered` (or the relay-offset state) must observe the released,
|
|
267
|
+
// self-consistent terminal state that close() documents — not a stale
|
|
268
|
+
// non-zero backlog — regardless of which path surfaces the close first.
|
|
269
|
+
this.ring.clear();
|
|
270
|
+
this.relayOffsets.clear();
|
|
271
|
+
const transport = this.transport;
|
|
272
|
+
this.transport = undefined;
|
|
273
|
+
try {
|
|
274
|
+
transport?.close();
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
// An already-broken transport may throw on close; the teardown proceeds.
|
|
278
|
+
}
|
|
279
|
+
this.handleClose({ local: true });
|
|
280
|
+
// Enforce the terminal state unconditionally. handleClose sets "closed" on
|
|
281
|
+
// the normal path, but it early-returns on its closeHandled guard when a
|
|
282
|
+
// prior close already fired for this connection attempt — e.g. a send-failure
|
|
283
|
+
// forceReconnect() left us "connecting" with a reconnect scheduled. Without
|
|
284
|
+
// this, close() would leave a non-terminal state: isClosed stays false, and
|
|
285
|
+
// (closedByCaller now set) the scheduled reconnect skips openTransport,
|
|
286
|
+
// wedging the client in "connecting". Own the terminal transition here so
|
|
287
|
+
// close() always honors its contract regardless of which path handled the
|
|
288
|
+
// close first.
|
|
289
|
+
this.state = "closed";
|
|
290
|
+
}
|
|
291
|
+
/** Subscribe to resolved SERVE tokens (fires on every SERVE, including reconnects). */
|
|
292
|
+
onServe(listener) {
|
|
293
|
+
this.serveListeners.add(listener);
|
|
294
|
+
return () => this.serveListeners.delete(listener);
|
|
295
|
+
}
|
|
296
|
+
/** Subscribe to every validated inbound frame. */
|
|
297
|
+
onFrame(listener) {
|
|
298
|
+
this.frameListeners.add(listener);
|
|
299
|
+
return () => this.frameListeners.delete(listener);
|
|
300
|
+
}
|
|
301
|
+
/** Subscribe to channel-open events (fires on first connect and each reconnect). */
|
|
302
|
+
onOpen(listener) {
|
|
303
|
+
this.openListeners.add(listener);
|
|
304
|
+
return () => this.openListeners.delete(listener);
|
|
305
|
+
}
|
|
306
|
+
/** Subscribe to channel-close events. */
|
|
307
|
+
onClose(listener) {
|
|
308
|
+
this.closeListeners.add(listener);
|
|
309
|
+
return () => this.closeListeners.delete(listener);
|
|
310
|
+
}
|
|
311
|
+
/** Subscribe to transport / decode / validation errors (never thrown; always non-fatal). */
|
|
312
|
+
onError(listener) {
|
|
313
|
+
this.errorListeners.add(listener);
|
|
314
|
+
return () => this.errorListeners.delete(listener);
|
|
315
|
+
}
|
|
316
|
+
/** Subscribe to buffer-drained events (fires when the outbound ring empties after sending). */
|
|
317
|
+
onDrain(listener) {
|
|
318
|
+
this.drainListeners.add(listener);
|
|
319
|
+
return () => this.drainListeners.delete(listener);
|
|
320
|
+
}
|
|
321
|
+
// ---- internals -----------------------------------------------------------
|
|
322
|
+
openTransport() {
|
|
323
|
+
this.state = "connecting";
|
|
324
|
+
this.closeHandled = false;
|
|
325
|
+
try {
|
|
326
|
+
this.transport = this.transportFactory(this.url, {
|
|
327
|
+
onOpen: () => this.handleOpen(),
|
|
328
|
+
onFrame: (bytes) => this.handleFrame(bytes),
|
|
329
|
+
onClose: (info) => this.handleClose(info),
|
|
330
|
+
onError: (error) => this.emitError(error),
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
// A transport factory can throw synchronously — e.g. the default
|
|
335
|
+
// websocketTransport when no global WebSocket is available. Without this
|
|
336
|
+
// guard the exception escapes openTransport() and the client wedges in
|
|
337
|
+
// "connecting" with no transport and no signal. Surface it as a non-fatal
|
|
338
|
+
// error and drive handleClose so the client leaves "connecting" the same
|
|
339
|
+
// way a failed connection would: scheduling a reconnect when enabled, or
|
|
340
|
+
// going idle/closed otherwise.
|
|
341
|
+
this.transport = undefined;
|
|
342
|
+
this.emitError(error instanceof Error ? error : new Error(String(error)));
|
|
343
|
+
this.handleClose({ reason: "transport factory failed" });
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
handleOpen() {
|
|
347
|
+
this.state = "open";
|
|
348
|
+
this.reconnectDelay = this.reconnectPolicy.initialDelayMs;
|
|
349
|
+
// Re-announce presence first so a reconnect re-registers before draining
|
|
350
|
+
// any buffered relay backlog. Coalesce any REGISTER already buffered
|
|
351
|
+
// (possibly behind other control-lane frames a caller queued during the
|
|
352
|
+
// outage, e.g. a heartbeat) and enqueue a fresh one at the front of the
|
|
353
|
+
// control lane, so it drains ahead of the entire backlog — never behind a
|
|
354
|
+
// heartbeat and never as a stale duplicate.
|
|
355
|
+
if (this.capability !== undefined) {
|
|
356
|
+
this.removeBuffered("register");
|
|
357
|
+
this.enqueueRegister(this.capability, true);
|
|
358
|
+
// A capability set in options auto-registers here without register() ever
|
|
359
|
+
// being called, so start the documented auto-heartbeat timer on open too
|
|
360
|
+
// — otherwise heartbeatIntervalMs would silently no-op for auto-register
|
|
361
|
+
// consumers. startHeartbeatTimer() is idempotent, so an explicit
|
|
362
|
+
// register() that already started it is unaffected.
|
|
363
|
+
if (this.heartbeatIntervalMs > 0) {
|
|
364
|
+
this.startHeartbeatTimer();
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
this.emitOpen();
|
|
368
|
+
this.pump();
|
|
369
|
+
}
|
|
370
|
+
handleClose(info) {
|
|
371
|
+
// Idempotent per connection attempt: a send failure may route us here
|
|
372
|
+
// directly AND the transport may still fire its own onClose. Handle once.
|
|
373
|
+
if (this.closeHandled) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.closeHandled = true;
|
|
377
|
+
this.transport = undefined;
|
|
378
|
+
if (this.closedByCaller) {
|
|
379
|
+
this.state = "closed";
|
|
380
|
+
}
|
|
381
|
+
else if (this.reconnectPolicy.enabled) {
|
|
382
|
+
this.state = "connecting";
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// No auto-reconnect: go idle so the caller can reconnect manually.
|
|
386
|
+
this.state = "idle";
|
|
387
|
+
}
|
|
388
|
+
// Emit exactly once per handled close (the closeHandled guard above makes
|
|
389
|
+
// this once-per-connection-attempt). Fire regardless of whether we reached
|
|
390
|
+
// "open": a caller-initiated close while still "connecting" must notify
|
|
391
|
+
// onClose just like a remote drop while connecting already does — otherwise
|
|
392
|
+
// onClose is silent for `connect()` immediately followed by `close()`.
|
|
393
|
+
this.emitClose(info);
|
|
394
|
+
if (!this.closedByCaller && this.reconnectPolicy.enabled) {
|
|
395
|
+
this.scheduleReconnect();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
scheduleReconnect() {
|
|
399
|
+
if (this.reconnecting) {
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
this.reconnecting = true;
|
|
403
|
+
const delay = this.reconnectDelay;
|
|
404
|
+
this.reconnectDelay = Math.min(this.reconnectDelay * this.reconnectPolicy.factor, this.reconnectPolicy.maxDelayMs);
|
|
405
|
+
this.schedule(() => {
|
|
406
|
+
this.reconnecting = false;
|
|
407
|
+
if (!this.closedByCaller) {
|
|
408
|
+
this.openTransport();
|
|
409
|
+
}
|
|
410
|
+
}, delay);
|
|
411
|
+
}
|
|
412
|
+
handleFrame(bytes) {
|
|
413
|
+
let frame;
|
|
414
|
+
try {
|
|
415
|
+
frame = decodeFrame(bytes);
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
// Malformed input from the wire must never crash the worker — surface it
|
|
419
|
+
// and keep the channel alive. This is what the conformance corpus's
|
|
420
|
+
// malformed vectors exercise.
|
|
421
|
+
this.emitError(asError(error, "failed to decode inbound frame"));
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const check = validatePayload(frame.family, frame.payload);
|
|
425
|
+
if (!check.ok) {
|
|
426
|
+
this.emitError(new Error(`inbound ${frame.family} payload failed validation: ${check.errors.map((e) => e.code).join(",")}`));
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
this.emitFrame(frame);
|
|
430
|
+
if (frame.family === "serve") {
|
|
431
|
+
this.handleServe(frame.payload);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
handleServe(payload) {
|
|
435
|
+
if (!isServePayload(payload) || payload.instance !== this.instance) {
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
this.lastServe = payload.tokens;
|
|
439
|
+
if (this.pendingServe !== undefined) {
|
|
440
|
+
const pending = this.pendingServe;
|
|
441
|
+
this.pendingServe = undefined;
|
|
442
|
+
pending.resolve({ serve: payload.tokens });
|
|
443
|
+
}
|
|
444
|
+
this.emitServe(payload);
|
|
445
|
+
}
|
|
446
|
+
enqueueRegister(capability, front = false) {
|
|
447
|
+
const payload = { instance: this.instance, capability };
|
|
448
|
+
const invalid = this.rejectInvalidOutbound("register", payload);
|
|
449
|
+
if (invalid) {
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (front) {
|
|
453
|
+
const frame = { lane: OUTBOUND_LANE.register, family: "register", seq: this.nextSeq(), payload };
|
|
454
|
+
this.ring.enqueueFront(frame);
|
|
455
|
+
this.pump();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.enqueue("register", OUTBOUND_LANE.register, payload);
|
|
459
|
+
}
|
|
460
|
+
enqueue(family, lane, payload) {
|
|
461
|
+
if (this.refuseWhenClosed(family)) {
|
|
462
|
+
return false;
|
|
463
|
+
}
|
|
464
|
+
if (this.rejectInvalidOutbound(family, payload)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
const frame = { lane, family, seq: this.nextSeq(), payload };
|
|
468
|
+
// The QoS overflow policy can drop the INCOMING frame when the ring is full
|
|
469
|
+
// of strictly higher-priority traffic (it returns the frame as `evicted` and
|
|
470
|
+
// leaves the buffer untouched). Report that as not-accepted so offset-tracking
|
|
471
|
+
// callers (relay) don't advance past bytes that never entered the buffer.
|
|
472
|
+
const { evicted } = this.ring.enqueue(frame);
|
|
473
|
+
this.pump();
|
|
474
|
+
return evicted !== frame;
|
|
475
|
+
}
|
|
476
|
+
/** True once close() has been called — the terminal state (see {@link close}). */
|
|
477
|
+
get isClosed() {
|
|
478
|
+
return this.state === "closed";
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* Categorical guard for every outbound-producing surface: once the client is
|
|
482
|
+
* closed (terminal), the transport can never reopen and the buffer has been
|
|
483
|
+
* released, so any frame produced here can never drain. Rather than silently
|
|
484
|
+
* re-grow the ring close() emptied — and mislead the caller — refuse and
|
|
485
|
+
* surface the misuse via onError. Returns true when the call was refused.
|
|
486
|
+
*/
|
|
487
|
+
refuseWhenClosed(family) {
|
|
488
|
+
if (!this.isClosed) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
this.emitError(new Error(`cannot ${family} on a closed client`));
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Validate an outbound payload against the S0 contract before it is buffered.
|
|
496
|
+
* An unsendable frame is never enqueued (so it can't silently occupy buffer
|
|
497
|
+
* space and then be dropped at encode time); the error is surfaced and, when
|
|
498
|
+
* it is the REGISTER we are awaiting a SERVE for, the register promise is
|
|
499
|
+
* failed fast instead of hanging until the serve timeout (or forever when the
|
|
500
|
+
* timeout is disabled). Returns true when the payload was rejected.
|
|
501
|
+
*/
|
|
502
|
+
rejectInvalidOutbound(family, payload) {
|
|
503
|
+
const check = validatePayload(family, payload);
|
|
504
|
+
if (check.ok) {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
const error = new Error(`outbound ${family} payload failed validation: ${check.errors.map((e) => e.code).join(",")}`);
|
|
508
|
+
if (family === "register") {
|
|
509
|
+
this.rejectPendingServe(error);
|
|
510
|
+
}
|
|
511
|
+
this.emitError(error);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
/** Drain the ring to the transport in strict QoS order until it empties or a send fails. */
|
|
515
|
+
pump() {
|
|
516
|
+
if (this.state !== "open" || this.transport === undefined) {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
let sentAny = false;
|
|
520
|
+
while (!this.ring.isEmpty) {
|
|
521
|
+
const frame = this.ring.peek();
|
|
522
|
+
if (frame === undefined) {
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
let bytes;
|
|
526
|
+
try {
|
|
527
|
+
bytes = encodeFrame(frame);
|
|
528
|
+
}
|
|
529
|
+
catch (error) {
|
|
530
|
+
// An unencodable frame can never be sent — drop it rather than wedge the
|
|
531
|
+
// drain, and report it. If it was the REGISTER we are awaiting a SERVE
|
|
532
|
+
// for, fail that promise fast instead of leaving it pending until (or
|
|
533
|
+
// beyond) the serve timeout.
|
|
534
|
+
this.ring.dequeue();
|
|
535
|
+
const err = asError(error, "failed to encode outbound frame; dropped");
|
|
536
|
+
if (frame.family === "register") {
|
|
537
|
+
this.rejectPendingServe(err);
|
|
538
|
+
}
|
|
539
|
+
this.emitError(err);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
this.transport.send(bytes);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
// The channel went away mid-drain: leave the frame buffered and stop.
|
|
547
|
+
// The transport is not required to also fire onClose (the send contract
|
|
548
|
+
// only mandates a synchronous throw), so drive the disconnect/reconnect
|
|
549
|
+
// ourselves rather than relying on an onClose that may never arrive —
|
|
550
|
+
// otherwise a throw-only transport wedges the client with a full buffer.
|
|
551
|
+
this.emitError(asError(error, "transport send failed; reconnecting"));
|
|
552
|
+
this.forceReconnect({ local: false });
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
this.ring.dequeue();
|
|
556
|
+
sentAny = true;
|
|
557
|
+
}
|
|
558
|
+
if (sentAny && this.ring.isEmpty) {
|
|
559
|
+
this.emitDrain();
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Drop every buffered frame of `family` from the ring, returning them. The
|
|
564
|
+
* single source of truth for outbound coalescing: register coalescing (a
|
|
565
|
+
* superseding `register()` and a reconnect's `handleOpen()`) and heartbeat
|
|
566
|
+
* coalescing both use it so the drain never emits a stale or duplicate frame.
|
|
567
|
+
*/
|
|
568
|
+
removeBuffered(family) {
|
|
569
|
+
return this.ring.remove((frame) => frame.family === family);
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Tear down the current transport and route through the normal close/reconnect
|
|
573
|
+
* path when a send throws but the transport does not (or has not yet) fired its
|
|
574
|
+
* own onClose. `handleClose` is idempotent for this connection attempt, so if
|
|
575
|
+
* the transport DOES also emit onClose the second pass is a no-op.
|
|
576
|
+
*/
|
|
577
|
+
forceReconnect(info) {
|
|
578
|
+
const transport = this.transport;
|
|
579
|
+
this.transport = undefined;
|
|
580
|
+
// Drive the close with the intended `info` FIRST, then tear down the
|
|
581
|
+
// transport. `handleClose` is idempotent per connection attempt, so if
|
|
582
|
+
// closing the transport synchronously fires its own onClose (the bundled
|
|
583
|
+
// FakeTransport does, with `{ local: true }`), that second pass is a no-op
|
|
584
|
+
// and cannot misreport this remote drop / send failure as a local close.
|
|
585
|
+
this.handleClose(info);
|
|
586
|
+
try {
|
|
587
|
+
transport?.close();
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
// A transport that is already broken may throw on close; ignore it.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
nextSeq() {
|
|
594
|
+
const value = this.seq;
|
|
595
|
+
this.seq = this.seq >= MAX_SEQ ? 0 : this.seq + 1;
|
|
596
|
+
return value;
|
|
597
|
+
}
|
|
598
|
+
startHeartbeatTimer() {
|
|
599
|
+
if (this.heartbeatTimer !== undefined || this.heartbeatIntervalMs <= 0) {
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
const timer = setInterval(() => this.heartbeat(), this.heartbeatIntervalMs);
|
|
603
|
+
if (typeof timer.unref === "function") {
|
|
604
|
+
timer.unref();
|
|
605
|
+
}
|
|
606
|
+
this.heartbeatTimer = timer;
|
|
607
|
+
}
|
|
608
|
+
stopHeartbeatTimer() {
|
|
609
|
+
if (this.heartbeatTimer !== undefined) {
|
|
610
|
+
clearInterval(this.heartbeatTimer);
|
|
611
|
+
this.heartbeatTimer = undefined;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
rejectPendingServe(error) {
|
|
615
|
+
if (this.pendingServe !== undefined) {
|
|
616
|
+
const pending = this.pendingServe;
|
|
617
|
+
this.pendingServe = undefined;
|
|
618
|
+
pending.reject(error);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Fan a value out to a set of subscribers, isolating each one's failures.
|
|
623
|
+
*
|
|
624
|
+
* A subscriber that throws must never break dispatch to the remaining
|
|
625
|
+
* subscribers, and — critically — must never propagate out of internal
|
|
626
|
+
* plumbing that emits events. `emitError` in particular runs inside error
|
|
627
|
+
* handling (e.g. a malformed inbound frame), so an `onError` subscriber that
|
|
628
|
+
* throws would otherwise escape that handler and can take the worker down.
|
|
629
|
+
* Containing throws here is the single canonical dispatch contract for every
|
|
630
|
+
* `emit*` below, so no individual emitter can reintroduce that failure mode.
|
|
631
|
+
*/
|
|
632
|
+
dispatch(listeners, value) {
|
|
633
|
+
for (const listener of listeners) {
|
|
634
|
+
try {
|
|
635
|
+
listener(value);
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
// A subscriber's failure is its own problem; contain it so event
|
|
639
|
+
// reporting can neither break sibling subscribers nor crash internal
|
|
640
|
+
// handling. We deliberately do not re-emit onError here — an onError
|
|
641
|
+
// subscriber that throws must not trigger unbounded re-entry.
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
emitServe(value) {
|
|
646
|
+
this.dispatch(this.serveListeners, value);
|
|
647
|
+
}
|
|
648
|
+
emitFrame(value) {
|
|
649
|
+
this.dispatch(this.frameListeners, value);
|
|
650
|
+
}
|
|
651
|
+
emitOpen() {
|
|
652
|
+
this.dispatch(this.openListeners, undefined);
|
|
653
|
+
}
|
|
654
|
+
emitClose(value) {
|
|
655
|
+
this.dispatch(this.closeListeners, value);
|
|
656
|
+
}
|
|
657
|
+
emitError(value) {
|
|
658
|
+
this.dispatch(this.errorListeners, value);
|
|
659
|
+
}
|
|
660
|
+
emitDrain() {
|
|
661
|
+
this.dispatch(this.drainListeners, undefined);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Connect a worker to the Nano agentic channel and return the client. The
|
|
666
|
+
* transport begins connecting immediately; because everything the worker
|
|
667
|
+
* produces is buffered, callers may `register`/`relay` straight away even before
|
|
668
|
+
* the channel is open (invariant #6).
|
|
669
|
+
*/
|
|
670
|
+
export function connectAgenticChannel(options) {
|
|
671
|
+
const client = new AgenticClient(options);
|
|
672
|
+
client.connect();
|
|
673
|
+
return client;
|
|
674
|
+
}
|
|
675
|
+
const utf8Encoder = new TextEncoder();
|
|
676
|
+
// UTF-8 byte length of `text`. Prefer Node's `Buffer.byteLength`, which computes
|
|
677
|
+
// the length without allocating, on the `relay()` hot path where the extra
|
|
678
|
+
// per-call `Uint8Array` allocation from `TextEncoder.encode()` would add
|
|
679
|
+
// measurable GC/CPU overhead during bulk output storms. Fall back to
|
|
680
|
+
// `TextEncoder` where `Buffer` is unavailable (non-Node hosts).
|
|
681
|
+
function byteLength(text) {
|
|
682
|
+
if (typeof Buffer !== "undefined") {
|
|
683
|
+
return Buffer.byteLength(text, "utf8");
|
|
684
|
+
}
|
|
685
|
+
return utf8Encoder.encode(text).length;
|
|
686
|
+
}
|
|
687
|
+
function isServePayload(payload) {
|
|
688
|
+
if (typeof payload !== "object" || payload === null) {
|
|
689
|
+
return false;
|
|
690
|
+
}
|
|
691
|
+
if (!("instance" in payload) || !("tokens" in payload)) {
|
|
692
|
+
return false;
|
|
693
|
+
}
|
|
694
|
+
const { instance, tokens } = payload;
|
|
695
|
+
return (typeof instance === "string" &&
|
|
696
|
+
Array.isArray(tokens) &&
|
|
697
|
+
tokens.every((token) => typeof token === "string"));
|
|
698
|
+
}
|
|
699
|
+
function asError(error, fallback) {
|
|
700
|
+
if (error instanceof Error) {
|
|
701
|
+
return error;
|
|
702
|
+
}
|
|
703
|
+
return new Error(fallback);
|
|
704
|
+
}
|
|
705
|
+
/** Re-exported so `isMessageFamily` is available to callers narrowing frames. */
|
|
706
|
+
export { isMessageFamily };
|