opinionated-machine 6.2.1 → 6.4.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 (60) hide show
  1. package/README.md +311 -82
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +4 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/lib/DIContext.d.ts +1 -2
  6. package/dist/lib/DIContext.js +3 -4
  7. package/dist/lib/DIContext.js.map +1 -1
  8. package/dist/lib/contracts/contractBuilders.d.ts +129 -0
  9. package/dist/lib/contracts/contractBuilders.js +106 -0
  10. package/dist/lib/contracts/contractBuilders.js.map +1 -0
  11. package/dist/lib/contracts/index.d.ts +1 -0
  12. package/dist/lib/contracts/index.js +2 -0
  13. package/dist/lib/contracts/index.js.map +1 -0
  14. package/dist/lib/dualmode/AbstractDualModeController.d.ts +8 -9
  15. package/dist/lib/dualmode/AbstractDualModeController.js +7 -7
  16. package/dist/lib/dualmode/AbstractDualModeController.js.map +1 -1
  17. package/dist/lib/dualmode/dualModeContracts.d.ts +66 -77
  18. package/dist/lib/dualmode/dualModeContracts.js +6 -70
  19. package/dist/lib/dualmode/dualModeContracts.js.map +1 -1
  20. package/dist/lib/dualmode/index.d.ts +4 -3
  21. package/dist/lib/dualmode/index.js +5 -4
  22. package/dist/lib/dualmode/index.js.map +1 -1
  23. package/dist/lib/routes/fastifyRouteBuilder.d.ts +30 -0
  24. package/dist/lib/routes/fastifyRouteBuilder.js +331 -0
  25. package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -0
  26. package/dist/lib/routes/fastifyRouteTypes.d.ts +541 -0
  27. package/dist/lib/routes/fastifyRouteTypes.js +68 -0
  28. package/dist/lib/routes/fastifyRouteTypes.js.map +1 -0
  29. package/dist/lib/{sse/fastifySSERouteUtils.d.ts → routes/fastifyRouteUtils.d.ts} +61 -3
  30. package/dist/lib/routes/fastifyRouteUtils.js +315 -0
  31. package/dist/lib/routes/fastifyRouteUtils.js.map +1 -0
  32. package/dist/lib/routes/index.d.ts +4 -0
  33. package/dist/lib/routes/index.js +11 -0
  34. package/dist/lib/routes/index.js.map +1 -0
  35. package/dist/lib/sse/AbstractSSEController.d.ts +14 -9
  36. package/dist/lib/sse/AbstractSSEController.js +10 -5
  37. package/dist/lib/sse/AbstractSSEController.js.map +1 -1
  38. package/dist/lib/sse/index.d.ts +3 -3
  39. package/dist/lib/sse/index.js +4 -4
  40. package/dist/lib/sse/index.js.map +1 -1
  41. package/dist/lib/sse/sseContracts.d.ts +3 -80
  42. package/dist/lib/sse/sseContracts.js +1 -69
  43. package/dist/lib/sse/sseContracts.js.map +1 -1
  44. package/dist/lib/testing/sseHttpClient.d.ts +1 -1
  45. package/dist/lib/testing/sseTestTypes.d.ts +1 -1
  46. package/package.json +6 -6
  47. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.d.ts +0 -30
  48. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js +0 -137
  49. package/dist/lib/dualmode/fastifyDualModeRouteBuilder.js.map +0 -1
  50. package/dist/lib/dualmode/fastifyDualModeTypes.d.ts +0 -153
  51. package/dist/lib/dualmode/fastifyDualModeTypes.js +0 -25
  52. package/dist/lib/dualmode/fastifyDualModeTypes.js.map +0 -1
  53. package/dist/lib/sse/fastifySSERouteBuilder.d.ts +0 -50
  54. package/dist/lib/sse/fastifySSERouteBuilder.js +0 -53
  55. package/dist/lib/sse/fastifySSERouteBuilder.js.map +0 -1
  56. package/dist/lib/sse/fastifySSERouteUtils.js +0 -163
  57. package/dist/lib/sse/fastifySSERouteUtils.js.map +0 -1
  58. package/dist/lib/sse/fastifySSETypes.d.ts +0 -200
  59. package/dist/lib/sse/fastifySSETypes.js +0 -47
  60. package/dist/lib/sse/fastifySSETypes.js.map +0 -1
