keryx 0.0.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/actions/status.ts +25 -0
  3. package/actions/swagger.ts +170 -0
  4. package/api.ts +45 -0
  5. package/classes/API.ts +168 -0
  6. package/classes/Action.ts +128 -0
  7. package/classes/Channel.ts +81 -0
  8. package/classes/Connection.ts +282 -0
  9. package/classes/ExitCode.ts +4 -0
  10. package/classes/Initializer.ts +45 -0
  11. package/classes/Logger.ts +132 -0
  12. package/classes/Server.ts +16 -0
  13. package/classes/TypedError.ts +91 -0
  14. package/config/channels.ts +9 -0
  15. package/config/database.ts +6 -0
  16. package/config/index.ts +23 -0
  17. package/config/logger.ts +8 -0
  18. package/config/process.ts +9 -0
  19. package/config/rateLimit.ts +22 -0
  20. package/config/redis.ts +8 -0
  21. package/config/server/cli.ts +9 -0
  22. package/config/server/mcp.ts +11 -0
  23. package/config/server/web.ts +68 -0
  24. package/config/session.ts +18 -0
  25. package/config/tasks.ts +26 -0
  26. package/index.ts +29 -0
  27. package/initializers/actionts.ts +669 -0
  28. package/initializers/channels.ts +284 -0
  29. package/initializers/connections.ts +37 -0
  30. package/initializers/db.ts +158 -0
  31. package/initializers/mcp.ts +477 -0
  32. package/initializers/oauth.ts +610 -0
  33. package/initializers/process.ts +25 -0
  34. package/initializers/pubsub.ts +86 -0
  35. package/initializers/redis.ts +77 -0
  36. package/initializers/resque.ts +354 -0
  37. package/initializers/servers.ts +66 -0
  38. package/initializers/session.ts +84 -0
  39. package/initializers/signals.ts +60 -0
  40. package/initializers/swagger.ts +317 -0
  41. package/keryx.ts +61 -0
  42. package/lua/add-presence.lua +13 -0
  43. package/lua/refresh-presence.lua +8 -0
  44. package/lua/remove-presence.lua +16 -0
  45. package/middleware/rateLimit.ts +92 -0
  46. package/migrations.ts +5 -0
  47. package/package.json +97 -0
  48. package/servers/web.ts +721 -0
  49. package/templates/lion.svg +102 -0
  50. package/templates/oauth-authorize.html +75 -0
  51. package/templates/oauth-common.css +140 -0
  52. package/templates/oauth-success.html +38 -0
  53. package/tsconfig.json +24 -0
  54. package/util/cli.ts +135 -0
  55. package/util/config.ts +24 -0
  56. package/util/connectionString.ts +5 -0
  57. package/util/glob.ts +41 -0
  58. package/util/http.ts +86 -0
  59. package/util/oauth.ts +69 -0
  60. package/util/zodMixins.ts +88 -0
