keryx 0.26.0 → 0.29.1

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.
@@ -256,14 +256,6 @@ export class Connection<
256
256
 
257
257
  const duration = new Date().getTime() - reqStartTime;
258
258
 
259
- api.observability.action.executionsTotal.add(1, {
260
- action: actionName ?? "unknown",
261
- status: loggerResponsePrefix === "OK" ? "success" : "error",
262
- });
263
- api.observability.action.duration.record(duration, {
264
- action: actionName ?? "unknown",
265
- });
266
-
267
259
  logAction({
268
260
  actionName,
269
261
  connectionType: this.type,
package/index.ts CHANGED
@@ -36,6 +36,11 @@ export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
36
36
  export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
37
37
  export type { KeryxConfig } from "./config";
38
38
  export type { OnEnqueueHook } from "./initializers/actionts";
39
+ export type {
40
+ OnMcpConnectHook,
41
+ OnMcpDisconnectHook,
42
+ OnMcpMessageHook,
43
+ } from "./initializers/mcp";
39
44
  export type {
40
45
  AfterJobHook,
41
46
  BeforeJobHook,
@@ -48,7 +53,11 @@ export { TransactionMiddleware } from "./middleware/transaction";
48
53
  export type {
49
54
  AfterRequestHook,
50
55
  BeforeRequestHook,
56
+ OnConnectHook,
57
+ OnDisconnectHook,
58
+ OnMessageHook,
51
59
  RequestContext,
60
+ RequestOutcome,
52
61
  WebServer,
53
62
  } from "./servers/web";
54
63
  export { buildProgram } from "./util/cli";
@@ -130,10 +130,6 @@ export class Actions extends Initializer {
130
130
  }
131
131
  queue = queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
132
132
  const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
133
- api.observability.task.enqueuedTotal.add(1, {
134
- action: actionName,
135
- queue,
136
- });
137
133
  return api.resque.queue.enqueue(queue, actionName, [finalInputs]);
138
134
  };
139
135
 
@@ -1,7 +1,18 @@
1
1
  import type { AfterActHook, BeforeActHook } from "../classes/Connection";
2
2
  import { Initializer } from "../classes/Initializer";
3
- import type { AfterRequestHook, BeforeRequestHook } from "../servers/web";
3
+ import type {
4
+ AfterRequestHook,
5
+ BeforeRequestHook,
6
+ OnConnectHook,
7
+ OnDisconnectHook,
8
+ OnMessageHook,
9
+ } from "../servers/web";
4
10
  import type { OnEnqueueHook } from "./actionts";
11
+ import type {
12
+ OnMcpConnectHook,
13
+ OnMcpDisconnectHook,
14
+ OnMcpMessageHook,
15
+ } from "./mcp";
5
16
  import type { AfterJobHook, BeforeJobHook } from "./resque";
6
17
 
7
18
  const namespace = "hooks";
@@ -17,13 +28,20 @@ declare module "keryx" {
17
28
  * from their initializer's `initialize()`; the framework iterates the registered
18
29
  * hooks at runtime.
19
30
  *
20
- * Public surface: `api.hooks.web`, `api.hooks.actions`, `api.hooks.resque`. See
21
- * the respective hook type definitions for semantics (in `servers/web.ts`,
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`,
22
34
  * `initializers/actionts.ts`, `initializers/resque.ts`).
23
35
  */
24
36
  export class Hooks extends Initializer {
25
37
  private webBeforeRequest: BeforeRequestHook[] = [];
26
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[] = [];
27
45
  private actionsOnEnqueue: OnEnqueueHook[] = [];
28
46
  private actionsBeforeAct: BeforeActHook[] = [];
29
47
  private actionsAfterAct: AfterActHook[] = [];
@@ -61,6 +79,67 @@ export class Hooks extends Initializer {
61
79
  afterRequestHooks:
62
80
  self.webAfterRequest as ReadonlyArray<AfterRequestHook>,
63
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
+ },
64
143
  actions: {
65
144
  /**
66
145
  * Register a hook to run on every enqueue. Fires for `enqueue`,
@@ -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
 
@@ -28,6 +28,8 @@ const namespace = "resque";
28
28
  * stash span refs, timing data, or any other state in `metadata`.
29
29
  */
30
30
  export interface JobContext {
31
+ /** The queue this job was pulled from. Populated from the node-resque worker. */
32
+ queue: string;
31
33
  /** Mutable scratch space shared between `beforeJob` and `afterJob`. */
32
34
  metadata: Record<string, unknown>;
33
35
  }
@@ -212,14 +214,6 @@ export class Resque extends Initializer {
212
214
  });
213
215
 
214
216
  worker.on("failure", (queue, job, failure, duration) => {
215
- api.observability.task.executedTotal.add(1, {
216
- action: job.class,
217
- queue,
218
- status: "failure",
219
- });
220
- api.observability.task.duration.record(duration, {
221
- action: job.class,
222
- });
223
217
  logResqueEvent(
224
218
  "warn",
225
219
  `[resque:${worker.name}] job failed, ${queue}, ${job.class}, ${JSON.stringify(job?.args[0] ?? {})}: ${failure} (${duration}ms)`,
@@ -250,14 +244,6 @@ export class Resque extends Initializer {
250
244
  });
251
245
 
252
246
  worker.on("success", (queue, job: ParsedJob, result, duration) => {
253
- api.observability.task.executedTotal.add(1, {
254
- action: job.class,
255
- queue,
256
- status: "success",
257
- });
258
- api.observability.task.duration.record(duration, {
259
- action: job.class,
260
- });
261
247
  logResqueEvent(
262
248
  "info",
263
249
  `[resque:${worker.name}] job success ${queue}, ${job.class}, ${JSON.stringify(job.args[0])} | ${JSON.stringify(result)} (${duration}ms)`,
@@ -367,16 +353,25 @@ export class Resque extends Initializer {
367
353
 
368
354
  const fanOutId = plainParams._fanOutId as string | undefined;
369
355
 
370
- const jobCtx: JobContext = { metadata: {} };
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: {} };
371
366
  const jobStartTime = Date.now();
372
- for (const hook of api.hooks.resque.beforeJobHooks) {
373
- await hook(action.name, plainParams, jobCtx);
374
- }
375
367
 
376
368
  let response: Awaited<ReturnType<(typeof action)["run"]>>;
377
369
  let error: TypedError | undefined;
378
370
  let outcome: JobOutcome | undefined;
379
371
  try {
372
+ for (const hook of api.hooks.resque.beforeJobHooks) {
373
+ await hook(action.name, plainParams, jobCtx);
374
+ }
380
375
  const payload = await connection.act(action.name, plainParams);
381
376
  response = payload.response;
382
377
  error = payload.error;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.26.0",
3
+ "version": "0.29.1",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -40,6 +40,22 @@ export interface RequestContext {
40
40
  metadata: Record<string, unknown>;
41
41
  }
42
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
+
43
59
  /**
44
60
  * Runs at the start of every HTTP request, before any routing or static file handling.
45
61
  * WebSocket upgrades do not fire this hook. Throwing an error propagates out of the
@@ -52,15 +68,38 @@ export type BeforeRequestHook = (
52
68
 
53
69
  /**
54
70
  * Runs after the `Response` is built and before compression. Receives the same `ctx`
55
- * object that was passed to the matching `beforeRequest`. Hooks run sequentially in
56
- * registration order.
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.
57
74
  */
58
75
  export type AfterRequestHook = (
59
76
  req: Request,
60
77
  res: Response,
61
78
  ctx: RequestContext,
79
+ outcome: RequestOutcome,
62
80
  ) => Promise<void> | void;
63
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
+
64
103
  /**
65
104
  * HTTP + WebSocket server built on `Bun.serve`. Handles REST action routing (with path params),
66
105
  * static file serving (with ETag/304 caching), WebSocket connections (actions, PubSub subscribe/unsubscribe),
@@ -209,14 +248,26 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
209
248
  return; // upgrade the request to a WebSocket
210
249
 
211
250
  const ctx: RequestContext = { ip, id, metadata: {} };
251
+ const requestStart = Date.now();
212
252
  for (const hook of api.hooks.web.beforeRequestHooks) {
213
253
  await hook(req, ctx);
214
254
  }
215
255
 
216
- const response = await this.handleHttpRequest(req, server, ip, id);
256
+ const { response, actionName } = await this.handleHttpRequest(
257
+ req,
258
+ server,
259
+ ip,
260
+ id,
261
+ );
217
262
 
263
+ const outcome: RequestOutcome = {
264
+ method: req.method.toUpperCase(),
265
+ status: response.status,
266
+ actionName,
267
+ durationMs: Date.now() - requestStart,
268
+ };
218
269
  for (const hook of api.hooks.web.afterRequestHooks) {
219
- await hook(req, response, ctx);
270
+ await hook(req, response, ctx, outcome);
220
271
  }
221
272
 
222
273
  // SSE and other streaming responses: disable idle timeout and skip compression
@@ -231,25 +282,27 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
231
282
  /**
232
283
  * Routes an HTTP request to the appropriate handler (static files, OAuth, MCP, metrics, or actions).
233
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.
234
287
  */
235
288
  private async handleHttpRequest(
236
289
  req: Request,
237
290
  server: ReturnType<typeof Bun.serve>,
238
291
  ip: string,
239
292
  id: string,
240
- ): Promise<Response> {
293
+ ): Promise<{ response: Response; actionName?: string }> {
241
294
  const parsedUrl = parse(req.url!, true);
242
295
 
243
296
  // Handle static file serving
244
297
  if (config.server.web.staticFiles.enabled && req.method === "GET") {
245
298
  const staticResponse = await handleStaticFile(req, parsedUrl);
246
- if (staticResponse) return staticResponse;
299
+ if (staticResponse) return { response: staticResponse };
247
300
  }
248
301
 
249
302
  // OAuth route interception (must come before MCP route check)
250
303
  if (config.server.mcp.enabled && api.oauth?.handleRequest) {
251
304
  const oauthResponse = await api.oauth.handleRequest(req, ip);
252
- if (oauthResponse) return oauthResponse;
305
+ if (oauthResponse) return { response: oauthResponse };
253
306
  }
254
307
 
255
308
  // MCP route interception
@@ -259,7 +312,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
259
312
  api.mcp?.handleRequest
260
313
  ) {
261
314
  server.timeout(req, 0); // disable idle timeout for long-lived MCP SSE streams
262
- return api.mcp.handleRequest(req, ip);
315
+ return { response: await api.mcp.handleRequest(req, ip) };
263
316
  }
264
317
  }
265
318
 
@@ -269,32 +322,36 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
269
322
  parsedUrl.pathname === config.observability.metricsRoute
270
323
  ) {
271
324
  const body = await api.observability.collectMetrics();
272
- return new Response(body || "", {
273
- status: 200,
274
- headers: {
275
- "Content-Type": "text/plain; version=0.0.4; charset=utf-8",
276
- },
277
- });
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
+ };
278
333
  }
279
334
 
280
335
  // Don't route .well-known paths to actions (covers both root and
281
336
  // sub-path variants like /mcp/.well-known/openid-configuration)
282
337
  if (parsedUrl.pathname?.includes("/.well-known/")) {
283
- return new Response(null, { status: 404 });
338
+ return { response: new Response(null, { status: 404 }) };
284
339
  }
285
340
 
286
341
  return this.handleWebAction(req, parsedUrl, ip, id);
287
342
  }
288
343
 
289
344
  /** Called when a new WebSocket connection opens. Creates a `Connection` and wires up broadcast delivery. */
290
- handleWebSocketConnectionOpen(ws: ServerWebSocket) {
345
+ async handleWebSocketConnectionOpen(ws: ServerWebSocket) {
291
346
  //@ts-expect-error (ws.data is not defined in the bun types)
292
347
  const { ip, id, wsConnectionId } = ws.data;
293
348
  const connection = new Connection("websocket", ip, wsConnectionId, ws, id);
294
349
  connection.onBroadcastMessageReceived = function (payload: PubSubMessage) {
295
350
  ws.send(JSON.stringify({ message: payload }));
296
351
  };
297
- api.observability.ws.connections.add(1);
352
+ for (const hook of api.hooks.ws.onConnectHooks) {
353
+ await hook(connection);
354
+ }
298
355
  logger.info(
299
356
  `New websocket connection from ${connection.identifier} (${connection.id})`,
300
357
  );
@@ -348,7 +405,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
348
405
  }
349
406
  }
350
407
 
351
- api.observability.ws.messagesTotal.add(1);
408
+ for (const hook of api.hooks.ws.onMessageHooks) {
409
+ await hook(connection, message);
410
+ }
352
411
 
353
412
  try {
354
413
  const parsedMessage = JSON.parse(message.toString());
@@ -389,7 +448,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
389
448
  );
390
449
  if (!connection) return;
391
450
 
392
- api.observability.ws.connections.add(-1);
451
+ for (const hook of api.hooks.ws.onDisconnectHooks) {
452
+ await hook(connection);
453
+ }
393
454
  this.wsRateMap.delete(connection.id);
394
455
 
395
456
  try {
@@ -416,7 +477,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
416
477
  url: ReturnType<typeof parse>,
417
478
  ip: string,
418
479
  id: string,
419
- ) {
480
+ ): Promise<{ response: Response; actionName?: string }> {
420
481
  if (!this.server) {
421
482
  throw new TypedError({
422
483
  message: "Server server not started",
@@ -424,11 +485,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
424
485
  });
425
486
  }
426
487
 
427
- const httpStartTime = Date.now();
428
488
  let errorStatusCode = 500;
429
489
  const httpMethod = req.method?.toUpperCase() as HTTP_METHOD;
430
490
 
431
- api.observability.http.activeConnections.add(1);
432
491
  const connection = new Connection("web", ip, id);
433
492
 
434
493
  if (
@@ -445,8 +504,9 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
445
504
 
446
505
  // Handle OPTIONS requests.
447
506
  // As we don't really know what action the client wants (HTTP Method is always OPTIONS), we just return a 200 response.
448
- if (httpMethod === "OPTIONS")
449
- return buildResponse(connection, {}, 200, requestOrigin);
507
+ if (httpMethod === "OPTIONS") {
508
+ return { response: buildResponse(connection, {}, 200, requestOrigin) };
509
+ }
450
510
 
451
511
  const { actionName, pathParams } = await determineActionName(
452
512
  url,
@@ -467,39 +527,24 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
467
527
  if (response instanceof StreamingResponse) {
468
528
  response.onClose = () => {
469
529
  connection.destroy();
470
- api.observability.http.activeConnections.add(-1);
471
530
  };
472
-
473
- api.observability.http.requestsTotal.add(1, {
474
- method: httpMethod,
475
- route: actionName ?? "unknown",
476
- status: "200",
477
- });
478
-
479
- return buildResponse(connection, response, 200, requestOrigin);
531
+ return {
532
+ response: buildResponse(connection, response, 200, requestOrigin),
533
+ actionName: actionName ?? undefined,
534
+ };
480
535
  }
481
536
 
482
537
  connection.destroy();
483
- api.observability.http.activeConnections.add(-1);
484
538
 
485
539
  if (error && ErrorStatusCodes[error.type]) {
486
540
  errorStatusCode = ErrorStatusCodes[error.type];
487
541
  }
488
542
 
489
- const statusCode = error ? errorStatusCode : 200;
490
- api.observability.http.requestsTotal.add(1, {
491
- method: httpMethod,
492
- route: actionName ?? "unknown",
493
- status: String(statusCode),
494
- });
495
- api.observability.http.requestDuration.record(Date.now() - httpStartTime, {
496
- method: httpMethod,
497
- route: actionName ?? "unknown",
498
- status: String(statusCode),
499
- });
500
-
501
- return error
502
- ? buildError(connection, error, errorStatusCode, requestOrigin)
503
- : 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
+ };
504
549
  }
505
550
  }
package/util/cli.ts CHANGED
@@ -41,6 +41,10 @@ export async function buildProgram(opts: {
41
41
  .option("--no-interactive", "Skip prompts and use defaults")
42
42
  .option("--no-db", "Skip database setup files")
43
43
  .option("--no-example", "Skip example action")
44
+ .option(
45
+ "--force",
46
+ "Scaffold into an existing directory (skips files that already exist)",
47
+ )
44
48
  .action(async (projectName: string | undefined, cmdOpts) => {
45
49
  let options: ScaffoldOptions;
46
50
 
@@ -49,11 +53,12 @@ export async function buildProgram(opts: {
49
53
  options = {
50
54
  includeDb: cmdOpts.db !== false,
51
55
  includeExample: cmdOpts.example !== false,
56
+ force: cmdOpts.force === true,
52
57
  };
53
58
  } else {
54
59
  const result = await interactiveScaffold(projectName);
55
60
  projectName = result.projectName;
56
- options = result.options;
61
+ options = { ...result.options, force: cmdOpts.force === true };
57
62
  }
58
63
 
59
64
  const targetDir = path.resolve(process.cwd(), projectName);
package/util/scaffold.ts CHANGED
@@ -9,6 +9,12 @@ import { loadScaffoldTemplate as loadTemplate } from "./componentRegistry";
9
9
  export interface ScaffoldOptions {
10
10
  includeDb: boolean;
11
11
  includeExample: boolean;
12
+ /**
13
+ * When true, scaffold into an existing directory instead of refusing.
14
+ * Files that already exist on disk are left untouched (merge-skip); only
15
+ * missing files are created. User files are never overwritten.
16
+ */
17
+ force?: boolean;
12
18
  }
13
19
 
14
20
  async function prompt(question: string, defaultValue: string): Promise<string> {
@@ -263,9 +269,15 @@ export async function scaffoldProject(
263
269
  const keryxVersion = pkg.version;
264
270
  const createdFiles: string[] = [];
265
271
 
266
- if (fs.existsSync(targetDir)) {
272
+ const dirExists = fs.existsSync(targetDir);
273
+ if (dirExists && !options.force) {
267
274
  throw new Error(`Directory "${projectName}" already exists`);
268
275
  }
276
+ if (dirExists && options.force) {
277
+ console.log(
278
+ ` ⚠ scaffolding into existing directory — existing files will be preserved`,
279
+ );
280
+ }
269
281
 
270
282
  fs.mkdirSync(targetDir, { recursive: true });
271
283
 
@@ -273,6 +285,10 @@ export async function scaffoldProject(
273
285
 
274
286
  const write = async (filePath: string, content: string) => {
275
287
  const fullPath = path.join(targetDir, filePath);
288
+ if (options.force && fs.existsSync(fullPath)) {
289
+ console.log(` ⊘ skipped ${filePath}`);
290
+ return;
291
+ }
276
292
  fs.mkdirSync(path.dirname(fullPath), { recursive: true });
277
293
  await Bun.write(fullPath, content);
278
294
  createdFiles.push(filePath);