keryx 0.21.7 → 0.21.9

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.
@@ -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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "keryx",
3
- "version": "0.21.7",
3
+ "version": "0.21.9",
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",
@@ -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
  /**