keryx 0.23.2 → 0.24.1
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/API.ts +121 -18
- package/classes/Initializer.ts +10 -17
- package/initializers/actionts.ts +0 -1
- package/initializers/channels.ts +1 -3
- package/initializers/connections.ts +0 -1
- package/initializers/db.ts +0 -3
- package/initializers/mcp.ts +1 -3
- package/initializers/oauth.ts +1 -2
- package/initializers/observability.ts +1 -3
- package/initializers/process.ts +0 -1
- package/initializers/pubsub.ts +1 -2
- package/initializers/redis.ts +0 -3
- package/initializers/resque.ts +1 -4
- package/initializers/servers.ts +1 -3
- package/initializers/session.ts +1 -1
- package/initializers/signals.ts +0 -1
- package/initializers/swagger.ts +1 -1
- package/package.json +1 -1
- package/testing/http.ts +49 -0
- package/testing/index.ts +5 -0
package/classes/API.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
formatLoadedMessage,
|
|
9
9
|
} from "../util/config";
|
|
10
10
|
import { globLoader } from "../util/glob";
|
|
11
|
-
import type { Initializer
|
|
11
|
+
import type { Initializer } from "./Initializer";
|
|
12
12
|
import { Logger } from "./Logger";
|
|
13
13
|
import { ErrorType, TypedError } from "./TypedError";
|
|
14
14
|
|
|
@@ -18,8 +18,6 @@ export enum RUN_MODE {
|
|
|
18
18
|
SERVER = "server",
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
let flapPreventer = false;
|
|
22
|
-
|
|
23
21
|
/**
|
|
24
22
|
* The global singleton that manages the full framework lifecycle: initialize → start → stop.
|
|
25
23
|
* All initializers attach their namespaces to this object (e.g., `api.db`, `api.actions`, `api.redis`).
|
|
@@ -42,8 +40,10 @@ export class API {
|
|
|
42
40
|
logger: Logger;
|
|
43
41
|
/** The current run mode (SERVER or CLI), set during `start()`. */
|
|
44
42
|
runMode!: RUN_MODE;
|
|
45
|
-
/** All discovered initializer instances, sorted by
|
|
43
|
+
/** All discovered initializer instances, topologically sorted by `dependsOn` after discovery. */
|
|
46
44
|
initializers: Initializer[];
|
|
45
|
+
/** Guards `restart()` against concurrent re-entry so rapid stop/start cycles get coalesced. */
|
|
46
|
+
private flapPreventer = false;
|
|
47
47
|
|
|
48
48
|
// allow arbitrary properties to be set on the API, to be added and typed later
|
|
49
49
|
[key: string]: any;
|
|
@@ -63,7 +63,7 @@ export class API {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* Load configuration overrides and discover + run all initializers.
|
|
66
|
-
* Calls each initializer's `initialize()` method in `
|
|
66
|
+
* Calls each initializer's `initialize()` method in dependency order (topological sort of `dependsOn`).
|
|
67
67
|
* The return value of each initializer is attached to `api[initializer.name]`.
|
|
68
68
|
*
|
|
69
69
|
* @throws {TypedError} With `ErrorType.SERVER_INITIALIZATION` if any initializer fails.
|
|
@@ -75,7 +75,8 @@ export class API {
|
|
|
75
75
|
await this.loadLocalConfig();
|
|
76
76
|
this.loadPluginConfig();
|
|
77
77
|
await this.findInitializers();
|
|
78
|
-
this.
|
|
78
|
+
this.topologicallySortInitializers();
|
|
79
|
+
this.logInitializerDag();
|
|
79
80
|
|
|
80
81
|
for (const initializer of this.initializers) {
|
|
81
82
|
try {
|
|
@@ -101,7 +102,7 @@ export class API {
|
|
|
101
102
|
/**
|
|
102
103
|
* Start the framework: connect to external services, bind server ports, start workers.
|
|
103
104
|
* Calls `initialize()` first if it hasn't been run yet, then calls each initializer's
|
|
104
|
-
* `start()` method in
|
|
105
|
+
* `start()` method in dependency order. Initializers whose `runModes` do not include
|
|
105
106
|
* the current `runMode` are skipped.
|
|
106
107
|
*
|
|
107
108
|
* @param runMode - Whether to start in SERVER mode (HTTP/WebSocket) or CLI mode.
|
|
@@ -117,8 +118,6 @@ export class API {
|
|
|
117
118
|
|
|
118
119
|
this.logger.warn("--- 🔼 Starting process ---");
|
|
119
120
|
|
|
120
|
-
this.sortInitializers("startPriority");
|
|
121
|
-
|
|
122
121
|
for (const initializer of this.initializers) {
|
|
123
122
|
if (!initializer.runModes.includes(runMode)) {
|
|
124
123
|
this.logger.debug(
|
|
@@ -148,7 +147,8 @@ export class API {
|
|
|
148
147
|
|
|
149
148
|
/**
|
|
150
149
|
* Gracefully shut down the framework: disconnect from services, close server ports, stop workers.
|
|
151
|
-
* Calls each initializer's `stop()` method in
|
|
150
|
+
* Calls each initializer's `stop()` method in reverse dependency order (dependents stop before
|
|
151
|
+
* their dependencies). No-ops if already stopped.
|
|
152
152
|
*
|
|
153
153
|
* @throws {TypedError} With `ErrorType.SERVER_STOP` if any initializer fails to stop.
|
|
154
154
|
*/
|
|
@@ -160,9 +160,7 @@ export class API {
|
|
|
160
160
|
|
|
161
161
|
this.logger.warn("--- 🔽 Stopping process ---");
|
|
162
162
|
|
|
163
|
-
this.
|
|
164
|
-
|
|
165
|
-
for (const initializer of this.initializers) {
|
|
163
|
+
for (const initializer of [...this.initializers].reverse()) {
|
|
166
164
|
try {
|
|
167
165
|
this.logger.debug(`Stopping initializer ${initializer.name}`);
|
|
168
166
|
await initializer.stop?.();
|
|
@@ -186,12 +184,12 @@ export class API {
|
|
|
186
184
|
* concurrent restart calls to avoid rapid stop/start cycles.
|
|
187
185
|
*/
|
|
188
186
|
async restart() {
|
|
189
|
-
if (flapPreventer) return;
|
|
187
|
+
if (this.flapPreventer) return;
|
|
190
188
|
|
|
191
|
-
flapPreventer = true;
|
|
189
|
+
this.flapPreventer = true;
|
|
192
190
|
await this.stop();
|
|
193
191
|
await this.start();
|
|
194
|
-
flapPreventer = false;
|
|
192
|
+
this.flapPreventer = false;
|
|
195
193
|
}
|
|
196
194
|
|
|
197
195
|
private async loadLocalConfig() {
|
|
@@ -228,6 +226,11 @@ export class API {
|
|
|
228
226
|
}
|
|
229
227
|
|
|
230
228
|
private async findInitializers() {
|
|
229
|
+
// Reset so that re-running `initialize()` (e.g. between test files that share
|
|
230
|
+
// the `globalThis.api` singleton) produces a deterministic graph rather than
|
|
231
|
+
// accumulating duplicates from previous runs.
|
|
232
|
+
this.initializers = [];
|
|
233
|
+
|
|
231
234
|
// Load framework initializers from the package directory
|
|
232
235
|
const frameworkInitializers = await globLoader<Initializer>(
|
|
233
236
|
path.join(this.packageDir, "initializers"),
|
|
@@ -273,8 +276,108 @@ export class API {
|
|
|
273
276
|
);
|
|
274
277
|
}
|
|
275
278
|
|
|
276
|
-
|
|
277
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Reorder `this.initializers` into a topological execution order derived from each
|
|
281
|
+
* initializer's `dependsOn` list. Uses Kahn's algorithm with insertion-order tie-breaking
|
|
282
|
+
* so framework initializers keep their relative load order when they are mutually
|
|
283
|
+
* independent. Throws on missing dependencies or cycles — both indicate misconfiguration
|
|
284
|
+
* that would otherwise surface as confusing runtime errors.
|
|
285
|
+
*
|
|
286
|
+
* @throws {TypedError} With `ErrorType.INITIALIZER_VALIDATION` on unknown dependency
|
|
287
|
+
* names or circular dependency chains.
|
|
288
|
+
*/
|
|
289
|
+
private topologicallySortInitializers() {
|
|
290
|
+
const byName = new Map<string, Initializer>();
|
|
291
|
+
for (const i of this.initializers) byName.set(i.name, i);
|
|
292
|
+
|
|
293
|
+
// Validate dependency names exist before we try to sort.
|
|
294
|
+
for (const i of this.initializers) {
|
|
295
|
+
for (const dep of i.dependsOn) {
|
|
296
|
+
if (!byName.has(dep)) {
|
|
297
|
+
throw new TypedError({
|
|
298
|
+
type: ErrorType.INITIALIZER_VALIDATION,
|
|
299
|
+
message: `Initializer "${i.name}" depends on unknown initializer "${dep}". Available: ${[...byName.keys()].join(", ")}.`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Kahn's algorithm. `indegree[name]` = number of unresolved deps.
|
|
306
|
+
const indegree = new Map<string, number>();
|
|
307
|
+
for (const i of this.initializers) indegree.set(i.name, i.dependsOn.length);
|
|
308
|
+
|
|
309
|
+
// Reverse adjacency: for each dep, who depends on it?
|
|
310
|
+
const dependents = new Map<string, string[]>();
|
|
311
|
+
for (const i of this.initializers) {
|
|
312
|
+
for (const dep of i.dependsOn) {
|
|
313
|
+
const list = dependents.get(dep) ?? [];
|
|
314
|
+
list.push(i.name);
|
|
315
|
+
dependents.set(dep, list);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const sorted: Initializer[] = [];
|
|
320
|
+
// Seed the queue with zero-indegree initializers, preserving original insertion order.
|
|
321
|
+
const queue: Initializer[] = this.initializers.filter(
|
|
322
|
+
(i) => indegree.get(i.name) === 0,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
while (queue.length > 0) {
|
|
326
|
+
const next = queue.shift()!;
|
|
327
|
+
sorted.push(next);
|
|
328
|
+
for (const dependentName of dependents.get(next.name) ?? []) {
|
|
329
|
+
const remaining = indegree.get(dependentName)! - 1;
|
|
330
|
+
indegree.set(dependentName, remaining);
|
|
331
|
+
if (remaining === 0) {
|
|
332
|
+
// Insert in original position to keep deterministic ordering.
|
|
333
|
+
const dependent = byName.get(dependentName)!;
|
|
334
|
+
// Keep queue ordered by original index among ready items.
|
|
335
|
+
const dependentIndex = this.initializers.indexOf(dependent);
|
|
336
|
+
let insertAt = queue.length;
|
|
337
|
+
for (let i = 0; i < queue.length; i++) {
|
|
338
|
+
if (this.initializers.indexOf(queue[i]) > dependentIndex) {
|
|
339
|
+
insertAt = i;
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
queue.splice(insertAt, 0, dependent);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (sorted.length !== this.initializers.length) {
|
|
349
|
+
const unresolved = this.initializers
|
|
350
|
+
.filter((i) => (indegree.get(i.name) ?? 0) > 0)
|
|
351
|
+
.map((i) => i.name);
|
|
352
|
+
throw new TypedError({
|
|
353
|
+
type: ErrorType.INITIALIZER_VALIDATION,
|
|
354
|
+
message: `Circular dependency detected among initializers: ${unresolved.join(" → ")}. Check each initializer's \`dependsOn\` list for a cycle.`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
this.initializers = sorted;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Render the resolved initializer dependency graph to the logs as a numbered list,
|
|
363
|
+
* one line per initializer, with each dependency shown to the right. Leaf initializers
|
|
364
|
+
* (no deps) render without an arrow.
|
|
365
|
+
*/
|
|
366
|
+
private logInitializerDag() {
|
|
367
|
+
const longestName = this.initializers.reduce(
|
|
368
|
+
(max, i) => Math.max(max, i.name.length),
|
|
369
|
+
0,
|
|
370
|
+
);
|
|
371
|
+
const digits = String(this.initializers.length).length;
|
|
372
|
+
|
|
373
|
+
this.logger.debug("--- 🔗 Initializer dependency graph ---");
|
|
374
|
+
this.initializers.forEach((i, idx) => {
|
|
375
|
+
const num = String(idx + 1).padStart(digits, "0");
|
|
376
|
+
const name = i.name.padEnd(longestName);
|
|
377
|
+
const deps =
|
|
378
|
+
i.dependsOn.length > 0 ? ` ← ${i.dependsOn.join(", ")}` : "";
|
|
379
|
+
this.logger.debug(` ${num} ${name}${deps}`);
|
|
380
|
+
});
|
|
278
381
|
}
|
|
279
382
|
|
|
280
383
|
/**
|
package/classes/Initializer.ts
CHANGED
|
@@ -2,19 +2,19 @@ import { RUN_MODE } from "./../api";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Abstract base class for lifecycle components. Initializers are discovered automatically
|
|
5
|
-
* and run in
|
|
6
|
-
* Each initializer typically extends the `API`
|
|
7
|
-
* returns its namespace object from `initialize()`.
|
|
5
|
+
* and run in topological order derived from `dependsOn` during the framework's
|
|
6
|
+
* `initialize → start → stop` phases. Each initializer typically extends the `API`
|
|
7
|
+
* interface via module augmentation and returns its namespace object from `initialize()`.
|
|
8
8
|
*/
|
|
9
9
|
export abstract class Initializer {
|
|
10
10
|
/** The unique name of this initializer (also used as the key on the `api` object). */
|
|
11
11
|
name: string;
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Names of other initializers that must complete their `initialize()` and `start()`
|
|
14
|
+
* phases before this one runs. Also reverses for `stop()` — dependents shut down
|
|
15
|
+
* before their dependencies. Unknown names or cycles cause a startup error.
|
|
16
|
+
*/
|
|
17
|
+
dependsOn: string[];
|
|
18
18
|
/** Which run modes this initializer participates in. Defaults to both SERVER and CLI. */
|
|
19
19
|
runModes: RUN_MODE[];
|
|
20
20
|
/**
|
|
@@ -27,9 +27,7 @@ export abstract class Initializer {
|
|
|
27
27
|
|
|
28
28
|
constructor(name: string) {
|
|
29
29
|
this.name = name;
|
|
30
|
-
this.
|
|
31
|
-
this.startPriority = 1000;
|
|
32
|
-
this.stopPriority = 1000;
|
|
30
|
+
this.dependsOn = [];
|
|
33
31
|
this.runModes = [RUN_MODE.SERVER, RUN_MODE.CLI];
|
|
34
32
|
this.declaresAPIProperty = true;
|
|
35
33
|
}
|
|
@@ -50,8 +48,3 @@ export abstract class Initializer {
|
|
|
50
48
|
*/
|
|
51
49
|
async stop?(): Promise<any>;
|
|
52
50
|
}
|
|
53
|
-
|
|
54
|
-
export type InitializerSortKeys =
|
|
55
|
-
| "loadPriority"
|
|
56
|
-
| "startPriority"
|
|
57
|
-
| "stopPriority";
|
package/initializers/actionts.ts
CHANGED
package/initializers/channels.ts
CHANGED
package/initializers/db.ts
CHANGED
package/initializers/mcp.ts
CHANGED
|
@@ -31,9 +31,7 @@ declare module "../classes/API" {
|
|
|
31
31
|
export class McpInitializer extends Initializer {
|
|
32
32
|
constructor() {
|
|
33
33
|
super(namespace);
|
|
34
|
-
this.
|
|
35
|
-
this.startPriority = 560;
|
|
36
|
-
this.stopPriority = 90;
|
|
34
|
+
this.dependsOn = ["actions", "oauth", "connections", "pubsub"];
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
async initialize() {
|
package/initializers/oauth.ts
CHANGED
|
@@ -36,8 +36,7 @@ declare module "../classes/API" {
|
|
|
36
36
|
export class OAuthInitializer extends Initializer {
|
|
37
37
|
constructor() {
|
|
38
38
|
super(namespace);
|
|
39
|
-
this.
|
|
40
|
-
this.startPriority = 175;
|
|
39
|
+
this.dependsOn = ["redis", "actions"];
|
|
41
40
|
}
|
|
42
41
|
|
|
43
42
|
async initialize() {
|
|
@@ -28,9 +28,7 @@ declare module "../classes/API" {
|
|
|
28
28
|
export class Observability extends Initializer {
|
|
29
29
|
constructor() {
|
|
30
30
|
super(namespace);
|
|
31
|
-
this.
|
|
32
|
-
this.startPriority = 50;
|
|
33
|
-
this.stopPriority = 50;
|
|
31
|
+
this.dependsOn = ["actions", "connections"];
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
async initialize() {
|
package/initializers/process.ts
CHANGED
package/initializers/pubsub.ts
CHANGED
package/initializers/redis.ts
CHANGED
package/initializers/resque.ts
CHANGED
|
@@ -49,10 +49,7 @@ let SERVER_JOB_COUNTER = 1;
|
|
|
49
49
|
export class Resque extends Initializer {
|
|
50
50
|
constructor() {
|
|
51
51
|
super(namespace);
|
|
52
|
-
|
|
53
|
-
this.loadPriority = 250;
|
|
54
|
-
this.startPriority = 10000;
|
|
55
|
-
this.stopPriority = 900;
|
|
52
|
+
this.dependsOn = ["redis", "actions", "process"];
|
|
56
53
|
}
|
|
57
54
|
|
|
58
55
|
/** Create and connect the resque `Queue` instance (used for enqueuing jobs). */
|
package/initializers/servers.ts
CHANGED
|
@@ -17,9 +17,7 @@ declare module "../classes/API" {
|
|
|
17
17
|
export class Servers extends Initializer {
|
|
18
18
|
constructor() {
|
|
19
19
|
super(namespace);
|
|
20
|
-
this.
|
|
21
|
-
this.startPriority = 550;
|
|
22
|
-
this.stopPriority = 100;
|
|
20
|
+
this.dependsOn = ["actions"];
|
|
23
21
|
this.runModes = [RUN_MODE.SERVER];
|
|
24
22
|
}
|
|
25
23
|
|
package/initializers/session.ts
CHANGED
package/initializers/signals.ts
CHANGED
package/initializers/swagger.ts
CHANGED
package/package.json
CHANGED
package/testing/http.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default test user credentials. Matches the inline values used in the example
|
|
3
|
+
* backend tests prior to this helper.
|
|
4
|
+
*/
|
|
5
|
+
export const DEFAULT_TEST_USER = {
|
|
6
|
+
name: "Mario Mario",
|
|
7
|
+
email: "mario@example.com",
|
|
8
|
+
password: "mushroom1",
|
|
9
|
+
} as const;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a test user via HTTP PUT `/api/user`. Defaults to the Mario credentials
|
|
13
|
+
* used across example backend tests; override any field per call.
|
|
14
|
+
*
|
|
15
|
+
* Returns the raw `Response` so callers can assert on status and parse the body
|
|
16
|
+
* into whatever response type they need.
|
|
17
|
+
*
|
|
18
|
+
* @param url - Base server URL (from `useTestServer()`'s getter).
|
|
19
|
+
* @param overrides - Override any of `name`, `email`, `password`.
|
|
20
|
+
*/
|
|
21
|
+
export async function createTestUser(
|
|
22
|
+
url: string,
|
|
23
|
+
overrides?: Partial<{ name: string; email: string; password: string }>,
|
|
24
|
+
): Promise<Response> {
|
|
25
|
+
return fetch(url + "/api/user", {
|
|
26
|
+
method: "PUT",
|
|
27
|
+
headers: { "Content-Type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ ...DEFAULT_TEST_USER, ...overrides }),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Log in as a test user via HTTP PUT `/api/session`. Pair with `createTestUser`.
|
|
34
|
+
* Returns the raw `Response`.
|
|
35
|
+
*/
|
|
36
|
+
export async function createTestSession(
|
|
37
|
+
url: string,
|
|
38
|
+
overrides?: Partial<{ email: string; password: string }>,
|
|
39
|
+
): Promise<Response> {
|
|
40
|
+
return fetch(url + "/api/session", {
|
|
41
|
+
method: "PUT",
|
|
42
|
+
headers: { "Content-Type": "application/json" },
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
email: DEFAULT_TEST_USER.email,
|
|
45
|
+
password: DEFAULT_TEST_USER.password,
|
|
46
|
+
...overrides,
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
}
|
package/testing/index.ts
CHANGED
|
@@ -2,6 +2,11 @@ import { afterAll, beforeAll } from "bun:test";
|
|
|
2
2
|
import { api } from "../api";
|
|
3
3
|
import type { WebServer } from "../servers/web";
|
|
4
4
|
|
|
5
|
+
export {
|
|
6
|
+
createTestSession,
|
|
7
|
+
createTestUser,
|
|
8
|
+
DEFAULT_TEST_USER,
|
|
9
|
+
} from "./http";
|
|
5
10
|
export {
|
|
6
11
|
buildWebSocket,
|
|
7
12
|
createSession,
|