@donkeylabs/server 2.0.18 → 2.0.19

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@donkeylabs/server",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
4
4
  "type": "module",
5
5
  "description": "Type-safe plugin system for building RPC-style APIs with Bun",
6
6
  "main": "./src/index.ts",
@@ -30,6 +30,10 @@
30
30
  "types": "./src/process-client.ts",
31
31
  "import": "./src/process-client.ts"
32
32
  },
33
+ "./testing": {
34
+ "types": "./src/testing/index.ts",
35
+ "import": "./src/testing/index.ts"
36
+ },
33
37
  "./context": {
34
38
  "types": "./context.d.ts"
35
39
  },
@@ -68,7 +72,10 @@
68
72
  "kysely": "^0.27.0 || ^0.28.0",
69
73
  "zod": "^3.20.0",
70
74
  "@aws-sdk/client-s3": "^3.0.0",
71
- "@aws-sdk/s3-request-presigner": "^3.0.0"
75
+ "@aws-sdk/s3-request-presigner": "^3.0.0",
76
+ "@playwright/test": "^1.40.0",
77
+ "pg": "^8.0.0",
78
+ "mysql2": "^3.0.0"
72
79
  },
73
80
  "peerDependenciesMeta": {
74
81
  "@aws-sdk/client-s3": {
@@ -76,6 +83,15 @@
76
83
  },
77
84
  "@aws-sdk/s3-request-presigner": {
78
85
  "optional": true
86
+ },
87
+ "@playwright/test": {
88
+ "optional": true
89
+ },
90
+ "pg": {
91
+ "optional": true
92
+ },
93
+ "mysql2": {
94
+ "optional": true
79
95
  }
80
96
  },
81
97
  "dependencies": {
package/src/core/cron.ts CHANGED
@@ -109,21 +109,109 @@ class CronExpression {
109
109
  );
110
110
  }
111
111
 
