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