keryx 0.5.0 → 0.7.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
@@ -67,17 +67,33 @@ export type ActionMiddlewareResponse = {
67
67
  updatedResponse?: any;
68
68
  };
69
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
+ */
70
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
+ */
71
79
  runBefore?: (
72
80
  params: ActionParams<Action>,
73
81
  connection: Connection,
74
82
  ) => Promise<ActionMiddlewareResponse | void>;
83
+ /**
84
+ * Runs after the action's `run()` method. Can replace the response by returning `{ updatedResponse }`.
85
+ */
75
86
  runAfter?: (
76
87
  params: ActionParams<Action>,
77
88
  connection: Connection,
78
89
  ) => Promise<ActionMiddlewareResponse | void>;
79
90
  };
80
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
+ */
81
97
  export abstract class Action {
82
98
  name: string;
83
99
  description?: string;
@@ -139,10 +155,18 @@ export abstract class Action {
139
155
  ): Promise<any>;
140
156
  }
141
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
+ */
142
162
  export type ActionParams<A extends Action> =
143
163
  A["inputs"] extends z.ZodType<any>
144
164
  ? z.infer<A["inputs"]>
145
165
  : Record<string, unknown>;
146
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
+ */
147
171
  export type ActionResponse<A extends Action> = Awaited<ReturnType<A["run"]>> &
148
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,
@@ -141,13 +174,23 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
141
174
  : "\r\n" + error.stack
142
175
  : "";
143
176
 
177
+ const correlationIdTag = this.correlationId
178
+ ? ` [cor:${this.correlationId}]`
179
+ : "";
180
+
144
181
  logger.info(
145
- `${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}`,
146
183
  );
147
184
 
148
185
  return { response, error };
149
186
  }
150
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
+ */
151
194
  async updateSession(data: Partial<T>) {
152
195
  await this.loadSession();
153
196
 
@@ -161,14 +204,23 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
161
204
  return api.session.update(this.session, data);
162
205
  }
163
206
 
207
+ /** Add a channel to this connection's subscription set. */
164
208
  subscribe(channel: string) {
165
209
  this.subscriptions.add(channel);
166
210
  }
167
211
 
212
+ /** Remove a channel from this connection's subscription set. */
168
213
  unsubscribe(channel: string) {
169
214
  this.subscriptions.delete(channel);
170
215
  }
171
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
+ */
172
224
  async broadcast(channel: string, message: string) {
173
225
  if (!this.subscriptions.has(channel)) {
174
226
  throw new TypedError({
@@ -180,16 +232,28 @@ export class Connection<T extends Record<string, any> = Record<string, any>> {
180
232
  return api.pubsub.broadcast(channel, message, this.id);
181
233
  }
182
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
+ */
183
242
  onBroadcastMessageReceived(_payload: PubSubMessage) {
184
243
  throw new Error(
185
244
  "unimplemented - this should be overwritten by connections that support it",
186
245
  );
187
246
  }
188
247
 
248
+ /** Remove this connection from the global connections map and clean up resources. */
189
249
  destroy() {
190
250
  return api.connections.destroy(this.type, this.identifier, this.id);
191
251
  }
192
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
+ */
193
257
  async loadSession() {
194
258
  if (this.session) return;
195
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",
@@ -33,6 +37,7 @@ export enum ErrorType {
33
37
  "CONNECTION_TASK_DEFINITION" = "CONNECTION_TASK_DEFINITION",
34
38
  }
35
39
 
40
+ /** Maps each `ErrorType` to the HTTP status code returned to the client. */
36
41
  export const ErrorStatusCodes: Record<ErrorType, number> = {
37
42
  [ErrorType.SERVER_INITIALIZATION]: 500,
38
43
  [ErrorType.SERVER_START]: 500,
@@ -64,16 +69,29 @@ export const ErrorStatusCodes: Record<ErrorType, number> = {
64
69
  };
65
70
 
66
71
  export type TypedErrorArgs = {
72
+ /** Human-readable error message returned to the client. */
67
73
  message: string;
74
+ /** The error category, which determines the HTTP status code. */
68
75
  type: ErrorType;
76
+ /** The original caught error, if wrapping. Its stack trace is preserved on the `TypedError`. */
69
77
  originalError?: unknown;
78
+ /** The param key that caused the error (for validation errors). */
70
79
  key?: string;
80
+ /** The param value that caused the error (for validation errors). */
71
81
  value?: any;
72
82
  };
73
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
+ */
74
89
  export class TypedError extends Error {
90
+ /** The error category, used to determine the HTTP status code via `ErrorStatusCodes`. */
75
91
  type: ErrorType;
92
+ /** The param key that caused the error (for validation errors). */
76
93
  key?: string;
94
+ /** The param value that caused the error (for validation errors). */
77
95
  value?: any;
78
96
 
79
97
  constructor(args: TypedErrorArgs) {
@@ -44,6 +44,7 @@ export const configServerWeb = {
44
44
  "WS_MAX_SUBSCRIPTIONS",
45
45
  100,
46
46
  ),
47
+ websocketDrainTimeout: await loadFromEnvIfSet("WS_DRAIN_TIMEOUT", 5000),
47
48
  includeStackInErrors: await loadFromEnvIfSet(
48
49
  "WEB_SERVER_INCLUDE_STACK_IN_ERRORS",
49
50
  (Bun.env.NODE_ENV ?? "development") !== "production",
@@ -70,4 +71,8 @@ export const configServerWeb = {
70
71
  "strict-origin-when-cross-origin",
71
72
  ),
72
73
  } as Record<string, string>,
74
+ correlationId: {
75
+ header: await loadFromEnvIfSet("WEB_CORRELATION_ID_HEADER", "X-Request-Id"),
76
+ trustProxy: await loadFromEnvIfSet("WEB_CORRELATION_ID_TRUST_PROXY", false),
77
+ },
73
78
  };