keryx 0.25.5 → 0.29.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.
@@ -11,6 +11,54 @@ import { LogFormat } from "./Logger";
11
11
  import { StreamingResponse } from "./StreamingResponse";
12
12
  import { ErrorType, TypedError } from "./TypedError";
13
13
 
14
+ /**
15
+ * Per-invocation context passed to {@link BeforeActHook} and {@link AfterActHook}.
16
+ * The same object instance threads from `beforeAct` to `afterAct` for a single
17
+ * action invocation, so hooks can stash span refs, timing data, etc.
18
+ */
19
+ export interface ActContext {
20
+ /** Mutable scratch space shared between `beforeAct` and `afterAct`. */
21
+ metadata: Record<string, unknown>;
22
+ }
23
+
24
+ /**
25
+ * Unified outcome passed to {@link AfterActHook}. Discriminate via the `success` field.
26
+ * Covers both the happy-path and error paths of an action invocation.
27
+ */
28
+ export type ActOutcome =
29
+ | { success: true; response: unknown; duration: number }
30
+ | { success: false; error: unknown; duration: number };
31
+
32
+ /**
33
+ * Runs inside `Connection.act()` after params are validated and before the action's
34
+ * own `runBefore` middleware. Fires for every action invocation regardless of
35
+ * transport (web, websocket, task, cli, mcp, …) — inspect `connection.type` to
36
+ * discriminate. Throwing fails the action.
37
+ *
38
+ * Register via `api.hooks.actions.beforeAct(...)`.
39
+ */
40
+ export type BeforeActHook = (
41
+ actionName: string,
42
+ params: Record<string, unknown>,
43
+ connection: Connection,
44
+ ctx: ActContext,
45
+ ) => Promise<void> | void;
46
+
47
+ /**
48
+ * Runs inside `Connection.act()` after the action completes (success or failure),
49
+ * in a `finally` block so it always fires if the corresponding `beforeAct` fired.
50
+ * Receives the same `ctx` plus an {@link ActOutcome} describing what happened.
51
+ *
52
+ * Register via `api.hooks.actions.afterAct(...)`.
53
+ */
54
+ export type AfterActHook = (
55
+ actionName: string,
56
+ params: Record<string, unknown>,
57
+ connection: Connection,
58
+ ctx: ActContext,
59
+ outcome: ActOutcome,
60
+ ) => Promise<void> | void;
61
+
14
62
  /**
15
63
  * Represents a client connection to the server — HTTP request, WebSocket, or internal caller.
16
64
  * Each connection tracks its own session, channel subscriptions, and rate-limit state.
@@ -108,6 +156,11 @@ export class Connection<
108
156
 
109
157
  let action: Action | undefined;
110
158
  let formattedParams: Record<string, unknown> | undefined;
159
+ // Cross-transport action hooks (api.hooks.actions.beforeAct / afterAct).
160
+ // beforeActRan guards afterAct so we only fire after-hooks for invocations
161
+ // where before-hooks also fired (i.e. action found + params validated).
162
+ const actCtx: ActContext = { metadata: {} };
163
+ let beforeActRan = false;
111
164
  try {
112
165
  action = this.findAction(actionName);
113
166
  if (!action) {
@@ -122,6 +175,11 @@ export class Connection<
122
175
 
123
176
  formattedParams = await this.formatParams(params, action);
124
177
 
178
+ for (const hook of api.hooks.actions.beforeActHooks) {
179
+ await hook(action.name, formattedParams, this, actCtx);
180
+ }
181
+ beforeActRan = true;
182
+
125
183
  for (const middleware of action.middleware ?? []) {
126
184
  if (middleware.runBefore) {
127
185
  const middlewareResponse = await middleware.runBefore(
@@ -184,19 +242,20 @@ export class Connection<
184
242
  }
185
243
  }
186
244
  }
245
+ if (beforeActRan && action && formattedParams) {
246
+ const actDuration = new Date().getTime() - reqStartTime;
247
+ const outcome: ActOutcome = error
248
+ ? { success: false, error, duration: actDuration }
249
+ : { success: true, response, duration: actDuration };
250
+ for (const hook of api.hooks.actions.afterActHooks) {
251
+ await hook(action.name, formattedParams, this, actCtx, outcome);
252
+ }
253
+ }
187
254
  this._actDepth--;
188
255
  }
189
256
 
190
257
  const duration = new Date().getTime() - reqStartTime;
191
258
 
192
- api.observability.action.executionsTotal.add(1, {
193
- action: actionName ?? "unknown",
194
- status: loggerResponsePrefix === "OK" ? "success" : "error",
195
- });
196
- api.observability.action.duration.record(duration, {
197
- action: actionName ?? "unknown",
198
- });
199
-
200
259
  logAction({
201
260
  actionName,
202
261
  connectionType: this.type,
package/index.ts CHANGED
@@ -5,6 +5,7 @@ import "./initializers/actionts";
5
5
  import "./initializers/channels";
6
6
  import "./initializers/connections";
7
7
  import "./initializers/db";
8
+ import "./initializers/hooks";
8
9
  import "./initializers/mcp";
9
10
  import "./initializers/oauth";
10
11
  import "./initializers/observability";
@@ -22,16 +23,43 @@ export type { ActionMiddleware } from "./classes/Action";
22
23
  export { HTTP_METHOD, MCP_RESPONSE_FORMAT } from "./classes/Action";
23
24
  export type { ChannelMiddleware } from "./classes/Channel";
24
25
  export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
26
+ export type {
27
+ ActContext,
28
+ ActOutcome,
29
+ AfterActHook,
30
+ BeforeActHook,
31
+ } from "./classes/Connection";
25
32
  export { Connection } from "./classes/Connection";
26
33
  export { LogLevel } from "./classes/Logger";
27
34
  export type { KeryxPlugin, PluginGenerator } from "./classes/Plugin";
28
35
  export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
29
36
  export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
30
37
  export type { KeryxConfig } from "./config";
38
+ export type { OnEnqueueHook } from "./initializers/actionts";
39
+ export type {
40
+ OnMcpConnectHook,
41
+ OnMcpDisconnectHook,
42
+ OnMcpMessageHook,
43
+ } from "./initializers/mcp";
44
+ export type {
45
+ AfterJobHook,
46
+ BeforeJobHook,
47
+ JobContext,
48
+ JobOutcome,
49
+ } from "./initializers/resque";
31
50
  export type { SessionData } from "./initializers/session";
32
51
  export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
33
52
  export { TransactionMiddleware } from "./middleware/transaction";
34
- export type { WebServer } from "./servers/web";
53
+ export type {
54
+ AfterRequestHook,
55
+ BeforeRequestHook,
56
+ OnConnectHook,
57
+ OnDisconnectHook,
58
+ OnMessageHook,
59
+ RequestContext,
60
+ RequestOutcome,
61
+ WebServer,
62
+ } from "./servers/web";
35
63
  export { buildProgram } from "./util/cli";
36
64
  export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
37
65
  export { getValidTypes } from "./util/generate";
@@ -68,11 +68,44 @@ declare module "keryx" {
68
68
 
69
69
  export type TaskInputs = Record<string, any>;
70
70
 
71
+ /**
72
+ * Runs when any action is enqueued — via {@link Actions.enqueue}, {@link Actions.enqueueAt},
73
+ * {@link Actions.enqueueIn}, or the per-job calls inside {@link Actions.fanOut}. Fires after
74
+ * the queue has been resolved and before the job is placed in Redis.
75
+ *
76
+ * Return a new `TaskInputs` object to replace the payload (e.g. to inject trace headers),
77
+ * or return `void` / `undefined` to leave the payload unchanged. If multiple hooks are
78
+ * registered they run sequentially in registration order; each receives the output of
79
+ * the previous one.
80
+ *
81
+ * Register via `api.hooks.actions.onEnqueue(...)`.
82
+ */
83
+ export type OnEnqueueHook = (
84
+ actionName: string,
85
+ inputs: TaskInputs,
86
+ queue: string,
87
+ ) => Promise<TaskInputs | void> | TaskInputs | void;
88
+
71
89
  export class Actions extends Initializer {
72
90
  constructor() {
73
91
  super(namespace);
92
+ this.dependsOn = ["hooks"];
74
93
  }
75
94
 
95
+ /** Run all registered `onEnqueue` hooks, threading inputs through each. */
96
+ private runOnEnqueueHooks = async (
97
+ actionName: string,
98
+ inputs: TaskInputs,
99
+ queue: string,
100
+ ): Promise<TaskInputs> => {
101
+ let current = inputs;
102
+ for (const hook of api.hooks.actions.onEnqueueHooks) {
103
+ const next = await hook(actionName, current, queue);
104
+ if (next !== undefined) current = next;
105
+ }
106
+ return current;
107
+ };
108
+
76
109
  /**
77
110
  * Enqueue an action to be performed in the background.
78
111
  *
@@ -96,11 +129,8 @@ export class Actions extends Initializer {
96
129
  });
97
130
  }
98
131
  queue = queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
99
- api.observability.task.enqueuedTotal.add(1, {
100
- action: actionName,
101
- queue,
102
- });
103
- return api.resque.queue.enqueue(queue, actionName, [inputs]);
132
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
133
+ return api.resque.queue.enqueue(queue, actionName, [finalInputs]);
104
134
  };
105
135
 
106
136
  /**
@@ -302,11 +332,12 @@ export class Actions extends Initializer {
302
332
  queue: string = DEFAULT_QUEUE,
303
333
  suppressDuplicateTaskError = false,
304
334
  ) => {
335
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
305
336
  return api.resque.queue.enqueueAt(
306
337
  timestamp,
307
338
  queue,
308
339
  actionName,
309
- [inputs],
340
+ [finalInputs],
310
341
  suppressDuplicateTaskError,
311
342
  );
312
343
  };
@@ -328,11 +359,12 @@ export class Actions extends Initializer {
328
359
  queue: string = DEFAULT_QUEUE,
329
360
  suppressDuplicateTaskError = false,
330
361
  ) => {
362
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
331
363
  return api.resque.queue.enqueueIn(
332
364
  time,
333
365
  queue,
334
366
  actionName,
335
- [inputs],
367
+ [finalInputs],
336
368
  suppressDuplicateTaskError,
337
369
  );
338
370
  };
@@ -0,0 +1,193 @@
1
+ import type { AfterActHook, BeforeActHook } from "../classes/Connection";
2
+ import { Initializer } from "../classes/Initializer";
3
+ import type {
4
+ AfterRequestHook,
5
+ BeforeRequestHook,
6
+ OnConnectHook,
7
+ OnDisconnectHook,
8
+ OnMessageHook,
9
+ } from "../servers/web";
10
+ import type { OnEnqueueHook } from "./actionts";
11
+ import type {
12
+ OnMcpConnectHook,
13
+ OnMcpDisconnectHook,
14
+ OnMcpMessageHook,
15
+ } from "./mcp";
16
+ import type { AfterJobHook, BeforeJobHook } from "./resque";
17
+
18
+ const namespace = "hooks";
19
+
20
+ declare module "keryx" {
21
+ export interface API {
22
+ [namespace]: Awaited<ReturnType<Hooks["initialize"]>>;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Central registry for framework lifecycle hooks. Plugins register hooks here
28
+ * from their initializer's `initialize()`; the framework iterates the registered
29
+ * hooks at runtime.
30
+ *
31
+ * Public surface: `api.hooks.web`, `api.hooks.ws`, `api.hooks.mcp`,
32
+ * `api.hooks.actions`, `api.hooks.resque`. See the respective hook type
33
+ * definitions for semantics (in `servers/web.ts`, `initializers/mcp.ts`,
34
+ * `initializers/actionts.ts`, `initializers/resque.ts`).
35
+ */
36
+ export class Hooks extends Initializer {
37
+ private webBeforeRequest: BeforeRequestHook[] = [];
38
+ private webAfterRequest: AfterRequestHook[] = [];
39
+ private wsOnConnect: OnConnectHook[] = [];
40
+ private wsOnMessage: OnMessageHook[] = [];
41
+ private wsOnDisconnect: OnDisconnectHook[] = [];
42
+ private mcpOnConnect: OnMcpConnectHook[] = [];
43
+ private mcpOnMessage: OnMcpMessageHook[] = [];
44
+ private mcpOnDisconnect: OnMcpDisconnectHook[] = [];
45
+ private actionsOnEnqueue: OnEnqueueHook[] = [];
46
+ private actionsBeforeAct: BeforeActHook[] = [];
47
+ private actionsAfterAct: AfterActHook[] = [];
48
+ private resqueBeforeJob: BeforeJobHook[] = [];
49
+ private resqueAfterJob: AfterJobHook[] = [];
50
+
51
+ constructor() {
52
+ super(namespace);
53
+ }
54
+
55
+ async initialize() {
56
+ const self = this;
57
+ return {
58
+ web: {
59
+ /**
60
+ * Register a hook to run at the start of every HTTP request, before
61
+ * routing. Covers static files, OAuth, MCP, metrics, and actions.
62
+ * Does not fire for WebSocket upgrades.
63
+ */
64
+ beforeRequest(hook: BeforeRequestHook): void {
65
+ self.webBeforeRequest.push(hook);
66
+ },
67
+ /**
68
+ * Register a hook to run after the `Response` is built, before
69
+ * compression. Receives the same `ctx` object passed to `beforeRequest`,
70
+ * so state stashed in `ctx.metadata` flows through.
71
+ */
72
+ afterRequest(hook: AfterRequestHook): void {
73
+ self.webAfterRequest.push(hook);
74
+ },
75
+ /** @internal Iterated by `WebServer.handleIncomingConnection`. */
76
+ beforeRequestHooks:
77
+ self.webBeforeRequest as ReadonlyArray<BeforeRequestHook>,
78
+ /** @internal Iterated by `WebServer.handleIncomingConnection`. */
79
+ afterRequestHooks:
80
+ self.webAfterRequest as ReadonlyArray<AfterRequestHook>,
81
+ },
82
+ ws: {
83
+ /**
84
+ * Register a hook to run when a new WebSocket connection is accepted,
85
+ * after the `Connection` has been constructed and registered.
86
+ */
87
+ onConnect(hook: OnConnectHook): void {
88
+ self.wsOnConnect.push(hook);
89
+ },
90
+ /**
91
+ * Register a hook to run for each inbound WebSocket message, after
92
+ * rate-limiting but before message parsing / dispatch.
93
+ */
94
+ onMessage(hook: OnMessageHook): void {
95
+ self.wsOnMessage.push(hook);
96
+ },
97
+ /**
98
+ * Register a hook to run when a WebSocket connection closes, before
99
+ * channel presence is cleaned up and the connection is destroyed.
100
+ */
101
+ onDisconnect(hook: OnDisconnectHook): void {
102
+ self.wsOnDisconnect.push(hook);
103
+ },
104
+ /** @internal Iterated by `WebServer.handleWebSocketConnectionOpen`. */
105
+ onConnectHooks: self.wsOnConnect as ReadonlyArray<OnConnectHook>,
106
+ /** @internal Iterated by `WebServer.handleWebSocketConnectionMessage`. */
107
+ onMessageHooks: self.wsOnMessage as ReadonlyArray<OnMessageHook>,
108
+ /** @internal Iterated by `WebServer.handleWebSocketConnectionClose`. */
109
+ onDisconnectHooks:
110
+ self.wsOnDisconnect as ReadonlyArray<OnDisconnectHook>,
111
+ },
112
+ mcp: {
113
+ /**
114
+ * Register a hook to run when a new MCP session is initialized (after
115
+ * the MCP initialize handshake completes).
116
+ */
117
+ onConnect(hook: OnMcpConnectHook): void {
118
+ self.mcpOnConnect.push(hook);
119
+ },
120
+ /**
121
+ * Register a hook to run for each inbound MCP request (POST/GET/DELETE
122
+ * to the MCP route). Fires before the transport dispatches the request.
123
+ * `sessionId` is `undefined` for the very first POST that creates a
124
+ * session.
125
+ */
126
+ onMessage(hook: OnMcpMessageHook): void {
127
+ self.mcpOnMessage.push(hook);
128
+ },
129
+ /**
130
+ * Register a hook to run when an MCP session's transport closes.
131
+ */
132
+ onDisconnect(hook: OnMcpDisconnectHook): void {
133
+ self.mcpOnDisconnect.push(hook);
134
+ },
135
+ /** @internal Iterated by the MCP handler on session init. */
136
+ onConnectHooks: self.mcpOnConnect as ReadonlyArray<OnMcpConnectHook>,
137
+ /** @internal Iterated by the MCP handler before dispatching a request. */
138
+ onMessageHooks: self.mcpOnMessage as ReadonlyArray<OnMcpMessageHook>,
139
+ /** @internal Iterated by the MCP handler on transport close. */
140
+ onDisconnectHooks:
141
+ self.mcpOnDisconnect as ReadonlyArray<OnMcpDisconnectHook>,
142
+ },
143
+ actions: {
144
+ /**
145
+ * Register a hook to run on every enqueue. Fires for `enqueue`,
146
+ * `enqueueAt`, `enqueueIn`, and per-job inside `fanOut`. Hooks may
147
+ * mutate inputs by returning a replacement object.
148
+ */
149
+ onEnqueue(hook: OnEnqueueHook): void {
150
+ self.actionsOnEnqueue.push(hook);
151
+ },
152
+ /**
153
+ * Register a hook to run inside `Connection.act()` after params are
154
+ * validated and before the action's `runBefore` middleware. Fires for
155
+ * every action invocation across all transports (web, websocket, task,
156
+ * cli, mcp, …) — inspect `connection.type` to discriminate.
157
+ */
158
+ beforeAct(hook: BeforeActHook): void {
159
+ self.actionsBeforeAct.push(hook);
160
+ },
161
+ /**
162
+ * Register a hook to run inside `Connection.act()` in a `finally` block
163
+ * after the action completes (success or failure). Receives the same
164
+ * `ctx` as `beforeAct` plus a unified {@link ActOutcome}. Fires across
165
+ * all transports.
166
+ */
167
+ afterAct(hook: AfterActHook): void {
168
+ self.actionsAfterAct.push(hook);
169
+ },
170
+ /** @internal Iterated by `Actions.enqueue`, `enqueueAt`, `enqueueIn`. */
171
+ onEnqueueHooks: self.actionsOnEnqueue as ReadonlyArray<OnEnqueueHook>,
172
+ /** @internal Iterated inside `Connection.act`. */
173
+ beforeActHooks: self.actionsBeforeAct as ReadonlyArray<BeforeActHook>,
174
+ /** @internal Iterated inside `Connection.act`. */
175
+ afterActHooks: self.actionsAfterAct as ReadonlyArray<AfterActHook>,
176
+ },
177
+ resque: {
178
+ /** Register a hook to run before each job's action executes. */
179
+ beforeJob(hook: BeforeJobHook): void {
180
+ self.resqueBeforeJob.push(hook);
181
+ },
182
+ /** Register a hook to run after each job's action executes (success or failure). */
183
+ afterJob(hook: AfterJobHook): void {
184
+ self.resqueAfterJob.push(hook);
185
+ },
186
+ /** @internal Iterated inside `wrapActionAsJob.perform`. */
187
+ beforeJobHooks: self.resqueBeforeJob as ReadonlyArray<BeforeJobHook>,
188
+ /** @internal Iterated inside `wrapActionAsJob.perform`. */
189
+ afterJobHooks: self.resqueAfterJob as ReadonlyArray<AfterJobHook>,
190
+ },
191
+ };
192
+ }
193
+ }
@@ -20,6 +20,29 @@ import type { PubSubMessage } from "./pubsub";
20
20
 
21
21
  type McpHandleRequest = (req: Request, ip: string) => Promise<Response>;
22
22
 
23
+ /**
24
+ * Runs when a new MCP session is initialized (after the initialize JSON-RPC
25
+ * handshake). `sessionId` is the server-assigned session id.
26
+ * Register via `api.hooks.mcp.onConnect(...)`.
27
+ */
28
+ export type OnMcpConnectHook = (sessionId: string) => Promise<void> | void;
29
+
30
+ /**
31
+ * Runs for each inbound MCP HTTP request (POST/GET/DELETE to the MCP route),
32
+ * before it's dispatched to the transport. `sessionId` is `undefined` for the
33
+ * very first POST that creates a new session. Register via
34
+ * `api.hooks.mcp.onMessage(...)`.
35
+ */
36
+ export type OnMcpMessageHook = (
37
+ sessionId: string | undefined,
38
+ ) => Promise<void> | void;
39
+
40
+ /**
41
+ * Runs when an MCP session's transport closes and the session is torn down.
42
+ * Register via `api.hooks.mcp.onDisconnect(...)`.
43
+ */
44
+ export type OnMcpDisconnectHook = (sessionId: string) => Promise<void> | void;
45
+
23
46
  const namespace = "mcp";
24
47
 
25
48
  declare module "keryx" {
@@ -31,7 +54,7 @@ declare module "keryx" {
31
54
  export class McpInitializer extends Initializer {
32
55
  constructor() {
33
56
  super(namespace);
34
- this.dependsOn = ["actions", "oauth", "connections", "pubsub"];
57
+ this.dependsOn = ["hooks", "actions", "oauth", "connections", "pubsub"];
35
58
  }
36
59
 
37
60
  async initialize() {
@@ -187,8 +210,11 @@ export class McpInitializer extends Initializer {
187
210
  const transport = new WebStandardStreamableHTTPServerTransport({
188
211
  sessionIdGenerator: () => randomUUID(),
189
212
  enableJsonResponse: true,
190
- onsessioninitialized: (sid) => {
213
+ onsessioninitialized: async (sid) => {
191
214
  transports.set(sid, transport);
215
+ for (const hook of api.hooks.mcp.onConnectHooks) {
216
+ await hook(sid);
217
+ }
192
218
  },
193
219
  onsessionclosed: (sid) => {
194
220
  transports.delete(sid);
@@ -197,10 +223,13 @@ export class McpInitializer extends Initializer {
197
223
  },
198
224
  });
199
225
 
200
- transport.onclose = () => {
226
+ transport.onclose = async () => {
201
227
  const sid = transport.sessionId;
202
228
  if (sid) {
203
229
  transports.delete(sid);
230
+ for (const hook of api.hooks.mcp.onDisconnectHooks) {
231
+ await hook(sid);
232
+ }
204
233
  }
205
234
  const idx = mcpServers.indexOf(mcpServer);
206
235
  if (idx !== -1) mcpServers.splice(idx, 1);
@@ -208,6 +237,9 @@ export class McpInitializer extends Initializer {
208
237
 
209
238
  await mcpServer.connect(transport);
210
239
 
240
+ for (const hook of api.hooks.mcp.onMessageHooks) {
241
+ await hook(undefined);
242
+ }
211
243
  return handleTransportRequest(transport, req, authInfo, corsHeaders);
212
244
  }
213
245
 
@@ -221,6 +253,9 @@ export class McpInitializer extends Initializer {
221
253
  );
222
254
  }
223
255
 
256
+ for (const hook of api.hooks.mcp.onMessageHooks) {
257
+ await hook(sessionId);
258
+ }
224
259
  return handleTransportRequest(transport, req, authInfo, corsHeaders);
225
260
  }
226
261
 
@@ -17,6 +17,55 @@ declare module "keryx" {
17
17
  }
18
18
  }
19
19
 
20
+ type MeterAdd = (value: number, attributes?: Record<string, string>) => void;
21
+ type MeterRecord = (value: number, attributes?: Record<string, string>) => void;
22
+
23
+ interface HttpMeters {
24
+ requestsTotal: { add: MeterAdd };
25
+ requestDuration: { record: MeterRecord };
26
+ }
27
+ interface WsMeters {
28
+ connections: { add: MeterAdd };
29
+ messagesTotal: { add: MeterAdd };
30
+ }
31
+ interface McpMeters {
32
+ sessions: { add: MeterAdd };
33
+ messagesTotal: { add: MeterAdd };
34
+ }
35
+ interface ActionMeters {
36
+ executionsTotal: { add: MeterAdd };
37
+ duration: { record: MeterRecord };
38
+ }
39
+ interface TaskMeters {
40
+ enqueuedTotal: { add: MeterAdd };
41
+ executedTotal: { add: MeterAdd };
42
+ duration: { record: MeterRecord };
43
+ }
44
+
45
+ const noopAdd: MeterAdd = () => {};
46
+ const noopRecord: MeterRecord = () => {};
47
+ const noopHttp = (): HttpMeters => ({
48
+ requestsTotal: { add: noopAdd },
49
+ requestDuration: { record: noopRecord },
50
+ });
51
+ const noopWs = (): WsMeters => ({
52
+ connections: { add: noopAdd },
53
+ messagesTotal: { add: noopAdd },
54
+ });
55
+ const noopMcp = (): McpMeters => ({
56
+ sessions: { add: noopAdd },
57
+ messagesTotal: { add: noopAdd },
58
+ });
59
+ const noopAction = (): ActionMeters => ({
60
+ executionsTotal: { add: noopAdd },
61
+ duration: { record: noopRecord },
62
+ });
63
+ const noopTask = (): TaskMeters => ({
64
+ enqueuedTotal: { add: noopAdd },
65
+ executedTotal: { add: noopAdd },
66
+ duration: { record: noopRecord },
67
+ });
68
+
20
69
  /**
21
70
  * Observability initializer — provides OpenTelemetry-based metrics for HTTP requests,
22
71
  * WebSocket connections, action executions, and background tasks.
@@ -24,41 +73,86 @@ declare module "keryx" {
24
73
  * Enable via `OTEL_METRICS_ENABLED=true`. The built-in Prometheus scrape endpoint is
25
74
  * served at `config.observability.metricsRoute` (default `/metrics`) on the existing
26
75
  * web server.
76
+ *
77
+ * All emission is wired through `api.hooks.*` — call sites elsewhere in the framework
78
+ * do not reference `api.observability` directly. Meter instruments are private to the
79
+ * initializer; apps that need custom metrics should create their own `Meter` off the
80
+ * global OTel `MeterProvider` that this initializer installs.
27
81
  */
28
82
  export class Observability extends Initializer {
83
+ private httpMeters: HttpMeters = noopHttp();
84
+ private wsMeters: WsMeters = noopWs();
85
+ private mcpMeters: McpMeters = noopMcp();
86
+ private actionMeters: ActionMeters = noopAction();
87
+ private taskMeters: TaskMeters = noopTask();
88
+
29
89
  constructor() {
30
90
  super(namespace);
31
- this.dependsOn = ["actions", "connections"];
91
+ this.dependsOn = ["hooks", "actions", "connections"];
32
92
  }
33
93
 
34
94
  async initialize() {
35
- const noopAdd = (
36
- _value: number,
37
- _attributes?: Record<string, string>,
38
- ) => {};
39
- const noopRecord = (
40
- _value: number,
41
- _attributes?: Record<string, string>,
42
- ) => {};
43
- return {
95
+ const ns = {
44
96
  enabled: false,
45
- http: {
46
- requestsTotal: { add: noopAdd },
47
- requestDuration: { record: noopRecord },
48
- activeConnections: { add: noopAdd },
49
- },
50
- ws: { connections: { add: noopAdd }, messagesTotal: { add: noopAdd } },
51
- action: {
52
- executionsTotal: { add: noopAdd },
53
- duration: { record: noopRecord },
54
- },
55
- task: {
56
- enqueuedTotal: { add: noopAdd },
57
- executedTotal: { add: noopAdd },
58
- duration: { record: noopRecord },
59
- },
60
97
  collectMetrics: async () => "" as string,
61
98
  };
99
+
100
+ api.hooks.actions.afterAct((actionName, _params, _conn, _ctx, outcome) => {
101
+ this.actionMeters.executionsTotal.add(1, {
102
+ action: actionName,
103
+ status: outcome.success ? "success" : "error",
104
+ });
105
+ this.actionMeters.duration.record(outcome.duration, {
106
+ action: actionName,
107
+ });
108
+ });
109
+
110
+ api.hooks.actions.onEnqueue((actionName, _inputs, queue) => {
111
+ this.taskMeters.enqueuedTotal.add(1, { action: actionName, queue });
112
+ });
113
+
114
+ api.hooks.resque.afterJob((actionName, _params, ctx, outcome) => {
115
+ this.taskMeters.executedTotal.add(1, {
116
+ action: actionName,
117
+ queue: ctx.queue,
118
+ status: outcome.success ? "success" : "failure",
119
+ });
120
+ this.taskMeters.duration.record(outcome.duration, {
121
+ action: actionName,
122
+ });
123
+ });
124
+
125
+ api.hooks.web.afterRequest((_req, _res, _ctx, outcome) => {
126
+ const labels = {
127
+ method: outcome.method,
128
+ route: outcome.actionName ?? "unknown",
129
+ status: String(outcome.status),
130
+ };
131
+ this.httpMeters.requestsTotal.add(1, labels);
132
+ this.httpMeters.requestDuration.record(outcome.durationMs, labels);
133
+ });
134
+
135
+ api.hooks.ws.onConnect(() => {
136
+ this.wsMeters.connections.add(1);
137
+ });
138
+ api.hooks.ws.onMessage(() => {
139
+ this.wsMeters.messagesTotal.add(1);
140
+ });
141
+ api.hooks.ws.onDisconnect(() => {
142
+ this.wsMeters.connections.add(-1);
143
+ });
144
+
145
+ api.hooks.mcp.onConnect(() => {
146
+ this.mcpMeters.sessions.add(1);
147
+ });
148
+ api.hooks.mcp.onMessage(() => {
149
+ this.mcpMeters.messagesTotal.add(1);
150
+ });
151
+ api.hooks.mcp.onDisconnect(() => {
152
+ this.mcpMeters.sessions.add(-1);
153
+ });
154
+
155
+ return ns;
62
156
  }
63
157
 
64
158
  async start() {
@@ -118,52 +212,58 @@ export class Observability extends Initializer {
118
212
  metrics.setGlobalMeterProvider(meterProvider);
119
213
  const meter = meterProvider.getMeter(serviceName);
120
214
 
121
- const ns = api.observability;
122
- ns.enabled = true;
215
+ this.httpMeters = {
216
+ requestsTotal: meter.createCounter("keryx.http.requests", {
217
+ description: "Total number of HTTP requests received",
218
+ }),
219
+ requestDuration: meter.createHistogram("keryx.http.request.duration", {
220
+ description: "HTTP request duration in milliseconds",
221
+ unit: "ms",
222
+ }),
223
+ };
123
224
 
124
- // --- HTTP Metrics ---
125
- ns.http.requestsTotal = meter.createCounter("keryx.http.requests", {
126
- description: "Total number of HTTP requests received",
127
- });
128
- ns.http.requestDuration = meter.createHistogram(
129
- "keryx.http.request.duration",
130
- { description: "HTTP request duration in milliseconds", unit: "ms" },
131
- );
132
- ns.http.activeConnections = meter.createUpDownCounter(
133
- "keryx.http.active_connections",
134
- { description: "Number of active HTTP connections" },
135
- );
136
-
137
- // --- WebSocket Metrics ---
138
- ns.ws.connections = meter.createUpDownCounter("keryx.ws.connections", {
139
- description: "Number of active WebSocket connections",
140
- });
141
- ns.ws.messagesTotal = meter.createCounter("keryx.ws.messages", {
142
- description: "Total WebSocket messages received",
143
- });
225
+ this.wsMeters = {
226
+ connections: meter.createUpDownCounter("keryx.ws.connections", {
227
+ description: "Number of active WebSocket connections",
228
+ }),
229
+ messagesTotal: meter.createCounter("keryx.ws.messages", {
230
+ description: "Total WebSocket messages received",
231
+ }),
232
+ };
144
233
 
145
- // --- Action Metrics ---
146
- ns.action.executionsTotal = meter.createCounter("keryx.action.executions", {
147
- description: "Total action executions",
148
- });
149
- ns.action.duration = meter.createHistogram("keryx.action.duration", {
150
- description: "Action execution duration in milliseconds",
151
- unit: "ms",
152
- });
234
+ this.mcpMeters = {
235
+ sessions: meter.createUpDownCounter("keryx.mcp.sessions", {
236
+ description: "Number of active MCP sessions",
237
+ }),
238
+ messagesTotal: meter.createCounter("keryx.mcp.messages", {
239
+ description: "Total MCP requests received",
240
+ }),
241
+ };
153
242
 
154
- // --- Task Metrics ---
155
- ns.task.enqueuedTotal = meter.createCounter("keryx.task.enqueued", {
156
- description: "Total tasks enqueued",
157
- });
158
- ns.task.executedTotal = meter.createCounter("keryx.task.executed", {
159
- description: "Total tasks executed by workers",
160
- });
161
- ns.task.duration = meter.createHistogram("keryx.task.duration", {
162
- description: "Task execution duration in milliseconds",
163
- unit: "ms",
164
- });
243
+ this.actionMeters = {
244
+ executionsTotal: meter.createCounter("keryx.action.executions", {
245
+ description: "Total action executions",
246
+ }),
247
+ duration: meter.createHistogram("keryx.action.duration", {
248
+ description: "Action execution duration in milliseconds",
249
+ unit: "ms",
250
+ }),
251
+ };
252
+
253
+ this.taskMeters = {
254
+ enqueuedTotal: meter.createCounter("keryx.task.enqueued", {
255
+ description: "Total tasks enqueued",
256
+ }),
257
+ executedTotal: meter.createCounter("keryx.task.executed", {
258
+ description: "Total tasks executed by workers",
259
+ }),
260
+ duration: meter.createHistogram("keryx.task.duration", {
261
+ description: "Task execution duration in milliseconds",
262
+ unit: "ms",
263
+ }),
264
+ };
165
265
 
166
- // --- System Metrics (observable gauges) ---
266
+ // System gauge reads directly from api.connections at scrape time
167
267
  meter
168
268
  .createObservableGauge("keryx.system.connections", {
169
269
  description: "Current number of connections",
@@ -174,43 +274,30 @@ export class Observability extends Initializer {
174
274
  }
175
275
  });
176
276
 
177
- ns.collectMetrics = async () => {
277
+ api.observability.collectMetrics = async () => {
178
278
  const { resourceMetrics, errors } = await reader.collect();
179
279
  if (errors?.length) {
180
280
  logger.warn(`Metrics collection errors: ${errors.join(", ")}`);
181
281
  }
182
282
  return serializeToPrometheus(resourceMetrics);
183
283
  };
284
+ api.observability.enabled = true;
184
285
 
185
286
  logger.info(`Observability initialized (service: ${serviceName})`);
186
287
  }
187
288
 
188
289
  async stop() {
189
- // Reset to no-ops so the next start() cycle can re-initialize cleanly.
290
+ // Reset meters to no-ops so the next start() cycle can re-initialize cleanly.
190
291
  // MeterProvider is intentionally NOT shut down — shutting it down would
191
292
  // make the reader unable to collect, but start() may create a new one.
192
293
  // In production stop() is called right before process exit.
193
- const noopAdd = (
194
- _value: number,
195
- _attributes?: Record<string, string>,
196
- ) => {};
197
- const noopRecord = (
198
- _value: number,
199
- _attributes?: Record<string, string>,
200
- ) => {};
201
- const ns = api.observability;
202
- ns.enabled = false;
203
- ns.http.requestsTotal = { add: noopAdd };
204
- ns.http.requestDuration = { record: noopRecord };
205
- ns.http.activeConnections = { add: noopAdd };
206
- ns.ws.connections = { add: noopAdd };
207
- ns.ws.messagesTotal = { add: noopAdd };
208
- ns.action.executionsTotal = { add: noopAdd };
209
- ns.action.duration = { record: noopRecord };
210
- ns.task.enqueuedTotal = { add: noopAdd };
211
- ns.task.executedTotal = { add: noopAdd };
212
- ns.task.duration = { record: noopRecord };
213
- ns.collectMetrics = async () => "";
294
+ this.httpMeters = noopHttp();
295
+ this.wsMeters = noopWs();
296
+ this.mcpMeters = noopMcp();
297
+ this.actionMeters = noopAction();
298
+ this.taskMeters = noopTask();
299
+ api.observability.collectMetrics = async () => "";
300
+ api.observability.enabled = false;
214
301
  }
215
302
  }
216
303
 
@@ -18,9 +18,56 @@ import {
18
18
  import { Initializer } from "../classes/Initializer";
19
19
  import { LogFormat } from "../classes/Logger";
20
20
  import { TypedError } from "../classes/TypedError";
21
+ import type { TaskInputs } from "./actionts";
21
22
 
22
23
  const namespace = "resque";
23
24
 
25
+ /**
26
+ * Per-job context passed to {@link BeforeJobHook} and {@link AfterJobHook}.
27
+ * The same object instance is threaded from `beforeJob` to `afterJob`, so hooks can
28
+ * stash span refs, timing data, or any other state in `metadata`.
29
+ */
30
+ export interface JobContext {
31
+ /** The queue this job was pulled from. Populated from the node-resque worker. */
32
+ queue: string;
33
+ /** Mutable scratch space shared between `beforeJob` and `afterJob`. */
34
+ metadata: Record<string, unknown>;
35
+ }
36
+
37
+ /**
38
+ * Unified outcome passed to {@link AfterJobHook}. Discriminate via the `success` field.
39
+ * Covers both the worker `success` and `failure` paths in a single shape.
40
+ */
41
+ export type JobOutcome =
42
+ | { success: true; result: unknown; duration: number }
43
+ | { success: false; error: unknown; duration: number };
44
+
45
+ /**
46
+ * Runs inside the job wrapper immediately before the action executes (i.e. before
47
+ * `connection.act()`). Receives the action name and decoded params, giving plugins
48
+ * access to trace headers or other correlation data embedded in inputs. Hooks run
49
+ * sequentially in registration order. Throwing fails the job.
50
+ */
51
+ export type BeforeJobHook = (
52
+ actionName: string,
53
+ params: TaskInputs,
54
+ ctx: JobContext,
55
+ ) => Promise<void> | void;
56
+
57
+ /**
58
+ * Runs inside the job wrapper after the action executes, in a `finally` block so it
59
+ * fires for both success and failure. Receives the same `ctx` passed to `beforeJob`
60
+ * plus a {@link JobOutcome} describing what happened. Hooks run sequentially in
61
+ * registration order. Errors thrown by an `afterJob` hook do not mask an action
62
+ * error but may surface instead of it if the action succeeded.
63
+ */
64
+ export type AfterJobHook = (
65
+ actionName: string,
66
+ params: TaskInputs,
67
+ ctx: JobContext,
68
+ outcome: JobOutcome,
69
+ ) => Promise<void> | void;
70
+
24
71
  function logResqueEvent(
25
72
  level: "info" | "warn",
26
73
  textMessage: string,
@@ -49,7 +96,7 @@ let SERVER_JOB_COUNTER = 1;
49
96
  export class Resque extends Initializer {
50
97
  constructor() {
51
98
  super(namespace);
52
- this.dependsOn = ["redis", "actions", "process"];
99
+ this.dependsOn = ["redis", "actions", "process", "hooks"];
53
100
  }
54
101
 
55
102
  /** Create and connect the resque `Queue` instance (used for enqueuing jobs). */
@@ -167,14 +214,6 @@ export class Resque extends Initializer {
167
214
  });
168
215
 
169
216
  worker.on("failure", (queue, job, failure, duration) => {
170
- api.observability.task.executedTotal.add(1, {
171
- action: job.class,
172
- queue,
173
- status: "failure",
174
- });
175
- api.observability.task.duration.record(duration, {
176
- action: job.class,
177
- });
178
217
  logResqueEvent(
179
218
  "warn",
180
219
  `[resque:${worker.name}] job failed, ${queue}, ${job.class}, ${JSON.stringify(job?.args[0] ?? {})}: ${failure} (${duration}ms)`,
@@ -205,14 +244,6 @@ export class Resque extends Initializer {
205
244
  });
206
245
 
207
246
  worker.on("success", (queue, job: ParsedJob, result, duration) => {
208
- api.observability.task.executedTotal.add(1, {
209
- action: job.class,
210
- queue,
211
- status: "success",
212
- });
213
- api.observability.task.duration.record(duration, {
214
- action: job.class,
215
- });
216
247
  logResqueEvent(
217
248
  "info",
218
249
  `[resque:${worker.name}] job success ${queue}, ${job.class}, ${JSON.stringify(job.args[0])} | ${JSON.stringify(result)} (${duration}ms)`,
@@ -322,15 +353,41 @@ export class Resque extends Initializer {
322
353
 
323
354
  const fanOutId = plainParams._fanOutId as string | undefined;
324
355
 
356
+ // node-resque invokes `perform` via `.apply(worker, args)`, so `this`
357
+ // is the Worker and `Worker.queue` is the queue the current job was
358
+ // pulled from. TypeScript infers `this` as the Job here because
359
+ // `perform` lives inside the Job literal, so cast through `unknown` to
360
+ // read the runtime binding. Exposed on JobContext so hooks (e.g.
361
+ // observability) can label per-job metrics.
362
+ const runtimeThis = this as unknown as { queue?: unknown };
363
+ const currentQueue =
364
+ typeof runtimeThis?.queue === "string" ? runtimeThis.queue : "";
365
+ const jobCtx: JobContext = { queue: currentQueue, metadata: {} };
366
+ const jobStartTime = Date.now();
367
+
325
368
  let response: Awaited<ReturnType<(typeof action)["run"]>>;
326
369
  let error: TypedError | undefined;
370
+ let outcome: JobOutcome | undefined;
327
371
  try {
372
+ for (const hook of api.hooks.resque.beforeJobHooks) {
373
+ await hook(action.name, plainParams, jobCtx);
374
+ }
328
375
  const payload = await connection.act(action.name, plainParams);
329
376
  response = payload.response;
330
377
  error = payload.error;
331
378
 
332
379
  if (error) throw error;
380
+ outcome = {
381
+ success: true,
382
+ result: response,
383
+ duration: Date.now() - jobStartTime,
384
+ };
333
385
  } catch (e) {
386
+ outcome = {
387
+ success: false,
388
+ error: e,
389
+ duration: Date.now() - jobStartTime,
390
+ };
334
391
  // Collect fan-out error before re-throwing
335
392
  if (fanOutId) {
336
393
  const metaKey = `fanout:${fanOutId}`;
@@ -351,6 +408,11 @@ export class Resque extends Initializer {
351
408
  }
352
409
  throw e;
353
410
  } finally {
411
+ if (outcome) {
412
+ for (const hook of api.hooks.resque.afterJobHooks) {
413
+ await hook(action.name, plainParams, jobCtx, outcome);
414
+ }
415
+ }
354
416
  if (
355
417
  action.task &&
356
418
  action.task.frequency &&
@@ -17,7 +17,7 @@ declare module "keryx" {
17
17
  export class Servers extends Initializer {
18
18
  constructor() {
19
19
  super(namespace);
20
- this.dependsOn = ["actions"];
20
+ this.dependsOn = ["actions", "hooks"];
21
21
  this.runModes = [RUN_MODE.SERVER];
22
22
  }
23
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.25.5",
3
+ "version": "0.29.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -25,10 +25,88 @@ import {
25
25
  } from "../util/webSocket";
26
26
  import { handleStaticFile } from "../util/webStaticFiles";
27
27
 
28
+ /**
29
+ * Per-request context passed to {@link BeforeRequestHook} and {@link AfterRequestHook}.
30
+ * The same object instance is passed to both hooks for a given request, so `beforeRequest`
31
+ * implementations can stash state (e.g. span refs, start time) in `metadata` for
32
+ * `afterRequest` to pick up.
33
+ */
34
+ export interface RequestContext {
35
+ /** Client IP address as reported by `server.requestIP()`, or `"unknown-IP"`. */
36
+ ip: string;
37
+ /** Session id from the session cookie, or a freshly minted UUID. */
38
+ id: string;
39
+ /** Mutable scratch space shared between `beforeRequest` and `afterRequest`. */
40
+ metadata: Record<string, unknown>;
41
+ }
42
+
43
+ /**
44
+ * Outcome payload passed to {@link AfterRequestHook} describing the routed request.
45
+ * `actionName` is `undefined` for paths that don't resolve to an action (static files,
46
+ * OAuth/MCP endpoints, `/metrics`, 404s).
47
+ */
48
+ export interface RequestOutcome {
49
+ /** HTTP method (uppercased). */
50
+ method: string;
51
+ /** Response status code. */
52
+ status: number;
53
+ /** Resolved action name, if the request routed to an action. */
54
+ actionName?: string;
55
+ /** End-to-end handling time in milliseconds (measured at hook-fire time). */
56
+ durationMs: number;
57
+ }
58
+
59
+ /**
60
+ * Runs at the start of every HTTP request, before any routing or static file handling.
61
+ * WebSocket upgrades do not fire this hook. Throwing an error propagates out of the
62
+ * request handler. Hooks run sequentially in registration order.
63
+ */
64
+ export type BeforeRequestHook = (
65
+ req: Request,
66
+ ctx: RequestContext,
67
+ ) => Promise<void> | void;
68
+
69
+ /**
70
+ * Runs after the `Response` is built and before compression. Receives the same `ctx`
71
+ * object that was passed to the matching `beforeRequest`, plus a {@link RequestOutcome}
72
+ * with the resolved routing decision (action name, status, duration). Hooks run
73
+ * sequentially in registration order.
74
+ */
75
+ export type AfterRequestHook = (
76
+ req: Request,
77
+ res: Response,
78
+ ctx: RequestContext,
79
+ outcome: RequestOutcome,
80
+ ) => Promise<void> | void;
81
+
82
+ /**
83
+ * Runs when a new WebSocket connection is accepted, after the {@link Connection}
84
+ * has been constructed and registered. Register via `api.hooks.ws.onConnect(...)`.
85
+ */
86
+ export type OnConnectHook = (connection: Connection) => Promise<void> | void;
87
+
88
+ /**
89
+ * Runs for each inbound WebSocket message, after rate-limiting but before parsing.
90
+ * Register via `api.hooks.ws.onMessage(...)`.
91
+ */
92
+ export type OnMessageHook = (
93
+ connection: Connection,
94
+ message: string | Buffer,
95
+ ) => Promise<void> | void;
96
+
97
+ /**
98
+ * Runs when a WebSocket connection closes, before channel presence is cleaned up
99
+ * and the connection is destroyed. Register via `api.hooks.ws.onDisconnect(...)`.
100
+ */
101
+ export type OnDisconnectHook = (connection: Connection) => Promise<void> | void;
102
+
28
103
  /**
29
104
  * HTTP + WebSocket server built on `Bun.serve`. Handles REST action routing (with path params),
30
105
  * static file serving (with ETag/304 caching), WebSocket connections (actions, PubSub subscribe/unsubscribe),
31
- * OAuth endpoints, and MCP SSE streams. Exposes `api.servers.web`.
106
+ * OAuth endpoints, and MCP SSE streams.
107
+ *
108
+ * Plugins register HTTP lifecycle hooks via `api.hooks.web.beforeRequest` /
109
+ * `api.hooks.web.afterRequest`.
32
110
  */
33
111
  export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
34
112
  /** The actual port the server bound to (resolved after start, e.g. when config port is 0). */
@@ -169,7 +247,28 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
169
247
  )
170
248
  return; // upgrade the request to a WebSocket
171
249
 
172
- const response = await this.handleHttpRequest(req, server, ip, id);
250
+ const ctx: RequestContext = { ip, id, metadata: {} };
251
+ const requestStart = Date.now();
252
+ for (const hook of api.hooks.web.beforeRequestHooks) {
253
+ await hook(req, ctx);
254
+ }
255
+
256
+ const { response, actionName } = await this.handleHttpRequest(
257
+ req,
258
+ server,
259
+ ip,
260
+ id,
261
+ );
262
+
263
+ const outcome: RequestOutcome = {
264
+ method: req.method.toUpperCase(),
265
+ status: response.status,
266
+ actionName,
267
+ durationMs: Date.now() - requestStart,
268
+ };
269
+ for (const hook of api.hooks.web.afterRequestHooks) {
270
+ await hook(req, response, ctx, outcome);
271
+ }
173
272
 
174
273
  // SSE and other streaming responses: disable idle timeout and skip compression
175
274
  if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
@@ -183,25 +282,27 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
183
282
  /**
184
283
  * Routes an HTTP request to the appropriate handler (static files, OAuth, MCP, metrics, or actions).
185
284
  * Called after WebSocket upgrade handling; the returned Response is compressed by the caller.
285
+ * Returns `actionName` alongside the response so `afterRequest` hooks can receive a
286
+ * {@link RequestOutcome} without snooping on internal routing state.
186
287
  */
187
288
  private async handleHttpRequest(
188
289
  req: Request,
189
290
  server: ReturnType<typeof Bun.serve>,
190
291
  ip: string,
191
292
  id: string,
192
- ): Promise<Response> {
293
+ ): Promise<{ response: Response; actionName?: string }> {
193
294
  const parsedUrl = parse(req.url!, true);
194
295
 
195
296
  // Handle static file serving
196
297
  if (config.server.web.staticFiles.enabled && req.method === "GET") {
197
298
  const staticResponse = await handleStaticFile(req, parsedUrl);
198
- if (staticResponse) return staticResponse;
299
+ if (staticResponse) return { response: staticResponse };
199
300
  }
200
301
 
201
302
  // OAuth route interception (must come before MCP route check)
202
303
  if (config.server.mcp.enabled && api.oauth?.handleRequest) {
203
304
  const oauthResponse = await api.oauth.handleRequest(req, ip);
204
- if (oauthResponse) return oauthResponse;
305
+ if (oauthResponse) return { response: oauthResponse };
205
306
  }
206
307
 
207
308
  // MCP route interception
@@ -211,7 +312,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
211
312
  api.mcp?.handleRequest
212
313
  ) {
213
314
  server.timeout(req, 0); // disable idle timeout for long-lived MCP SSE streams
214
- return api.mcp.handleRequest(req, ip);
315
+ return { response: await api.mcp.handleRequest(req, ip) };
215
316
  }
216
317
  }
217
318
 
@@ -221,32 +322,36 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
221
322
  parsedUrl.pathname === config.observability.metricsRoute
222
323
  ) {
223
324
  const body = await api.observability.collectMetrics();
224
- return new Response(body || "", {
225
- status: 200,
226
- headers: {
227
- "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
228
- },
229
- });
325
+ return {
326
+ response: new Response(body || "", {
327
+ status: 200,
328
+ headers: {
329
+ "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
330
+ },
331
+ }),
332
+ };
230
333
  }
231
334
 
232
335
  // Don't route .well-known paths to actions (covers both root and
233
336
  // sub-path variants like /mcp/.well-known/openid-configuration)
234
337
  if (parsedUrl.pathname?.includes("/.well-known/")) {
235
- return new Response(null, { status: 404 });
338
+ return { response: new Response(null, { status: 404 }) };
236
339
  }
237
340
 
238
341
  return this.handleWebAction(req, parsedUrl, ip, id);
239
342
  }
240
343
 
241
344
  /** Called when a new WebSocket connection opens. Creates a `Connection` and wires up broadcast delivery. */
242
- handleWebSocketConnectionOpen(ws: ServerWebSocket) {
345
+ async handleWebSocketConnectionOpen(ws: ServerWebSocket) {
243
346
  //@ts-expect-error (ws.data is not defined in the bun types)
244
347
  const { ip, id, wsConnectionId } = ws.data;
245
348
  const connection = new Connection("websocket", ip, wsConnectionId, ws, id);
246
349
  connection.onBroadcastMessageReceived = function (payload: PubSubMessage) {
247
350
  ws.send(JSON.stringify({ message: payload }));
248
351
  };
249
- api.observability.ws.connections.add(1);
352
+ for (const hook of api.hooks.ws.onConnectHooks) {
353
+ await hook(connection);
354
+ }
250
355
  logger.info(
251
356
  `New websocket connection from ${connection.identifier} (${connection.id})`,
252
357
  );
@@ -300,7 +405,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
300
405
  }
301
406
  }
302
407
 
303
- api.observability.ws.messagesTotal.add(1);
408
+ for (const hook of api.hooks.ws.onMessageHooks) {
409
+ await hook(connection, message);
410
+ }
304
411
 
305
412
  try {
306
413
  const parsedMessage = JSON.parse(message.toString());
@@ -341,7 +448,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
341
448
  );
342
449
  if (!connection) return;
343
450
 
344
- api.observability.ws.connections.add(-1);
451
+ for (const hook of api.hooks.ws.onDisconnectHooks) {
452
+ await hook(connection);
453
+ }
345
454
  this.wsRateMap.delete(connection.id);
346
455
 
347
456
  try {
@@ -368,7 +477,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
368
477
  url: ReturnType<typeof parse>,
369
478
  ip: string,
370
479
  id: string,
371
- ) {
480
+ ): Promise<{ response: Response; actionName?: string }> {
372
481
  if (!this.server) {
373
482
  throw new TypedError({
374
483
  message: "Server server not started",
@@ -376,11 +485,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
376
485
  });
377
486
  }
378
487
 
379
- const httpStartTime = Date.now();
380
488
  let errorStatusCode = 500;
381
489
  const httpMethod = req.method?.toUpperCase() as HTTP_METHOD;
382
490
 
383
- api.observability.http.activeConnections.add(1);
384
491
  const connection = new Connection("web", ip, id);
385
492
 
386
493
  if (
@@ -397,8 +504,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
397
504
 
398
505
  // Handle OPTIONS requests.
399
506
  // As we don't really know what action the client wants (HTTP Method is always OPTIONS), we just return a 200 response.
400
- if (httpMethod === "OPTIONS")
401
- return buildResponse(connection, {}, 200, requestOrigin);
507
+ if (httpMethod === "OPTIONS") {
508
+ return { response: buildResponse(connection, {}, 200, requestOrigin) };
509
+ }
402
510
 
403
511
  const { actionName, pathParams } = await determineActionName(
404
512
  url,
@@ -419,39 +527,24 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
419
527
  if (response instanceof StreamingResponse) {
420
528
  response.onClose = () => {
421
529
  connection.destroy();
422
- api.observability.http.activeConnections.add(-1);
423
530
  };
424
-
425
- api.observability.http.requestsTotal.add(1, {
426
- method: httpMethod,
427
- route: actionName ?? "unknown",
428
- status: "200",
429
- });
430
-
431
- return buildResponse(connection, response, 200, requestOrigin);
531
+ return {
532
+ response: buildResponse(connection, response, 200, requestOrigin),
533
+ actionName: actionName ?? undefined,
534
+ };
432
535
  }
433
536
 
434
537
  connection.destroy();
435
- api.observability.http.activeConnections.add(-1);
436
538
 
437
539
  if (error && ErrorStatusCodes[error.type]) {
438
540
  errorStatusCode = ErrorStatusCodes[error.type];
439
541
  }
440
542
 
441
- const statusCode = error ? errorStatusCode : 200;
442
- api.observability.http.requestsTotal.add(1, {
443
- method: httpMethod,
444
- route: actionName ?? "unknown",
445
- status: String(statusCode),
446
- });
447
- api.observability.http.requestDuration.record(Date.now() - httpStartTime, {
448
- method: httpMethod,
449
- route: actionName ?? "unknown",
450
- status: String(statusCode),
451
- });
452
-
453
- return error
454
- ? buildError(connection, error, errorStatusCode, requestOrigin)
455
- : buildResponse(connection, response, 200, requestOrigin);
543
+ return {
544
+ response: error
545
+ ? buildError(connection, error, errorStatusCode, requestOrigin)
546
+ : buildResponse(connection, response, 200, requestOrigin),
547
+ actionName: actionName ?? undefined,
548
+ };
456
549
  }
457
550
  }