keryx 0.25.5 → 0.26.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,6 +242,15 @@ 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
 
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,34 @@ 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
+ AfterJobHook,
41
+ BeforeJobHook,
42
+ JobContext,
43
+ JobOutcome,
44
+ } from "./initializers/resque";
31
45
  export type { SessionData } from "./initializers/session";
32
46
  export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
33
47
  export { TransactionMiddleware } from "./middleware/transaction";
34
- export type { WebServer } from "./servers/web";
48
+ export type {
49
+ AfterRequestHook,
50
+ BeforeRequestHook,
51
+ RequestContext,
52
+ WebServer,
53
+ } from "./servers/web";
35
54
  export { buildProgram } from "./util/cli";
36
55
  export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
37
56
  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,12 @@ export class Actions extends Initializer {
96
129
  });
97
130
  }
98
131
  queue = queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
132
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
99
133
  api.observability.task.enqueuedTotal.add(1, {
100
134
  action: actionName,
101
135
  queue,
102
136
  });
103
- return api.resque.queue.enqueue(queue, actionName, [inputs]);
137
+ return api.resque.queue.enqueue(queue, actionName, [finalInputs]);
104
138
  };
105
139
 
106
140
  /**
@@ -302,11 +336,12 @@ export class Actions extends Initializer {
302
336
  queue: string = DEFAULT_QUEUE,
303
337
  suppressDuplicateTaskError = false,
304
338
  ) => {
339
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
305
340
  return api.resque.queue.enqueueAt(
306
341
  timestamp,
307
342
  queue,
308
343
  actionName,
309
- [inputs],
344
+ [finalInputs],
310
345
  suppressDuplicateTaskError,
311
346
  );
312
347
  };
@@ -328,11 +363,12 @@ export class Actions extends Initializer {
328
363
  queue: string = DEFAULT_QUEUE,
329
364
  suppressDuplicateTaskError = false,
330
365
  ) => {
366
+ const finalInputs = await this.runOnEnqueueHooks(actionName, inputs, queue);
331
367
  return api.resque.queue.enqueueIn(
332
368
  time,
333
369
  queue,
334
370
  actionName,
335
- [inputs],
371
+ [finalInputs],
336
372
  suppressDuplicateTaskError,
337
373
  );
338
374
  };
@@ -0,0 +1,114 @@
1
+ import type { AfterActHook, BeforeActHook } from "../classes/Connection";
2
+ import { Initializer } from "../classes/Initializer";
3
+ import type { AfterRequestHook, BeforeRequestHook } from "../servers/web";
4
+ import type { OnEnqueueHook } from "./actionts";
5
+ import type { AfterJobHook, BeforeJobHook } from "./resque";
6
+
7
+ const namespace = "hooks";
8
+
9
+ declare module "keryx" {
10
+ export interface API {
11
+ [namespace]: Awaited<ReturnType<Hooks["initialize"]>>;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Central registry for framework lifecycle hooks. Plugins register hooks here
17
+ * from their initializer's `initialize()`; the framework iterates the registered
18
+ * hooks at runtime.
19
+ *
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`,
22
+ * `initializers/actionts.ts`, `initializers/resque.ts`).
23
+ */
24
+ export class Hooks extends Initializer {
25
+ private webBeforeRequest: BeforeRequestHook[] = [];
26
+ private webAfterRequest: AfterRequestHook[] = [];
27
+ private actionsOnEnqueue: OnEnqueueHook[] = [];
28
+ private actionsBeforeAct: BeforeActHook[] = [];
29
+ private actionsAfterAct: AfterActHook[] = [];
30
+ private resqueBeforeJob: BeforeJobHook[] = [];
31
+ private resqueAfterJob: AfterJobHook[] = [];
32
+
33
+ constructor() {
34
+ super(namespace);
35
+ }
36
+
37
+ async initialize() {
38
+ const self = this;
39
+ return {
40
+ web: {
41
+ /**
42
+ * Register a hook to run at the start of every HTTP request, before
43
+ * routing. Covers static files, OAuth, MCP, metrics, and actions.
44
+ * Does not fire for WebSocket upgrades.
45
+ */
46
+ beforeRequest(hook: BeforeRequestHook): void {
47
+ self.webBeforeRequest.push(hook);
48
+ },
49
+ /**
50
+ * Register a hook to run after the `Response` is built, before
51
+ * compression. Receives the same `ctx` object passed to `beforeRequest`,
52
+ * so state stashed in `ctx.metadata` flows through.
53
+ */
54
+ afterRequest(hook: AfterRequestHook): void {
55
+ self.webAfterRequest.push(hook);
56
+ },
57
+ /** @internal Iterated by `WebServer.handleIncomingConnection`. */
58
+ beforeRequestHooks:
59
+ self.webBeforeRequest as ReadonlyArray<BeforeRequestHook>,
60
+ /** @internal Iterated by `WebServer.handleIncomingConnection`. */
61
+ afterRequestHooks:
62
+ self.webAfterRequest as ReadonlyArray<AfterRequestHook>,
63
+ },
64
+ actions: {
65
+ /**
66
+ * Register a hook to run on every enqueue. Fires for `enqueue`,
67
+ * `enqueueAt`, `enqueueIn`, and per-job inside `fanOut`. Hooks may
68
+ * mutate inputs by returning a replacement object.
69
+ */
70
+ onEnqueue(hook: OnEnqueueHook): void {
71
+ self.actionsOnEnqueue.push(hook);
72
+ },
73
+ /**
74
+ * Register a hook to run inside `Connection.act()` after params are
75
+ * validated and before the action's `runBefore` middleware. Fires for
76
+ * every action invocation across all transports (web, websocket, task,
77
+ * cli, mcp, …) — inspect `connection.type` to discriminate.
78
+ */
79
+ beforeAct(hook: BeforeActHook): void {
80
+ self.actionsBeforeAct.push(hook);
81
+ },
82
+ /**
83
+ * Register a hook to run inside `Connection.act()` in a `finally` block
84
+ * after the action completes (success or failure). Receives the same
85
+ * `ctx` as `beforeAct` plus a unified {@link ActOutcome}. Fires across
86
+ * all transports.
87
+ */
88
+ afterAct(hook: AfterActHook): void {
89
+ self.actionsAfterAct.push(hook);
90
+ },
91
+ /** @internal Iterated by `Actions.enqueue`, `enqueueAt`, `enqueueIn`. */
92
+ onEnqueueHooks: self.actionsOnEnqueue as ReadonlyArray<OnEnqueueHook>,
93
+ /** @internal Iterated inside `Connection.act`. */
94
+ beforeActHooks: self.actionsBeforeAct as ReadonlyArray<BeforeActHook>,
95
+ /** @internal Iterated inside `Connection.act`. */
96
+ afterActHooks: self.actionsAfterAct as ReadonlyArray<AfterActHook>,
97
+ },
98
+ resque: {
99
+ /** Register a hook to run before each job's action executes. */
100
+ beforeJob(hook: BeforeJobHook): void {
101
+ self.resqueBeforeJob.push(hook);
102
+ },
103
+ /** Register a hook to run after each job's action executes (success or failure). */
104
+ afterJob(hook: AfterJobHook): void {
105
+ self.resqueAfterJob.push(hook);
106
+ },
107
+ /** @internal Iterated inside `wrapActionAsJob.perform`. */
108
+ beforeJobHooks: self.resqueBeforeJob as ReadonlyArray<BeforeJobHook>,
109
+ /** @internal Iterated inside `wrapActionAsJob.perform`. */
110
+ afterJobHooks: self.resqueAfterJob as ReadonlyArray<AfterJobHook>,
111
+ },
112
+ };
113
+ }
114
+ }
@@ -18,9 +18,54 @@ 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
+ /** Mutable scratch space shared between `beforeJob` and `afterJob`. */
32
+ metadata: Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Unified outcome passed to {@link AfterJobHook}. Discriminate via the `success` field.
37
+ * Covers both the worker `success` and `failure` paths in a single shape.
38
+ */
39
+ export type JobOutcome =
40
+ | { success: true; result: unknown; duration: number }
41
+ | { success: false; error: unknown; duration: number };
42
+
43
+ /**
44
+ * Runs inside the job wrapper immediately before the action executes (i.e. before
45
+ * `connection.act()`). Receives the action name and decoded params, giving plugins
46
+ * access to trace headers or other correlation data embedded in inputs. Hooks run
47
+ * sequentially in registration order. Throwing fails the job.
48
+ */
49
+ export type BeforeJobHook = (
50
+ actionName: string,
51
+ params: TaskInputs,
52
+ ctx: JobContext,
53
+ ) => Promise<void> | void;
54
+
55
+ /**
56
+ * Runs inside the job wrapper after the action executes, in a `finally` block so it
57
+ * fires for both success and failure. Receives the same `ctx` passed to `beforeJob`
58
+ * plus a {@link JobOutcome} describing what happened. Hooks run sequentially in
59
+ * registration order. Errors thrown by an `afterJob` hook do not mask an action
60
+ * error but may surface instead of it if the action succeeded.
61
+ */
62
+ export type AfterJobHook = (
63
+ actionName: string,
64
+ params: TaskInputs,
65
+ ctx: JobContext,
66
+ outcome: JobOutcome,
67
+ ) => Promise<void> | void;
68
+
24
69
  function logResqueEvent(
25
70
  level: "info" | "warn",
26
71
  textMessage: string,
@@ -49,7 +94,7 @@ let SERVER_JOB_COUNTER = 1;
49
94
  export class Resque extends Initializer {
50
95
  constructor() {
51
96
  super(namespace);
52
- this.dependsOn = ["redis", "actions", "process"];
97
+ this.dependsOn = ["redis", "actions", "process", "hooks"];
53
98
  }
54
99
 
55
100
  /** Create and connect the resque `Queue` instance (used for enqueuing jobs). */
@@ -322,15 +367,32 @@ export class Resque extends Initializer {
322
367
 
323
368
  const fanOutId = plainParams._fanOutId as string | undefined;
324
369
 
370
+ const jobCtx: JobContext = { metadata: {} };
371
+ const jobStartTime = Date.now();
372
+ for (const hook of api.hooks.resque.beforeJobHooks) {
373
+ await hook(action.name, plainParams, jobCtx);
374
+ }
375
+
325
376
  let response: Awaited<ReturnType<(typeof action)["run"]>>;
326
377
  let error: TypedError | undefined;
378
+ let outcome: JobOutcome | undefined;
327
379
  try {
328
380
  const payload = await connection.act(action.name, plainParams);
329
381
  response = payload.response;
330
382
  error = payload.error;
331
383
 
332
384
  if (error) throw error;
385
+ outcome = {
386
+ success: true,
387
+ result: response,
388
+ duration: Date.now() - jobStartTime,
389
+ };
333
390
  } catch (e) {
391
+ outcome = {
392
+ success: false,
393
+ error: e,
394
+ duration: Date.now() - jobStartTime,
395
+ };
334
396
  // Collect fan-out error before re-throwing
335
397
  if (fanOutId) {
336
398
  const metaKey = `fanout:${fanOutId}`;
@@ -351,6 +413,11 @@ export class Resque extends Initializer {
351
413
  }
352
414
  throw e;
353
415
  } finally {
416
+ if (outcome) {
417
+ for (const hook of api.hooks.resque.afterJobHooks) {
418
+ await hook(action.name, plainParams, jobCtx, outcome);
419
+ }
420
+ }
354
421
  if (
355
422
  action.task &&
356
423
  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.26.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -25,10 +25,49 @@ 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
+ * Runs at the start of every HTTP request, before any routing or static file handling.
45
+ * WebSocket upgrades do not fire this hook. Throwing an error propagates out of the
46
+ * request handler. Hooks run sequentially in registration order.
47
+ */
48
+ export type BeforeRequestHook = (
49
+ req: Request,
50
+ ctx: RequestContext,
51
+ ) => Promise<void> | void;
52
+
53
+ /**
54
+ * 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.
57
+ */
58
+ export type AfterRequestHook = (
59
+ req: Request,
60
+ res: Response,
61
+ ctx: RequestContext,
62
+ ) => Promise<void> | void;
63
+
28
64
  /**
29
65
  * HTTP + WebSocket server built on `Bun.serve`. Handles REST action routing (with path params),
30
66
  * static file serving (with ETag/304 caching), WebSocket connections (actions, PubSub subscribe/unsubscribe),
31
- * OAuth endpoints, and MCP SSE streams. Exposes `api.servers.web`.
67
+ * OAuth endpoints, and MCP SSE streams.
68
+ *
69
+ * Plugins register HTTP lifecycle hooks via `api.hooks.web.beforeRequest` /
70
+ * `api.hooks.web.afterRequest`.
32
71
  */
33
72
  export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
34
73
  /** The actual port the server bound to (resolved after start, e.g. when config port is 0). */
@@ -169,8 +208,17 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
169
208
  )
170
209
  return; // upgrade the request to a WebSocket
171
210
 
211
+ const ctx: RequestContext = { ip, id, metadata: {} };
212
+ for (const hook of api.hooks.web.beforeRequestHooks) {
213
+ await hook(req, ctx);
214
+ }
215
+
172
216
  const response = await this.handleHttpRequest(req, server, ip, id);
173
217
 
218
+ for (const hook of api.hooks.web.afterRequestHooks) {
219
+ await hook(req, response, ctx);
220
+ }
221
+
174
222
  // SSE and other streaming responses: disable idle timeout and skip compression
175
223
  if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
176
224
  server.timeout(req, 0);