112
+ /**
113
+ * Get the next run time using an optimized jump algorithm.
114
+ * Instead of iterating second-by-second (which could be 31M iterations),
115
+ * this jumps directly to the next valid value for each field.
116
+ */
112
117
  getNextRun(from: Date = new Date()): Date {
113
118
  const next = new Date(from);
114
119
  next.setMilliseconds(0);
115
120
  next.setSeconds(next.getSeconds() + 1);
116
121
 
117
- // Search up to 1 year ahead
118
- const maxIterations = 366 * 24 * 60 * 60;
119
- for (let i = 0; i < maxIterations; i++) {
120
- if (this.matches(next)) {
121
- return next;
122
+ const [seconds, minutes, hours, daysOfMonth, months, daysOfWeek] = this.fields;
123
+
124
+ // Maximum iterations to prevent infinite loops (covers 4 years to handle leap years)
125
+ const maxYearIterations = 4;
126
+ const startYear = next.getFullYear();
127
+
128
+ // Iterate through potential dates (worst case: a few hundred iterations)
129
+ for (let yearOffset = 0; yearOffset <= maxYearIterations; yearOffset++) {
130
+ // Try each valid month
131
+ for (const month of months) {
132
+ const targetMonth = month - 1; // JS months are 0-indexed
133
+
134
+ // Skip months in the past
135
+ if (next.getFullYear() === startYear + yearOffset) {
136
+ if (targetMonth < next.getMonth()) continue;
137
+ }
138
+
139
+ // Set to this month
140
+ if (targetMonth !== next.getMonth() || next.getFullYear() !== startYear + yearOffset) {
141
+ next.setFullYear(startYear + yearOffset, targetMonth, 1);
142
+ next.setHours(0, 0, 0, 0);
143
+ }
144
+
145
+ // Get days in this month
146
+ const daysInMonth = new Date(next.getFullYear(), targetMonth + 1, 0).getDate();
147
+
148
+ // Try each valid day of month
149
+ for (const dayOfMonth of daysOfMonth) {
150
+ if (dayOfMonth > daysInMonth) continue; // Skip invalid days for this month
151
+
152
+ // Check if this day matches day-of-week constraint
153
+ const testDate = new Date(next.getFullYear(), targetMonth, dayOfMonth);
154
+ const dayOfWeek = testDate.getDay();
155
+ if (!daysOfWeek.includes(dayOfWeek)) continue;
156
+
157
+ // Skip days in the past
158
+ if (testDate < new Date(from.getFullYear(), from.getMonth(), from.getDate())) continue;
159
+
160
+ // Set to this day
161
+ if (dayOfMonth !== next.getDate()) {
162
+ next.setDate(dayOfMonth);
163
+ next.setHours(0, 0, 0, 0);
164
+ }
165
+
166
+ // Try each valid hour
167
+ for (const hour of hours) {
168
+ // Skip hours in the past for today
169
+ if (next.getFullYear() === from.getFullYear() &&
170
+ next.getMonth() === from.getMonth() &&
171
+ next.getDate() === from.getDate() &&
172
+ hour < from.getHours()) continue;
173
+
174
+ if (hour !== next.getHours()) {
175
+ next.setHours(hour, 0, 0, 0);
176
+ }
177
+
178
+ // Try each valid minute
179
+ for (const minute of minutes) {
180
+ // Skip minutes in the past for this hour
181
+ if (next.getFullYear() === from.getFullYear() &&
182
+ next.getMonth() === from.getMonth() &&
183
+ next.getDate() === from.getDate() &&
184
+ next.getHours() === from.getHours() &&
185
+ minute < from.getMinutes()) continue;
186
+
187
+ if (minute !== next.getMinutes()) {
188
+ next.setMinutes(minute, 0, 0);
189
+ }
190
+
191
+ // Try each valid second
192
+ for (const second of seconds) {
193
+ // Skip seconds in the past for this minute
194
+ if (next.getFullYear() === from.getFullYear() &&
195
+ next.getMonth() === from.getMonth() &&
196
+ next.getDate() === from.getDate() &&
197
+ next.getHours() === from.getHours() &&
198
+ next.getMinutes() === from.getMinutes() &&
199
+ second <= from.getSeconds()) continue;
200
+
201
+ next.setSeconds(second);
202
+
203
+ // Verify the date is still valid (handles edge cases like month rollover)
204
+ if (next > from && this.matches(next)) {
205
+ return next;
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
122
211
  }
123
- next.setSeconds(next.getSeconds() + 1);
124
212
  }
125
213
 
126
- throw new Error("Could not find next run time within 1 year");
214
+ throw new Error("Could not find next run time within 4 years");
127
215
  }
128
216
  }
129
217
 
@@ -195,6 +195,8 @@ export interface WorkflowContext {
195
195
  getStepResult<T = any>(stepName: string): T | undefined;
196
196
  /** Core services (logger, events, cache, etc.) */
197
197
  core: CoreServices;
198
+ /** Plugin services - available for business logic in workflow handlers */
199
+ plugins: Record<string, any>;
198
200
  }
199
201
 
200
202
  // ============================================
@@ -558,6 +560,8 @@ export interface Workflows {
558
560
  stop(): Promise<void>;
559
561
  /** Set core services (called after initialization to resolve circular dependency) */
560
562
  setCore(core: CoreServices): void;
563
+ /** Set plugin services (called after plugins are initialized) */
564
+ setPlugins(plugins: Record<string, any>): void;
561
565
  }
562
566
 
563
567
  // ============================================
@@ -570,6 +574,7 @@ class WorkflowsImpl implements Workflows {
570
574
  private jobs?: Jobs;
571
575
  private sse?: SSE;
572
576
  private core?: CoreServices;
577
+ private plugins: Record<string, any> = {};
573
578
  private definitions = new Map<string, WorkflowDefinition>();
574
579
  private running = new Map<string, { timeout?: ReturnType<typeof setTimeout> }>();
575
580
  private pollInterval: number;
@@ -587,6 +592,10 @@ class WorkflowsImpl implements Workflows {
587
592
  this.core = core;
588
593
  }
589
594
 
595
+ setPlugins(plugins: Record<string, any>): void {
596
+ this.plugins = plugins;
597
+ }
598
+
590
599
  register(definition: WorkflowDefinition): void {
591
600
  if (this.definitions.has(definition.name)) {
592
601
  throw new Error(`Workflow "${definition.name}" is already registered`);
@@ -1150,6 +1159,7 @@ class WorkflowsImpl implements Workflows {
1150
1159
  return steps[stepName] as T | undefined;
1151
1160
  },
1152
1161
  core: this.core!,
1162
+ plugins: this.plugins,
1153
1163
  };
1154
1164
  }
1155
1165
 
package/src/server.ts CHANGED
@@ -205,6 +205,8 @@ export class AppServer {
205
205
  private shutdownHandlers: OnShutdownHandler[] = [];
206
206
  private errorHandlers: OnErrorHandler[] = [];
207
207
  private isShuttingDown = false;
208
+ private isInitialized = false;
209
+ private initializationPromise: Promise<void> | null = null;
208
210
  private generateModeSetup = false;
209
211
 
210
212
  // Custom services registry
@@ -955,6 +957,27 @@ ${factoryFunction}
955
957
  process.exit(0);
956
958
  }
957
959
 
960
+ // Guard against multiple initializations using promise-based mutex
961
+ // This prevents race conditions when multiple requests arrive concurrently
962
+ if (this.isInitialized) {
963
+ this.coreServices.logger.debug("Server already initialized, skipping");
964
+ return;
965
+ }
966
+ if (this.initializationPromise) {
967
+ this.coreServices.logger.debug("Server initialization in progress, waiting...");
968
+ await this.initializationPromise;
969
+ return;
970
+ }
971
+
972
+ // Create the initialization promise - all concurrent callers will await this same promise
973
+ this.initializationPromise = this.doInitialize();
974
+ await this.initializationPromise;
975
+ }
976
+
977
+ /**
978
+ * Internal initialization logic - only called once via the promise mutex
979
+ */
980
+ private async doInitialize(): Promise<void> {
958
981
  const { logger } = this.coreServices;
959
982
 
960
983
  // Auto-generate types in dev mode if configured
@@ -963,6 +986,11 @@ ${factoryFunction}
963
986
  await this.manager.migrate();
964
987
  await this.manager.init();
965
988
 
989
+ // Pass plugins to workflows so handlers can access ctx.plugins
990
+ this.coreServices.workflows.setPlugins(this.manager.getServices());
991
+
992
+ this.isInitialized = true;
993
+
966
994
  this.coreServices.cron.start();
967
995
  this.coreServices.jobs.start();
968
996
  await this.coreServices.workflows.resume();
@@ -1185,34 +1213,20 @@ ${factoryFunction}
1185
1213
  process.exit(0);
1186
1214
  }
1187
1215
 
1188
- const { logger } = this.coreServices;
1189
-
1190
- // Auto-generate types in dev mode if configured
1191
- await this.generateTypes();
1192
-
1193
- // 1. Run migrations
1194
- await this.manager.migrate();
1195
-
1196
- // 2. Initialize plugins
1197
- await this.manager.init();
1198
-
1199
- // 3. Start background services
1200
- this.coreServices.cron.start();
1201
- this.coreServices.jobs.start();
1202
- await this.coreServices.workflows.resume();
1203
- this.coreServices.processes.start();
1204
- logger.info("Background services started (cron, jobs, workflows, processes)");
1205
-
1206
- // 4. Build route map
1207
- for (const router of this.routers) {
1208
- for (const route of router.getRoutes()) {
1209
- if (this.routeMap.has(route.name)) {
1210
- logger.warn(`Duplicate route detected`, { route: route.name });
1211
- }
1212
- this.routeMap.set(route.name, route);
1216
+ // Guard against multiple initializations using promise-based mutex
1217
+ // This prevents race conditions when multiple requests arrive concurrently
1218
+ if (!this.isInitialized) {
1219
+ if (this.initializationPromise) {
1220
+ this.coreServices.logger.debug("Server initialization in progress, waiting...");
1221
+ await this.initializationPromise;
1222
+ } else {
1223
+ // Create the initialization promise - all concurrent callers will await this same promise
1224
+ this.initializationPromise = this.doInitialize();
1225
+ await this.initializationPromise;
1213
1226
  }
1214
1227
  }
1215
- logger.info(`Loaded ${this.routeMap.size} RPC routes`);
1228
+
1229
+ const { logger } = this.coreServices;
1216
1230
 
1217
1231
  // 5. Start HTTP server with port retry logic
1218
1232
  const fetchHandler = async (req: Request, server: ReturnType<typeof Bun.serve>) => {