keryx 0.19.1 → 0.20.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/README.md +31 -0
- package/classes/API.ts +41 -1
- package/classes/Plugin.ts +54 -0
- package/config/index.ts +4 -0
- package/config/plugins.ts +3 -0
- package/index.ts +3 -1
- package/initializers/actionts.ts +37 -8
- package/initializers/channels.ts +24 -5
- package/initializers/mcp.ts +2 -1
- package/initializers/resque.ts +4 -3
- package/initializers/servers.ts +22 -2
- package/initializers/swagger.ts +2 -2
- package/package.json +1 -1
- package/util/cli.ts +3 -3
- package/util/config.ts +55 -0
- package/util/generate.ts +95 -21
- package/util/oauthHandlers.ts +15 -7
- package/util/scaffold.ts +9 -0
package/README.md
CHANGED
|
@@ -88,6 +88,10 @@ That's it. The agent can now discover all your actions as tools, authenticate vi
|
|
|
88
88
|
- **Strongly-typed frontend integration** — `ActionResponse<MyAction>` gives the frontend type-safe API responses, no code generation needed
|
|
89
89
|
- **Drizzle ORM** with auto-migrations (replacing the old `ah-sequelize-plugin`)
|
|
90
90
|
- **Companion Vite + React frontend** as a separate application (replacing `ah-next-plugin`)
|
|
91
|
+
- **Streaming responses** — SSE and chunked binary streaming via `StreamingResponse`, with per-transport behavior (HTTP, WebSocket, MCP)
|
|
92
|
+
- **Pagination helpers** — `paginationInputs()` Zod mixin + `paginate()` utility for standardized paginated responses
|
|
93
|
+
- **Database transactions** — `withTransaction()` and `TransactionMiddleware` for automatic commit/rollback across action execution
|
|
94
|
+
- **Redis caching patterns** — cache-aside and response-level cache middleware using the built-in ioredis connection
|
|
91
95
|
|
|
92
96
|
### Why Bun?
|
|
93
97
|
|
|
@@ -213,6 +217,31 @@ OAuth 2.1 with PKCE is used for authentication — MCP clients go through a brow
|
|
|
213
217
|
|
|
214
218
|
A parent task can distribute work across many child jobs using `api.actions.fanOut()` for parallel processing. Results are collected automatically in Redis. See the [Tasks guide](https://keryxjs.com/guide/tasks) for full API and examples.
|
|
215
219
|
|
|
220
|
+
### Streaming Responses
|
|
221
|
+
|
|
222
|
+
Actions can stream data by returning a `StreamingResponse`. The framework handles SSE, chunked binary, and cross-transport behavior automatically:
|
|
223
|
+
|
|
224
|
+
```ts
|
|
225
|
+
async run(params: { prompt: string }) {
|
|
226
|
+
const sse = StreamingResponse.sse();
|
|
227
|
+
|
|
228
|
+
(async () => {
|
|
229
|
+
try {
|
|
230
|
+
for await (const token of callLLM(params.prompt)) {
|
|
231
|
+
sse.send(token, { event: "token" });
|
|
232
|
+
}
|
|
233
|
+
sse.close();
|
|
234
|
+
} catch (e) {
|
|
235
|
+
sse.sendError(String(e));
|
|
236
|
+
}
|
|
237
|
+
})();
|
|
238
|
+
|
|
239
|
+
return sse;
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
Over HTTP this is native SSE; over WebSocket each chunk becomes an incremental message; over MCP chunks are forwarded as logging messages. See the [Streaming guide](https://keryxjs.com/guide/streaming) for full details.
|
|
244
|
+
|
|
216
245
|
## Coming from ActionHero?
|
|
217
246
|
|
|
218
247
|
Keryx keeps the core ideas but rewrites everything on Bun with first-class MCP support. The biggest changes: unified controllers (actions = tasks = CLI commands = MCP tools), separate frontend/backend applications, Drizzle ORM, and MCP as a first-class transport.
|
|
@@ -228,6 +257,8 @@ Each application has its own `Dockerfile`, and a `docker-compose.yml` runs them
|
|
|
228
257
|
Full docs at [keryxjs.com](https://keryxjs.com), including:
|
|
229
258
|
- [Getting Started](https://keryxjs.com/guide/)
|
|
230
259
|
- [Actions Guide](https://keryxjs.com/guide/actions)
|
|
260
|
+
- [Streaming](https://keryxjs.com/guide/streaming)
|
|
261
|
+
- [Caching](https://keryxjs.com/guide/caching)
|
|
231
262
|
- [API Reference](https://keryxjs.com/reference/actions)
|
|
232
263
|
|
|
233
264
|
<p align="center">
|
package/classes/API.ts
CHANGED
|
@@ -2,7 +2,11 @@ import { Glob } from "bun";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { config } from "../config";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
deepMerge,
|
|
7
|
+
deepMergeDefaults,
|
|
8
|
+
formatLoadedMessage,
|
|
9
|
+
} from "../util/config";
|
|
6
10
|
import { globLoader } from "../util/glob";
|
|
7
11
|
import type { Initializer, InitializerSortKeys } from "./Initializer";
|
|
8
12
|
import { Logger } from "./Logger";
|
|
@@ -69,6 +73,7 @@ export class API {
|
|
|
69
73
|
this.initialized = false;
|
|
70
74
|
|
|
71
75
|
await this.loadLocalConfig();
|
|
76
|
+
this.loadPluginConfig();
|
|
72
77
|
await this.findInitializers();
|
|
73
78
|
this.sortInitializers("loadPriority");
|
|
74
79
|
|
|
@@ -205,6 +210,19 @@ export class API {
|
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Apply plugin config defaults using deepMergeDefaults so that
|
|
215
|
+
* user-set config values are never overwritten by plugin defaults.
|
|
216
|
+
*/
|
|
217
|
+
private loadPluginConfig() {
|
|
218
|
+
for (const plugin of config.plugins) {
|
|
219
|
+
if (plugin.configDefaults) {
|
|
220
|
+
deepMergeDefaults(config, plugin.configDefaults);
|
|
221
|
+
this.logger.debug(`Merged config defaults from plugin ${plugin.name}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
208
226
|
private async findInitializers() {
|
|
209
227
|
// Load framework initializers from the package directory
|
|
210
228
|
const frameworkInitializers = await globLoader<Initializer>(
|
|
@@ -214,6 +232,17 @@ export class API {
|
|
|
214
232
|
this.initializers.push(i);
|
|
215
233
|
}
|
|
216
234
|
|
|
235
|
+
// Load plugin initializers
|
|
236
|
+
let pluginInitializerCount = 0;
|
|
237
|
+
for (const plugin of config.plugins) {
|
|
238
|
+
if (plugin.initializers) {
|
|
239
|
+
for (const InitializerClass of plugin.initializers) {
|
|
240
|
+
this.initializers.push(new InitializerClass());
|
|
241
|
+
pluginInitializerCount++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
217
246
|
// Load user project initializers (if rootDir differs from packageDir)
|
|
218
247
|
if (this.rootDir !== this.packageDir) {
|
|
219
248
|
try {
|
|
@@ -227,6 +256,17 @@ export class API {
|
|
|
227
256
|
// user project may not have initializers, that's fine
|
|
228
257
|
}
|
|
229
258
|
}
|
|
259
|
+
|
|
260
|
+
this.logger.info(
|
|
261
|
+
formatLoadedMessage("initializers", {
|
|
262
|
+
core: frameworkInitializers.length,
|
|
263
|
+
plugin: pluginInitializerCount,
|
|
264
|
+
user:
|
|
265
|
+
this.initializers.length -
|
|
266
|
+
frameworkInitializers.length -
|
|
267
|
+
pluginInitializerCount,
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
230
270
|
}
|
|
231
271
|
|
|
232
272
|
private sortInitializers(key: InitializerSortKeys) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Action } from "./Action";
|
|
2
|
+
import type { Channel } from "./Channel";
|
|
3
|
+
import type { Initializer } from "./Initializer";
|
|
4
|
+
import type { Server } from "./Server";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A Keryx plugin manifest. Plugins provide class constructors that the framework
|
|
8
|
+
* instantiates during discovery, alongside any config defaults and generator extensions.
|
|
9
|
+
*
|
|
10
|
+
* First-party plugins use the `@keryxjs/*` npm scope (e.g., `@keryxjs/resque-admin`).
|
|
11
|
+
* Third-party plugins follow the `keryx-plugin-*` naming convention.
|
|
12
|
+
*
|
|
13
|
+
* Register plugins via config:
|
|
14
|
+
* ```typescript
|
|
15
|
+
* // config/plugins.ts
|
|
16
|
+
* import { myPlugin } from "@keryxjs/my-plugin";
|
|
17
|
+
* export default { plugins: [myPlugin] };
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export interface KeryxPlugin {
|
|
21
|
+
/** Unique plugin name (e.g., `"resque-admin"`). */
|
|
22
|
+
name: string;
|
|
23
|
+
/** SemVer version string for the plugin. */
|
|
24
|
+
version: string;
|
|
25
|
+
|
|
26
|
+
/** Initializer class constructors. Instantiated and merged into the lifecycle alongside framework and user initializers. */
|
|
27
|
+
initializers?: Array<new () => Initializer>;
|
|
28
|
+
/** Action class constructors. Instantiated and registered alongside user actions. */
|
|
29
|
+
actions?: Array<new () => Action>;
|
|
30
|
+
/** Channel class constructors. Instantiated and registered alongside user channels. */
|
|
31
|
+
channels?: Array<new () => Channel>;
|
|
32
|
+
/** Server class constructors. Instantiated and registered alongside framework and user servers. */
|
|
33
|
+
servers?: Array<new () => Server<unknown>>;
|
|
34
|
+
|
|
35
|
+
/** Config defaults merged before user config, so user overrides take precedence. */
|
|
36
|
+
configDefaults?: Record<string, unknown>;
|
|
37
|
+
|
|
38
|
+
/** Custom generator types for the `keryx generate` CLI command. */
|
|
39
|
+
generators?: PluginGenerator[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A custom generator type that a plugin registers with the `keryx generate` CLI command.
|
|
44
|
+
*/
|
|
45
|
+
export interface PluginGenerator {
|
|
46
|
+
/** The generator type name (e.g., `"graphql-resolver"`). Used as `keryx generate <type> <name>`. */
|
|
47
|
+
type: string;
|
|
48
|
+
/** The output subdirectory name relative to the project root (e.g., `"resolvers"`). */
|
|
49
|
+
directory: string;
|
|
50
|
+
/** Absolute path to the Mustache template file for the generated component. */
|
|
51
|
+
templatePath: string;
|
|
52
|
+
/** Absolute path to the Mustache template file for the generated test. If omitted, the default test template is used. */
|
|
53
|
+
testTemplatePath?: string;
|
|
54
|
+
}
|
package/config/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import type { KeryxPlugin } from "../classes/Plugin";
|
|
1
2
|
import { configActions } from "./actions";
|
|
2
3
|
import { configChannels } from "./channels";
|
|
3
4
|
import { configDatabase } from "./database";
|
|
4
5
|
import { configLogger } from "./logger";
|
|
5
6
|
import { configObservability } from "./observability";
|
|
7
|
+
import { configPlugins } from "./plugins";
|
|
6
8
|
import { configProcess } from "./process";
|
|
7
9
|
import { configRateLimit } from "./rateLimit";
|
|
8
10
|
import { configRedis } from "./redis";
|
|
@@ -13,6 +15,7 @@ import { configSession } from "./session";
|
|
|
13
15
|
import { configTasks } from "./tasks";
|
|
14
16
|
|
|
15
17
|
export const config = {
|
|
18
|
+
plugins: configPlugins,
|
|
16
19
|
actions: configActions,
|
|
17
20
|
channels: configChannels,
|
|
18
21
|
process: configProcess,
|
|
@@ -39,6 +42,7 @@ export const config = {
|
|
|
39
42
|
* ```
|
|
40
43
|
*/
|
|
41
44
|
export interface KeryxConfig {
|
|
45
|
+
plugins: KeryxPlugin[];
|
|
42
46
|
actions: typeof configActions;
|
|
43
47
|
channels: typeof configChannels;
|
|
44
48
|
process: typeof configProcess;
|
package/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type { ChannelMiddleware } from "./classes/Channel";
|
|
|
24
24
|
export { CHANNEL_NAME_PATTERN } from "./classes/Channel";
|
|
25
25
|
export { Connection } from "./classes/Connection";
|
|
26
26
|
export { LogLevel } from "./classes/Logger";
|
|
27
|
+
export type { KeryxPlugin, PluginGenerator } from "./classes/Plugin";
|
|
27
28
|
export { SSEResponse, StreamingResponse } from "./classes/StreamingResponse";
|
|
28
29
|
export { ErrorStatusCodes, ErrorType, TypedError } from "./classes/TypedError";
|
|
29
30
|
export type { KeryxConfig } from "./config";
|
|
@@ -32,7 +33,8 @@ export { checkRateLimit, RateLimitMiddleware } from "./middleware/rateLimit";
|
|
|
32
33
|
export { TransactionMiddleware } from "./middleware/transaction";
|
|
33
34
|
export type { WebServer } from "./servers/web";
|
|
34
35
|
export { buildProgram } from "./util/cli";
|
|
35
|
-
export { deepMerge, loadFromEnvIfSet } from "./util/config";
|
|
36
|
+
export { deepMerge, deepMergeDefaults, loadFromEnvIfSet } from "./util/config";
|
|
37
|
+
export { getValidTypes } from "./util/generate";
|
|
36
38
|
export { globLoader } from "./util/glob";
|
|
37
39
|
export { type PaginatedResult, paginate } from "./util/pagination";
|
|
38
40
|
export { toMarkdown } from "./util/toMarkdown";
|
package/initializers/actionts.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { type Action, DEFAULT_QUEUE } from "../classes/Action";
|
|
|
6
6
|
import { Initializer } from "../classes/Initializer";
|
|
7
7
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
8
8
|
import { config } from "../config";
|
|
9
|
+
import { formatLoadedMessage } from "../util/config";
|
|
9
10
|
import { globLoader } from "../util/glob";
|
|
10
11
|
|
|
11
12
|
const namespace = "actions";
|
|
@@ -85,7 +86,9 @@ export class Actions extends Initializer {
|
|
|
85
86
|
inputs: TaskInputs = {},
|
|
86
87
|
queue?: string,
|
|
87
88
|
) => {
|
|
88
|
-
const action = api.actions.actions.find(
|
|
89
|
+
const action = api.actions.actions.find(
|
|
90
|
+
(a: Action) => a.name === actionName,
|
|
91
|
+
);
|
|
89
92
|
if (!action) {
|
|
90
93
|
throw new TypedError({
|
|
91
94
|
message: `action ${actionName} not found`,
|
|
@@ -140,7 +143,9 @@ export class Actions extends Initializer {
|
|
|
140
143
|
// Validate all action names up front
|
|
141
144
|
const actionNames = new Set<string>();
|
|
142
145
|
for (const job of jobs) {
|
|
143
|
-
const action = api.actions.actions.find(
|
|
146
|
+
const action = api.actions.actions.find(
|
|
147
|
+
(a: Action) => a.name === job.action,
|
|
148
|
+
);
|
|
144
149
|
if (!action) {
|
|
145
150
|
throw new TypedError({
|
|
146
151
|
message: `action ${job.action} not found`,
|
|
@@ -152,7 +157,9 @@ export class Actions extends Initializer {
|
|
|
152
157
|
|
|
153
158
|
// Resolve queue per job: explicit job.queue > action's task.queue > DEFAULT_QUEUE
|
|
154
159
|
const resolvedJobs = jobs.map((job) => {
|
|
155
|
-
const action = api.actions.actions.find(
|
|
160
|
+
const action = api.actions.actions.find(
|
|
161
|
+
(a: Action) => a.name === job.action,
|
|
162
|
+
)!;
|
|
156
163
|
const resolvedQueue = job.queue ?? action?.task?.queue ?? DEFAULT_QUEUE;
|
|
157
164
|
return { ...job, queue: resolvedQueue, inputs: job.inputs ?? {} };
|
|
158
165
|
});
|
|
@@ -250,8 +257,8 @@ export class Actions extends Initializer {
|
|
|
250
257
|
total: parseInt(meta.total, 10) || 0,
|
|
251
258
|
completed: parseInt(meta.completed, 10) || 0,
|
|
252
259
|
failed: parseInt(meta.failed, 10) || 0,
|
|
253
|
-
results: rawResults.map((r) => JSON.parse(r)),
|
|
254
|
-
errors: rawErrors.map((e) => JSON.parse(e)),
|
|
260
|
+
results: rawResults.map((r: string) => JSON.parse(r)),
|
|
261
|
+
errors: rawErrors.map((e: string) => JSON.parse(e)),
|
|
255
262
|
};
|
|
256
263
|
};
|
|
257
264
|
|
|
@@ -565,7 +572,9 @@ export class Actions extends Initializer {
|
|
|
565
572
|
* Will throw an error if redis cannot be reached.
|
|
566
573
|
*/
|
|
567
574
|
stopRecurrentAction = async (actionName: string): Promise<number> => {
|
|
568
|
-
const action = api.actions.actions.find(
|
|
575
|
+
const action = api.actions.actions.find(
|
|
576
|
+
(a: Action) => a.name === actionName,
|
|
577
|
+
);
|
|
569
578
|
if (!action) {
|
|
570
579
|
throw new TypedError({
|
|
571
580
|
message: `action ${actionName} not found`,
|
|
@@ -634,14 +643,34 @@ export class Actions extends Initializer {
|
|
|
634
643
|
};
|
|
635
644
|
|
|
636
645
|
async initialize() {
|
|
637
|
-
|
|
646
|
+
// Load plugin actions
|
|
647
|
+
const pluginActions: Action[] = [];
|
|
648
|
+
for (const plugin of config.plugins) {
|
|
649
|
+
if (plugin.actions) {
|
|
650
|
+
for (const ActionClass of plugin.actions) {
|
|
651
|
+
pluginActions.push(new ActionClass());
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// Load user actions
|
|
657
|
+
const userActions = await globLoader<Action>(
|
|
658
|
+
path.join(api.rootDir, "actions"),
|
|
659
|
+
);
|
|
660
|
+
|
|
661
|
+
const actions = [...pluginActions, ...userActions];
|
|
638
662
|
|
|
639
663
|
for (const a of actions) {
|
|
640
664
|
if (!a.description) a.description = `An Action: ${a.name}`;
|
|
641
665
|
a.mcp = { tool: true, ...a.mcp };
|
|
642
666
|
}
|
|
643
667
|
|
|
644
|
-
logger.info(
|
|
668
|
+
logger.info(
|
|
669
|
+
formatLoadedMessage("actions", {
|
|
670
|
+
plugin: pluginActions.length,
|
|
671
|
+
user: userActions.length,
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
645
674
|
|
|
646
675
|
return {
|
|
647
676
|
actions,
|
package/initializers/channels.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Connection } from "../classes/Connection";
|
|
|
5
5
|
import { Initializer } from "../classes/Initializer";
|
|
6
6
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
7
7
|
import { config } from "../config";
|
|
8
|
+
import { formatLoadedMessage } from "../util/config";
|
|
8
9
|
import { globLoader } from "../util/glob";
|
|
9
10
|
|
|
10
11
|
const namespace = "channels";
|
|
@@ -35,7 +36,7 @@ export class Channels extends Initializer {
|
|
|
35
36
|
* Returns undefined if no matching channel is found.
|
|
36
37
|
*/
|
|
37
38
|
findChannel = (channelName: string): Channel | undefined => {
|
|
38
|
-
return api.channels.channels.find((c) => c.matches(channelName));
|
|
39
|
+
return api.channels.channels.find((c: Channel) => c.matches(channelName));
|
|
39
40
|
};
|
|
40
41
|
|
|
41
42
|
/**
|
|
@@ -229,11 +230,22 @@ export class Channels extends Initializer {
|
|
|
229
230
|
join(LUA_DIR, "refresh-presence.lua"),
|
|
230
231
|
).text();
|
|
231
232
|
|
|
232
|
-
|
|
233
|
+
// Load plugin channels
|
|
234
|
+
const pluginChannels: Channel[] = [];
|
|
235
|
+
for (const plugin of config.plugins) {
|
|
236
|
+
if (plugin.channels) {
|
|
237
|
+
for (const ChannelClass of plugin.channels) {
|
|
238
|
+
pluginChannels.push(new ChannelClass());
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
233
242
|
|
|
234
|
-
//
|
|
243
|
+
// Load user channels
|
|
244
|
+
let userChannels: Channel[] = [];
|
|
235
245
|
try {
|
|
236
|
-
|
|
246
|
+
userChannels = await globLoader<Channel>(
|
|
247
|
+
path.join(api.rootDir, "channels"),
|
|
248
|
+
);
|
|
237
249
|
} catch (e) {
|
|
238
250
|
// channels directory may not exist, which is fine
|
|
239
251
|
logger.debug(
|
|
@@ -241,11 +253,18 @@ export class Channels extends Initializer {
|
|
|
241
253
|
);
|
|
242
254
|
}
|
|
243
255
|
|
|
256
|
+
const channels = [...pluginChannels, ...userChannels];
|
|
257
|
+
|
|
244
258
|
for (const c of channels) {
|
|
245
259
|
if (!c.description) c.description = `A Channel: ${c.name}`;
|
|
246
260
|
}
|
|
247
261
|
|
|
248
|
-
logger.info(
|
|
262
|
+
logger.info(
|
|
263
|
+
formatLoadedMessage("channels", {
|
|
264
|
+
plugin: pluginChannels.length,
|
|
265
|
+
user: userChannels.length,
|
|
266
|
+
}),
|
|
267
|
+
);
|
|
249
268
|
|
|
250
269
|
return {
|
|
251
270
|
channels,
|
package/initializers/mcp.ts
CHANGED
|
@@ -7,6 +7,7 @@ import colors from "colors";
|
|
|
7
7
|
import { randomUUID } from "crypto";
|
|
8
8
|
import * as z4mini from "zod/v4-mini";
|
|
9
9
|
import { api, logger } from "../api";
|
|
10
|
+
import type { Action } from "../classes/Action";
|
|
10
11
|
import { MCP_RESPONSE_FORMAT } from "../classes/Action";
|
|
11
12
|
import { Connection } from "../classes/Connection";
|
|
12
13
|
import { Initializer } from "../classes/Initializer";
|
|
@@ -46,7 +47,7 @@ function formatToolName(actionName: string): string {
|
|
|
46
47
|
function parseToolName(toolName: string): string {
|
|
47
48
|
// Reverse lookup against registered actions
|
|
48
49
|
const action = api.actions.actions.find(
|
|
49
|
-
(a) => formatToolName(a.name) === toolName,
|
|
50
|
+
(a: Action) => formatToolName(a.name) === toolName,
|
|
50
51
|
);
|
|
51
52
|
return action ? action.name : toolName;
|
|
52
53
|
}
|
package/initializers/resque.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ErrorPayload,
|
|
2
3
|
type Job,
|
|
3
4
|
type ParsedJob,
|
|
4
5
|
Queue,
|
|
@@ -61,7 +62,7 @@ export class Resque extends Initializer {
|
|
|
61
62
|
api.resque.jobs,
|
|
62
63
|
);
|
|
63
64
|
|
|
64
|
-
api.resque.queue.on("error", (error) => {
|
|
65
|
+
api.resque.queue.on("error", (error: Error) => {
|
|
65
66
|
logger.error(`[resque:queue] ${error}`);
|
|
66
67
|
});
|
|
67
68
|
|
|
@@ -85,7 +86,7 @@ export class Resque extends Initializer {
|
|
|
85
86
|
retryStuckJobs: config.tasks.retryStuckJobs,
|
|
86
87
|
});
|
|
87
88
|
|
|
88
|
-
api.resque.scheduler.on("error", (error) => {
|
|
89
|
+
api.resque.scheduler.on("error", (error: Error) => {
|
|
89
90
|
logger.error(`[resque:scheduler] ${error}`);
|
|
90
91
|
});
|
|
91
92
|
|
|
@@ -105,7 +106,7 @@ export class Resque extends Initializer {
|
|
|
105
106
|
});
|
|
106
107
|
api.resque.scheduler.on(
|
|
107
108
|
"cleanStuckWorker",
|
|
108
|
-
(workerName, errorPayload, delta) => {
|
|
109
|
+
(workerName: string, errorPayload: ErrorPayload, delta: number) => {
|
|
109
110
|
logger.warn(
|
|
110
111
|
`[resque:scheduler] cleaning stuck worker: ${workerName}, ${errorPayload}, ${delta}`,
|
|
111
112
|
);
|
package/initializers/servers.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "path";
|
|
2
|
-
import { api, RUN_MODE } from "../api";
|
|
2
|
+
import { api, logger, RUN_MODE } from "../api";
|
|
3
3
|
import { Initializer } from "../classes/Initializer";
|
|
4
4
|
import type { Server } from "../classes/Server";
|
|
5
|
+
import { config } from "../config";
|
|
6
|
+
import { formatLoadedMessage } from "../util/config";
|
|
5
7
|
import { globLoader } from "../util/glob";
|
|
6
8
|
|
|
7
9
|
const namespace = "servers";
|
|
@@ -27,6 +29,16 @@ export class Servers extends Initializer {
|
|
|
27
29
|
path.join(api.packageDir, "servers"),
|
|
28
30
|
);
|
|
29
31
|
|
|
32
|
+
// Load plugin servers
|
|
33
|
+
const pluginServers: Server<any>[] = [];
|
|
34
|
+
for (const plugin of config.plugins) {
|
|
35
|
+
if (plugin.servers) {
|
|
36
|
+
for (const ServerClass of plugin.servers) {
|
|
37
|
+
pluginServers.push(new ServerClass());
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
30
42
|
// Load user project servers (if rootDir differs from packageDir)
|
|
31
43
|
let userServers: Server<any>[] = [];
|
|
32
44
|
if (api.rootDir !== api.packageDir) {
|
|
@@ -39,12 +51,20 @@ export class Servers extends Initializer {
|
|
|
39
51
|
}
|
|
40
52
|
}
|
|
41
53
|
|
|
42
|
-
const servers = [...frameworkServers, ...userServers];
|
|
54
|
+
const servers = [...frameworkServers, ...pluginServers, ...userServers];
|
|
43
55
|
|
|
44
56
|
for (const server of servers) {
|
|
45
57
|
await server.initialize();
|
|
46
58
|
}
|
|
47
59
|
|
|
60
|
+
logger.info(
|
|
61
|
+
formatLoadedMessage("servers", {
|
|
62
|
+
core: frameworkServers.length,
|
|
63
|
+
plugin: pluginServers.length,
|
|
64
|
+
user: userServers.length,
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
|
|
48
68
|
return { servers };
|
|
49
69
|
}
|
|
50
70
|
|
package/initializers/swagger.ts
CHANGED
|
@@ -202,8 +202,8 @@ export class SwaggerInitializer extends Initializer {
|
|
|
202
202
|
try {
|
|
203
203
|
const cached = await cacheFileHandle.json();
|
|
204
204
|
if (cached.hash === hash) {
|
|
205
|
-
logger.
|
|
206
|
-
`Loaded ${Object.keys(cached.responseSchemas).length} response schemas from cache`,
|
|
205
|
+
logger.debug(
|
|
206
|
+
`Loaded ${Object.keys(cached.responseSchemas).length} OpenAPI response schemas from cache`,
|
|
207
207
|
);
|
|
208
208
|
return { responseSchemas: cached.responseSchemas };
|
|
209
209
|
}
|
package/package.json
CHANGED
package/util/cli.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { Action, api, Connection, RUN_MODE } from "../api";
|
|
|
5
5
|
import { ExitCode } from "./../classes/ExitCode";
|
|
6
6
|
import { TypedError } from "./../classes/TypedError";
|
|
7
7
|
import { config } from "../config";
|
|
8
|
-
import { generateComponent } from "./generate";
|
|
8
|
+
import { generateComponent, getValidTypes } from "./generate";
|
|
9
9
|
import { globLoader } from "./glob";
|
|
10
10
|
import {
|
|
11
11
|
interactiveScaffold,
|
|
@@ -100,7 +100,7 @@ export async function buildProgram(opts: {
|
|
|
100
100
|
.alias("g")
|
|
101
101
|
.summary("Generate a new component")
|
|
102
102
|
.description(
|
|
103
|
-
|
|
103
|
+
`Scaffold a new component file.\n\nValid types: ${getValidTypes().join(", ")}\n\n` +
|
|
104
104
|
"Examples:\n" +
|
|
105
105
|
" keryx generate action user:delete\n" +
|
|
106
106
|
" keryx generate initializer cache\n" +
|
|
@@ -232,7 +232,7 @@ async function runActionViaCLI(options: Record<string, string>, command: any) {
|
|
|
232
232
|
|
|
233
233
|
await api.initialize();
|
|
234
234
|
|
|
235
|
-
const action = api.actions.actions.find((a) => a.name === actionName);
|
|
235
|
+
const action = api.actions.actions.find((a: Action) => a.name === actionName);
|
|
236
236
|
if (!action) {
|
|
237
237
|
exitWithError(`Action "${actionName}" not found`);
|
|
238
238
|
}
|
package/util/config.ts
CHANGED
|
@@ -27,6 +27,61 @@ export function deepMerge<T extends Record<string, any>>(
|
|
|
27
27
|
return target;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Like `deepMerge`, but only sets values that don't already exist in target.
|
|
32
|
+
* Useful for applying plugin config defaults without overwriting user-set values.
|
|
33
|
+
*/
|
|
34
|
+
export function deepMergeDefaults<T extends Record<string, any>>(
|
|
35
|
+
target: T,
|
|
36
|
+
source: Record<string, any>,
|
|
37
|
+
): T {
|
|
38
|
+
for (const key of Object.keys(source)) {
|
|
39
|
+
if (!(key in target)) {
|
|
40
|
+
(target as any)[key] = source[key];
|
|
41
|
+
} else {
|
|
42
|
+
const targetVal = target[key];
|
|
43
|
+
const sourceVal = source[key];
|
|
44
|
+
|
|
45
|
+
if (
|
|
46
|
+
targetVal &&
|
|
47
|
+
sourceVal &&
|
|
48
|
+
typeof targetVal === "object" &&
|
|
49
|
+
typeof sourceVal === "object" &&
|
|
50
|
+
!Array.isArray(targetVal) &&
|
|
51
|
+
!Array.isArray(sourceVal)
|
|
52
|
+
) {
|
|
53
|
+
deepMergeDefaults(targetVal, sourceVal);
|
|
54
|
+
}
|
|
55
|
+
// If key exists in target and isn't a nested object, keep the target value
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return target;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Build a human-readable summary of how many components were loaded and where
|
|
64
|
+
* they came from. Example: `"loaded 12 initializers (10 core, 1 from plugins, 1 user-defined)"`
|
|
65
|
+
*
|
|
66
|
+
* @param label - Plural noun for the component type (e.g. "actions", "servers").
|
|
67
|
+
* @param counts - Breakdown by source. All fields are optional; zero-value
|
|
68
|
+
* categories are omitted from the output.
|
|
69
|
+
*/
|
|
70
|
+
export function formatLoadedMessage(
|
|
71
|
+
label: string,
|
|
72
|
+
counts: { core?: number; plugin?: number; user?: number },
|
|
73
|
+
): string {
|
|
74
|
+
const total = (counts.core ?? 0) + (counts.plugin ?? 0) + (counts.user ?? 0);
|
|
75
|
+
const parts: string[] = [];
|
|
76
|
+
if (counts.core) parts.push(`${counts.core} core`);
|
|
77
|
+
if (counts.plugin) parts.push(`${counts.plugin} from plugins`);
|
|
78
|
+
if (counts.user) parts.push(`${counts.user} user-defined`);
|
|
79
|
+
if (parts.length > 0) {
|
|
80
|
+
return `loaded ${total} ${label} (${parts.join(", ")})`;
|
|
81
|
+
}
|
|
82
|
+
return `loaded ${total} ${label}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
30
85
|
/**
|
|
31
86
|
Loads a value from the environment, if it's set, otherwise returns the default value.
|
|
32
87
|
*/
|
package/util/generate.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import Mustache from "mustache";
|
|
3
3
|
import path from "path";
|
|
4
|
+
import type { PluginGenerator } from "../classes/Plugin";
|
|
5
|
+
import { config } from "../config";
|
|
4
6
|
|
|
5
7
|
const VALID_TYPES = [
|
|
6
8
|
"action",
|
|
@@ -11,6 +13,35 @@ const VALID_TYPES = [
|
|
|
11
13
|
] as const;
|
|
12
14
|
type GeneratorType = (typeof VALID_TYPES)[number];
|
|
13
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Returns all valid generator types, including built-in types and
|
|
18
|
+
* any custom types registered by plugins.
|
|
19
|
+
*/
|
|
20
|
+
export function getValidTypes(): string[] {
|
|
21
|
+
const types = [...VALID_TYPES] as string[];
|
|
22
|
+
for (const plugin of config.plugins) {
|
|
23
|
+
if (plugin.generators) {
|
|
24
|
+
for (const gen of plugin.generators) {
|
|
25
|
+
if (!types.includes(gen.type)) types.push(gen.type);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return types;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Find a plugin generator definition for a given type.
|
|
34
|
+
*/
|
|
35
|
+
function findPluginGenerator(type: string): PluginGenerator | undefined {
|
|
36
|
+
for (const plugin of config.plugins) {
|
|
37
|
+
if (plugin.generators) {
|
|
38
|
+
const gen = plugin.generators.find((g) => g.type === type);
|
|
39
|
+
if (gen) return gen;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
14
45
|
export interface GenerateOptions {
|
|
15
46
|
dryRun?: boolean;
|
|
16
47
|
force?: boolean;
|
|
@@ -60,6 +91,27 @@ function resolveFilePath(type: GeneratorType, name: string): string {
|
|
|
60
91
|
return path.join(baseDir, `${name}.ts`);
|
|
61
92
|
}
|
|
62
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Determine the directory and filename for a plugin generator type + name.
|
|
96
|
+
*/
|
|
97
|
+
function resolvePluginFilePath(gen: PluginGenerator, name: string): string {
|
|
98
|
+
const segments = name.split(":");
|
|
99
|
+
if (segments.length > 1) {
|
|
100
|
+
const fileName = segments.pop()!;
|
|
101
|
+
return path.join(gen.directory, ...segments, `${fileName}.ts`);
|
|
102
|
+
}
|
|
103
|
+
return path.join(gen.directory, `${name}.ts`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Determine the test file path for a plugin generator component.
|
|
108
|
+
*/
|
|
109
|
+
function resolvePluginTestPath(gen: PluginGenerator, name: string): string {
|
|
110
|
+
const componentPath = resolvePluginFilePath(gen, name);
|
|
111
|
+
const parsed = path.parse(componentPath);
|
|
112
|
+
return path.join("__tests__", parsed.dir, `${parsed.name}.test.ts`);
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
/**
|
|
64
116
|
* Determine the test file path for a component.
|
|
65
117
|
* "user:delete" action → "__tests__/actions/user/delete.test.ts"
|
|
@@ -92,14 +144,21 @@ export async function generateComponent(
|
|
|
92
144
|
rootDir: string,
|
|
93
145
|
options: GenerateOptions = {},
|
|
94
146
|
): Promise<string[]> {
|
|
95
|
-
|
|
147
|
+
const allValidTypes = getValidTypes();
|
|
148
|
+
if (!allValidTypes.includes(type)) {
|
|
96
149
|
throw new Error(
|
|
97
|
-
`Unknown generator type "${type}". Valid types: ${
|
|
150
|
+
`Unknown generator type "${type}". Valid types: ${allValidTypes.join(", ")}`,
|
|
98
151
|
);
|
|
99
152
|
}
|
|
100
153
|
|
|
101
|
-
|
|
102
|
-
const
|
|
154
|
+
// Check if this is a plugin-provided generator type
|
|
155
|
+
const pluginGen = VALID_TYPES.includes(type as GeneratorType)
|
|
156
|
+
? undefined
|
|
157
|
+
: findPluginGenerator(type);
|
|
158
|
+
|
|
159
|
+
const filePath = pluginGen
|
|
160
|
+
? resolvePluginFilePath(pluginGen, name)
|
|
161
|
+
: resolveFilePath(type as GeneratorType, name);
|
|
103
162
|
const fullPath = path.join(rootDir, filePath);
|
|
104
163
|
const createdFiles: string[] = [];
|
|
105
164
|
|
|
@@ -112,25 +171,30 @@ export async function generateComponent(
|
|
|
112
171
|
|
|
113
172
|
// Build template view
|
|
114
173
|
let className = toClassName(name);
|
|
115
|
-
if (
|
|
116
|
-
if (
|
|
174
|
+
if (type === "middleware") className += "Middleware";
|
|
175
|
+
if (type === "channel") className += "Channel";
|
|
117
176
|
|
|
118
177
|
const view: Record<string, string> = { name, className };
|
|
119
|
-
if (
|
|
178
|
+
if (type === "action") {
|
|
120
179
|
view.route = toRoute(name);
|
|
121
180
|
}
|
|
122
181
|
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
182
|
+
// Load and render template
|
|
183
|
+
let content: string;
|
|
184
|
+
if (pluginGen) {
|
|
185
|
+
const templateStr = await Bun.file(pluginGen.templatePath).text();
|
|
186
|
+
content = Mustache.render(templateStr, view);
|
|
187
|
+
} else {
|
|
188
|
+
const templateMap: Record<GeneratorType, string> = {
|
|
189
|
+
action: "action.ts.mustache",
|
|
190
|
+
initializer: "initializer.ts.mustache",
|
|
191
|
+
middleware: "action-middleware.ts.mustache",
|
|
192
|
+
channel: "channel.ts.mustache",
|
|
193
|
+
ops: "ops.ts.mustache",
|
|
194
|
+
};
|
|
195
|
+
const template = await loadTemplate(templateMap[type as GeneratorType]);
|
|
196
|
+
content = Mustache.render(template, view);
|
|
197
|
+
}
|
|
134
198
|
|
|
135
199
|
if (options.dryRun) {
|
|
136
200
|
console.log(`Would create: ${filePath}`);
|
|
@@ -145,14 +209,24 @@ export async function generateComponent(
|
|
|
145
209
|
|
|
146
210
|
// Generate test file
|
|
147
211
|
if (!options.noTest) {
|
|
148
|
-
const testPath =
|
|
212
|
+
const testPath = pluginGen
|
|
213
|
+
? resolvePluginTestPath(pluginGen, name)
|
|
214
|
+
: resolveTestPath(type as GeneratorType, name);
|
|
149
215
|
const testFullPath = path.join(rootDir, testPath);
|
|
150
216
|
|
|
151
217
|
if (!options.force && fs.existsSync(testFullPath)) {
|
|
152
218
|
// Silently skip test file if it already exists
|
|
153
219
|
} else {
|
|
154
|
-
|
|
155
|
-
|
|
220
|
+
let testContent: string;
|
|
221
|
+
if (pluginGen?.testTemplatePath) {
|
|
222
|
+
const testTemplateStr = await Bun.file(
|
|
223
|
+
pluginGen.testTemplatePath,
|
|
224
|
+
).text();
|
|
225
|
+
testContent = Mustache.render(testTemplateStr, view);
|
|
226
|
+
} else {
|
|
227
|
+
const testTemplate = await loadTemplate("test.ts.mustache");
|
|
228
|
+
testContent = Mustache.render(testTemplate, view);
|
|
229
|
+
}
|
|
156
230
|
|
|
157
231
|
if (options.dryRun) {
|
|
158
232
|
console.log(`Would create: ${testPath}`);
|
package/util/oauthHandlers.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
2
|
import { api } from "../api";
|
|
3
|
-
import type { OAuthActionResponse } from "../classes/Action";
|
|
3
|
+
import type { Action, OAuthActionResponse } from "../classes/Action";
|
|
4
4
|
import { Connection } from "../classes/Connection";
|
|
5
5
|
import { config } from "../config";
|
|
6
6
|
import {
|
|
@@ -180,8 +180,10 @@ export function handleAuthorizeGet(
|
|
|
180
180
|
};
|
|
181
181
|
|
|
182
182
|
return renderAuthPage(params, templates, {
|
|
183
|
-
loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
|
|
184
|
-
signupAction: api.actions.actions.find(
|
|
183
|
+
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
184
|
+
signupAction: api.actions.actions.find(
|
|
185
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
186
|
+
),
|
|
185
187
|
});
|
|
186
188
|
}
|
|
187
189
|
|
|
@@ -227,8 +229,10 @@ export async function handleAuthorizePost(
|
|
|
227
229
|
};
|
|
228
230
|
|
|
229
231
|
const authActions = {
|
|
230
|
-
loginAction: api.actions.actions.find((a) => a.mcp?.isLoginAction),
|
|
231
|
-
signupAction: api.actions.actions.find(
|
|
232
|
+
loginAction: api.actions.actions.find((a: Action) => a.mcp?.isLoginAction),
|
|
233
|
+
signupAction: api.actions.actions.find(
|
|
234
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
235
|
+
),
|
|
232
236
|
};
|
|
233
237
|
|
|
234
238
|
// Validate client
|
|
@@ -263,7 +267,9 @@ export async function handleAuthorizePost(
|
|
|
263
267
|
let userId: number;
|
|
264
268
|
|
|
265
269
|
if (mode === "signup") {
|
|
266
|
-
const signupAction = api.actions.actions.find(
|
|
270
|
+
const signupAction = api.actions.actions.find(
|
|
271
|
+
(a: Action) => a.mcp?.isSignupAction,
|
|
272
|
+
);
|
|
267
273
|
if (!signupAction) {
|
|
268
274
|
oauthParams.error = "No signup action configured";
|
|
269
275
|
return renderAuthPage(oauthParams, templates, authActions);
|
|
@@ -283,7 +289,9 @@ export async function handleAuthorizePost(
|
|
|
283
289
|
connection.destroy();
|
|
284
290
|
}
|
|
285
291
|
} else {
|
|
286
|
-
const loginAction = api.actions.actions.find(
|
|
292
|
+
const loginAction = api.actions.actions.find(
|
|
293
|
+
(a: Action) => a.mcp?.isLoginAction,
|
|
294
|
+
);
|
|
287
295
|
if (!loginAction) {
|
|
288
296
|
oauthParams.error = "No login action configured";
|
|
289
297
|
return renderAuthPage(oauthParams, templates, authActions);
|
package/util/scaffold.ts
CHANGED
|
@@ -103,6 +103,10 @@ export async function generateConfigFileContents(): Promise<
|
|
|
103
103
|
/from ["']\.\.\/classes\/Logger["']/g,
|
|
104
104
|
'from "keryx"',
|
|
105
105
|
);
|
|
106
|
+
content = content.replace(
|
|
107
|
+
/from ["']\.\.\/classes\/Plugin["']/g,
|
|
108
|
+
'from "keryx"',
|
|
109
|
+
);
|
|
106
110
|
|
|
107
111
|
// In index.ts, change `export const config` to `export default`
|
|
108
112
|
// and remove the KeryxConfig interface export (it comes from the package)
|
|
@@ -112,6 +116,11 @@ export async function generateConfigFileContents(): Promise<
|
|
|
112
116
|
/\nexport interface KeryxConfig \{[\s\S]*?\}\n/,
|
|
113
117
|
"\n",
|
|
114
118
|
);
|
|
119
|
+
// Remove the KeryxPlugin import (only used by the stripped KeryxConfig interface)
|
|
120
|
+
content = content.replace(
|
|
121
|
+
/import type \{ KeryxPlugin \} from ["']keryx["'];\n/,
|
|
122
|
+
"",
|
|
123
|
+
);
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
result.set(`config/${file}`, content);
|