opinionated-machine 6.3.0 → 6.5.0

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.
Files changed (39) hide show
  1. package/README.md +282 -137
  2. package/dist/lib/contracts/contractBuilders.d.ts +59 -92
  3. package/dist/lib/contracts/contractBuilders.js +94 -18
  4. package/dist/lib/contracts/contractBuilders.js.map +1 -1
  5. package/dist/lib/dualmode/AbstractDualModeController.d.ts +1 -1
  6. package/dist/lib/dualmode/AbstractDualModeController.js +1 -1
  7. package/dist/lib/dualmode/dualModeContracts.d.ts +71 -9
  8. package/dist/lib/dualmode/dualModeContracts.js +12 -1
  9. package/dist/lib/dualmode/dualModeContracts.js.map +1 -1
  10. package/dist/lib/dualmode/index.d.ts +2 -1
  11. package/dist/lib/dualmode/index.js +1 -0
  12. package/dist/lib/dualmode/index.js.map +1 -1
  13. package/dist/lib/routes/fastifyRouteBuilder.d.ts +3 -3
  14. package/dist/lib/routes/fastifyRouteBuilder.js +199 -69
  15. package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
  16. package/dist/lib/routes/fastifyRouteTypes.d.ts +316 -67
  17. package/dist/lib/routes/fastifyRouteTypes.js +32 -22
  18. package/dist/lib/routes/fastifyRouteTypes.js.map +1 -1
  19. package/dist/lib/routes/fastifyRouteUtils.d.ts +94 -8
  20. package/dist/lib/routes/fastifyRouteUtils.js +285 -45
  21. package/dist/lib/routes/fastifyRouteUtils.js.map +1 -1
  22. package/dist/lib/routes/index.d.ts +2 -3
  23. package/dist/lib/routes/index.js +1 -3
  24. package/dist/lib/routes/index.js.map +1 -1
  25. package/dist/lib/sse/AbstractSSEController.d.ts +13 -13
  26. package/dist/lib/sse/AbstractSSEController.js +3 -3
  27. package/dist/lib/sse/AbstractSSEController.js.map +1 -1
  28. package/dist/lib/sse/{SSEConnectionSpy.d.ts → SSESessionSpy.d.ts} +8 -8
  29. package/dist/lib/sse/{SSEConnectionSpy.js → SSESessionSpy.js} +4 -4
  30. package/dist/lib/sse/SSESessionSpy.js.map +1 -0
  31. package/dist/lib/sse/index.d.ts +2 -2
  32. package/dist/lib/sse/index.js +1 -1
  33. package/dist/lib/sse/index.js.map +1 -1
  34. package/dist/lib/sse/sseContracts.d.ts +3 -3
  35. package/dist/lib/testing/index.d.ts +1 -1
  36. package/dist/lib/testing/sseHttpClient.d.ts +7 -7
  37. package/dist/lib/testing/sseTestTypes.d.ts +1 -1
  38. package/package.json +7 -7
  39. package/dist/lib/sse/SSEConnectionSpy.js.map +0 -1
