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.
- package/classes/Router.ts +157 -0
- package/initializers/actionts.ts +9 -0
- package/package.json +4 -2
- package/testing/index.ts +105 -0
- package/util/webRouting.ts +11 -39
|
@@ -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
|
+
}
|
package/initializers/actionts.ts
CHANGED
|
@@ -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.
|
|
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",
|
package/testing/index.ts
ADDED
|
@@ -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
|
+
}
|
package/util/webRouting.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
/**
|