@donkeylabs/server 0.1.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.
@@ -0,0 +1,210 @@
1
+ // Core SSE Service
2
+ // Server-Sent Events for server→client push
3
+
4
+ export interface SSEClient {
5
+ id: string;
6
+ channels: Set<string>;
7
+ controller: ReadableStreamDefaultController<Uint8Array>;
8
+ createdAt: Date;
9
+ lastEventId?: string;
10
+ }
11
+
12
+ export interface SSEConfig {
13
+ heartbeatInterval?: number; // ms, default 30000 (30s)
14
+ retryInterval?: number; // ms suggested to client, default 3000
15
+ }
16
+
17
+ export interface SSE {
18
+ addClient(options?: { lastEventId?: string }): { client: SSEClient; response: Response };
19
+ removeClient(clientId: string): void;
20
+ getClient(clientId: string): SSEClient | undefined;
21
+ subscribe(clientId: string, channel: string): boolean;
22
+ unsubscribe(clientId: string, channel: string): boolean;
23
+ broadcast(channel: string, event: string, data: any, id?: string): void;
24
+ broadcastAll(event: string, data: any, id?: string): void;
25
+ sendTo(clientId: string, event: string, data: any, id?: string): boolean;
26
+ getClients(): SSEClient[];
27
+ getClientsByChannel(channel: string): SSEClient[];
28
+ shutdown(): void;
29
+ }
30
+
31
+ class SSEImpl implements SSE {
32
+ private clients = new Map<string, SSEClient>();
33
+ private heartbeatInterval: number;
34
+ private retryInterval: number;
35
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
36
+ private clientCounter = 0;
37
+ private encoder = new TextEncoder();
38
+
39
+ constructor(config: SSEConfig = {}) {
40
+ this.heartbeatInterval = config.heartbeatInterval ?? 30000;
41
+ this.retryInterval = config.retryInterval ?? 3000;
42
+
43
+ // Start heartbeat to keep connections alive
44
+ this.heartbeatTimer = setInterval(() => {
45
+ this.sendHeartbeat();
46
+ }, this.heartbeatInterval);
47
+ }
48
+
49
+ addClient(options: { lastEventId?: string } = {}): { client: SSEClient; response: Response } {
50
+ const id = `sse_${++this.clientCounter}_${Date.now()}`;
51
+
52
+ let clientController: ReadableStreamDefaultController<Uint8Array>;
53
+
54
+ const stream = new ReadableStream<Uint8Array>({
55
+ start: (controller) => {
56
+ clientController = controller;
57
+
58
+ // Send retry interval to client
59
+ const retryMsg = `retry: ${this.retryInterval}\n\n`;
60
+ controller.enqueue(this.encoder.encode(retryMsg));
61
+ },
62
+ cancel: () => {
63
+ this.removeClient(id);
64
+ },
65
+ });
66
+
67
+ const client: SSEClient = {
68
+ id,
69
+ channels: new Set(),
70
+ controller: clientController!,
71
+ createdAt: new Date(),
72
+ lastEventId: options.lastEventId,
73
+ };
74
+
75
+ this.clients.set(id, client);
76
+
77
+ const response = new Response(stream, {
78
+ headers: {
79
+ "Content-Type": "text/event-stream",
80
+ "Cache-Control": "no-cache",
81
+ "Connection": "keep-alive",
82
+ "X-SSE-Client-Id": id,
83
+ },
84
+ });
85
+
86
+ return { client, response };
87
+ }
88
+
89
+ removeClient(clientId: string): void {
90
+ const client = this.clients.get(clientId);
91
+ if (client) {
92
+ try {
93
+ client.controller.close();
94
+ } catch {
95
+ // Controller may already be closed
96
+ }
97
+ this.clients.delete(clientId);
98
+ }
99
+ }
100
+
101
+ getClient(clientId: string): SSEClient | undefined {
102
+ return this.clients.get(clientId);
103
+ }
104
+
105
+ subscribe(clientId: string, channel: string): boolean {
106
+ const client = this.clients.get(clientId);
107
+ if (!client) return false;
108
+ client.channels.add(channel);
109
+ return true;
110
+ }
111
+
112
+ unsubscribe(clientId: string, channel: string): boolean {
113
+ const client = this.clients.get(clientId);
114
+ if (!client) return false;
115
+ return client.channels.delete(channel);
116
+ }
117
+
118
+ broadcast(channel: string, event: string, data: any, id?: string): void {
119
+ for (const client of this.clients.values()) {
120
+ if (client.channels.has(channel)) {
121
+ this.sendEvent(client, event, data, id);
122
+ }
123
+ }
124
+ }
125
+
126
+ broadcastAll(event: string, data: any, id?: string): void {
127
+ for (const client of this.clients.values()) {
128
+ this.sendEvent(client, event, data, id);
129
+ }
130
+ }
131
+
132
+ sendTo(clientId: string, event: string, data: any, id?: string): boolean {
133
+ const client = this.clients.get(clientId);
134
+ if (!client) return false;
135
+ return this.sendEvent(client, event, data, id);
136
+ }
137
+
138
+ getClients(): SSEClient[] {
139
+ return Array.from(this.clients.values());
140
+ }
141
+
142
+ getClientsByChannel(channel: string): SSEClient[] {
143
+ return Array.from(this.clients.values()).filter(c => c.channels.has(channel));
144
+ }
145
+
146
+ shutdown(): void {
147
+ if (this.heartbeatTimer) {
148
+ clearInterval(this.heartbeatTimer);
149
+ this.heartbeatTimer = null;
150
+ }
151
+
152
+ // Close all client connections
153
+ for (const client of this.clients.values()) {
154
+ try {
155
+ client.controller.close();
156
+ } catch {
157
+ // Ignore errors during shutdown
158
+ }
159
+ }
160
+ this.clients.clear();
161
+ }
162
+
163
+ private sendEvent(client: SSEClient, event: string, data: any, id?: string): boolean {
164
+ try {
165
+ let message = "";
166
+
167
+ if (id) {
168
+ message += `id: ${id}\n`;
169
+ }
170
+
171
+ message += `event: ${event}\n`;
172
+
173
+ // Handle data - serialize if object
174
+ const dataStr = typeof data === "string" ? data : JSON.stringify(data);
175
+
176
+ // Split data by newlines for proper SSE format
177
+ for (const line of dataStr.split("\n")) {
178
+ message += `data: ${line}\n`;
179
+ }
180
+
181
+ message += "\n"; // End of message
182
+
183
+ client.controller.enqueue(this.encoder.encode(message));
184
+ return true;
185
+ } catch {
186
+ // Client may be disconnected
187
+ this.removeClient(client.id);
188
+ return false;
189
+ }
190
+ }
191
+
192
+ private sendHeartbeat(): void {
193
+ // Send comment as heartbeat to keep connections alive
194
+ const heartbeat = `: heartbeat ${Date.now()}\n\n`;
195
+ const encoded = this.encoder.encode(heartbeat);
196
+
197
+ for (const client of this.clients.values()) {
198
+ try {
199
+ client.controller.enqueue(encoded);
200
+ } catch {
201
+ // Client disconnected
202
+ this.removeClient(client.id);
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ export function createSSE(config?: SSEConfig): SSE {
209
+ return new SSEImpl(config);
210
+ }
package/src/core.ts ADDED
@@ -0,0 +1,428 @@
1
+ import type { Kysely } from "kysely";
2
+ import { readdir } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { z } from "zod";
5
+ import type { Logger } from "./core/logger";
6
+ import type { Cache } from "./core/cache";
7
+ import type { Events } from "./core/events";
8
+ import type { Cron } from "./core/cron";
9
+ import type { Jobs } from "./core/jobs";
10
+ import type { SSE } from "./core/sse";
11
+ import type { RateLimiter } from "./core/rate-limiter";
12
+ import type { Errors, CustomErrorRegistry } from "./core/errors";
13
+
14
+ export interface PluginRegistry {}
15
+
16
+ export interface ClientConfig {
17
+ credentials?: "include" | "same-origin" | "omit";
18
+ }
19
+
20
+ export type EventSchemas = Record<string, z.ZodType<any>>;
21
+
22
+ export type Register<
23
+ Service = void,
24
+ Schema = {},
25
+ Handlers = {},
26
+ Dependencies extends readonly string[] = readonly [],
27
+ Middleware = {},
28
+ Config = void,
29
+ Events extends EventSchemas = {}
30
+ > = {
31
+ service: Service;
32
+ schema: Schema;
33
+ handlers: Handlers;
34
+ _dependencies: Dependencies;
35
+ middleware: Middleware;
36
+ config: Config;
37
+ events: Events;
38
+ };
39
+
40
+ export interface PluginHandlerRegistry {}
41
+
42
+ export interface PluginMiddlewareRegistry {}
43
+
44
+ export interface CoreServices {
45
+ db: Kysely<any>;
46
+ config: Record<string, any>;
47
+ logger: Logger;
48
+ cache: Cache;
49
+ events: Events;
50
+ cron: Cron;
51
+ jobs: Jobs;
52
+ sse: SSE;
53
+ rateLimiter: RateLimiter;
54
+ errors: Errors;
55
+ }
56
+
57
+ export class PluginContext<Deps = any, Schema = any, Config = void> {
58
+ constructor(
59
+ public readonly core: CoreServices,
60
+ public readonly deps: Deps,
61
+ public readonly config: Config
62
+ ) {}
63
+
64
+ get db(): Kysely<Schema> {
65
+ return this.core.db as unknown as Kysely<Schema>;
66
+ }
67
+ }
68
+
69
+ type UnionToIntersection<U> =
70
+ (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
71
+
72
+ type ExtractPluginSchema<K> = K extends keyof PluginRegistry
73
+ ? PluginRegistry[K] extends { schema: infer S } ? S : {}
74
+ : {};
75
+
76
+ type ExtractServices<T extends readonly (keyof PluginRegistry)[] | undefined> =
77
+ T extends readonly []
78
+ ? {}
79
+ : T extends readonly (infer K)[]
80
+ ? K extends keyof PluginRegistry
81
+ ? { [P in K]: PluginRegistry[P] extends { service: infer S } ? S : unknown }
82
+ : {}
83
+ : {};
84
+
85
+ type ExtractSchemas<T extends readonly (keyof PluginRegistry)[] | undefined> =
86
+ T extends readonly []
87
+ ? {}
88
+ : T extends readonly (infer K)[]
89
+ ? UnionToIntersection<ExtractPluginSchema<K>>
90
+ : {};
91
+
92
+ type SelfDependencyError<Name extends string> =
93
+ `Error: Plugin '${Name}' cannot depend on itself`;
94
+
95
+ type PluginDependsOn<
96
+ PluginName extends keyof PluginRegistry,
97
+ Target extends string
98
+ > = PluginRegistry[PluginName] extends { _dependencies: readonly (infer D)[] }
99
+ ? Target extends D
100
+ ? true
101
+ : false
102
+ : false;
103
+
104
+ type FindCircularDep<
105
+ Name extends string,
106
+ Deps extends readonly (keyof PluginRegistry)[]
107
+ > = {
108
+ [K in Deps[number]]: PluginDependsOn<K, Name> extends true ? K : never
109
+ }[Deps[number]];
110
+
111
+ type CircularDependencyError<Name extends string, CircularDep extends string> =
112
+ `Error: Circular dependency - '${CircularDep}' already depends on '${Name}'`;
113
+
114
+ type ValidateDeps<
115
+ Name extends string,
116
+ Deps extends readonly (keyof PluginRegistry)[]
117
+ > = Name extends Deps[number]
118
+ ? SelfDependencyError<Name>
119
+ : FindCircularDep<Name, Deps> extends never
120
+ ? Deps
121
+ : CircularDependencyError<Name, FindCircularDep<Name, Deps> & string>;
122
+
123
+ export interface PluginConfig<
124
+ Name extends keyof PluginRegistry,
125
+ Deps extends readonly (keyof PluginRegistry)[] | undefined,
126
+ LocalSchema,
127
+ Service,
128
+ Handlers = {},
129
+ Middleware = {},
130
+ Config = void,
131
+ FullSchema = LocalSchema & ExtractSchemas<Deps>
132
+ > {
133
+ name: Name;
134
+ version?: string;
135
+ dependencies?: Deps;
136
+ service: (ctx: PluginContext<ExtractServices<Deps>, FullSchema, Config>) => Promise<Service> | Service;
137
+ handlers?: Handlers;
138
+ middleware?: Middleware;
139
+ }
140
+
141
+ export type PluginFactory<Config, P extends Plugin = Plugin> = ((config: Config) => P) & {
142
+ _configType: Config;
143
+ };
144
+
145
+ export class PluginBuilder<LocalSchema = {}> {
146
+ withSchema<S>(): PluginBuilder<S> {
147
+ return new PluginBuilder<S>();
148
+ }
149
+
150
+ withConfig<C>(): ConfiguredPluginBuilder<LocalSchema, C> {
151
+ return new ConfiguredPluginBuilder<LocalSchema, C>();
152
+ }
153
+
154
+ define<
155
+ Name extends string,
156
+ const Deps extends readonly (keyof PluginRegistry)[] = readonly [],
157
+ const Handlers extends object = {},
158
+ const Middleware extends object = {},
159
+ const Events extends EventSchemas = {},
160
+ const CustomErrors extends CustomErrorRegistry = {},
161
+ Service = void
162
+ >(
163
+ config: {
164
+ name: Name;
165
+ version?: string;
166
+ dependencies?: ValidateDeps<Name, Deps> extends Deps ? Deps : ValidateDeps<Name, Deps>;
167
+ handlers?: Handlers;
168
+ middleware?: Middleware;
169
+ events?: Events;
170
+ client?: ClientConfig;
171
+ customErrors?: CustomErrors;
172
+ service: (
173
+ ctx: PluginContext<ExtractServices<Deps>, LocalSchema & ExtractSchemas<Deps>, void>
174
+ ) => Promise<Service> | Service;
175
+ }
176
+ ): Plugin & {
177
+ name: Name;
178
+ _dependencies: Deps;
179
+ _schema: LocalSchema;
180
+ _fullSchema: LocalSchema & ExtractSchemas<Deps>;
181
+ handlers?: Handlers;
182
+ middleware?: Middleware;
183
+ events?: Events;
184
+ client?: ClientConfig;
185
+ customErrors?: CustomErrors;
186
+ } {
187
+ return config as any;
188
+ }
189
+ }
190
+
191
+ export class ConfiguredPluginBuilder<LocalSchema, Config> {
192
+ withSchema<S>(): ConfiguredPluginBuilder<S, Config> {
193
+ return new ConfiguredPluginBuilder<S, Config>();
194
+ }
195
+
196
+ define<
197
+ Name extends string,
198
+ const Deps extends readonly (keyof PluginRegistry)[] = readonly [],
199
+ const Handlers extends object = {},
200
+ const Middleware extends object = {},
201
+ const Events extends EventSchemas = {},
202
+ const CustomErrors extends CustomErrorRegistry = {},
203
+ Service = void
204
+ >(
205
+ pluginDef: {
206
+ name: Name;
207
+ version?: string;
208
+ dependencies?: ValidateDeps<Name, Deps> extends Deps ? Deps : ValidateDeps<Name, Deps>;
209
+ handlers?: Handlers;
210
+ middleware?: Middleware;
211
+ events?: Events;
212
+ client?: ClientConfig;
213
+ customErrors?: CustomErrors;
214
+ service: (
215
+ ctx: PluginContext<ExtractServices<Deps>, LocalSchema & ExtractSchemas<Deps>, Config>
216
+ ) => Promise<Service> | Service;
217
+ }
218
+ ): PluginFactory<Config, Plugin & {
219
+ name: Name;
220
+ _dependencies: Deps;
221
+ _schema: LocalSchema;
222
+ _fullSchema: LocalSchema & ExtractSchemas<Deps>;
223
+ _config: Config;
224
+ handlers?: Handlers;
225
+ middleware?: Middleware;
226
+ events?: Events;
227
+ client?: ClientConfig;
228
+ customErrors?: CustomErrors;
229
+ }> {
230
+ const factory = (config: Config) => ({
231
+ ...pluginDef,
232
+ _boundConfig: config,
233
+ });
234
+ return factory as any;
235
+ }
236
+ }
237
+
238
+ export const createPlugin: PluginBuilder<{}> = new PluginBuilder();
239
+
240
+ type UnwrapPluginFactory<T> = T extends (config: any) => infer P ? P : T;
241
+
242
+ export type InferService<T> = UnwrapPluginFactory<T> extends { service: (ctx: any) => Promise<infer S> | infer S }
243
+ ? S
244
+ : UnwrapPluginFactory<T> extends { service: (...args: any[]) => infer S }
245
+ ? Awaited<S>
246
+ : never;
247
+ export type InferSchema<T> = UnwrapPluginFactory<T> extends { _schema: infer S } ? S : never;
248
+ export type InferHandlers<T> = UnwrapPluginFactory<T> extends { handlers?: infer H } ? H : {};
249
+ export type InferMiddleware<T> = UnwrapPluginFactory<T> extends { middleware?: infer M } ? M : {};
250
+ export type InferDependencies<T> = UnwrapPluginFactory<T> extends { _dependencies: infer D } ? D : readonly [];
251
+ export type InferConfig<T> = T extends (config: infer C) => any ? C : void;
252
+ export type InferEvents<T> = UnwrapPluginFactory<T> extends { events?: infer E } ? E : {};
253
+ export type InferClientConfig<T> = UnwrapPluginFactory<T> extends { client?: infer C } ? C : undefined;
254
+ export type InferCustomErrors<T> = UnwrapPluginFactory<T> extends { customErrors?: infer E } ? E : {};
255
+
256
+ export type { ExtractServices, ExtractSchemas };
257
+
258
+ export type Plugin = {
259
+ name: string;
260
+ version?: string;
261
+ dependencies?: readonly string[];
262
+ handlers?: Record<string, any>;
263
+ middleware?: Record<string, any>;
264
+ events?: Record<string, any>;
265
+ client?: ClientConfig;
266
+ customErrors?: CustomErrorRegistry;
267
+ service: (ctx: any) => any;
268
+ };
269
+
270
+ export type PluginWithConfig<Config = void> = Plugin & {
271
+ _config?: Config;
272
+ };
273
+
274
+ export type ConfiguredPlugin = Plugin & { _boundConfig?: any };
275
+
276
+ export class PluginManager {
277
+ private plugins: Map<string, ConfiguredPlugin> = new Map();
278
+ private services: Record<string, any> = {};
279
+ private core: CoreServices;
280
+
281
+ constructor(core: CoreServices) {
282
+ this.core = core;
283
+ }
284
+
285
+ getServices(): any {
286
+ return this.services;
287
+ }
288
+
289
+ getCore(): CoreServices {
290
+ return this.core;
291
+ }
292
+
293
+ getPlugins(): Plugin[] {
294
+ return Array.from(this.plugins.values());
295
+ }
296
+
297
+ register(plugin: ConfiguredPlugin): void {
298
+ if (this.plugins.has(plugin.name)) {
299
+ throw new Error(`Plugin ${plugin.name} is already registered.`);
300
+ }
301
+ this.plugins.set(plugin.name, plugin);
302
+ }
303
+
304
+ async migrate(): Promise<void> {
305
+ console.log("Running migrations (File-System Based)...");
306
+ const sortedPlugins = this.resolveOrder();
307
+
308
+ for (const plugin of sortedPlugins) {
309
+ const pluginName = plugin.name;
310
+ const possibleMigrationDirs = [
311
+ join(process.cwd(), "examples/basic-server/src/plugins", pluginName, "migrations"),
312
+ join(process.cwd(), "src/plugins", pluginName, "migrations"),
313
+ join(process.cwd(), "plugins", pluginName, "migrations"),
314
+ ];
315
+
316
+ let migrationDir = "";
317
+ for (const dir of possibleMigrationDirs) {
318
+ try {
319
+ await readdir(dir);
320
+ migrationDir = dir;
321
+ break;
322
+ } catch {
323
+ continue;
324
+ }
325
+ }
326
+
327
+ if (!migrationDir) continue;
328
+
329
+ try {
330
+ const files = await readdir(migrationDir);
331
+ const migrationFiles = files.filter(f => f.endsWith(".ts"));
332
+
333
+ if (migrationFiles.length > 0) {
334
+ console.log(`[Migration] checking plugin: ${pluginName} at ${migrationDir}`);
335
+
336
+ for (const file of migrationFiles.sort()) {
337
+ console.log(` - Executing migration: ${file}`);
338
+ const migrationPath = join(migrationDir, file);
339
+ const migration = await import(migrationPath);
340
+
341
+ if (migration.up) {
342
+ try {
343
+ await migration.up(this.core.db);
344
+ console.log(` Success`);
345
+ } catch (e) {
346
+ console.error(` Failed to run ${file}:`, e);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ } catch {
352
+ // Migration directory doesn't exist, skip
353
+ }
354
+ }
355
+ }
356
+
357
+ async init(): Promise<void> {
358
+ for (const plugin of this.plugins.values()) {
359
+ const deps = plugin.dependencies || [];
360
+ for (const dep of deps) {
361
+ if (!this.plugins.has(dep)) {
362
+ throw new Error(`Plugin '${plugin.name}' depends on '${dep}', but it is not registered.`);
363
+ }
364
+ }
365
+ }
366
+
367
+ const sortedPlugins = this.resolveOrder();
368
+
369
+ for (const plugin of sortedPlugins) {
370
+ console.log(`Initializing plugin: ${plugin.name}`);
371
+
372
+ if (plugin.customErrors) {
373
+ for (const [errorName, errorDef] of Object.entries(plugin.customErrors)) {
374
+ this.core.errors.register(errorName, errorDef);
375
+ console.log(`[${plugin.name}] Registered custom error: ${errorName}`);
376
+ }
377
+ }
378
+
379
+ const pluginDeps: Record<string, unknown> = {};
380
+ if (plugin.dependencies) {
381
+ for (const depName of plugin.dependencies) {
382
+ pluginDeps[depName] = this.services[depName];
383
+ }
384
+ }
385
+
386
+ const pluginConfig = (plugin as ConfiguredPlugin)._boundConfig;
387
+ const ctx = new PluginContext(this.core, pluginDeps, pluginConfig);
388
+ const service = await plugin.service(ctx);
389
+
390
+ if (service) {
391
+ this.services[plugin.name] = service;
392
+ console.log(`[${plugin.name}] Service registered.`);
393
+ }
394
+ }
395
+
396
+ console.log("All plugins initialized.");
397
+ }
398
+
399
+ private resolveOrder(): Plugin[] {
400
+ const visited = new Set<string>();
401
+ const sorted: Plugin[] = [];
402
+ const visiting = new Set<string>();
403
+
404
+ const visit = (plugin: Plugin) => {
405
+ const name = plugin.name;
406
+ if (visited.has(name)) return;
407
+ if (visiting.has(name)) throw new Error(`Circular dependency detected: ${name}`);
408
+
409
+ visiting.add(name);
410
+
411
+ const deps = plugin.dependencies || [];
412
+ for (const depName of deps) {
413
+ const depPlugin = this.plugins.get(depName);
414
+ if (depPlugin) visit(depPlugin);
415
+ }
416
+
417
+ visiting.delete(name);
418
+ visited.add(name);
419
+ sorted.push(plugin);
420
+ };
421
+
422
+ for (const plugin of this.plugins.values()) {
423
+ visit(plugin);
424
+ }
425
+
426
+ return sorted;
427
+ }
428
+ }