@@ -0,0 +1,284 @@
1
+ import path, { join } from "path";
2
+ import { api, logger } from "../api";
3
+ import type { Channel } from "../classes/Channel";
4
+ import type { Connection } from "../classes/Connection";
5
+ import { Initializer } from "../classes/Initializer";
6
+ import { ErrorType, TypedError } from "../classes/TypedError";
7
+ import { config } from "../config";
8
+ import { globLoader } from "../util/glob";
9
+
10
+ const namespace = "channels";
11
+ const PRESENCE_KEY_PREFIX = "presence:";
12
+ const LUA_DIR = join(import.meta.dir, "..", "lua");
13
+
14
+ declare module "../classes/API" {
15
+ export interface API {
16
+ [namespace]: Awaited<ReturnType<Channels["initialize"]>>;
17
+ }
18
+ }
19
+
20
+ export class Channels extends Initializer {
21
+ private addPresenceLua = "";
22
+ private removePresenceLua = "";
23
+ private refreshPresenceLua = "";
24
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
25
+
26
+ constructor() {
27
+ super(namespace);
28
+ this.loadPriority = 100;
29
+ this.startPriority = 600;
30
+ this.stopPriority = 50;
31
+ }
32
+
33
+ /**
34
+ * Find a channel definition that matches the given channel name.
35
+ * Returns undefined if no matching channel is found.
36
+ */
37
+ findChannel = (channelName: string): Channel | undefined => {
38
+ return api.channels.channels.find((c) => c.matches(channelName));
39
+ };
40
+
41
+ /**
42
+ * Authorize a connection to subscribe to a channel.
43
+ * Runs all middleware and the channel's authorize method.
44
+ * Throws TypedError if authorization fails.
45
+ */
46
+ authorizeSubscription = async (
47
+ channelName: string,
48
+ connection: Connection,
49
+ ): Promise<void> => {
50
+ const channel = this.findChannel(channelName);
51
+
52
+ if (!channel) {
53
+ throw new TypedError({
54
+ message: `Channel not found: ${channelName}`,
55
+ type: ErrorType.CONNECTION_CHANNEL_AUTHORIZATION,
56
+ });
57
+ }
58
+
59
+ // Run all middleware runBefore hooks
60
+ for (const middleware of channel.middleware) {
61
+ if (middleware.runBefore) {
62
+ await middleware.runBefore(channelName, connection);
63
+ }
64
+ }
65
+
66
+ // Run the channel's authorize method
67
+ await channel.authorize(channelName, connection);
68
+ };
69
+
70
+ /**
71
+ * Called when a connection unsubscribes from a channel.
72
+ * Runs all middleware runAfter hooks.
73
+ */
74
+ handleUnsubscription = async (
75
+ channelName: string,
76
+ connection: Connection,
77
+ ): Promise<void> => {
78
+ const channel = this.findChannel(channelName);
79
+
80
+ if (!channel) {
81
+ return;
82
+ }
83
+
84
+ // Run all middleware runAfter hooks
85
+ for (const middleware of channel.middleware) {
86
+ if (middleware.runAfter) {
87
+ await middleware.runAfter(channelName, connection);
88
+ }
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Record a connection's presence in a channel and broadcast a join event
94
+ * if this is the first connection for that presence key.
95
+ *
96
+ * Redis keys:
97
+ * presence:{channelName} — Set of presence keys
98
+ * presence:{channelName}:{presenceKey} — Set of connection IDs
99
+ */
100
+ addPresence = async (
101
+ channelName: string,
102
+ connection: Connection,
103
+ ): Promise<void> => {
104
+ const channel = this.findChannel(channelName);
105
+ const key = channel ? await channel.presenceKey(connection) : connection.id;
106
+
107
+ const channelKey = `${PRESENCE_KEY_PREFIX}${channelName}`;
108
+ const connectionSetKey = `${PRESENCE_KEY_PREFIX}${channelName}:${key}`;
109
+
110
+ const added = await api.redis.redis.eval(
111
+ this.addPresenceLua,
112
+ 2,
113
+ connectionSetKey,
114
+ channelKey,
115
+ connection.id,
116
+ key,
117
+ config.channels.presenceTTL,
118
+ );
119
+
120
+ if (added === 1) {
121
+ await api.pubsub.broadcast(
122
+ channelName,
123
+ JSON.stringify({ event: "join", presenceKey: key }),
124
+ "presence",
125
+ );
126
+ }
127
+ };
128
+
129
+ /**
130
+ * Remove a connection's presence from a channel and broadcast a leave event
131
+ * if this was the last connection for that presence key.
132
+ */
133
+ removePresence = async (
134
+ channelName: string,
135
+ connection: Connection,
136
+ ): Promise<void> => {
137
+ const channel = this.findChannel(channelName);
138
+ const key = channel ? await channel.presenceKey(connection) : connection.id;
139
+
140
+ const channelKey = `${PRESENCE_KEY_PREFIX}${channelName}`;
141
+ const connectionSetKey = `${PRESENCE_KEY_PREFIX}${channelName}:${key}`;
142
+
143
+ const shouldLeave = await api.redis.redis.eval(
144
+ this.removePresenceLua,
145
+ 2,
146
+ connectionSetKey,
147
+ channelKey,
148
+ connection.id,
149
+ key,
150
+ );
151
+
152
+ if (shouldLeave === 1) {
153
+ await api.pubsub.broadcast(
154
+ channelName,
155
+ JSON.stringify({ event: "leave", presenceKey: key }),
156
+ "presence",
157
+ );
158
+ }
159
+ };
160
+
161
+ /**
162
+ * Returns the list of presence keys for members currently in the channel
163
+ * across all server instances.
164
+ */
165
+ members = async (channelName: string): Promise<string[]> => {
166
+ const channelKey = `${PRESENCE_KEY_PREFIX}${channelName}`;
167
+ return api.redis.redis.smembers(channelKey);
168
+ };
169
+
170
+ /**
171
+ * Refresh TTLs on all presence keys owned by local connections.
172
+ * Called periodically by the heartbeat timer to prevent keys from
173
+ * expiring while the server is still alive.
174
+ */
175
+ refreshPresence = async (): Promise<void> => {
176
+ const keysToRefresh = new Set<string>();
177
+
178
+ for (const connection of api.connections.connections) {
179
+ for (const channelName of connection.subscriptions) {
180
+ const channel = this.findChannel(channelName);
181
+ const key = channel
182
+ ? await channel.presenceKey(connection)
183
+ : connection.id;
184
+
185
+ keysToRefresh.add(`${PRESENCE_KEY_PREFIX}${channelName}`);
186
+ keysToRefresh.add(`${PRESENCE_KEY_PREFIX}${channelName}:${key}`);
187
+ }
188
+ }
189
+
190
+ if (keysToRefresh.size === 0) return;
191
+
192
+ const keys = [...keysToRefresh];
193
+ await api.redis.redis.eval(
194
+ this.refreshPresenceLua,
195
+ keys.length,
196
+ ...keys,
197
+ config.channels.presenceTTL,
198
+ );
199
+ };
200
+
201
+ /**
202
+ * Clear all presence data. Useful for test cleanup.
203
+ */
204
+ clearPresence = async (): Promise<void> => {
205
+ let cursor = "0";
206
+ do {
207
+ const [nextCursor, keys] = await api.redis.redis.scan(
208
+ cursor,
209
+ "MATCH",
210
+ `${PRESENCE_KEY_PREFIX}*`,
211
+ "COUNT",
212
+ 100,
213
+ );
214
+ cursor = nextCursor;
215
+ if (keys.length > 0) {
216
+ await api.redis.redis.del(...keys);
217
+ }
218
+ } while (cursor !== "0");
219
+ };
220
+
221
+ async initialize() {
222
+ this.addPresenceLua = await Bun.file(
223
+ join(LUA_DIR, "add-presence.lua"),
224
+ ).text();
225
+ this.removePresenceLua = await Bun.file(
226
+ join(LUA_DIR, "remove-presence.lua"),
227
+ ).text();
228
+ this.refreshPresenceLua = await Bun.file(
229
+ join(LUA_DIR, "refresh-presence.lua"),
230
+ ).text();
231
+
232
+ let channels: Channel[] = [];
233
+
234
+ // Channels are always user-defined, load from rootDir only
235
+ try {
236
+ channels = await globLoader<Channel>(path.join(api.rootDir, "channels"));
237
+ } catch (e) {
238
+ // channels directory may not exist, which is fine
239
+ logger.debug(
240
+ `No channels directory found or error loading channels: ${e}`,
241
+ );
242
+ }
243
+
244
+ for (const c of channels) {
245
+ if (!c.description) c.description = `A Channel: ${c.name}`;
246
+ }
247
+
248
+ logger.info(`loaded ${channels.length} channels`);
249
+
250
+ return {
251
+ channels,
252
+ findChannel: this.findChannel,
253
+ authorizeSubscription: this.authorizeSubscription,
254
+ handleUnsubscription: this.handleUnsubscription,
255
+ addPresence: this.addPresence,
256
+ removePresence: this.removePresence,
257
+ refreshPresence: this.refreshPresence,
258
+ members: this.members,
259
+ clearPresence: this.clearPresence,
260
+ };
261
+ }
262
+
263
+ async start() {
264
+ const intervalMs = config.channels.presenceHeartbeatInterval * 1000;
265
+ this.heartbeatTimer = setInterval(async () => {
266
+ try {
267
+ await this.refreshPresence();
268
+ } catch (e) {
269
+ logger.error(`presence heartbeat error: ${e}`);
270
+ }
271
+ }, intervalMs);
272
+
273
+ logger.info(
274
+ `presence heartbeat started (interval=${config.channels.presenceHeartbeatInterval}s, ttl=${config.channels.presenceTTL}s)`,
275
+ );
276
+ }
277
+
278
+ async stop() {
279
+ if (this.heartbeatTimer) {
280
+ clearInterval(this.heartbeatTimer);
281
+ this.heartbeatTimer = null;
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,37 @@
1
+ import { Connection, api } from "../api";
2
+ import { Initializer } from "../classes/Initializer";
3
+
4
+ const namespace = "connections";
5
+
6
+ declare module "../classes/API" {
7
+ export interface API {
8
+ [namespace]: Awaited<ReturnType<Connections["initialize"]>>;
9
+ }
10
+ }
11
+
12
+ export class Connections extends Initializer {
13
+ constructor() {
14
+ super(namespace);
15
+ this.loadPriority = 1;
16
+ }
17
+
18
+ async initialize() {
19
+ function find(type: string, identifier: string, id: string) {
20
+ const index = api.connections.connections.findIndex(
21
+ (c) => c.type === type && c.id === id && c.identifier === identifier,
22
+ );
23
+
24
+ return { connection: api.connections.connections[index], index };
25
+ }
26
+
27
+ function destroy(type: string, identifier: string, id: string) {
28
+ const { connection, index } = find(type, identifier, id);
29
+ if (connection) {
30
+ return api.connections.connections.splice(index, 1);
31
+ }
32
+ return [];
33
+ }
34
+
35
+ return { connections: [] as Connection[], find, destroy };
36
+ }
37
+ }
@@ -0,0 +1,158 @@
1
+ import { $ } from "bun";
2
+ import { type Config as DrizzleMigrateConfig } from "drizzle-kit";
3
+ import { DefaultLogger, type LogWriter, sql } from "drizzle-orm";
4
+ import { drizzle } from "drizzle-orm/node-postgres";
5
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
6
+ import { unlink } from "node:fs/promises";
7
+ import path from "path";
8
+ import { Pool } from "pg";
9
+ import { api, logger } from "../api";
10
+ import { Initializer } from "../classes/Initializer";
11
+ import { ErrorType, TypedError } from "../classes/TypedError";
12
+ import { config } from "../config";
13
+ import { formatConnectionStringForLogging } from "../util/connectionString";
14
+
15
+ const namespace = "db";
16
+
17
+ declare module "../classes/API" {
18
+ export interface API {
19
+ [namespace]: Awaited<ReturnType<DB["initialize"]>>;
20
+ }
21
+ }
22
+
23
+ export class DB extends Initializer {
24
+ constructor() {
25
+ super(namespace);
26
+ this.loadPriority = 100;
27
+ this.startPriority = 100;
28
+ this.stopPriority = 910;
29
+ }
30
+
31
+ async initialize() {
32
+ const dbContainer = {} as {
33
+ db: ReturnType<typeof drizzle>;
34
+ pool: Pool;
35
+ };
36
+ return Object.assign(
37
+ {
38
+ generateMigrations: this.generateMigrations,
39
+ clearDatabase: this.clearDatabase,
40
+ },
41
+ dbContainer,
42
+ );
43
+ }
44
+
45
+ async start() {
46
+ api.db.pool = new Pool({
47
+ connectionString: config.database.connectionString,
48
+ });
49
+
50
+ class DrizzleLogger implements LogWriter {
51
+ write(message: string) {
52
+ logger.debug(message);
53
+ }
54
+ }
55
+
56
+ api.db.db = drizzle(api.db.pool, {
57
+ logger: new DefaultLogger({ writer: new DrizzleLogger() }),
58
+ });
59
+
60
+ try {
61
+ await api.db.db.execute(sql`SELECT NOW()`);
62
+ } catch (e) {
63
+ throw new TypedError({
64
+ type: ErrorType.SERVER_INITIALIZATION,
65
+ message: `Cannot connect to database (${formatConnectionStringForLogging(config.database.connectionString)}): ${e}`,
66
+ });
67
+ }
68
+
69
+ if (config.database.autoMigrate) {
70
+ try {
71
+ await migrate(api.db.db, {
72
+ migrationsFolder: path.join(api.rootDir, "drizzle"),
73
+ });
74
+ logger.info("database migrated successfully");
75
+ } catch (e) {
76
+ throw new TypedError({
77
+ type: ErrorType.SERVER_INITIALIZATION,
78
+ message: `Cannot migrate database (${formatConnectionStringForLogging(config.database.connectionString)}): ${e}`,
79
+ });
80
+ }
81
+ }
82
+
83
+ logger.info(
84
+ `database connection established (${formatConnectionStringForLogging(config.database.connectionString)})`,
85
+ );
86
+ }
87
+
88
+ async stop() {
89
+ if (api.db.db && api.db.pool) {
90
+ try {
91
+ await api.db.pool.end();
92
+ logger.info("database connection closed");
93
+ } catch (e) {
94
+ logger.error("error closing database connection", e);
95
+ }
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Generate migrations for the database schema.
101
+ * Learn more @ https://orm.drizzle.team/kit-docs/overview
102
+ */
103
+ async generateMigrations() {
104
+ const migrationConfig = {
105
+ schema: path.join("models", "*"),
106
+ dbCredentials: {
107
+ uri: config.database.connectionString,
108
+ },
109
+ out: path.join("drizzle"),
110
+ } satisfies DrizzleMigrateConfig;
111
+
112
+ const fileContent = `export default ${JSON.stringify(migrationConfig, null, 2)}`;
113
+ const tmpfilePath = path.join(api.rootDir, "drizzle", "config.tmp.ts");
114
+
115
+ try {
116
+ await Bun.write(tmpfilePath, fileContent);
117
+ const { exitCode, stdout, stderr } =
118
+ await $`bun drizzle-kit generate:pg --config ${tmpfilePath}`;
119
+ logger.trace(stdout.toString());
120
+ if (exitCode !== 0) {
121
+ {
122
+ throw new TypedError({
123
+ message: `Failed to generate migrations: ${stderr.toString()}`,
124
+ type: ErrorType.SERVER_INITIALIZATION,
125
+ });
126
+ }
127
+ }
128
+ } finally {
129
+ const filePointer = Bun.file(tmpfilePath);
130
+ if (await filePointer.exists()) await unlink(tmpfilePath);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Erase all the tables in the active database. Will fail on production environments.
136
+ */
137
+ async clearDatabase(restartIdentity = true, cascade = true) {
138
+ if (Bun.env.NODE_ENV === "production") {
139
+ throw new TypedError({
140
+ message: "clearDatabase cannot be called in production",
141
+ type: ErrorType.SERVER_INITIALIZATION,
142
+ });
143
+ }
144
+
145
+ const { rows } = await api.db.db.execute(
146
+ sql`SELECT tablename FROM pg_tables WHERE schemaname = CURRENT_SCHEMA`,
147
+ );
148
+
149
+ for (const row of rows) {
150
+ logger.debug(`truncating table ${row.tablename}`);
151
+ await api.db.db.execute(
152
+ sql.raw(
153
+ `TRUNCATE TABLE "${row.tablename}" ${restartIdentity ? "RESTART IDENTITY" : ""} ${cascade ? "CASCADE" : ""} `,
154
+ ),
155
+ );
156
+ }
157
+ }
158
+ }