@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.
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `@nanobpm/urban-agent-client` — the worker-side client for the Nano agentic
3
+ * channel (ADR 0056, slice S9).
4
+ *
5
+ * A worker uses it on a connection SEPARATE from the C8 job protocol to:
6
+ * - `REGISTER` a capability and receive its resolved `SERVE` tokens,
7
+ * - `heartbeat` / `deregister` for presence & liveness,
8
+ * - produce `relay` bytes (live terminal / command-stream output), and
9
+ * - keep producing across a hub outage — everything is buffered in a bounded,
10
+ * QoS-aware {@link OutboundRing} and drained, control-before-bulk, on
11
+ * reconnect (hub-down tolerance, invariant #6).
12
+ *
13
+ * The wire contract itself is owned by `@nanobpm/agentic/protocol` (S0); this
14
+ * package imports and is held to it (including its shared conformance corpus),
15
+ * never redefining it.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { connectAgenticChannel } from "@nanobpm/urban-agent-client";
20
+ *
21
+ * const agent = connectAgenticChannel({ url: process.env.AGENTIC_CHANNEL_URL! });
22
+ * const { serve } = await agent.register({
23
+ * capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
24
+ * });
25
+ * agent.heartbeat();
26
+ * agent.relay("stdout", "hello\n");
27
+ * // …later…
28
+ * agent.deregister("done");
29
+ * ```
30
+ */
31
+ export { AgenticClient, connectAgenticChannel, isMessageFamily, } from "./client.ts";
32
+ export type { AgenticClientOptions, AgenticClientState, ReconnectOptions, RegisterResult, } from "./client.ts";
33
+ export { OutboundRing, compareFrameOrder } from "./ring.ts";
34
+ export type { EnqueueResult, OutboundRingOptions } from "./ring.ts";
35
+ export { websocketTransport, normaliseIncoming } from "./transport.ts";
36
+ export type { Transport, TransportCloseInfo, TransportFactory, TransportHooks, } from "./transport.ts";
37
+ export { MESSAGE_FAMILIES, QOS_LANES, encodeFrame, decodeFrame, parseToken, isValidToken, validatePayload, } from "./protocol.ts";
38
+ export type { Capability, Frame, MessageFamily, QosLane, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, } from "./protocol.ts";
package/dist/index.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `@nanobpm/urban-agent-client` — the worker-side client for the Nano agentic
3
+ * channel (ADR 0056, slice S9).
4
+ *
5
+ * A worker uses it on a connection SEPARATE from the C8 job protocol to:
6
+ * - `REGISTER` a capability and receive its resolved `SERVE` tokens,
7
+ * - `heartbeat` / `deregister` for presence & liveness,
8
+ * - produce `relay` bytes (live terminal / command-stream output), and
9
+ * - keep producing across a hub outage — everything is buffered in a bounded,
10
+ * QoS-aware {@link OutboundRing} and drained, control-before-bulk, on
11
+ * reconnect (hub-down tolerance, invariant #6).
12
+ *
13
+ * The wire contract itself is owned by `@nanobpm/agentic/protocol` (S0); this
14
+ * package imports and is held to it (including its shared conformance corpus),
15
+ * never redefining it.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * import { connectAgenticChannel } from "@nanobpm/urban-agent-client";
20
+ *
21
+ * const agent = connectAgenticChannel({ url: process.env.AGENTIC_CHANNEL_URL! });
22
+ * const { serve } = await agent.register({
23
+ * capability: { cognition: "high", weight: 3, family: "opus", host: "cli" },
24
+ * });
25
+ * agent.heartbeat();
26
+ * agent.relay("stdout", "hello\n");
27
+ * // …later…
28
+ * agent.deregister("done");
29
+ * ```
30
+ */
31
+ export { AgenticClient, connectAgenticChannel, isMessageFamily, } from "./client.js";
32
+ export { OutboundRing, compareFrameOrder } from "./ring.js";
33
+ export { websocketTransport, normaliseIncoming } from "./transport.js";
34
+ // Re-export the S0 contract surface a worker needs so consumers can build and
35
+ // inspect frames without a second dependency line. Sourced from
36
+ // @nanobpm/agentic/protocol (the single source of truth).
37
+ export { MESSAGE_FAMILIES, QOS_LANES, encodeFrame, decodeFrame, parseToken, isValidToken, validatePayload, } from "./protocol.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Single import point for the S0 wire contract.
3
+ *
4
+ * We import from the package's `./source` export (raw `.ts`) rather than the
5
+ * bare entry (`dist`) on purpose: the CI `conformance` job runs
6
+ * `npm run test:conformance` with **no build step**, so every module this client
7
+ * touches must be runnable straight from source under
8
+ * `node --experimental-strip-types`. The Urban stack ships source `.ts` and runs
9
+ * under strip-types (ADR 0052/0053), so a published consumer resolves the same
10
+ * source. Keeping the import in one module gives a single swap point.
11
+ *
12
+ * The contract itself is owned by `@nanobpm/agentic/protocol` (S0, #126) — this
13
+ * client imports it, it never redefines it.
14
+ */
15
+ export { MESSAGE_FAMILIES, isMessageFamily, QOS_LANES, isQosLane, compareFrameOrder, encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, MAX_SEQ, parseToken, isValidToken, validatePayload, bytesToHex, hexToBytes, } from "@nanobpm/agentic/source/protocol";
16
+ export type { MessageFamily, QosLane, Frame, FrameDecodeErrorCode, Capability, RegisterPayload, HeartbeatPayload, DeregisterPayload, ServePayload, RelayPayload, BlackboardPayload, DemandPayload, } from "@nanobpm/agentic/source/protocol";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Single import point for the S0 wire contract.
3
+ *
4
+ * We import from the package's `./source` export (raw `.ts`) rather than the
5
+ * bare entry (`dist`) on purpose: the CI `conformance` job runs
6
+ * `npm run test:conformance` with **no build step**, so every module this client
7
+ * touches must be runnable straight from source under
8
+ * `node --experimental-strip-types`. The Urban stack ships source `.ts` and runs
9
+ * under strip-types (ADR 0052/0053), so a published consumer resolves the same
10
+ * source. Keeping the import in one module gives a single swap point.
11
+ *
12
+ * The contract itself is owned by `@nanobpm/agentic/protocol` (S0, #126) — this
13
+ * client imports it, it never redefines it.
14
+ */
15
+ export { MESSAGE_FAMILIES, isMessageFamily, QOS_LANES, isQosLane, compareFrameOrder, encodeFrame, decodeFrame, FrameDecodeError, FrameEncodeError, MAX_SEQ, parseToken, isValidToken, validatePayload, bytesToHex, hexToBytes, } from "@nanobpm/agentic/source/protocol";
package/dist/ring.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { compareFrameOrder } from "./protocol.ts";
2
+ import type { Frame } from "./protocol.ts";
3
+ /**
4
+ * The worker-side outbound buffer: a bounded, QoS-aware ring that holds frames
5
+ * the worker has produced but not yet handed to the transport. It is the local
6
+ * buffer / flush-on-reconnect store that gives the client its hub-down
7
+ * tolerance (invariant #6) — the worker keeps producing while the hub is gone,
8
+ * and drains in order when the channel comes back.
9
+ *
10
+ * Two properties are load-bearing:
11
+ *
12
+ * 1. **QoS drain order (invariant #5).** Frames drain in strict lane priority —
13
+ * `control` before `interactive` before `bulk` — and FIFO within a lane.
14
+ * A bulk-output storm can never head-of-line-block a queued heartbeat or
15
+ * blackboard write: the heartbeat rides the control lane and drains first.
16
+ * The ordering is DERIVED from S0's canonical {@link compareFrameOrder}, not
17
+ * re-specified here (see {@link toArray}'s invariant test).
18
+ *
19
+ * 2. **Overflow sheds the single least important frame.** When the ring is
20
+ * full, the next enqueue drops the lowest-priority frame among the buffer
21
+ * AND the incoming frame: the oldest frame from the lowest-priority
22
+ * non-empty lane is evicted (bulk before interactive before control) —
23
+ * UNLESS the incoming frame is itself strictly lower priority than
24
+ * everything buffered, in which case the incoming frame is dropped and the
25
+ * buffer is left untouched. A higher-priority buffered frame (e.g. control)
26
+ * is therefore never evicted to admit a lower-priority one (e.g. bulk):
27
+ * bulk/interactive traffic can never displace buffered control frames. This
28
+ * bounds memory during a long outage without ever losing liveness/
29
+ * coordination traffic to a relay storm.
30
+ */
31
+ export interface OutboundRingOptions {
32
+ /** Maximum number of buffered frames. Must be a positive integer. */
33
+ readonly capacity: number;
34
+ }
35
+ export interface EnqueueResult {
36
+ /**
37
+ * The frame shed by this enqueue, or `null` if the ring had spare capacity.
38
+ * Usually the oldest frame from the lowest-priority non-empty lane, evicted to
39
+ * make room; but when the ring is full of strictly higher-priority frames the
40
+ * INCOMING frame is itself the least important — it is dropped rather than
41
+ * displace higher-priority traffic, and is returned here with the buffer left
42
+ * unchanged.
43
+ */
44
+ readonly evicted: Frame | null;
45
+ }
46
+ export declare class OutboundRing {
47
+ readonly capacity: number;
48
+ private readonly buckets;
49
+ private count;
50
+ constructor(options: OutboundRingOptions);
51
+ /** Number of frames currently buffered. */
52
+ get size(): number;
53
+ /** True when no frames are buffered. */
54
+ get isEmpty(): boolean;
55
+ /**
56
+ * Buffer a frame. When the ring is already at capacity, the least important
57
+ * frame among the buffer and this one is shed (see {@link EnqueueResult.evicted}
58
+ * and the class-level overflow contract).
59
+ */
60
+ enqueue(frame: Frame): EnqueueResult;
61
+ /**
62
+ * Buffer a frame at the FRONT of its lane (drains before frames already
63
+ * queued in that lane). Used to make a reconnect's re-`register` precede any
64
+ * backlog buffered during the outage. Overflow follows the same QoS-correct
65
+ * policy as {@link enqueue}: the incoming frame is dropped rather than
66
+ * displace strictly higher-priority buffered traffic.
67
+ */
68
+ enqueueFront(frame: Frame): EnqueueResult;
69
+ /**
70
+ * Shared admission path for {@link enqueue} / {@link enqueueFront}.
71
+ *
72
+ * Overflow is QoS-correct: when the ring is full we shed the single
73
+ * least-important frame among the buffer ∪ the incoming frame. If the incoming
74
+ * frame is strictly lower priority than every buffered frame, IT is the least
75
+ * important, so it is dropped (and returned as `evicted`) and the buffer is
76
+ * untouched — a bulk/interactive frame never evicts buffered control traffic.
77
+ * Otherwise the oldest frame from the lowest-priority non-empty lane is
78
+ * evicted to make room.
79
+ */
80
+ private admit;
81
+ /** The next frame to drain (highest priority, oldest within its lane) without removing it. */
82
+ peek(): Frame | undefined;
83
+ /** Remove and return the next frame to drain, or `undefined` when empty. */
84
+ dequeue(): Frame | undefined;
85
+ /** Non-destructive snapshot in drain order (priority lane, then FIFO). */
86
+ toArray(): Frame[];
87
+ /**
88
+ * Remove every buffered frame matching `predicate`, returning the removed
89
+ * frames. Used to coalesce superseded control frames (e.g. an in-flight
90
+ * REGISTER replaced by a newer one) so the drain never emits a stale duplicate.
91
+ */
92
+ remove(predicate: (frame: Frame) => boolean): Frame[];
93
+ /** Discard all buffered frames. */
94
+ clear(): void;
95
+ private lowestNonEmptyLane;
96
+ }
97
+ /**
98
+ * The canonical drain comparator, re-exported from S0 so callers that need to
99
+ * reason about ordering derive it from one source rather than re-implementing
100
+ * lane priority. {@link OutboundRing.toArray} is asserted equal to sorting by
101
+ * this comparator in the ring's tests.
102
+ */
103
+ export { compareFrameOrder };
package/dist/ring.js ADDED
@@ -0,0 +1,166 @@
1
+ import { QOS_LANES, compareFrameOrder } from "./protocol.js";
2
+ // Lanes in strict priority order (highest first). Kept as a local const so the
3
+ // bucket walk is O(number-of-lanes) and independent of insertion.
4
+ const LANES_BY_PRIORITY = [...QOS_LANES];
5
+ const LANES_BY_EVICTION = [...QOS_LANES].reverse();
6
+ // Priority rank per lane (0 = highest), derived from the canonical QOS_LANES
7
+ // order so lane comparison has a single source of truth. A LARGER rank means
8
+ // lower priority (evicted sooner).
9
+ const LANE_RANK = new Map(LANES_BY_PRIORITY.map((lane, index) => [lane, index]));
10
+ function laneRank(lane) {
11
+ return LANE_RANK.get(lane) ?? Number.POSITIVE_INFINITY;
12
+ }
13
+ export class OutboundRing {
14
+ capacity;
15
+ buckets;
16
+ count = 0;
17
+ constructor(options) {
18
+ if (!Number.isInteger(options.capacity) || options.capacity < 1) {
19
+ throw new RangeError(`OutboundRing capacity must be a positive integer, got ${options.capacity}`);
20
+ }
21
+ this.capacity = options.capacity;
22
+ this.buckets = new Map(LANES_BY_PRIORITY.map((lane) => [lane, []]));
23
+ }
24
+ /** Number of frames currently buffered. */
25
+ get size() {
26
+ return this.count;
27
+ }
28
+ /** True when no frames are buffered. */
29
+ get isEmpty() {
30
+ return this.count === 0;
31
+ }
32
+ /**
33
+ * Buffer a frame. When the ring is already at capacity, the least important
34
+ * frame among the buffer and this one is shed (see {@link EnqueueResult.evicted}
35
+ * and the class-level overflow contract).
36
+ */
37
+ enqueue(frame) {
38
+ return this.admit(frame, false);
39
+ }
40
+ /**
41
+ * Buffer a frame at the FRONT of its lane (drains before frames already
42
+ * queued in that lane). Used to make a reconnect's re-`register` precede any
43
+ * backlog buffered during the outage. Overflow follows the same QoS-correct
44
+ * policy as {@link enqueue}: the incoming frame is dropped rather than
45
+ * displace strictly higher-priority buffered traffic.
46
+ */
47
+ enqueueFront(frame) {
48
+ return this.admit(frame, true);
49
+ }
50
+ /**
51
+ * Shared admission path for {@link enqueue} / {@link enqueueFront}.
52
+ *
53
+ * Overflow is QoS-correct: when the ring is full we shed the single
54
+ * least-important frame among the buffer ∪ the incoming frame. If the incoming
55
+ * frame is strictly lower priority than every buffered frame, IT is the least
56
+ * important, so it is dropped (and returned as `evicted`) and the buffer is
57
+ * untouched — a bulk/interactive frame never evicts buffered control traffic.
58
+ * Otherwise the oldest frame from the lowest-priority non-empty lane is
59
+ * evicted to make room.
60
+ */
61
+ admit(frame, toFront) {
62
+ const bucket = this.buckets.get(frame.lane);
63
+ if (bucket === undefined) {
64
+ throw new RangeError(`unknown QoS lane: ${String(frame.lane)}`);
65
+ }
66
+ let evicted = null;
67
+ if (this.count >= this.capacity) {
68
+ const victimLane = this.lowestNonEmptyLane();
69
+ if (victimLane === undefined || laneRank(frame.lane) > laneRank(victimLane)) {
70
+ // The incoming frame is the least important thing in play — drop it
71
+ // rather than evict a higher-priority buffered frame.
72
+ return { evicted: frame };
73
+ }
74
+ evicted = this.buckets.get(victimLane)?.shift() ?? null;
75
+ if (evicted !== null) {
76
+ this.count -= 1;
77
+ }
78
+ }
79
+ if (toFront) {
80
+ bucket.unshift(frame);
81
+ }
82
+ else {
83
+ bucket.push(frame);
84
+ }
85
+ this.count += 1;
86
+ return { evicted };
87
+ }
88
+ /** The next frame to drain (highest priority, oldest within its lane) without removing it. */
89
+ peek() {
90
+ for (const lane of LANES_BY_PRIORITY) {
91
+ const bucket = this.buckets.get(lane);
92
+ if (bucket !== undefined && bucket.length > 0) {
93
+ return bucket[0];
94
+ }
95
+ }
96
+ return undefined;
97
+ }
98
+ /** Remove and return the next frame to drain, or `undefined` when empty. */
99
+ dequeue() {
100
+ for (const lane of LANES_BY_PRIORITY) {
101
+ const bucket = this.buckets.get(lane);
102
+ if (bucket !== undefined && bucket.length > 0) {
103
+ this.count -= 1;
104
+ return bucket.shift();
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+ /** Non-destructive snapshot in drain order (priority lane, then FIFO). */
110
+ toArray() {
111
+ const out = [];
112
+ for (const lane of LANES_BY_PRIORITY) {
113
+ const bucket = this.buckets.get(lane);
114
+ if (bucket !== undefined) {
115
+ out.push(...bucket);
116
+ }
117
+ }
118
+ return out;
119
+ }
120
+ /**
121
+ * Remove every buffered frame matching `predicate`, returning the removed
122
+ * frames. Used to coalesce superseded control frames (e.g. an in-flight
123
+ * REGISTER replaced by a newer one) so the drain never emits a stale duplicate.
124
+ */
125
+ remove(predicate) {
126
+ const removed = [];
127
+ for (const lane of LANES_BY_PRIORITY) {
128
+ const bucket = this.buckets.get(lane);
129
+ if (bucket === undefined) {
130
+ continue;
131
+ }
132
+ for (let i = bucket.length - 1; i >= 0; i--) {
133
+ const frame = bucket[i];
134
+ if (frame !== undefined && predicate(frame)) {
135
+ removed.push(frame);
136
+ bucket.splice(i, 1);
137
+ this.count -= 1;
138
+ }
139
+ }
140
+ }
141
+ return removed;
142
+ }
143
+ /** Discard all buffered frames. */
144
+ clear() {
145
+ for (const bucket of this.buckets.values()) {
146
+ bucket.length = 0;
147
+ }
148
+ this.count = 0;
149
+ }
150
+ lowestNonEmptyLane() {
151
+ for (const lane of LANES_BY_EVICTION) {
152
+ const bucket = this.buckets.get(lane);
153
+ if (bucket !== undefined && bucket.length > 0) {
154
+ return lane;
155
+ }
156
+ }
157
+ return undefined;
158
+ }
159
+ }
160
+ /**
161
+ * The canonical drain comparator, re-exported from S0 so callers that need to
162
+ * reason about ordering derive it from one source rather than re-implementing
163
+ * lane priority. {@link OutboundRing.toArray} is asserted equal to sorting by
164
+ * this comparator in the ring's tests.
165
+ */
166
+ export { compareFrameOrder };
@@ -0,0 +1,53 @@
1
+ import type { Frame } from "./protocol.ts";
2
+ import type { Transport, TransportFactory, TransportHooks } from "./transport.ts";
3
+ /**
4
+ * An in-memory transport double for exercising the client with no live hub. It
5
+ * records every frame the client sends, lets a test drive the connection
6
+ * lifecycle deterministically (open / deliver / drop / reopen), and can be
7
+ * pointed at by a {@link TransportFactory} so reconnects rebuild it.
8
+ */
9
+ export declare class FakeTransport implements Transport {
10
+ readonly sent: Uint8Array[];
11
+ open: boolean;
12
+ /**
13
+ * When true, {@link send} throws synchronously WITHOUT firing `onClose` — the
14
+ * minimum a transport is required to do per its contract. Models a channel
15
+ * that signals disconnect solely by throwing, which must still drive the
16
+ * client's reconnect path.
17
+ */
18
+ throwOnSend: boolean;
19
+ /**
20
+ * When true, {@link close} does NOT fire `onClose` — models a transport whose
21
+ * close is asynchronous (a real WebSocket fires its close event on a later
22
+ * tick) or one that never surfaces its own close. The client must still notify
23
+ * its own `onClose` subscribers on a caller-initiated shutdown.
24
+ */
25
+ silentClose: boolean;
26
+ private closedLocal;
27
+ private readonly hooks;
28
+ constructor(hooks: TransportHooks);
29
+ /** Frames decoded from what the client sent, in send order. */
30
+ get sentFrames(): Frame[];
31
+ send(bytes: Uint8Array): void;
32
+ close(): void;
33
+ /** Simulate the channel coming up. */
34
+ fireOpen(): void;
35
+ /** Simulate a remote drop (hub outage / network loss). */
36
+ drop(info?: {
37
+ code?: number;
38
+ reason?: string;
39
+ }): void;
40
+ /** Deliver a raw inbound frame to the client. */
41
+ deliver(bytes: Uint8Array): void;
42
+ wasClosedLocally(): boolean;
43
+ }
44
+ /**
45
+ * A {@link TransportFactory} that hands out {@link FakeTransport}s and records
46
+ * each one it builds, so a test can drive reconnects (the client calls the
47
+ * factory again on every reconnect attempt).
48
+ */
49
+ export declare function fakeTransportFactory(): {
50
+ factory: TransportFactory;
51
+ transports: FakeTransport[];
52
+ last(): FakeTransport;
53
+ };
@@ -0,0 +1,95 @@
1
+ import { decodeFrame } from "./protocol.js";
2
+ /**
3
+ * An in-memory transport double for exercising the client with no live hub. It
4
+ * records every frame the client sends, lets a test drive the connection
5
+ * lifecycle deterministically (open / deliver / drop / reopen), and can be
6
+ * pointed at by a {@link TransportFactory} so reconnects rebuild it.
7
+ */
8
+ export class FakeTransport {
9
+ sent = [];
10
+ open = false;
11
+ /**
12
+ * When true, {@link send} throws synchronously WITHOUT firing `onClose` — the
13
+ * minimum a transport is required to do per its contract. Models a channel
14
+ * that signals disconnect solely by throwing, which must still drive the
15
+ * client's reconnect path.
16
+ */
17
+ throwOnSend = false;
18
+ /**
19
+ * When true, {@link close} does NOT fire `onClose` — models a transport whose
20
+ * close is asynchronous (a real WebSocket fires its close event on a later
21
+ * tick) or one that never surfaces its own close. The client must still notify
22
+ * its own `onClose` subscribers on a caller-initiated shutdown.
23
+ */
24
+ silentClose = false;
25
+ closedLocal = false;
26
+ hooks;
27
+ constructor(hooks) {
28
+ this.hooks = hooks;
29
+ }
30
+ /** Frames decoded from what the client sent, in send order. */
31
+ get sentFrames() {
32
+ return this.sent.map((bytes) => decodeFrame(bytes));
33
+ }
34
+ send(bytes) {
35
+ if (this.throwOnSend) {
36
+ throw new Error("fake transport send failure (no onClose)");
37
+ }
38
+ if (!this.open) {
39
+ throw new Error("fake transport not open");
40
+ }
41
+ this.sent.push(bytes);
42
+ }
43
+ close() {
44
+ this.closedLocal = true;
45
+ if (this.open) {
46
+ this.open = false;
47
+ if (!this.silentClose) {
48
+ this.hooks.onClose({ local: true });
49
+ }
50
+ }
51
+ }
52
+ /** Simulate the channel coming up. */
53
+ fireOpen() {
54
+ this.open = true;
55
+ this.hooks.onOpen();
56
+ }
57
+ /** Simulate a remote drop (hub outage / network loss). */
58
+ drop(info = {}) {
59
+ if (this.open) {
60
+ this.open = false;
61
+ this.hooks.onClose({ ...info, local: false });
62
+ }
63
+ }
64
+ /** Deliver a raw inbound frame to the client. */
65
+ deliver(bytes) {
66
+ this.hooks.onFrame(bytes);
67
+ }
68
+ wasClosedLocally() {
69
+ return this.closedLocal;
70
+ }
71
+ }
72
+ /**
73
+ * A {@link TransportFactory} that hands out {@link FakeTransport}s and records
74
+ * each one it builds, so a test can drive reconnects (the client calls the
75
+ * factory again on every reconnect attempt).
76
+ */
77
+ export function fakeTransportFactory() {
78
+ const transports = [];
79
+ const factory = (_url, hooks) => {
80
+ const transport = new FakeTransport(hooks);
81
+ transports.push(transport);
82
+ return transport;
83
+ };
84
+ return {
85
+ factory,
86
+ transports,
87
+ last: () => {
88
+ const transport = transports[transports.length - 1];
89
+ if (transport === undefined) {
90
+ throw new Error("fakeTransportFactory().last() called before any transport was created");
91
+ }
92
+ return transport;
93
+ },
94
+ };
95
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The transport seam. The client is written against a minimal, injectable
3
+ * transport so it can be exercised without a live hub (hub-down tolerance is a
4
+ * first-class, unit-tested property) and so a host can supply any framing
5
+ * (a real WebSocket, an in-process pipe, a test double).
6
+ *
7
+ * A transport carries WHOLE encoded frames as binary messages — one
8
+ * {@link Uint8Array} in, one {@link Uint8Array} out. It never interprets the
9
+ * bytes; the client owns encode/decode via the S0 codec.
10
+ */
11
+ export interface TransportHooks {
12
+ /** The channel is open and ready to send. */
13
+ onOpen(): void;
14
+ /** One binary frame arrived from the hub. */
15
+ onFrame(bytes: Uint8Array): void;
16
+ /** The channel closed (cleanly or otherwise). */
17
+ onClose(info: TransportCloseInfo): void;
18
+ /** A transport-level error occurred. Non-fatal on its own; a close follows. */
19
+ onError(error: Error): void;
20
+ }
21
+ export interface TransportCloseInfo {
22
+ readonly code?: number;
23
+ readonly reason?: string;
24
+ /** True when the close was requested by the client (a deregister / shutdown). */
25
+ readonly local?: boolean;
26
+ }
27
+ export interface Transport {
28
+ /**
29
+ * Send one encoded frame. Implementations MUST throw synchronously if the
30
+ * channel is not open, so the client can re-buffer the frame and stop
31
+ * draining until the next reconnect.
32
+ */
33
+ send(bytes: Uint8Array): void;
34
+ /** Close the channel. Idempotent. */
35
+ close(code?: number, reason?: string): void;
36
+ }
37
+ /**
38
+ * Builds a transport for a URL, wiring the hub's events into `hooks`. Called
39
+ * once per connection attempt (a reconnect calls it again).
40
+ */
41
+ export type TransportFactory = (url: string, hooks: TransportHooks) => Transport;
42
+ /**
43
+ * The default transport: a binary WebSocket over the app's bound port. Each
44
+ * agentic frame is one WebSocket binary message. Incoming messages are
45
+ * normalised to {@link Uint8Array} regardless of whether the runtime delivers
46
+ * an `ArrayBuffer`, a typed array, or a Node `Buffer`.
47
+ *
48
+ * Uses the global `WebSocket` (Node >= 22 provides one). A host on an older
49
+ * runtime, or one that wants a different framing, passes its own
50
+ * {@link TransportFactory} to `connectAgenticChannel`.
51
+ */
52
+ export declare const websocketTransport: TransportFactory;
53
+ /**
54
+ * Route one inbound transport message: a binary frame goes to `onFrame`; a
55
+ * non-binary message (a protocol violation under the "one binary frame per
56
+ * message" contract) is surfaced via `onError` rather than silently dropped, so
57
+ * the client never hangs seeing no frames yet no error. This is the single
58
+ * source of truth for the transport's message-dispatch branch.
59
+ */
60
+ export declare function deliverIncoming(data: unknown, hooks: Pick<TransportHooks, "onFrame" | "onError">): void;
61
+ /** Normalise a WebSocket message payload to bytes, or `undefined` if it is text. */
62
+ export declare function normaliseIncoming(data: unknown): Uint8Array | undefined;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The transport seam. The client is written against a minimal, injectable
3
+ * transport so it can be exercised without a live hub (hub-down tolerance is a
4
+ * first-class, unit-tested property) and so a host can supply any framing
5
+ * (a real WebSocket, an in-process pipe, a test double).
6
+ *
7
+ * A transport carries WHOLE encoded frames as binary messages — one
8
+ * {@link Uint8Array} in, one {@link Uint8Array} out. It never interprets the
9
+ * bytes; the client owns encode/decode via the S0 codec.
10
+ */
11
+ /**
12
+ * The default transport: a binary WebSocket over the app's bound port. Each
13
+ * agentic frame is one WebSocket binary message. Incoming messages are
14
+ * normalised to {@link Uint8Array} regardless of whether the runtime delivers
15
+ * an `ArrayBuffer`, a typed array, or a Node `Buffer`.
16
+ *
17
+ * Uses the global `WebSocket` (Node >= 22 provides one). A host on an older
18
+ * runtime, or one that wants a different framing, passes its own
19
+ * {@link TransportFactory} to `connectAgenticChannel`.
20
+ */
21
+ export const websocketTransport = (url, hooks) => {
22
+ if (typeof WebSocket !== "function") {
23
+ throw new Error("No global WebSocket is available. Run on Node >= 22.6 (which provides a global WebSocket) " +
24
+ "or pass a custom `transport` factory to connectAgenticChannel().");
25
+ }
26
+ const socket = new WebSocket(url);
27
+ socket.binaryType = "arraybuffer";
28
+ let localClose = false;
29
+ socket.addEventListener("open", () => hooks.onOpen());
30
+ socket.addEventListener("message", (event) => deliverIncoming(event.data, hooks));
31
+ socket.addEventListener("error", (event) => {
32
+ // Preserve the underlying error event as the cause so callers (tests,
33
+ // operational logs) can inspect the transport-level failure rather than
34
+ // only seeing a generic message.
35
+ hooks.onError(new Error("agentic channel transport error", { cause: event }));
36
+ });
37
+ socket.addEventListener("close", (event) => {
38
+ hooks.onClose({ code: event.code, reason: event.reason, local: localClose });
39
+ });
40
+ return {
41
+ send(bytes) {
42
+ if (socket.readyState !== WebSocket.OPEN) {
43
+ throw new Error("agentic channel is not open");
44
+ }
45
+ socket.send(bytes);
46
+ },
47
+ close(code, reason) {
48
+ localClose = true;
49
+ socket.close(code, reason);
50
+ },
51
+ };
52
+ };
53
+ /**
54
+ * Route one inbound transport message: a binary frame goes to `onFrame`; a
55
+ * non-binary message (a protocol violation under the "one binary frame per
56
+ * message" contract) is surfaced via `onError` rather than silently dropped, so
57
+ * the client never hangs seeing no frames yet no error. This is the single
58
+ * source of truth for the transport's message-dispatch branch.
59
+ */
60
+ export function deliverIncoming(data, hooks) {
61
+ const bytes = normaliseIncoming(data);
62
+ if (bytes !== undefined) {
63
+ hooks.onFrame(bytes);
64
+ return;
65
+ }
66
+ hooks.onError(new Error("agentic channel received a non-binary message; expected one binary frame per message"));
67
+ }
68
+ /** Normalise a WebSocket message payload to bytes, or `undefined` if it is text. */
69
+ export function normaliseIncoming(data) {
70
+ if (data instanceof Uint8Array) {
71
+ return data;
72
+ }
73
+ if (data instanceof ArrayBuffer) {
74
+ return new Uint8Array(data);
75
+ }
76
+ if (ArrayBuffer.isView(data)) {
77
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
78
+ }
79
+ return undefined;
80
+ }