keryx 0.21.7 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/classes/Action.ts CHANGED
@@ -181,10 +181,12 @@ export abstract class Action {
181
181
  * action's `inputs` Zod schema (falls back to `Record<string, unknown>` when no schema is
182
182
  * defined). By the time `run` is called, all middleware `runBefore` hooks have already
183
183
  * executed and may have mutated the params.
184
- * @param connection - The connection that initiated this action. Provides access to the
185
- * caller's session (`connection.session`), subscription state, and raw transport handle.
186
- * It is `undefined` when the action is invoked outside an HTTP/WebSocket request context
187
- * (e.g., as a background task via the Resque worker or via `api.actions.run()`).
184
+ * @param connection - The connection that initiated this action. Always defined. Provides
185
+ * access to the caller's session (`connection.session`), subscription state, and raw
186
+ * transport handle. For background tasks (resque worker), `connection.type === "task"`
187
+ * and `connection.session.data` is empty tasks are fresh starts, so actions that
188
+ * need user context should receive it as an input parameter rather than reading from
189
+ * the session.
188
190
  * @param abortSignal - An `AbortSignal` tied to the action's timeout. The signal is aborted
189
191
  * when the per-action `timeout` (or the global `config.actions.timeout`, default 300 000 ms)
190
192
  * elapses. Long-running actions should check `abortSignal.aborted` or pass the signal to
@@ -194,7 +196,7 @@ export abstract class Action {
194
196
  */
195
197
  abstract run(
196
198
  params: ActionParams<Action>,
197
- connection?: Connection,
199
+ connection: Connection,
198
200
  abortSignal?: AbortSignal,
199
201
  ): Promise<any>;
200
202
  }
