@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 +18 -2
- package/src/core/cron.ts +95 -7
- package/src/core/workflows.ts +10 -0
- package/src/server.ts +40 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@donkeylabs/server",
|
|
3
|
-
"version": "2.0.
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
214
|
+
throw new Error("Could not find next run time within 4 years");
|
|
127
215
|
}
|
|
128
216
|
}
|
|
129
217
|
|
package/src/core/workflows.ts
CHANGED
|
@@ -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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
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
|
-
|
|
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>) => {
|