@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/src/testkit.ts ADDED
@@ -0,0 +1,112 @@
1
+ import { decodeFrame } from "./protocol.ts";
2
+ import type { Frame } from "./protocol.ts";
3
+ import type { Transport, TransportFactory, TransportHooks } from "./transport.ts";
4
+
5
+ /**
6
+ * An in-memory transport double for exercising the client with no live hub. It
7
+ * records every frame the client sends, lets a test drive the connection
8
+ * lifecycle deterministically (open / deliver / drop / reopen), and can be
9
+ * pointed at by a {@link TransportFactory} so reconnects rebuild it.
10
+ */
11
+ export class FakeTransport implements Transport {
12
+ readonly sent: Uint8Array[] = [];
13
+ open = false;
14
+ /**
15
+ * When true, {@link send} throws synchronously WITHOUT firing `onClose` — the
16
+ * minimum a transport is required to do per its contract. Models a channel
17
+ * that signals disconnect solely by throwing, which must still drive the
18
+ * client's reconnect path.
19
+ */
20
+ throwOnSend = false;
21
+ /**
22
+ * When true, {@link close} does NOT fire `onClose` — models a transport whose
23
+ * close is asynchronous (a real WebSocket fires its close event on a later
24
+ * tick) or one that never surfaces its own close. The client must still notify
25
+ * its own `onClose` subscribers on a caller-initiated shutdown.
26
+ */
27
+ silentClose = false;
28
+ private closedLocal = false;
29
+
30
+ private readonly hooks: TransportHooks;
31
+
32
+ constructor(hooks: TransportHooks) {
33
+ this.hooks = hooks;
34
+ }
35
+
36
+ /** Frames decoded from what the client sent, in send order. */
37
+ get sentFrames(): Frame[] {
38
+ return this.sent.map((bytes) => decodeFrame(bytes));
39
+ }
40
+
41
+ send(bytes: Uint8Array): void {
42
+ if (this.throwOnSend) {
43
+ throw new Error("fake transport send failure (no onClose)");
44
+ }
45
+ if (!this.open) {
46
+ throw new Error("fake transport not open");
47
+ }
48
+ this.sent.push(bytes);
49
+ }
50
+
51
+ close(): void {
52
+ this.closedLocal = true;
53
+ if (this.open) {
54
+ this.open = false;
55
+ if (!this.silentClose) {
56
+ this.hooks.onClose({ local: true });
57
+ }
58
+ }
59
+ }
60
+
61
+ /** Simulate the channel coming up. */
62
+ fireOpen(): void {
63
+ this.open = true;
64
+ this.hooks.onOpen();
65
+ }
66
+
67
+ /** Simulate a remote drop (hub outage / network loss). */
68
+ drop(info: { code?: number; reason?: string } = {}): void {
69
+ if (this.open) {
70
+ this.open = false;
71
+ this.hooks.onClose({ ...info, local: false });
72
+ }
73
+ }
74
+
75
+ /** Deliver a raw inbound frame to the client. */
76
+ deliver(bytes: Uint8Array): void {
77
+ this.hooks.onFrame(bytes);
78
+ }
79
+
80
+ wasClosedLocally(): boolean {
81
+ return this.closedLocal;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * A {@link TransportFactory} that hands out {@link FakeTransport}s and records
87
+ * each one it builds, so a test can drive reconnects (the client calls the
88
+ * factory again on every reconnect attempt).
89
+ */
90
+ export function fakeTransportFactory(): {
91
+ factory: TransportFactory;
92
+ transports: FakeTransport[];
93
+ last(): FakeTransport;
94
+ } {
95
+ const transports: FakeTransport[] = [];
96
+ const factory: TransportFactory = (_url, hooks) => {
97
+ const transport = new FakeTransport(hooks);
98
+ transports.push(transport);
99
+ return transport;
100
+ };
101
+ return {
102
+ factory,
103
+ transports,
104
+ last: () => {
105
+ const transport = transports[transports.length - 1];
106
+ if (transport === undefined) {
107
+ throw new Error("fakeTransportFactory().last() called before any transport was created");
108
+ }
109
+ return transport;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,77 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { deliverIncoming, normaliseIncoming, websocketTransport } from "./transport.ts";
4
+ import type { TransportHooks } from "./transport.ts";
5
+
6
+ test("normaliseIncoming accepts every binary shape a WebSocket may deliver", () => {
7
+ const bytes = new Uint8Array([1, 2, 3, 4]);
8
+
9
+ assert.deepEqual([...(normaliseIncoming(bytes) ?? [])], [1, 2, 3, 4]);
10
+ assert.deepEqual([...(normaliseIncoming(bytes.buffer) ?? [])], [1, 2, 3, 4]);
11
+
12
+ const view = new DataView(bytes.buffer, 1, 2);
13
+ assert.deepEqual([...(normaliseIncoming(view) ?? [])], [2, 3]);
14
+
15
+ const buffer = Buffer.from([9, 8, 7]);
16
+ assert.deepEqual([...(normaliseIncoming(buffer) ?? [])], [9, 8, 7]);
17
+ });
18
+
19
+ test("normaliseIncoming returns undefined for a text (non-binary) message", () => {
20
+ assert.equal(normaliseIncoming("hello"), undefined);
21
+ assert.equal(normaliseIncoming(42), undefined);
22
+ assert.equal(normaliseIncoming(null), undefined);
23
+ });
24
+
25
+ test("websocketTransport builds a Transport and refuses to send before the socket is open", () => {
26
+ const hooks: TransportHooks = {
27
+ onOpen: () => {},
28
+ onFrame: () => {},
29
+ onClose: () => {},
30
+ onError: () => {},
31
+ };
32
+
33
+ // Stub the global WebSocket with a socket that stays in CONNECTING, so the
34
+ // test is fully deterministic and performs no real network I/O (no dependency
35
+ // on the host stack / port timing). A send while CONNECTING must throw rather
36
+ // than silently drop the frame — that "throw, don't drop" contract is exactly
37
+ // what lets the client re-buffer instead of losing data.
38
+ class ConnectingWebSocket {
39
+ static readonly CONNECTING = 0;
40
+ static readonly OPEN = 1;
41
+ readonly readyState = ConnectingWebSocket.CONNECTING;
42
+ binaryType = "blob";
43
+ addEventListener(): void {}
44
+ send(): void {}
45
+ close(): void {}
46
+ }
47
+ const original = Reflect.get(globalThis, "WebSocket");
48
+ Reflect.set(globalThis, "WebSocket", ConnectingWebSocket);
49
+ try {
50
+ const transport = websocketTransport("ws://stub/agentic", hooks);
51
+ assert.equal(typeof transport.send, "function");
52
+ assert.equal(typeof transport.close, "function");
53
+ assert.throws(() => transport.send(new Uint8Array([1])), /not open/);
54
+ transport.close();
55
+ } finally {
56
+ Reflect.set(globalThis, "WebSocket", original);
57
+ }
58
+ });
59
+
60
+ test("websocketTransport surfaces a non-binary inbound message via onError instead of dropping it", () => {
61
+ const frames: Uint8Array[] = [];
62
+ const errors: Error[] = [];
63
+ const hooks = {
64
+ onFrame: (bytes: Uint8Array) => frames.push(bytes),
65
+ onError: (error: Error) => errors.push(error),
66
+ };
67
+
68
+ // A binary frame is delivered to onFrame.
69
+ deliverIncoming(new Uint8Array([1, 2, 3]), hooks);
70
+ // A text frame is a protocol violation — surfaced via onError, not dropped.
71
+ deliverIncoming("not-binary", hooks);
72
+
73
+ assert.equal(frames.length, 1, "the binary frame reached onFrame");
74
+ assert.deepEqual([...(frames[0] ?? [])], [1, 2, 3]);
75
+ assert.equal(errors.length, 1, "the non-binary frame was surfaced via onError");
76
+ assert.match(errors[0]?.message ?? "", /non-binary message/);
77
+ });
@@ -0,0 +1,123 @@
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
+ export interface TransportHooks {
13
+ /** The channel is open and ready to send. */
14
+ onOpen(): void;
15
+ /** One binary frame arrived from the hub. */
16
+ onFrame(bytes: Uint8Array): void;
17
+ /** The channel closed (cleanly or otherwise). */
18
+ onClose(info: TransportCloseInfo): void;
19
+ /** A transport-level error occurred. Non-fatal on its own; a close follows. */
20
+ onError(error: Error): void;
21
+ }
22
+
23
+ export interface TransportCloseInfo {
24
+ readonly code?: number;
25
+ readonly reason?: string;
26
+ /** True when the close was requested by the client (a deregister / shutdown). */
27
+ readonly local?: boolean;
28
+ }
29
+
30
+ export interface Transport {
31
+ /**
32
+ * Send one encoded frame. Implementations MUST throw synchronously if the
33
+ * channel is not open, so the client can re-buffer the frame and stop
34
+ * draining until the next reconnect.
35
+ */
36
+ send(bytes: Uint8Array): void;
37
+ /** Close the channel. Idempotent. */
38
+ close(code?: number, reason?: string): void;
39
+ }
40
+
41
+ /**
42
+ * Builds a transport for a URL, wiring the hub's events into `hooks`. Called
43
+ * once per connection attempt (a reconnect calls it again).
44
+ */
45
+ export type TransportFactory = (url: string, hooks: TransportHooks) => Transport;
46
+
47
+ /**
48
+ * The default transport: a binary WebSocket over the app's bound port. Each
49
+ * agentic frame is one WebSocket binary message. Incoming messages are
50
+ * normalised to {@link Uint8Array} regardless of whether the runtime delivers
51
+ * an `ArrayBuffer`, a typed array, or a Node `Buffer`.
52
+ *
53
+ * Uses the global `WebSocket` (Node >= 22 provides one). A host on an older
54
+ * runtime, or one that wants a different framing, passes its own
55
+ * {@link TransportFactory} to `connectAgenticChannel`.
56
+ */
57
+ export const websocketTransport: TransportFactory = (url, hooks) => {
58
+ if (typeof WebSocket !== "function") {
59
+ throw new Error(
60
+ "No global WebSocket is available. Run on Node >= 22.6 (which provides a global WebSocket) " +
61
+ "or pass a custom `transport` factory to connectAgenticChannel().",
62
+ );
63
+ }
64
+
65
+ const socket = new WebSocket(url);
66
+ socket.binaryType = "arraybuffer";
67
+ let localClose = false;
68
+
69
+ socket.addEventListener("open", () => hooks.onOpen());
70
+ socket.addEventListener("message", (event) => deliverIncoming(event.data, hooks));
71
+ socket.addEventListener("error", (event) => {
72
+ // Preserve the underlying error event as the cause so callers (tests,
73
+ // operational logs) can inspect the transport-level failure rather than
74
+ // only seeing a generic message.
75
+ hooks.onError(new Error("agentic channel transport error", { cause: event }));
76
+ });
77
+ socket.addEventListener("close", (event) => {
78
+ hooks.onClose({ code: event.code, reason: event.reason, local: localClose });
79
+ });
80
+
81
+ return {
82
+ send(bytes: Uint8Array): void {
83
+ if (socket.readyState !== WebSocket.OPEN) {
84
+ throw new Error("agentic channel is not open");
85
+ }
86
+ socket.send(bytes);
87
+ },
88
+ close(code?: number, reason?: string): void {
89
+ localClose = true;
90
+ socket.close(code, reason);
91
+ },
92
+ };
93
+ };
94
+
95
+ /**
96
+ * Route one inbound transport message: a binary frame goes to `onFrame`; a
97
+ * non-binary message (a protocol violation under the "one binary frame per
98
+ * message" contract) is surfaced via `onError` rather than silently dropped, so
99
+ * the client never hangs seeing no frames yet no error. This is the single
100
+ * source of truth for the transport's message-dispatch branch.
101
+ */
102
+ export function deliverIncoming(data: unknown, hooks: Pick<TransportHooks, "onFrame" | "onError">): void {
103
+ const bytes = normaliseIncoming(data);
104
+ if (bytes !== undefined) {
105
+ hooks.onFrame(bytes);
106
+ return;
107
+ }
108
+ hooks.onError(new Error("agentic channel received a non-binary message; expected one binary frame per message"));
109
+ }
110
+
111
+ /** Normalise a WebSocket message payload to bytes, or `undefined` if it is text. */
112
+ export function normaliseIncoming(data: unknown): Uint8Array | undefined {
113
+ if (data instanceof Uint8Array) {
114
+ return data;
115
+ }
116
+ if (data instanceof ArrayBuffer) {
117
+ return new Uint8Array(data);
118
+ }
119
+ if (ArrayBuffer.isView(data)) {
120
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
121
+ }
122
+ return undefined;
123
+ }