@@ -0,0 +1,157 @@
1
+ import type { Action, HTTP_METHOD } from "./Action";
2
+
3
+ /**
4
+ * Result of a successful route match.
5
+ */
6
+ export type RouterMatch = {
7
+ actionName: string;
8
+ pathParams?: Record<string, string>;
9
+ };
10
+
11
+ type DynamicRoute = {
12
+ matcher: RegExp;
13
+ paramNames: string[];
14
+ actionName: string;
15
+ };
16
+
17
+ /**
18
+ * Fast path-to-action lookup.
19
+ *
20
+ * `compile()` builds two lookup structures from a list of actions:
21
+ * - a static index keyed by method + exact path for plain-string routes with no `:param` segments
22
+ * - a dynamic list (per method) of pre-compiled regexes for `:param` and `RegExp` routes
23
+ *
24
+ * `match()` performs an O(1) static lookup first, then falls back to an O(k) scan of
25
+ * dynamic routes for that method (where k is the number of dynamic routes per method).
26
+ *
27
+ * The router preserves the registration order of dynamic routes, so when two dynamic
28
+ * patterns can both match a path, the first one registered wins — matching the
29
+ * behavior of the pre-router iteration loop.
30
+ *
31
+ * The router reads the action list through a source function supplied to
32
+ * `compile()`. On each `match()` call it checks whether the returned array
33
+ * reference or length has changed since the last build, and rebuilds on drift.
34
+ * This preserves the live-iteration semantics of the previous loop-based matcher
35
+ * — including tolerating tests that both push new actions onto
36
+ * `api.actions.actions` and that swap the array wholesale via
37
+ * `api.actions.actions = api.actions.actions.filter(...)`.
38
+ */
39
+ export class Router {
40
+ private staticIndex: Map<HTTP_METHOD, Map<string, string>> = new Map();
41
+ private dynamicList: Map<HTTP_METHOD, DynamicRoute[]> = new Map();
42
+ private source: () => Action[] = () => [];
43
+ private lastSeen: Action[] | null = null;
44
+ private lastLength = -1;
45
+
46
+ /**
47
+ * Bind the router to an action source and eagerly build the lookup structures.
48
+ *
49
+ * The `source` argument may be the action array directly, or a getter function
50
+ * that returns the current array. A getter is the recommended form for
51
+ * long-lived routers (e.g. the one on `api.actions`) because some callers
52
+ * swap the underlying array reference rather than mutating in place.
53
+ *
54
+ * Safe to call multiple times — each call fully replaces the previous state.
55
+ * When in-process hot-reload for actions is added, callers must re-invoke this
56
+ * method; the currently relied-on dev restart via `bun --watch` is a full
57
+ * process restart, so `initialize()` naturally re-runs this.
58
+ *
59
+ * @param source - Either the actions array or a function returning it. Actions without a `web.route` are skipped.
60
+ */
61
+ compile(source: Action[] | (() => Action[])): void {
62
+ this.source =
63
+ typeof source === "function" ? source : () => source as Action[];
64
+
65
+ // Eagerly build if the source is ready. If the getter throws (e.g. called
66
+ // inside `initialize()` before `api.actions` is assigned), defer to the
67
+ // first `match()` call.
68
+ try {
69
+ const actions = this.source();
70
+ this.rebuild(actions);
71
+ } catch {
72
+ this.lastSeen = null;
73
+ this.lastLength = -1;
74
+ }
75
+ }
76
+
77
+ private rebuild(actions: Action[]): void {
78
+ this.staticIndex = new Map();
79
+ this.dynamicList = new Map();
80
+
81
+ for (const action of actions) {
82
+ if (!action?.web?.route) continue;
83
+ const { route, method } = action.web;
84
+
85
+ const isRegExp = route instanceof RegExp;
86
+ const hasParams = !isRegExp && /:\w+/.test(route as string);
87
+
88
+ if (!isRegExp && !hasParams) {
89
+ let byPath = this.staticIndex.get(method);
90
+ if (!byPath) {
91
+ byPath = new Map();
92
+ this.staticIndex.set(method, byPath);
93
+ }
94
+ if (!byPath.has(route as string)) {
95
+ byPath.set(route as string, action.name);
96
+ }
97
+ continue;
98
+ }
99
+
100
+ const matcher = isRegExp
101
+ ? (route as RegExp)
102
+ : new RegExp(`^${(route as string).replace(/:\w+/g, "([^/]+)")}$`);
103
+ const paramNames = isRegExp
104
+ ? []
105
+ : ((route as string).match(/:\w+/g) ?? []).map((n) => n.slice(1));
106
+
107
+ let list = this.dynamicList.get(method);
108
+ if (!list) {
109
+ list = [];
110
+ this.dynamicList.set(method, list);
111
+ }
112
+ list.push({ matcher, paramNames, actionName: action.name });
113
+ }
114
+
115
+ this.lastSeen = actions;
116
+ this.lastLength = actions.length;
117
+ }
118
+
119
+ /**
120
+ * Match a path + method against the compiled routes.
121
+ *
122
+ * @param path - The request path (already stripped of any API prefix).
123
+ * @param method - Uppercase HTTP method.
124
+ * @returns The matched action name plus any extracted path parameters, or `null` if no route matched.
125
+ */
126
+ match(path: string, method: HTTP_METHOD): RouterMatch | null {
127
+ const actions = this.source();
128
+ if (actions !== this.lastSeen || actions.length !== this.lastLength) {
129
+ this.rebuild(actions);
130
+ }
131
+
132
+ const staticHit = this.staticIndex.get(method)?.get(path);
133
+ if (staticHit) return { actionName: staticHit };
134
+
135
+ const candidates = this.dynamicList.get(method);
136
+ if (!candidates) return null;
137
+
138
+ for (const { matcher, paramNames, actionName } of candidates) {
139
+ const result = matcher.exec(path);
140
+ if (!result) continue;
141
+
142
+ if (paramNames.length === 0) return { actionName };
143
+
144
+ const pathParams: Record<string, string> = {};
145
+ for (let i = 0; i < paramNames.length; i++) {
146
+ const value = result[i + 1];
147
+ if (value !== undefined) pathParams[paramNames[i]] = value;
148
+ }
149
+ return {
150
+ actionName,
151
+ pathParams: Object.keys(pathParams).length > 0 ? pathParams : undefined,
152
+ };
153
+ }
154
+
155
+ return null;
156
+ }
157
+ }
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import { api, logger } from "../api";
5
5
  import { type Action, DEFAULT_QUEUE } from "../classes/Action";
6
6
  import { Initializer } from "../classes/Initializer";
7
+ import { Router } from "../classes/Router";
7
8
  import { ErrorType, TypedError } from "../classes/TypedError";
