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.
@@ -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
+ })
@@ -375,7 +375,6 @@ test('openapi response', async () => {
375
375
  "content": {
376
376
  "application/json": {
377
377
  "schema": {
378
- "additionalProperties": true,
379
378
  "properties": {
380
379
  "name": {
381
380
  "type": "string",
@@ -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
+ }
@@ -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: Expected string, received number"`,
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: Required"}"`,
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
- | Response
363
+ | ResponseLike
357
364
  | MaybePromiseIterable<
358
365
  {} extends Route['response']
359
366
  ? unknown