spiceflow 1.17.3 → 1.17.5
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/README.md +26 -18
- package/dist/client/types.d.ts +1 -1
- package/dist/client/types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/middleware.test.js +191 -0
- package/dist/middleware.test.js.map +1 -1
- package/dist/openapi.test.js +0 -1
- package/dist/openapi.test.js.map +1 -1
- package/dist/prevent-process-exit-if-busy.d.ts +15 -0
- package/dist/prevent-process-exit-if-busy.d.ts.map +1 -0
- package/dist/prevent-process-exit-if-busy.js +54 -0
- package/dist/prevent-process-exit-if-busy.js.map +1 -0
- package/dist/spiceflow.test.js +2 -2
- package/dist/spiceflow.test.js.map +1 -1
- package/dist/types.d.ts +6 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/client/types.ts +1 -1
- package/src/index.ts +1 -0
- package/src/middleware.test.ts +231 -0
- package/src/openapi.test.ts +0 -1
- package/src/prevent-process-exit-if-busy.js +60 -0
- package/src/spiceflow.test.ts +2 -2
- package/src/types.ts +8 -1
package/src/middleware.test.ts
CHANGED
|
@@ -381,3 +381,234 @@ test('each middleware and route is called exactly once if an error is thrown', a
|
|
|
381
381
|
route: 1,
|
|
382
382
|
})
|
|
383
383
|
})
|
|
384
|
+
|
|
385
|
+
test('middleware with try/finally correctly tracks operations even when errors are thrown', async () => {
|
|
386
|
+
let operationCount = 0
|
|
387
|
+
let finallyExecuted = false
|
|
388
|
+
|
|
389
|
+
const createTrackingMiddleware = () => {
|
|
390
|
+
return async (_: any, next: any) => {
|
|
391
|
+
operationCount++
|
|
392
|
+
try {
|
|
393
|
+
await next()
|
|
394
|
+
} finally {
|
|
395
|
+
operationCount--
|
|
396
|
+
finallyExecuted = true
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Test 1: Normal successful request
|
|
402
|
+
operationCount = 0
|
|
403
|
+
finallyExecuted = false
|
|
404
|
+
const app1 = new Spiceflow()
|
|
405
|
+
.use(createTrackingMiddleware())
|
|
406
|
+
.get('/success', () => ({ message: 'success' }))
|
|
407
|
+
|
|
408
|
+
const res1 = await app1.handle(new Request('http://localhost/success'))
|
|
409
|
+
expect(res1.status).toBe(200)
|
|
410
|
+
expect(operationCount).toBe(0) // Should be decremented back to 0
|
|
411
|
+
expect(finallyExecuted).toBe(true)
|
|
412
|
+
|
|
413
|
+
// Test 2: Route throws an error
|
|
414
|
+
operationCount = 0
|
|
415
|
+
finallyExecuted = false
|
|
416
|
+
const app2 = new Spiceflow()
|
|
417
|
+
.use(createTrackingMiddleware())
|
|
418
|
+
.get('/error', () => {
|
|
419
|
+
throw new Error('Route error')
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
const res2 = await app2.handle(new Request('http://localhost/error'))
|
|
423
|
+
expect(res2.status).toBe(500)
|
|
424
|
+
expect(operationCount).toBe(0) // Should be decremented back to 0
|
|
425
|
+
expect(finallyExecuted).toBe(true)
|
|
426
|
+
|
|
427
|
+
// Test 3: Route throws a Response
|
|
428
|
+
operationCount = 0
|
|
429
|
+
finallyExecuted = false
|
|
430
|
+
const app3 = new Spiceflow()
|
|
431
|
+
.use(createTrackingMiddleware())
|
|
432
|
+
.get('/response', () => {
|
|
433
|
+
throw new Response('Custom response', { status: 403 })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
const res3 = await app3.handle(new Request('http://localhost/response'))
|
|
437
|
+
expect(res3.status).toBe(403)
|
|
438
|
+
expect(operationCount).toBe(0) // Should be decremented back to 0
|
|
439
|
+
expect(finallyExecuted).toBe(true)
|
|
440
|
+
|
|
441
|
+
// Test 4: Multiple concurrent requests
|
|
442
|
+
operationCount = 0
|
|
443
|
+
const app4 = new Spiceflow()
|
|
444
|
+
.use(createTrackingMiddleware())
|
|
445
|
+
.get('/concurrent', async () => {
|
|
446
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
447
|
+
return { ok: true }
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// Start 3 concurrent requests
|
|
451
|
+
const concurrentRequests = Promise.all([
|
|
452
|
+
app4.handle(new Request('http://localhost/concurrent')),
|
|
453
|
+
app4.handle(new Request('http://localhost/concurrent')),
|
|
454
|
+
app4.handle(new Request('http://localhost/concurrent'))
|
|
455
|
+
])
|
|
456
|
+
|
|
457
|
+
// Wait a bit to ensure requests are in flight
|
|
458
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
459
|
+
|
|
460
|
+
// Operation count should be 3 while requests are in flight
|
|
461
|
+
expect(operationCount).toBe(3)
|
|
462
|
+
|
|
463
|
+
// Wait for all requests to complete
|
|
464
|
+
const results = await concurrentRequests
|
|
465
|
+
results.forEach(res => expect(res.status).toBe(200))
|
|
466
|
+
|
|
467
|
+
// Operation count should be back to 0
|
|
468
|
+
expect(operationCount).toBe(0)
|
|
469
|
+
})
|
|
470
|
+
|
|
471
|
+
test('middleware with try/finally tracks operations correctly with child apps', async () => {
|
|
472
|
+
let operationCount = 0
|
|
473
|
+
let finallyExecuted = false
|
|
474
|
+
|
|
475
|
+
const createTrackingMiddleware = () => {
|
|
476
|
+
return async (_: any, next: any) => {
|
|
477
|
+
operationCount++
|
|
478
|
+
try {
|
|
479
|
+
await next()
|
|
480
|
+
} finally {
|
|
481
|
+
operationCount--
|
|
482
|
+
finallyExecuted = true
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Test 1: Middleware on parent, route in child app
|
|
488
|
+
operationCount = 0
|
|
489
|
+
finallyExecuted = false
|
|
490
|
+
const childApp1 = new Spiceflow()
|
|
491
|
+
.get('/child/route', () => ({ message: 'from child' }))
|
|
492
|
+
|
|
493
|
+
const parentApp1 = new Spiceflow()
|
|
494
|
+
.use(createTrackingMiddleware())
|
|
495
|
+
.use(childApp1)
|
|
496
|
+
|
|
497
|
+
const res1 = await parentApp1.handle(new Request('http://localhost/child/route'))
|
|
498
|
+
expect(res1.status).toBe(200)
|
|
499
|
+
expect(operationCount).toBe(0)
|
|
500
|
+
expect(finallyExecuted).toBe(true)
|
|
501
|
+
|
|
502
|
+
// Test 2: Middleware on parent, error thrown in child route
|
|
503
|
+
operationCount = 0
|
|
504
|
+
finallyExecuted = false
|
|
505
|
+
const childApp2 = new Spiceflow()
|
|
506
|
+
.get('/child/error', () => {
|
|
507
|
+
throw new Error('Child route error')
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
const parentApp2 = new Spiceflow()
|
|
511
|
+
.use(createTrackingMiddleware())
|
|
512
|
+
.use(childApp2)
|
|
513
|
+
|
|
514
|
+
const res2 = await parentApp2.handle(new Request('http://localhost/child/error'))
|
|
515
|
+
expect(res2.status).toBe(500)
|
|
516
|
+
expect(operationCount).toBe(0)
|
|
517
|
+
expect(finallyExecuted).toBe(true)
|
|
518
|
+
|
|
519
|
+
// Test 3: Multiple nested child apps with middleware
|
|
520
|
+
operationCount = 0
|
|
521
|
+
finallyExecuted = false
|
|
522
|
+
const grandchildApp = new Spiceflow()
|
|
523
|
+
.get('/level3/route', () => ({ level: 3 }))
|
|
524
|
+
|
|
525
|
+
const childApp3 = new Spiceflow({ basePath: '/level2' })
|
|
526
|
+
.use(grandchildApp)
|
|
527
|
+
|
|
528
|
+
const parentApp3 = new Spiceflow()
|
|
529
|
+
.use(createTrackingMiddleware())
|
|
530
|
+
.use(childApp3)
|
|
531
|
+
|
|
532
|
+
const res3 = await parentApp3.handle(new Request('http://localhost/level2/level3/route'))
|
|
533
|
+
expect(res3.status).toBe(200)
|
|
534
|
+
expect(operationCount).toBe(0)
|
|
535
|
+
expect(finallyExecuted).toBe(true)
|
|
536
|
+
|
|
537
|
+
// Test 4: Middleware on both parent and child
|
|
538
|
+
operationCount = 0
|
|
539
|
+
const childApp4 = new Spiceflow()
|
|
540
|
+
.use(createTrackingMiddleware())
|
|
541
|
+
.get('/child/both', () => ({ from: 'both' }))
|
|
542
|
+
|
|
543
|
+
const parentApp4 = new Spiceflow()
|
|
544
|
+
.use(createTrackingMiddleware())
|
|
545
|
+
.use(childApp4)
|
|
546
|
+
|
|
547
|
+
const res4 = await parentApp4.handle(new Request('http://localhost/child/both'))
|
|
548
|
+
expect(res4.status).toBe(200)
|
|
549
|
+
expect(operationCount).toBe(0) // Both middlewares increment and decrement
|
|
550
|
+
|
|
551
|
+
// Test 5: Child app with basePath
|
|
552
|
+
operationCount = 0
|
|
553
|
+
finallyExecuted = false
|
|
554
|
+
const childApp5 = new Spiceflow({ basePath: '/api/v1' })
|
|
555
|
+
.get('/users', () => ({ users: [] }))
|
|
556
|
+
|
|
557
|
+
const parentApp5 = new Spiceflow()
|
|
558
|
+
.use(createTrackingMiddleware())
|
|
559
|
+
.use(childApp5)
|
|
560
|
+
|
|
561
|
+
const res5 = await parentApp5.handle(new Request('http://localhost/api/v1/users'))
|
|
562
|
+
expect(res5.status).toBe(200)
|
|
563
|
+
expect(operationCount).toBe(0)
|
|
564
|
+
expect(finallyExecuted).toBe(true)
|
|
565
|
+
|
|
566
|
+
// Test 6: Concurrent requests to child app routes
|
|
567
|
+
operationCount = 0
|
|
568
|
+
const childApp6 = new Spiceflow()
|
|
569
|
+
.get('/child/slow', async () => {
|
|
570
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
571
|
+
return { ok: true }
|
|
572
|
+
})
|
|
573
|
+
|
|
574
|
+
const parentApp6 = new Spiceflow()
|
|
575
|
+
.use(createTrackingMiddleware())
|
|
576
|
+
.use(childApp6)
|
|
577
|
+
|
|
578
|
+
// Start 3 concurrent requests
|
|
579
|
+
const concurrentRequests = Promise.all([
|
|
580
|
+
parentApp6.handle(new Request('http://localhost/child/slow')),
|
|
581
|
+
parentApp6.handle(new Request('http://localhost/child/slow')),
|
|
582
|
+
parentApp6.handle(new Request('http://localhost/child/slow'))
|
|
583
|
+
])
|
|
584
|
+
|
|
585
|
+
// Wait a bit to ensure requests are in flight
|
|
586
|
+
await new Promise(resolve => setTimeout(resolve, 10))
|
|
587
|
+
|
|
588
|
+
// Operation count should be 3 while requests are in flight
|
|
589
|
+
expect(operationCount).toBe(3)
|
|
590
|
+
|
|
591
|
+
// Wait for all requests to complete
|
|
592
|
+
const results = await concurrentRequests
|
|
593
|
+
results.forEach(res => expect(res.status).toBe(200))
|
|
594
|
+
|
|
595
|
+
// Operation count should be back to 0
|
|
596
|
+
expect(operationCount).toBe(0)
|
|
597
|
+
|
|
598
|
+
// Test 7: Child app throws Response
|
|
599
|
+
operationCount = 0
|
|
600
|
+
finallyExecuted = false
|
|
601
|
+
const childApp7 = new Spiceflow()
|
|
602
|
+
.get('/child/response', () => {
|
|
603
|
+
throw new Response('Custom child response', { status: 403 })
|
|
604
|
+
})
|
|
605
|
+
|
|
606
|
+
const parentApp7 = new Spiceflow()
|
|
607
|
+
.use(createTrackingMiddleware())
|
|
608
|
+
.use(childApp7)
|
|
609
|
+
|
|
610
|
+
const res7 = await parentApp7.handle(new Request('http://localhost/child/response'))
|
|
611
|
+
expect(res7.status).toBe(403)
|
|
612
|
+
expect(operationCount).toBe(0)
|
|
613
|
+
expect(finallyExecuted).toBe(true)
|
|
614
|
+
})
|
package/src/openapi.test.ts
CHANGED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Spiceflow } from 'spiceflow'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a Spiceflow middleware that tracks in-flight requests and prevents
|
|
5
|
+
* the process from exiting while requests are being processed.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} options - Configuration options
|
|
8
|
+
* @param {number} [options.maxWaitSeconds=300] - Maximum time to wait for requests to complete
|
|
9
|
+
* @param {number} [options.checkIntervalMs=250] - Interval to check if requests are complete
|
|
10
|
+
* @returns {Spiceflow} Spiceflow app that can be mounted with .use()
|
|
11
|
+
*/
|
|
12
|
+
export function preventProcessExitIfBusy(options = {}) {
|
|
13
|
+
const { maxWaitSeconds = 300, checkIntervalMs = 250 } = options
|
|
14
|
+
|
|
15
|
+
// Track in-flight requests in closure
|
|
16
|
+
let inFlightRequests = 0
|
|
17
|
+
let isShuttingDown = false
|
|
18
|
+
|
|
19
|
+
// Sleep utility
|
|
20
|
+
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
|
21
|
+
|
|
22
|
+
// Graceful shutdown handler
|
|
23
|
+
async function handleShutdown(signal) {
|
|
24
|
+
if (isShuttingDown) return // Prevent multiple shutdown attempts
|
|
25
|
+
isShuttingDown = true
|
|
26
|
+
|
|
27
|
+
console.log(`${signal} received – waiting for ${inFlightRequests} request(s) to complete...`)
|
|
28
|
+
|
|
29
|
+
const deadline = Date.now() + (maxWaitSeconds * 1000)
|
|
30
|
+
|
|
31
|
+
while (inFlightRequests > 0 && Date.now() < deadline) {
|
|
32
|
+
await sleep(checkIntervalMs)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (inFlightRequests > 0) {
|
|
36
|
+
console.log(`Shutdown timeout reached; ${inFlightRequests} request(s) still in progress`)
|
|
37
|
+
} else {
|
|
38
|
+
console.log('All requests completed')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
process.exit(inFlightRequests > 0 ? 1 : 0)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Register shutdown handlers only in Node.js environments
|
|
45
|
+
if (typeof process !== 'undefined' && process.prependListener) {
|
|
46
|
+
;['SIGINT', 'SIGTERM'].forEach(sig => {
|
|
47
|
+
process.prependListener(sig, handleShutdown)
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Return Spiceflow middleware
|
|
52
|
+
return new Spiceflow().use(async (_, next) => {
|
|
53
|
+
inFlightRequests++
|
|
54
|
+
try {
|
|
55
|
+
await next()
|
|
56
|
+
} finally {
|
|
57
|
+
inFlightRequests--
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
package/src/spiceflow.test.ts
CHANGED
|
@@ -376,7 +376,7 @@ test('onError fires on validation errors', async () => {
|
|
|
376
376
|
|
|
377
377
|
expect(res.status).toBe(400)
|
|
378
378
|
expect(errorMessage).toMatchInlineSnapshot(
|
|
379
|
-
`"name:
|
|
379
|
+
`"name: Invalid input: expected string, received number"`,
|
|
380
380
|
)
|
|
381
381
|
expect(await res.text()).toMatchInlineSnapshot(`"Error"`)
|
|
382
382
|
})
|
|
@@ -603,7 +603,7 @@ test('validate body works, request fails', async () => {
|
|
|
603
603
|
)
|
|
604
604
|
expect(res.status).toBe(422)
|
|
605
605
|
expect(await res.text()).toMatchInlineSnapshot(
|
|
606
|
-
`"{"code":"VALIDATION","status":422,"message":"requiredField:
|
|
606
|
+
`"{"code":"VALIDATION","status":422,"message":"requiredField: Invalid input: expected string, received undefined"}"`,
|
|
607
607
|
)
|
|
608
608
|
})
|
|
609
609
|
|
package/src/types.ts
CHANGED
|
@@ -339,6 +339,13 @@ export type CoExist<Original, Target, With> =
|
|
|
339
339
|
? Original | With
|
|
340
340
|
: Original
|
|
341
341
|
|
|
342
|
+
|
|
343
|
+
type ResponseLike = {
|
|
344
|
+
status: number
|
|
345
|
+
headers?: any
|
|
346
|
+
body?: any
|
|
347
|
+
}
|
|
348
|
+
|
|
342
349
|
export type InlineHandler<
|
|
343
350
|
This,
|
|
344
351
|
Route extends RouteSchema = {},
|
|
@@ -353,7 +360,7 @@ export type InlineHandler<
|
|
|
353
360
|
? Prettify<MacroContext & Context<Route, Singleton, Path>>
|
|
354
361
|
: Context<Route, Singleton, Path>,
|
|
355
362
|
) =>
|
|
356
|
-
|
|
|
363
|
+
| ResponseLike
|
|
357
364
|
| MaybePromiseIterable<
|
|
358
365
|
{} extends Route['response']
|
|
359
366
|
? unknown
|