package/README.md CHANGED
@@ -30,7 +30,7 @@ Very opinionated DI framework for fastify, built on top of awilix
30
30
  - [Prerequisites](#prerequisites)
31
31
  - [Defining SSE Contracts](#defining-sse-contracts)
32
32
  - [Creating SSE Controllers](#creating-sse-controllers)
33
- - [Type-Safe SSE Handlers with buildSSEHandler](#type-safe-sse-handlers-with-buildssehandler)
33
+ - [Type-Safe SSE Handlers with buildHandler](#type-safe-sse-handlers-with-buildhandler)
34
34
  - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
35
35
  - [Registering SSE Controllers](#registering-sse-controllers)
36
36
  - [Registering SSE Routes](#registering-sse-routes)
@@ -398,14 +398,14 @@ await app.register(FastifySSEPlugin)
398
398
 
399
399
  ### Defining SSE Contracts
400
400
 
401
- Use `buildSSEContract` for GET-based SSE streams or `buildPayloadSSEContract` for POST/PUT/PATCH streams. 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'
405
- import { buildSSEContract, buildPayloadSSEContract } from 'opinionated-machine'
405
+ import { buildContract } from 'opinionated-machine'
406
406
 
407
- // GET-based SSE stream with path params
408
- export const channelStreamContract = buildSSEContract({
407
+ // GET-based SSE stream with path params (no body = GET)
408
+ export const channelStreamContract = buildContract({
409
409
  pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
410
410
  params: z.object({ channelId: z.string() }),
411
411
  query: z.object({}),
@@ -416,7 +416,7 @@ export const channelStreamContract = buildSSEContract({
416
416
  })
417
417
 
418
418
  // GET-based SSE stream without path params
419
- export const notificationsContract = buildSSEContract({
419
+ export const notificationsContract = buildContract({
420
420
  pathResolver: () => '/api/notifications/stream',
421
421
  params: z.object({}),
422
422
  query: z.object({ userId: z.string().optional() }),
@@ -429,14 +429,14 @@ export const notificationsContract = buildSSEContract({
429
429
  },
430
430
  })
431
431
 
432
- // POST-based SSE stream (e.g., AI chat completions)
433
- export const chatCompletionContract = buildPayloadSSEContract({
432
+ // POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
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
  }),
@@ -463,12 +463,13 @@ const streamingEvents = {
463
463
 
464
464
  ### Creating SSE Controllers
465
465
 
466
- SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildSSEHandler` for automatic type inference of request parameters:
466
+ SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildHandler` for automatic type inference of request parameters:
467
467
 
468
468
  ```ts
469
469
  import {
470
470
  AbstractSSEController,
471
- buildSSEHandler,
471
+ buildHandler,
472
+ success,
472
473
  type SSEControllerConfig,
473
474
  type SSEConnection
474
475
  } from 'opinionated-machine'
@@ -498,10 +499,10 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
498
499
  return {
499
500
  notificationsStream: {
500
501
  contract: NotificationsSSEController.contracts.notificationsStream,
501
- handler: this.handleStream,
502
+ handlers: this.handleStream,
502
503
  options: {
503
504
  onConnect: (conn) => this.onConnect(conn),
504
- onDisconnect: (conn) => this.onDisconnect(conn),
505
+ onClose: (conn, reason) => this.onClose(conn, reason),
505
506
  },
506
507
  },
507
508
  }
@@ -509,9 +510,8 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
509
510
 
510
511
  // Handler with automatic type inference from contract
511
512
  // connection.send provides type-safe event sending
512
- private handleStream = buildSSEHandler(
513
- notificationsContract,
514
- async (request, connection) => {
513
+ private handleStream = buildHandler(notificationsContract, {
514
+ sse: async (request, connection) => {
515
515
  // request.query is typed from contract: { userId?: string }
516
516
  const userId = request.query.userId ?? 'anonymous'
517
517
  connection.context = { userId }
@@ -531,29 +531,32 @@ export class NotificationsSSEController extends AbstractSSEController<Contracts>
531
531
  // For direct sending within the handler, use the connection's send method.
532
532
  // It provides stricter per-route typing (only events from this specific contract).
533
533
  await connection.send('notification', { id: 'welcome', message: 'Connected!' })
534
+
535
+ // Keep connection open for subscription events (client closes when done)
536
+ return success('maintain_connection')
534
537
  },
535
- )
538
+ })
536
539
 
537
540
  private onConnect = (connection: SSEConnection) => {
538
541
  console.log('Client connected:', connection.id)
539
542
  }
540
543
 
541
- private onDisconnect = (connection: SSEConnection) => {
544
+ private onClose = (connection: SSEConnection, reason: SSECloseReason) => {
542
545
  const userId = connection.context?.userId as string
543
546
  this.notificationService.unsubscribe(userId)
544
- console.log('Client disconnected:', connection.id)
547
+ console.log(`Client disconnected (${reason}):`, connection.id)
545
548
  }
546
549
  }
547
550
  ```
548
551
 
549
- ### Type-Safe SSE Handlers with `buildSSEHandler`
552
+ ### Type-Safe SSE Handlers with `buildHandler`
550
553
 
551
- For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildSSEHandler`:
554
+ For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildHandler`:
552
555
 
553
556
  ```ts
554
557
  import {
555
558
  AbstractSSEController,
556
- buildSSEHandler,
559
+ buildHandler,
557
560
  type SSEControllerConfig,
558
561
  type SSEConnection
559
562
  } from 'opinionated-machine'
@@ -569,9 +572,8 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
569
572
 
570
573
  // Handler with automatic type inference from contract
571
574
  // connection.send is fully typed per-route
572
- private handleChatCompletion = buildSSEHandler(
573
- chatCompletionContract,
574
- async (request, connection) => {
575
+ private handleChatCompletion = buildHandler(chatCompletionContract, {
576
+ sse: async (request, connection) => {
575
577
  // request.body is typed as { message: string; stream: true }
576
578
  // request.query, request.params, request.headers all typed from contract
577
579
  const words = request.body.message.split(' ')
@@ -582,15 +584,15 @@ class ChatSSEController extends AbstractSSEController<Contracts> {
582
584
  }
583
585
 
584
586
  // Gracefully end the stream - all sent data is flushed before connection closes
585
- this.closeConnection(connection.id)
587
+ return success('disconnect')
586
588
  },
587
- )
589
+ })
588
590
 
589
591
  public buildSSERoutes() {
590
592
  return {
591
593
  chatCompletion: {
592
594
  contract: ChatSSEController.contracts.chatCompletion,
593
- handler: this.handleChatCompletion,
595
+ handlers: this.handleChatCompletion,
594
596
  },
595
597
  }
596
598
  }
@@ -725,7 +727,7 @@ public buildSSERoutes() {
725
727
  return {
726
728
  adminStream: {
727
729
  contract: AdminSSEController.contracts.adminStream,
728
- handler: this.handleAdminStream,
730
+ handlers: this.handleAdminStream,
729
731
  options: {
730
732
  // Route-specific authentication
731
733
  preHandler: (request, reply) => {
@@ -734,7 +736,7 @@ public buildSSERoutes() {
734
736
  }
735
737
  },
736
738
  onConnect: (conn) => console.log('Admin connected'),
737
- onDisconnect: (conn) => console.log('Admin disconnected'),
739
+ onClose: (conn, reason) => console.log(`Admin disconnected (${reason})`),
738
740
  // Handle client reconnection with Last-Event-ID
739
741
  onReconnect: async (conn, lastEventId) => {
740
742
  // Return events to replay, or handle manually
@@ -751,12 +753,64 @@ public buildSSERoutes() {
751
753
  **Available route options:**
752
754
 
753
755
  | Option | Description |
754
- |--------|-------------|
756
+ | -------- | ------------- |
755
757
  | `preHandler` | Authentication/authorization hook that runs before SSE connection |
756
758
  | `onConnect` | Called after client connects (SSE handshake complete) |
757
- | `onDisconnect` | Called when client disconnects |
759
+ | `onClose` | Called when connection closes (client disconnect, network failure, or server close). Receives `(connection, reason)` where reason is `'server'` or `'client'` |
758
760
  | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
759
761
  | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
762
+ | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
763
+ | `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
764
+
765
+ **onClose reason parameter:**
766
+ - `'server'`: Server explicitly closed the connection (via `closeConnection()` or `success('disconnect')`)
767
+ - `'client'`: Client closed the connection (EventSource.close(), navigation, network failure)
768
+
769
+ ```ts
770
+ options: {
771
+ onConnect: (conn) => console.log('Client connected'),
772
+ onClose: (conn, reason) => {
773
+ console.log(`Connection closed (${reason}):`, conn.id)
774
+ // reason is 'server' or 'client'
775
+ },
776
+ serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
777
+ heartbeatInterval: 30000, // Send heartbeat every 30 seconds
778
+ }
779
+ ```
780
+
781
+ ### SSE Connection Methods
782
+
783
+ The `connection` object in SSE handlers provides several useful methods:
784
+
785
+ ```ts
786
+ private handleStream = buildHandler(streamContract, {
787
+ sse: async (request, connection) => {
788
+ // Check if connection is still active
789
+ if (connection.isConnected()) {
790
+ await connection.send('status', { connected: true })
791
+ }
792
+
793
+ // Get raw writable stream for advanced use cases (e.g., pipeline)
794
+ const stream = connection.getStream()
795
+
796
+ // Stream messages from an async iterable with automatic validation
797
+ async function* generateMessages() {
798
+ yield { event: 'message' as const, data: { text: 'Hello' } }
799
+ yield { event: 'message' as const, data: { text: 'World' } }
800
+ }
801
+ await connection.sendStream(generateMessages())
802
+
803
+ return success('disconnect')
804
+ },
805
+ })
806
+ ```
807
+
808
+ | Method | Description |
809
+ | -------- | ------------- |
810
+ | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
811
+ | `isConnected()` | Check if the connection is still active |
812
+ | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
813
+ | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
760
814
 
761
815
  ### Graceful Shutdown
762
816
 
@@ -777,7 +831,7 @@ if (!sent) {
777
831
  }
778
832
  ```
779
833
 
780
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onDisconnect`):
834
+ **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
781
835
  - All lifecycle hooks are wrapped in try/catch to prevent crashes
782
836
  - If a `logger` is provided in route options, errors are logged with context
783
837
  - If no logger is provided, errors are silently ignored
@@ -789,11 +843,11 @@ public buildSSERoutes() {
789
843
  return {
790
844
  stream: {
791
845
  contract: streamContract,
792
- handler: this.handleStream,
846
+ handlers: this.handleStream,
793
847
  options: {
794
848
  logger: this.logger, // pino-compatible logger
795
849
  onConnect: (conn) => { /* may throw */ },
796
- onDisconnect: (conn) => { /* may throw */ },
850
+ onClose: (conn, reason) => { /* may throw */ },
797
851
  },
798
852
  },
799
853
  }
@@ -802,43 +856,87 @@ public buildSSERoutes() {
802
856
 
803
857
  ### Long-lived Connections vs Request-Response Streaming
804
858
 
859
+ SSE handlers must return an `Either<Error, SSEHandlerResult>` to explicitly indicate connection management.
860
+
861
+ 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:
862
+
863
+ ```ts
864
+ import { type Either, success, failure } from 'opinionated-machine'
865
+
866
+ // Return success('disconnect') - close connection after handler completes
867
+ // Return success('maintain_connection') - keep connection open for external events
868
+ // Return failure(error) - handle error and close connection
869
+ ```
870
+
805
871
  **Long-lived connections** (notifications, live updates):
806
- - Handler sets up subscriptions and returns
807
- - Connection stays open until client disconnects
808
- - Use `sendEventInternal()` for external triggers (typed with union of all contract events)
872
+ - Handler sets up subscriptions and returns `success('maintain_connection')`
873
+ - Connection stays open indefinitely after handler returns
874
+ - Events are sent later via callbacks using `sendEventInternal()`
875
+ - **Client closes connection** when done (e.g., `eventSource.close()` or navigating away)
876
+ - Server cleans up via `onConnectionClosed()` hook
809
877
 
810
878
  ```ts
811
- private handleStream = buildSSEHandler(streamContract, async (request, connection) => {
812
- // External callbacks (subscriptions, timers) can't access `connection` - it's only in this scope.
813
- // Use sendEventInternal instead - it's a controller method accessible from any callback.
814
- this.service.subscribe(connection.id, (data) => {
815
- this.sendEventInternal(connection.id, { event: 'update', data })
816
- })
817
- // Handler returns, connection stays open
879
+ import { success } from 'opinionated-machine'
880
+
881
+ private handleStream = buildHandler(streamContract, {
882
+ sse: async (request, connection) => {
883
+ // Set up subscription - events sent via callback AFTER handler returns
884
+ this.service.subscribe(connection.id, (data) => {
885
+ this.sendEventInternal(connection.id, { event: 'update', data })
886
+ })
887
+ // Keep connection open until CLIENT disconnects
888
+ return success('maintain_connection')
889
+ },
818
890
  })
891
+
892
+ // Clean up when client disconnects
893
+ protected onConnectionClosed(connection: SSEConnection): void {
894
+ this.service.unsubscribe(connection.id)
895
+ }
819
896
  ```
820
897
 
821
898
  **Request-response streaming** (AI completions):
822
- - Handler sends all events and closes connection
823
- - Use `connection.send` for type-safe event sending
899
+ - Handler sends all events synchronously, then returns `success('disconnect')`
900
+ - Use `connection.send()` for type-safe event sending within the handler
901
+ - Connection automatically closes when handler returns with `'disconnect'`
824
902
 
825
903
  ```ts
826
- private handleChatCompletion = buildSSEHandler(chatCompletionContract, async (request, connection) => {
827
- // request.body is typed from contract
828
- const words = request.body.message.split(' ')
904
+ import { success } from 'opinionated-machine'
829
905
 
830
- for (const word of words) {
831
- // connection.send() provides compile-time type checking for event names and data
832
- await connection.send('chunk', { content: word })
833
- }
906
+ private handleChatCompletion = buildHandler(chatCompletionContract, {
907
+ sse: async (request, connection) => {
908
+ const words = request.body.message.split(' ')
834
909
 
835
- await connection.send('done', { totalTokens: words.length })
910
+ for (const word of words) {
911
+ await connection.send('chunk', { content: word })
912
+ }
913
+ await connection.send('done', { totalTokens: words.length })
836
914
 
837
- // Gracefully end the stream - all sent data is flushed before connection closes
838
- this.closeConnection(connection.id)
915
+ // Signal that streaming is complete and connection should close
916
+ return success('disconnect')
917
+ },
839
918
  })
840
919
  ```
841
920
 
921
+ **Error handling:**
922
+
923
+ Return `failure(error)` to signal an error occurred. The framework will send an error event and close the connection:
924
+
925
+ ```ts
926
+ import { success, failure } from 'opinionated-machine'
927
+
928
+ private handleStream = buildHandler(streamContract, {
929
+ sse: async (request, connection) => {
930
+ try {
931
+ const data = await this.service.getData(request.params.id)
932
+ await connection.send('data', data)
933
+ return success('disconnect')
934
+ } catch (err) {
935
+ return failure(err instanceof Error ? err : new Error(String(err)))
936
+ }
937
+ },
938
+ })
939
+
842
940
  ### SSE Parsing Utilities
843
941
 
844
942
  The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
@@ -1096,7 +1194,7 @@ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1096
1194
  const server = await SSETestServer.create(async (app) => {
1097
1195
  app.get('/api/events', async (request, reply) => {
1098
1196
  reply.sse({ event: 'message', data: { hello: 'world' } })
1099
- reply.sseClose()
1197
+ reply.sse.close()
1100
1198
  })
1101
1199
  })
1102
1200
 
@@ -1308,14 +1406,22 @@ Dual-mode controllers extend `AbstractDualModeController` which inherits from `A
1308
1406
 
1309
1407
  ### Defining Dual-Mode Contracts
1310
1408
 
1311
- Use `buildDualModeContract` for GET routes or `buildPayloadDualModeContract` for POST/PUT/PATCH routes. The key difference from SSE contracts is the addition of `jsonResponse` schema:
1409
+ Dual-mode contracts define endpoints that can return **either** a complete JSON response **or** stream SSE events, based on the client's `Accept` header. Use dual-mode when:
1410
+
1411
+ - Clients may want immediate results (JSON) or real-time updates (SSE)
1412
+ - You're building OpenAI-style APIs where `stream: true` triggers SSE
1413
+ - You need polling fallback for clients that don't support SSE
1414
+
1415
+ To create a dual-mode contract, include a `jsonResponse` schema in your `buildContract` call:
1416
+ - Has `jsonResponse` but no `requestBody` → GET dual-mode route
1417
+ - Has both `jsonResponse` and `requestBody` → POST/PUT/PATCH dual-mode route
1312
1418
 
1313
1419
  ```ts
1314
1420
  import { z } from 'zod'
1315
- import { buildDualModeContract, buildPayloadDualModeContract } from 'opinionated-machine'
1421
+ import { buildContract } from 'opinionated-machine'
1316
1422
 
1317
- // GET dual-mode route (polling or streaming job status)
1318
- export const jobStatusContract = buildDualModeContract({
1423
+ // GET dual-mode route (polling or streaming job status) - has jsonResponse, no requestBody
1424
+ export const jobStatusContract = buildContract({
1319
1425
  pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1320
1426
  params: z.object({ jobId: z.string().uuid() }),
1321
1427
  query: z.object({ verbose: z.string().optional() }),
@@ -1331,14 +1437,14 @@ export const jobStatusContract = buildDualModeContract({
1331
1437
  },
1332
1438
  })
1333
1439
 
1334
- // POST dual-mode route (OpenAI-style chat completion)
1335
- export const chatCompletionContract = buildPayloadDualModeContract({
1440
+ // POST dual-mode route (OpenAI-style chat completion) - has both jsonResponse and requestBody
1441
+ export const chatCompletionContract = buildContract({
1336
1442
  method: 'POST',
1337
1443
  pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1338
1444
  params: z.object({ chatId: z.string().uuid() }),
1339
1445
  query: z.object({}),
1340
1446
  requestHeaders: z.object({ authorization: z.string() }),
1341
- body: z.object({ message: z.string() }),
1447
+ requestBody: z.object({ message: z.string() }),
1342
1448
  jsonResponse: z.object({
1343
1449
  reply: z.string(),
1344
1450
  usage: z.object({ tokens: z.number() }),
@@ -1352,14 +1458,122 @@ export const chatCompletionContract = buildPayloadDualModeContract({
1352
1458
 
1353
1459
  **Note**: Dual-mode contracts use `pathResolver` instead of static `path` for type-safe path construction. The `pathResolver` function receives typed params and returns the URL path.
1354
1460
 
1461
+ ### Response Headers (JSON Mode)
1462
+
1463
+ Dual-mode contracts support an optional `responseHeaders` schema to define and validate headers sent with JSON responses. This is useful for documenting expected headers (rate limits, pagination, cache control) and validating that your handlers set them correctly:
1464
+
1465
+ ```ts
1466
+ export const rateLimitedContract = buildContract({
1467
+ method: 'POST',
1468
+ pathResolver: () => '/api/rate-limited',
1469
+ params: z.object({}),
1470
+ query: z.object({}),
1471
+ requestHeaders: z.object({}),
1472
+ requestBody: z.object({ data: z.string() }),
1473
+ jsonResponse: z.object({ result: z.string() }),
1474
+ // Define expected response headers
1475
+ responseHeaders: z.object({
1476
+ 'x-ratelimit-limit': z.string(),
1477
+ 'x-ratelimit-remaining': z.string(),
1478
+ 'x-ratelimit-reset': z.string(),
1479
+ }),
1480
+ events: {
1481
+ result: z.object({ success: z.boolean() }),
1482
+ },
1483
+ })
1484
+ ```
1485
+
1486
+ In your handler, set headers using `reply.header()`:
1487
+
1488
+ ```ts
1489
+ handlers: {
1490
+ json: async (request, reply) => {
1491
+ reply.header('x-ratelimit-limit', '100')
1492
+ reply.header('x-ratelimit-remaining', '99')
1493
+ reply.header('x-ratelimit-reset', '1640000000')
1494
+ return { result: 'success' }
1495
+ },
1496
+ sse: async (request, connection) => { /* ... */ },
1497
+ }
1498
+ ```
1499
+
1500
+ If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
1501
+
1502
+ ### Multi-Format Responses (Verbose Mode)
1503
+
1504
+ 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:
1505
+
1506
+ ```ts
1507
+ import { z } from 'zod'
1508
+ import { buildContract } from 'opinionated-machine'
1509
+
1510
+ // Multi-format export endpoint
1511
+ export const exportContract = buildContract({
1512
+ method: 'POST',
1513
+ pathResolver: () => '/api/export',
1514
+ params: z.object({}),
1515
+ query: z.object({}),
1516
+ requestHeaders: z.object({}),
1517
+ requestBody: z.object({
1518
+ data: z.array(z.object({ name: z.string(), value: z.number() })),
1519
+ }),
1520
+ // Define multiple response formats
1521
+ multiFormatResponses: {
1522
+ 'application/json': z.object({
1523
+ items: z.array(z.object({ name: z.string(), value: z.number() })),
1524
+ count: z.number(),
1525
+ }),
1526
+ 'text/plain': z.string(),
1527
+ 'text/csv': z.string(),
1528
+ },
1529
+ events: {
1530
+ progress: z.object({ percent: z.number() }),
1531
+ done: z.object({ format: z.string() }),
1532
+ },
1533
+ })
1534
+ ```
1535
+
1536
+ The handler structure changes to `sync` with per-format handlers:
1537
+
1538
+ ```ts
1539
+ handlers: buildHandler(exportContract, {
1540
+ sync: {
1541
+ 'application/json': (request) => ({
1542
+ items: request.body.data,
1543
+ count: request.body.data.length,
1544
+ }),
1545
+ 'text/plain': (request) =>
1546
+ request.body.data.map((item) => `${item.name}: ${item.value}`).join('\n'),
1547
+ 'text/csv': (request) =>
1548
+ `name,value\n${request.body.data.map((item) => `${item.name},${item.value}`).join('\n')}`,
1549
+ },
1550
+ sse: async (request, connection) => {
1551
+ // SSE streaming handler
1552
+ await connection.send('done', { totalItems: request.body.data.length })
1553
+ return success('disconnect')
1554
+ },
1555
+ })
1556
+ ```
1557
+
1558
+ **Contract styles comparison:**
1559
+
1560
+ | Style | Contract Field | Handler Key | Use Case |
1561
+ |-------|---------------|-------------|----------|
1562
+ | Simplified | `jsonResponse` | `json` | Single JSON format (recommended) |
1563
+ | Verbose | `multiFormatResponses` | `sync` | Multiple formats (JSON, text, CSV, etc.) |
1564
+
1565
+ TypeScript enforces the correct handler structure based on your contract:
1566
+ - `jsonResponse` contracts must use `json` handler
1567
+ - `multiFormatResponses` contracts must use `sync` handlers for all declared formats
1568
+
1355
1569
  ### Implementing Dual-Mode Controllers
1356
1570
 
1357
- Dual-mode controllers use `buildDualModeHandler` to define both JSON and SSE handlers:
1571
+ Dual-mode controllers use `buildHandler` to define both JSON and SSE handlers:
1358
1572
 
1359
1573
  ```ts
1360
1574
  import {
1361
1575
  AbstractDualModeController,
1362
- buildDualModeHandler,
1576
+ buildHandler,
1363
1577
  type BuildFastifyDualModeRoutesReturnType,
1364
1578
  type DualModeControllerConfig,
1365
1579
  } from 'opinionated-machine'
@@ -1388,24 +1602,24 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1388
1602
  return {
1389
1603
  chatCompletion: {
1390
1604
  contract: ChatDualModeController.contracts.chatCompletion,
1391
- handlers: buildDualModeHandler(chatCompletionContract, {
1605
+ handlers: buildHandler(chatCompletionContract, {
1392
1606
  // JSON mode - return complete response
1393
- json: async (ctx) => {
1394
- const result = await this.aiService.complete(ctx.request.body.message)
1607
+ json: async (request, _reply) => {
1608
+ const result = await this.aiService.complete(request.body.message)
1395
1609
  return {
1396
1610
  reply: result.text,
1397
1611
  usage: { tokens: result.tokenCount },
1398
1612
  }
1399
1613
  },
1400
1614
  // SSE mode - stream response chunks
1401
- sse: async (ctx) => {
1615
+ sse: async (request, connection) => {
1402
1616
  let totalTokens = 0
1403
- for await (const chunk of this.aiService.stream(ctx.request.body.message)) {
1404
- await ctx.connection.send('chunk', { delta: chunk.text })
1617
+ for await (const chunk of this.aiService.stream(request.body.message)) {
1618
+ await connection.send('chunk', { delta: chunk.text })
1405
1619
  totalTokens += chunk.tokenCount ?? 0
1406
1620
  }
1407
- await ctx.connection.send('done', { usage: { total: totalTokens } })
1408
- this.closeConnection(ctx.connection.id)
1621
+ await connection.send('done', { usage: { total: totalTokens } })
1622
+ return success('disconnect')
1409
1623
  },
1410
1624
  }),
1411
1625
  options: {
@@ -1419,7 +1633,7 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1419
1633
  },
1420
1634
  // Optional: SSE lifecycle hooks
1421
1635
  onConnect: (conn) => console.log('Client connected:', conn.id),
1422
- onDisconnect: (conn) => console.log('Client disconnected:', conn.id),
1636
+ onClose: (conn, reason) => console.log(`Client disconnected (${reason}):`, conn.id),
1423
1637
  },
1424
1638
  },
1425
1639
  }
@@ -1427,14 +1641,14 @@ export class ChatDualModeController extends AbstractDualModeController<Contracts
1427
1641
  }
1428
1642
  ```
1429
1643
 
1430
- **Handler Context:**
1644
+ **Handler Signatures:**
1431
1645
 
1432
- | Mode | Context Properties |
1433
- | ---- | ------------------ |
1434
- | `json` | `ctx.mode`, `ctx.request`, `ctx.reply` |
1435
- | `sse` | `ctx.mode`, `ctx.connection`, `ctx.request` |
1646
+ | Mode | Signature |
1647
+ | ---- | --------- |
1648
+ | `json` | `(request, reply) => Response` |
1649
+ | `sse` | `(request, connection) => Either<Error, SSEHandlerResult>` |
1436
1650
 
1437
- The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `ctx.connection.send()` for type-safe event streaming.
1651
+ The `json` handler must return a value matching `jsonResponse` schema. The `sse` handler uses `connection.send()` for type-safe event streaming.
1438
1652
 
1439
1653
  ### Registering Dual-Mode Controllers
1440
1654
 
@@ -1517,6 +1731,21 @@ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1517
1731
  curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1518
1732
  ```
1519
1733
 
1734
+ **Subtype wildcards** are supported for flexible content negotiation:
1735
+
1736
+ ```bash
1737
+ # Accept any text format (matches text/plain, text/csv, etc.)
1738
+ curl -H "Accept: text/*" ...
1739
+
1740
+ # Accept any application format (matches application/json, application/xml, etc.)
1741
+ curl -H "Accept: application/*" ...
1742
+
1743
+ # Combine with quality values
1744
+ curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
1745
+ ```
1746
+
1747
+ The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
1748
+
1520
1749
  ### Testing Dual-Mode Controllers
1521
1750
 
1522
1751
  Test both JSON and SSE modes:
package/dist/index.d.ts CHANGED
@@ -2,9 +2,11 @@ export { AbstractController, type BuildRoutesReturnType } from './lib/AbstractCo
2
2
  export { AbstractModule, type MandatoryNameAndRegistrationPair, type UnionToIntersection, } from './lib/AbstractModule.js';
3
3
  export { AbstractTestContextFactory, type CreateTestContextParams, } from './lib/AbstractTestContextFactory.js';
4
4
  export type { NestedPartial } from './lib/configUtils.js';
5
+ export * from './lib/contracts/index.js';
5
6
  export { type DependencyInjectionOptions, DIContext, type RegisterDependenciesParams, } from './lib/DIContext.js';
6
7
  export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
7
8
  export * from './lib/dualmode/index.js';
8
9
  export * from './lib/resolverFunctions.js';
10
+ export * from './lib/routes/index.js';
9
11
  export * from './lib/sse/index.js';
10
12
  export * from './lib/testing/index.js';
package/dist/index.js CHANGED
@@ -1,11 +1,15 @@
1
1
  export { AbstractController } from './lib/AbstractController.js';
2
2
  export { AbstractModule, } from './lib/AbstractModule.js';
3
3
  export { AbstractTestContextFactory, } from './lib/AbstractTestContextFactory.js';
4
+ // Contracts (unified builder)
5
+ export * from './lib/contracts/index.js';
4
6
  export { DIContext, } from './lib/DIContext.js';
5
7
  export { ENABLE_ALL, isAnyMessageQueueConsumerEnabled, isEnqueuedJobWorkersEnabled, isJobQueueEnabled, isMessageQueueConsumerEnabled, isPeriodicJobEnabled, resolveJobQueuesEnabled, } from './lib/diConfigUtils.js';
6
8
  // Dual-mode (SSE + JSON)
7
9
  export * from './lib/dualmode/index.js';
8
10
  export * from './lib/resolverFunctions.js';
11
+ // Routes (unified route builder)
12
+ export * from './lib/routes/index.js';
9
13
  // SSE
10
14
  export * from './lib/sse/index.js';
11
15
  // SSE testing utilities