8
9
  import { config } from "../config";
9
10
  import { formatLoadedMessage } from "../util/config";
@@ -672,8 +673,16 @@ export class Actions extends Initializer {
672
673
  }),
673
674
  );
674
675
 
676
+ // Keep the router compile co-located with action assembly — if in-process
677
+ // hot-reload is ever added, this is the single seam that must re-fire.
678
+ // The getter lets the router track wholesale array replacement (e.g. tests
679
+ // that do `api.actions.actions = api.actions.actions.filter(...)`).
680
+ const router = new Router();
681
+ router.compile(() => api.actions.actions);
682
+
675
683
  return {
676
684
  actions,
685
+ router,
677
686
 
678
687
  enqueue: this.enqueue,
679
688
  fanOut: this.fanOut,
@@ -279,9 +279,11 @@ export class Resque extends Initializer {
279
279
  };
280
280
 
281
281
  /**
282
- * Wrap an action as a node-resque job. Creates a temporary `Connection` with type `"resque"`,
283
- * converts inputs to a plain object, and runs the action via `connection.act()`. Handles
284
- * fan-out result/error collection and recurring task re-enqueue.
282
+ * Wrap an action as a node-resque job. Creates a fresh `Connection` of type `"task"`
283
+ * with an empty in-memory session stub (tasks are fresh starts no Redis read/write
284
+ * for session), converts inputs to a plain object, and runs the action via
285
+ * `connection.act()`. Handles fan-out result/error collection and recurring task
286
+ * re-enqueue.
285
287
  */
286
288
  wrapActionAsJob = (
287
289
  action: Action,
@@ -291,18 +293,6 @@ export class Resque extends Initializer {
291
293
  pluginOptions: {},
292
294
 
293
295
  perform: async function (params: ActionParams<typeof action>) {
294
- const connection = new Connection(
295
- "resque",
296
- `job:${api.process.name}:${SERVER_JOB_COUNTER++}}`,
297
- );
298
-
299
- const propagatedCorrelationId = params._correlationId as
300
- | string
301
- | undefined;
302
- if (propagatedCorrelationId) {
303
- connection.correlationId = propagatedCorrelationId;
304
- }
305
-
306
296
  const plainParams: Record<string, unknown> =
307
297
  typeof params === "object" && params !== null
308
298
  ? Object.fromEntries(
@@ -312,6 +302,27 @@ export class Resque extends Initializer {
312
302
  )
313
303
  : {};
314
304
 
305
+ const propagatedCorrelationId = plainParams._correlationId as
306
+ | string
307
+ | undefined;
308
+
309
+ const connection = new Connection(
310
+ "task",
311
+ `job:${api.process.name}:${SERVER_JOB_COUNTER++}`,
312
+ );
313
+ if (propagatedCorrelationId) {
314
+ connection.correlationId = propagatedCorrelationId;
315
+ }
316
+ // Synthesize an empty session in-memory — tasks are fresh starts; needed data
317
+ // must come through action params, not session state.
318
+ connection.session = {
319
+ id: `task:${connection.id}`,
320
+ cookieName: config.session.cookieName,
321
+ createdAt: Date.now(),
322
+ data: {},
323
+ };
324
+ connection.sessionLoaded = true;
325
+
315
326
  const fanOutId = plainParams._fanOutId as string | undefined;
316
327
 
317
328
  let response: Awaited<ReturnType<(typeof action)["run"]>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.21.7",
3
+ "version": "0.22.0",
4
4
  "module": "index.ts",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,6 +49,7 @@
49
49
  "middleware/",
50
50
  "servers/",
51
51
  "templates/",
52
+ "testing/",
52
53
  "util/",
53
54
  "api.ts",
54
55
  "index.ts",
@@ -69,7 +70,8 @@
69
70
  "./util/*": "./util/*",
70
71
  "./config": "./config/index.ts",
71
72
  "./config/*": "./config/*",
72
- "./servers/*": "./servers/*"
73
+ "./servers/*": "./servers/*",
74
+ "./testing": "./testing/index.ts"
73
75
  },
74
76
  "scripts": {
75
77
  "start": "bun keryx.ts start",
@@ -3,7 +3,7 @@ import type { ActionMiddleware } from "keryx/classes/Action.ts";
3
3
 
4
4
  export const SessionMiddleware: ActionMiddleware = {
5
5
  runBefore: async (_params, connection: Connection<{ userId?: number }>) => {
6
- if (!connection.session || !connection.session.data.userId) {
6
+ if (!connection.session?.data.userId) {
7
7
  throw new TypedError({
8
8
  message: "Session not found",
9
9
  type: ErrorType.CONNECTION_SESSION_NOT_FOUND,
@@ -0,0 +1,105 @@
1
+ import { afterAll, beforeAll } from "bun:test";
2
+ import { api } from "../api";
3
+ import type { WebServer } from "../servers/web";
4
+
5
+ /**
6
+ * Generous lifecycle hook timeout (15s) for `beforeAll` / `afterAll`.
7
+ *
8
+ * `api.start()` and `api.stop()` connect to Redis, Postgres, run migrations,
9
+ * etc. — slower than a unit test, especially in CI. Pass this as the second
10
+ * argument to `beforeAll` / `afterAll` so hooks don't time out.
11
+ *
12
+ * Note: `bun:test`'s `setDefaultTimeout` and `bunfig.toml [test].timeout` only
13
+ * apply to `test()` blocks, not lifecycle hooks.
14
+ */
15
+ export const HOOK_TIMEOUT = 15_000;
16
+
17
+ /**
18
+ * Return the actual URL the web server bound to (with resolved port).
19
+ * Returns an empty string if the web server isn't running.
20
+ *
21
+ * Call after `api.start()` so the server has bound its port. Useful when
22
+ * `WEB_SERVER_PORT=0` is set so each test file gets a random available port.
23
+ */
24
+ export function serverUrl(): string {
25
+ const web = api.servers.servers.find(
26
+ (s: { name: string }) => s.name === "web",
27
+ ) as WebServer | undefined;
28
+ return web?.url || "";
29
+ }
30
+
31
+ /**
32
+ * Register `beforeAll` / `afterAll` hooks that start and stop the API around
33
+ * a test file. Returns a getter for the bound server URL — the URL isn't known
34
+ * until after `api.start()` binds the port, so it can't be returned directly.
35
+ *
36
+ * **Hook ordering gotcha.** `bun:test` runs `beforeAll` and `afterAll` hooks in
37
+ * registration order (not LIFO). So whichever hook is registered first runs
38
+ * first — in both phases. This matters in two ways:
39
+ *
40
+ * 1. **Config overrides that must happen before `api.start()`** — register a
41
+ * separate `beforeAll` that sets the config *before* calling `useTestServer()`,
42
+ * so it runs first.
43
+ * 2. **Teardown cleanup that needs Redis/DB access** — the helper's `afterAll`
44
+ * calls `api.stop()`, which closes the Redis and Postgres connections. Any
45
+ * cleanup that touches those must run *before* `api.stop()`. Since `afterAll`
46
+ * also runs in registration order, the helper's `api.stop()` runs first. Put
47
+ * connection-dependent cleanup in the same `afterAll` block as the original
48
+ * start/stop pair rather than splitting it, or handle cleanup in `afterEach`.
49
+ *
50
+ * @param opts.clearDatabase - Truncate all tables in `beforeAll`. Default `false`.
51
+ * Requires the `db` initializer to be active. Opt in for tests that mutate
52
+ * persistent state.
53
+ * @param opts.clearRedis - Flush the current Redis DB in `beforeAll`. Default
54
+ * `false`. Requires the `redis` initializer to be active. Opt in for tests
55
+ * that exercise pub/sub so messages from prior tests don't leak in.
56
+ * @returns A getter function that returns the server URL once `api.start()` has
57
+ * bound its port. Call the getter at each fetch site: `fetch(getUrl() + "/api/...")`.
58
+ *
59
+ * @example
60
+ * const getUrl = useTestServer({ clearDatabase: true });
61
+ *
62
+ * test("creates a user", async () => {
63
+ * const res = await fetch(getUrl() + "/api/user", { method: "PUT", ... });
64
+ * expect(res.status).toBe(200);
65
+ * });
66
+ */
67
+ export function useTestServer(
68
+ opts: { clearDatabase?: boolean; clearRedis?: boolean } = {},
69
+ ): () => string {
70
+ const { clearDatabase = false, clearRedis = false } = opts;
71
+ let url = "";
72
+ beforeAll(async () => {
73
+ await api.start();
74
+ url = serverUrl();
75
+ if (clearDatabase) await api.db.clearDatabase();
76
+ if (clearRedis) await api.redis.redis.flushdb();
77
+ }, HOOK_TIMEOUT);
78
+ afterAll(async () => {
79
+ await api.stop();
80
+ }, HOOK_TIMEOUT);
81
+ return () => url;
82
+ }
83
+
84
+ /**
85
+ * Poll a condition until it returns true, or throw after a timeout.
86
+ *
87
+ * Use this instead of fixed `Bun.sleep()` calls when waiting for async side
88
+ * effects like background tasks, pub/sub delivery, or presence updates.
89
+ *
90
+ * @param condition - Function returning a boolean (or Promise of one). Polled
91
+ * repeatedly until it returns truthy.
92
+ * @param opts.interval - Milliseconds between polls. Default 50.
93
+ * @param opts.timeout - Milliseconds before giving up. Default 5000.
94
+ * @throws {Error} If the condition doesn't become truthy before the timeout.
95
+ */
96
+ export async function waitFor(
97
+ condition: () => Promise<boolean> | boolean,
98
+ { interval = 50, timeout = 5000 } = {},
99
+ ): Promise<void> {
100
+ const start = Date.now();
101
+ while (!(await condition())) {
102
+ if (Date.now() - start > timeout) throw new Error("waitFor timed out");
103
+ await Bun.sleep(interval);
104
+ }
105
+ }
@@ -7,6 +7,10 @@ import { config } from "../config";
7
7
  /**
8
8
  * Match a URL path + HTTP method against registered action routes.
9
9
  * Returns the action name and any extracted path parameters, or `null` if no match.
10
+ *
11
+ * Delegates to the pre-compiled `api.actions.router` for O(1) static-route lookup
12
+ * and an ordered scan of parameterized routes. This function only handles the
13
+ * transport concern of stripping the configured API prefix before routing.
10
14
  */
11
15
  export async function determineActionName(
12
16
  url: ReturnType<typeof parse>,
@@ -20,46 +24,14 @@ export async function determineActionName(
20
24
  "",
21
25
  );
22
26
 
23
- for (const action of api.actions.actions) {
24
- if (!action?.web?.route) continue;
25
-
26
- // Convert route with path parameters to regex
27
- const routeWithParams = `${action.web.route}`.replace(/:\w+/g, "([^/]+)");
28
- const matcher =
29
- action.web.route instanceof RegExp
30
- ? action.web.route
31
- : new RegExp(`^${routeWithParams}$`);
32
-
33
- if (
34
- pathToMatch &&
35
- pathToMatch.match(matcher) &&
36
- method.toUpperCase() === action.web.method
37
- ) {
38
- // Extract path parameters if the route has them
39
- const pathParams: Record<string, string> = {};
40
- const paramNames = (`${action.web.route}`.match(/:\w+/g) || []).map(
41
- (name) => name.slice(1),
42
- );
43
- const match = pathToMatch.match(matcher);
27
+ if (!pathToMatch) return { actionName: null, pathParams: null };
44
28
 
45
- if (match && paramNames.length > 0) {
46
- // Skip the first match (full string) and use the captured groups
47
- for (let i = 0; i < paramNames.length; i++) {
48
- const value = match[i + 1];
49
- if (value !== undefined) {
50
- pathParams[paramNames[i]] = value;
51
- }
52
- }
53
- }
54
-
55
- return {
56
- actionName: action.name,
57
- pathParams: Object.keys(pathParams).length > 0 ? pathParams : undefined,
58
- };
59
- }
60
- }
61
-
62
- return { actionName: null, pathParams: null };
29
+ const match = api.actions.router.match(
30
+ pathToMatch,
31
+ method.toUpperCase() as HTTP_METHOD,
32
+ );
33
+ if (!match) return { actionName: null, pathParams: null };
34
+ return { actionName: match.actionName, pathParams: match.pathParams };
63
35
  }
64
36
 
65
37
  /**