@donkeylabs/server 2.0.22 → 2.0.24

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/docs/cron.md CHANGED
@@ -26,7 +26,7 @@ ctx.core.cron.schedule("0 0 * * *", async () => {
26
26
  interface Cron {
27
27
  schedule(
28
28
  expression: string,
29
- handler: () => void | Promise<void>,
29
+ handler: (logger?: Logger, ctx?: CronRunContext) => void | Promise<void>,
30
30
  options?: { name?: string; enabled?: boolean }
31
31
  ): string;
32
32
  unschedule(taskId: string): boolean;
@@ -43,11 +43,19 @@ interface CronTask {
43
43
  id: string;
44
44
  name: string;
45
45
  expression: string;
46
- handler: () => void | Promise<void>;
46
+ handler: (logger?: Logger, ctx?: CronRunContext) => void | Promise<void>;
47
47
  enabled: boolean;
48
48
  lastRun?: Date;
49
49
  nextRun?: Date;
50
50
  }
51
+
52
+ interface CronRunContext {
53
+ taskId: string;
54
+ name: string;
55
+ logger?: Logger;
56
+ emit?: (event: string, data?: Record<string, any>) => Promise<void>;
57
+ log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
58
+ }
51
59
  ```
52
60
 
53
61
  ### Methods
@@ -356,6 +364,24 @@ ctx.core.cron.schedule("0 * * * *", async () => {
356
364
 
357
365
  ---
358
366
 
367
+ ## Logs and Custom Events
368
+
369
+ Cron handlers receive a scoped logger and a context helper. Logs are persisted and emitted as events:
370
+
371
+ - `log.cron` (all cron logs)
372
+ - `log.cron.<taskName>` (per task)
373
+
374
+ You can also emit custom events scoped to a cron task:
375
+
376
+ ```ts
377
+ ctx.core.cron.schedule("0 * * * *", async (logger, ctx) => {
378
+ ctx?.log?.("info", "Hourly cleanup started");
379
+ await ctx?.emit?.("cleanup.started", { when: new Date().toISOString() });
380
+ });
381
+ ```
382
+
383
+ ---
384
+
359
385
  ## Testing Cron Tasks
360
386
 
361
387
  ```ts
package/docs/jobs.md CHANGED
@@ -438,6 +438,21 @@ interface JobAdapter {
438
438
  }
439
439
  ```
440
440
 
441
+ ---
442
+
443
+ ## Logs and Custom Events
444
+
445
+ Job handlers receive a scoped logger and helpers. Logs are persisted and emitted as events:
446
+
447
+ - `log.job` (all job logs)
448
+ - `log.job.<jobId>` (per job)
449
+
450
+ Custom events are emitted via `ctx.emit`:
451
+
452
+ - `job.event`
453
+ - `job.<jobName>.event`
454
+ - `job.<jobId>.event`
455
+
441
456
  ### SQLite Adapter Example
442
457
 
443
458
  ```ts
package/docs/workflows.md CHANGED
@@ -487,6 +487,62 @@ ctx.core.workflows.register(myWorkflow);
487
487
 
488
488
  > **Advanced:** The module path is captured automatically when you call `.build()`. If you re-export a workflow definition from a different module, pass `{ modulePath: import.meta.url }` explicitly so the subprocess can find the definition.
489
489
 
490
+ #### Isolated Plugin Initialization
491
+
492
+ In isolated mode, the subprocess **boots a full plugin manager** and runs plugin `init` hooks locally. This means your workflow handlers can use `ctx.plugins` without IPC fallbacks, and cron/jobs/workflows/services registered in `init` are available inside the subprocess.
493
+
494
+ Requirements:
495
+ - Plugin modules must be discoverable from their module path (captured during `createPlugin.define()` / `pluginFactory()` calls).
496
+ - Plugin configs and `ctx.core.config` must be JSON-serializable.
497
+
498
+ ```typescript
499
+ // plugins/reports/index.ts
500
+ export const reportsPlugin = createPlugin.define({
501
+ name: "reports",
502
+ service: async (ctx) => ({
503
+ generate: async (id: string) => ctx.db.selectFrom("reports").selectAll().execute(),
504
+ }),
505
+ init: async (ctx) => {
506
+ ctx.core.jobs.register("reports.generate", async () => undefined);
507
+ },
508
+ });
509
+
510
+ // workflows/report.ts
511
+ export const reportWorkflow = workflow("report.generate")
512
+ .task("run", {
513
+ handler: async (input, ctx) => {
514
+ const data = await ctx.plugins.reports.generate(input.reportId);
515
+ return { data };
516
+ },
517
+ })
518
+ .build();
519
+ ```
520
+
521
+ #### Workflow Logs and Custom Events
522
+
523
+ Workflow handlers get a scoped logger and helpers:
524
+
525
+ - `ctx.logger` and `ctx.core.logger` are scoped to `source=workflow`, `sourceId=<instanceId>`
526
+ - Logs are persisted and emitted as events:
527
+ - `log.workflow` (all workflow logs)
528
+ - `log.workflow.<instanceId>` (per workflow instance)
529
+
530
+ Custom events can be emitted with `ctx.emit`:
531
+
532
+ ```typescript
533
+ export const reportWorkflow = workflow("report.generate")
534
+ .task("run", {
535
+ handler: async (input, ctx) => {
536
+ ctx.log?.("info", "Starting report", { reportId: input.reportId });
537
+ await ctx.emit?.("progress", { stage: "fetch" });
538
+ const data = await ctx.plugins.reports.generate(input.reportId);
539
+ await ctx.emit?.("progress", { stage: "write" });
540
+ return { data };
541
+ },
542
+ })
543
+ .build();
544
+ ```
545
+
490
546
  ### Inline Mode
491
547
 
492
548
  For lightweight workflows that complete quickly, you can opt into inline execution:
@@ -512,7 +568,7 @@ ctx.core.workflows.register(quickWorkflow);
512
568
  |---|---|---|
513
569
  | Step types | All (task, choice, parallel, pass) | All (task, choice, parallel, pass) |
514
570
  | Event loop | Separate process, won't block server | Runs on main thread |
515
- | Plugin access | Via IPC proxy | Direct access |
571
+ | Plugin access | Local plugin services in subprocess | Direct access |
516
572
  | Best for | Long-running, CPU-intensive workflows | Quick validations, lightweight flows |
517
573
  | Setup | `workflows.register(wf)` | `workflows.register(wf)` |
518
574
 
@@ -545,6 +601,9 @@ interface Workflows {
545
601
 
546
602
  /** Stop the workflow service */
547
603
  stop(): Promise<void>;
604
+
605
+ /** Set plugin metadata for isolated workflows (AppServer sets this automatically) */
606
+ setPluginMetadata(metadata: PluginMetadata): void;
548
607
  }
549
608
 
550
609
  interface WorkflowRegisterOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.22",
3
+ "version": "2.0.24",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
package/src/core/cron.ts CHANGED
@@ -1,13 +1,14 @@
1
1
  // Core Cron Service
2
2
  // Schedule recurring tasks with cron expressions
3
3
 
4
- import type { Logger } from "./logger";
4
+ import type { Logger, LogLevel } from "./logger";
5
+ import type { Events } from "./events";
5
6
 
6
7
  export interface CronTask {
7
8
  id: string;
8
9
  name: string;
9
10
  expression: string;
10
- handler: (logger?: Logger) => void | Promise<void>;
11
+ handler: (logger?: Logger, ctx?: CronRunContext) => void | Promise<void>;
11
12
  enabled: boolean;
12
13
  lastRun?: Date;
13
14
  nextRun?: Date;
@@ -16,12 +17,13 @@ export interface CronTask {
16
17
  export interface CronConfig {
17
18
  timezone?: string; // For future use
18
19
  logger?: Logger;
20
+ events?: Events;
19
21
  }
20
22
 
21
23
  export interface Cron {
22
24
  schedule(
23
25
  expression: string,
24
- handler: (logger?: Logger) => void | Promise<void>,
26
+ handler: (logger?: Logger, ctx?: CronRunContext) => void | Promise<void>,
25
27
  options?: { name?: string; enabled?: boolean }
26
28
  ): string;
27
29
  unschedule(taskId: string): boolean;
@@ -34,6 +36,14 @@ export interface Cron {
34
36
  stop(): Promise<void>;
35
37
  }
36
38
 
39
+ export interface CronRunContext {
40
+ taskId: string;
41
+ name: string;
42
+ logger?: Logger;
43
+ emit?: (event: string, data?: Record<string, any>) => Promise<void>;
44
+ log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
45
+ }
46
+
37
47
  // Simple cron expression parser
38
48
  // Supports: * (any), specific values, ranges (1-5), steps (*/5)
39
49
  // Format: second minute hour dayOfMonth month dayOfWeek
@@ -263,14 +273,16 @@ class CronImpl implements Cron {
263
273
  private timer: ReturnType<typeof setInterval> | null = null;
264
274
  private taskCounter = 0;
265
275
  private logger?: Logger;
276
+ private events?: Events;
266
277
 
267
278
  constructor(config: CronConfig = {}) {
268
279
  this.logger = config.logger;
280
+ this.events = config.events;
269
281
  }
270
282
 
271
283
  schedule(
272
284
  expression: string,
273
- handler: (logger?: Logger) => void | Promise<void>,
285
+ handler: (logger?: Logger, ctx?: CronRunContext) => void | Promise<void>,
274
286
  options: { name?: string; enabled?: boolean } = {}
275
287
  ): string {
276
288
  const id = `cron_${++this.taskCounter}_${Date.now()}`;
@@ -341,7 +353,7 @@ class CronImpl implements Cron {
341
353
 
342
354
  task.lastRun = new Date();
343
355
  const scopedLogger = this.logger?.scoped("cron", task.name);
344
- await task.handler(scopedLogger);
356
+ await task.handler(scopedLogger, this.createRunContext(task, scopedLogger));
345
357
  }
346
358
 
347
359
  start(): void {
@@ -365,7 +377,7 @@ class CronImpl implements Cron {
365
377
 
366
378
  // Execute handler with scoped logger (fire and forget, but log errors)
367
379
  const scopedLogger = this.logger?.scoped("cron", task.name);
368
- Promise.resolve(task.handler(scopedLogger)).catch(err => {
380
+ Promise.resolve(task.handler(scopedLogger, this.createRunContext(task, scopedLogger))).catch(err => {
369
381
  console.error(`[Cron] Task "${task.name}" failed:`, err);
370
382
  });
371
383
  }
@@ -396,7 +408,7 @@ class CronImpl implements Cron {
396
408
 
397
409
  // Execute the handler asynchronously with scoped logger
398
410
  const scopedLogger = this.logger?.scoped("cron", task.name);
399
- Promise.resolve(task.handler(scopedLogger)).catch(err => {
411
+ Promise.resolve(task.handler(scopedLogger, this.createRunContext(task, scopedLogger))).catch(err => {
400
412
  console.error(`[Cron] Catch-up task "${task.name}" failed:`, err);
401
413
  });
402
414
 
@@ -424,6 +436,37 @@ class CronImpl implements Cron {
424
436
  this.timer = null;
425
437
  }
426
438
  }
439
+
440
+ private createRunContext(task: InternalCronTask, logger?: Logger): CronRunContext {
441
+ const emit = this.events
442
+ ? async (event: string, data?: Record<string, any>) => {
443
+ const payload = {
444
+ taskId: task.id,
445
+ name: task.name,
446
+ event,
447
+ data,
448
+ };
449
+
450
+ await this.events!.emit("cron.event", payload);
451
+ await this.events!.emit(`cron.${task.name}.event`, payload);
452
+ await this.events!.emit(`cron.${task.id}.event`, payload);
453
+ }
454
+ : undefined;
455
+
456
+ const log = logger
457
+ ? (level: LogLevel, message: string, data?: Record<string, any>) => {
458
+ logger[level](message, data);
459
+ }
460
+ : undefined;
461
+
462
+ return {
463
+ taskId: task.id,
464
+ name: task.name,
465
+ logger,
466
+ emit,
467
+ log,
468
+ };
469
+ }
427
470
  }
428
471
 
429
472
  export function createCron(config?: CronConfig): Cron {
package/src/core/index.ts CHANGED
@@ -32,6 +32,7 @@ export {
32
32
  export {
33
33
  type Cron,
34
34
  type CronTask,
35
+ type CronRunContext,
35
36
  type CronConfig,
36
37
  createCron,
37
38
  } from "./cron";
@@ -41,6 +42,7 @@ export {
41
42
  type Job,
42
43
  type JobStatus,
43
44
  type JobHandler,
45
+ type JobHandlerContext,
44
46
  type JobAdapter,
45
47
  type JobsConfig,
46
48
  type GetAllJobsOptions,
@@ -149,6 +151,7 @@ export {
149
151
  type PassStepDefinition,
150
152
  type RetryConfig,
151
153
  type GetAllWorkflowsOptions,
154
+ type PluginMetadata,
152
155
  WorkflowBuilder,
153
156
  MemoryWorkflowAdapter,
154
157
  workflow,
package/src/core/jobs.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // Supports both in-process handlers and external processes (Python, Go, Shell, etc.)
4
4
 
5
5
  import type { Events } from "./events";
6
- import type { Logger } from "./logger";
6
+ import type { Logger, LogLevel } from "./logger";
7
7
  import type {
8
8
  ExternalJobConfig,
9
9
  ExternalJob,
@@ -59,7 +59,13 @@ export interface Job {
59
59
  }
60
60
 
61
61
  export interface JobHandler<T = any, R = any> {
62
- (data: T, ctx?: { logger: Logger }): Promise<R>;
62
+ (data: T, ctx?: JobHandlerContext): Promise<R>;
63
+ }
64
+
65
+ export interface JobHandlerContext {
66
+ logger?: Logger;
67
+ emit?: (event: string, data?: Record<string, any>) => Promise<void>;
68
+ log?: (level: LogLevel, message: string, data?: Record<string, any>) => void;
63
69
  }
64
70
 
65
71
  /** Options for listing all jobs */
@@ -999,6 +1005,7 @@ class JobsImpl implements Jobs {
999
1005
  ): void {
1000
1006
  const decoder = new TextDecoder();
1001
1007
  const events = this.events;
1008
+ const scopedLogger = this.logger?.scoped("job", jobId);
1002
1009
 
1003
1010
  // Helper to stream a ReadableStream
1004
1011
  const streamOutput = async (
@@ -1028,6 +1035,10 @@ class JobsImpl implements Jobs {
1028
1035
  message: text.trim(),
1029
1036
  });
1030
1037
  }
1038
+
1039
+ if (scopedLogger) {
1040
+ scopedLogger[level](text.trim(), { external: true });
1041
+ }
1031
1042
  }
1032
1043
  } catch {
1033
1044
  // Stream may be closed
@@ -1067,7 +1078,18 @@ class JobsImpl implements Jobs {
1067
1078
 
1068
1079
  // Create scoped logger for this job execution
1069
1080
  const scopedLogger = this.logger?.scoped("job", job.id);
1070
- const result = await handler(job.data, scopedLogger ? { logger: scopedLogger } : undefined);
1081
+ const emit = this.createJobEmitter(job);
1082
+ const log = scopedLogger
1083
+ ? (level: LogLevel, message: string, data?: Record<string, any>) => {
1084
+ scopedLogger[level](message, data);
1085
+ }
1086
+ : undefined;
1087
+
1088
+ const result = await handler(job.data, {
1089
+ logger: scopedLogger,
1090
+ emit,
1091
+ log,
1092
+ });
1071
1093
 
1072
1094
  await this.adapter.update(job.id, {
1073
1095
  status: "completed",
@@ -1141,6 +1163,23 @@ class JobsImpl implements Jobs {
1141
1163
  this.activeJobs--;
1142
1164
  }
1143
1165
  }
1166
+
1167
+ private createJobEmitter(job: Job): JobHandlerContext["emit"] {
1168
+ const events = this.events;
1169
+ if (!events) return undefined;
1170
+ return async (event: string, data?: Record<string, any>) => {
1171
+ const payload = {
1172
+ jobId: job.id,
1173
+ name: job.name,
1174
+ event,
1175
+ data,
1176
+ };
1177
+
1178
+ await events.emit("job.event", payload);
1179
+ await events.emit(`job.${job.name}.event`, payload);
1180
+ await events.emit(`job.${job.id}.event`, payload);
1181
+ };
1182
+ }
1144
1183
  }
1145
1184
 
1146
1185
  export function createJobs(config?: JobsConfig): Jobs {
@@ -0,0 +1,241 @@
1
+ import { Kysely } from "kysely";
2
+ import { BunSqliteDialect } from "kysely-bun-sqlite";
3
+ import Database from "bun:sqlite";
4
+ import {
5
+ createLogger,
6
+ createCache,
7
+ createEvents,
8
+ createCron,
9
+ createJobs,
10
+ createSSE,
11
+ createRateLimiter,
12
+ createErrors,
13
+ createWorkflows,
14
+ createProcesses,
15
+ createAudit,
16
+ createWebSocket,
17
+ createStorage,
18
+ createLogs,
19
+ KyselyJobAdapter,
20
+ KyselyWorkflowAdapter,
21
+ MemoryAuditAdapter,
22
+ MemoryLogsAdapter,
23
+ } from "./index";
24
+ import { PluginManager, type CoreServices, type ConfiguredPlugin } from "../core";
25
+
26
+ export interface SubprocessPluginMetadata {
27
+ names: string[];
28
+ modulePaths: Record<string, string>;
29
+ configs: Record<string, any>;
30
+ }
31
+
32
+ export interface SubprocessBootstrapOptions {
33
+ dbPath: string;
34
+ coreConfig?: Record<string, any>;
35
+ pluginMetadata: SubprocessPluginMetadata;
36
+ startServices?: {
37
+ cron?: boolean;
38
+ jobs?: boolean;
39
+ workflows?: boolean;
40
+ processes?: boolean;
41
+ };
42
+ }
43
+
44
+ export interface SubprocessBootstrapResult {
45
+ core: CoreServices;
46
+ manager: PluginManager;
47
+ db: Kysely<any>;
48
+ workflowAdapter: KyselyWorkflowAdapter;
49
+ cleanup: () => Promise<void>;
50
+ }
51
+
52
+ export async function bootstrapSubprocess(
53
+ options: SubprocessBootstrapOptions
54
+ ): Promise<SubprocessBootstrapResult> {
55
+ const sqlite = new Database(options.dbPath);
56
+ sqlite.run("PRAGMA busy_timeout = 5000");
57
+
58
+ const db = new Kysely<any>({
59
+ dialect: new BunSqliteDialect({ database: sqlite }),
60
+ });
61
+
62
+ const cache = createCache();
63
+ const events = createEvents();
64
+ const sse = createSSE();
65
+ const rateLimiter = createRateLimiter();
66
+ const errors = createErrors();
67
+
68
+ const logs = createLogs({ adapter: new MemoryLogsAdapter(), events });
69
+ const logger = createLogger();
70
+
71
+ const cron = createCron({ logger, events });
72
+
73
+ const jobAdapter = new KyselyJobAdapter(db, { cleanupDays: 0 });
74
+ const workflowAdapter = new KyselyWorkflowAdapter(db, { cleanupDays: 0 });
75
+ const auditAdapter = new MemoryAuditAdapter();
76
+
77
+ const jobs = createJobs({
78
+ events,
79
+ logger,
80
+ adapter: jobAdapter,
81
+ persist: false,
82
+ });
83
+
84
+ const workflows = createWorkflows({
85
+ events,
86
+ jobs,
87
+ sse,
88
+ adapter: workflowAdapter,
89
+ });
90
+
91
+ const processes = createProcesses({ events, autoRecoverOrphans: false });
92
+ const audit = createAudit({ adapter: auditAdapter });
93
+ const websocket = createWebSocket();
94
+ const storage = createStorage();
95
+
96
+ const core: CoreServices = {
97
+ db,
98
+ config: options.coreConfig ?? {},
99
+ logger,
100
+ cache,
101
+ events,
102
+ cron,
103
+ jobs,
104
+ sse,
105
+ rateLimiter,
106
+ errors,
107
+ workflows,
108
+ processes,
109
+ audit,
110
+ websocket,
111
+ storage,
112
+ logs,
113
+ };
114
+
115
+ workflows.setCore(core);
116
+
117
+ const manager = new PluginManager(core);
118
+ const plugins = await loadConfiguredPlugins(options.pluginMetadata);
119
+
120
+ for (const plugin of plugins) {
121
+ manager.register(plugin);
122
+ }
123
+
124
+ await manager.init();
125
+ workflows.setPlugins(manager.getServices());
126
+
127
+ if (options.startServices?.cron) {
128
+ core.cron.start();
129
+ }
130
+ if (options.startServices?.jobs) {
131
+ core.jobs.start();
132
+ }
133
+ if (options.startServices?.workflows) {
134
+ await core.workflows.resolveDbPath();
135
+ await core.workflows.resume();
136
+ }
137
+ if (options.startServices?.processes) {
138
+ core.processes.start();
139
+ }
140
+
141
+ const cleanup = async () => {
142
+ await core.cron.stop();
143
+ await core.jobs.stop();
144
+ await core.workflows.stop();
145
+ await core.processes.shutdown();
146
+
147
+ if (typeof (logs as any).stop === "function") {
148
+ (logs as any).stop();
149
+ }
150
+
151
+ if (typeof (audit as any).stop === "function") {
152
+ (audit as any).stop();
153
+ }
154
+
155
+ await db.destroy();
156
+ sqlite.close();
157
+ };
158
+
159
+ return { core, manager, db, workflowAdapter, cleanup };
160
+ }
161
+
162
+ async function loadConfiguredPlugins(
163
+ metadata: SubprocessPluginMetadata
164
+ ): Promise<ConfiguredPlugin[]> {
165
+ const plugins: ConfiguredPlugin[] = [];
166
+
167
+ for (const name of metadata.names) {
168
+ const modulePath = metadata.modulePaths[name];
169
+ if (!modulePath) {
170
+ throw new Error(`Missing module path for plugin "${name}"`);
171
+ }
172
+
173
+ const module = await import(modulePath);
174
+ const config = metadata.configs?.[name];
175
+ const plugin = findPluginDefinition(module, name, config);
176
+
177
+ if (!plugin) {
178
+ throw new Error(
179
+ `Plugin "${name}" not found in module ${modulePath}. ` +
180
+ `Ensure the plugin is exported and its config is serializable.`
181
+ );
182
+ }
183
+
184
+ plugins.push(plugin);
185
+ }
186
+
187
+ return plugins;
188
+ }
189
+
190
+ function findPluginDefinition(
191
+ mod: any,
192
+ pluginName: string,
193
+ boundConfig?: any
194
+ ): ConfiguredPlugin | null {
195
+ for (const key of Object.keys(mod)) {
196
+ const exported = mod[key];
197
+ const direct = resolvePluginDefinition(exported, pluginName, boundConfig);
198
+ if (direct) return direct;
199
+ }
200
+
201
+ if (mod.default) {
202
+ const direct = resolvePluginDefinition(mod.default, pluginName, boundConfig);
203
+ if (direct) return direct;
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ function resolvePluginDefinition(
210
+ exported: any,
211
+ pluginName: string,
212
+ boundConfig?: any
213
+ ): ConfiguredPlugin | null {
214
+ if (!exported) return null;
215
+
216
+ if (
217
+ typeof exported === "object" &&
218
+ exported.name === pluginName &&
219
+ typeof exported.service === "function"
220
+ ) {
221
+ return exported as ConfiguredPlugin;
222
+ }
223
+
224
+ if (typeof exported === "function" && boundConfig !== undefined) {
225
+ try {
226
+ const result = exported(boundConfig);
227
+ if (
228
+ result &&
229
+ typeof result === "object" &&
230
+ result.name === pluginName &&
231
+ typeof result.service === "function"
232
+ ) {
233
+ return result as ConfiguredPlugin;
234
+ }
235
+ } catch {
236
+ return null;
237
+ }
238
+ }
239
+
240
+ return null;
241
+ }