@schlessera/brain-ui-server 0.15.0 → 0.17.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 (160) hide show
  1. package/README.md +4 -0
  2. package/dist/agent/backend.d.ts +31 -10
  3. package/dist/agent/backend.d.ts.map +1 -1
  4. package/dist/agent/backend.js +92 -28
  5. package/dist/agent/backend.js.map +1 -1
  6. package/dist/app.d.ts +9 -0
  7. package/dist/app.d.ts.map +1 -1
  8. package/dist/app.js +80 -18
  9. package/dist/app.js.map +1 -1
  10. package/dist/config/env-core.d.ts +73 -0
  11. package/dist/config/env-core.d.ts.map +1 -0
  12. package/dist/config/env-core.js +62 -0
  13. package/dist/config/env-core.js.map +1 -0
  14. package/dist/config/env.d.ts +27 -5
  15. package/dist/config/env.d.ts.map +1 -1
  16. package/dist/config/env.js +90 -10
  17. package/dist/config/env.js.map +1 -1
  18. package/dist/cron/scheduler.d.ts +20 -0
  19. package/dist/cron/scheduler.d.ts.map +1 -1
  20. package/dist/cron/scheduler.js +43 -8
  21. package/dist/cron/scheduler.js.map +1 -1
  22. package/dist/db/client.d.ts +10 -1
  23. package/dist/db/client.d.ts.map +1 -1
  24. package/dist/db/client.js +5 -14
  25. package/dist/db/client.js.map +1 -1
  26. package/dist/db/settings.d.ts +3 -2
  27. package/dist/db/settings.d.ts.map +1 -1
  28. package/dist/db/settings.js +8 -4
  29. package/dist/db/settings.js.map +1 -1
  30. package/dist/index.d.ts +6 -3
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +10 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/middleware/auth.d.ts +8 -2
  35. package/dist/middleware/auth.d.ts.map +1 -1
  36. package/dist/middleware/auth.js +74 -10
  37. package/dist/middleware/auth.js.map +1 -1
  38. package/dist/middleware/passkeys.d.ts +10 -0
  39. package/dist/middleware/passkeys.d.ts.map +1 -1
  40. package/dist/middleware/passkeys.js +19 -3
  41. package/dist/middleware/passkeys.js.map +1 -1
  42. package/dist/observability/index.d.ts +73 -0
  43. package/dist/observability/index.d.ts.map +1 -0
  44. package/dist/observability/index.js +74 -0
  45. package/dist/observability/index.js.map +1 -0
  46. package/dist/observability/loggers.d.ts +62 -0
  47. package/dist/observability/loggers.d.ts.map +1 -0
  48. package/dist/observability/loggers.js +109 -0
  49. package/dist/observability/loggers.js.map +1 -0
  50. package/dist/observability/meter.d.ts +39 -0
  51. package/dist/observability/meter.d.ts.map +1 -0
  52. package/dist/observability/meter.js +95 -0
  53. package/dist/observability/meter.js.map +1 -0
  54. package/dist/observability/types.d.ts +67 -0
  55. package/dist/observability/types.d.ts.map +1 -0
  56. package/dist/observability/types.js +44 -0
  57. package/dist/observability/types.js.map +1 -0
  58. package/dist/routes/brain.d.ts.map +1 -1
  59. package/dist/routes/brain.js +9 -1
  60. package/dist/routes/brain.js.map +1 -1
  61. package/dist/routes/files.d.ts +2 -0
  62. package/dist/routes/files.d.ts.map +1 -1
  63. package/dist/routes/files.js +7 -7
  64. package/dist/routes/files.js.map +1 -1
  65. package/dist/routes/graph.d.ts +2 -0
  66. package/dist/routes/graph.d.ts.map +1 -1
  67. package/dist/routes/graph.js +7 -7
  68. package/dist/routes/graph.js.map +1 -1
  69. package/dist/routes/health.d.ts +7 -14
  70. package/dist/routes/health.d.ts.map +1 -1
  71. package/dist/routes/health.js +25 -6
  72. package/dist/routes/health.js.map +1 -1
  73. package/dist/routes/models.d.ts +3 -0
  74. package/dist/routes/models.d.ts.map +1 -1
  75. package/dist/routes/models.js +5 -1
  76. package/dist/routes/models.js.map +1 -1
  77. package/dist/routes/render.d.ts +2 -1
  78. package/dist/routes/render.d.ts.map +1 -1
  79. package/dist/routes/render.js +2 -2
  80. package/dist/routes/render.js.map +1 -1
  81. package/dist/routes/share.d.ts +3 -0
  82. package/dist/routes/share.d.ts.map +1 -1
  83. package/dist/routes/share.js +5 -5
  84. package/dist/routes/share.js.map +1 -1
  85. package/dist/share/staging.d.ts +3 -2
  86. package/dist/share/staging.d.ts.map +1 -1
  87. package/dist/share/staging.js +12 -4
  88. package/dist/share/staging.js.map +1 -1
  89. package/dist/voice/keyterm-builder.d.ts +4 -1
  90. package/dist/voice/keyterm-builder.d.ts.map +1 -1
  91. package/dist/voice/keyterm-builder.js +15 -8
  92. package/dist/voice/keyterm-builder.js.map +1 -1
  93. package/dist/ws/bridge.d.ts.map +1 -1
  94. package/dist/ws/bridge.js +23 -3
  95. package/dist/ws/bridge.js.map +1 -1
  96. package/dist/ws/clients.d.ts +10 -3
  97. package/dist/ws/clients.d.ts.map +1 -1
  98. package/dist/ws/clients.js +16 -4
  99. package/dist/ws/clients.js.map +1 -1
  100. package/dist/ws/connection.d.ts +15 -0
  101. package/dist/ws/connection.d.ts.map +1 -1
  102. package/dist/ws/connection.js +66 -10
  103. package/dist/ws/connection.js.map +1 -1
  104. package/dist/ws/dispatch.d.ts +22 -1
  105. package/dist/ws/dispatch.d.ts.map +1 -1
  106. package/dist/ws/dispatch.js +36 -16
  107. package/dist/ws/dispatch.js.map +1 -1
  108. package/dist/ws/host.d.ts +73 -0
  109. package/dist/ws/host.d.ts.map +1 -1
  110. package/dist/ws/host.js +127 -1
  111. package/dist/ws/host.js.map +1 -1
  112. package/dist/ws/rate-limit.d.ts +58 -0
  113. package/dist/ws/rate-limit.d.ts.map +1 -0
  114. package/dist/ws/rate-limit.js +62 -0
  115. package/dist/ws/rate-limit.js.map +1 -0
  116. package/dist/ws/run-session.d.ts +3 -0
  117. package/dist/ws/run-session.d.ts.map +1 -1
  118. package/dist/ws/run-session.js +49 -9
  119. package/dist/ws/run-session.js.map +1 -1
  120. package/dist/ws/session-catalog.d.ts +2 -1
  121. package/dist/ws/session-catalog.d.ts.map +1 -1
  122. package/dist/ws/session-catalog.js +20 -3
  123. package/dist/ws/session-catalog.js.map +1 -1
  124. package/dist/ws/turns.d.ts +21 -0
  125. package/dist/ws/turns.d.ts.map +1 -1
  126. package/dist/ws/turns.js +11 -1
  127. package/dist/ws/turns.js.map +1 -1
  128. package/package.json +9 -6
  129. package/src/agent/backend.ts +131 -35
  130. package/src/app.ts +97 -18
  131. package/src/config/env-core.ts +93 -0
  132. package/src/config/env.ts +116 -15
  133. package/src/cron/scheduler.ts +61 -16
  134. package/src/db/client.ts +16 -5
  135. package/src/db/settings.ts +9 -4
  136. package/src/index.ts +43 -2
  137. package/src/middleware/auth.ts +80 -15
  138. package/src/middleware/passkeys.ts +29 -5
  139. package/src/observability/index.ts +150 -0
  140. package/src/observability/loggers.ts +174 -0
  141. package/src/observability/meter.ts +190 -0
  142. package/src/observability/types.ts +101 -0
  143. package/src/routes/brain.ts +10 -1
  144. package/src/routes/files.ts +9 -8
  145. package/src/routes/graph.ts +9 -8
  146. package/src/routes/health.ts +29 -6
  147. package/src/routes/models.ts +8 -5
  148. package/src/routes/render.ts +3 -2
  149. package/src/routes/share.ts +17 -10
  150. package/src/share/staging.ts +18 -4
  151. package/src/voice/keyterm-builder.ts +19 -10
  152. package/src/ws/bridge.ts +22 -6
  153. package/src/ws/clients.ts +24 -6
  154. package/src/ws/connection.ts +73 -13
  155. package/src/ws/dispatch.ts +47 -16
  156. package/src/ws/host.ts +164 -1
  157. package/src/ws/rate-limit.ts +86 -0
  158. package/src/ws/run-session.ts +57 -14
  159. package/src/ws/session-catalog.ts +21 -3
  160. package/src/ws/turns.ts +27 -3
