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.
- package/classes/Connection.ts +67 -0
- package/index.ts +20 -1
- package/initializers/actionts.ts +39 -3
- package/initializers/hooks.ts +114 -0
- package/initializers/resque.ts +68 -1
- package/initializers/servers.ts +1 -1
- package/package.json +1 -1
- package/servers/web.ts +49 -1
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,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 {
|
|
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";
|
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,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, [
|
|
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
|
-
[
|
|
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
|
-
[
|
|
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
|
+
}
|
package/initializers/resque.ts
CHANGED
|
@@ -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 &&
|
package/initializers/servers.ts
CHANGED
package/package.json
CHANGED
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.
|
|
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);
|