@donkeylabs/server 2.0.17 → 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/CLAUDE.md +22 -1
- package/docs/testing.md +267 -17
- package/package.json +18 -2
- package/src/core/cron.ts +95 -7
- package/src/core/workflows.ts +10 -0
- package/src/harness.ts +277 -0
- package/src/index.ts +8 -2
- package/src/server.ts +40 -26
package/CLAUDE.md
CHANGED
|
@@ -139,11 +139,31 @@ throw ctx.errors.BadRequest("Invalid input"); // 400
|
|
|
139
139
|
throw ctx.errors.Unauthorized("Login required"); // 401
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
+
## Testing
|
|
143
|
+
```ts
|
|
144
|
+
// Unit test plugins (no HTTP)
|
|
145
|
+
import { createTestHarness } from "@donkeylabs/server";
|
|
146
|
+
const { manager } = await createTestHarness(myPlugin);
|
|
147
|
+
|
|
148
|
+
// Integration test with HTTP (parallel-safe, unique ports)
|
|
149
|
+
import { createIntegrationHarness } from "@donkeylabs/server";
|
|
150
|
+
import { createApiClient } from "../lib/api";
|
|
151
|
+
|
|
152
|
+
const harness = await createIntegrationHarness({
|
|
153
|
+
routers: [usersRouter],
|
|
154
|
+
plugins: [usersPlugin],
|
|
155
|
+
});
|
|
156
|
+
const api = harness.createClient(createApiClient); // Fully typed!
|
|
157
|
+
await api.users.create({ name: "Test" });
|
|
158
|
+
await harness.shutdown();
|
|
159
|
+
```
|
|
160
|
+
|
|
142
161
|
## Commands
|
|
143
162
|
```sh
|
|
144
163
|
bun run dev # Dev server
|
|
145
164
|
bunx donkeylabs generate # Regen types after changes
|
|
146
165
|
bun --bun tsc --noEmit # Type check
|
|
166
|
+
bun test # Run tests
|
|
147
167
|
```
|
|
148
168
|
|
|
149
169
|
## MCP Tools
|
|
@@ -154,4 +174,5 @@ bun --bun tsc --noEmit # Type check
|
|
|
154
174
|
- Core: logger, cache, events, cron, jobs, external-jobs, processes, workflows, sse, rate-limiter, errors
|
|
155
175
|
- API: router, handlers, middleware
|
|
156
176
|
- Server: lifecycle-hooks, services (custom services)
|
|
157
|
-
-
|
|
177
|
+
- Testing: `createTestHarness` (unit), `createIntegrationHarness` (HTTP)
|
|
178
|
+
- Infrastructure: database, plugins, sveltekit-adapter, api-client
|
package/docs/testing.md
CHANGED
|
@@ -1,25 +1,61 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
This guide covers testing plugins and routes using the built-in test
|
|
3
|
+
This guide covers testing plugins and routes using the built-in test harnesses.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
|
-
- [
|
|
7
|
+
- [Quick Start](#quick-start)
|
|
8
|
+
- [Test Harnesses](#test-harnesses)
|
|
9
|
+
- [createTestHarness](#createtestharness) - Unit testing plugins
|
|
10
|
+
- [createIntegrationHarness](#createintegrationharness) - Full HTTP API testing
|
|
8
11
|
- [Unit Testing Plugins](#unit-testing-plugins)
|
|
9
|
-
- [Integration Testing](#integration-testing)
|
|
12
|
+
- [Integration Testing with HTTP](#integration-testing-with-http)
|
|
10
13
|
- [Testing Routes](#testing-routes)
|
|
14
|
+
- [Parallel Test Execution](#parallel-test-execution)
|
|
11
15
|
- [Mocking Core Services](#mocking-core-services)
|
|
12
16
|
- [Test Organization](#test-organization)
|
|
13
17
|
- [Running Tests](#running-tests)
|
|
14
18
|
|
|
15
19
|
---
|
|
16
20
|
|
|
17
|
-
##
|
|
21
|
+
## Quick Start
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
```ts
|
|
24
|
+
// Unit test (no HTTP, fast)
|
|
25
|
+
import { createTestHarness } from "@donkeylabs/server";
|
|
26
|
+
const { manager } = await createTestHarness(myPlugin);
|
|
27
|
+
const result = await manager.getServices().myPlugin.doSomething();
|
|
28
|
+
|
|
29
|
+
// Integration test (full HTTP server)
|
|
30
|
+
import { createIntegrationHarness } from "@donkeylabs/server";
|
|
31
|
+
import { createApiClient } from "../lib/api"; // Your generated client
|
|
32
|
+
|
|
33
|
+
const harness = await createIntegrationHarness({
|
|
34
|
+
routers: [myRouter],
|
|
35
|
+
plugins: [myPlugin],
|
|
36
|
+
});
|
|
37
|
+
const api = harness.createClient(createApiClient);
|
|
38
|
+
const user = await api.users.create({ name: "Test" }); // Fully typed!
|
|
39
|
+
await harness.shutdown();
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## Test Harnesses
|
|
45
|
+
|
|
46
|
+
DonkeyLabs provides two test harnesses for different testing needs:
|
|
47
|
+
|
|
48
|
+
| Harness | Use Case | HTTP Server | Speed |
|
|
49
|
+
|---------|----------|-------------|-------|
|
|
50
|
+
| `createTestHarness` | Unit testing plugins | No | Fast |
|
|
51
|
+
| `createIntegrationHarness` | Full API testing | Yes | Medium |
|
|
52
|
+
|
|
53
|
+
### createTestHarness
|
|
54
|
+
|
|
55
|
+
For unit testing plugin services without HTTP overhead. Creates an in-memory environment with SQLite, migrations, and all core services.
|
|
20
56
|
|
|
21
57
|
```ts
|
|
22
|
-
import { createTestHarness } from "@donkeylabs/server
|
|
58
|
+
import { createTestHarness } from "@donkeylabs/server";
|
|
23
59
|
import { myPlugin } from "./plugins/myPlugin";
|
|
24
60
|
|
|
25
61
|
const { manager, db, core } = await createTestHarness(myPlugin);
|
|
@@ -35,12 +71,12 @@ core.logger.info("Test log");
|
|
|
35
71
|
core.cache.set("key", "value");
|
|
36
72
|
```
|
|
37
73
|
|
|
38
|
-
|
|
74
|
+
#### With Dependencies
|
|
39
75
|
|
|
40
76
|
If your plugin depends on other plugins, pass them as the second argument:
|
|
41
77
|
|
|
42
78
|
```ts
|
|
43
|
-
import { createTestHarness } from "@donkeylabs/server
|
|
79
|
+
import { createTestHarness } from "@donkeylabs/server";
|
|
44
80
|
import { ordersPlugin } from "./plugins/orders";
|
|
45
81
|
import { usersPlugin } from "./plugins/users";
|
|
46
82
|
|
|
@@ -51,6 +87,65 @@ const orders = manager.getServices().orders;
|
|
|
51
87
|
const users = manager.getServices().users;
|
|
52
88
|
```
|
|
53
89
|
|
|
90
|
+
### createIntegrationHarness
|
|
91
|
+
|
|
92
|
+
For full HTTP API testing with your generated client. Starts a real HTTP server on a unique port for parallel test execution.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { createIntegrationHarness, type IntegrationHarnessResult } from "@donkeylabs/server";
|
|
96
|
+
import { createApiClient } from "../lib/api"; // Your generated client
|
|
97
|
+
import { usersRouter } from "../server/routes/users";
|
|
98
|
+
import { usersPlugin } from "../server/plugins/users";
|
|
99
|
+
|
|
100
|
+
describe("Users API", () => {
|
|
101
|
+
let harness: IntegrationHarnessResult;
|
|
102
|
+
let api: ReturnType<typeof createApiClient>;
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
harness = await createIntegrationHarness({
|
|
106
|
+
routers: [usersRouter],
|
|
107
|
+
plugins: [usersPlugin],
|
|
108
|
+
});
|
|
109
|
+
api = harness.createClient(createApiClient);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterAll(async () => {
|
|
113
|
+
await harness.shutdown();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("should create a user", async () => {
|
|
117
|
+
const user = await api.users.create({ name: "Test", email: "test@example.com" });
|
|
118
|
+
expect(user.id).toBeDefined();
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
#### Harness Result Properties
|
|
124
|
+
|
|
125
|
+
| Property | Type | Description |
|
|
126
|
+
|----------|------|-------------|
|
|
127
|
+
| `server` | `AppServer` | The running server instance |
|
|
128
|
+
| `baseUrl` | `string` | Base URL (e.g., `http://localhost:12345`) |
|
|
129
|
+
| `port` | `number` | Actual port the server is running on |
|
|
130
|
+
| `db` | `Kysely<any>` | Database instance for direct queries |
|
|
131
|
+
| `core` | `CoreServices` | Core services (logger, cache, etc.) |
|
|
132
|
+
| `plugins` | `Record<string, any>` | Plugin services |
|
|
133
|
+
| `client` | `TestApiClient` | Untyped client for quick testing |
|
|
134
|
+
| `createClient` | `<T>(factory) => T` | Create typed client from your factory |
|
|
135
|
+
| `shutdown` | `() => Promise<void>` | Cleanup function |
|
|
136
|
+
|
|
137
|
+
#### Configuration Options
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
interface IntegrationHarnessOptions {
|
|
141
|
+
routers?: IRouter[]; // Routers to register
|
|
142
|
+
plugins?: Plugin[]; // Plugins to register
|
|
143
|
+
port?: number; // Starting port (default: random 10000-60000)
|
|
144
|
+
maxPortAttempts?: number; // Retry attempts (default: 10)
|
|
145
|
+
logLevel?: "debug" | "info" | "warn" | "error"; // Default: "error"
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
54
149
|
---
|
|
55
150
|
|
|
56
151
|
## Unit Testing Plugins
|
|
@@ -138,9 +233,93 @@ describe("usersPlugin", () => {
|
|
|
138
233
|
|
|
139
234
|
---
|
|
140
235
|
|
|
141
|
-
## Integration Testing
|
|
236
|
+
## Integration Testing with HTTP
|
|
237
|
+
|
|
238
|
+
Use `createIntegrationHarness` for full end-to-end API testing with your generated client.
|
|
239
|
+
|
|
240
|
+
### Basic Example
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
244
|
+
import { createIntegrationHarness, type IntegrationHarnessResult } from "@donkeylabs/server";
|
|
245
|
+
import { createApiClient } from "../lib/api";
|
|
246
|
+
import { usersRouter } from "../server/routes/users";
|
|
247
|
+
|
|
248
|
+
describe("Users API", () => {
|
|
249
|
+
let harness: IntegrationHarnessResult;
|
|
250
|
+
let api: ReturnType<typeof createApiClient>;
|
|
251
|
+
|
|
252
|
+
beforeAll(async () => {
|
|
253
|
+
harness = await createIntegrationHarness({
|
|
254
|
+
routers: [usersRouter],
|
|
255
|
+
});
|
|
256
|
+
api = harness.createClient(createApiClient);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
afterAll(async () => {
|
|
260
|
+
await harness.shutdown();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("should create and retrieve a user", async () => {
|
|
264
|
+
// Create
|
|
265
|
+
const created = await api.users.create({
|
|
266
|
+
name: "Test User",
|
|
267
|
+
email: "test@example.com",
|
|
268
|
+
});
|
|
269
|
+
expect(created.id).toBeDefined();
|
|
270
|
+
|
|
271
|
+
// Retrieve
|
|
272
|
+
const user = await api.users.get({ id: created.id });
|
|
273
|
+
expect(user.name).toBe("Test User");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it("should list users", async () => {
|
|
277
|
+
const result = await api.users.list({});
|
|
278
|
+
expect(result.users.length).toBeGreaterThan(0);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### With Plugins
|
|
284
|
+
|
|
285
|
+
```ts
|
|
286
|
+
import { usersPlugin } from "../server/plugins/users";
|
|
287
|
+
import { ordersPlugin } from "../server/plugins/orders";
|
|
288
|
+
import { ordersRouter } from "../server/routes/orders";
|
|
289
|
+
|
|
290
|
+
const harness = await createIntegrationHarness({
|
|
291
|
+
routers: [ordersRouter],
|
|
292
|
+
plugins: [usersPlugin, ordersPlugin],
|
|
293
|
+
});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
### Accessing Services Directly
|
|
297
|
+
|
|
298
|
+
You can access plugin services and core services for test setup:
|
|
299
|
+
|
|
300
|
+
```ts
|
|
301
|
+
it("should process order for existing user", async () => {
|
|
302
|
+
// Use plugin service directly to create test data
|
|
303
|
+
const user = await harness.plugins.users.create({
|
|
304
|
+
email: "buyer@example.com",
|
|
305
|
+
name: "Buyer",
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Test via API
|
|
309
|
+
const order = await api.orders.create({
|
|
310
|
+
userId: user.id,
|
|
311
|
+
items: [{ productId: 1, quantity: 2 }],
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
expect(order.status).toBe("pending");
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## Integration Testing (Plugin-Level)
|
|
142
321
|
|
|
143
|
-
Integration tests verify multiple plugins working together.
|
|
322
|
+
Integration tests verify multiple plugins working together without HTTP.
|
|
144
323
|
|
|
145
324
|
```ts
|
|
146
325
|
// tests/checkout.integ.test.ts
|
|
@@ -419,12 +598,83 @@ jobs:
|
|
|
419
598
|
|
|
420
599
|
---
|
|
421
600
|
|
|
601
|
+
## Parallel Test Execution
|
|
602
|
+
|
|
603
|
+
`createIntegrationHarness` is designed for parallel test execution:
|
|
604
|
+
|
|
605
|
+
1. **Unique ports** - Each harness gets a random port (10000-60000 range)
|
|
606
|
+
2. **Automatic retry** - If port is in use, tries next port (up to 10 attempts)
|
|
607
|
+
3. **In-memory isolation** - Each test has its own SQLite `:memory:` database
|
|
608
|
+
4. **No file conflicts** - Type generation is disabled during tests
|
|
609
|
+
|
|
610
|
+
### Example: Parallel Test Suites
|
|
611
|
+
|
|
612
|
+
```ts
|
|
613
|
+
// users.test.ts
|
|
614
|
+
describe("Users API", () => {
|
|
615
|
+
let harness: IntegrationHarnessResult;
|
|
616
|
+
|
|
617
|
+
beforeAll(async () => {
|
|
618
|
+
harness = await createIntegrationHarness({ routers: [usersRouter] });
|
|
619
|
+
});
|
|
620
|
+
afterAll(() => harness.shutdown());
|
|
621
|
+
|
|
622
|
+
it("creates users", async () => {
|
|
623
|
+
// This test runs on port 12345 (random)
|
|
624
|
+
});
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
// orders.test.ts (runs in parallel)
|
|
628
|
+
describe("Orders API", () => {
|
|
629
|
+
let harness: IntegrationHarnessResult;
|
|
630
|
+
|
|
631
|
+
beforeAll(async () => {
|
|
632
|
+
harness = await createIntegrationHarness({ routers: [ordersRouter] });
|
|
633
|
+
});
|
|
634
|
+
afterAll(() => harness.shutdown());
|
|
635
|
+
|
|
636
|
+
it("creates orders", async () => {
|
|
637
|
+
// This test runs on port 54321 (different random port)
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
```
|
|
641
|
+
|
|
642
|
+
Both test files run simultaneously without conflicts.
|
|
643
|
+
|
|
644
|
+
### Why Use the Generated Client?
|
|
645
|
+
|
|
646
|
+
The generated client (`lib/api.ts`) provides:
|
|
647
|
+
- Full TypeScript types for all routes
|
|
648
|
+
- IDE autocomplete while writing tests
|
|
649
|
+
- Compile-time error checking
|
|
650
|
+
|
|
651
|
+
The client is generated once during development (`bunx donkeylabs generate`) and imported in tests. No regeneration happens at test runtime.
|
|
652
|
+
|
|
653
|
+
---
|
|
654
|
+
|
|
422
655
|
## Best Practices
|
|
423
656
|
|
|
424
|
-
1. **
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
657
|
+
1. **Choose the right harness**:
|
|
658
|
+
- `createTestHarness` for unit tests (fast, no HTTP)
|
|
659
|
+
- `createIntegrationHarness` for API tests (full HTTP, typed client)
|
|
660
|
+
|
|
661
|
+
2. **Use fresh harness per test suite** - Create harness in `beforeAll`, shutdown in `afterAll`
|
|
662
|
+
|
|
663
|
+
3. **Always call shutdown** - Prevent port leaks and hanging tests:
|
|
664
|
+
```ts
|
|
665
|
+
afterAll(() => harness.shutdown());
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
4. **Use your generated client** - Import from `lib/api.ts` for full type safety
|
|
669
|
+
|
|
670
|
+
5. **Test the public API** - Focus on testing service methods, not internal implementation
|
|
671
|
+
|
|
672
|
+
6. **Use realistic data** - Create test data that resembles production data
|
|
673
|
+
|
|
674
|
+
7. **Test edge cases** - Empty inputs, null values, boundary conditions
|
|
675
|
+
|
|
676
|
+
8. **Test error cases** - Verify proper error throwing and handling
|
|
677
|
+
|
|
678
|
+
9. **Keep tests fast** - In-memory SQLite is fast; avoid unnecessary delays
|
|
679
|
+
|
|
680
|
+
10. **Run type checks** - Always run `tsc --noEmit` before committing
|
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/harness.ts
CHANGED
|
@@ -20,6 +20,9 @@ import {
|
|
|
20
20
|
KyselyWorkflowAdapter,
|
|
21
21
|
MemoryAuditAdapter,
|
|
22
22
|
} from "./core/index";
|
|
23
|
+
import { AppServer, type ServerConfig } from "./server";
|
|
24
|
+
import type { IRouter, RouteDefinition } from "./router";
|
|
25
|
+
import { ApiClientBase, type ApiClientOptions } from "./client/base";
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
28
|
* Creates a fully functional (in-memory) testing environment for a plugin.
|
|
@@ -104,3 +107,277 @@ export async function createTestHarness(targetPlugin: Plugin, dependencies: Plug
|
|
|
104
107
|
core
|
|
105
108
|
};
|
|
106
109
|
}
|
|
110
|
+
|
|
111
|
+
// =============================================================================
|
|
112
|
+
// INTEGRATION TEST HARNESS
|
|
113
|
+
// =============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Configuration for the integration test harness.
|
|
117
|
+
*/
|
|
118
|
+
export interface IntegrationHarnessOptions {
|
|
119
|
+
/** Routers to register with the server */
|
|
120
|
+
routers?: IRouter[];
|
|
121
|
+
/** Plugins to register with the server */
|
|
122
|
+
plugins?: Plugin[];
|
|
123
|
+
/** Starting port range (default: 10000-60000 random) */
|
|
124
|
+
port?: number;
|
|
125
|
+
/** Maximum port retry attempts (default: 10) */
|
|
126
|
+
maxPortAttempts?: number;
|
|
127
|
+
/** Logger level (default: "error" for quiet tests) */
|
|
128
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A dynamic API client built from router definitions.
|
|
133
|
+
* Provides an untyped but convenient way to call routes.
|
|
134
|
+
*/
|
|
135
|
+
export class TestApiClient extends ApiClientBase {
|
|
136
|
+
private routeMap: Map<string, RouteDefinition>;
|
|
137
|
+
|
|
138
|
+
constructor(baseUrl: string, routers: IRouter[]) {
|
|
139
|
+
super(baseUrl);
|
|
140
|
+
this.routeMap = new Map();
|
|
141
|
+
for (const router of routers) {
|
|
142
|
+
for (const route of router.getRoutes()) {
|
|
143
|
+
this.routeMap.set(route.name, route);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Call any route by name with the given input.
|
|
150
|
+
* Convenient for quick testing without generated types.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* const user = await client.call("users.create", { name: "Test", email: "test@example.com" });
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
async call<TOutput = any>(route: string, input: any = {}): Promise<TOutput> {
|
|
158
|
+
const routeDef = this.routeMap.get(route);
|
|
159
|
+
if (!routeDef) {
|
|
160
|
+
throw new Error(`Route not found: ${route}. Available routes: ${[...this.routeMap.keys()].join(", ")}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Handle different handler types
|
|
164
|
+
if (routeDef.handler === "typed" || routeDef.handler === "formData") {
|
|
165
|
+
return this.request(route, input);
|
|
166
|
+
} else if (routeDef.handler === "stream" || routeDef.handler === "html") {
|
|
167
|
+
const response = await this.rawRequest(route, {
|
|
168
|
+
method: "POST",
|
|
169
|
+
headers: { "Content-Type": "application/json" },
|
|
170
|
+
body: JSON.stringify(input),
|
|
171
|
+
});
|
|
172
|
+
return response as any;
|
|
173
|
+
} else if (routeDef.handler === "raw") {
|
|
174
|
+
const response = await this.rawRequest(route);
|
|
175
|
+
return response as any;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return this.request(route, input);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get a raw Response for stream/file routes.
|
|
183
|
+
*/
|
|
184
|
+
async stream(route: string, input: any = {}): Promise<Response> {
|
|
185
|
+
return this.rawRequest(route, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "Content-Type": "application/json" },
|
|
188
|
+
body: JSON.stringify(input),
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* List all available routes.
|
|
194
|
+
*/
|
|
195
|
+
routes(): string[] {
|
|
196
|
+
return [...this.routeMap.keys()];
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Result from createIntegrationHarness.
|
|
202
|
+
*/
|
|
203
|
+
export interface IntegrationHarnessResult {
|
|
204
|
+
/** The running AppServer instance */
|
|
205
|
+
server: AppServer;
|
|
206
|
+
/** Base URL for API calls (e.g., "http://localhost:12345") */
|
|
207
|
+
baseUrl: string;
|
|
208
|
+
/** The actual port the server is running on */
|
|
209
|
+
port: number;
|
|
210
|
+
/** Database instance */
|
|
211
|
+
db: Kysely<any>;
|
|
212
|
+
/** Core services */
|
|
213
|
+
core: CoreServices;
|
|
214
|
+
/** Plugin services (after initialization) */
|
|
215
|
+
plugins: Record<string, any>;
|
|
216
|
+
/**
|
|
217
|
+
* Untyped test client for quick testing.
|
|
218
|
+
* Use `client.call("route.name", input)` to call any route.
|
|
219
|
+
*
|
|
220
|
+
* @example
|
|
221
|
+
* ```ts
|
|
222
|
+
* const user = await harness.client.call("users.create", { name: "Test" });
|
|
223
|
+
* ```
|
|
224
|
+
*/
|
|
225
|
+
client: TestApiClient;
|
|
226
|
+
/**
|
|
227
|
+
* Create a typed client using your generated client factory.
|
|
228
|
+
* Pass your `createApiClient` function to get full type safety.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* ```ts
|
|
232
|
+
* import { createApiClient } from "../lib/api";
|
|
233
|
+
* const api = harness.createClient(createApiClient);
|
|
234
|
+
* const user = await api.users.create({ name: "Test" }); // Fully typed!
|
|
235
|
+
* ```
|
|
236
|
+
*/
|
|
237
|
+
createClient: <T>(factory: (config: { baseUrl: string }) => T) => T;
|
|
238
|
+
/** Shutdown function - call this in afterAll/afterEach */
|
|
239
|
+
shutdown: () => Promise<void>;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Track used ports across test files running in parallel
|
|
243
|
+
const usedPorts = new Set<number>();
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get a unique starting port for parallel test execution.
|
|
247
|
+
* Uses random port in range 10000-60000 and tracks to avoid collisions.
|
|
248
|
+
*/
|
|
249
|
+
function getUniquePort(): number {
|
|
250
|
+
const minPort = 10000;
|
|
251
|
+
const maxPort = 60000;
|
|
252
|
+
let port: number;
|
|
253
|
+
let attempts = 0;
|
|
254
|
+
const maxAttempts = 100;
|
|
255
|
+
|
|
256
|
+
do {
|
|
257
|
+
port = minPort + Math.floor(Math.random() * (maxPort - minPort));
|
|
258
|
+
attempts++;
|
|
259
|
+
} while (usedPorts.has(port) && attempts < maxAttempts);
|
|
260
|
+
|
|
261
|
+
usedPorts.add(port);
|
|
262
|
+
return port;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Creates a full integration testing environment with a real HTTP server.
|
|
267
|
+
*
|
|
268
|
+
* Use this when you need to test API routes end-to-end with the generated
|
|
269
|
+
* API client. The server runs in-memory (SQLite :memory:) and uses a random
|
|
270
|
+
* port for parallel test execution.
|
|
271
|
+
*
|
|
272
|
+
* @example
|
|
273
|
+
* ```ts
|
|
274
|
+
* import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
275
|
+
* import { createIntegrationHarness } from "@donkeylabs/server";
|
|
276
|
+
* import { createApiClient } from "../lib/api"; // Your generated client
|
|
277
|
+
* import { usersRouter } from "../server/routes/users";
|
|
278
|
+
* import { usersPlugin } from "../server/plugins/users";
|
|
279
|
+
*
|
|
280
|
+
* describe("Users API", () => {
|
|
281
|
+
* let harness: Awaited<ReturnType<typeof createIntegrationHarness>>;
|
|
282
|
+
* let api: ReturnType<typeof createApiClient>;
|
|
283
|
+
*
|
|
284
|
+
* beforeAll(async () => {
|
|
285
|
+
* harness = await createIntegrationHarness({
|
|
286
|
+
* routers: [usersRouter],
|
|
287
|
+
* plugins: [usersPlugin],
|
|
288
|
+
* });
|
|
289
|
+
* api = createApiClient({ baseUrl: harness.baseUrl });
|
|
290
|
+
* });
|
|
291
|
+
*
|
|
292
|
+
* afterAll(async () => {
|
|
293
|
+
* await harness.shutdown();
|
|
294
|
+
* });
|
|
295
|
+
*
|
|
296
|
+
* it("should create a user", async () => {
|
|
297
|
+
* const user = await api.users.create({ name: "Test", email: "test@example.com" });
|
|
298
|
+
* expect(user.id).toBeDefined();
|
|
299
|
+
* });
|
|
300
|
+
*
|
|
301
|
+
* it("should list users", async () => {
|
|
302
|
+
* const result = await api.users.list({});
|
|
303
|
+
* expect(result.users.length).toBeGreaterThan(0);
|
|
304
|
+
* });
|
|
305
|
+
* });
|
|
306
|
+
* ```
|
|
307
|
+
*/
|
|
308
|
+
export async function createIntegrationHarness(
|
|
309
|
+
options: IntegrationHarnessOptions = {}
|
|
310
|
+
): Promise<IntegrationHarnessResult> {
|
|
311
|
+
const {
|
|
312
|
+
routers = [],
|
|
313
|
+
plugins = [],
|
|
314
|
+
port = getUniquePort(),
|
|
315
|
+
maxPortAttempts = 10,
|
|
316
|
+
logLevel = "error",
|
|
317
|
+
} = options;
|
|
318
|
+
|
|
319
|
+
// 1. Setup In-Memory DB
|
|
320
|
+
const db = new Kysely<any>({
|
|
321
|
+
dialect: new BunSqliteDialect({ database: new Database(":memory:") }),
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// 2. Create server with test configuration
|
|
325
|
+
const server = new AppServer({
|
|
326
|
+
db,
|
|
327
|
+
port,
|
|
328
|
+
maxPortAttempts,
|
|
329
|
+
logger: { level: logLevel },
|
|
330
|
+
// Disable file generation in tests
|
|
331
|
+
generateTypes: undefined,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// 3. Register plugins
|
|
335
|
+
for (const plugin of plugins) {
|
|
336
|
+
server.registerPlugin(plugin);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// 4. Register routers
|
|
340
|
+
for (const router of routers) {
|
|
341
|
+
server.use(router);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// 5. Start the server
|
|
345
|
+
await server.start();
|
|
346
|
+
|
|
347
|
+
// Get the actual port (may have changed if initial was in use)
|
|
348
|
+
const actualPort = (server as any).port as number;
|
|
349
|
+
const baseUrl = `http://localhost:${actualPort}`;
|
|
350
|
+
|
|
351
|
+
// Track this port as used
|
|
352
|
+
usedPorts.add(actualPort);
|
|
353
|
+
|
|
354
|
+
// Get core services and plugins for direct access in tests
|
|
355
|
+
const core = (server as any).coreServices as CoreServices;
|
|
356
|
+
const pluginServices = (server as any).manager.getServices();
|
|
357
|
+
|
|
358
|
+
// Create untyped test client
|
|
359
|
+
const client = new TestApiClient(baseUrl, routers);
|
|
360
|
+
|
|
361
|
+
// Factory for typed clients
|
|
362
|
+
const createClient = <T>(factory: (config: { baseUrl: string }) => T): T => {
|
|
363
|
+
return factory({ baseUrl });
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
// Shutdown function
|
|
367
|
+
const shutdown = async () => {
|
|
368
|
+
await server.shutdown();
|
|
369
|
+
usedPorts.delete(actualPort);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
server,
|
|
374
|
+
baseUrl,
|
|
375
|
+
port: actualPort,
|
|
376
|
+
db,
|
|
377
|
+
core,
|
|
378
|
+
plugins: pluginServices,
|
|
379
|
+
client,
|
|
380
|
+
createClient,
|
|
381
|
+
shutdown,
|
|
382
|
+
};
|
|
383
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -121,5 +121,11 @@ export {
|
|
|
121
121
|
createAdminRouter,
|
|
122
122
|
} from "./admin";
|
|
123
123
|
|
|
124
|
-
// Test Harness - for plugin testing
|
|
125
|
-
export {
|
|
124
|
+
// Test Harness - for plugin and integration testing
|
|
125
|
+
export {
|
|
126
|
+
createTestHarness,
|
|
127
|
+
createIntegrationHarness,
|
|
128
|
+
TestApiClient,
|
|
129
|
+
type IntegrationHarnessOptions,
|
|
130
|
+
type IntegrationHarnessResult,
|
|
131
|
+
} from "./harness";
|
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>) => {
|