keryx 0.4.0 → 0.6.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/api.ts CHANGED
@@ -40,6 +40,9 @@ if (!globalThis.api) {
40
40
  globalThis.config = Config;
41
41
  }
42
42
 
43
+ /** The global API singleton. All framework state (actions, db, redis, etc.) is accessed through this object. */
43
44
  export const api = globalThis.api;
45
+ /** Convenience re-export of `api.logger`. */
44
46
  export const logger = globalThis.logger;
47
+ /** The merged configuration object (framework defaults + user overrides + env vars). */
45
48
  export const config = globalThis.config;
package/classes/API.ts CHANGED
@@ -8,6 +8,7 @@ import type { Initializer, InitializerSortKeys } from "./Initializer";
8
8
  import { Logger } from "./Logger";
9
9
  import { ErrorType, TypedError } from "./TypedError";
10
10
 
11
+ /** The mode the API process is running in, which determines which initializers start. */
11
12
  export enum RUN_MODE {
12
13
  CLI = "cli",
13
14
  SERVER = "server",
@@ -15,15 +16,29 @@ export enum RUN_MODE {
15
16
 
16
17
  let flapPreventer = false;
17
18
 
19
+ /**
20
+ * The global singleton that manages the full framework lifecycle: initialize → start → stop.
21
+ * All initializers attach their namespaces to this object (e.g., `api.db`, `api.actions`, `api.redis`).
22
+ * Stored on `globalThis` so every module shares the same instance.
23
+ */
18
24
  export class API {
25
+ /** The root directory of the user's application. Set this before calling `initialize()`. */
19
26
  rootDir: string;
27
+ /** The root directory of the keryx package itself (auto-resolved from `import.meta.path`). */
20
28
  packageDir: string;
29
+ /** Whether `initialize()` has completed successfully. */
21
30
  initialized: boolean;
31
+ /** Whether `start()` has completed successfully. */
22
32
  started: boolean;
33
+ /** Whether `stop()` has completed successfully. */
23
34
  stopped: boolean;
35
+ /** Epoch timestamp (ms) when the API instance was created. */
24
36
  bootTime: number;
37
+ /** The framework logger instance, configured from `config.logger`. */
25
38
  logger: Logger;
39
+ /** The current run mode (SERVER or CLI), set during `start()`. */
26
40
  runMode!: RUN_MODE;
41
+ /** All discovered initializer instances, sorted by the most-recently-used priority key. */
27
42
  initializers: Initializer[];
28
43
 
29
44
  // allow arbitrary properties to be set on the API, to be added and typed later
@@ -42,6 +57,13 @@ export class API {
42
57
  this.initializers = [];
43
58
  }
44
59
 
60
+ /**
61
+ * Load configuration overrides and discover + run all initializers.
62
+ * Calls each initializer's `initialize()` method in `loadPriority` order.
63
+ * The return value of each initializer is attached to `api[initializer.name]`.
64
+ *
65
+ * @throws {TypedError} With `ErrorType.SERVER_INITIALIZATION` if any initializer fails.
66
+ */
45
67
  async initialize() {
46
68
  this.logger.warn("--- 🔄 Initializing process ---");
47
69
  this.initialized = false;
@@ -69,6 +91,17 @@ export class API {
69
91
  this.logger.warn("--- 🔄 Initializing complete ---");
70
92
  }
71
93
 
94
+ /**
95
+ * Start the framework: connect to external services, bind server ports, start workers.
96
+ * Calls `initialize()` first if it hasn't been run yet, then calls each initializer's
97
+ * `start()` method in `startPriority` order. Initializers whose `runModes` do not include
98
+ * the current `runMode` are skipped.
99
+ *
100
+ * @param runMode - Whether to start in SERVER mode (HTTP/WebSocket) or CLI mode.
101
+ * Defaults to `RUN_MODE.SERVER`. Initializers can opt out of specific modes via their
102
+ * `runModes` property.
103
+ * @throws {TypedError} With `ErrorType.SERVER_START` if any initializer fails to start.
104
+ */
72
105
  async start(runMode: RUN_MODE = RUN_MODE.SERVER) {
73
106
  this.stopped = false;
74
107
  this.started = false;
@@ -104,6 +137,12 @@ export class API {
104
137
  this.logger.warn("--- 🔼 Starting complete ---");
105
138
  }
106
139
 
140
+ /**
141
+ * Gracefully shut down the framework: disconnect from services, close server ports, stop workers.
142
+ * Calls each initializer's `stop()` method in `stopPriority` order. No-ops if already stopped.
143
+ *
144
+ * @throws {TypedError} With `ErrorType.SERVER_STOP` if any initializer fails to stop.
145
+ */
107
146
  async stop() {
108
147
  if (this.stopped) {
109
148
  this.logger.warn("API is already stopped");
@@ -133,6 +172,10 @@ export class API {
133
172
  this.logger.warn("--- 🔽 Stopping complete ---");
134
173
  }
135
174
 
175
+ /**
176
+ * Stop and then re-start the framework. Includes a flap preventer that ignores
177
+ * concurrent restart calls to avoid rapid stop/start cycles.
178
+ */
136
179
  async restart() {
137
180
  if (flapPreventer) return;
138
181
 
package/classes/Action.ts CHANGED
@@ -50,6 +50,9 @@ export type ActionConstructorInputs = {
50
50
  method?: HTTP_METHOD;
51
51
  };
52
52
 
53
+ /** Per-action timeout in ms (overrides global `config.server.web.actionTimeout`; 0 disables) */
54
+ timeout?: number;
55
+
53
56
  /** Configure this action as a background task/job */
54
57
  task?: {
55
58
  /** Optional recurring frequency in milliseconds */
@@ -64,17 +67,33 @@ export type ActionMiddlewareResponse = {
64
67
  updatedResponse?: any;
65
68
  };
66
69
 
70
+ /**
71
+ * Middleware hooks that run before and/or after an action's `run()` method.
72
+ * Middleware can mutate params (via `updatedParams`) or replace the response (via `updatedResponse`).
73
+ */
67
74
  export type ActionMiddleware = {
75
+ /**
76
+ * Runs before the action's `run()` method. Can modify params by returning `{ updatedParams }`.
77
+ * Throw a `TypedError` to abort the action (e.g., for auth checks).
78
+ */
68
79
  runBefore?: (
69
80
  params: ActionParams<Action>,
70
81
  connection: Connection,
71
82
  ) => Promise<ActionMiddlewareResponse | void>;
83
+ /**
84
+ * Runs after the action's `run()` method. Can replace the response by returning `{ updatedResponse }`.
85
+ */
72
86
  runAfter?: (
73
87
  params: ActionParams<Action>,
74
88
  connection: Connection,
75
89
  ) => Promise<ActionMiddlewareResponse | void>;
76
90
  };
77
91
 
92
+ /**
93
+ * Abstract base class for transport-agnostic controllers. Actions serve simultaneously as
94
+ * HTTP endpoints, WebSocket handlers, CLI commands, background tasks, and MCP tools.
95
+ * Subclasses must implement the `run()` method.
96
+ */
78
97
  export abstract class Action {
79
98
  name: string;
80
99
  description?: string;
@@ -85,6 +104,7 @@ export abstract class Action {
85
104
  route: RegExp | string;
86
105
  method: HTTP_METHOD;
87
106
  };
107
+ timeout?: number;
88
108
  task?: {
89
109
  frequency?: number;
90
110
  queue: string;
@@ -95,6 +115,7 @@ export abstract class Action {
95
115
  this.description = args.description ?? `An Action: ${this.name}`;
96
116
  this.inputs = args.inputs;
97
117
  this.middleware = args.middleware ?? [];
118
+ this.timeout = args.timeout;
98
119
  this.mcp = { enabled: true, ...args.mcp };
99
120
  this.web = {
100
121
  route: args.web?.route ?? `/${this.name}`,
@@ -111,18 +132,41 @@ export abstract class Action {
111
132
  * It can be `async`.
112
133
  * Usually the goal of this run method is to return the data that you want to be sent to API consumers.
113
134
  * If error is thrown in this method, it will be logged, caught, and returned to the client as `error`
135
+ *
136
+ * @param params - The validated and coerced action inputs. The type is inferred from the
137
+ * action's `inputs` Zod schema (falls back to `Record<string, unknown>` when no schema is
138
+ * defined). By the time `run` is called, all middleware `runBefore` hooks have already
139
+ * executed and may have mutated the params.
140
+ * @param connection - The connection that initiated this action. Provides access to the
141
+ * caller's session (`connection.session`), subscription state, and raw transport handle.
142
+ * It is `undefined` when the action is invoked outside an HTTP/WebSocket request context
143
+ * (e.g., as a background task via the Resque worker or via `api.actions.run()`).
144
+ * @param abortSignal - An `AbortSignal` tied to the action's timeout. The signal is aborted
145
+ * when the per-action `timeout` (or the global `config.actions.timeout`, default 300 000 ms)
146
+ * elapses. Long-running actions should check `abortSignal.aborted` or pass the signal to
147
+ * cancellable APIs (e.g., `fetch`) to exit promptly. Not provided when timeouts are
148
+ * disabled (`timeout: 0`).
114
149
  * @throws {TypedError} All errors thrown should be TypedError instances
115
150
  */
116
151
  abstract run(
117
152
  params: ActionParams<Action>,
118
153
  connection?: Connection,
154
+ abortSignal?: AbortSignal,
119
155
  ): Promise<any>;
120
156
  }
121
157
 
158
+ /**
159
+ * Infers the validated input type for an action from its `inputs` Zod schema.
160
+ * Falls back to `Record<string, unknown>` when no schema is defined.
161
+ */
122
162
  export type ActionParams<A extends Action> =
123
163
  A["inputs"] extends z.ZodType<any>
124
164
  ? z.infer<A["inputs"]>
125
165
  : Record<string, unknown>;
126
166
 
167
+ /**
168
+ * Infers the return type of an action's `run()` method, merged with an optional `error` field.
169
+ * Useful for typing API responses on the client side.
170
+ */
127
171
  export type ActionResponse<A extends Action> = Awaited<ReturnType<A["run"]>> &
128
172
  Partial<{ error?: TypedError }>;
@@ -37,6 +37,11 @@ export type ChannelConstructorInputs = {
37
37
  middleware?: ChannelMiddleware[];
38
38
  };
39
39
 
40
+ /**
41
+ * Abstract base class for PubSub channels. Channels define a `name` (exact string or RegExp)
42
+ * and optional middleware for authorization on subscribe and cleanup on unsubscribe.
43
+ * Subclasses can override `authorize()` and `presenceKey()` for custom behavior.
44
+ */
40
45
  export abstract class Channel {
41
46
  name: string | RegExp;
42
47
  description?: string;
@@ -9,16 +9,39 @@ import { isSecret } from "../util/zodMixins";
9
9
  import type { Action, ActionParams } from "./Action";
10
10
  import { ErrorType, TypedError } from "./TypedError";
11
11
 
12
+ /**
13
+ * Represents a client connection to the server — HTTP request, WebSocket, or internal caller.
14
+ * Each connection tracks its own session, channel subscriptions, and rate-limit state.
15
+ * The generic `T` allows typing the session data shape for your application.
16
+ */
12
17
  export class Connection<T extends Record<string, any> = Record<string, any>> {
18
+ /** Transport type identifier (e.g., `"web"`, `"websocket"`). */
13
19
  type: string;
20
+ /** A human-readable identifier for the connection, typically the remote IP or a session key. */
14
21
  identifier: string;
22
+ /** Unique connection ID (UUID by default). Used as the key in `api.connections`. */
15
23
  id: string;
24
+ /** The connection's session data, lazily loaded on first action invocation. */
16
25
  session?: SessionData<T>;
26
+ /** Set of channel names this connection is currently subscribed to. */
17
27
  subscriptions: Set<string>;
28
+ /** Whether the session has been loaded from Redis at least once. */
18
29
  sessionLoaded: boolean;
30
+ /** The underlying transport handle (e.g., Bun `ServerWebSocket`). */
19
31
  rawConnection?: any;
32
+ /** Rate-limit metadata populated by the rate-limit middleware. */
20
33
  rateLimitInfo?: RateLimitInfo;
34
+ /** Request correlation ID for distributed tracing. Propagated from the incoming `X-Request-Id` header when `config.server.web.correlationId.trustProxy` is enabled. */
35
+ correlationId?: string;
21
36
 
37
+ /**
38
+ * Create a new connection and register it in `api.connections`.
39
+ *
40
+ * @param type - Transport type (e.g., `"web"`, `"websocket"`).
41
+ * @param identifier - Human-readable identifier, typically the remote IP address.
42
+ * @param id - Unique connection ID. Defaults to a random UUID.
43
+ * @param rawConnection - The underlying transport handle (e.g., Bun `ServerWebSocket`).
44
+ */
22
45
  constructor(
23
46
  type: string,
24
47
  identifier: string,
@@ -36,8 +59,18 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
36
59
  }
37
60
 
38
61
  /**
39
- * Runs an action for this connection, given FormData params.
40
- * Throws errors.
62
+ * Execute an action in the context of this connection. Handles the full lifecycle:
63
+ * session loading, param validation via the action's Zod schema, middleware execution
64
+ * (before/after), timeout enforcement, and structured logging.
65
+ *
66
+ * @param actionName - The name of the action to run. If not found, throws
67
+ * `ErrorType.CONNECTION_ACTION_NOT_FOUND`.
68
+ * @param params - Raw `FormData` from the HTTP request or WebSocket message.
69
+ * Validated and coerced against the action's `inputs` Zod schema.
70
+ * @param method - The HTTP method of the incoming request (used for logging).
71
+ * @param url - The request URL (used for logging).
72
+ * @returns The action response and optional error.
73
+ * @throws {TypedError} With the appropriate `ErrorType` for validation, timeout, or runtime failures.
41
74
  */
42
75
  async act(
43
76
  actionName: string | undefined,
@@ -76,7 +109,26 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
76
109
  }
77
110
  }
78
111
 
79
- response = await action.run(formattedParams, this);
112
+ const timeoutMs = action.timeout ?? config.actions.timeout;
113
+ if (timeoutMs > 0) {
114
+ const controller = new AbortController();
115
+ const timeoutError = new TypedError({
116
+ message: `Action '${action.name}' timed out after ${timeoutMs}ms`,
117
+ type: ErrorType.CONNECTION_ACTION_TIMEOUT,
118
+ });
119
+ const timeoutPromise = new Promise<never>((_, reject) => {
120
+ setTimeout(() => {
121
+ controller.abort();
122
+ reject(timeoutError);
123
+ }, timeoutMs);
124
+ });
125
+ response = await Promise.race([
126
+ action.run(formattedParams, this, controller.signal),
127
+ timeoutPromise,
128
+ ]);
129
+ } else {
130
+ response = await action.run(formattedParams, this);
131
+ }
80
132
 
81
133
  for (const middleware of action.middleware ?? []) {
82
134
  if (middleware.runAfter) {
@@ -122,13 +174,23 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
122
174
  : "\r\n" + error.stack
123
175
  : "";
124
176
 
177
+ const correlationIdTag = this.correlationId
178
+ ? ` [cor:${this.correlationId}]`
179
+ : "";
180
+
125
181
  logger.info(
126
- `${messagePrefix} ${actionName} (${duration}ms) ${method.length > 0 ? `[${method}]` : ""} ${this.identifier}${url.length > 0 ? `(${url})` : ""} ${error ? error : ""} ${loggingParams} ${errorStack}`,
182
+ `${messagePrefix} ${actionName} (${duration}ms) ${method.length > 0 ? `[${method}]` : ""} ${this.identifier}${url.length > 0 ? `(${url})` : ""}${correlationIdTag} ${error ? error : ""} ${loggingParams} ${errorStack}`,
127
183
  );
128
184
 
129
185
  return { response, error };
130
186
  }
131
187
 
188
+ /**
189
+ * Merge new data into the connection's session. Loads the session first if not yet loaded.
190
+ *
191
+ * @param data - Partial session data to merge into the existing session.
192
+ * @throws {TypedError} With `ErrorType.CONNECTION_SESSION_NOT_FOUND` if no session exists.
193
+ */
132
194
  async updateSession(data: Partial<T>) {
133
195
  await this.loadSession();
134
196
 
@@ -142,14 +204,23 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
142
204
  return api.session.update(this.session, data);
143
205
  }
144
206
 
207
+ /** Add a channel to this connection's subscription set. */
145
208
  subscribe(channel: string) {
146
209
  this.subscriptions.add(channel);
147
210
  }
148
211
 
212
+ /** Remove a channel from this connection's subscription set. */
149
213
  unsubscribe(channel: string) {
150
214
  this.subscriptions.delete(channel);
151
215
  }
152
216
 
217
+ /**
218
+ * Publish a message to a PubSub channel. The connection must already be subscribed.
219
+ *
220
+ * @param channel - The channel name to broadcast to.
221
+ * @param message - The message payload to send.
222
+ * @throws {TypedError} With `ErrorType.CONNECTION_NOT_SUBSCRIBED` if not subscribed.
223
+ */
153
224
  async broadcast(channel: string, message: string) {
154
225
  if (!this.subscriptions.has(channel)) {
155
226
  throw new TypedError({
@@ -161,16 +232,28 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
161
232
  return api.pubsub.broadcast(channel, message, this.id);
162
233
  }
163
234
 
235
+ /**
236
+ * Called when a PubSub message arrives for a channel this connection is subscribed to.
237
+ * Must be overridden by transport-specific subclasses (e.g., WebSocket connections).
238
+ *
239
+ * @param _payload - The incoming PubSub message.
240
+ * @throws {Error} Always throws in the base class — subclasses must override.
241
+ */
164
242
  onBroadcastMessageReceived(_payload: PubSubMessage) {
165
243
  throw new Error(
166
244
  "unimplemented - this should be overwritten by connections that support it",
167
245
  );
168
246
  }
169
247
 
248
+ /** Remove this connection from the global connections map and clean up resources. */
170
249
  destroy() {
171
250
  return api.connections.destroy(this.type, this.identifier, this.id);
172
251
  }
173
252
 
253
+ /**
254
+ * Load the session from Redis (or create a new one if none exists).
255
+ * No-ops if the session is already loaded. Sets `sessionLoaded` to `true`.
256
+ */
174
257
  async loadSession() {
175
258
  if (this.session) return;
176
259
 
@@ -1,3 +1,4 @@
1
+ /** Process exit codes used by the CLI runner and signal handlers. */
1
2
  export enum ExitCode {
2
3
  success = 0,
3
4
  error = 1,
@@ -1,18 +1,21 @@
1
1
  import { RUN_MODE } from "./../api";
2
2
 
3
3
  /**
4
- * Create a new Initializer. The required properties of an initializer. These can be defined statically (this.name) or as methods which return a value.
4
+ * Abstract base class for lifecycle components. Initializers are discovered automatically
5
+ * and run in priority order during the framework's initialize → start → stop phases.
6
+ * Each initializer typically extends the `API` interface via module augmentation and
7
+ * returns its namespace object from `initialize()`.
5
8
  */
6
9
  export abstract class Initializer {
7
- /**The name of the Initializer. */
10
+ /** The unique name of this initializer (also used as the key on the `api` object). */
8
11
  name: string;
9
- /**What order should this Initializer load at (Default: 1000, core methods are < 1000) */
12
+ /** Priority order for `initialize()`. Lower values run first. Default: 1000; core initializers use < 1000. */
10
13
  loadPriority: number;
11
- /**What order should this Initializer start at (Default: 1000, core methods are < 1000) */
14
+ /** Priority order for `start()`. Lower values run first. Default: 1000; core initializers use < 1000. */
12
15
  startPriority: number;
13
- /**What order should this Initializer stop at (Default: 1000, core methods are < 1000) */
16
+ /** Priority order for `stop()`. Lower values run first. Default: 1000; core initializers use < 1000. */
14
17
  stopPriority: number;
15
- /** which run modes does this sever start in*/
18
+ /** Which run modes this initializer participates in. Defaults to both SERVER and CLI. */
16
19
  runModes: RUN_MODE[];
17
20
 
18
21
  constructor(name: string) {
@@ -24,17 +27,18 @@ export abstract class Initializer {
24
27
  }
25
28
 
26
29
  /**
27
- * Method run as part of the `initialize` lifecycle of your process. Usually sets api['YourNamespace']
30
+ * Called during the `initialize` phase. Return a namespace object to attach to `api[this.name]`.
31
+ * @returns The namespace object (e.g., `{ actions, enqueue, ... }`) that gets set on `api`.
28
32
  */
29
33
  async initialize?(): Promise<any>;
30
34
 
31
35
  /**
32
- * Method run as part of the `start` lifecycle of your process. Usually connects to remote servers or processes.
36
+ * Called during the `start` phase. Connect to external services, bind ports, start workers.
33
37
  */
34
38
  async start?(): Promise<any>;
35
39
 
36
40
  /**
37
- * Method run as part of the `initialize` lifecycle of your process. Usually disconnects from remote servers or processes.
41
+ * Called during the `stop` phase. Disconnect from services, release resources, stop workers.
38
42
  */
39
43
  async stop?(): Promise<any>;
40
44
  }
package/classes/Logger.ts CHANGED
@@ -15,11 +15,17 @@ export enum LogLevel {
15
15
  * The Logger Class. I write to stdout or stderr, and can be colorized.
16
16
  */
17
17
  export class Logger {
18
+ /** Minimum log level to output. Messages below this level are silently dropped. */
18
19
  level: LogLevel;
20
+ /** Whether to apply ANSI color codes to the output. */
19
21
  colorize: boolean;
22
+ /** Whether to prepend an ISO-8601 timestamp to each log line. */
20
23
  includeTimestamps: boolean;
24
+ /** Indentation spaces used when JSON-stringifying the optional object argument. */
21
25
  jSONObjectParsePadding: number;
22
- quiet: boolean; // an override to disable all logging (used by CLI)
26
+ /** When `true`, all logging is suppressed (used by CLI mode). */
27
+ quiet: boolean;
28
+ /** The output function — defaults to `console.log`. Override for custom transports. */
23
29
  outputStream: typeof console.log;
24
30
 
25
31
  constructor(config: typeof configLogger) {
@@ -31,6 +37,14 @@ export class Logger {
31
37
  this.outputStream = console.log;
32
38
  }
33
39
 
40
+ /**
41
+ * Core logging method. Formats and writes a log line to `outputStream` if the given
42
+ * level meets the minimum threshold. Optionally includes a timestamp and pretty-printed object.
43
+ *
44
+ * @param level - The severity level of this log entry.
45
+ * @param message - The log message string.
46
+ * @param object - An optional object to JSON-stringify and append to the log line.
47
+ */
34
48
  log(level: LogLevel, message: string, object?: any) {
35
49
  if (this.quiet) return;
36
50
 
@@ -82,6 +96,11 @@ export class Logger {
82
96
  this.log(LogLevel.debug, message, object);
83
97
  }
84
98
 
99
+ /**
100
+ * Log an info message.
101
+ * @param message - The message to log.
102
+ * @param object - The object to log.
103
+ */
85
104
  info(message: string, object?: any) {
86
105
  this.log(LogLevel.info, message, object);
87
106
  }
package/classes/Server.ts CHANGED
@@ -1,16 +1,24 @@
1
+ /**
2
+ * Abstract base class for transport servers (e.g., HTTP, WebSocket).
3
+ * The generic `T` is the type of the underlying server object (e.g., `Bun.Server`).
4
+ * Subclasses must implement `initialize()`, `start()`, and `stop()`.
5
+ */
1
6
  export abstract class Server<T> {
2
7
  name: string;
3
8
 
4
- /**A place to store the actually server object you create */
9
+ /** The underlying server instance created by the subclass (e.g., `Bun.Server`). */
5
10
  server?: T;
6
11
 
7
12
  constructor(name: string) {
8
13
  this.name = name;
9
14
  }
10
15
 
16
+ /** Set up routes, handlers, and configuration. Called during the framework's initialize phase. */
11
17
  abstract initialize(): Promise<void>;
12
18
 
19
+ /** Bind to a port and begin accepting connections. Called during the framework's start phase. */
13
20
  abstract start(): Promise<void>;
14
21
 
22
+ /** Close the server and release its port. Called during the framework's stop phase. */
15
23
  abstract stop(): Promise<void>;
16
24
  }
@@ -1,3 +1,7 @@
1
+ /**
2
+ * Categorizes all framework errors. Each type maps to an HTTP status code via `ErrorStatusCodes`.
3
+ * Actions should always throw `TypedError` with one of these types.
4
+ */
1
5
  export enum ErrorType {
2
6
  // general
3
7
  "SERVER_INITIALIZATION" = "SERVER_INITIALIZATION",
@@ -27,11 +31,13 @@ export enum ErrorType {
27
31
  "CONNECTION_CHANNEL_AUTHORIZATION" = "CONNECTION_CHANNEL_AUTHORIZATION",
28
32
  "CONNECTION_CHANNEL_VALIDATION" = "CONNECTION_CHANNEL_VALIDATION",
29
33
 
34
+ "CONNECTION_ACTION_TIMEOUT" = "CONNECTION_ACTION_TIMEOUT",
30
35
  "CONNECTION_RATE_LIMITED" = "CONNECTION_RATE_LIMITED",
31
36
 
32
37
  "CONNECTION_TASK_DEFINITION" = "CONNECTION_TASK_DEFINITION",
33
38
  }
34
39
 
40
+ /** Maps each `ErrorType` to the HTTP status code returned to the client. */
35
41
  export const ErrorStatusCodes: Record<ErrorType, number> = {
36
42
  [ErrorType.SERVER_INITIALIZATION]: 500,
37
43
  [ErrorType.SERVER_START]: 500,
@@ -56,22 +62,36 @@ export const ErrorStatusCodes: Record<ErrorType, number> = {
56
62
  [ErrorType.CONNECTION_NOT_SUBSCRIBED]: 406,
57
63
  [ErrorType.CONNECTION_CHANNEL_AUTHORIZATION]: 403,
58
64
  [ErrorType.CONNECTION_CHANNEL_VALIDATION]: 400,
65
+ [ErrorType.CONNECTION_ACTION_TIMEOUT]: 408,
59
66
  [ErrorType.CONNECTION_RATE_LIMITED]: 429,
60
67
 
61
68
  [ErrorType.CONNECTION_TASK_DEFINITION]: 500,
62
69
  };
63
70
 
64
71
  export type TypedErrorArgs = {
72
+ /** Human-readable error message returned to the client. */
65
73
  message: string;
74
+ /** The error category, which determines the HTTP status code. */
66
75
  type: ErrorType;
76
+ /** The original caught error, if wrapping. Its stack trace is preserved on the `TypedError`. */
67
77
  originalError?: unknown;
78
+ /** The param key that caused the error (for validation errors). */
68
79
  key?: string;
80
+ /** The param value that caused the error (for validation errors). */
69
81
  value?: any;
70
82
  };
71
83
 
84
+ /**
85
+ * Structured error class for action and framework failures. Extends `Error` with an
86
+ * `ErrorType` that maps to an HTTP status code, and optional `key`/`value` fields for
87
+ * param validation errors. If `originalError` is provided, its stack trace is preserved.
88
+ */
72
89
  export class TypedError extends Error {
90
+ /** The error category, used to determine the HTTP status code via `ErrorStatusCodes`. */
73
91
  type: ErrorType;
92
+ /** The param key that caused the error (for validation errors). */
74
93
  key?: string;
94
+ /** The param value that caused the error (for validation errors). */
75
95
  value?: any;
76
96
 
77
97
  constructor(args: TypedErrorArgs) {
@@ -0,0 +1,7 @@
1
+ import { loadFromEnvIfSet } from "../util/config";
2
+
3
+ export const configActions = {
4
+ timeout: await loadFromEnvIfSet("ACTION_TIMEOUT", 300_000),
5
+ fanOutBatchSize: await loadFromEnvIfSet("ACTION_FAN_OUT_BATCH_SIZE", 100),
6
+ fanOutResultTtl: await loadFromEnvIfSet("ACTION_FAN_OUT_RESULT_TTL", 600),
7
+ };
package/config/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { configActions } from "./actions";
1
2
  import { configChannels } from "./channels";
2
3
  import { configDatabase } from "./database";
3
4
  import { configLogger } from "./logger";
@@ -11,6 +12,7 @@ import { configSession } from "./session";
11
12
  import { configTasks } from "./tasks";
12
13
 
13
14
  export const config = {
15
+ actions: configActions,
14
16
  channels: configChannels,
15
17
  process: configProcess,
16
18
  logger: configLogger,
@@ -70,4 +70,8 @@ export const configServerWeb = {
70
70
  "strict-origin-when-cross-origin",
71
71
  ),
72
72
  } as Record<string, string>,
73
+ correlationId: {
74
+ header: await loadFromEnvIfSet("WEB_CORRELATION_ID_HEADER", "X-Request-Id"),
75
+ trustProxy: await loadFromEnvIfSet("WEB_CORRELATION_ID_TRUST_PROXY", false),
76
+ },
73
77
  };
@@ -5,37 +5,56 @@ import { api, logger } from "../api";
5
5
  import { DEFAULT_QUEUE, type Action } from "../classes/Action";
6
6
  import { Initializer } from "../classes/Initializer";
7
7
  import { ErrorType, TypedError } from "../classes/TypedError";
8
+ import { config } from "../config";
8
9
  import { globLoader } from "../util/glob";
9
10
 
10
11
  const namespace = "actions";
11
12
 
12
- const DEFAULT_FAN_OUT_BATCH_SIZE = 100;
13
- const DEFAULT_FAN_OUT_RESULT_TTL = 600; // 10 minutes in seconds
14
-
13
+ /** A single job descriptor for multi-action fan-out. */
15
14
  export type FanOutJob = {
15
+ /** The action name to enqueue. */
16
16
  action: string;
17
+ /** Inputs to pass to the action. Defaults to `{}`. */
17
18
  inputs?: TaskInputs;
19
+ /** Override the queue for this specific job. Falls back to the action's configured queue. */
18
20
  queue?: string;
19
21
  };
20
22
 
23
+ /** Options for controlling fan-out behavior. */
21
24
  export type FanOutOptions = {
25
+ /** Max jobs to enqueue per Redis batch. Defaults to `config.actions.fanOutBatchSize`. */
22
26
  batchSize?: number;
27
+ /** TTL in seconds for the fan-out metadata and result keys in Redis. Defaults to `config.actions.fanOutResultTtl`. */
23
28
  resultTtl?: number;
29
+ /** Correlation ID to propagate to all child jobs for distributed tracing. Injected as `_correlationId` in each job's inputs. */
30
+ correlationId?: string;
24
31
  };
25
32
 
33
+ /** Returned immediately from `fanOut()` with metadata about the enqueue operation. */
26
34
  export type FanOutResult = {
35
+ /** Unique ID for querying status later via `fanOutStatus()`. */
27
36
  fanOutId: string;
37
+ /** The action name(s) that were fanned out. */
28
38
  actionName: string | string[];
39
+ /** The queue(s) jobs were enqueued to. */
29
40
  queue: string | string[];
41
+ /** Number of jobs successfully enqueued. */
30
42
  enqueued: number;
43
+ /** Any enqueue-time failures, with the index of the failed job. */
31
44
  errors: Array<{ index: number; error: string }>;
32
45
  };
33
46
 
47
+ /** Status of a fan-out operation, as returned by `fanOutStatus()`. */
34
48
  export type FanOutStatus = {
49
+ /** Total number of jobs in this fan-out batch. */
35
50
  total: number;
51
+ /** Number of jobs that have completed successfully. */
36
52
  completed: number;
53
+ /** Number of jobs that have failed. */
37
54
  failed: number;
55
+ /** Collected results from completed child jobs. */
38
56
  results: Array<{ params: Record<string, any>; result: any }>;
57
+ /** Collected errors from failed child jobs. */
39
58
  errors: Array<{ params: Record<string, any>; error: string }>;
40
59
  };
41
60
 
@@ -55,7 +74,11 @@ export class Actions extends Initializer {
55
74
 
56
75
  /**
57
76
  * Enqueue an action to be performed in the background.
58
- * Will throw an error if redis cannot be reached.
77
+ *
78
+ * @param actionName - The name of the action to enqueue.
79
+ * @param inputs - Inputs to pass to the action. Defaults to `{}`.
80
+ * @param queue - Which queue to enqueue on. Falls back to the action's configured queue, then `"default"`.
81
+ * @throws {TypedError} With `ErrorType.CONNECTION_TASK_DEFINITION` if the action is not found.
59
82
  */
60
83
  enqueue = async (
61
84
  actionName: string,
@@ -130,8 +153,10 @@ export class Actions extends Initializer {
130
153
  return { ...job, queue: resolvedQueue, inputs: job.inputs ?? {} };
131
154
  });
132
155
 
133
- const batchSize = resolvedOptions.batchSize ?? DEFAULT_FAN_OUT_BATCH_SIZE;
134
- const resultTtl = resolvedOptions.resultTtl ?? DEFAULT_FAN_OUT_RESULT_TTL;
156
+ const batchSize =
157
+ resolvedOptions.batchSize ?? config.actions.fanOutBatchSize;
158
+ const resultTtl =
159
+ resolvedOptions.resultTtl ?? config.actions.fanOutResultTtl;
135
160
  const fanOutId = randomUUID();
136
161
  const metaKey = `fanout:${fanOutId}`;
137
162
 
@@ -166,6 +191,9 @@ export class Actions extends Initializer {
166
191
  const enrichedInputs = {
167
192
  ...job.inputs,
168
193
  _fanOutId: fanOutId,
194
+ ...(resolvedOptions.correlationId
195
+ ? { _correlationId: resolvedOptions.correlationId }
196
+ : {}),
169
197
  };
170
198
  return this.enqueue(job.action, enrichedInputs, job.queue);
171
199
  }),
@@ -224,14 +252,15 @@ export class Actions extends Initializer {
224
252
  };
225
253
 
226
254
  /**
227
- * Enqueue a task to be performed in the background, at a certain time in the future.
228
- * Will throw an error if redis cannot be reached.
255
+ * Enqueue an action to run at a specific time in the future.
229
256
  *
230
- * Inputs:
231
- * * actionName: The name of the task.
232
- * * inputs: inputs to pass to the task.
233
- * * queue: (Optional) Which queue/priority to run this instance of the task on.
234
- * * suppressDuplicateTaskError: (optional) Suppress errors when the same task with the same arguments are double-enqueued for the same time
257
+ * @param timestamp - Epoch timestamp (ms) when the task becomes eligible to run.
258
+ * Does not guarantee the task will run at exactly this time.
259
+ * @param actionName - The name of the action to enqueue.
260
+ * @param inputs - Inputs to pass to the action.
261
+ * @param queue - Which queue to enqueue on. Defaults to `"default"`.
262
+ * @param suppressDuplicateTaskError - If `true`, silently ignore errors when the same
263
+ * task with the same arguments is already enqueued for the same time.
235
264
  */
236
265
  enqueueAt = async (
237
266
  timestamp: number,
@@ -250,15 +279,14 @@ export class Actions extends Initializer {
250
279
  };
251
280
 
252
281
  /**
253
- * Enqueue a task to be performed in the background, at a certain number of ms from now.
254
- * Will throw an error if redis cannot be reached.
282
+ * Enqueue an action to run after a delay (in milliseconds from now).
255
283
  *
256
- * Inputs:
257
- * * timestamp: At what time the task is able to be run. Does not guarantee that the task will be run at this time. (in ms)
258
- * * actionName: The name of the task.
259
- * * inputs: inputs to pass to the task.
260
- * * queue: (Optional) Which queue/priority to run this instance of the task on.
261
- * * suppressDuplicateTaskError: (optional) Suppress errors when the same task with the same arguments are double-enqueued for the same time
284
+ * @param time - Delay in milliseconds before the task becomes eligible to run.
285
+ * @param actionName - The name of the action to enqueue.
286
+ * @param inputs - Inputs to pass to the action.
287
+ * @param queue - Which queue to enqueue on. Defaults to `"default"`.
288
+ * @param suppressDuplicateTaskError - If `true`, silently ignore errors when the same
289
+ * task with the same arguments is already enqueued for the same time.
262
290
  */
263
291
  enqueueIn = async (
264
292
  time: number,
@@ -277,14 +305,13 @@ export class Actions extends Initializer {
277
305
  };
278
306
 
279
307
  /**
280
- * Delete a previously enqueued task, which hasn't been run yet, from a queue.
281
- * Will throw an error if redis cannot be reached.
308
+ * Delete a previously enqueued task that hasn't been run yet.
282
309
  *
283
- * Inputs:
284
- * * q: Which queue/priority is the task stored on?
285
- * * actionName: The name of the job, likely to be the same name as a tak.
286
- * * args: The arguments of the job. Note, arguments passed to a Task initially may be modified when enqueuing. It is best to read job properties first via `api.tasks.queued` or similar method.
287
- * * count: Of the jobs that match q, actionName, and args, up to what position should we delete? (Default 0; this command is 0-indexed)
310
+ * @param queue - The queue the task is stored on.
311
+ * @param actionName - The name of the job to delete.
312
+ * @param args - The arguments of the job to match. Note: arguments may have been modified
313
+ * during enqueuing read job properties via `api.actions.queued` first.
314
+ * @param count - Up to how many matching jobs to delete (0-indexed). Default: 0.
288
315
  */
289
316
  del = async (
290
317
  queue: string,
@@ -296,15 +323,13 @@ export class Actions extends Initializer {
296
323
  };
297
324
 
298
325
  /**
299
- * * will delete all jobs in the given queue of the named function/class
300
- * * will not prevent new jobs from being added as this method is running
301
- * * will not delete jobs in the delayed queues
326
+ * Delete all jobs of a given action name from a queue. Does not affect delayed queues,
327
+ * and will not prevent new jobs from being added while running.
302
328
  *
303
- * Inputs:
304
- * * q: Which queue/priority is to run on?
305
- * * actionName: The name of the job, likely to be the same name as a tak.
306
- * * start? - starting position of task count to remove
307
- * * stop? - stop position of task count to remove
329
+ * @param queue - The queue to delete from.
330
+ * @param actionName - The action name whose jobs to remove.
331
+ * @param start - Starting position (0-indexed) of the range to remove.
332
+ * @param stop - Stop position (0-indexed) of the range to remove.
308
333
  */
309
334
  delByFunction = async (
310
335
  queue: string,
@@ -316,13 +341,12 @@ export class Actions extends Initializer {
316
341
  };
317
342
 
318
343
  /**
319
- * Delete all previously enqueued tasks, which haven't been run yet, from all possible delayed timestamps.
320
- * Will throw an error if redis cannot be reached.
344
+ * Delete all delayed instances of a task across all future timestamps.
321
345
  *
322
- * Inputs:
323
- * * q: Which queue/priority is to run on?
324
- * * actionName: The name of the job, likely to be the same name as a tak.
325
- * * inputs The arguments of the job. Note, arguments passed to a Task initially may be modified when enqueuing. It is best to read job properties first via `api.tasks.delayedAt` or similar method.
346
+ * @param queue - The queue the task is stored on.
347
+ * @param actionName - The action name to delete.
348
+ * @param inputs - The job arguments to match. Arguments may have been modified during
349
+ * enqueuing read properties via `api.actions.delayedAt` first.
326
350
  */
327
351
  delDelayed = async (
328
352
  queue: string,
@@ -333,13 +357,12 @@ export class Actions extends Initializer {
333
357
  };
334
358
 
335
359
  /**
336
- * Return the timestamps a task is scheduled for.
337
- * Will throw an error if redis cannot be reached.
360
+ * Return the timestamps at which a task is scheduled to run.
338
361
  *
339
- * Inputs:
340
- * * q: Which queue/priority is to run on?
341
- * * actionName: The name of the job, likely to be the same name as a tak.
342
- * * inputs: The arguments of the job. Note, arguments passed to a Task initially may be modified when enqueuing. It is best to read job properties first via `api.tasks.delayedAt` or similar method.
362
+ * @param queue - The queue the task is stored on.
363
+ * @param actionName - The action name to look up.
364
+ * @param inputs - The job arguments to match.
365
+ * @returns Array of epoch timestamps (ms) when the job is scheduled.
343
366
  */
344
367
  scheduledAt = async (
345
368
  queue: string,
@@ -358,13 +381,12 @@ export class Actions extends Initializer {
358
381
  };
359
382
 
360
383
  /**
361
- * Retrieve the details of jobs enqueued on a certain queue between start and stop (0-indexed)
362
- * Will throw an error if redis cannot be reached.
384
+ * Retrieve details of jobs enqueued on a queue (0-indexed range).
363
385
  *
364
- * Inputs:
365
- * * q The name of the queue.
366
- * * start The index of the first job to return.
367
- * * stop The index of the last job to return.
386
+ * @param queue - The queue name. Defaults to `"default"`.
387
+ * @param start - Starting index. Defaults to 0.
388
+ * @param stop - Ending index. Defaults to 100.
389
+ * @returns Array of job input objects.
368
390
  */
369
391
  queued = (
370
392
  queue: string = DEFAULT_QUEUE,
@@ -592,6 +614,10 @@ export class Actions extends Initializer {
592
614
  return details;
593
615
  };
594
616
 
617
+ /**
618
+ * Swallow "already enqueued" errors for recurring tasks (expected during multi-process boot).
619
+ * Re-throws any other error.
620
+ */
595
621
  checkForRepeatRecurringTaskEnqueue = (actionName: string, error: any) => {
596
622
  if (error.toString().match(/already enqueued at this time/)) {
597
623
  // this is OK, the job was enqueued by another process as this method was running
@@ -2,7 +2,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
3
3
  import colors from "colors";
4
4
  import { randomUUID } from "crypto";
5
- import { z } from "zod";
6
5
  import * as z4mini from "zod/v4-mini";
7
6
  import { api, logger } from "../api";
8
7
  import { Connection } from "../classes/Connection";
@@ -462,16 +461,16 @@ function sanitizeSchemaForMcp(schema: any): any {
462
461
  schema.shape as Record<string, any>,
463
462
  )) {
464
463
  try {
465
- z4mini.toJSONSchema(z.object({ [key]: fieldSchema }), {
464
+ z4mini.toJSONSchema(z4mini.object({ [key]: fieldSchema }), {
466
465
  target: "draft-7",
467
466
  io: "input",
468
467
  });
469
468
  newShape[key] = fieldSchema;
470
469
  } catch {
471
470
  needsSanitization = true;
472
- newShape[key] = z.string().describe(`${key}`);
471
+ newShape[key] = z4mini.string();
473
472
  }
474
473
  }
475
474
 
476
- return needsSanitization ? z.object(newShape) : schema;
475
+ return needsSanitization ? z4mini.object(newShape) : schema;
477
476
  }
@@ -38,6 +38,13 @@ export class PubSub extends Initializer {
38
38
  }
39
39
 
40
40
  async initialize() {
41
+ /**
42
+ * Publish a message to all subscribers of a channel across the cluster via Redis PubSub.
43
+ *
44
+ * @param channel - The application-level channel name.
45
+ * @param message - The message payload (will be JSON-serialized).
46
+ * @param sender - Identifier of the sending connection. Defaults to `"unknown-sender"`.
47
+ */
41
48
  async function broadcast(
42
49
  channel: string,
43
50
  message: any,
@@ -67,6 +74,10 @@ export class PubSub extends Initializer {
67
74
  }
68
75
  }
69
76
 
77
+ /**
78
+ * Redis subscription callback. Delivers the incoming PubSub message to all local
79
+ * connections subscribed to the target channel, and forwards to MCP if enabled.
80
+ */
70
81
  async handleMessage(
71
82
  _pubSubChannel: string,
72
83
  incomingMessage: string | Buffer,
@@ -14,6 +14,11 @@ declare module "../classes/API" {
14
14
  }
15
15
  }
16
16
 
17
+ /**
18
+ * Initializer that manages two Redis connections: `redis` for general commands and
19
+ * `subscription` for PubSub. Both are created during `start()` and closed during `stop()`.
20
+ * Exposes `api.redis.redis` and `api.redis.subscription` as ioredis `RedisClient` instances.
21
+ */
17
22
  export class Redis extends Initializer {
18
23
  constructor() {
19
24
  super(namespace);
@@ -27,6 +27,11 @@ declare module "../classes/API" {
27
27
 
28
28
  let SERVER_JOB_COUNTER = 1;
29
29
 
30
+ /**
31
+ * Initializer for the node-resque background job system. Manages the queue, scheduler,
32
+ * and worker pool. All actions are automatically registered as resque jobs.
33
+ * Exposes `api.resque.queue`, `api.resque.scheduler`, and `api.resque.workers`.
34
+ */
30
35
  export class Resque extends Initializer {
31
36
  constructor() {
32
37
  super(namespace);
@@ -36,6 +41,7 @@ export class Resque extends Initializer {
36
41
  this.stopPriority = 900;
37
42
  }
38
43
 
44
+ /** Create and connect the resque `Queue` instance (used for enqueuing jobs). */
39
45
  startQueue = async () => {
40
46
  api.resque.queue = new Queue(
41
47
  { connection: { redis: api.redis.redis } },
@@ -49,12 +55,14 @@ export class Resque extends Initializer {
49
55
  await api.resque.queue.connect();
50
56
  };
51
57
 
58
+ /** Disconnect the resque `Queue`. */
52
59
  stopQueue = async () => {
53
60
  if (api.resque.queue) {
54
61
  return api.resque.queue.end();
55
62
  }
56
63
  };
57
64
 
65
+ /** Create and start the resque `Scheduler` (leader election, delayed job promotion, stuck worker cleanup). */
58
66
  startScheduler = async () => {
59
67
  if (config.tasks.enabled === true) {
60
68
  api.resque.scheduler = new Scheduler({
@@ -96,12 +104,14 @@ export class Resque extends Initializer {
96
104
  }
97
105
  };
98
106
 
107
+ /** Stop the resque `Scheduler` and disconnect. */
99
108
  stopScheduler = async () => {
100
109
  if (api.resque.scheduler && api.resque.scheduler.connection.connected) {
101
110
  await api.resque.scheduler.end();
102
111
  }
103
112
  };
104
113
 
114
+ /** Spin up `config.tasks.taskProcessors` worker instances and connect them to Redis. */
105
115
  startWorkers = async () => {
106
116
  let id = 0;
107
117
 
@@ -177,6 +187,7 @@ export class Resque extends Initializer {
177
187
  }
178
188
  };
179
189
 
190
+ /** Gracefully stop all workers: signal them to stop polling, drain in-flight operations, then disconnect. */
180
191
  stopWorkers = async () => {
181
192
  // Signal all workers to stop polling/pinging before closing connections.
182
193
  // worker.end() clears timers and closes the Redis connection, but if a
@@ -208,6 +219,11 @@ export class Resque extends Initializer {
208
219
  return jobs;
209
220
  };
210
221
 
222
+ /**
223
+ * Wrap an action as a node-resque job. Creates a temporary `Connection` with type `"resque"`,
224
+ * converts inputs to `FormData`, and runs the action via `connection.act()`. Handles
225
+ * fan-out result/error collection and recurring task re-enqueue.
226
+ */
211
227
  wrapActionAsJob = (
212
228
  action: Action,
213
229
  ): Job<Awaited<ReturnType<(typeof action)["run"]>>> => {
@@ -220,6 +236,14 @@ export class Resque extends Initializer {
220
236
  "resque",
221
237
  `job:${api.process.name}:${SERVER_JOB_COUNTER++}}`,
222
238
  );
239
+
240
+ const propagatedCorrelationId = params._correlationId as
241
+ | string
242
+ | undefined;
243
+ if (propagatedCorrelationId) {
244
+ connection.correlationId = propagatedCorrelationId;
245
+ }
246
+
223
247
  const paramsAsFormData = new FormData();
224
248
 
225
249
  if (typeof params.entries === "function") {
@@ -18,6 +18,12 @@ function getKey(connectionId: Connection["id"]) {
18
18
  return `${prefix}:${connectionId}`;
19
19
  }
20
20
 
21
+ /**
22
+ * Load a session from Redis by connection ID. Refreshes the TTL on access.
23
+ *
24
+ * @param connection - The connection whose session to load.
25
+ * @returns The parsed session data, or `null` if no session exists.
26
+ */
21
27
  async function load<T extends Record<string, any>>(connection: Connection) {
22
28
  const key = getKey(connection.id);
23
29
  const data = await api.redis.redis.get(key);
@@ -26,6 +32,13 @@ async function load<T extends Record<string, any>>(connection: Connection) {
26
32
  return JSON.parse(data) as SessionData<T>;
27
33
  }
28
34
 
35
+ /**
36
+ * Create a new session in Redis for the given connection.
37
+ *
38
+ * @param connection - The connection to create a session for.
39
+ * @param data - Initial session data. Defaults to `{}`.
40
+ * @returns The newly created `SessionData` object.
41
+ */
29
42
  async function create<T extends Record<string, any>>(
30
43
  connection: Connection,
31
44
  data = {} as T,
@@ -44,6 +57,13 @@ async function create<T extends Record<string, any>>(
44
57
  return sessionData;
45
58
  }
46
59
 
60
+ /**
61
+ * Merge new data into an existing session and persist to Redis. Refreshes the TTL.
62
+ *
63
+ * @param session - The existing session object to update.
64
+ * @param data - Partial data to shallow-merge into `session.data`.
65
+ * @returns The updated `session.data`.
66
+ */
47
67
  async function update<T extends Record<string, any>>(
48
68
  session: SessionData<T>,
49
69
  data: Record<string, any>,
@@ -55,6 +75,12 @@ async function update<T extends Record<string, any>>(
55
75
  return session.data;
56
76
  }
57
77
 
78
+ /**
79
+ * Delete a session from Redis.
80
+ *
81
+ * @param connection - The connection whose session to destroy.
82
+ * @returns `true` if a session was deleted, `false` if none existed.
83
+ */
58
84
  async function destroy(connection: Connection) {
59
85
  const key = getKey(connection.id);
60
86
  const response = await api.redis.redis.del(key);
@@ -4,23 +4,37 @@ import type { Connection } from "../classes/Connection";
4
4
  import { ErrorType, TypedError } from "../classes/TypedError";
5
5
  import { config } from "../config";
6
6
 
7
+ /** Rate-limit metadata attached to `connection.rateLimitInfo` and used to set response headers. */
7
8
  export type RateLimitInfo = {
9
+ /** The max number of requests allowed in the current window. */
8
10
  limit: number;
11
+ /** Requests remaining before the limit is hit. */
9
12
  remaining: number;
10
- resetAt: number; // unix timestamp in seconds
11
- retryAfter?: number; // seconds until window resets (only when limited)
13
+ /** Unix timestamp (seconds) when the current window resets. */
14
+ resetAt: number;
15
+ /** Seconds until the window resets. Only present when the limit has been exceeded. */
16
+ retryAfter?: number;
12
17
  };
13
18
 
19
+ /** Optional overrides for `checkRateLimit()` to use a custom limit/window instead of the global config. */
14
20
  export type RateLimitOverrides = {
21
+ /** Max requests per window. Defaults to the authenticated or unauthenticated config limit. */
15
22
  limit?: number;
23
+ /** Window duration in milliseconds. Defaults to `config.rateLimit.windowMs`. */
16
24
  windowMs?: number;
25
+ /** Redis key prefix. Defaults to `config.rateLimit.keyPrefix`. */
17
26
  keyPrefix?: string;
18
27
  };
19
28
 
20
29
  /**
21
- * Sliding window rate-limit check using Redis.
22
- * Exported so OAuth and other non-action handlers can reuse it.
23
- * Pass `overrides` to use a custom limit/window instead of the global config.
30
+ * Sliding-window rate-limit check using Redis. Exported so OAuth and other
31
+ * non-action handlers can reuse it.
32
+ *
33
+ * @param identifier - A unique key for the client (e.g., `"ip:1.2.3.4"` or `"user:42"`).
34
+ * @param isAuthenticated - Whether the caller is authenticated. Determines which
35
+ * config limit to use (authenticated vs unauthenticated).
36
+ * @param overrides - Optional overrides for limit, window duration, or key prefix.
37
+ * @returns Rate-limit metadata including remaining requests and retry-after (if limited).
24
38
  */
25
39
  export async function checkRateLimit(
26
40
  identifier: string,
@@ -68,6 +82,10 @@ export async function checkRateLimit(
68
82
  return { limit, remaining, resetAt };
69
83
  }
70
84
 
85
+ /**
86
+ * Action middleware that enforces per-connection rate limiting. Add to an action's
87
+ * `middleware` array to apply. Throws `ErrorType.CONNECTION_RATE_LIMITED` when exceeded.
88
+ */
71
89
  export const RateLimitMiddleware: ActionMiddleware = {
72
90
  runBefore: async (_params, connection: Connection) => {
73
91
  if (!config.rateLimit.enabled) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/servers/web.ts CHANGED
@@ -27,6 +27,11 @@ function validateChannelName(channel: string) {
27
27
  }
28
28
  }
29
29
 
30
+ /**
31
+ * HTTP + WebSocket server built on `Bun.serve`. Handles REST action routing (with path params),
32
+ * static file serving (with ETag/304 caching), WebSocket connections (actions, PubSub subscribe/unsubscribe),
33
+ * OAuth endpoints, and MCP SSE streams. Exposes `api.servers.web`.
34
+ */
30
35
  export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
31
36
  /** The actual port the server bound to (resolved after start, e.g. when config port is 0). */
32
37
  port: number = 0;
@@ -77,6 +82,10 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
77
82
  }
78
83
  }
79
84
 
85
+ /**
86
+ * Main request handler passed to `Bun.serve({ fetch })`. Dispatches to WebSocket upgrade,
87
+ * static files, OAuth, MCP, or REST action handling in that order.
88
+ */
80
89
  async handleIncomingConnection(
81
90
  req: Request,
82
91
  server: ReturnType<typeof Bun.serve>,
@@ -129,6 +138,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
129
138
  return this.handleWebAction(req, parsedUrl, ip, id);
130
139
  }
131
140
 
141
+ /** Called when a new WebSocket connection opens. Creates a `Connection` and wires up broadcast delivery. */
132
142
  handleWebSocketConnectionOpen(ws: ServerWebSocket) {
133
143
  //@ts-expect-error (ws.data is not defined in the bun types)
134
144
  const connection = new Connection("websocket", ws.data.ip, ws.data.id, ws);
@@ -140,6 +150,10 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
140
150
  );
141
151
  }
142
152
 
153
+ /**
154
+ * Called when a WebSocket message arrives. Parses JSON, enforces per-connection rate limiting,
155
+ * and dispatches to action, subscribe, or unsubscribe handlers based on `messageType`.
156
+ */
143
157
  async handleWebSocketConnectionMessage(
144
158
  ws: ServerWebSocket,
145
159
  message: string | Buffer,
@@ -212,6 +226,7 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
212
226
  }
213
227
  }
214
228
 
229
+ /** Called when a WebSocket connection closes. Removes presence from all channels and destroys the connection. */
215
230
  async handleWebSocketConnectionClose(ws: ServerWebSocket) {
216
231
  const { connection } = api.connections.find(
217
232
  "websocket",
@@ -399,6 +414,17 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
399
414
  const httpMethod = req.method?.toUpperCase() as HTTP_METHOD;
400
415
 
401
416
  const connection = new Connection("web", ip, id);
417
+
418
+ if (
419
+ config.server.web.correlationId.header &&
420
+ config.server.web.correlationId.trustProxy
421
+ ) {
422
+ const incomingId = req.headers.get(
423
+ config.server.web.correlationId.header,
424
+ );
425
+ if (incomingId) connection.correlationId = incomingId;
426
+ }
427
+
402
428
  const requestOrigin = req.headers.get("origin") ?? undefined;
403
429
 
404
430
  // Handle OPTIONS requests.
@@ -720,6 +746,11 @@ const buildHeaders = (connection?: Connection, requestOrigin?: string) => {
720
746
  headers["Retry-After"] = String(rateLimitInfo.retryAfter);
721
747
  }
722
748
  }
749
+
750
+ if (config.server.web.correlationId.header && connection.correlationId) {
751
+ headers[config.server.web.correlationId.header] =
752
+ connection.correlationId;
753
+ }
723
754
  }
724
755
 
725
756
  return headers;
@@ -1,3 +1,4 @@
1
+ /** Strip the password from a connection string for safe logging. Preserves protocol, user, host, port, and path. */
1
2
  export function formatConnectionStringForLogging(connectionString: string) {
2
3
  const connectionStringParsed = new URL(connectionString);
3
4
  const connectionStringInfo = `${connectionStringParsed.protocol ? `${connectionStringParsed.protocol}//` : ""}${connectionStringParsed.username ? `${connectionStringParsed.username}@` : ""}${connectionStringParsed.hostname}:${connectionStringParsed.port}${connectionStringParsed.pathname}`;
package/util/glob.ts CHANGED
@@ -4,8 +4,12 @@ import { api } from "../api";
4
4
  import { ErrorType, TypedError } from "../classes/TypedError";
5
5
 
6
6
  /**
7
+ * Auto-discover and instantiate all exported classes from `.ts`/`.tsx` files in a directory.
8
+ * Files prefixed with `.` are skipped. Used to load actions, initializers, and servers.
7
9
  *
8
- * @param searchDir Absolute path or relative path (resolved from api.rootDir) to search for files
10
+ * @param searchDir - Absolute path or relative path (resolved from `api.rootDir`) to scan.
11
+ * @returns Array of instantiated class instances of type `T`.
12
+ * @throws {TypedError} With `ErrorType.SERVER_INITIALIZATION` if any class fails to instantiate.
9
13
  */
10
14
  export async function globLoader<T>(searchDir: string) {
11
15
  const results: T[] = [];
package/util/oauth.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Validate an OAuth redirect URI per RFC 6749 / OAuth 2.1 rules:
3
+ * no fragments, no userinfo, and HTTPS required for non-localhost URIs.
4
+ *
5
+ * @param uri - The redirect URI to validate.
6
+ * @returns `{ valid: true }` or `{ valid: false, error: string }`.
7
+ */
1
8
  export function validateRedirectUri(uri: string): {
2
9
  valid: boolean;
3
10
  error?: string;
@@ -32,6 +39,10 @@ export function validateRedirectUri(uri: string): {
32
39
  return { valid: true };
33
40
  }
34
41
 
42
+ /**
43
+ * Compare two redirect URIs by origin and pathname (ignoring query params).
44
+ * Returns `false` if either URI is malformed.
45
+ */
35
46
  export function redirectUrisMatch(
36
47
  registeredUri: string,
37
48
  requestedUri: string,
@@ -48,6 +59,7 @@ export function redirectUrisMatch(
48
59
  }
49
60
  }
50
61
 
62
+ /** Encode a byte array as a URL-safe base64 string (no padding). Used for PKCE code challenges. */
51
63
  export function base64UrlEncode(buffer: Uint8Array): string {
52
64
  let binary = "";
53
65
  for (const byte of buffer) {
@@ -59,6 +71,7 @@ export function base64UrlEncode(buffer: Uint8Array): string {
59
71
  .replace(/=+$/, "");
60
72
  }
61
73
 
74
+ /** Escape a string for safe inclusion in HTML output (prevents XSS). */
62
75
  export function escapeHtml(str: string): string {
63
76
  return str
64
77
  .replace(/&/g, "&amp;")
package/util/zodMixins.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { eq } from "drizzle-orm";
2
- import { z } from "zod";
2
+ import { z } from "zod/v4";
3
3
  import { api } from "../api";
4
4
  import { ErrorType, TypedError } from "../classes/TypedError";
5
5
 
6
6
  // Zod v4: Extend GlobalMeta to support custom 'isSecret' metadata
7
7
  // This allows using .meta({ isSecret: true }) on any zod schema
8
- declare module "zod" {
8
+ declare module "zod/v4" {
9
9
  interface GlobalMeta {
10
10
  isSecret?: boolean;
11
11
  }