@@ -3,16 +3,32 @@ import { PROTOCOL_REV } from "@schlessera/brain-ui-sdk/protocol";
3
3
  import { parseClientMessage } from "@schlessera/brain-ui-sdk/schemas";
4
4
  import { withTurnScope } from "./frames.js";
5
5
  import { sendSessionHistory } from "./history.js";
6
- import { handleClientMessage } from "./dispatch.js";
6
+ import { handleClientMessage, type ConnectionState } from "./dispatch.js";
7
7
  import type { WsHost } from "./host.js";
8
+ import type { WSContext } from "./clients.js";
8
9
 
9
10
  export { websocket };
10
11
 
11
- /** Build the Hono WebSocket upgrade handler bound to one host. */
12
- export function createWsUpgrade(host: WsHost) {
13
- return upgradeWebSocket(() => ({
14
- async onOpen(_evt, ws) {
15
- console.log("[ws] Client connected");
12
+ /**
13
+ * The socket lifecycle handlers for one host, separate from the Hono upgrade
14
+ * that wraps them.
15
+ *
16
+ * Split out so the real frame path is reachable from a test without standing
17
+ * up an HTTP server: `createWsHandlers(host).onMessage(...)` runs exactly what
18
+ * production runs, which is what makes an assertion about a dropped frame an
19
+ * assertion about the shipped code rather than about a re-implementation.
20
+ */
21
+ export function createWsHandlers(host: WsHost) {
22
+ // One bucket per connection, created here so it lives and dies with the
23
+ // socket rather than in a map keyed by something a peer controls.
24
+ const limiter = host.newRateLimiter();
25
+ // Per-connection negotiation state: what revision this client declared.
26
+ // Lives with the socket, like the limiter.
27
+ const connection: ConnectionState = {};
28
+
29
+ return {
30
+ async onOpen(_evt: Event, ws: WSContext) {
31
+ host.log.emit({ severityText: "INFO", body: "client connected" });
16
32
  const { coordinator, catalog } = host;
17
33
 
18
34
  // Handshake first (rev 2, additive): protocol revision + coarse
@@ -53,7 +69,11 @@ export function createWsUpgrade(host: WsHost) {
53
69
  const history = await backend.getHistory(sid);
54
70
  if (history.length > 0) sendSessionHistory(ws, sid, history);
55
71
  } catch (err) {
56
- console.error("[ws] snapshot-on-connect failed:", err);
72
+ host.log.emit({
73
+ severityText: "ERROR",
74
+ body: "snapshot-on-connect failed",
75
+ attributes: { error: err instanceof Error ? err.message : String(err) },
76
+ });
57
77
  } finally {
58
78
  host.clients.add(ws);
59
79
  host.sendMessage(
@@ -80,7 +100,7 @@ export function createWsUpgrade(host: WsHost) {
80
100
  });
81
101
  },
82
102
 
83
- onMessage(evt, ws) {
103
+ onMessage(evt: MessageEvent, ws: WSContext) {
84
104
  // Boundary validation (rev 2): byte cap + JSON decode + schema, in one
85
105
  // place. No more casting client JSON to ClientMessage.
86
106
  //
@@ -88,8 +108,21 @@ export function createWsUpgrade(host: WsHost) {
88
108
  // underlying POOLED ArrayBuffer (byteOffset/byteLength discarded), so a
89
109
  // binary frame cannot be decoded correctly here. The protocol is JSON
90
110
  // text; reject anything else rather than parse a slab.
111
+ // Metered BEFORE parsing: the point is to bound work an unmetered peer
112
+ // can make this process do, and parsing is most of that work.
113
+ if (limiter && !limiter.take().allowed) {
114
+ host.reportDroppedFrame("rate_limited");
115
+ host.sendMessage(ws, {
116
+ type: "error",
117
+ code: "RATE_LIMITED",
118
+ message: "Too many frames; slow down.",
119
+ });
120
+ return;
121
+ }
122
+
91
123
  const raw = evt.data;
92
124
  if (typeof raw !== "string") {
125
+ host.reportDroppedFrame("binary_frame");
93
126
  host.sendMessage(ws, {
94
127
  type: "error",
95
128
  code: "PARSE_ERROR",
@@ -99,14 +132,27 @@ export function createWsUpgrade(host: WsHost) {
99
132
  }
100
133
  const parsed = parseClientMessage(raw);
101
134
  if (!parsed.ok) {
135
+ // The reason is the parser's own bounded message, never the frame:
136
+ // the payload is caller-supplied and capped at 12 MB.
137
+ host.reportDroppedFrame("parse_error", parsed.error);
102
138
  host.sendMessage(ws, { type: "error", code: "PARSE_ERROR", message: parsed.error });
103
139
  return;
104
140
  }
105
141
  // handleClientMessage is async — a rejection must not escape as an
106
142
  // unhandled rejection with no frame sent.
107
143
  void Promise.resolve()
108
- .then(() => handleClientMessage(host, ws, parsed.message))
109
- .catch(() => {
144
+ .then(() => handleClientMessage(host, ws, parsed.message, connection))
145
+ .catch((err) => {
146
+ // This used to swallow the cause entirely: the client got a generic
147
+ // frame and the server kept no record of what threw.
148
+ host.log.emit({
149
+ severityText: "ERROR",
150
+ body: "client message handler failed",
151
+ attributes: {
152
+ "frame.type": parsed.message.type,
153
+ error: err instanceof Error ? err.message : String(err),
154
+ },
155
+ });
110
156
  host.sendMessage(ws, {
111
157
  type: "error",
112
158
  code: "INTERNAL_ERROR",
@@ -115,8 +161,17 @@ export function createWsUpgrade(host: WsHost) {
115
161
  });
116
162
  },
117
163
 
118
- onClose(_evt, ws) {
119
- console.log("[ws] Client disconnected");
164
+ onClose(evt: CloseEvent, ws: WSContext) {
165
+ host.log.emit({ severityText: "INFO", body: "client disconnected" });
166
+ // No onError here on purpose: hono's Bun adapter never dispatches it
167
+ // (only open/message/close reach these handlers), so a transport failure
168
+ // is only visible as an abnormal close code. 1000/1001 are the two
169
+ // clean endings (normal closure, going away); anything else — before it
170
+ // was recorded — looked exactly like a clean disconnect.
171
+ const code = (evt as { code?: unknown }).code;
172
+ if (typeof code === "number" && code !== 1000 && code !== 1001) {
173
+ host.reportAbnormalClose(code);
174
+ }
120
175
  host.clients.remove(ws);
121
176
  // Turns keep running in the background. Only reject pending interactive
122
177
  // requests once the LAST client leaves — while another client remains it
@@ -126,5 +181,10 @@ export function createWsUpgrade(host: WsHost) {
126
181
  host.coordinator.drainPendingForTurn(turn, "Client disconnected");
127
182
  }
128
183
  },
129
- }));
184
+ };
185
+ }
186
+
187
+ /** Build the Hono WebSocket upgrade handler bound to one host. */
188
+ export function createWsUpgrade(host: WsHost) {
189
+ return upgradeWebSocket(() => createWsHandlers(host));
130
190
  }
@@ -1,3 +1,4 @@
1
+ import { PROTOCOL_REV_CLIENT_ECHO } from "@schlessera/brain-ui-sdk/protocol";
1
2
  import type { ClientMessage } from "@schlessera/brain-ui-sdk/protocol";
2
3
  import type { WSContext } from "./clients.js";
3
4
  import { locationErrorText } from "./frames.js";
@@ -6,23 +7,51 @@ import { validateAttachments } from "./attachments.js";
6
7
  import { handleChatMessage } from "./run-session.js";
7
8
  import type { WsHost } from "./host.js";
8
9
 
10
+ /** Per-connection negotiation state, owned by the socket handler. */
11
+ export interface ConnectionState {
12
+ /** Revision the client declared via `client_hello`; absent means rev 2. */
13
+ protocolRev?: number;
14
+ }
15
+
9
16
  /**
10
- * A client MAY echo the request's turnId (rev 2). When it does, it must match
11
- * the turn that raised the request — a stale echo from before a reconnect or
12
- * a follow-up would otherwise resolve the wrong turn's pending promise.
13
- * Absent turnId stays valid: the field is optional on the wire.
17
+ * Does this reply's echoed turnId identify the turn that raised the request?
18
+ *
19
+ * A wrong id is always refused: a stale echo from before a reconnect or a
20
+ * follow-up would resolve a different turn's pending promise.
21
+ *
22
+ * A MISSING id depends on who is speaking. A client that declared rev 3
23
+ * promised to echo, so silence means the reply cannot be correlated and is
24
+ * refused. A client that declared nothing is rev 2, where the field is
25
+ * optional, and is still tolerated — that tolerance is the deprecation window,
26
+ * and it is what lets this be enforced at all without breaking clients that
27
+ * predate `client_hello`.
14
28
  */
15
- function turnIdMatches(pending: { turnId: string }, echoed: string | undefined): boolean {
16
- return echoed === undefined || echoed === pending.turnId;
29
+ export function turnIdMatches(
30
+ pending: { turnId: string },
31
+ echoed: string | undefined,
32
+ requireEcho: boolean
33
+ ): boolean {
34
+ if (echoed === undefined) return !requireEcho;
35
+ return echoed === pending.turnId;
17
36
  }
18
37
 
19
38
  export async function handleClientMessage(
20
39
  host: WsHost,
21
40
  ws: WSContext,
22
- msg: ClientMessage
41
+ msg: ClientMessage,
42
+ connection: ConnectionState = {}
23
43
  ): Promise<void> {
24
44
  const { coordinator, catalog } = host;
45
+ const requireEcho = (connection.protocolRev ?? 2) >= PROTOCOL_REV_CLIENT_ECHO;
25
46
  switch (msg.type) {
47
+ case "client_hello": {
48
+ // Record what this connection speaks. Never rejected on version: a
49
+ // future client declaring rev 9 is simply held to the rules this host
50
+ // knows, and an unknown capability flag is ignored.
51
+ connection.protocolRev = msg.protocolRev;
52
+ return;
53
+ }
54
+
26
55
  case "chat_message": {
27
56
  const attachmentResult = validateAttachments(msg.attachments);
28
57
  if (!attachmentResult.ok) {
@@ -46,7 +75,7 @@ export async function handleClientMessage(
46
75
 
47
76
  case "ask_user_response": {
48
77
  const pending = coordinator.pendingAskUser.get(msg.requestId);
49
- if (pending && turnIdMatches(pending, msg.turnId)) {
78
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
50
79
  coordinator.pendingAskUser.delete(msg.requestId);
51
80
  pending.resolve({ answers: msg.answers, annotations: msg.annotations });
52
81
  }
@@ -55,7 +84,7 @@ export async function handleClientMessage(
55
84
 
56
85
  case "ask_user_cancel": {
57
86
  const pending = coordinator.pendingAskUser.get(msg.requestId);
58
- if (pending && turnIdMatches(pending, msg.turnId)) {
87
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
59
88
  coordinator.pendingAskUser.delete(msg.requestId);
60
89
  pending.reject(new Error(msg.reason || "User cancelled the question"));
61
90
  }
@@ -64,7 +93,7 @@ export async function handleClientMessage(
64
93
 
65
94
  case "location_response": {
66
95
  const pending = coordinator.pendingLocation.get(msg.requestId);
67
- if (pending && turnIdMatches(pending, msg.turnId)) {
96
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
68
97
  coordinator.pendingLocation.delete(msg.requestId);
69
98
  pending.resolve({ coords: msg.coords, timestamp: msg.timestamp });
70
99
  }
@@ -73,7 +102,7 @@ export async function handleClientMessage(
73
102
 
74
103
  case "location_error": {
75
104
  const pending = coordinator.pendingLocation.get(msg.requestId);
76
- if (pending && turnIdMatches(pending, msg.turnId)) {
105
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
77
106
  coordinator.pendingLocation.delete(msg.requestId);
78
107
  pending.reject(new Error(locationErrorText(msg.code, msg.message)));
79
108
  }
@@ -82,7 +111,7 @@ export async function handleClientMessage(
82
111
 
83
112
  case "mask_response": {
84
113
  const pending = coordinator.pendingMask.get(msg.requestId);
85
- if (pending && turnIdMatches(pending, msg.turnId)) {
114
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
86
115
  coordinator.pendingMask.delete(msg.requestId);
87
116
  // Decoded here rather than in the tool: the boundary already validated
88
117
  // the base64 and its size, so the backend gets bytes it can trust.
@@ -93,7 +122,7 @@ export async function handleClientMessage(
93
122
 
94
123
  case "mask_error": {
95
124
  const pending = coordinator.pendingMask.get(msg.requestId);
96
- if (pending && turnIdMatches(pending, msg.turnId)) {
125
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
97
126
  coordinator.pendingMask.delete(msg.requestId);
98
127
  pending.reject(
99
128
  new Error(
@@ -108,7 +137,7 @@ export async function handleClientMessage(
108
137
 
109
138
  case "tool_approval": {
110
139
  const pending = coordinator.pendingApprovals.get(msg.toolUseId);
111
- if (pending && turnIdMatches(pending, msg.turnId)) {
140
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
112
141
  coordinator.pendingApprovals.delete(msg.toolUseId);
113
142
  pending.resolve(
114
143
  msg.updatedInput
@@ -121,7 +150,7 @@ export async function handleClientMessage(
121
150
 
122
151
  case "tool_denial": {
123
152
  const pending = coordinator.pendingApprovals.get(msg.toolUseId);
124
- if (pending && turnIdMatches(pending, msg.turnId)) {
153
+ if (pending && turnIdMatches(pending, msg.turnId, requireEcho)) {
125
154
  coordinator.pendingApprovals.delete(msg.toolUseId);
126
155
  pending.resolve({ behavior: "deny", message: msg.message });
127
156
  }
@@ -173,10 +202,12 @@ export async function handleClientMessage(
173
202
  ...(runningTurn ? { turnId: runningTurn.turnId } : {}),
174
203
  });
175
204
  } catch (err) {
205
+ const message = err instanceof Error ? err.message : "Failed to load session";
206
+ host.reportTurnFailed("SESSION_LOAD_ERROR", { sessionId: msg.sessionId }, message);
176
207
  host.sendMessage(ws, {
177
208
  type: "error",
178
209
  code: "SESSION_LOAD_ERROR",
179
- message: err instanceof Error ? err.message : "Failed to load session",
210
+ message,
180
211
  sessionId: msg.sessionId,
181
212
  });
182
213
  }
package/src/ws/host.ts CHANGED
@@ -3,6 +3,8 @@ import { ClientSet, sendTo, type WSContext } from "./clients.js";
3
3
  import { TurnCoordinator } from "./turns.js";
4
4
  import type { SessionCatalog } from "./session-catalog.js";
5
5
  import type { BackendRegistry } from "../agent/backend.js";
6
+ import { createSilentObservability, type Observability } from "../observability/index.js";
7
+ import { FrameRateLimiter } from "./rate-limit.js";
6
8
 
7
9
  /** Host-side turn timeout. The backend no longer times out — the host owns it. */
8
10
  const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
@@ -50,6 +52,38 @@ export interface WsHostOptions {
50
52
  * it dynamic; createApp passes the resolved config value.
51
53
  */
52
54
  maxConcurrentSessions?: () => number;
55
+ /**
56
+ * Where this coordinator reports. Injected rather than reached for, so two
57
+ * apps in one process report separately and a test can assert on what the
58
+ * socket layer actually said. Defaults to silence: an embedder that never
59
+ * passes one gets no output, not a surprise stream on stdout.
60
+ */
61
+ observability?: Observability;
62
+ /**
63
+ * Inbound frame metering policy, per connection. Omitted (or a rate of 0)
64
+ * means no metering — which is what a test wants, and what an embedder
65
+ * fronting the socket with its own limiter wants.
66
+ */
67
+ wsRate?: { ratePerSecond: number; burst: number };
68
+ }
69
+
70
+ /** Identity of one turn, as it appears on a log record. */
71
+ export interface TurnLogContext {
72
+ sessionId?: string | null;
73
+ turnId?: string | null;
74
+ providerId?: string | null;
75
+ }
76
+
77
+ /**
78
+ * Log attributes for one turn. Null fields are omitted rather than stringified
79
+ * — a new session has no sessionId until `session_info` names it.
80
+ */
81
+ export function turnLogAttributes(turn: TurnLogContext): Record<string, string> {
82
+ return {
83
+ ...(turn.sessionId ? { "session.id": turn.sessionId } : {}),
84
+ ...(turn.turnId ? { "turn.id": turn.turnId } : {}),
85
+ ...(turn.providerId ? { profile: turn.providerId } : {}),
86
+ };
53
87
  }
54
88
 
55
89
  /**
@@ -66,6 +100,25 @@ export class WsHost {
66
100
  appName: string;
67
101
  turnTimeoutMs: number;
68
102
  maxConcurrentSessions: () => number;
103
+ readonly observability: Observability;
104
+ readonly wsRate: { ratePerSecond: number; burst: number } | null;
105
+ /** Scoped instruments, resolved once — `[ws]` is the existing log prefix. */
106
+ readonly log: ReturnType<Observability["logger"]>;
107
+ private readonly framesDropped: ReturnType<
108
+ ReturnType<Observability["meter"]>["createCounter"]
109
+ >;
110
+ private readonly turnsStarted: ReturnType<
111
+ ReturnType<Observability["meter"]>["createCounter"]
112
+ >;
113
+ private readonly turnsCompleted: ReturnType<
114
+ ReturnType<Observability["meter"]>["createCounter"]
115
+ >;
116
+ private readonly turnsFailed: ReturnType<
117
+ ReturnType<Observability["meter"]>["createCounter"]
118
+ >;
119
+ private readonly wsErrors: ReturnType<
120
+ ReturnType<Observability["meter"]>["createCounter"]
121
+ >;
69
122
 
70
123
  constructor(options: WsHostOptions) {
71
124
  this.registry = options.registry;
@@ -74,11 +127,121 @@ export class WsHost {
74
127
  this.turnTimeoutMs = options.turnTimeoutMs ?? DEFAULT_TIMEOUT_MS;
75
128
  this.maxConcurrentSessions =
76
129
  options.maxConcurrentSessions ?? (() => DEFAULT_MAX_CONCURRENT_SESSIONS);
130
+ this.observability = options.observability ?? createSilentObservability();
131
+ this.wsRate =
132
+ options.wsRate && options.wsRate.ratePerSecond > 0 ? options.wsRate : null;
133
+ this.log = this.observability.logger("ws");
134
+ const meter = this.observability.meter("ws");
135
+ this.framesDropped = meter.createCounter("ws.frames.dropped", {
136
+ description: "Inbound frames refused before reaching a handler",
137
+ });
138
+ this.turnsStarted = meter.createCounter("turns.started", {
139
+ description: "Turns handed to a backend",
140
+ });
141
+ this.turnsCompleted = meter.createCounter("turns.completed", {
142
+ description: "Turns whose backend call resolved without throwing",
143
+ });
144
+ this.turnsFailed = meter.createCounter("turns.failed", {
145
+ description: "Turn failures surfaced to the client, by error code",
146
+ });
147
+ this.wsErrors = meter.createCounter("ws.errors", {
148
+ description: "Transport errors reported by the socket layer",
149
+ });
150
+ this.coordinator.log = this.log;
151
+ }
152
+
153
+ /** A metering bucket for one new connection, or null when metering is off. */
154
+ newRateLimiter(): FrameRateLimiter | null {
155
+ return this.wsRate ? new FrameRateLimiter(this.wsRate) : null;
156
+ }
157
+
158
+ /**
159
+ * Record a frame this server refused, and say so.
160
+ *
161
+ * Before this existed, every rejection answered the client and vanished:
162
+ * `parseClientMessage` failures were never logged, so the validation we
163
+ * already shipped had no observability at all. `reason` is a bounded token,
164
+ * never the frame body — the payload is caller-supplied and can be 12 MB.
165
+ */
166
+ reportDroppedFrame(reason: string, detail?: string): void {
167
+ this.framesDropped.add(1, { reason, direction: "inbound" });
168
+ this.log.emit({
169
+ severityText: "WARN",
170
+ body: "inbound frame rejected",
171
+ attributes: detail ? { reason, detail } : { reason },
172
+ });
173
+ }
174
+
175
+ /** A turn began executing: counted, and logged with its correlation ids. */
176
+ reportTurnStarted(turn: TurnLogContext): void {
177
+ this.turnsStarted.add(1);
178
+ this.log.emit({
179
+ severityText: "INFO",
180
+ body: "turn started",
181
+ attributes: turnLogAttributes(turn),
182
+ });
183
+ }
184
+
185
+ /** A turn's backend call resolved: counted, and logged with its duration. */
186
+ reportTurnCompleted(turn: TurnLogContext, durationMs: number): void {
187
+ this.turnsCompleted.add(1);
188
+ this.log.emit({
189
+ severityText: "INFO",
190
+ body: "turn completed",
191
+ attributes: { ...turnLogAttributes(turn), "duration.ms": durationMs },
192
+ });
193
+ }
194
+
195
+ /**
196
+ * Record a turn failure the client is being told about. Before this existed
197
+ * every such failure was an error FRAME only — visible on one phone screen,
198
+ * absent from the server's own record.
199
+ *
200
+ * `code` is the bounded error-frame code (it feeds a counter attribute);
201
+ * `error` is the thrown message and rides only on the log record — never a
202
+ * caller-supplied frame body, per the reportDroppedFrame model.
203
+ */
204
+ reportTurnFailed(code: string, turn: TurnLogContext, error?: string): void {
205
+ this.turnsFailed.add(1, { code });
206
+ this.log.emit({
207
+ // A busy session is the client racing itself; everything else is a
208
+ // failure the operator should see.
209
+ severityText: code === "SESSION_BUSY" ? "WARN" : "ERROR",
210
+ body: "turn failed",
211
+ attributes: {
212
+ code,
213
+ ...turnLogAttributes(turn),
214
+ ...(error ? { error } : {}),
215
+ },
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Record a socket that closed abnormally. This is the transport-error signal
221
+ * available on Bun: hono's Bun adapter dispatches only open/message/close
222
+ * (never WSEvents.onError, and Bun's ServerWebSocket has no error callback),
223
+ * so a transport failure surfaces as a close with an abnormal code.
224
+ */
225
+ reportAbnormalClose(code: number): void {
226
+ this.wsErrors.add(1, { "close.code": code });
227
+ this.log.emit({
228
+ severityText: "WARN",
229
+ body: "websocket closed abnormally",
230
+ attributes: { "close.code": code },
231
+ });
77
232
  }
78
233
 
79
234
  /** Fan a frame out to every attached client (size-bounded per frame). */
80
235
  sendToClients(msg: ServerMessage): void {
81
- this.clients.broadcast(msg);
236
+ this.clients.broadcast(msg, () => {
237
+ // Outbound counterpart of reportDroppedFrame: the peer never saw this
238
+ // frame. Counted only — a dead socket would otherwise WARN per frame
239
+ // until its onClose prunes it.
240
+ this.framesDropped.add(1, {
241
+ reason: "broadcast_send_failed",
242
+ direction: "outbound",
243
+ });
244
+ });
82
245
  }
83
246
 
84
247
  /** Send a frame to one specific socket (size-bounded). */
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-connection inbound frame metering.
3
+ *
4
+ * Frames are already size-, cardinality- and depth-bounded, so the cheap CPU
5
+ * amplification is closed. What was left unmetered is VOLUME: a flood of
6
+ * individually valid frames behind the auth guard. That is a smaller threat
7
+ * than an unauthenticated one — it needs a session — but "the attacker had to
8
+ * log in first" is not a bound.
9
+ *
10
+ * A token bucket rather than a fixed window, because the traffic this protects
11
+ * is bursty by nature and a fixed window punishes the wrong thing. Opening the
12
+ * app fires a handful of frames at once (resume, environment, a first
13
+ * message); an approval storm during a busy turn is a dozen in a second. Both
14
+ * are legitimate and both fit in the burst. What does not fit is a sustained
15
+ * rate no human produces.
16
+ *
17
+ * Inbound traffic is naturally low-rate — deltas flow the other way — so the
18
+ * defaults are generous enough that no real client should ever see one of
19
+ * these, which is the property that makes it safe to turn on by default.
20
+ */
21
+
22
+ /** Injected so tests need no real time. */
23
+ export interface Clock {
24
+ now(): number;
25
+ }
26
+
27
+ const realClock: Clock = { now: () => Date.now() };
28
+
29
+ export interface RateLimitOptions {
30
+ /** Sustained frames per second. */
31
+ ratePerSecond: number;
32
+ /** Frames absorbable in one burst before the sustained rate applies. */
33
+ burst: number;
34
+ clock?: Clock;
35
+ }
36
+
37
+ export interface RateLimitDecision {
38
+ allowed: boolean;
39
+ /** Tokens left after this decision, for reporting. */
40
+ remaining: number;
41
+ }
42
+
43
+ /**
44
+ * One bucket per connection. Not shared, and not keyed by anything
45
+ * user-supplied: a map keyed by session or IP is itself an unbounded
46
+ * allocation an attacker controls, which is the classic way a rate limiter
47
+ * becomes the memory-exhaustion bug it was added to prevent. This lives on the
48
+ * socket and dies with it.
49
+ */
50
+ export class FrameRateLimiter {
51
+ private tokens: number;
52
+ private lastRefill: number;
53
+ private readonly ratePerMs: number;
54
+ private readonly burst: number;
55
+ private readonly clock: Clock;
56
+ /** Frames refused since the connection opened. */
57
+ private rejected = 0;
58
+
59
+ constructor(options: RateLimitOptions) {
60
+ this.burst = Math.max(1, options.burst);
61
+ this.ratePerMs = Math.max(0, options.ratePerSecond) / 1000;
62
+ this.clock = options.clock ?? realClock;
63
+ this.tokens = this.burst;
64
+ this.lastRefill = this.clock.now();
65
+ }
66
+
67
+ /** Take one token. False means this frame should be refused. */
68
+ take(): RateLimitDecision {
69
+ const now = this.clock.now();
70
+ const elapsed = Math.max(0, now - this.lastRefill);
71
+ this.lastRefill = now;
72
+ this.tokens = Math.min(this.burst, this.tokens + elapsed * this.ratePerMs);
73
+
74
+ if (this.tokens < 1) {
75
+ this.rejected++;
76
+ return { allowed: false, remaining: 0 };
77
+ }
78
+ this.tokens -= 1;
79
+ return { allowed: true, remaining: Math.floor(this.tokens) };
80
+ }
81
+
82
+ /** How many frames this connection has had refused. */
83
+ get rejectedCount(): number {
84
+ return this.rejected;
85
+ }
86
+ }