package/README.md CHANGED
@@ -45,8 +45,8 @@ Very opinionated DI framework for fastify, built on top of awilix
45
45
  - [parseSSEBuffer](#parsessebuffer)
46
46
  - [ParsedSSEEvent Type](#parsedsseevent-type)
47
47
  - [Testing SSE Controllers](#testing-sse-controllers)
48
- - [SSEConnectionSpy API](#sseconnectionspy-api)
49
- - [Connection Monitoring](#connection-monitoring)
48
+ - [SSESessionSpy API](#ssesessionspy-api)
49
+ - [Session Monitoring](#session-monitoring)
50
50
  - [SSE Test Utilities](#sse-test-utilities)
51
51
  - [Quick Reference](#quick-reference)
52
52
  - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
@@ -398,7 +398,7 @@ await app.register(FastifySSEPlugin)
398
398
 
399
399
  ### Defining SSE Contracts
400
400
 
401
- Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `body` and `syncResponse` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
401
+ Use `buildContract` to define SSE routes. The contract type is automatically determined based on the presence of `requestBody` and `jsonResponse` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
402
402
 
403
403
  ```ts
404
404
  import { z } from 'zod'
@@ -429,14 +429,14 @@ export const notificationsContract = buildContract({
429
429
  },
430
430
  })
431
431
 
432
- // POST-based SSE stream (e.g., AI chat completions) - has body = POST/PUT/PATCH
432
+ // POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
433
433
  export const chatCompletionContract = buildContract({
434
434
  method: 'POST',
435
435
  pathResolver: () => '/api/chat/completions',
436
436
  params: z.object({}),
437
437
  query: z.object({}),
438
438
  requestHeaders: z.object({}),
439
- body: z.object({
439
+ requestBody: z.object({
440
440
  message: z.string(),
441
441
  stream: z.literal(true),
442
442
  }),
@@ -469,9 +469,8 @@ SSE controllers extend `AbstractSSEController` and must implement a two-paramete
469
469
  import {
470
470
  AbstractSSEController,
471
471
  buildHandler,
472
- success,
473
472
  type SSEControllerConfig,
474
- type SSEConnection
473
+ type SSESession
475
474
  } from 'opinionated-machine'
476
475
 
477
476
  type Contracts = {
@@ -501,50 +500,53 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
501
500
  contract: NotificationsSSEController.contracts.notificationsStream,
502
501
  handlers: this.handleStream,
503
502
  options: {
504
- onConnect: (conn) => this.onConnect(conn),
505
- onDisconnect: (conn) => this.onDisconnect(conn),
503
+ onConnect: (session) => this.onConnect(session),
504
+ onClose: (session, reason) => this.onClose(session, reason),
506
505
  },
507
506
  },
508
507
  }
509
508
  }
510
509
 
511
510
  // Handler with automatic type inference from contract
512
- // connection.send provides type-safe event sending
511
+ // sse.start(mode) returns a session with type-safe event sending
513
512
  private handleStream = buildHandler(notificationsContract, {
514
- sse: async (request, connection) => {
513
+ sse: async (request, sse) => {
515
514
  // request.query is typed from contract: { userId?: string }
516
515
  const userId = request.query.userId ?? 'anonymous'
517
- connection.context = { userId }
516
+
517
+ // Start streaming with 'keepAlive' mode - stays open for external events
518
+ // Sends HTTP 200 + SSE headers immediately
519
+ const session = sse.start('keepAlive', { context: { userId } })
518
520
 
519
521
  // For external triggers (subscriptions, timers, message queues), use sendEventInternal.
520
- // connection.send is only available within this handler's scope - external callbacks
521
- // like subscription handlers execute later, outside this function, so they can't access connection.
522
+ // session.send is only available within this handler's scope - external callbacks
523
+ // like subscription handlers execute later, outside this function, so they can't access session.
522
524
  // sendEventInternal is a controller method, so it's accessible from any callback.
523
525
  // It provides autocomplete for all event names defined in the controller's contracts.
524
526
  this.notificationService.subscribe(userId, async (notification) => {
525
- await this.sendEventInternal(connection.id, {
527
+ await this.sendEventInternal(session.id, {
526
528
  event: 'notification',
527
529
  data: notification,
528
530
  })
529
531
  })
530
532
 
531
- // For direct sending within the handler, use the connection's send method.
533
+ // For direct sending within the handler, use the session's send method.
532
534
  // It provides stricter per-route typing (only events from this specific contract).
533
- await connection.send('notification', { id: 'welcome', message: 'Connected!' })
535
+ await session.send('notification', { id: 'welcome', message: 'Connected!' })
534
536
 
535
- // Keep connection open for subscription events (client closes when done)
536
- return success('maintain_connection')
537
+ // 'keepAlive' mode: handler returns, but connection stays open for subscription events
538
+ // Connection closes when client disconnects or server calls closeConnection()
537
539
  },
538
540
  })
539
541
 
540
- private onConnect = (connection: SSEConnection) => {
541
- console.log('Client connected:', connection.id)
542
+ private onConnect = (session: SSESession) => {
543
+ console.log('Client connected:', session.id)
542
544
  }
543
545
 
544
- private onDisconnect = (connection: SSEConnection) => {
545
- const userId = connection.context?.userId as string
546
+ private onClose = (session: SSESession, reason: SSECloseReason) => {
547
+ const userId = session.context?.userId as string
546
548
  this.notificationService.unsubscribe(userId)
547
- console.log('Client disconnected:', connection.id)
549
+ console.log(`Client disconnected (${reason}):`, session.id)
548
550
  }
549
551
  }
550
552
  ```
@@ -558,7 +560,7 @@ import {
558
560
  AbstractSSEController,
559
561
  buildHandler,
560
562
  type SSEControllerConfig,
561
- type SSEConnection
563
+ type SSESession
562
564
  } from 'opinionated-machine'
563
565
 
564
566
  class ChatSSEController extends AbstractSSEController<Contracts> {
@@ -571,20 +573,23 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
571
573
  }
572
574
 
573
575
  // Handler with automatic type inference from contract
574
- // connection.send is fully typed per-route
576
+ // sse.start(mode) returns session with fully typed send()
575
577
  private handleChatCompletion = buildHandler(chatCompletionContract, {
576
- sse: async (request, connection) => {
578
+ sse: async (request, sse) => {
577
579
  // request.body is typed as { message: string; stream: true }
578
580
  // request.query, request.params, request.headers all typed from contract
579
581
  const words = request.body.message.split(' ')
580
582
 
583
+ // Start streaming with 'autoClose' mode - closes after handler completes
584
+ // Sends HTTP 200 + SSE headers immediately
585
+ const session = sse.start('autoClose')
586
+
581
587
  for (const word of words) {
582
- // connection.send() provides compile-time type checking for event names and data
583
- await connection.send('chunk', { content: word })
588
+ // session.send() provides compile-time type checking for event names and data
589
+ await session.send('chunk', { content: word })
584
590
  }
585
591
 
586
- // Gracefully end the stream - all sent data is flushed before connection closes
587
- return success('disconnect')
592
+ // 'autoClose' mode: connection closes automatically when handler returns
588
593
  },
589
594
  })
590
595
 
@@ -602,15 +607,17 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
602
607
  You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
603
608
 
604
609
  ```ts
605
- import { type InferSSERequest, type SSEConnection } from 'opinionated-machine'
610
+ import { type InferSSERequest, type SSEContext, type SSESession } from 'opinionated-machine'
606
611
 
607
612
  private handleStream = async (
608
613
  request: InferSSERequest<typeof chatCompletionContract>,
609
- connection: SSEConnection<typeof chatCompletionContract['events']>,
614
+ sse: SSEContext<typeof chatCompletionContract['events']>,
610
615
  ) => {
611
616
  // request.body, request.params, etc. all typed from contract
612
- // connection.send() is typed based on contract events
613
- await connection.send('chunk', { content: 'hello' })
617
+ const session = sse.start('autoClose')
618
+ // session.send() is typed based on contract events
619
+ await session.send('chunk', { content: 'hello' })
620
+ // 'autoClose' mode: connection closes when handler returns
614
621
  }
615
622
  ```
616
623
 
@@ -691,10 +698,10 @@ await this.broadcast({
691
698
  data: { message: 'Server maintenance in 5 minutes' },
692
699
  })
693
700
 
694
- // Broadcast to connections matching a predicate
701
+ // Broadcast to sessions matching a predicate
695
702
  await this.broadcastIf(
696
703
  { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
697
- (connection) => connection.context.channelId === '123',
704
+ (session) => session.context.channelId === '123',
698
705
  )
699
706
  ```
700
707
 
@@ -702,17 +709,17 @@ Both methods return the number of clients the message was successfully sent to.
702
709
 
703
710
  ### Controller-Level Hooks
704
711
 
705
- Override these optional methods on your controller for global connection handling:
712
+ Override these optional methods on your controller for global session handling:
706
713
 
707
714
  ```ts
708
715
  class MySSEController extends AbstractSSEController<Contracts> {
709
- // Called AFTER connection is registered (for all routes)
710
- protected onConnectionEstablished(connection: SSEConnection): void {
716
+ // Called AFTER session is registered (for all routes)
717
+ protected onConnectionEstablished(session: SSESession): void {
711
718
  this.metrics.incrementConnections()
712
719
  }
713
720
 
714
- // Called BEFORE connection is unregistered (for all routes)
715
- protected onConnectionClosed(connection: SSEConnection): void {
721
+ // Called BEFORE session is unregistered (for all routes)
722
+ protected onConnectionClosed(session: SSESession): void {
716
723
  this.metrics.decrementConnections()
717
724
  }
718
725
  }
@@ -735,10 +742,10 @@ public buildSSERoutes() {
735
742
  reply.code(403).send({ error: 'Forbidden' })
736
743
  }
737
744
  },
738
- onConnect: (conn) => console.log('Admin connected'),
739
- onDisconnect: (conn) => console.log('Admin disconnected'),
745
+ onConnect: (session) => console.log('Admin connected'),
746
+ onClose: (session, reason) => console.log(`Admin disconnected (${reason})`),
740
747
  // Handle client reconnection with Last-Event-ID
741
- onReconnect: async (conn, lastEventId) => {
748
+ onReconnect: async (session, lastEventId) => {
742
749
  // Return events to replay, or handle manually
743
750
  return this.getEventsSince(lastEventId)
744
751
  },
@@ -753,12 +760,66 @@ public buildSSERoutes() {
753
760
  **Available route options:**
754
761
 
755
762
  | Option | Description |
756
- |--------|-------------|
757
- | `preHandler` | Authentication/authorization hook that runs before SSE connection |
763
+ | -------- | ------------- |
764
+ | `preHandler` | Authentication/authorization hook that runs before SSE session |
758
765
  | `onConnect` | Called after client connects (SSE handshake complete) |
759
- | `onDisconnect` | Called when client disconnects |
766
+ | `onClose` | Called when session closes (client disconnect, network failure, or server close). Receives `(session, reason)` where reason is `'server'` or `'client'` |
760
767
  | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
761
768
  | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
769
+ | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
770
+ | `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
771
+
772
+ **onClose reason parameter:**
773
+ - `'server'`: Server explicitly closed the session (via `closeConnection()` or `autoClose` mode)
774
+ - `'client'`: Client closed the session (EventSource.close(), navigation, network failure)
775
+
776
+ ```ts
777
+ options: {
778
+ onConnect: (session) => console.log('Client connected'),
779
+ onClose: (session, reason) => {
780
+ console.log(`Session closed (${reason}):`, session.id)
781
+ // reason is 'server' or 'client'
782
+ },
783
+ serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
784
+ heartbeatInterval: 30000, // Send heartbeat every 30 seconds
785
+ }
786
+ ```
787
+
788
+ ### SSE Session Methods
789
+
790
+ The `session` object returned by `sse.start(mode)` provides several useful methods:
791
+
792
+ ```ts
793
+ private handleStream = buildHandler(streamContract, {
794
+ sse: async (request, sse) => {
795
+ const session = sse.start('autoClose')
796
+
797
+ // Check if session is still active
798
+ if (session.isConnected()) {
799
+ await session.send('status', { connected: true })
800
+ }
801
+
802
+ // Get raw writable stream for advanced use cases (e.g., pipeline)
803
+ const stream = session.getStream()
804
+
805
+ // Stream messages from an async iterable with automatic validation
806
+ async function* generateMessages() {
807
+ yield { event: 'message' as const, data: { text: 'Hello' } }
808
+ yield { event: 'message' as const, data: { text: 'World' } }
809
+ }
810
+ await session.sendStream(generateMessages())
811
+
812
+ // 'autoClose' mode: connection closes when handler returns
813
+ },
814
+ })
815
+ ```
816
+
817
+ | Method | Description |
818
+ | -------- | ------------- |
819
+ | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
820
+ | `isConnected()` | Check if the session is still active |
821
+ | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
822
+ | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
762
823
 
763
824
  ### Graceful Shutdown
764
825
 
@@ -779,11 +840,11 @@ if (!sent) {
779
840
  }
780
841
  ```
781
842
 
782
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onDisconnect`):
843
+ **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
783
844
  - All lifecycle hooks are wrapped in try/catch to prevent crashes
784
845
  - If a `logger` is provided in route options, errors are logged with context
785
846
  - If no logger is provided, errors are silently ignored
786
- - The connection lifecycle continues even if a hook throws
847
+ - The session lifecycle continues even if a hook throws
787
848
 
788
849
  ```ts
789
850
  // Provide a logger to capture lifecycle errors
@@ -794,8 +855,8 @@ public buildSSERoutes() {
794
855
  handlers: this.handleStream,
795
856
  options: {
796
857
  logger: this.logger, // pino-compatible logger
797
- onConnect: (conn) => { /* may throw */ },
798
- onDisconnect: (conn) => { /* may throw */ },
858
+ onConnect: (session) => { /* may throw */ },
859
+ onClose: (session, reason) => { /* may throw */ },
799
860
  },
800
861
  },
801
862
  }
@@ -804,84 +865,80 @@ public buildSSERoutes() {
804
865
 
805
866
  ### Long-lived Connections vs Request-Response Streaming
806
867
 
807
- SSE handlers must return an `Either<Error, SSEHandlerResult>` to explicitly indicate connection management.
808
-
809
- The `Either` type along with the `success` and `failure` helper functions are re-exported from `@lokalise/node-core` for convenience, so you don't need to add `@lokalise/node-core` as a direct dependency:
868
+ SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
810
869
 
811
870
  ```ts
812
- import { type Either, success, failure } from 'opinionated-machine'
813
-
814
- // Return success('disconnect') - close connection after handler completes
815
- // Return success('maintain_connection') - keep connection open for external events
816
- // Return failure(error) - handle error and close connection
871
+ // sse.start('autoClose') - close connection when handler returns (request-response pattern)
872
+ // sse.start('keepAlive') - keep connection open for external events (subscription pattern)
873
+ // sse.respond(code, body) - send HTTP response before streaming (early return)
817
874
  ```
818
875
 
819
- **Long-lived connections** (notifications, live updates):
820
- - Handler sets up subscriptions and returns `success('maintain_connection')`
821
- - Connection stays open indefinitely after handler returns
876
+ **Long-lived sessions** (notifications, live updates):
877
+ - Handler starts streaming with `sse.start('keepAlive')`
878
+ - Session stays open indefinitely after handler returns
822
879
  - Events are sent later via callbacks using `sendEventInternal()`
823
- - **Client closes connection** when done (e.g., `eventSource.close()` or navigating away)
880
+ - **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
824
881
  - Server cleans up via `onConnectionClosed()` hook
825
882
 
826
883
  ```ts
827
- import { success } from 'opinionated-machine'
828
-
829
884
  private handleStream = buildHandler(streamContract, {
830
- sse: async (request, connection) => {
885
+ sse: async (request, sse) => {
886
+ // Start streaming with 'keepAlive' mode - stays open for external events
887
+ const session = sse.start('keepAlive')
888
+
831
889
  // Set up subscription - events sent via callback AFTER handler returns
832
- this.service.subscribe(connection.id, (data) => {
833
- this.sendEventInternal(connection.id, { event: 'update', data })
890
+ this.service.subscribe(session.id, (data) => {
891
+ this.sendEventInternal(session.id, { event: 'update', data })
834
892
  })
835
- // Keep connection open until CLIENT disconnects
836
- return success('maintain_connection')
893
+ // 'keepAlive' mode: handler returns, but connection stays open
837
894
  },
838
895
  })
839
896
 
840
897
  // Clean up when client disconnects
841
- protected onConnectionClosed(connection: SSEConnection): void {
842
- this.service.unsubscribe(connection.id)
898
+ protected onConnectionClosed(session: SSESession): void {
899
+ this.service.unsubscribe(session.id)
843
900
  }
844
901
  ```
845
902
 
846
903
  **Request-response streaming** (AI completions):
847
- - Handler sends all events synchronously, then returns `success('disconnect')`
848
- - Use `connection.send()` for type-safe event sending within the handler
849
- - Connection automatically closes when handler returns with `'disconnect'`
904
+ - Handler starts streaming with `sse.start('autoClose')`
905
+ - Use `session.send()` for type-safe event sending within the handler
906
+ - Session automatically closes when handler returns
850
907
 
851
908
  ```ts
852
- import { success } from 'opinionated-machine'
853
-
854
909
  private handleChatCompletion = buildHandler(chatCompletionContract, {
855
- sse: async (request, connection) => {
856
- const words = request.body.message.split(' ')
910
+ sse: async (request, sse) => {
911
+ // Start streaming with 'autoClose' mode - closes when handler returns
912
+ const session = sse.start('autoClose')
857
913
 
914
+ const words = request.body.message.split(' ')
858
915
  for (const word of words) {
859
- await connection.send('chunk', { content: word })
916
+ await session.send('chunk', { content: word })
860
917
  }
861
- await connection.send('done', { totalTokens: words.length })
918
+ await session.send('done', { totalTokens: words.length })
862
919
 
863
- // Signal that streaming is complete and connection should close
864
- return success('disconnect')
920
+ // 'autoClose' mode: connection closes automatically when handler returns
865
921
  },
866
922
  })
867
923
  ```
868
924
 
869
- **Error handling:**
925
+ **Error handling before streaming:**
870
926
 
871
- Return `failure(error)` to signal an error occurred. The framework will send an error event and close the connection:
927
+ Use `sse.respond(code, body)` to return an HTTP response before streaming starts. This is useful for any early return: validation errors, not found, redirects, etc.
872
928
 
873
929
  ```ts
874
- import { success, failure } from 'opinionated-machine'
875
-
876
930
  private handleStream = buildHandler(streamContract, {
877
- sse: async (request, connection) => {
878
- try {
879
- const data = await this.service.getData(request.params.id)
880
- await connection.send('data', data)
881
- return success('disconnect')
882
- } catch (err) {
883
- return failure(err instanceof Error ? err : new Error(String(err)))
931
+ sse: async (request, sse) => {
932
+ // Early return BEFORE starting stream - can return any HTTP response
933
+ const entity = await this.service.find(request.params.id)
934
+ if (!entity) {
935
+ return sse.respond(404, { error: 'Entity not found' })
884
936
  }
937
+
938
+ // Validation passed - start streaming with autoClose mode
939
+ const session = sse.start('autoClose')
940
+ await session.send('data', entity)
941
+ // Connection closes automatically when handler returns
885
942
  },
886
943
  })
887
944
 
@@ -1057,47 +1114,47 @@ describe('NotificationsSSEController', () => {
1057
1114
  })
1058
1115
  ```
1059
1116
 
1060
- ### SSEConnectionSpy API
1117
+ ### SSESessionSpy API
1061
1118
 
1062
1119
  The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
1063
1120
 
1064
1121
  ```ts
1065
- // Wait for a connection to be established (with timeout)
1066
- const connection = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1122
+ // Wait for a session to be established (with timeout)
1123
+ const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1067
1124
 
1068
- // Wait for a connection matching a predicate (useful for multiple connections)
1069
- const connection = await controller.connectionSpy.waitForConnection({
1125
+ // Wait for a session matching a predicate (useful for multiple sessions)
1126
+ const session = await controller.connectionSpy.waitForConnection({
1070
1127
  timeout: 5000,
1071
- predicate: (conn) => conn.request.url.includes('/api/notifications'),
1128
+ predicate: (s) => s.request.url.includes('/api/notifications'),
1072
1129
  })
1073
1130
 
1074
- // Check if a specific connection is active
1075
- const isConnected = controller.connectionSpy.isConnected(connectionId)
1131
+ // Check if a specific session is active
1132
+ const isConnected = controller.connectionSpy.isConnected(sessionId)
1076
1133
 
1077
- // Wait for a specific connection to disconnect
1078
- await controller.connectionSpy.waitForDisconnection(connectionId, { timeout: 5000 })
1134
+ // Wait for a specific session to disconnect
1135
+ await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
1079
1136
 
1080
- // Get all connection events (connect/disconnect history)
1137
+ // Get all session events (connect/disconnect history)
1081
1138
  const events = controller.connectionSpy.getEvents()
1082
1139
 
1083
- // Clear event history and claimed connections between tests
1140
+ // Clear event history and claimed sessions between tests
1084
1141
  controller.connectionSpy.clear()
1085
1142
  ```
1086
1143
 
1087
- **Note**: `waitForConnection` tracks "claimed" connections internally. Each call returns a unique unclaimed connection, allowing sequential waits for the same URL path without returning the same connection twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
1144
+ **Note**: `waitForConnection` tracks "claimed" sessions internally. Each call returns a unique unclaimed session, allowing sequential waits for the same URL path without returning the same session twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
1088
1145
 
1089
- ### Connection Monitoring
1146
+ ### Session Monitoring
1090
1147
 
1091
- Controllers have access to utility methods for monitoring connections:
1148
+ Controllers have access to utility methods for monitoring sessions:
1092
1149
 
1093
1150
  ```ts
1094
- // Get count of active connections
1151
+ // Get count of active sessions
1095
1152
  const count = this.getConnectionCount()
1096
1153
 
1097
- // Get all active connections (for iteration/inspection)
1098
- const connections = this.getConnections()
1154
+ // Get all active sessions (for iteration/inspection)
1155
+ const sessions = this.getConnections()
1099
1156
 
1100
- // Check if connection spy is enabled (useful for conditional logic)
1157
+ // Check if session spy is enabled (useful for conditional logic)
1101
1158
  if (this.hasConnectionSpy()) {
1102
1159
  // ...
1103
1160
  }
@@ -1107,9 +1164,9 @@ if (this.hasConnectionSpy()) {
1107
1164
 
1108
1165
  The library provides utilities for testing SSE endpoints.
1109
1166
 
1110
- **Two connection methods:**
1111
- - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the connection for the request to complete.
1112
- - **Real HTTP** - Actual HTTP connection via `fetch()`. Requires the server to be listening. Supports long-lived connections.
1167
+ **Two transport methods:**
1168
+ - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the session for the request to complete.
1169
+ - **Real HTTP** - Actual HTTP via `fetch()`. Requires the server to be listening. Supports long-lived sessions.
1113
1170
 
1114
1171
  #### Quick Reference
1115
1172
 
@@ -1142,7 +1199,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1142
1199
  const server = await SSETestServer.create(async (app) => {
1143
1200
  app.get('/api/events', async (request, reply) => {
1144
1201
  reply.sse({ event: 'message', data: { hello: 'world' } })
1145
- reply.sseClose()
1202
+ reply.sse.close()
1146
1203
  })
1147
1204
  })
1148
1205
 
@@ -1360,21 +1417,21 @@ Dual-mode contracts define endpoints that can return **either** a complete JSON
1360
1417
  - You're building OpenAI-style APIs where `stream: true` triggers SSE
1361
1418
  - You need polling fallback for clients that don't support SSE
1362
1419
 
1363
- To create a dual-mode contract, include a `syncResponse` schema in your `buildContract` call:
1364
- - Has `syncResponse` but no `body` → GET dual-mode route
1365
- - Has both `syncResponse` and `body` → POST/PUT/PATCH dual-mode route
1420
+ To create a dual-mode contract, include a `jsonResponse` schema in your `buildContract` call:
1421
+ - Has `jsonResponse` but no `requestBody` → GET dual-mode route
1422
+ - Has both `jsonResponse` and `requestBody` → POST/PUT/PATCH dual-mode route
1366
1423
 
1367
1424
  ```ts
1368
1425
  import { z } from 'zod'
1369
1426
  import { buildContract } from 'opinionated-machine'
1370
1427
 
1371
- // GET dual-mode route (polling or streaming job status) - has syncResponse, no body
1428
+ // GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
1372
1429
  export const jobStatusContract = buildContract({
1373
1430
  pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1374
1431
  params: z.object({ jobId: z.string().uuid() }),
1375
1432
  query: z.object({ verbose: z.string().optional() }),
1376
1433
  requestHeaders: z.object({}),
1377
- syncResponse: z.object({
1434
+ jsonResponse: z.object({
1378
1435
  status: z.enum(['pending', 'running', 'completed', 'failed']),
1379
1436
  progress: z.number(),
1380
1437
  result: z.string().optional(),
@@ -1385,15 +1442,15 @@ export const jobStatusContract = buildContract({
1385
1442
  },
1386
1443
  })
1387
1444
 
1388
- // POST dual-mode route (OpenAI-style chat completion) - has both syncResponse and body
1445
+ // POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
1389
1446
  export const chatCompletionContract = buildContract({
1390
1447
  method: 'POST',
1391
1448
  pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1392
1449
  params: z.object({ chatId: z.string().uuid() }),
1393
1450
  query: z.object({}),
1394
1451
  requestHeaders: z.object({ authorization: z.string() }),
1395
- body: z.object({ message: z.string() }),
1396
- syncResponse: z.object({
1452
+ requestBody: z.object({ message: z.string() }),
1453
+ jsonResponse: z.object({
1397
1454
  reply: z.string(),
1398
1455
  usage: z.object({ tokens: z.number() }),
1399
1456
  }),
@@ -1417,8 +1474,8 @@ export const rateLimitedContract = buildContract({
1417
1474
  params: z.object({}),
1418
1475
  query: z.object({}),
1419
1476
  requestHeaders: z.object({}),
1420
- body: z.object({ data: z.string() }),
1421
- syncResponse: z.object({ result: z.string() }),
1477
+ requestBody: z.object({ data: z.string() }),
1478
+ jsonResponse: z.object({ result: z.string() }),
1422
1479
  // Define expected response headers
1423
1480
  responseHeaders: z.object({
1424
1481
  'x-ratelimit-limit': z.string(),
@@ -1441,12 +1498,84 @@ handlers: {
1441
1498
  reply.header('x-ratelimit-reset', '1640000000')
1442
1499
  return { result: 'success' }
1443
1500
  },
1444
- sse: async (request, connection) => { /* ... */ },
1501
+ sse: async (request, sse) => {
1502
+ const session = sse.start('autoClose')
1503
+ // ... send events ...
1504
+ // Connection closes automatically when handler returns
1505
+ },
1445
1506
  }
1446
1507
  ```
1447
1508
 
1448
1509
  If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
1449
1510
 
1511
+ ### Multi-Format Responses (Verbose Mode)
1512
+
1513
+ For endpoints that need to return multiple response formats (JSON, plain text, CSV, etc.), use `multiFormatResponses` instead of `jsonResponse`. This enables content negotiation based on the `Accept` header:
1514
+
1515
+ ```ts
1516
+ import { z } from 'zod'
1517
+ import { buildContract } from 'opinionated-machine'
1518
+
1519
+ // Multi-format export endpoint
1520
+ export const exportContract = buildContract({
1521
+ method: 'POST',
1522
+ pathResolver: () => '/api/export',
1523
+ params: z.object({}),
1524
+ query: z.object({}),
1525
+ requestHeaders: z.object({}),
1526
+ requestBody: z.object({
1527
+ data: z.array(z.object({ name: z.string(), value: z.number() })),
1528
+ }),
1529
+ // Define multiple response formats
1530
+ multiFormatResponses: {
1531
+ 'application/json': z.object({
1532
+ items: z.array(z.object({ name: z.string(), value: z.number() })),
1533
+ count: z.number(),
1534
+ }),
1535
+ 'text/plain': z.string(),
1536
+ 'text/csv': z.string(),
1537
+ },
1538
+ events: {
1539
+ progress: z.object({ percent: z.number() }),
1540
+ done: z.object({ format: z.string() }),
1541
+ },
1542
+ })
1543
+ ```
1544
+
1545
+ The handler structure changes to `sync` with per-format handlers:
1546
+
1547
+ ```ts
1548
+ handlers: buildHandler(exportContract, {
1549
+ sync: {
1550
+ 'application/json': (request) => ({
1551
+ items: request.body.data,
1552
+ count: request.body.data.length,
1553
+ }),
1554
+ 'text/plain': (request) =>
1555
+ request.body.data.map((item) => `${item.name}: ${item.value}`).join('\n'),
1556
+ 'text/csv': (request) =>
1557
+ `name,value\n${request.body.data.map((item) => `${item.name},${item.value}`).join('\n')}`,
1558
+ },
1559
+ sse: async (request, sse) => {
1560
+ // SSE streaming handler
1561
+ const session = sse.start('autoClose')
1562
+ await session.send('done', { totalItems: request.body.data.length })
1563
+ // Connection closes automatically when handler returns
1564
+ },
1565
+ })
1566
+ ```
1567
+
1568
+ **Contract styles comparison:**
1569
+
1570
+ | Style | Contract Field | Handler Key | Use Case |
1571
+ |-------|---------------|-------------|----------|
1572
+ | Simplified | `jsonResponse` | `json` | Single JSON format (recommended) |
1573
+ | Verbose | `multiFormatResponses` | `sync` | Multiple formats (JSON, text, CSV, etc.) |
1574
+
1575
+ TypeScript enforces the correct handler structure based on your contract:
1576
+ - `jsonResponse` contracts must use `json` handler
1577
+ - `multiFormatResponses` contracts must use `sync` handlers for all declared formats
1578
+
1450
1579
  ### Implementing Dual-Mode Controllers
1451
1580
 
1452
1581
  Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
@@ -1493,14 +1622,15 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1493
1622
  }
1494
1623
  },
1495
1624
  // SSE mode - stream response chunks
1496
- sse: async (request, connection) => {
1625
+ sse: async (request, sse) => {
1626
+ const session = sse.start('autoClose')
1497
1627
  let totalTokens = 0
1498
1628
  for await (const chunk of this.aiService.stream(request.body.message)) {
1499
- await connection.send('chunk', { delta: chunk.text })
1629
+ await session.send('chunk', { delta: chunk.text })
1500
1630
  totalTokens += chunk.tokenCount ?? 0
1501
1631
  }
1502
- await connection.send('done', { usage: { total: totalTokens } })
1503
- return success('disconnect')
1632
+ await session.send('done', { usage: { total: totalTokens } })
1633
+ // Connection closes automatically when handler returns
1504
1634
  },
1505
1635
  }),
1506
1636
  options: {
@@ -1513,8 +1643,8 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1513
1643
  }
1514
1644
  },
1515
1645
  // Optional: SSE lifecycle hooks
1516
- onConnect: (conn) => console.log('Client connected:', conn.id),
1517
- onDisconnect: (conn) => console.log('Client disconnected:', conn.id),
1646
+ onConnect: (session) => console.log('Client connected:', session.id),
1647
+ onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
1518
1648
  },
1519
1649
  },
1520
1650
  }
@@ -1527,9 +1657,9 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1527
1657
  | Mode | Signature |
1528
1658
  | ---- | --------- |
1529
1659
  | `json` | `(request, reply) => Response` |
1530
- | `sse` | `(request, connection) => Either<Error, SSEHandlerResult>` |
1660
+ | `sse` | `(request, sse) => SSEHandlerResult` |
1531
1661
 
1532
- The `json` handler must return a value matching `syncResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
1662
+ The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `sse.start(mode)` to begin streaming (`'autoClose'` for request-response, `'keepAlive'` for long-lived sessions) and `session.send()` for type-safe event sending.
1533
1663
 
1534
1664
  ### Registering Dual-Mode Controllers
1535
1665
 
@@ -1612,6 +1742,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1612
1742
  curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1613
1743
  ```
1614
1744
 
1745
+ **Subtype wildcards** are supported for flexible content negotiation:
1746
+
1747
+ ```bash
1748
+ # Accept any text format (matches text/plain, text/csv, etc.)
1749
+ curl -H "Accept: text/*" ...
1750
+
1751
+ # Accept any application format (matches application/json, application/xml, etc.)
1752
+ curl -H "Accept: application/*" ...
1753
+
1754
+ # Combine with quality values
1755
+ curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
1756
+ ```
1757
+
1758
+ The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
1759
+
1615
1760
  ### Testing Dual-Mode Controllers
1616
1761
 
1617
1762
  Test both JSON and SSE modes: