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.
- package/classes/Connection.ts +67 -8
- package/index.ts +29 -1
- package/initializers/actionts.ts +39 -7
- package/initializers/hooks.ts +193 -0
- package/initializers/mcp.ts +38 -3
- package/initializers/observability.ts +177 -90
- package/initializers/resque.ts +79 -17
- package/initializers/servers.ts +1 -1
- package/package.json +1 -1
- package/servers/web.ts +140 -47
package/classes/Connection.ts
CHANGED
|
@@ -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 {
|
|
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";
|
package/initializers/actionts.ts
CHANGED
|
@@ -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
|
-
|
|
100
|
-
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
+
}
|
package/initializers/mcp.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
|
package/initializers/resque.ts
CHANGED
|
@@ -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 &&
|
package/initializers/servers.ts
CHANGED
package/package.json
CHANGED
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.
|
|
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
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
}
|