@nanobpm/agentic 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.
Files changed (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,137 @@
1
+ /**
2
+ * The connection registry with liveness.
3
+ *
4
+ * S1 tracks every authenticated connection and its last-seen time; a connection
5
+ * not seen within the liveness TTL ages out on {@link ConnectionRegistry.sweep}.
6
+ * Presence detail (instance / declared capability / host / family) is left blank
7
+ * by S1 and filled in by the S2 register/heartbeat family module through
8
+ * {@link ConnectionRegistry.setPresence} — S1 owns liveness, S2 owns presence.
9
+ */
10
+ import type { Capability } from "../protocol/index.ts";
11
+ import { systemClock } from "./clock.ts";
12
+ import type { Clock } from "./clock.ts";
13
+
14
+ /** Presence attributes a family module (S2) attaches to a live connection. */
15
+ export interface Presence {
16
+ /** The worker instance id declared on `register`. */
17
+ instance?: string;
18
+ /** The enrolment capability declared on `register` (never a routing token). */
19
+ capability?: Capability;
20
+ }
21
+
22
+ /** A tracked, authenticated connection. */
23
+ export interface RegisteredConnection {
24
+ /** The connection id (matches {@link ChannelConnection.id}). */
25
+ readonly id: string;
26
+ /** The authenticated principal (ADR 0028 identity) from the handshake. */
27
+ readonly identity: string;
28
+ /** When the connection was accepted, in epoch ms. */
29
+ readonly connectedAt: number;
30
+ /** Last time any inbound frame or keepalive pong was seen, in epoch ms. */
31
+ lastSeen: number;
32
+ /** Presence detail, populated by the S2 family module (blank until then). */
33
+ presence: Presence;
34
+ }
35
+
36
+ export interface ConnectionRegistryOptions {
37
+ /** Liveness TTL in ms; a connection unseen for longer ages out. Default 30000. */
38
+ ttlMs?: number;
39
+ /** Injectable clock (deterministic tests). Default {@link systemClock}. */
40
+ clock?: Clock;
41
+ }
42
+
43
+ const DEFAULT_TTL_MS = 30_000;
44
+
45
+ export class ConnectionRegistry {
46
+ readonly #byId = new Map<string, RegisteredConnection>();
47
+ readonly #ttlMs: number;
48
+ readonly #clock: Clock;
49
+
50
+ constructor(options: ConnectionRegistryOptions = {}) {
51
+ this.#ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
52
+ this.#clock = options.clock ?? systemClock;
53
+ }
54
+
55
+ /** The liveness TTL in ms. */
56
+ get ttlMs(): number {
57
+ return this.#ttlMs;
58
+ }
59
+
60
+ /** Number of tracked connections. */
61
+ get size(): number {
62
+ return this.#byId.size;
63
+ }
64
+
65
+ /**
66
+ * Start tracking a newly authenticated connection. `connectedAt` and
67
+ * `lastSeen` are set to now; presence starts blank.
68
+ */
69
+ add(id: string, identity: string): RegisteredConnection {
70
+ const now = this.#clock.now();
71
+ const entry: RegisteredConnection = {
72
+ id,
73
+ identity,
74
+ connectedAt: now,
75
+ lastSeen: now,
76
+ presence: {},
77
+ };
78
+ this.#byId.set(id, entry);
79
+ return entry;
80
+ }
81
+
82
+ /** Update a connection's liveness to now. No-op if unknown. */
83
+ touch(id: string): void {
84
+ const entry = this.#byId.get(id);
85
+ if (entry !== undefined) {
86
+ entry.lastSeen = this.#clock.now();
87
+ }
88
+ }
89
+
90
+ /** Merge presence detail onto a tracked connection (used by S2). No-op if unknown. */
91
+ setPresence(id: string, patch: Presence): void {
92
+ const entry = this.#byId.get(id);
93
+ if (entry !== undefined) {
94
+ entry.presence = { ...entry.presence, ...patch };
95
+ }
96
+ }
97
+
98
+ /** Look up a tracked connection. */
99
+ get(id: string): RegisteredConnection | undefined {
100
+ return this.#byId.get(id);
101
+ }
102
+
103
+ /** Whether a connection is tracked. */
104
+ has(id: string): boolean {
105
+ return this.#byId.has(id);
106
+ }
107
+
108
+ /** Stop tracking a connection, returning the removed entry if present. */
109
+ remove(id: string): RegisteredConnection | undefined {
110
+ const entry = this.#byId.get(id);
111
+ this.#byId.delete(id);
112
+ return entry;
113
+ }
114
+
115
+ /** All tracked connections, in insertion order. */
116
+ list(): RegisteredConnection[] {
117
+ return [...this.#byId.values()];
118
+ }
119
+
120
+ /**
121
+ * Age out every connection whose last-seen time is older than the TTL and
122
+ * return the removed entries, so the hub can close their sockets. `now`
123
+ * defaults to the clock; pass an explicit value for deterministic tests.
124
+ */
125
+ sweep(now: number = this.#clock.now()): RegisteredConnection[] {
126
+ const stale: RegisteredConnection[] = [];
127
+ for (const entry of this.#byId.values()) {
128
+ if (now - entry.lastSeen > this.#ttlMs) {
129
+ stale.push(entry);
130
+ }
131
+ }
132
+ for (const entry of stale) {
133
+ this.#byId.delete(entry.id);
134
+ }
135
+ return stale;
136
+ }
137
+ }
@@ -0,0 +1,234 @@
1
+ import assert from "node:assert/strict";
2
+ import { once } from "node:events";
3
+ import { createServer } from "node:http";
4
+ import { test } from "node:test";
5
+ import { encodeFrame } from "../protocol/index.ts";
6
+ import type { Frame } from "../protocol/index.ts";
7
+ import { WebSocket } from "ws";
8
+ import { AUTH_UNAUTHORIZED, sharedSecretAuthenticator } from "./auth.ts";
9
+ import { AgenticHub } from "./hub.ts";
10
+ import type { HubConnection } from "./hub.ts";
11
+ import { WebSocketChannelTransport } from "./ws-transport.ts";
12
+
13
+ const SECRET = "s3cret";
14
+
15
+ function registerBytes(instance: string): Uint8Array {
16
+ const frame: Frame = {
17
+ lane: "control",
18
+ family: "register",
19
+ seq: 1,
20
+ payload: { instance, capability: { cognition: "opus" } },
21
+ };
22
+ return encodeFrame(frame);
23
+ }
24
+
25
+ test("a client connects, authenticates, is tracked, and its frame routes to a family handler", async (t) => {
26
+ const transport = new WebSocketChannelTransport({ port: 0 });
27
+ const hub = new AgenticHub({
28
+ transport,
29
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
30
+ sweepIntervalMs: 0,
31
+ });
32
+ t.after(() => hub.close());
33
+
34
+ let resolveRouted: (conn: HubConnection) => void = () => {};
35
+ const routed = new Promise<HubConnection>((resolve) => {
36
+ resolveRouted = resolve;
37
+ });
38
+ hub.registerFamilyHandler("register", (_frame, ctx) => {
39
+ ctx.registry.setPresence(ctx.id, { instance: "w-1" });
40
+ resolveRouted(ctx);
41
+ });
42
+
43
+ await transport.ready();
44
+ const port = transport.address?.port;
45
+ assert.ok(port !== undefined && port > 0);
46
+
47
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}&capability=cap-1`);
48
+ client.on("error", () => {});
49
+ t.after(() => client.close());
50
+ await once(client, "open");
51
+ client.send(registerBytes("w-1"), { binary: true });
52
+
53
+ const ctx = await routed;
54
+ assert.ok(ctx.identity.length > 0); // resolved from the peer's remote address
55
+ assert.equal(hub.connectionCount, 1);
56
+ assert.equal(hub.registry.get(ctx.id)?.presence.instance, "w-1");
57
+ });
58
+
59
+ test("a client may present its capability credential via a mixed-case header", async (t) => {
60
+ const transport = new WebSocketChannelTransport({ port: 0 });
61
+ const hub = new AgenticHub({
62
+ transport,
63
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
64
+ sweepIntervalMs: 0,
65
+ });
66
+ t.after(() => hub.close());
67
+
68
+ let resolveRouted: (conn: HubConnection) => void = () => {};
69
+ const routed = new Promise<HubConnection>((resolve) => {
70
+ resolveRouted = resolve;
71
+ });
72
+ hub.registerFamilyHandler("register", (_frame, ctx) => resolveRouted(ctx));
73
+
74
+ await transport.ready();
75
+ const port = transport.address?.port;
76
+ assert.ok(port !== undefined && port > 0);
77
+
78
+ // Credential arrives only in a mixed-case header (no `capability` query param);
79
+ // header lookup must be case-insensitive for the handshake to grant.
80
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}`, {
81
+ headers: { "X-Capability-Credential": "cap-1" },
82
+ });
83
+ client.on("error", () => {});
84
+ t.after(() => client.close());
85
+ await once(client, "open");
86
+ client.send(registerBytes("w-1"), { binary: true });
87
+
88
+ const ctx = await routed;
89
+ // Reaching the family handler proves the required credential was found via the
90
+ // header (a case-sensitive lookup would have rejected with AUTH_FORBIDDEN).
91
+ assert.ok(ctx.identity.length > 0);
92
+ assert.equal(hub.connectionCount, 1);
93
+ });
94
+
95
+ test("an unauthenticated client is closed with the unauthorized code and never tracked", async (t) => {
96
+ const transport = new WebSocketChannelTransport({ port: 0 });
97
+ const hub = new AgenticHub({
98
+ transport,
99
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
100
+ sweepIntervalMs: 0,
101
+ });
102
+ t.after(() => hub.close());
103
+
104
+ await transport.ready();
105
+ const port = transport.address?.port;
106
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=wrong&capability=cap-1`);
107
+ client.on("error", () => {});
108
+
109
+ const [code] = await once(client, "close");
110
+ assert.equal(code, AUTH_UNAUTHORIZED);
111
+ assert.equal(hub.connectionCount, 0);
112
+ });
113
+
114
+ test("the hub replies on the same connection", async (t) => {
115
+ const transport = new WebSocketChannelTransport({ port: 0 });
116
+ const hub = new AgenticHub({
117
+ transport,
118
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
119
+ sweepIntervalMs: 0,
120
+ });
121
+ t.after(() => hub.close());
122
+
123
+ hub.registerFamilyHandler("register", (_frame, ctx) => {
124
+ ctx.send({ lane: "control", family: "serve", seq: 1, payload: { instance: "w-1", tokens: [] } });
125
+ });
126
+
127
+ await transport.ready();
128
+ const port = transport.address?.port;
129
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}&capability=cap-1`);
130
+ client.on("error", () => {});
131
+ t.after(() => client.close());
132
+ await once(client, "open");
133
+ client.send(registerBytes("w-1"), { binary: true });
134
+
135
+ const [data] = await once(client, "message");
136
+ assert.ok(data instanceof Buffer);
137
+ assert.ok(data.length > 0);
138
+ });
139
+
140
+ test("a text frame is rejected with 1003 and never reaches the codec", async (t) => {
141
+ // The protocol is binary-only. Pre-fix, `onMessage` called `toBytes` on the
142
+ // string payload of a text frame — which throws (no `.buffer`) and breaks the
143
+ // connection handler. The transport must instead close the socket with 1003.
144
+ const transport = new WebSocketChannelTransport({ port: 0 });
145
+ const hub = new AgenticHub({
146
+ transport,
147
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
148
+ sweepIntervalMs: 0,
149
+ });
150
+ t.after(() => hub.close());
151
+
152
+ let sawFrame = false;
153
+ hub.registerFamilyHandler("register", () => {
154
+ sawFrame = true;
155
+ });
156
+
157
+ await transport.ready();
158
+ const port = transport.address?.port;
159
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}&capability=cap-1`);
160
+ client.on("error", () => {});
161
+ t.after(() => client.close());
162
+ await once(client, "open");
163
+
164
+ // Send a TEXT frame (binary:false) — the transport must reject it.
165
+ client.send("not-a-binary-frame", { binary: false });
166
+
167
+ const [code] = await once(client, "close");
168
+ assert.equal(code, 1003);
169
+ assert.equal(sawFrame, false); // no text payload ever reached a family handler
170
+ });
171
+
172
+ test("ready() resolves in shared-port mode when attached to an app HTTP server", async (t) => {
173
+ // `ws` never emits `listening` on the WebSocketServer when it shares an
174
+ // existing HTTP server, so ready() must observe the HTTP server instead —
175
+ // otherwise it hangs forever. This test would time out without that fix.
176
+ const server = createServer();
177
+ const transport = new WebSocketChannelTransport({ server });
178
+ const hub = new AgenticHub({
179
+ transport,
180
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
181
+ sweepIntervalMs: 0,
182
+ });
183
+ t.after(() => hub.close());
184
+ t.after(() => new Promise<void>((resolve) => server.close(() => resolve())));
185
+
186
+ server.listen(0, "127.0.0.1");
187
+ await once(server, "listening");
188
+
189
+ // ready() must resolve now that the shared HTTP server is listening.
190
+ await transport.ready();
191
+
192
+ const addr = server.address();
193
+ assert.ok(addr !== null && typeof addr === "object");
194
+ const port = addr.port;
195
+
196
+ hub.registerFamilyHandler("register", (_frame, ctx) => {
197
+ ctx.send({ lane: "control", family: "serve", seq: 1, payload: { instance: "w-1", tokens: [] } });
198
+ });
199
+
200
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}&capability=cap-1`);
201
+ client.on("error", () => {});
202
+ t.after(() => client.close());
203
+ await once(client, "open");
204
+ client.send(registerBytes("w-1"), { binary: true });
205
+
206
+ const [data] = await once(client, "message");
207
+ assert.ok(data instanceof Buffer);
208
+ assert.ok(data.length > 0);
209
+ });
210
+ test("close() shuts a connected peer down with a clean close handshake, not an abnormal 1006", async (t) => {
211
+ // terminate() aborts without a handshake, so peers observe 1006 (abnormal) and
212
+ // lose any application close code/reason. A graceful close lets them observe a
213
+ // normal closure during shutdown.
214
+ const transport = new WebSocketChannelTransport({ port: 0 });
215
+ const hub = new AgenticHub({
216
+ transport,
217
+ authenticator: sharedSecretAuthenticator({ secret: SECRET }),
218
+ sweepIntervalMs: 0,
219
+ });
220
+
221
+ await transport.ready();
222
+ const port = transport.address?.port;
223
+ assert.ok(port !== undefined && port > 0);
224
+
225
+ const client = new WebSocket(`ws://127.0.0.1:${port}/agentic?token=${SECRET}&capability=cap-1`);
226
+ client.on("error", () => {});
227
+ t.after(() => client.close());
228
+ await once(client, "open");
229
+
230
+ const closed = once(client, "close");
231
+ await transport.close();
232
+ const [code] = await closed;
233
+ assert.notEqual(code, 1006); // not an abnormal closure
234
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The production WebSocket transport for the agentic hub.
3
+ *
4
+ * A thin adapter over the `ws` server that binds the app's OWN port (or attaches
5
+ * to an existing app HTTP server so it shares that port), captures the ADR 0028
6
+ * identity token + capability credential from the upgrade request, and hands the
7
+ * hub a transport-agnostic {@link ChannelConnection} per peer. The Camunda-8
8
+ * engine transport is a separate connection and is never touched here.
9
+ */
10
+ import { randomUUID } from "node:crypto";
11
+ import type { IncomingMessage, Server as HttpServer } from "node:http";
12
+ import { WebSocketServer } from "ws";
13
+ import type { RawData, WebSocket } from "ws";
14
+ import type { ChannelConnection, ChannelTransport, CloseCode, HandshakeRequest } from "./connection.ts";
15
+
16
+ export interface WebSocketChannelTransportOptions {
17
+ /** Port to bind (the app's own port). Use 0 for an ephemeral port. Ignored if `server` is set. */
18
+ port?: number;
19
+ /** Host/interface to bind. Ignored if `server` is set. */
20
+ host?: string;
21
+ /** Path the channel is served on. Default `/agentic`. */
22
+ path?: string;
23
+ /** Attach to an existing app HTTP server (share the app's port) instead of binding a new one. */
24
+ server?: HttpServer;
25
+ }
26
+
27
+ const DEFAULT_PATH = "/agentic";
28
+
29
+ /** RFC 6455 close code for a frame whose type the endpoint cannot accept. */
30
+ const CLOSE_UNSUPPORTED_DATA = 1003;
31
+
32
+ /**
33
+ * Grace period for the shutdown close handshake before a still-open peer is
34
+ * force-terminated, so `close()` stays bounded even if a peer stalls.
35
+ */
36
+ const GRACEFUL_CLOSE_TIMEOUT_MS = 250;
37
+
38
+ /** Normalise Node's header map to a flat, lower-cased string record. */
39
+ function flattenHeaders(req: IncomingMessage): Record<string, string> {
40
+ const headers: Record<string, string> = {};
41
+ for (const [key, value] of Object.entries(req.headers)) {
42
+ const name = key.toLowerCase();
43
+ if (typeof value === "string") {
44
+ headers[name] = value;
45
+ } else if (Array.isArray(value)) {
46
+ headers[name] = value.join(", ");
47
+ }
48
+ }
49
+ return headers;
50
+ }
51
+
52
+ /** Parse the upgrade request into the handshake the authenticator reads. */
53
+ function handshakeFrom(req: IncomingMessage): HandshakeRequest {
54
+ const url = new URL(req.url ?? "/", "http://localhost");
55
+ const query: Record<string, string> = {};
56
+ for (const [key, value] of url.searchParams) {
57
+ query[key] = value;
58
+ }
59
+ const headers = flattenHeaders(req);
60
+ return {
61
+ token: query.token,
62
+ credential: query.capability ?? headers["x-capability-credential"],
63
+ remote: req.socket.remoteAddress ?? undefined,
64
+ headers,
65
+ query,
66
+ };
67
+ }
68
+
69
+ /** Coerce `ws` RawData into a single contiguous byte view for the codec. */
70
+ function toBytes(data: RawData): Uint8Array {
71
+ if (Array.isArray(data)) {
72
+ return Buffer.concat(data);
73
+ }
74
+ if (data instanceof ArrayBuffer) {
75
+ return new Uint8Array(data);
76
+ }
77
+ return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
78
+ }
79
+
80
+ /** One `ws` socket wrapped as a {@link ChannelConnection}. */
81
+ class WsConnection implements ChannelConnection {
82
+ readonly id: string;
83
+ readonly handshake: HandshakeRequest;
84
+ readonly #ws: WebSocket;
85
+
86
+ constructor(id: string, ws: WebSocket, handshake: HandshakeRequest) {
87
+ this.id = id;
88
+ this.#ws = ws;
89
+ this.handshake = handshake;
90
+ }
91
+
92
+ send(bytes: Uint8Array): void {
93
+ if (this.#ws.readyState === this.#ws.OPEN) {
94
+ this.#ws.send(bytes, { binary: true });
95
+ }
96
+ }
97
+
98
+ close(code?: CloseCode, reason?: string): void {
99
+ if (code === undefined) {
100
+ this.#ws.close();
101
+ } else {
102
+ this.#ws.close(code, reason);
103
+ }
104
+ }
105
+
106
+ onMessage(listener: (bytes: Uint8Array) => void): void {
107
+ this.#ws.on("message", (data: RawData, isBinary: boolean) => {
108
+ // The channel protocol is binary-only. A text frame (`isBinary === false`,
109
+ // which `ws` may deliver as a `string` with no `.buffer`) is a protocol
110
+ // violation: reject it with 1003 rather than letting `toBytes` throw.
111
+ if (!isBinary) {
112
+ this.#ws.close(CLOSE_UNSUPPORTED_DATA, "binary frames only");
113
+ return;
114
+ }
115
+ listener(toBytes(data));
116
+ });
117
+ }
118
+
119
+ onClose(listener: (code?: CloseCode, reason?: string) => void): void {
120
+ this.#ws.on("close", (code: number, reason: Buffer) => listener(code, reason.toString()));
121
+ }
122
+
123
+ onPong(listener: () => void): void {
124
+ this.#ws.on("pong", () => listener());
125
+ }
126
+
127
+ onPing(listener: () => void): void {
128
+ this.#ws.on("ping", () => listener());
129
+ }
130
+
131
+ ping(): void {
132
+ if (this.#ws.readyState === this.#ws.OPEN) {
133
+ this.#ws.ping();
134
+ }
135
+ }
136
+ }
137
+
138
+ export class WebSocketChannelTransport implements ChannelTransport {
139
+ readonly #wss: WebSocketServer;
140
+ readonly #server: HttpServer | undefined;
141
+ #listener: ((conn: ChannelConnection) => void) | undefined;
142
+
143
+ constructor(options: WebSocketChannelTransportOptions = {}) {
144
+ const path = options.path ?? DEFAULT_PATH;
145
+ this.#server = options.server;
146
+ this.#wss = options.server
147
+ ? new WebSocketServer({ server: options.server, path })
148
+ : new WebSocketServer({ port: options.port ?? 0, host: options.host, path });
149
+
150
+ this.#wss.on("connection", (ws: WebSocket, req: IncomingMessage) => {
151
+ const conn = new WsConnection(randomUUID(), ws, handshakeFrom(req));
152
+ this.#listener?.(conn);
153
+ });
154
+ }
155
+
156
+ onConnection(listener: (conn: ChannelConnection) => void): void {
157
+ this.#listener = listener;
158
+ }
159
+
160
+ /** Resolve once the server is listening on its port. */
161
+ ready(): Promise<void> {
162
+ // In shared-port mode the app owns the HTTP server: `ws` never emits
163
+ // `listening` on the WebSocketServer, so wait on the HTTP server instead
164
+ // (or resolve at once if it is already listening) to avoid hanging forever.
165
+ const source = this.#server ?? this.#wss;
166
+ if (this.#server ? this.#server.listening : this.#wss.address() !== null) {
167
+ return Promise.resolve();
168
+ }
169
+ return new Promise((resolve, reject) => {
170
+ const onListening = () => {
171
+ source.off("error", onError);
172
+ resolve();
173
+ };
174
+ const onError = (err: Error) => {
175
+ source.off("listening", onListening);
176
+ reject(err);
177
+ };
178
+ source.once("listening", onListening);
179
+ source.once("error", onError);
180
+ });
181
+ }
182
+
183
+ get address(): { readonly port: number } | null {
184
+ const addr = this.#wss.address();
185
+ if (addr !== null && typeof addr === "object" && "port" in addr) {
186
+ return { port: addr.port };
187
+ }
188
+ return null;
189
+ }
190
+
191
+ close(): Promise<void> {
192
+ return new Promise((resolve, reject) => {
193
+ // Prefer a graceful close handshake so peers observe a normal closure
194
+ // (with any application close code/reason) instead of an abnormal 1006.
195
+ // Fall back to terminate() for any peer that doesn't complete the
196
+ // handshake promptly, keeping shutdown bounded and deterministic.
197
+ for (const client of this.#wss.clients) {
198
+ client.close();
199
+ }
200
+ const fallback = setTimeout(() => {
201
+ for (const client of this.#wss.clients) {
202
+ client.terminate();
203
+ }
204
+ }, GRACEFUL_CLOSE_TIMEOUT_MS);
205
+ fallback.unref?.();
206
+ this.#wss.close((err) => {
207
+ clearTimeout(fallback);
208
+ err ? reject(err) : resolve();
209
+ });
210
+ });
211
+ }
212
+ }