@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/index.d.ts
ADDED
|
@@ -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";
|
package/dist/protocol.js
ADDED
|
@@ -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
|
+
};
|
package/dist/testkit.js
ADDED
|
@@ -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
|
+
}
|