opinionated-machine 12.0.1 → 13.0.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 (107) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +846 -2027
  3. package/dist/index.d.ts +0 -3
  4. package/dist/index.js +0 -5
  5. package/dist/index.js.map +1 -1
  6. package/dist/lib/AbstractModule.d.ts +7 -9
  7. package/dist/lib/AbstractModule.js +7 -9
  8. package/dist/lib/AbstractModule.js.map +1 -1
  9. package/dist/lib/DIContext.d.ts +4 -80
  10. package/dist/lib/DIContext.js +14 -232
  11. package/dist/lib/DIContext.js.map +1 -1
  12. package/dist/lib/api-contracts/AbstractApiController.d.ts +3 -1
  13. package/dist/lib/api-contracts/AbstractApiController.js +3 -1
  14. package/dist/lib/api-contracts/AbstractApiController.js.map +1 -1
  15. package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
  16. package/dist/lib/api-contracts/apiSseConnectionRegistry.d.ts +3 -4
  17. package/dist/lib/api-contracts/apiSseConnectionRegistry.js +1 -2
  18. package/dist/lib/api-contracts/apiSseConnectionRegistry.js.map +1 -1
  19. package/dist/lib/api-contracts/asApiControllerClass.d.ts +1 -2
  20. package/dist/lib/api-contracts/asApiControllerClass.js +1 -2
  21. package/dist/lib/api-contracts/asApiControllerClass.js.map +1 -1
  22. package/dist/lib/gateway/manifest/buildManifest.d.ts +3 -28
  23. package/dist/lib/gateway/manifest/buildManifest.js +1 -19
  24. package/dist/lib/gateway/manifest/buildManifest.js.map +1 -1
  25. package/dist/lib/gateway/manifest/manifestSchema.js +1 -1
  26. package/dist/lib/gateway/manifest/manifestSchema.js.map +1 -1
  27. package/dist/lib/gateway/routeStreaming.d.ts +3 -4
  28. package/dist/lib/gateway/routeStreaming.js.map +1 -1
  29. package/dist/lib/gateway/withGatewayMetadata.d.ts +18 -20
  30. package/dist/lib/gateway/withGatewayMetadata.js +16 -16
  31. package/dist/lib/gateway/withGatewayMetadata.js.map +1 -1
  32. package/dist/lib/resolverFunctions.d.ts +0 -58
  33. package/dist/lib/resolverFunctions.js +0 -92
  34. package/dist/lib/resolverFunctions.js.map +1 -1
  35. package/dist/lib/sse/SSESessionSpy.d.ts +8 -11
  36. package/dist/lib/sse/SSESessionSpy.js +3 -4
  37. package/dist/lib/sse/SSESessionSpy.js.map +1 -1
  38. package/dist/lib/sse/index.d.ts +1 -3
  39. package/dist/lib/sse/index.js +0 -3
  40. package/dist/lib/sse/index.js.map +1 -1
  41. package/dist/lib/sse/rooms/SSERoomBroadcaster.d.ts +8 -7
  42. package/dist/lib/sse/rooms/SSERoomBroadcaster.js +8 -7
  43. package/dist/lib/sse/rooms/SSERoomBroadcaster.js.map +1 -1
  44. package/dist/lib/sse/rooms/SSERoomManager.d.ts +1 -1
  45. package/dist/lib/sse/rooms/SSERoomManager.js +1 -1
  46. package/dist/lib/sse/rooms/defineRoom.d.ts +2 -2
  47. package/dist/lib/sse/rooms/defineRoom.js +2 -2
  48. package/dist/lib/sse/rooms/types.d.ts +3 -3
  49. package/dist/lib/sse/sseSendDiagnostics.d.ts +13 -2
  50. package/dist/lib/sse/sseSendDiagnostics.js +17 -0
  51. package/dist/lib/sse/sseSendDiagnostics.js.map +1 -1
  52. package/dist/lib/sse/sseTypes.d.ts +2 -55
  53. package/dist/lib/testing/apiSseEventValidation.d.ts +3 -3
  54. package/dist/lib/testing/apiSseEventValidation.js.map +1 -1
  55. package/dist/lib/testing/apiSseHttpHelpers.js +7 -2
  56. package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -1
  57. package/dist/lib/testing/apiSseInjectHelpers.d.ts +2 -4
  58. package/dist/lib/testing/apiSseInjectHelpers.js +11 -6
  59. package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
  60. package/dist/lib/testing/apiSseTestTypes.d.ts +3 -3
  61. package/dist/lib/testing/index.d.ts +2 -3
  62. package/dist/lib/testing/index.js +0 -1
  63. package/dist/lib/testing/index.js.map +1 -1
  64. package/dist/lib/testing/sseHttpClient.d.ts +5 -41
  65. package/dist/lib/testing/sseHttpClient.js +2 -6
  66. package/dist/lib/testing/sseHttpClient.js.map +1 -1
  67. package/dist/lib/testing/sseInjectShared.d.ts +1 -2
  68. package/dist/lib/testing/sseInjectShared.js +1 -2
  69. package/dist/lib/testing/sseInjectShared.js.map +1 -1
  70. package/dist/lib/testing/sseSessionSpyFactory.d.ts +8 -11
  71. package/dist/lib/testing/sseSessionSpyFactory.js +6 -8
  72. package/dist/lib/testing/sseSessionSpyFactory.js.map +1 -1
  73. package/dist/lib/testing/sseTestTypes.d.ts +0 -77
  74. package/package.json +6 -6
  75. package/dist/lib/AbstractController.d.ts +0 -35
  76. package/dist/lib/AbstractController.js +0 -23
  77. package/dist/lib/AbstractController.js.map +0 -1
  78. package/dist/lib/dualmode/AbstractDualModeController.d.ts +0 -95
  79. package/dist/lib/dualmode/AbstractDualModeController.js +0 -79
  80. package/dist/lib/dualmode/AbstractDualModeController.js.map +0 -1
  81. package/dist/lib/dualmode/dualModeTypes.d.ts +0 -24
  82. package/dist/lib/dualmode/dualModeTypes.js +0 -2
  83. package/dist/lib/dualmode/dualModeTypes.js.map +0 -1
  84. package/dist/lib/dualmode/index.d.ts +0 -4
  85. package/dist/lib/dualmode/index.js +0 -4
  86. package/dist/lib/dualmode/index.js.map +0 -1
  87. package/dist/lib/routes/fastifyRouteBuilder.d.ts +0 -29
  88. package/dist/lib/routes/fastifyRouteBuilder.js +0 -519
  89. package/dist/lib/routes/fastifyRouteBuilder.js.map +0 -1
  90. package/dist/lib/routes/fastifyRouteTypes.d.ts +0 -905
  91. package/dist/lib/routes/fastifyRouteTypes.js +0 -20
  92. package/dist/lib/routes/fastifyRouteTypes.js.map +0 -1
  93. package/dist/lib/routes/fastifyRouteUtils.d.ts +0 -190
  94. package/dist/lib/routes/fastifyRouteUtils.js +0 -528
  95. package/dist/lib/routes/fastifyRouteUtils.js.map +0 -1
  96. package/dist/lib/routes/index.d.ts +0 -4
  97. package/dist/lib/routes/index.js +0 -11
  98. package/dist/lib/routes/index.js.map +0 -1
  99. package/dist/lib/routes/sseResponseSchema.d.ts +0 -35
  100. package/dist/lib/routes/sseResponseSchema.js +0 -115
  101. package/dist/lib/routes/sseResponseSchema.js.map +0 -1
  102. package/dist/lib/sse/AbstractSSEController.d.ts +0 -343
  103. package/dist/lib/sse/AbstractSSEController.js +0 -457
  104. package/dist/lib/sse/AbstractSSEController.js.map +0 -1
  105. package/dist/lib/testing/sseInjectHelpers.d.ts +0 -64
  106. package/dist/lib/testing/sseInjectHelpers.js +0 -154
  107. package/dist/lib/testing/sseInjectHelpers.js.map +0 -1
package/README.md CHANGED
@@ -7,6 +7,7 @@ Very opinionated DI framework for fastify, built on top of awilix
7
7
  - [Managing global public dependencies across modules](#managing-global-public-dependencies-across-modules)
8
8
  - [Avoiding circular dependencies in typed cradle parameters](#avoiding-circular-dependencies-in-typed-cradle-parameters)
9
9
  - [Defining controllers](#defining-controllers)
10
+ - [Migrating from legacy contracts](#migrating-from-legacy-contracts)
10
11
  - [Putting it all together](#putting-it-all-together)
11
12
  - [Resolver Functions](#resolver-functions)
12
13
  - [Basic Resolvers](#basic-resolvers)
@@ -17,9 +18,7 @@ Very opinionated DI framework for fastify, built on top of awilix
17
18
  - [`asServiceClass`](#asserviceclasstype-opts)
18
19
  - [`asUseCaseClass`](#asusecaseclasstype-opts)
19
20
  - [`asRepositoryClass`](#asrepositoryclasstype-opts)
20
- - [`asControllerClass`](#ascontrollerclasstype-opts)
21
- - [`asSSEControllerClass`](#asssecontrollerclasstype-sseoptions-opts)
22
- - [`asDualModeControllerClass`](#asdualmodecontrollerclasstype-sseoptions-opts)
21
+ - [`asApiControllerClass`](#asapicontrollerclasstype-opts)
23
22
  - [Message Queue Resolvers](#message-queue-resolvers)
24
23
  - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
25
24
  - [Background Job Resolvers](#background-job-resolvers)
@@ -30,33 +29,27 @@ Very opinionated DI framework for fastify, built on top of awilix
30
29
  - [`asEnqueuedJobQueueManagerFunction`](#asenqueuedjobqueuemanagerfunctionfn-dioptions-opts)
31
30
  - [Server-Sent Events (SSE)](#server-sent-events-sse)
32
31
  - [Prerequisites](#prerequisites)
33
- - [Defining SSE Contracts](#defining-sse-contracts)
34
- - [Creating SSE Controllers](#creating-sse-controllers)
35
- - [Type-Safe SSE Handlers with buildHandler](#type-safe-sse-handlers-with-buildhandler)
36
- - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
37
- - [Registering SSE Controllers](#registering-sse-controllers)
38
- - [Registering SSE Routes](#registering-sse-routes)
39
- - [Broadcasting Events](#broadcasting-events)
40
- - [Controller-Level Hooks](#controller-level-hooks)
41
- - [Route-Level Options](#route-level-options)
42
- - [Graceful Shutdown](#graceful-shutdown)
32
+ - [Defining SSE Routes](#defining-sse-routes)
33
+ - [Session Modes](#session-modes)
34
+ - [SSE Session Methods](#sse-session-methods)
35
+ - [Route Options](#route-options)
43
36
  - [Error Handling](#error-handling)
44
- - [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
37
+ - [Graceful Shutdown](#graceful-shutdown)
38
+ - [Dual-Mode Routes](#dual-mode-routes)
45
39
  - [SSE Parsing Utilities](#sse-parsing-utilities)
46
40
  - [parseSSEResponse](#parsesseresponse)
47
41
  - [createSSEStreamParser and parseSSEStream](#createssestreamparser-and-parsessestream)
48
42
  - [parseSSEEvents](#parsesseevents)
49
43
  - [parseSSEBuffer](#parsessebuffer)
50
44
  - [ParsedSSEEvent Type](#parsedsseevent-type)
51
- - [Testing SSE Controllers](#testing-sse-controllers)
45
+ - [Testing SSE Routes](#testing-sse-routes)
52
46
  - [SSESessionSpy API](#ssesessionspy-api)
53
- - [Session Monitoring](#session-monitoring)
54
47
  - [SSE Rooms](#sse-rooms)
55
48
  - [Enabling Rooms](#enabling-rooms)
56
49
  - [Session Room Operations](#session-room-operations)
57
50
  - [Broadcasting to Rooms](#broadcasting-to-rooms)
58
- - [Room Broadcaster (Decoupled Broadcasting)](#room-broadcaster-decoupled-broadcasting)
59
51
  - [Room Event Publisher (Fire-and-Forget)](#room-event-publisher-fire-and-forget)
52
+ - [`publish` vs `safePublish`](#publish-vs-safepublish)
60
53
  - [Room Name Helpers](#room-name-helpers)
61
54
  - [Room Query Methods](#room-query-methods)
62
55
  - [Auto-Leave on Disconnect](#auto-leave-on-disconnect)
@@ -73,22 +66,13 @@ Very opinionated DI framework for fastify, built on top of awilix
73
66
  - [Data Loading with layered-loader](#data-loading-with-layered-loader)
74
67
  - [Testing](#testing)
75
68
  - [SSE Test Utilities](#sse-test-utilities)
76
- - [Quick Reference](#quick-reference)
77
- - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
69
+ - [Which test client should I use?](#which-test-client-should-i-use)
70
+ - [Detailed Comparison](#detailed-comparison)
78
71
  - [SSEHttpClient](#ssehttpclient)
79
72
  - [SSEInjectClient](#sseinjectclient)
80
73
  - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
81
74
  - [Contract-Aware HTTP Helpers](#contract-aware-http-helpers)
82
75
  - [When a Handler Fails to Send an Event](#when-a-handler-fails-to-send-an-event)
83
- - [Dual-Mode Controllers (SSE + Sync)](#dual-mode-controllers-sse--sync)
84
- - [Overview](#overview)
85
- - [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
86
- - [Response Headers (Sync Mode)](#response-headers-sync-mode)
87
- - [Status-Specific Response Schemas (responseBodySchemasByStatusCode)](#status-specific-response-schemas-responsebodyschemasbystatuscode)
88
- - [Implementing Dual-Mode Controllers](#implementing-dual-mode-controllers)
89
- - [Registering Dual-Mode Controllers](#registering-dual-mode-controllers)
90
- - [Accept Header Routing](#accept-header-routing)
91
- - [Testing Dual-Mode Controllers](#testing-dual-mode-controllers)
92
76
  - [Gateway Configuration](#gateway-configuration)
93
77
  - [Quick Start](#quick-start)
94
78
  - [Annotating Routes](#annotating-routes)
@@ -101,6 +85,7 @@ Very opinionated DI framework for fastify, built on top of awilix
101
85
  - [What's Not Covered](#whats-not-covered)
102
86
  - [Polling Fallback for SSE](#polling-fallback-for-sse)
103
87
  - [Serving the Pattern](#serving-the-pattern)
88
+ - [SSE Rooms Authorization](#sse-rooms-authorization)
104
89
  - [Monotonic Event IDs](#monotonic-event-ids)
105
90
  - [Server-Side Guarantees Checklist](#server-side-guarantees-checklist)
106
91
  - [Development](#development)
@@ -110,7 +95,7 @@ Very opinionated DI framework for fastify, built on top of awilix
110
95
  Define a module, or several modules, that will be used for resolving dependency graphs, using awilix:
111
96
 
112
97
  ```ts
113
- import { AbstractModule, type InferModuleDependencies, asSingletonClass, asMessageQueueHandlerClass, asEnqueuedJobWorkerClass, asJobQueueClass, asControllerClass } from 'opinionated-machine'
98
+ import { AbstractModule, type InferModuleDependencies, asSingletonClass, asMessageQueueHandlerClass, asEnqueuedJobWorkerClass, asJobQueueClass, asApiControllerClass } from 'opinionated-machine'
114
99
 
115
100
  export class MyModule extends AbstractModule {
116
101
  resolveDependencies(
@@ -151,10 +136,10 @@ export class MyModule extends AbstractModule {
151
136
  }
152
137
 
153
138
  // controllers will be automatically registered on fastify app
154
- // both REST and SSE controllers go here - SSE controllers are auto-detected
139
+ // by DIContext.registerRoutes(); JSON, SSE and dual-mode routes alike
155
140
  resolveControllers(diOptions: DependencyInjectionOptions) {
156
141
  return {
157
- controller: asControllerClass(MyController),
142
+ controller: asApiControllerClass(MyController),
158
143
  }
159
144
  }
160
145
  }
@@ -472,58 +457,108 @@ export class MyModule extends AbstractModule<ModuleDependencies, ExternalDepende
472
457
 
473
458
  ## Defining controllers
474
459
 
475
- Controllers require using fastify-api-contracts and allow to define application routes.
460
+ A controller extends `AbstractApiController`, declares its contracts in a `static contracts` object and builds one route per contract with `buildApiRoute`. Contracts come from `defineApiContract` in `@lokalise/api-contracts`. The same controller can hold plain JSON routes, SSE routes and dual-mode routes; the response mode comes from the contract.
476
461
 
477
462
  ```ts
478
- import { buildFastifyRoute } from '@lokalise/fastify-api-contracts'
479
- import { buildRestContract } from '@lokalise/api-contracts'
463
+ import { defineApiContract, noBodyResponse } from '@lokalise/api-contracts'
464
+ import { AbstractApiController, buildApiRoute } from 'opinionated-machine'
480
465
  import { z } from 'zod/v4'
481
- import { AbstractController } from 'opinionated-machine'
482
466
 
483
- const BODY_SCHEMA = z.object({})
484
- const PATH_PARAMS_SCHEMA = z.object({
485
- userId: z.string(),
467
+ const getUserContract = defineApiContract({
468
+ visibility: 'public',
469
+ method: 'get',
470
+ summary: 'Get user',
471
+ pathResolver: ({ userId }) => `/users/${userId}`,
472
+ requestPathParamsSchema: z.object({ userId: z.string() }),
473
+ responsesByStatusCode: {
474
+ 200: z.object({ id: z.string(), name: z.string() }),
475
+ 404: z.object({ message: z.string() }),
476
+ },
486
477
  })
487
478
 
488
- const contract = buildRestContract({
479
+ const deleteUserContract = defineApiContract({
489
480
  visibility: 'public',
490
481
  method: 'delete',
491
- successResponseBodySchema: BODY_SCHEMA,
492
- requestPathParamsSchema: PATH_PARAMS_SCHEMA,
493
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
482
+ summary: 'Delete user',
483
+ pathResolver: ({ userId }) => `/users/${userId}`,
484
+ requestPathParamsSchema: z.object({ userId: z.string() }),
485
+ responsesByStatusCode: { 204: noBodyResponse() },
494
486
  })
495
487
 
496
- export class MyController extends AbstractController<typeof MyController.contracts> {
497
- public static contracts = { deleteItem: contract } as const
498
- private readonly service: Service
488
+ export class UserController extends AbstractApiController<typeof UserController.contracts> {
489
+ static contracts = {
490
+ getUser: getUserContract,
491
+ deleteUser: deleteUserContract,
492
+ } as const
493
+
494
+ private readonly userService: UserService
499
495
 
500
- constructor({ service }: ModuleDependencies) {
501
- super()
502
- this.service = testService
496
+ constructor({ userService }: ModuleDependencies) {
497
+ super()
498
+ this.userService = userService
503
499
  }
504
500
 
505
- private deleteItem = buildFastifyRoute(
506
- TestController.contracts.deleteItem,
507
- async (req, reply) => {
508
- req.log.info(req.params.userId)
509
- this.service.execute()
510
- await reply.status(204).send()
511
- },
512
- )
501
+ readonly routes = {
502
+ getUser: buildApiRoute(UserController.contracts.getUser, async (request) => {
503
+ const user = await this.userService.find(request.params.userId)
504
+ if (!user) {
505
+ return { status: 404, body: { message: 'User not found' } }
506
+ }
507
+ return { status: 200, body: user }
508
+ }),
513
509
 
514
- public buildRoutes() {
515
- return {
516
- deleteItem: this.deleteItem,
517
- }
518
- }
510
+ deleteUser: buildApiRoute(UserController.contracts.deleteUser, async (request) => {
511
+ await this.userService.delete(request.params.userId)
512
+ return { status: 204, body: null }
513
+ }),
514
+ }
515
+ }
516
+ ```
517
+
518
+ Handlers return `{ status, body }` and never call `reply.send()`; the response is validated against the contract before it is sent. The `routes` object must have one entry per key of `contracts`, which the class generic enforces.
519
+
520
+ Register the controller in the module's `resolveControllers()` with `asApiControllerClass`:
521
+
522
+ ```ts
523
+ resolveControllers() {
524
+ return {
525
+ userController: asApiControllerClass(UserController),
526
+ }
519
527
  }
520
528
  ```
521
529
 
530
+ `DIContext` throws when a controller returned from `resolveControllers()` was registered with anything other than `asApiControllerClass`.
531
+
532
+ The full guide to the handler model, SSE and dual-mode routes, route options and testing is in [lib/api-contracts/docs.md](./lib/api-contracts/docs.md).
533
+
534
+ ## Migrating from legacy contracts
535
+
536
+ Version 9 of `@lokalise/api-contracts` and version 8 of `@lokalise/fastify-api-contracts` removed the legacy contract builders (`buildRestContract`, `buildContract`, `buildSseContract`, ...) and `buildFastifyRoute`. This package dropped everything built on them. Define contracts with `defineApiContract` (SSE responses with `sseBody()` / `sseResponse()`), then replace the removed APIs as follows:
537
+
538
+ | Removed | Replacement |
539
+ | ------- | ----------- |
540
+ | `AbstractController`, `AbstractSSEController`, `AbstractDualModeController` | `AbstractApiController`. One controller holds JSON, SSE and dual-mode routes |
541
+ | `buildRoutes()`, `buildSSERoutes()`, `buildDualModeRoutes()`, `BuildRoutesReturnType` | a `readonly routes` object |
542
+ | `asControllerClass`, `asSSEControllerClass`, `asDualModeControllerClass` | `asApiControllerClass` |
543
+ | `buildFastifyRoute`, `buildHandler` (`sync` / `sse` handlers) | `buildApiRoute(contract, handler, options?)` with a single `(request, reply, { sse, expectedContentType }) => { status, body }` handler |
544
+ | `sse.respond(status, body)` | return `{ status, body }` before calling `sse.start()` |
545
+ | `defaultMode` on dual-mode routes | branch on `expectedContentType` in the handler |
546
+ | `DIContext.registerSSERoutes()`, `DIContext.registerDualModeRoutes()` | `DIContext.registerRoutes()`, which registers every route, SSE included |
547
+ | `asSSEControllerClass(..., { rooms: true })`, `session.rooms` | `buildApiRoute(contract, handler, { sseRooms: sseRoomBroadcaster })`, `getSessionRooms(session)` |
548
+ | Controller `broadcast()`, `broadcastToRoom()`, `sendEvent()`, `sendEventInternal()`, `getConnections()` | `SSERoomBroadcaster` / `SSERoomEventPublisher` for fan-out, `session.send()` inside the handler |
549
+ | Controller hooks `onConnectionEstablished` / `onConnectionClosed` | route options `onConnect` / `onClose` |
550
+ | `injectSSE`, `injectPayloadSSE` | `injectApiSSE` |
551
+ | `awaitServerConnection: { controller }`, `controller.connectionSpy` | `createSSESessionSpy()` and `awaitServerConnection: { spy }` |
552
+ | `DependencyInjectionOptions.isTestMode` | removed, nothing replaces it |
553
+ | `buildGatewayManifest({ includeStreamingControllers })` | removed. Every route is in the manifest |
554
+ | `SSEContractDefinition`, `DualModeContractDefinition`, `SSEEventSchemas` and the other legacy contract type re-exports | the types of `@lokalise/api-contracts` / `@lokalise/fastify-api-contracts` |
555
+
522
556
  ## Putting it all together
523
557
 
524
558
  Typical usage with a fastify app looks like this:
525
559
 
526
560
  ```ts
561
+ import FastifySSEPlugin from '@fastify/sse'
527
562
  import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
528
563
  import { createContainer } from 'awilix'
529
564
  import { fastify } from 'fastify'
@@ -547,7 +582,7 @@ type ExternalDependencies = {
547
582
  const context = new DIContext<ModuleDependencies, AppConfig, ExternalDependencies>(container, {
548
583
  messageQueueConsumersEnabled: [MessageQueueConsumer.QUEUE_ID],
549
584
  jobQueuesEnabled: false,
550
- jobWorkersEnabled: false,
585
+ enqueuedJobWorkersEnabled: false,
551
586
  periodicJobsEnabled: false,
552
587
  })
553
588
 
@@ -566,12 +601,17 @@ const app = fastify()
566
601
  app.setValidatorCompiler(validatorCompiler)
567
602
  app.setSerializerCompiler(serializerCompiler)
568
603
 
604
+ // Only needed when a controller declares SSE routes
605
+ await app.register(FastifySSEPlugin)
606
+
569
607
  app.after(() => {
570
608
  context.registerRoutes(app)
571
609
  })
572
610
  await app.ready()
573
611
  ```
574
612
 
613
+ `registerRoutes(app)` registers the routes of every controller from `resolveControllers()`, including SSE and dual-mode routes.
614
+
575
615
  ## Resolver Functions
576
616
 
577
617
  The library provides a set of resolver functions that wrap awilix's `asClass` and `asFunction` with sensible defaults for different types of dependencies. All resolvers create singletons by default.
@@ -632,35 +672,13 @@ For repository classes. Marks the dependency as **private** (not exposed when mo
632
672
  userRepository: asRepositoryClass(UserRepository)
633
673
  ```
634
674
 
635
- #### `asControllerClass(Type, opts?)`
636
- For REST controller classes. Marks the dependency as **private**. Use in `resolveControllers()`.
637
-
638
- ```ts
639
- userController: asControllerClass(UserController)
640
- ```
641
-
642
- #### `asSSEControllerClass(Type, sseOptions?, opts?)`
643
- For SSE controller classes. Marks the dependency as **private** with `isSSEController: true` for auto-detection. Automatically configures `closeAllConnections` as the async dispose method for graceful shutdown. When `sseOptions.diOptions.isTestMode` is true, enables the connection spy for testing. Use in `resolveControllers()` alongside REST controllers.
644
-
645
- ```ts
646
- // In resolveControllers()
647
- resolveControllers(diOptions: DependencyInjectionOptions) {
648
- return {
649
- userController: asControllerClass(UserController),
650
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
651
- }
652
- }
653
- ```
654
-
655
- #### `asDualModeControllerClass(Type, sseOptions?, opts?)`
656
- For dual-mode controller classes that handle both SSE and JSON responses on the same route. Marks the dependency as **private** with `isDualModeController: true` for auto-detection. Inherits all SSE controller features including connection management and graceful shutdown. When `sseOptions.diOptions.isTestMode` is true, enables the connection spy for testing SSE mode.
675
+ #### `asApiControllerClass(Type, opts?)`
676
+ For controllers extending `AbstractApiController`. Marks the dependency as **private** and tags it as an API controller, so `DIContext.registerRoutes()` registers its `routes`. Controllers are always singletons. Use in `resolveControllers()`; `DIContext` rejects controllers registered any other way.
657
677
 
658
678
  ```ts
659
- // In resolveControllers()
660
- resolveControllers(diOptions: DependencyInjectionOptions) {
679
+ resolveControllers() {
661
680
  return {
662
- userController: asControllerClass(UserController),
663
- chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
681
+ userController: asApiControllerClass(UserController),
664
682
  }
665
683
  }
666
684
  ```
@@ -730,11 +748,11 @@ jobQueueManager: asEnqueuedJobQueueManagerFunction(
730
748
 
731
749
  ## Server-Sent Events (SSE)
732
750
 
733
- The library provides first-class support for Server-Sent Events using [@fastify/sse](https://github.com/fastify/sse). SSE enables real-time, unidirectional streaming from server to client - perfect for notifications, live updates, and streaming responses (like AI chat completions).
751
+ SSE routes are ordinary `AbstractApiController` routes whose contract declares an SSE response. Streaming runs on [@fastify/sse](https://github.com/fastify/sse); the route builder and handler model come from `@lokalise/fastify-api-contracts`. This section covers setup, the streaming patterns, rooms, subscriptions and test utilities. [lib/api-contracts/docs.md](./lib/api-contracts/docs.md) has the complete handler and route option reference.
734
752
 
735
753
  ### Prerequisites
736
754
 
737
- Register the `@fastify/sse` plugin before using SSE controllers:
755
+ Register the `@fastify/sse` plugin before `registerRoutes()` when any controller declares an SSE route:
738
756
 
739
757
  ```ts
740
758
  import FastifySSEPlugin from '@fastify/sse'
@@ -750,754 +768,337 @@ here - it is not a per-route option:
750
768
  await app.register(FastifySSEPlugin, { heartbeatInterval: 30000 })
751
769
  ```
752
770
 
753
- ### Defining SSE Contracts
771
+ An app serving SSE routes **must** also register an SSE-aware error handler. An error thrown after the stream started reaches the global error handler with the stream still open and the headers already sent. A handler that always calls `reply.status().send()` fails with `ERR_HTTP_HEADERS_SENT`, and the stream never closes. On a live stream, send a terminal event and close the stream instead:
772
+
773
+ ```ts
774
+ app.setErrorHandler(async (error, request, reply) => {
775
+ const { statusCode, payload } = resolveError(error) // your error-to-response mapping
776
+ // `isConnected` alone is not enough — @fastify/sse sets it before the handler runs.
777
+ if (reply.sse?.isConnected && reply.raw.headersSent) {
778
+ await reply.sse.send({ event: 'error', data: payload })
779
+ reply.sse.close()
780
+ return
781
+ }
782
+ return reply.status(statusCode).send(payload)
783
+ })
784
+ ```
785
+
786
+ The `errorHandler` from `@lokalise/fastify-extras` handles live streams this way already.
787
+
788
+ ### Defining SSE Routes
754
789
 
755
- Use `buildSseContract` from `@lokalise/api-contracts` to define SSE routes. The `method` field determines the HTTP method. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
790
+ An SSE response is declared per status code, either as `sseResponse({ ... })` or as `sseBody({ ... })` inside a content map. A status whose content map carries both a JSON schema and an `sseBody` makes the route dual-mode (see [Dual-Mode Routes](#dual-mode-routes)).
756
791
 
757
792
  ```ts
758
- import { z } from 'zod'
759
- import { buildSseContract } from '@lokalise/api-contracts'
793
+ import { defineApiContract, sseBody, sseResponse } from '@lokalise/api-contracts'
794
+ import { z } from 'zod/v4'
760
795
 
761
- // GET-based SSE stream with path params
762
- export const channelStreamContract = buildSseContract({
796
+ // GET stream with path params
797
+ export const channelStreamContract = defineApiContract({
763
798
  visibility: 'public',
764
799
  method: 'get',
765
- pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
800
+ summary: 'Stream channel messages',
801
+ pathResolver: ({ channelId }) => `/api/channels/${channelId}/stream`,
766
802
  requestPathParamsSchema: z.object({ channelId: z.string() }),
767
- requestQuerySchema: z.object({}),
768
- requestHeaderSchema: z.object({}),
769
- serverSentEventSchemas: {
770
- message: z.object({ content: z.string() }),
771
- },
772
- })
773
-
774
- // GET-based SSE stream without path params
775
- export const notificationsContract = buildSseContract({
776
- visibility: 'public',
777
- method: 'get',
778
- pathResolver: () => '/api/notifications/stream',
779
- requestPathParamsSchema: z.object({}),
780
- requestQuerySchema: z.object({ userId: z.string().optional() }),
781
- requestHeaderSchema: z.object({}),
782
- serverSentEventSchemas: {
783
- notification: z.object({
784
- id: z.string(),
785
- message: z.string(),
786
- }),
803
+ responsesByStatusCode: {
804
+ 200: sseResponse({ message: z.object({ content: z.string() }) }),
805
+ 404: z.object({ message: z.string() }),
787
806
  },
788
807
  })
789
808
 
790
- // POST-based SSE stream (e.g., AI chat completions)
791
- export const chatCompletionContract = buildSseContract({
809
+ // POST stream (e.g., AI chat completions)
810
+ export const chatCompletionContract = defineApiContract({
792
811
  visibility: 'public',
793
812
  method: 'post',
813
+ summary: 'Chat completion',
794
814
  pathResolver: () => '/api/chat/completions',
795
- requestPathParamsSchema: z.object({}),
796
- requestQuerySchema: z.object({}),
797
- requestHeaderSchema: z.object({}),
798
- requestBodySchema: z.object({
799
- message: z.string(),
800
- stream: z.literal(true),
801
- }),
802
- serverSentEventSchemas: {
803
- chunk: z.object({ content: z.string() }),
804
- done: z.object({ totalTokens: z.number() }),
815
+ requestBodySchema: z.object({ message: z.string() }),
816
+ responsesByStatusCode: {
817
+ 200: {
818
+ content: {
819
+ 'text/event-stream': sseBody({
820
+ chunk: z.object({ content: z.string() }),
821
+ done: z.object({ totalTokens: z.number() }),
822
+ }),
823
+ },
824
+ },
805
825
  },
806
826
  })
807
827
  ```
808
828
 
809
- For reusable event schema definitions, you can use the `SSEEventSchemas` type (requires TypeScript 4.9+ for `satisfies`):
810
-
811
- ```ts
812
- import { z } from 'zod'
813
- import type { SSEEventSchemas } from 'opinionated-machine'
814
-
815
- // Define reusable event schemas for multiple contracts
816
- const streamingEvents = {
817
- chunk: z.object({ content: z.string() }),
818
- done: z.object({ totalTokens: z.number() }),
819
- error: z.object({ code: z.number(), message: z.string() }),
820
- } satisfies SSEEventSchemas
821
- ```
822
-
823
- ### Creating SSE Controllers
824
-
825
- SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildHandler` for automatic type inference of request parameters:
829
+ For contracts that declare an SSE response, the handler's third argument carries `sse`. `sse.start(mode)` sends the SSE headers and returns a session whose `send()` is typed by the contract's event schemas:
826
830
 
827
831
  ```ts
828
- import {
829
- AbstractSSEController,
830
- buildHandler,
831
- type SSEControllerConfig,
832
- type SSESession
833
- } from 'opinionated-machine'
834
-
835
- type Contracts = {
836
- notificationsStream: typeof notificationsContract
837
- }
838
-
839
- type Dependencies = {
840
- notificationService: NotificationService
841
- }
842
-
843
- export class NotificationsSSEController extends AbstractSSEController<Contracts> {
844
- public static contracts = {
845
- notificationsStream: notificationsContract,
832
+ export class ChannelController extends AbstractApiController<typeof ChannelController.contracts> {
833
+ static contracts = {
834
+ channelStream: channelStreamContract,
835
+ chatCompletion: chatCompletionContract,
846
836
  } as const
847
837
 
848
- private readonly notificationService: NotificationService
838
+ private readonly channelService: ChannelService
849
839
 
850
- // Required: two-parameter constructor (deps object, optional SSE config)
851
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
852
- super(deps, sseConfig)
853
- this.notificationService = deps.notificationService
840
+ constructor({ channelService }: ModuleDependencies) {
841
+ super()
842
+ this.channelService = channelService
854
843
  }
855
844
 
856
- public buildSSERoutes() {
857
- return {
858
- notificationsStream: this.handleStream,
859
- }
845
+ readonly routes = {
846
+ channelStream: buildApiRoute(
847
+ ChannelController.contracts.channelStream,
848
+ async (request, _reply, { sse }) => {
849
+ const channel = await this.channelService.find(request.params.channelId)
850
+ if (!channel) {
851
+ // Early HTTP response, before the stream starts
852
+ return { status: 404, body: { message: 'Channel not found' } }
853
+ }
854
+ const session = sse.start('keepAlive')
855
+ await session.send('message', { content: `Joined ${channel.name}` })
856
+ },
857
+ ),
858
+
859
+ chatCompletion: buildApiRoute(
860
+ ChannelController.contracts.chatCompletion,
861
+ async (request, _reply, { sse }) => {
862
+ const session = sse.start('autoClose')
863
+ const words = request.body.message.split(' ')
864
+ for (const word of words) {
865
+ await session.send('chunk', { content: word })
866
+ }
867
+ await session.send('done', { totalTokens: words.length })
868
+ },
869
+ ),
860
870
  }
861
-
862
- // Handler with automatic type inference from contract
863
- // sse.start(mode) returns a session with type-safe event sending
864
- // Options (onConnect, onClose) are passed as the third parameter to buildHandler
865
- private handleStream = buildHandler(notificationsContract, {
866
- sse: async (request, sse) => {
867
- // request.query is typed from contract: { userId?: string }
868
- const userId = request.query.userId ?? 'anonymous'
869
-
870
- // Start streaming with 'keepAlive' mode - stays open for external events
871
- // Sends HTTP 200 + SSE headers immediately
872
- const session = sse.start('keepAlive', { context: { userId } })
873
-
874
- // For external triggers (subscriptions, timers, message queues), use sendEventInternal.
875
- // session.send is only available within this handler's scope - external callbacks
876
- // like subscription handlers execute later, outside this function, so they can't access session.
877
- // sendEventInternal is a controller method, so it's accessible from any callback.
878
- // It provides autocomplete for all event names defined in the controller's contracts.
879
- this.notificationService.subscribe(userId, async (notification) => {
880
- await this.sendEventInternal(session.id, {
881
- event: 'notification',
882
- data: notification,
883
- })
884
- })
885
-
886
- // For direct sending within the handler, use the session's send method.
887
- // It provides stricter per-route typing (only events from this specific contract).
888
- await session.send('notification', { id: 'welcome', message: 'Connected!' })
889
-
890
- // 'keepAlive' mode: handler returns, but connection stays open for subscription events
891
- // Connection closes when client disconnects or server calls closeConnection()
892
- },
893
- }, {
894
- onConnect: (session) => console.log('Client connected:', session.id),
895
- onClose: (session, reason) => {
896
- const userId = session.context?.userId as string
897
- this.notificationService.unsubscribe(userId)
898
- console.log(`Client disconnected (${reason}):`, session.id)
899
- },
900
- })
901
871
  }
902
872
  ```
903
873
 
904
- ### Type-Safe SSE Handlers with `buildHandler`
874
+ ### Session Modes
905
875
 
906
- For automatic type inference of request parameters (similar to `buildFastifyRoute` for regular controllers), use `buildHandler`:
876
+ The mode passed to `sse.start(mode)` decides when the connection closes:
907
877
 
908
- ```ts
909
- import {
910
- AbstractSSEController,
911
- buildHandler,
912
- type SSEControllerConfig,
913
- type SSESession
914
- } from 'opinionated-machine'
915
-
916
- class ChatSSEController extends AbstractSSEController<Contracts> {
917
- public static contracts = {
918
- chatCompletion: chatCompletionContract,
919
- } as const
920
-
921
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
922
- super(deps, sseConfig)
923
- }
878
+ - `'autoClose'` closes the connection when the handler returns. Use it for request-response streaming such as AI completions.
879
+ - `'keepAlive'` keeps the connection open after the handler returns, until the client disconnects or `session.close()` is called. Use it for notifications and live updates. Events pushed later, from outside the handler, reach the session through [rooms](#sse-rooms), or through a `session.send` reference the route stores itself in `onConnect` and drops in `onClose`.
924
880
 
925
- // Handler with automatic type inference from contract
926
- // sse.start(mode) returns session with fully typed send()
927
- private handleChatCompletion = buildHandler(chatCompletionContract, {
928
- sse: async (request, sse) => {
929
- // request.body is typed as { message: string; stream: true }
930
- // request.query, request.params, request.headers all typed from contract
931
- const words = request.body.message.split(' ')
932
-
933
- // Start streaming with 'autoClose' mode - closes after handler completes
934
- // Sends HTTP 200 + SSE headers immediately
935
- const session = sse.start('autoClose')
936
-
937
- for (const word of words) {
938
- // session.send() provides compile-time type checking for event names and data
939
- await session.send('chunk', { content: word })
940
- }
881
+ To answer with a regular HTTP response instead of a stream (validation error, not found), return `{ status, body }` before calling `sse.start()`. After `sse.start()`, the handler returns nothing.
941
882
 
942
- // 'autoClose' mode: connection closes automatically when handler returns
943
- },
944
- })
883
+ A handler can also stream declaratively: returning `{ status, body }` for a status whose representation is an SSE stream streams `body` as an `AsyncIterable` of `{ event, data }` messages, in `autoClose` mode:
945
884
 
946
- public buildSSERoutes() {
947
- return {
948
- chatCompletion: this.handleChatCompletion,
949
- }
885
+ ```ts
886
+ buildApiRoute(contract, () => {
887
+ async function* chunks() {
888
+ yield { event: 'chunk' as const, data: { content: 'Hello' } }
889
+ yield { event: 'done' as const, data: { totalTokens: 1 } }
950
890
  }
951
- }
891
+ return { status: 200, body: chunks() }
892
+ })
952
893
  ```
953
894
 
954
- You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
955
-
956
- ```ts
957
- import { type InferSSERequest, type SSEContext, type SSESession } from 'opinionated-machine'
895
+ ### SSE Session Methods
958
896
 
959
- private handleStream = async (
960
- request: InferSSERequest<typeof chatCompletionContract>,
961
- sse: SSEContext<typeof chatCompletionContract['serverSentEventSchemas']>,
962
- ) => {
963
- // request.body, request.params, etc. all typed from contract
964
- const session = sse.start('autoClose')
965
- // session.send() is typed based on contract serverSentEventSchemas
966
- await session.send('chunk', { content: 'hello' })
967
- // 'autoClose' mode: connection closes when handler returns
968
- }
969
- ```
897
+ The session returned by `sse.start(mode)`:
970
898
 
971
- ### SSE Controllers Without Dependencies
899
+ | Member | Description |
900
+ | ------ | ----------- |
901
+ | `id` | Unique connection id |
902
+ | `request` / `reply` | The Fastify request and reply |
903
+ | `context` | Per-connection data passed as `sse.start(mode, { context })` |
904
+ | `send(event, data, options?)` | Send a typed event, validated against the contract schema. `options` takes `id` and `retry` |
905
+ | `sendStream(messages)` | Send messages from an `AsyncIterable`, validating each one |
906
+ | `isConnected()` | Whether the connection is still open |
907
+ | `getStream()` | The underlying writable stream |
908
+ | `close()` | Close the connection from the server side |
972
909
 
973
- For controllers without dependencies, still provide the two-parameter constructor:
910
+ ### Route Options
974
911
 
975
- ```ts
976
- export class SimpleSSEController extends AbstractSSEController<Contracts> {
977
- constructor(deps: object, sseConfig?: SSEControllerConfig) {
978
- super(deps, sseConfig)
979
- }
912
+ The third argument of `buildApiRoute` takes any Fastify route option except the ones the contract provides (`method`, `url`, `schema`, `handler`), plus these SSE options:
980
913
 
981
- // ... implementation
982
- }
914
+ | Option | Description |
915
+ | ------ | ----------- |
916
+ | `onConnect` | Called with the session when the stream starts |
917
+ | `onClose` | Called with `(session, initiator)` when the connection closes. `initiator` is `'server'` (`session.close()`, or an `autoClose` handler returned) or `'client'` |
918
+ | `onReconnect` | Called with `(session, lastEventId)` when the client reconnects with `Last-Event-ID`. Return an iterable of events to replay, or replay them yourself |
919
+ | `serializer` | Custom serializer for event data (default `JSON.stringify`) |
920
+ | `heartbeat` | `false` disables the heartbeat comments on this route. The interval is set when registering `@fastify/sse` |
921
+ | `contractMetadataToRouteMapper` | Maps the contract's `metadata` to Fastify route options (`config`, `onRequest`, `preHandler`, ...). Explicit options override it; `config` objects are merged |
922
+ | `sseRooms` | Enables [SSE rooms](#sse-rooms) for the route |
923
+ | `gatewayMetadata` | Per-route gateway policy (see [Gateway Configuration](#gateway-configuration)) |
924
+
925
+ ```ts
926
+ buildApiRoute(contract, handler, {
927
+ preHandler: async (request, reply) => {
928
+ if (!request.headers.authorization) {
929
+ reply.code(401).send({ message: 'Unauthorized' })
930
+ }
931
+ },
932
+ onConnect: (session) => session.request.log.info({ id: session.id }, 'connected'),
933
+ onClose: (session, initiator) => session.request.log.info({ id: session.id, initiator }, 'closed'),
934
+ onReconnect: async (session, lastEventId) => this.eventStore.since(lastEventId),
935
+ heartbeat: false,
936
+ })
983
937
  ```
984
938
 
985
- ### Registering SSE Controllers
939
+ SSE-capable routes are registered with the `@fastify/sse` kind `'manual'`: the plugin does no `Accept` negotiation, so `reply.sse` is always attached and the handler decides whether to stream or return a regular response. Clients that send no `Accept: text/event-stream` (a wildcard, `application/json`, or no header at all) still reach the handler.
986
940
 
987
- Use `asSSEControllerClass` in your module's `resolveControllers` method alongside REST controllers. SSE controllers are automatically detected via the `isSSEController` flag and registered in the DI container:
941
+ ### Error Handling
988
942
 
989
- ```ts
990
- import { AbstractModule, type InferModuleDependencies, asControllerClass, asSSEControllerClass, asServiceClass, type DependencyInjectionOptions } from 'opinionated-machine'
943
+ - Errors thrown by a handler go to the app's `setErrorHandler`, on SSE routes too, and the route builder adds no error mapping of its own. Before the stream starts they take the regular HTTP error path. After it starts, the stream is still open when the error handler runs, so the handler has to send a terminal event and close the stream (see [Prerequisites](#prerequisites)).
944
+ - Errors thrown or rejected by `onConnect`, `onClose` and `onReconnect` are caught and logged on the request logger. The connection lifecycle continues.
945
+ - `session.send()` throws when the payload does not match the event schema. In tests, `injectApiSSE` and `connectApiSSE` report such failures with the event name and Zod issues (see [When a Handler Fails to Send an Event](#when-a-handler-fails-to-send-an-event)).
946
+ - Room broadcasts do not throw for a closed or failing connection. A send the session rejects is logged, that connection counts as not delivered, and the fan-out continues.
991
947
 
992
- export class NotificationsModule extends AbstractModule {
993
- resolveDependencies() {
994
- return {
995
- notificationService: asServiceClass(NotificationService),
996
- }
997
- }
948
+ ### Graceful Shutdown
998
949
 
999
- resolveControllers(diOptions: DependencyInjectionOptions) {
1000
- return {
1001
- // REST controller
1002
- usersController: asControllerClass(UsersController),
1003
- // SSE controller (automatically detected and registered for SSE routes)
1004
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
1005
- }
1006
- }
1007
- }
950
+ SSE streams don't hold `app.close()`. For `buildApiRoute` routes registered through `registerRoutes`, a `preClose`
951
+ hook runs before Fastify closes its HTTP server:
1008
952
 
1009
- export type NotificationsModuleDependencies = InferModuleDependencies<NotificationsModule>
1010
- ```
953
+ - keepAlive streams still open are closed. Left open, the server would wait for them, and no `onClose` hook, the DI
954
+ container dispose included, would run until the process was killed.
955
+ - autoClose streams still being generated are left to finish, like any in-flight request.
956
+ - idle keep-alive connections are closed as requests complete, so a response that finishes during shutdown doesn't
957
+ keep the server open until `keepAliveTimeout`.
1011
958
 
1012
- ### Registering SSE Routes
959
+ ### Dual-Mode Routes
1013
960
 
1014
- Call `registerSSERoutes` after registering the `@fastify/sse` plugin:
961
+ A route is dual-mode when one success status declares both a JSON body and an SSE stream. The handler reads `expectedContentType`, the `Accept`-negotiated content type, and either returns JSON or starts a stream:
1015
962
 
1016
963
  ```ts
1017
- const app = fastify()
1018
- app.setValidatorCompiler(validatorCompiler)
1019
- app.setSerializerCompiler(serializerCompiler)
1020
-
1021
- // Register @fastify/sse plugin first
1022
- await app.register(FastifySSEPlugin)
1023
-
1024
- // Then register SSE routes
1025
- context.registerSSERoutes(app)
1026
-
1027
- // Optionally with global preHandler for authentication
1028
- context.registerSSERoutes(app, {
1029
- preHandler: async (request, reply) => {
1030
- if (!request.headers.authorization) {
1031
- reply.code(401).send({ error: 'Unauthorized' })
1032
- }
964
+ const chatContract = defineApiContract({
965
+ visibility: 'public',
966
+ method: 'post',
967
+ summary: 'Chat',
968
+ pathResolver: () => '/api/chat',
969
+ requestBodySchema: z.object({ message: z.string() }),
970
+ responsesByStatusCode: {
971
+ 200: {
972
+ content: {
973
+ 'application/json': z.object({ reply: z.string() }),
974
+ 'text/event-stream': sseBody({
975
+ chunk: z.object({ delta: z.string() }),
976
+ done: z.object({}),
977
+ }),
978
+ },
979
+ },
1033
980
  },
1034
981
  })
1035
982
 
1036
- await app.ready()
983
+ buildApiRoute(chatContract, async (request, _reply, { expectedContentType, sse }) => {
984
+ if (expectedContentType === 'text/event-stream') {
985
+ const session = sse.start('autoClose')
986
+ for await (const chunk of this.aiService.stream(request.body.message)) {
987
+ await session.send('chunk', { delta: chunk.text })
988
+ }
989
+ await session.send('done', {})
990
+ return
991
+ }
992
+ const result = await this.aiService.complete(request.body.message)
993
+ // A status with several media types needs an explicit contentType
994
+ return { status: 200, contentType: 'application/json', body: { reply: result.text } }
995
+ })
1037
996
  ```
1038
997
 
1039
- ### Broadcasting Events
998
+ Negotiation honours quality values and wildcards. Under a full wildcard (`Accept: */*`) the first content type the contract declares wins. `expectedContentType` is `null` when the request has no `Accept` header or accepts none of the declared types, and the handler picks the fallback (JSON in the example above).
1040
999
 
1041
- Send events to multiple connections using `broadcast()` or `broadcastIf()`:
1000
+ To test the JSON side, use `app.inject()` or `injectByApiContract` from `@lokalise/fastify-api-contracts` with `accept: application/json`. To test the stream, use `injectApiSSE` (it always sends `accept: text/event-stream`). The [dual-mode testing section of docs.md](./lib/api-contracts/docs.md#testing-dual-mode-routes) has an example of each.
1042
1001
 
1043
- ```ts
1044
- // Broadcast to ALL connected clients
1045
- await this.broadcast({
1046
- event: 'system',
1047
- data: { message: 'Server maintenance in 5 minutes' },
1048
- })
1002
+ ### SSE Parsing Utilities
1049
1003
 
1050
- // Broadcast to sessions matching a predicate
1051
- await this.broadcastIf(
1052
- { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
1053
- (session) => session.context.channelId === '123',
1054
- )
1055
- ```
1004
+ Wire-format parsing lives in
1005
+ [`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
1006
+ re-exported here, so the server's test helpers and the browser client
1007
+ (`@opinionated-machine/sse-fallback`) frame a stream with the same code.
1056
1008
 
1057
- Both methods return the number of clients the message was successfully sent to.
1009
+ | Function | Use case |
1010
+ |----------|----------|
1011
+ | `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
1012
+ | `parseSSEStream` | An async iterable of already-decoded text chunks |
1013
+ | `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
1014
+ | `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
1015
+ | `parseSSEBuffer` | The primitive the others are built on |
1058
1016
 
1059
- ### Controller-Level Hooks
1017
+ #### parseSSEResponse
1060
1018
 
1061
- Override these optional methods on your controller for global session handling:
1019
+ Consume a live SSE stream from `fetch`. Multi-byte characters split across
1020
+ network chunks are held back, and breaking out of the loop cancels the
1021
+ response body.
1062
1022
 
1063
1023
  ```ts
1064
- class MySSEController extends AbstractSSEController<Contracts> {
1065
- // Called AFTER session is registered (for all routes)
1066
- protected onConnectionEstablished(session: SSESession): void {
1067
- this.metrics.incrementConnections()
1068
- }
1024
+ import { parseSSEResponse } from 'opinionated-machine'
1069
1025
 
1070
- // Called BEFORE session is unregistered (for all routes)
1071
- protected onConnectionClosed(session: SSESession): void {
1072
- this.metrics.decrementConnections()
1073
- }
1026
+ const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
1027
+
1028
+ for await (const event of parseSSEResponse(response)) {
1029
+ console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
1030
+ if (event.event === 'done') break
1074
1031
  }
1075
1032
  ```
1076
1033
 
1077
- ### Route-Level Options
1034
+ Unlike `EventSource` the request is yours: custom headers, a POST body, an
1035
+ `AbortSignal`, your own reconnect policy.
1078
1036
 
1079
- Each route can have its own `preHandler`, lifecycle hooks, and logger. Pass these as the third parameter to `buildHandler`:
1037
+ #### createSSEStreamParser and parseSSEStream
1080
1038
 
1081
- ```ts
1082
- public buildSSERoutes() {
1083
- return {
1084
- adminStream: this.handleAdminStream,
1085
- }
1086
- }
1039
+ When the transport hands you decoded text rather than a `Response`, or when you
1040
+ need the reconnect cursor after the stream ends.
1087
1041
 
1088
- private handleAdminStream = buildHandler(adminStreamContract, {
1089
- sse: async (request, sse) => {
1090
- const session = sse.start('keepAlive')
1091
- // ... handler logic
1092
- },
1093
- }, {
1094
- // Route-specific authentication
1095
- preHandler: (request, reply) => {
1096
- if (!request.user?.isAdmin) {
1097
- reply.code(403).send({ error: 'Forbidden' })
1098
- }
1099
- },
1100
- onConnect: (session) => console.log('Admin connected'),
1101
- onClose: (session, reason) => console.log(`Admin disconnected (${reason})`),
1102
- // Handle client reconnection with Last-Event-ID
1103
- onReconnect: async (session, lastEventId) => {
1104
- // Return events to replay, or handle manually
1105
- return this.getEventsSince(lastEventId)
1106
- },
1107
- // Optional: logger for error handling (requires @lokalise/node-core)
1108
- logger: this.logger,
1109
- })
1110
- ```
1042
+ ```ts
1043
+ import { createSSEStreamParser } from 'opinionated-machine'
1111
1044
 
1112
- **Available route options:**
1045
+ // One per connection: it holds the partial frame, the Last-Event-ID cursor and
1046
+ // the BOM that may open the stream.
1047
+ const parser = createSSEStreamParser({ lastEventId: resumeFrom })
1113
1048
 
1114
- | Option | Description |
1115
- | -------- | ------------- |
1116
- | `preHandler` | Authentication/authorization hook that runs before SSE session |
1117
- | `onConnect` | Called after client connects (SSE handshake complete) |
1118
- | `onClose` | Called when session closes (client disconnect, network failure, or server close). Receives `(session, reason)` where reason is `'server'` or `'client'` |
1119
- | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
1120
- | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
1121
- | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
1122
- | `heartbeat` | Set to `false` to disable heartbeat keep-alive comments for this route. The *interval* is not per-route — configure it once via `app.register(fastifySSE, { heartbeatInterval })` |
1123
- | `kind` | `@fastify/sse` route kind - how the `Accept` header is negotiated. Defaults to `'manual'` (see below) |
1124
- | `contractMetadataToRouteMapper` | Maps contract metadata to Fastify route options (see below) |
1125
-
1126
- **onClose reason parameter:**
1127
- - `'server'`: Server explicitly closed the session (via `closeConnection()` or `autoClose` mode)
1128
- - `'client'`: Client closed the session (EventSource.close(), navigation, network failure)
1129
-
1130
- ```ts
1131
- options: {
1132
- onConnect: (session) => console.log('Client connected'),
1133
- onClose: (session, reason) => {
1134
- console.log(`Session closed (${reason}):`, session.id)
1135
- // reason is 'server' or 'client'
1136
- },
1137
- serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
1138
- heartbeat: false, // Disable heartbeat comments on this route
1049
+ for await (const chunk of chunks) {
1050
+ for (const event of parser.push(chunk)) {
1051
+ console.log('Received:', event.event ?? 'message', event.data)
1052
+ }
1139
1053
  }
1054
+
1055
+ reconnectWith(parser.lastEventId)
1140
1056
  ```
1141
1057
 
1142
- The heartbeat *interval* is not a route option - `@fastify/sse` only exposes a boolean at route
1143
- level, and reads the interval once, when the plugin is registered, applying it to every SSE route:
1058
+ `parseSSEStream` wraps that loop when you only want the events:
1144
1059
 
1145
1060
  ```ts
1146
- await app.register(FastifySSEPlugin, { heartbeatInterval: 30000 })
1061
+ import { parseSSEStream } from 'opinionated-machine'
1062
+
1063
+ for await (const event of parseSSEStream(chunks, {
1064
+ onChunk: () => resetStaleConnectionTimer(),
1065
+ })) {
1066
+ handle(event)
1067
+ }
1147
1068
  ```
1148
1069
 
1149
- #### `kind` and `Accept` header negotiation
1070
+ `onChunk` fires for every chunk before it is framed, comment frames included.
1071
+ Framing consumes `: heartbeat` comments, so a consumer watching only events
1072
+ cannot tell an idle-but-healthy connection from a dead one.
1150
1073
 
1151
- Routes are registered with the `@fastify/sse` kind `'manual'`, which means the plugin performs **no**
1152
- `Accept` header negotiation: `reply.sse` is always attached and the route handler decides at runtime
1153
- whether to stream or to send a regular HTTP response.
1074
+ #### parseSSEEvents
1154
1075
 
1155
- This matters because SSE handlers built with `buildHandler` have a single code path that calls
1156
- `sse.start()`. Clients that do not send an explicit `Accept: text/event-stream` token - a wildcard
1157
- `Accept` header (the default for most non-browser HTTP clients), `Accept: application/json`, or no
1158
- `Accept` header at all (typical of clients generated from the route's OpenAPI spec) - would otherwise
1159
- reach the handler with `reply.sse` left undefined and get a `500` instead of a stream. It also keeps
1160
- `sse.respond()` early returns available to every client. Dual-mode routes negotiate the `Accept`
1161
- header themselves (honouring `defaultMode`), so they use the same kind.
1076
+ Parse a complete SSE response body into an array of events.
1162
1077
 
1163
- Each route type accepts only the kinds that can actually work for it:
1078
+ **When to use:** testing with Fastify's `inject()`, or when the full response is
1079
+ available (request-response style SSE such as OpenAI completions):
1164
1080
 
1165
- **SSE-only routes** - `'manual' | 'only'`
1081
+ ```ts
1082
+ import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
1166
1083
 
1167
- | Kind | Behavior |
1168
- | ---- | -------- |
1169
- | `'manual'` (default) | No negotiation. `reply.sse` is always attached, the handler decides |
1170
- | `'only'` | The plugin gates on `Accept` and answers `406 Not Acceptable` before the handler runs. A missing `Accept` header and the wildcards `*/*` and `text/*` pass; **every other concrete media type is rejected**, `application/json` included - so `sse.respond()` early returns become unreachable for JSON clients |
1084
+ const responseBody = `event: notification
1085
+ data: {"id":"1","message":"Hello"}
1171
1086
 
1172
- **Dual-mode routes** - `'manual' | 'dual'`
1087
+ event: notification
1088
+ data: {"id":"2","message":"World"}
1173
1089
 
1174
- | Kind | Behavior |
1175
- | ---- | -------- |
1176
- | `'manual'` (default) | No plugin-side negotiation. The route's own `determineMode()` picks the mode, honouring `defaultMode` |
1177
- | `'dual'` | The plugin gates first: only an explicit `text/event-stream` token admits SSE, everything else reaches the handler with `reply.sse` undefined. Sound only with `defaultMode: 'json'` - pairing it with `defaultMode: 'sse'` is rejected at route-build time, because a wildcard or absent `Accept` header would select the SSE branch after the plugin already declined to attach the stream |
1090
+ `
1178
1091
 
1179
- The plugin's other kinds are deliberately not exposed: `'dual'` on an SSE-only route (and the
1180
- `sse: true` `'legacy'` default) can only ever leave a single-code-path handler without `reply.sse`,
1181
- and `'only'` on a dual-mode route would make the JSON half unreachable.
1092
+ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
1093
+ // Result:
1094
+ // [
1095
+ // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
1096
+ // { event: 'notification', data: '{"id":"2","message":"World"}' }
1097
+ // ]
1182
1098
 
1183
- Override it per route when you want different semantics:
1184
-
1185
- ```ts
1186
- private handleAdminStream = buildHandler(adminStreamContract, {
1187
- sse: async (request, sse) => {
1188
- const session = sse.start('keepAlive')
1189
- // ... handler logic
1190
- },
1191
- }, {
1192
- // Content-negotiate: anything that does not accept text/event-stream gets 406 Not Acceptable
1193
- kind: 'only',
1194
- })
1195
- ```
1196
-
1197
- #### `contractMetadataToRouteMapper`
1198
-
1199
- Allows attaching cross-cutting behavior (auth, rate limiting, tracing, etc.) to a route based on metadata defined in the
1200
- contract.
1201
-
1202
- The return value is merged into Fastify's `RouteOptions` as a base.
1203
- The mapper can return any of: `config`, `bodyLimit`, `onRequest`, `preParsing`, `preValidation`, `preHandler`,
1204
- `preSerialization`, `onSend`, `onResponse`, `onError`, `onTimeout`, `onRequestAbort`.
1205
-
1206
- ```ts
1207
- // In the contract definition
1208
- const adminStreamContract = buildSseContract({
1209
- visibility: 'public',
1210
- method: 'get',
1211
- pathResolver: () => '/api/admin/stream',
1212
- // ...schemas...
1213
- metadata: { requiresAuth: true, rateLimit: 100 },
1214
- })
1215
-
1216
- // In the controller — driven by metadata, not duplicated per-route
1217
- private handleAdminStream = buildHandler(adminStreamContract, {
1218
- sse: async (request, sse) => {
1219
- const session = sse.start('keepAlive')
1220
- // ...
1221
- },
1222
- }, {
1223
- contractMetadataToRouteMapper: (metadata) => ({
1224
- config: { rateLimit: metadata.rateLimit },
1225
- onRequest: metadata.requiresAuth ? authHook : undefined,
1226
- }),
1227
- })
1228
- ```
1229
-
1230
- This is the same API as `contractMetadataToRouteMapper` in `@lokalise/fastify-api-contracts`, making it straightforward
1231
- to share a single mapper function across REST, SSE, and dual-mode routes.
1232
-
1233
- ### SSE Session Methods
1234
-
1235
- The `session` object returned by `sse.start(mode)` provides several useful methods:
1236
-
1237
- ```ts
1238
- private handleStream = buildHandler(streamContract, {
1239
- sse: async (request, sse) => {
1240
- const session = sse.start('autoClose')
1241
-
1242
- // Check if session is still active
1243
- if (session.isConnected()) {
1244
- await session.send('status', { connected: true })
1245
- }
1246
-
1247
- // Get raw writable stream for advanced use cases (e.g., pipeline)
1248
- const stream = session.getStream()
1249
-
1250
- // Stream messages from an async iterable with automatic validation
1251
- async function* generateMessages() {
1252
- yield { event: 'message' as const, data: { text: 'Hello' } }
1253
- yield { event: 'message' as const, data: { text: 'World' } }
1254
- }
1255
- await session.sendStream(generateMessages())
1256
-
1257
- // 'autoClose' mode: connection closes when handler returns
1258
- },
1259
- })
1260
- ```
1261
-
1262
- | Method | Description |
1263
- | -------- | ------------- |
1264
- | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
1265
- | `isConnected()` | Check if the session is still active |
1266
- | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
1267
- | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
1268
-
1269
- ### Graceful Shutdown
1270
-
1271
- SSE streams don't hold `app.close()`. For `buildApiRoute` routes registered through `registerRoutes`, a `preClose`
1272
- hook runs before Fastify closes its HTTP server:
1273
-
1274
- - keepAlive streams still open are closed. Left open, the server would wait for them, and no `onClose` hook, the DI
1275
- container dispose included, would run until the process was killed.
1276
- - autoClose streams still being generated are left to finish, like any in-flight request.
1277
- - idle keep-alive connections are closed as requests complete, so a response that finishes during shutdown doesn't
1278
- keep the server open until `keepAliveTimeout`.
1279
-
1280
- ### Error Handling
1281
-
1282
- When `sendEvent()` fails (e.g., client disconnected), it:
1283
- - Returns `false` to indicate failure
1284
- - Automatically removes the dead connection from tracking
1285
- - Prevents further send attempts to that connection
1286
-
1287
- ```ts
1288
- const sent = await this.sendEvent(connectionId, { event: 'update', data })
1289
- if (!sent) {
1290
- // Connection was closed or failed - already removed from tracking
1291
- this.cleanup(connectionId)
1292
- }
1293
- ```
1294
-
1295
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
1296
- - All lifecycle hooks are wrapped in try/catch to prevent crashes
1297
- - If a `logger` is provided in route options, errors are logged with context
1298
- - If no logger is provided, errors are silently ignored
1299
- - The session lifecycle continues even if a hook throws
1300
-
1301
- ```ts
1302
- // Provide a logger to capture lifecycle errors
1303
- public buildSSERoutes() {
1304
- return {
1305
- stream: this.handleStream,
1306
- }
1307
- }
1308
-
1309
- private handleStream = buildHandler(streamContract, {
1310
- sse: async (request, sse) => {
1311
- const session = sse.start('autoClose')
1312
- // ... handler logic
1313
- },
1314
- }, {
1315
- logger: this.logger, // pino-compatible logger
1316
- onConnect: (session) => { /* may throw */ },
1317
- onClose: (session, reason) => { /* may throw */ },
1318
- })
1319
- ```
1320
-
1321
- ### Long-lived Connections vs Request-Response Streaming
1322
-
1323
- SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
1324
-
1325
- ```ts
1326
- // sse.start('autoClose') - close connection when handler returns (request-response pattern)
1327
- // sse.start('keepAlive') - keep connection open for external events (subscription pattern)
1328
- // sse.respond(code, body) - send HTTP response before streaming (early return)
1329
- ```
1330
-
1331
- **Long-lived sessions** (notifications, live updates):
1332
- - Handler starts streaming with `sse.start('keepAlive')`
1333
- - Session stays open indefinitely after handler returns
1334
- - Events are sent later via callbacks using `sendEventInternal()`
1335
- - **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
1336
- - Server cleans up via `onConnectionClosed()` hook
1337
-
1338
- ```ts
1339
- private handleStream = buildHandler(streamContract, {
1340
- sse: async (request, sse) => {
1341
- // Start streaming with 'keepAlive' mode - stays open for external events
1342
- const session = sse.start('keepAlive')
1343
-
1344
- // Set up subscription - events sent via callback AFTER handler returns
1345
- this.service.subscribe(session.id, (data) => {
1346
- this.sendEventInternal(session.id, { event: 'update', data })
1347
- })
1348
- // 'keepAlive' mode: handler returns, but connection stays open
1349
- },
1350
- })
1351
-
1352
- // Clean up when client disconnects
1353
- protected onConnectionClosed(session: SSESession): void {
1354
- this.service.unsubscribe(session.id)
1355
- }
1356
- ```
1357
-
1358
- **Request-response streaming** (AI completions):
1359
- - Handler starts streaming with `sse.start('autoClose')`
1360
- - Use `session.send()` for type-safe event sending within the handler
1361
- - Session automatically closes when handler returns
1362
-
1363
- ```ts
1364
- private handleChatCompletion = buildHandler(chatCompletionContract, {
1365
- sse: async (request, sse) => {
1366
- // Start streaming with 'autoClose' mode - closes when handler returns
1367
- const session = sse.start('autoClose')
1368
-
1369
- const words = request.body.message.split(' ')
1370
- for (const word of words) {
1371
- await session.send('chunk', { content: word })
1372
- }
1373
- await session.send('done', { totalTokens: words.length })
1374
-
1375
- // 'autoClose' mode: connection closes automatically when handler returns
1376
- },
1377
- })
1378
- ```
1379
-
1380
- **Error handling before streaming:**
1381
-
1382
- 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.
1383
-
1384
- ```ts
1385
- private handleStream = buildHandler(streamContract, {
1386
- sse: async (request, sse) => {
1387
- // Early return BEFORE starting stream - can return any HTTP response
1388
- const entity = await this.service.find(request.params.id)
1389
- if (!entity) {
1390
- return sse.respond(404, { error: 'Entity not found' })
1391
- }
1392
-
1393
- // Validation passed - start streaming with autoClose mode
1394
- const session = sse.start('autoClose')
1395
- await session.send('data', entity)
1396
- // Connection closes automatically when handler returns
1397
- },
1398
- })
1399
- ```
1400
-
1401
- ### SSE Parsing Utilities
1402
-
1403
- Wire-format parsing lives in
1404
- [`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
1405
- re-exported here, so the server's test helpers and the browser client
1406
- (`@opinionated-machine/sse-fallback`) frame a stream with the same code.
1407
-
1408
- | Function | Use case |
1409
- |----------|----------|
1410
- | `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
1411
- | `parseSSEStream` | An async iterable of already-decoded text chunks |
1412
- | `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
1413
- | `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
1414
- | `parseSSEBuffer` | The primitive the others are built on |
1415
-
1416
- #### parseSSEResponse
1417
-
1418
- Consume a live SSE stream from `fetch`. Multi-byte characters split across
1419
- network chunks are held back, and breaking out of the loop cancels the
1420
- response body.
1421
-
1422
- ```ts
1423
- import { parseSSEResponse } from 'opinionated-machine'
1424
-
1425
- const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
1426
-
1427
- for await (const event of parseSSEResponse(response)) {
1428
- console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
1429
- if (event.event === 'done') break
1430
- }
1431
- ```
1432
-
1433
- Unlike `EventSource` the request is yours: custom headers, a POST body, an
1434
- `AbortSignal`, your own reconnect policy.
1435
-
1436
- #### createSSEStreamParser and parseSSEStream
1437
-
1438
- When the transport hands you decoded text rather than a `Response`, or when you
1439
- need the reconnect cursor after the stream ends.
1440
-
1441
- ```ts
1442
- import { createSSEStreamParser } from 'opinionated-machine'
1443
-
1444
- // One per connection: it holds the partial frame, the Last-Event-ID cursor and
1445
- // the BOM that may open the stream.
1446
- const parser = createSSEStreamParser({ lastEventId: resumeFrom })
1447
-
1448
- for await (const chunk of chunks) {
1449
- for (const event of parser.push(chunk)) {
1450
- console.log('Received:', event.event ?? 'message', event.data)
1451
- }
1452
- }
1453
-
1454
- reconnectWith(parser.lastEventId)
1455
- ```
1456
-
1457
- `parseSSEStream` wraps that loop when you only want the events:
1458
-
1459
- ```ts
1460
- import { parseSSEStream } from 'opinionated-machine'
1461
-
1462
- for await (const event of parseSSEStream(chunks, {
1463
- onChunk: () => resetStaleConnectionTimer(),
1464
- })) {
1465
- handle(event)
1466
- }
1467
- ```
1468
-
1469
- `onChunk` fires for every chunk before it is framed, comment frames included.
1470
- Framing consumes `: heartbeat` comments, so a consumer watching only events
1471
- cannot tell an idle-but-healthy connection from a dead one.
1472
-
1473
- #### parseSSEEvents
1474
-
1475
- Parse a complete SSE response body into an array of events.
1476
-
1477
- **When to use:** testing with Fastify's `inject()`, or when the full response is
1478
- available (request-response style SSE such as OpenAI completions):
1479
-
1480
- ```ts
1481
- import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
1482
-
1483
- const responseBody = `event: notification
1484
- data: {"id":"1","message":"Hello"}
1485
-
1486
- event: notification
1487
- data: {"id":"2","message":"World"}
1488
-
1489
- `
1490
-
1491
- const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
1492
- // Result:
1493
- // [
1494
- // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
1495
- // { event: 'notification', data: '{"id":"2","message":"World"}' }
1496
- // ]
1497
-
1498
- // Access parsed data
1499
- const notifications = events.map(e => JSON.parse(e.data))
1500
- ```
1099
+ // Access parsed data
1100
+ const notifications = events.map(e => JSON.parse(e.data))
1101
+ ```
1501
1102
 
1502
1103
  A trailing frame with no blank line after it is discarded, which is what the
1503
1104
  spec requires at the end of a stream: a body cut mid-frame must not surface its
@@ -1551,39 +1152,104 @@ events that carry no `id:` of their own, so it is what you reconnect with;
1551
1152
  on. Ordering on the cursor instead makes every inheriting event look like a
1552
1153
  duplicate of the last id-bearing one.
1553
1154
 
1554
- ### Testing SSE Controllers
1155
+ ### Testing SSE Routes
1555
1156
 
1556
1157
  The test client depends on the session mode:
1557
1158
 
1558
1159
  | Session Mode | Test Client | Why |
1559
1160
  |-------------|-------------|--------|
1560
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
1561
- | `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
1161
+ | `autoClose` | `injectApiSSE` (or `SSEInjectClient` for raw URLs) | Handler completes and closes connection; all events available at once |
1162
+ | `keepAlive` | `connectApiSSE` (or `SSEHttpClient` for raw URLs) | Connection stays open; events arrive incrementally via server push |
1163
+
1164
+ #### Testing autoClose SSE (request-response streaming)
1165
+
1166
+ `injectApiSSE(app, contract, params)` injects the request through Fastify, so no server has to listen. The HTTP method comes from the contract, and `params` has the shape `injectByApiContract` takes (`pathParams`, `queryParams`, `headers`, `body`, `pathPrefix`, each required only when the contract declares the matching schema):
1167
+
1168
+ ```ts
1169
+ import { defineApiContract, sseResponse } from '@lokalise/api-contracts'
1170
+ import { z } from 'zod/v4'
1171
+ import { injectApiSSE } from 'opinionated-machine'
1172
+
1173
+ const lqaSegmentContract = defineApiContract({
1174
+ visibility: 'internal',
1175
+ method: 'post',
1176
+ summary: 'Perform LQA on a text segment',
1177
+ pathResolver: () => '/v1/content/actions/lqa-text-segment',
1178
+ requestBodySchema: z.object({ segment: z.string() }),
1179
+ responsesByStatusCode: {
1180
+ 200: sseResponse({
1181
+ issue: z.object({ severity: z.enum(['neutral', 'minor', 'major', 'critical']) }),
1182
+ review: z.object({ score: z.number() }),
1183
+ }),
1184
+ 400: z.object({ message: z.string() }),
1185
+ },
1186
+ })
1187
+
1188
+ it('streams the review', async () => {
1189
+ const { events } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1190
+
1191
+ // Events are validated against the contract and typed as a union on `event`.
1192
+ for (const event of await events()) {
1193
+ if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
1194
+ }
1195
+ })
1196
+
1197
+ it('returns the documented 400 body for an empty segment', async () => {
1198
+ const { bodyForStatus } = injectApiSSE(app, lqaSegmentContract, { body: { segment: '' } })
1199
+
1200
+ // `body` is typed as `{ message: string }` — the contract's 400 schema.
1201
+ const body = await bodyForStatus(400)
1202
+ expect(body.message).toBe('segment must not be empty')
1203
+ })
1204
+ ```
1205
+
1206
+ ```ts
1207
+ it('sends each issue as soon as it is found', async () => {
1208
+ const { head, stream } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1209
+
1210
+ // The head is on the wire as soon as the handler calls sse.start()
1211
+ expect((await head).statusCode).toBe(200)
1212
+
1213
+ for await (const event of stream()) {
1214
+ // Each event is observed while the handler is still producing the next one
1215
+ if (event.event === 'issue') expect(handlerFinished).toBe(false)
1216
+ }
1217
+ })
1218
+ ```
1219
+
1220
+ The result exposes:
1562
1221
 
1563
- Enable the connection spy by passing `isTestMode: true` in diOptions (required for `awaitServerConnection`).
1222
+ - `closed`: resolves with `{ statusCode, headers, body }` once the response completes.
1223
+ - `head`: `{ statusCode, headers }`, resolved as soon as the response head is on the wire (for a streaming handler, at `sse.start()`).
1224
+ - `events()`: parses the SSE body and validates each event against the contract's SSE schemas. It throws when the response isn't a stream, when an event name isn't declared, or when a payload fails its schema.
1225
+ - `stream(signal?)`: the same typed, validated events, yielded as the handler writes them. The request is injected with Fastify's `payloadAsStream`; events are buffered from the moment it is injected, so a generator started late replays the stream from its first event, a consumer that breaks early leaves `closed` / `events()` intact, and the handler is never blocked waiting to be read.
1226
+ - `bodyForStatus(status)`: asserts the status, JSON-parses the body and validates it against the schema `responsesByStatusCode` declares for that status, following the same exact → range → `'default'` precedence as the contract client. It throws, with the status and a truncated body snippet, when any of those steps fails.
1227
+ - `sendFailures()`: the send failures recorded for this request (see [When a Handler Fails to Send an Event](#when-a-handler-fails-to-send-an-event)).
1228
+
1229
+ `closed` and `events()` wait for the response to complete, so a route that never closes its stream (a `keepAlive` session) can only be read through `stream()`, or over real HTTP with [`connectApiSSE`](#contract-aware-http-helpers).
1230
+
1231
+ The request always carries `accept: text/event-stream`, so a status that declares a stream answers with it, including a dual-mode status whose content map also carries a JSON schema. Those statuses are therefore not callable through `bodyForStatus`; read them with `events()`, or use `injectByApiContract` for the JSON side. A contract that declares no SSE response at all types `events` as `never`, so calling it is a compile error rather than a guaranteed throw. `events()` is typed from the SSE schemas of *every* declared status, merged the same way the runtime merges them, so a contract streaming on both `200` and `'4xx'` yields the union of both event sets. See the [testing section of docs.md](./lib/api-contracts/docs.md#testing) for more.
1564
1232
 
1565
1233
  #### Testing keepAlive SSE (long-lived connections)
1566
1234
 
1567
- Use `SSEHttpClient` against your running app. The key pattern:
1235
+ A `keepAlive` response never completes, so it needs a real HTTP connection. The pattern:
1568
1236
 
1569
- 1. Connect with `awaitServerConnection` to eliminate the race condition
1237
+ 1. Wire a session spy into the route with `createSSESessionSpy()` and connect with `awaitServerConnection: { spy }`, so the test gets the server-side session without racing the handler
1570
1238
  2. Call `collectEvents()` **before** pushing events (they arrive asynchronously)
1571
- 3. Push events from the server via `sendEventInternal()` or `broadcastToRoom()`
1239
+ 3. Push events from the server, through the session or a room broadcast
1572
1240
  4. Await the collected events
1573
1241
  5. Always call `client.close()` to release the connection
1574
1242
 
1575
1243
  ```ts
1576
- import { SSEHttpClient, SSETestServer } from 'opinionated-machine'
1244
+ import { connectApiSSE, createSSESessionSpy, SSETestServer } from 'opinionated-machine'
1577
1245
 
1578
- describe('NotificationsSSEController', () => {
1579
- let app: AppInstance
1246
+ describe('notifications stream', () => {
1247
+ const { spy, routeOptions } = createSSESessionSpy()
1580
1248
  let server: SSETestServer
1581
- let controller: NotificationsSSEController
1582
1249
 
1583
1250
  beforeAll(async () => {
1584
- app = await getApp({ /* your test config */ })
1585
- controller = app.diContainer.resolve('notificationsSSEController')
1586
-
1251
+ // The spy's hooks must reach the buildApiRoute() call, see below
1252
+ const app = await getApp({ notificationsRouteOptions: routeOptions })
1587
1253
  // SSETestServer.start() starts your app on a random port and provides baseUrl
1588
1254
  server = await SSETestServer.start(app)
1589
1255
  })
@@ -1593,215 +1259,76 @@ describe('NotificationsSSEController', () => {
1593
1259
  })
1594
1260
 
1595
1261
  it('receives notifications over keepAlive SSE', async () => {
1596
- // 1. Connect with awaitServerConnection to eliminate race condition
1597
- const { client, serverConnection } = await SSEHttpClient.connect(
1262
+ const { client, serverConnection } = await connectApiSSE(
1598
1263
  server.baseUrl,
1599
- '/api/notifications/stream',
1600
- {
1601
- query: { userId: 'test-user' },
1602
- awaitServerConnection: { controller },
1603
- },
1264
+ notificationsContract,
1265
+ { queryParams: { userId: 'test-user' } },
1266
+ { awaitServerConnection: { spy } },
1604
1267
  )
1605
1268
 
1606
1269
  expect(client.response.ok).toBe(true)
1607
1270
 
1608
- // 2. Start collecting events BEFORE pushing (they arrive asynchronously)
1609
1271
  const eventsPromise = client.collectEvents(2)
1610
1272
 
1611
- // 3. Push events from server
1612
- await controller.sendEventInternal(serverConnection.id, {
1613
- event: 'notification',
1614
- data: { id: '1', message: 'Hello!' },
1615
- })
1616
- await controller.sendEventInternal(serverConnection.id, {
1617
- event: 'notification',
1618
- data: { id: '2', message: 'World!' },
1619
- })
1620
-
1621
- // 4. Await collected events
1622
- const events = await eventsPromise
1273
+ await serverConnection.send('notification', { id: '1', message: 'Hello!' })
1274
+ await serverConnection.send('notification', { id: '2', message: 'World!' })
1623
1275
 
1624
- expect(events).toHaveLength(2)
1625
- expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
1626
- expect(JSON.parse(events[1].data)).toEqual({ id: '2', message: 'World!' })
1276
+ // Events are typed and validated against the contract
1277
+ expect(await eventsPromise).toMatchObject([
1278
+ { event: 'notification', data: { id: '1', message: 'Hello!' } },
1279
+ { event: 'notification', data: { id: '2', message: 'World!' } },
1280
+ ])
1627
1281
 
1628
- // 5. Clean up
1629
1282
  client.close()
1630
1283
  })
1631
1284
  })
1632
1285
  ```
1633
1286
 
1634
- #### Testing autoClose SSE (request-response streaming)
1635
-
1636
- Use `SSEInjectClient` or the contract-aware `injectSSE`/`injectPayloadSSE` helpers (`injectApiSSE` for `defineApiContract` contracts). No real HTTP server needed - all events are available immediately after the handler completes:
1637
-
1638
- ```ts
1639
- import { SSEInjectClient } from 'opinionated-machine'
1640
-
1641
- it('streams chat completions', async () => {
1642
- const client = new SSEInjectClient(app) // works without app.listen()
1643
-
1644
- const conn = await client.connectWithBody(
1645
- '/api/chat/completions',
1646
- { message: 'Hello world' },
1647
- )
1648
-
1649
- expect(conn.getStatusCode()).toBe(200)
1650
- const events = conn.getReceivedEvents()
1651
- const chunks = events.filter((e) => e.event === 'chunk')
1652
- expect(chunks.length).toBeGreaterThan(0)
1653
- })
1654
- ```
1655
-
1656
- If the route answers with an error status before streaming starts, the response carries a JSON body instead of events - read it with `conn.getBody()` or `conn.json()` (see [SSEInjectClient](#sseinjectclient)).
1657
-
1658
- #### Asserting documented error responses with `bodyForStatus`
1659
-
1660
- When a contract declares `responseBodySchemasByStatusCode` for non-2xx responses (the shape the handler emits via `sse.respond(status, body)` before streaming starts), `injectSSE` / `injectPayloadSSE` expose a typed `bodyForStatus(status)` accessor:
1661
-
1662
- ```ts
1663
- import { buildSseContract } from '@lokalise/api-contracts'
1664
- import { z } from 'zod'
1665
- import { injectSSE } from 'opinionated-machine'
1666
-
1667
- const streamContract = buildSseContract({
1668
- visibility: 'public',
1669
- method: 'get',
1670
- pathResolver: () => '/api/stream',
1671
- requestQuerySchema: z.object({}),
1672
- requestHeaderSchema: z.object({}),
1673
- responseBodySchemasByStatusCode: {
1674
- 401: z.object({ message: z.string() }),
1675
- 404: z.object({ resourceId: z.string() }),
1676
- },
1677
- serverSentEventSchemas: { message: z.object({ text: z.string() }) },
1678
- })
1679
-
1680
- it('returns the documented 401 body when unauthenticated', async () => {
1681
- const { bodyForStatus } = injectSSE(app, streamContract, {})
1682
-
1683
- // `body` is typed as `{ message: string }` — the 401 schema.
1684
- // TS rejects status codes the contract doesn't declare, e.g. bodyForStatus(500).
1685
- const body = await bodyForStatus(401)
1686
- expect(body.message).toBe('Unauthorized')
1687
- })
1688
- ```
1689
-
1690
- `bodyForStatus(status)` awaits the response, asserts the actual status matches, JSON-parses the body, and runs it through the Zod schema declared for that status. It throws — with the offending status and a truncated body snippet — if the status doesn't match, the contract declares no schema for that status, the body isn't valid JSON, or Zod parsing fails. The raw `closed` promise is still exposed for callers that want to read `body: string` directly.
1691
-
1692
- #### Contracts built with `defineApiContract`: `injectApiSSE`
1287
+ ### SSESessionSpy API
1693
1288
 
1694
- `injectSSE` / `injectPayloadSSE` are typed against the legacy `SSEContractDefinition` from `buildSseContract`. For contracts built with the newer `defineApiContract` + `sseResponse` / `sseBody` API, use `injectApiSSE` instead — one function for every method, with `params` in the same shape `injectByApiContract` takes:
1289
+ `createSSESessionSpy()` returns a spy plus the `onConnect` / `onClose` route hooks that drive it:
1695
1290
 
1696
1291
  ```ts
1697
- import { defineApiContract, sseResponse } from '@lokalise/api-contracts'
1698
- import { z } from 'zod/v4'
1699
- import { injectApiSSE } from 'opinionated-machine'
1700
-
1701
- const lqaSegmentContract = defineApiContract({
1702
- visibility: 'internal',
1703
- method: 'post',
1704
- summary: 'Perform LQA on a text segment',
1705
- pathResolver: () => '/v1/content/actions/lqa-text-segment',
1706
- requestBodySchema: z.object({ segment: z.string() }),
1707
- responsesByStatusCode: {
1708
- 200: sseResponse({ review: z.object({ score: z.number() }) }),
1709
- 400: z.object({ message: z.string() }),
1710
- },
1711
- })
1712
-
1713
- it('streams the review', async () => {
1714
- const { events } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1292
+ import { buildApiRoute, createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
1715
1293
 
1716
- // Events are validated against the contract and typed as a union on `event`.
1717
- for (const event of await events()) {
1718
- if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
1719
- }
1720
- })
1721
-
1722
- it('returns the documented 400 body for an empty segment', async () => {
1723
- const { bodyForStatus } = injectApiSSE(app, lqaSegmentContract, { body: { segment: '' } })
1724
-
1725
- // `body` is typed as `{ message: string }` — the contract's 400 schema.
1726
- const body = await bodyForStatus(400)
1727
- expect(body.message).toBe('segment must not be empty')
1728
- })
1729
- ```
1730
-
1731
- ```ts
1732
- it('sends each issue as soon as it is found', async () => {
1733
- const { head, stream } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1294
+ const { spy, routeOptions } = createSSESessionSpy()
1734
1295
 
1735
- // The head is on the wire as soon as the handler calls sse.start()
1736
- expect((await head).statusCode).toBe(200)
1296
+ // in the app under test — `routeOptions` is just `{ onConnect, onClose }`
1297
+ app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
1737
1298
 
1738
- for await (const event of stream()) {
1739
- // Each event is observed while the handler is still producing the next one
1740
- if (event.event === 'issue') expect(handlerFinished).toBe(false)
1741
- }
1299
+ // in the test — waits for the server-side session before resolving
1300
+ const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, '/api/stream', {
1301
+ awaitServerConnection: { spy },
1742
1302
  })
1303
+ await serverConnection.send('ping', { seq: 1 })
1743
1304
  ```
1744
1305
 
1745
- `closed` and `bodyForStatus` behave as they do on `injectSSE`, except that `bodyForStatus` resolves its schema from `responsesByStatusCode`, following the same exact → range → `'default'` precedence as the contract client. `events()` is additionally available: it parses the SSE body and validates each event against the contract's SSE schemas, throwing when the response isn't a stream, when an event name isn't declared, or when a payload fails its schema.
1746
-
1747
- Two accessors read the response before it completes, so an inject-based suite can assert *progressive delivery* without opening a real port for it:
1748
-
1749
- - `head` — `{ statusCode, headers }`, resolved as soon as the response head is on the wire (for a streaming handler, at `sse.start()`).
1750
- - `stream(signal?)` — the same typed, validated events `events()` returns, yielded as the handler writes them. The request is injected with Fastify's `payloadAsStream`; events are buffered from the moment it is injected, so a generator started late replays the stream from its first event, a consumer that breaks early leaves `closed` / `events()` intact, and the handler is never blocked waiting to be read.
1751
-
1752
- `closed` and `events()` still wait for the response to complete, so a route that never closes its stream (a `keepAlive` session) can only be read through `stream()` — or over real HTTP with [`connectApiSSE`](#contract-aware-http-helpers).
1753
-
1754
- The request always carries `accept: text/event-stream`, so a status that declares a stream answers with it — including a dual-mode status whose content map also carries a JSON schema. Those statuses are therefore not callable through `bodyForStatus`; read them with `events()`, or use `injectByApiContract` when you want the JSON side. Conversely, a contract that declares no SSE response at all types `events` as `never`, so calling it is a compile error rather than a guaranteed throw. `events()` is typed from the SSE schemas of *every* declared status, merged the same way the runtime merges them, so a contract streaming on both `200` and `'4xx'` yields the union of both event sets. See [ApiContract controller docs](./lib/api-contracts/docs.md#testing) for the full testing guide.
1755
-
1756
- ### SSESessionSpy API
1757
-
1758
- The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
1306
+ The spy can also be queried directly:
1759
1307
 
1760
1308
  ```ts
1761
1309
  // Wait for a session to be established (with timeout)
1762
- const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1310
+ const session = await spy.waitForConnection({ timeout: 5000 })
1763
1311
 
1764
1312
  // Wait for a session matching a predicate (useful for multiple sessions)
1765
- const session = await controller.connectionSpy.waitForConnection({
1313
+ const session = await spy.waitForConnection({
1766
1314
  timeout: 5000,
1767
1315
  predicate: (s) => s.request.url.includes('/api/notifications'),
1768
1316
  })
1769
1317
 
1770
1318
  // Check if a specific session is active
1771
- const isConnected = controller.connectionSpy.isConnected(sessionId)
1319
+ const isConnected = spy.isConnected(sessionId)
1772
1320
 
1773
1321
  // Wait for a specific session to disconnect
1774
- await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
1322
+ await spy.waitForDisconnection(sessionId, { timeout: 5000 })
1775
1323
 
1776
1324
  // Get all session events (connect/disconnect history)
1777
- const events = controller.connectionSpy.getEvents()
1325
+ const events = spy.getEvents()
1778
1326
 
1779
1327
  // Clear event history and claimed sessions between tests
1780
- controller.connectionSpy.clear()
1781
- ```
1782
-
1783
- **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`.
1784
-
1785
- #### Standalone spy for `buildApiRoute` routes
1786
-
1787
- `connectionSpy` only exists on `AbstractSSEController`. For routes built with `buildApiRoute` there is no controller to read it off, so `createSSESessionSpy()` returns a spy plus the `onConnect` / `onClose` route hooks that drive it:
1788
-
1789
- ```ts
1790
- import { createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
1791
-
1792
- const { spy, routeOptions } = createSSESessionSpy()
1793
-
1794
- // in the app under test — `routeOptions` is just `{ onConnect, onClose }`
1795
- app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
1796
-
1797
- // in the test — same race-free connect as with a controller
1798
- const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, '/api/stream', {
1799
- awaitServerConnection: { spy },
1800
- })
1801
- await serverConnection.send('ping', { seq: 1 })
1328
+ spy.clear()
1802
1329
  ```
1803
1330
 
1804
- `awaitServerConnection` accepts either `{ controller }` or `{ spy }`; both wait for the server-side handler to finish registering the session before `connect()` resolves.
1331
+ **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. `awaitServerConnection` uses it the same way.
1805
1332
 
1806
1333
  **The hooks have to reach the `buildApiRoute()` call itself.** `buildApiRoute` captures `onConnect` / `onClose` when it builds the handler, so assigning them to the `RouteOptions` object it returns does nothing — the connect would just time out:
1807
1334
 
@@ -1851,24 +1378,7 @@ app.route(
1851
1378
 
1852
1379
  **`keepAlive` sessions only.** An `autoClose` route closes its session as the handler returns, and `waitForConnection` only hands back sessions that are still open, since a closed one can no longer be sent events. Awaiting such a connection races and usually times out (with an error that says as much) — omit `awaitServerConnection` for `autoClose` routes and assert on the events the client received instead.
1853
1380
 
1854
- The spy is typed for the `SSESession` of `@lokalise/fastify-api-contracts`, which is what `buildApiRoute` passes to its hooks. To wire the same spy into a `buildFastifyRoute`-built route, parameterize it with this package's session type: `createSSESessionSpy<SSESession>()`.
1855
-
1856
- ### Session Monitoring
1857
-
1858
- Controllers have access to utility methods for monitoring sessions:
1859
-
1860
- ```ts
1861
- // Get count of active sessions
1862
- const count = this.getConnectionCount()
1863
-
1864
- // Get all active sessions (for iteration/inspection)
1865
- const sessions = this.getConnections()
1866
-
1867
- // Check if session spy is enabled (useful for conditional logic)
1868
- if (this.hasConnectionSpy()) {
1869
- // ...
1870
- }
1871
- ```
1381
+ The spy is typed for the `SSESession` of `@lokalise/fastify-api-contracts`, which is what `buildApiRoute` passes to its hooks.
1872
1382
 
1873
1383
  ### SSE Rooms
1874
1384
 
@@ -1881,15 +1391,18 @@ SSE Rooms provide Socket.IO-style room functionality for grouping connections an
1881
1391
 
1882
1392
  #### Enabling Rooms
1883
1393
 
1884
- Room infrastructure is registered at the module level via `resolveDependencies()`. Controllers opt in with `rooms: true`, which resolves `sseRoomBroadcaster` from the DI cradle:
1394
+ Register the room infrastructure once, in any module's `resolveDependencies()`, and inject the broadcaster into the controllers that need it. A route opts in by passing the broadcaster as the `sseRooms` route option:
1885
1395
 
1886
1396
  ```ts
1887
1397
  import { asValue } from 'awilix'
1398
+ import type { RouteOptions } from 'fastify'
1888
1399
  import {
1400
+ AbstractApiController,
1889
1401
  AbstractModule,
1890
- AbstractSSEController,
1402
+ asApiControllerClass,
1891
1403
  asSingletonClass,
1892
- asSSEControllerClass,
1404
+ buildApiRoute,
1405
+ getSessionRooms,
1893
1406
  SSERoomBroadcaster,
1894
1407
  SSERoomManager,
1895
1408
  } from 'opinionated-machine'
@@ -1897,123 +1410,75 @@ import {
1897
1410
  class DashboardModule extends AbstractModule {
1898
1411
  resolveDependencies() {
1899
1412
  return {
1900
- // Required: room infrastructure — registered once, shared across controllers
1413
+ // Registered once, shared by every route that uses rooms
1901
1414
  sseRoomManager: asValue(new SSERoomManager()),
1902
1415
  sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster), // expects 'sseRoomManager' in cradle — name must match exactly
1903
1416
  }
1904
1417
  }
1905
1418
 
1906
- resolveControllers(diOptions: DependencyInjectionOptions) {
1419
+ resolveControllers() {
1907
1420
  return {
1908
- // rooms: true → resolves 'sseRoomBroadcaster' from DI cradle
1909
- dashboardController: asSSEControllerClass(DashboardSSEController, {
1910
- diOptions,
1911
- rooms: true,
1912
- }),
1421
+ dashboardController: asApiControllerClass(DashboardController),
1913
1422
  }
1914
1423
  }
1915
1424
  }
1916
- ```
1917
-
1918
- > **Required DI registrations for rooms:** Any module using `rooms: true` must have both `sseRoomManager` and `sseRoomBroadcaster` registered in the DI container before the controller is resolved. `SSERoomBroadcaster` expects `sseRoomManager` in its constructor cradle.
1919
1425
 
1920
- #### Session Room Operations
1426
+ class DashboardController extends AbstractApiController<typeof DashboardController.contracts> {
1427
+ static contracts = { dashboardStream: dashboardStreamContract } as const
1921
1428
 
1922
- When rooms are enabled, each SSE session has access to room operations via `session.rooms`:
1429
+ readonly routes: Record<keyof typeof DashboardController.contracts, RouteOptions>
1923
1430
 
1924
- ```ts
1925
- private handleDashboardStream = buildHandler(dashboardStreamContract, {
1926
- sse: async (request, sse) => {
1927
- const session = sse.start('keepAlive')
1431
+ constructor({ sseRoomBroadcaster }: { sseRoomBroadcaster: SSERoomBroadcaster }) {
1432
+ super()
1433
+ // Built in the constructor: a field initializer would run before the
1434
+ // broadcaster is available to pass as an option
1435
+ this.routes = {
1436
+ dashboardStream: buildApiRoute(
1437
+ DashboardController.contracts.dashboardStream,
1438
+ (request, _reply, { sse }) => {
1439
+ const session = sse.start('keepAlive')
1440
+ getSessionRooms(session).join(`dashboard:${request.params.dashboardId}`)
1441
+ },
1442
+ { sseRooms: sseRoomBroadcaster },
1443
+ ),
1444
+ }
1445
+ }
1446
+ }
1447
+ ```
1928
1448
 
1929
- // Join one or more rooms
1930
- session.rooms.join(`dashboard:${request.params.dashboardId}`)
1931
- session.rooms.join(['org:acme', 'plan:enterprise']) // Multiple rooms
1449
+ With `sseRooms`, each session opened by the route is registered with the broadcaster, receives broadcasts for the rooms it joined, and is cleaned up (rooms left, dedup cache cleared) when the connection closes. Without it, `getSessionRooms(session)` returns no-ops and the session receives no broadcasts.
1932
1450
 
1933
- // Leave rooms
1934
- session.rooms.leave('plan:enterprise')
1935
- },
1936
- })
1937
- ```
1451
+ `sseRooms` also accepts an options object, `{ broadcaster, authorizeJoin?, maxSessionLifetimeMs? }`, for a per-route scope check on joins and a bounded session lifetime. See [SSE Rooms Authorization](#sse-rooms-authorization).
1938
1452
 
1939
- #### Broadcasting to Rooms
1453
+ #### Session Room Operations
1940
1454
 
1941
- Use `broadcastToRoom()` from your controller to send type-safe messages to all connections in a room. Event names and data are validated against your contract schemas at compile time:
1455
+ `getSessionRooms(session)` returns the room operations of a session opened by an `sseRooms` route:
1942
1456
 
1943
1457
  ```ts
1944
- class DashboardSSEController extends AbstractSSEController<typeof contracts> {
1945
- // Send metrics update to everyone viewing the dashboard
1946
- // Event name and data are type-checked against contract's sseEvents
1947
- async broadcastMetricsUpdate(dashboardId: string, metrics: DashboardMetrics) {
1948
- const count = await this.broadcastToRoom(
1949
- `dashboard:${dashboardId}`,
1950
- 'metricsUpdate', // Must be a valid event name from contracts
1951
- metrics, // Must match the schema for 'metricsUpdate'
1952
- )
1953
- console.log(`Metrics sent to ${count} viewers`)
1954
- }
1955
-
1956
- // Broadcast to a room
1957
- async broadcastChange(dashboardId: string, change: DashboardChange) {
1958
- await this.broadcastToRoom(
1959
- `dashboard:${dashboardId}`,
1960
- 'change',
1961
- change,
1962
- )
1963
- }
1458
+ const session = sse.start('keepAlive')
1459
+ const rooms = getSessionRooms(session)
1964
1460
 
1965
- // Broadcast to multiple rooms (connections in any room receive it, de-duplicated)
1966
- async announceFeature(feature: string) {
1967
- const count = await this.broadcastToRoom(
1968
- ['premium', 'beta-testers'],
1969
- 'featureFlag',
1970
- { flag: feature, enabled: true },
1971
- )
1972
- // Each connection receives the message only once, even if in multiple rooms
1973
- }
1461
+ // Join one or more rooms
1462
+ rooms.join(`dashboard:${request.params.dashboardId}`)
1463
+ rooms.join(['org:acme', 'plan:enterprise']) // Multiple rooms
1974
1464
 
1975
- // Local-only broadcast (skip Redis propagation in multi-node setups)
1976
- async localAnnouncement(room: string, message: string) {
1977
- await this.broadcastToRoom(room, 'announcement', { message }, { local: true })
1978
- }
1979
- }
1465
+ // Leave rooms
1466
+ rooms.leave('plan:enterprise')
1980
1467
  ```
1981
1468
 
1982
- #### Room Broadcaster (Decoupled Broadcasting)
1469
+ #### Broadcasting to Rooms
1983
1470
 
1984
- The `broadcastToRoom()` method on the controller is `protected`, which means domain services (use cases, event handlers, message queue consumers) can't call it directly. The `SSERoomBroadcaster` solves this — it's a shared, non-generic service registered in DI that domain services receive directly:
1471
+ `SSERoomBroadcaster` is a shared, non-generic service, so domain services (use cases, event handlers, message queue consumers) receive it from DI and broadcast directly. Events are declared with `defineEvent()`, which types the payload:
1985
1472
 
1986
1473
  ```ts
1987
1474
  import { defineEvent, type SSERoomBroadcaster } from 'opinionated-machine'
1988
- import { z } from 'zod'
1475
+ import { z } from 'zod/v4'
1989
1476
 
1990
- // 1. Define type-safe events with schemas
1991
1477
  const metricsUpdateEvent = defineEvent(
1992
1478
  'metricsUpdate',
1993
1479
  z.object({ cpu: z.number(), memory: z.number() }),
1994
1480
  )
1995
1481
 
1996
- // 2. Register domain service in resolveDependencies()
1997
- class DashboardModule extends AbstractModule {
1998
- resolveDependencies() {
1999
- return {
2000
- sseRoomManager: asValue(new SSERoomManager()),
2001
- sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster), // expects 'sseRoomManager' in cradle — name must match exactly
2002
- metricsService: asSingletonClass(MetricsService),
2003
- }
2004
- }
2005
-
2006
- resolveControllers(diOptions: DependencyInjectionOptions) {
2007
- return {
2008
- dashboardController: asSSEControllerClass(DashboardSSEController, {
2009
- diOptions,
2010
- rooms: true,
2011
- }),
2012
- }
2013
- }
2014
- }
2015
-
2016
- // 3. Inject broadcaster into domain services — no generic needed
2017
1482
  class MetricsService {
2018
1483
  private broadcaster: SSERoomBroadcaster
2019
1484
 
@@ -2022,17 +1487,30 @@ class MetricsService {
2022
1487
  }
2023
1488
 
2024
1489
  async onMetricsUpdate(dashboardId: string, metrics: { cpu: number; memory: number }) {
2025
- // Type-safe: data is validated against the event's schema at compile time
2026
- await this.broadcaster.broadcastToRoom(
1490
+ // Returns the number of local connections the event was delivered to
1491
+ const count = await this.broadcaster.broadcastToRoom(
2027
1492
  `dashboard:${dashboardId}`,
2028
1493
  metricsUpdateEvent,
2029
1494
  metrics,
2030
1495
  )
2031
1496
  }
1497
+
1498
+ async announceFeature(flag: string) {
1499
+ // Several rooms: a connection in more than one of them receives the event once
1500
+ await this.broadcaster.broadcastToRoom(['premium', 'beta-testers'], featureFlagEvent, {
1501
+ flag,
1502
+ enabled: true,
1503
+ })
1504
+ }
1505
+
1506
+ async localAnnouncement(room: string, message: string) {
1507
+ // Skip adapter (Redis) propagation in multi-node setups
1508
+ await this.broadcaster.broadcastToRoom(room, announcementEvent, { message }, { local: true })
1509
+ }
2032
1510
  }
2033
1511
  ```
2034
1512
 
2035
- The broadcaster provides `broadcastToRoom()` (with `defineEvent()`-based type safety), `broadcastMessage()` (raw SSEMessage), plus room query methods (`getConnectionsInRoom`, `getConnectionCountInRoom`). Multiple controllers register their `sendEvent` with the same broadcaster — the first to recognize a connection handles delivery.
1513
+ `broadcastToRoom(room, event, data, options?)` takes `id` and `retry` in its options as well, and generates a random `id` when none is given. `broadcastMessage(room, message, options?)` sends a raw `SSEMessage` and returns `{ delivered, filtered }`. The event must also be declared in the SSE schemas of the route's contract: the session validates every message it sends, and a broadcast it rejects is logged and counted as not delivered.
2036
1514
 
2037
1515
  #### Room Event Publisher (Fire-and-Forget)
2038
1516
 
@@ -2044,7 +1522,7 @@ without the promise.
2044
1522
 
2045
1523
  ```ts
2046
1524
  import { defineEvent, SSERoomEventPublisher } from 'opinionated-machine'
2047
- import { z } from 'zod'
1525
+ import { z } from 'zod/v4'
2048
1526
 
2049
1527
  const metricsUpdateEvent = defineEvent(
2050
1528
  'metricsUpdate',
@@ -2133,7 +1611,7 @@ count matters, or when a failed delivery is something the caller can act on.
2133
1611
 
2134
1612
  #### Room Name Helpers
2135
1613
 
2136
- Room names are plain strings (like Socket.IO), but `defineRoom()` adds type-safe resolvers that ensure consistent naming across controllers and domain services:
1614
+ Room names are plain strings (like Socket.IO), but `defineRoom()` adds type-safe resolvers that keep naming consistent between routes and domain services:
2137
1615
 
2138
1616
  ```ts
2139
1617
  import { defineRoom } from 'opinionated-machine'
@@ -2147,48 +1625,36 @@ const projectChannelRoom = defineRoom<{ projectId: string; channelId: string }>(
2147
1625
  ({ projectId, channelId }) => `project:${projectId}:channel:${channelId}`,
2148
1626
  )
2149
1627
 
2150
- // In controller handler — params are type-checked
2151
- session.rooms.join(dashboardRoom({ dashboardId: request.params.dashboardId }))
1628
+ // In the route handler — params are type-checked
1629
+ getSessionRooms(session).join(dashboardRoom({ dashboardId: request.params.dashboardId }))
2152
1630
 
2153
- // In domain service — same resolver, same type safety
2154
- await broadcaster.broadcastToRoom(
2155
- dashboardRoom({ dashboardId }),
2156
- 'metricsUpdate',
2157
- metrics,
2158
- )
1631
+ // In a domain service — same resolver, same type safety
1632
+ await broadcaster.broadcastToRoom(dashboardRoom({ dashboardId }), metricsUpdateEvent, metrics)
2159
1633
  ```
2160
1634
 
2161
1635
  `defineRoom()` is a zero-overhead identity wrapper — it simply returns the function you pass in, typed as `RoomNameResolver<TParams>`. The value is purely at compile time: typos in room name patterns become type errors, and refactoring a room's naming scheme only requires changing one place.
2162
1636
 
2163
1637
  #### Room Query Methods
2164
1638
 
2165
- Controllers have access to room query methods:
1639
+ The broadcaster answers the common queries; the underlying `SSERoomManager` (`broadcaster.roomManager`) has the rest:
2166
1640
 
2167
1641
  ```ts
2168
- class DashboardSSEController extends AbstractSSEController<typeof contracts> {
2169
- // Get all connection IDs in a room
2170
- getDashboardViewers(dashboardId: string): string[] {
2171
- return this.getConnectionsInRoom(`dashboard:${dashboardId}`)
2172
- }
2173
-
2174
- // Get count of connections in a room
2175
- getDashboardViewerCount(dashboardId: string): number {
2176
- return this.getConnectionCountInRoom(`dashboard:${dashboardId}`)
2177
- }
1642
+ // Connection ids in a room, and their count
1643
+ broadcaster.getConnectionsInRoom(`dashboard:${dashboardId}`)
1644
+ broadcaster.getConnectionCountInRoom(`dashboard:${dashboardId}`)
2178
1645
 
2179
- // Get all rooms a specific connection is in
2180
- getConnectionRooms(connectionId: string): string[] {
2181
- return this.getRooms(connectionId)
2182
- }
1646
+ // Rooms a connection is in, membership checks, all rooms on this node
1647
+ broadcaster.roomManager.getRooms(connectionId)
1648
+ broadcaster.roomManager.isInRoom(connectionId, room)
1649
+ broadcaster.roomManager.getAllRooms()
2183
1650
 
2184
- // Manually join/leave rooms from controller (useful for admin operations)
2185
- moveToRoom(connectionId: string, fromRoom: string, toRoom: string) {
2186
- this.leaveRoom(connectionId, fromRoom)
2187
- this.joinRoom(connectionId, toRoom)
2188
- }
2189
- }
1651
+ // Join or leave on behalf of a connection (e.g., admin operations)
1652
+ broadcaster.roomManager.leave(connectionId, fromRoom)
1653
+ broadcaster.roomManager.join(connectionId, toRoom)
2190
1654
  ```
2191
1655
 
1656
+ Joining through `roomManager` directly bypasses the route's `authorizeJoin` check. To end a connection's stream or remove it from a room as a revocation, use the registry described in [SSE Rooms Authorization](#sse-rooms-authorization).
1657
+
2192
1658
  #### Auto-Leave on Disconnect
2193
1659
 
2194
1660
  When a connection closes (client disconnect or server close), it automatically leaves all rooms. No manual cleanup is required.
@@ -2216,19 +1682,10 @@ class InfraModule extends AbstractModule {
2216
1682
  }
2217
1683
  }
2218
1684
  }
2219
-
2220
- class DashboardModule extends AbstractModule {
2221
- resolveControllers(diOptions: DependencyInjectionOptions) {
2222
- return {
2223
- dashboardController: asSSEControllerClass(DashboardSSEController, {
2224
- diOptions,
2225
- rooms: true,
2226
- }),
2227
- }
2228
- }
2229
- }
2230
1685
  ```
2231
1686
 
1687
+ Routes need no change: they keep passing the same broadcaster as `sseRooms`.
1688
+
2232
1689
  The Redis adapter uses Pub/Sub for cross-node message propagation. When you call `broadcastToRoom()`, the message is published to Redis and delivered to all nodes that have connections in that room.
2233
1690
 
2234
1691
  See the [@opinionated-machine/sse-rooms-redis](../sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
@@ -2322,25 +1779,61 @@ const subscriptionManager = new SSESubscriptionManager<UserCtx, EventMetadata>(
2322
1779
 
2323
1780
  #### Integrating with a Controller
2324
1781
 
2325
- Wire `handleConnect` and `handleDisconnect` into the SSE session lifecycle:
1782
+ Wire `handleConnect` and `handleDisconnect` into the route's `onConnect` / `onClose` hooks, and pass the broadcaster as `sseRooms`:
2326
1783
 
2327
1784
  ```typescript
2328
- class NotificationController extends AbstractSSEController<Contracts> {
2329
- private handleStream = buildHandler(contract, {
2330
- sse: (request, sse) => {
2331
- const session = sse.start('keepAlive')
2332
- this.subscriptionManager.handleConnect(session).catch(() => {
2333
- // Handle connection setup failure (e.g., resolver threw)
2334
- })
2335
- },
2336
- }, {
2337
- onClose: (session) => {
2338
- this.subscriptionManager.handleDisconnect(session)
2339
- },
2340
- })
1785
+ class NotificationController extends AbstractApiController<typeof NotificationController.contracts> {
1786
+ static contracts = { notificationStream: notificationStreamContract } as const
1787
+
1788
+ readonly routes: Record<keyof typeof NotificationController.contracts, RouteOptions>
1789
+
1790
+ private readonly subscriptionManager: SSESubscriptionManager<UserCtx, EventMetadata>
1791
+ // onConnect is not awaited by the route builder, see below
1792
+ private readonly pendingConnects = new Map<string, Promise<void>>()
1793
+
1794
+ constructor(deps: Dependencies) {
1795
+ super()
1796
+ this.subscriptionManager = deps.subscriptionManager
1797
+
1798
+ this.routes = {
1799
+ notificationStream: buildApiRoute(
1800
+ NotificationController.contracts.notificationStream,
1801
+ (_request, _reply, { sse }) => {
1802
+ sse.start('keepAlive')
1803
+ },
1804
+ {
1805
+ onConnect: (session) => this.connect(session),
1806
+ onClose: (session) => this.disconnect(session),
1807
+ // Required: registers the session with the broadcaster
1808
+ sseRooms: deps.sseRoomBroadcaster,
1809
+ },
1810
+ ),
1811
+ }
1812
+ }
1813
+
1814
+ private connect(session: SSESession): Promise<void> {
1815
+ const connected = this.subscriptionManager.handleConnect(session)
1816
+ // Must not reject, so that disconnect still runs after a failed connect
1817
+ const settled = connected
1818
+ .catch(() => {})
1819
+ .finally(() => this.pendingConnects.delete(session.id))
1820
+ this.pendingConnects.set(session.id, settled)
1821
+ return connected // a rejection is logged by the route
1822
+ }
1823
+
1824
+ private async disconnect(session: SSESession): Promise<void> {
1825
+ await this.pendingConnects.get(session.id)
1826
+ this.subscriptionManager.handleDisconnect(session)
1827
+ }
2341
1828
  }
2342
1829
  ```
2343
1830
 
1831
+ - `sseRooms` is required. The manager joins rooms on `SSERoomManager` directly, and `sseRooms` is what registers the session's sender with the broadcaster. Without it, nothing is delivered.
1832
+ - The route builder does not await `onConnect`, so the client can close while `handleConnect` is still running the resolver chain. `onClose` waits for the in-flight connect before calling `handleDisconnect`; otherwise the connect would finish after the disconnect and leave a managed entry behind.
1833
+ - Rooms joined by the manager bypass the `sseRooms.authorizeJoin` check. The resolvers are the authorization for those rooms.
1834
+
1835
+ `SSESession` here is the type from `@lokalise/fastify-api-contracts`. `test/sse/fixtures/subscriptionFixtures.ts` has a working version of this wiring.
1836
+
2344
1837
  #### Publishing Events
2345
1838
 
2346
1839
  ```typescript
@@ -2432,21 +1925,21 @@ The library provides utilities for testing SSE endpoints.
2432
1925
 
2433
1926
  | Session Mode | Test Client | Reason |
2434
1927
  |-------------|-------------|--------|
2435
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
2436
- | `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
1928
+ | `autoClose` | `injectApiSSE` or `SSEInjectClient` | Handler completes and closes connection; all events available at once |
1929
+ | `keepAlive` | `connectApiSSE` or `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
2437
1930
 
2438
- `SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs. Contracts built with `defineApiContract` use `injectApiSSE` instead of `injectSSE`/`injectPayloadSSE`.
1931
+ `injectApiSSE` and `SSEInjectClient` both use Fastify inject; `injectApiSSE` takes the contract and returns typed, validated events, while `SSEInjectClient` works with raw URLs and unparsed event data. `connectApiSSE` and `SSEHttpClient` have the same relationship over real HTTP.
2439
1932
 
2440
1933
  #### Detailed Comparison
2441
1934
 
2442
- | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
2443
- |---------|----------------------------------------|------------------------|
1935
+ | Feature | Inject (`injectApiSSE`, `SSEInjectClient`) | HTTP (`connectApiSSE`, `SSEHttpClient`) |
1936
+ |---------|--------------------------------------------|------------------------------------------|
2444
1937
  | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
2445
1938
  | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
2446
1939
  | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
2447
1940
  | **Server requirement** | No `listen()` needed | Requires a listening server (`SSETestServer.start(app)` or manual `app.listen()`) |
2448
- | **Request body** | `injectPayloadSSE` / `connectWithBody` | `method` + `body` connect options |
2449
- | **Assertions before the handler finishes** | `injectApiSSE`'s `head` / `stream()` (other inject helpers buffer the whole response) | `client.response` is available as soon as headers arrive |
1941
+ | **Request body** | `body` param / `connectWithBody` | `body` param / `method` + `body` connect options |
1942
+ | **Assertions before the handler finishes** | `injectApiSSE`'s `head` / `stream()` (`SSEInjectClient` buffers the whole response) | `client.response` is available as soon as headers arrive |
2450
1943
  | **Best for** | `autoClose` SSE (OpenAI-style, batch exports) | `keepAlive` SSE (notifications, live feeds, rooms), streams whose headers must be asserted mid-handler |
2451
1944
  | **Dual-mode sync** | Use `app.inject()` with `accept: 'application/json'` | Same |
2452
1945
 
@@ -2455,7 +1948,10 @@ The library provides utilities for testing SSE endpoints.
2455
1948
  For testing `keepAlive` SSE connections using real HTTP, and for any assertion that has to happen on the wire while the handler is still running. Supports `GET`, `POST`, `PUT` and `PATCH`. Requires a listening server — use `SSETestServer.start(app)` to start your app on a random port:
2456
1949
 
2457
1950
  ```ts
2458
- import { SSEHttpClient } from 'opinionated-machine'
1951
+ import { createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
1952
+
1953
+ // The spy's routeOptions are passed to the route's buildApiRoute() call
1954
+ const { spy, routeOptions } = createSSESessionSpy()
2459
1955
 
2460
1956
  // Connect to SSE endpoint with awaitServerConnection (recommended)
2461
1957
  // This eliminates the race condition between client connect and server-side registration
@@ -2465,13 +1961,13 @@ const { client, serverConnection } = await SSEHttpClient.connect(
2465
1961
  {
2466
1962
  query: { userId: 'test' },
2467
1963
  headers: { authorization: 'Bearer token' },
2468
- awaitServerConnection: { controller }, // Pass your SSE controller
1964
+ awaitServerConnection: { spy },
2469
1965
  },
2470
1966
  )
2471
1967
 
2472
- // serverConnection is ready to use immediately
1968
+ // serverConnection is the server-side session, ready to use immediately
2473
1969
  expect(client.response.ok).toBe(true)
2474
- await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1970
+ await serverConnection.send('test', {})
2475
1971
 
2476
1972
  // Collect events by count with timeout
2477
1973
  const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
@@ -2503,917 +1999,238 @@ Collects events until a count is reached or a predicate returns true.
2503
1999
 
2504
2000
  Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
2505
2001
 
2506
- ```ts
2507
- // Collect exactly 3 events
2508
- const events = await client.collectEvents(3)
2509
-
2510
- // Collect with custom timeout
2511
- const events = await client.collectEvents(5, 10000) // 10s timeout
2512
-
2513
- // Collect until a specific event type (the matching event IS included)
2514
- const events = await client.collectEvents((event) => event.event === 'done')
2515
-
2516
- // Collect until condition with timeout
2517
- const events = await client.collectEvents(
2518
- (event) => JSON.parse(event.data).status === 'complete',
2519
- 30000,
2520
- )
2521
- ```
2522
-
2523
- **`events(signal?)`**
2524
-
2525
- Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
2526
-
2527
- ```ts
2528
- // Basic iteration
2529
- for await (const event of client.events()) {
2530
- console.log(event.event, event.data)
2531
- if (event.event === 'done') break
2532
- }
2533
-
2534
- // With abort signal for timeout control
2535
- const controller = new AbortController()
2536
- const timeoutId = setTimeout(() => controller.abort(), 5000)
2537
-
2538
- try {
2539
- for await (const event of client.events(controller.signal)) {
2540
- console.log(event)
2541
- }
2542
- } finally {
2543
- clearTimeout(timeoutId)
2544
- }
2545
- ```
2546
-
2547
- **When to omit `awaitServerConnection`**
2548
-
2549
- Omit `awaitServerConnection` only in these cases:
2550
- - Testing against external SSE endpoints (not your own controller)
2551
- - When `isTestMode: false` (connectionSpy not available)
2552
- - Simple smoke tests that only verify response headers/status without sending server events
2553
- - Routes whose handler starts an `autoClose` session: it closes as the handler returns, so there is no live session left to wait for — assert on the received events instead
2554
-
2555
- For `keepAlive` routes built with `buildApiRoute` (no controller, so no `connectionSpy`), pass a standalone spy instead of dropping the option: `awaitServerConnection: { spy }`, with the spy from [`createSSESessionSpy()`](#standalone-spy-for-buildapiroute-routes).
2556
-
2557
- **Consequence**: Without `awaitServerConnection`, `connect()` resolves as soon as HTTP headers are received. Server-side connection registration may not have completed yet, so you cannot reliably send events from the server immediately after `connect()` returns.
2558
-
2559
- ```ts
2560
- // Example: smoke test that only checks connection works
2561
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
2562
- expect(client.response.ok).toBe(true)
2563
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
2564
- client.close()
2565
- ```
2566
-
2567
- **POST/PUT/PATCH endpoints**
2568
-
2569
- `connect()` also issues non-GET requests, so SSE endpoints that take a request body can be tested over real HTTP. Pass `method` and `body`. The method is accepted in either case, so the lowercase spelling your contracts already use (`method: 'post'`) works as-is:
2570
-
2571
- ```ts
2572
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2573
- method: 'POST',
2574
- body: { message: 'Hello', stream: true },
2575
- })
2576
-
2577
- const events = await client.collectEvents((event) => event.event === 'done')
2578
- ```
2579
-
2580
- Bodies `fetch()` can send natively — strings, `URLSearchParams`, `FormData`, `Blob`/`File`, `ArrayBuffer`, typed arrays (`Buffer`, `Uint8Array`, …) and `ReadableStream` — are passed through untouched; anything else is JSON-stringified. `content-type: application/json` is defaulted for JSON-stringified and string bodies (so a raw string stays verbatim, which is handy for asserting on malformed payloads), unless you provide your own content type. Payloads that describe their own encoding — `URLSearchParams`, `FormData`, `Blob` — keep the content type `fetch()` gives them:
2581
-
2582
- ```ts
2583
- // Sent as application/x-www-form-urlencoded, not JSON-stringified into `{}`
2584
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2585
- method: 'post',
2586
- body: new URLSearchParams({ message: 'Hello' }),
2587
- })
2588
- ```
2589
-
2590
- A body without a non-GET `method` throws — `fetch()` cannot attach one to a GET request.
2591
-
2592
- **Asserting on the wire before the handler finishes**
2593
-
2594
- `connect()` resolves as soon as HTTP headers arrive, and `client.response` is populated at that point. That is what makes the "open the stream before the slow work starts" behaviour testable: assert on status and headers while the handler is still awaiting its slow call, then let it proceed.
2595
-
2596
- ```ts
2597
- // Handler calls sse.start() and only then makes its slow LLM call
2598
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2599
- method: 'POST',
2600
- body: { message: 'Hello', stream: true },
2601
- })
2602
-
2603
- // Already on the wire while the LLM call is still in flight
2604
- expect(client.response.status).toBe(200)
2605
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
2606
-
2607
- releaseSlowCall()
2608
- const events = await client.collectEvents((event) => event.event === 'done')
2609
- ```
2610
-
2611
- The mirror case works too: a failure raised *before* `sse.start()` reaches the client as the JSON status the contract declares, not as a terminal `error` event. The response body is only locked once you start consuming events, so it can still be read as JSON:
2612
-
2613
- ```ts
2614
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2615
- method: 'POST',
2616
- body: { message: 'Hello', stream: true },
2617
- })
2618
-
2619
- expect(client.response.status).toBe(503)
2620
- expect(client.response.headers.get('content-type')).not.toContain('text/event-stream')
2621
- expect(await client.response.json()).toEqual({ message: 'Upstream unavailable' })
2622
- ```
2623
-
2624
- Read that body *before* `close()`: closing aborts the request, so a body read after it (or from a `finally { client.close() }` block that runs first) rejects with an `AbortError`.
2625
-
2626
- #### SSEInjectClient
2627
-
2628
- For testing `autoClose` SSE streams (like OpenAI completions). Uses Fastify's `inject()` - no `app.listen()` needed:
2629
-
2630
- ```ts
2631
- import { SSEInjectClient } from 'opinionated-machine'
2632
-
2633
- const client = new SSEInjectClient(app) // No server.listen() needed
2634
-
2635
- // GET request
2636
- const conn = await client.connect('/api/export/progress', {
2637
- headers: { authorization: 'Bearer token' },
2638
- })
2639
-
2640
- // POST request with body (OpenAI-style)
2641
- const conn = await client.connectWithBody(
2642
- '/api/chat/completions',
2643
- { model: 'gpt-4', messages: [...], stream: true },
2644
- )
2645
-
2646
- // Any other method inject() accepts works too - the option is typed as
2647
- // `SSEInjectMethod`, exported so you never have to redeclare that union
2648
- const conn = await client.connectWithBody(
2649
- '/api/exports/42',
2650
- { reason: 'cleanup' },
2651
- { method: 'DELETE' },
2652
- )
2653
-
2654
- // All events are available immediately (inject waits for handler to complete)
2655
- expect(conn.getStatusCode()).toBe(200)
2656
- const events = conn.getReceivedEvents()
2657
- const chunks = events.filter(e => e.event === 'chunk')
2658
- ```
2659
-
2660
- When the route answers with a status code *before* streaming starts (auth failure,
2661
- validation error, integration unavailable), it sends a JSON body rather than events.
2662
- `getBody()` returns that body raw and `json()` parses it, mirroring Fastify's own
2663
- inject response:
2664
-
2665
- ```ts
2666
- const conn = await client.connect('/api/export/progress')
2667
-
2668
- expect(conn.getStatusCode()).toBe(503)
2669
- expect(conn.json<{ errorCode: string }>()).toMatchObject({
2670
- errorCode: 'INTEGRATION_NOT_AVAILABLE',
2671
- })
2672
- ```
2673
-
2674
- `json()` throws if the body is empty or isn't valid JSON, so it only makes sense for
2675
- these pre-stream responses - a `text/event-stream` body is not JSON. For contract-typed
2676
- tests, `injectSSE`/`injectPayloadSSE`/`injectApiSSE` offer `bodyForStatus(status)`, which
2677
- also validates the body against the contract's schema for that status.
2678
-
2679
- #### Contract-Aware Inject Helpers
2680
-
2681
- For typed testing with SSE contracts:
2682
-
2683
- ```ts
2684
- import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
2685
-
2686
- // For GET SSE endpoints with contracts
2687
- const { closed } = injectSSE(app, notificationsContract, {
2688
- query: { userId: 'test' },
2689
- })
2690
- const result = await closed
2691
- const events = parseSSEEvents(result.body)
2692
-
2693
- // For POST/PUT/PATCH SSE endpoints with contracts
2694
- const { closed } = injectPayloadSSE(app, chatCompletionContract, {
2695
- body: { message: 'Hello', stream: true },
2696
- })
2697
- const result = await closed
2698
- const events = parseSSEEvents(result.body)
2699
- ```
2700
-
2701
- For contracts built with `defineApiContract`, use `injectApiSSE` — it types and validates the events for you, and `stream()` yields them as the handler writes them. See [Contracts built with `defineApiContract`: `injectApiSSE`](#contracts-built-with-defineapicontract-injectapisse).
2702
-
2703
- #### Contract-Aware HTTP Helpers
2704
-
2705
- `connectApiSSE` is the real-HTTP counterpart of `injectApiSSE`: the same typed, validated event union, over a connection that can stay open. Use it for `keepAlive` routes, whose response never completes, and wherever a suite needs a real socket.
2706
-
2707
- ```ts
2708
- import { connectApiSSE, SSETestServer } from 'opinionated-machine'
2709
-
2710
- const server = await SSETestServer.start(app)
2711
-
2712
- // Method, path, query params, headers and body all come from the contract
2713
- const client = await connectApiSSE(server.baseUrl, lqaSegmentContract, {
2714
- body: { segment: 'hello' },
2715
- })
2716
-
2717
- expect(client.response.status).toBe(200) // asserted while the handler is still working
2718
-
2719
- for await (const event of client.events()) {
2720
- if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed by the contract
2721
- if (event.event === 'review') break
2722
- }
2723
-
2724
- client.close()
2725
- await server.close()
2726
- ```
2727
-
2728
- - `client.events(signal?)` and `client.collectEvents(countOrPredicate, timeout?)` mirror `SSEHttpClient`'s readers, with each event validated against the contract's SSE schemas and typed as a union on `event` — the predicate sees the narrowed type too, and is invoked exactly once per event.
2729
- - Both readers reject a response that isn't an event stream (a documented `400`/`401` raised before `sse.start()`, say) with its status and body, rather than reporting a stream that produced no events. `client.response` is still readable afterwards, so the JSON body can be asserted.
2730
- - `client.response` is the fetch `Response`, available before any event is consumed.
2731
- - `client.raw` is the underlying `SSEHttpClient`, for anything this wrapper doesn't cover.
2732
- - Pass `{ awaitServerConnection: { spy } }` as a fourth argument (with a spy from `createSSESessionSpy()`) to also wait for the server-side session of a `keepAlive` route; the call then resolves to `{ client, serverConnection }`.
2733
-
2734
- On a connection you already have, the same typing is available per read: `client.apiEvents(contract)` and `client.collectApiEvents(contract, countOrPredicate)` on any `SSEHttpClient`.
2735
-
2736
- #### When a Handler Fails to Send an Event
2737
-
2738
- `session.send(name, payload)` validates the payload against the contract's schema for that event and throws when it doesn't match. The throw happens inside the handler: the event never reaches the wire, the stream ends early with HTTP 200, and the reason only lands in the server log — leaving the test to explain an event that is simply missing.
2739
-
2740
- Routes built with `buildApiRoute` report those failures to whichever helper is reading the stream. When the failure is what cut the stream short — nothing caught it — `events()`, `stream()` and the `connectApiSSE` readers throw with the offending event name, its Zod issues and the rejected payload:
2741
-
2742
- ```
2743
- events() — 1 SSE send failure recorded for this request:
2744
- - event "issue" was never sent: severity: Invalid option: expected one of "neutral"|"minor"|"major"|"critical"; payload: {"severity":"min"}
2745
- ```
2746
-
2747
- A failure the route *recovered* from does not fail the read. A handler that catches its own best-effort send and streams a fallback instead produced exactly the response it meant to, so the readers deliver it and record the failure as context; the same goes for a `sendStream()` source that throws while producing its next message, which is reported as the source failing rather than blamed on the last event that did reach the client.
2748
-
2749
- Both `injectApiSSE(...).sendFailures()` and `connectApiSSE(...).sendFailures()` return every record (`{ eventName?, data?, message, issues?, error, handled }`) — including the recovered ones — for assertions the thrown message doesn't cover:
2750
-
2751
- ```ts
2752
- const { closed, events, sendFailures } = injectApiSSE(app, lqaSegmentContract, { body })
2753
- await closed
2754
-
2755
- expect(await events()).toHaveLength(2) // the fallback stream is intact
2756
- expect(sendFailures()).toMatchObject([{ eventName: 'issue', handled: true }])
2757
- ```
2758
-
2759
- This is test-only and costs production traffic nothing: the helpers tag their requests with an `x-om-sse-diagnostics-id` header, and a session is instrumented only when that header names a diagnostics scope open in the same process — something only those helpers create. A stale or forged header matches nothing.
2760
-
2761
- ## Dual-Mode Controllers (SSE + Sync)
2762
-
2763
- Dual-mode controllers handle both SSE streaming and sync responses on the same route path, automatically branching based on the `Accept` header. This is ideal for APIs that support both real-time streaming and traditional request-response patterns.
2764
-
2765
- ### Overview
2766
-
2767
- | Accept Header | Response Mode |
2768
- | ------------- | ------------- |
2769
- | `text/event-stream` | SSE streaming |
2770
- | `application/json` | Sync response |
2771
- | `*/*` or missing | Sync (default, configurable) |
2772
-
2773
- Dual-mode controllers extend `AbstractDualModeController` which inherits from `AbstractSSEController`, providing access to all SSE features (connection management, broadcasting, lifecycle hooks) while adding sync response support.
2774
-
2775
- ### Defining Dual-Mode Contracts
2776
-
2777
- Dual-mode contracts define endpoints that can return **either** a complete sync response **or** stream SSE events, based on the client's `Accept` header. Use dual-mode when:
2778
-
2779
- - Clients may want immediate results (sync) or real-time updates (SSE)
2780
- - You're building OpenAI-style APIs where `stream: true` triggers SSE
2781
- - You need polling fallback for clients that don't support SSE
2782
-
2783
- To create a dual-mode contract, include a `successResponseBodySchema` in your `buildSseContract` call:
2784
- - Has `successResponseBodySchema` but no `requestBodySchema` → GET dual-mode route
2785
- - Has both `successResponseBodySchema` and `requestBodySchema` → POST/PUT/PATCH dual-mode route
2786
-
2787
- ```ts
2788
- import { z } from 'zod'
2789
- import { buildSseContract } from '@lokalise/api-contracts'
2790
-
2791
- // GET dual-mode route (polling or streaming job status)
2792
- export const jobStatusContract = buildSseContract({
2793
- visibility: 'public',
2794
- method: 'get',
2795
- pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
2796
- requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
2797
- requestQuerySchema: z.object({ verbose: z.string().optional() }),
2798
- requestHeaderSchema: z.object({}),
2799
- successResponseBodySchema: z.object({
2800
- status: z.enum(['pending', 'running', 'completed', 'failed']),
2801
- progress: z.number(),
2802
- result: z.string().optional(),
2803
- }),
2804
- serverSentEventSchemas: {
2805
- progress: z.object({ percent: z.number(), message: z.string().optional() }),
2806
- done: z.object({ result: z.string() }),
2807
- },
2808
- })
2809
-
2810
- // POST dual-mode route (OpenAI-style chat completion)
2811
- export const chatCompletionContract = buildSseContract({
2812
- visibility: 'public',
2813
- method: 'post',
2814
- pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
2815
- requestPathParamsSchema: z.object({ chatId: z.string().uuid() }),
2816
- requestQuerySchema: z.object({}),
2817
- requestHeaderSchema: z.object({ authorization: z.string() }),
2818
- requestBodySchema: z.object({ message: z.string() }),
2819
- successResponseBodySchema: z.object({
2820
- reply: z.string(),
2821
- usage: z.object({ tokens: z.number() }),
2822
- }),
2823
- serverSentEventSchemas: {
2824
- chunk: z.object({ delta: z.string() }),
2825
- done: z.object({ usage: z.object({ total: z.number() }) }),
2826
- },
2827
- })
2828
- ```
2829
-
2830
- **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.
2831
-
2832
- ### Response Headers (Sync Mode)
2833
-
2834
- Dual-mode contracts support an optional `responseHeaderSchema` to define and validate headers sent with sync responses. This is useful for documenting expected headers (rate limits, pagination, cache control) and validating that your handlers set them correctly:
2835
-
2836
- ```ts
2837
- export const rateLimitedContract = buildSseContract({
2838
- visibility: 'public',
2839
- method: 'post',
2840
- pathResolver: () => '/api/rate-limited',
2841
- requestPathParamsSchema: z.object({}),
2842
- requestQuerySchema: z.object({}),
2843
- requestHeaderSchema: z.object({}),
2844
- requestBodySchema: z.object({ data: z.string() }),
2845
- successResponseBodySchema: z.object({ result: z.string() }),
2846
- // Define expected response headers
2847
- responseHeaderSchema: z.object({
2848
- 'x-ratelimit-limit': z.string(),
2849
- 'x-ratelimit-remaining': z.string(),
2850
- 'x-ratelimit-reset': z.string(),
2851
- }),
2852
- serverSentEventSchemas: {
2853
- result: z.object({ success: z.boolean() }),
2854
- },
2855
- })
2856
- ```
2857
-
2858
- In your handler, set headers using `reply.header()`:
2859
-
2860
- ```ts
2861
- handlers: buildHandler(rateLimitedContract, {
2862
- sync: async (request, reply) => {
2863
- reply.header('x-ratelimit-limit', '100')
2864
- reply.header('x-ratelimit-remaining', '99')
2865
- reply.header('x-ratelimit-reset', '1640000000')
2866
- return { result: 'success' }
2867
- },
2868
- sse: async (request, sse) => {
2869
- const session = sse.start('autoClose')
2870
- // ... send events ...
2871
- // Connection closes automatically when handler returns
2872
- },
2873
- })
2874
- ```
2875
-
2876
- If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
2877
-
2878
- ### Status-Specific Response Schemas (responseBodySchemasByStatusCode)
2879
-
2880
- Dual-mode and SSE contracts support `responseBodySchemasByStatusCode` to define and validate responses for specific HTTP status codes. This is typically used for error responses (4xx, 5xx), but can define schemas for any status code where you need a different response shape:
2881
-
2882
- ```ts
2883
- export const resourceContract = buildSseContract({
2884
- visibility: 'public',
2885
- method: 'post',
2886
- pathResolver: (params) => `/api/resources/${params.id}`,
2887
- requestPathParamsSchema: z.object({ id: z.string() }),
2888
- requestQuerySchema: z.object({}),
2889
- requestHeaderSchema: z.object({}),
2890
- requestBodySchema: z.object({ data: z.string() }),
2891
- // Success response (2xx)
2892
- successResponseBodySchema: z.object({
2893
- success: z.boolean(),
2894
- data: z.string(),
2895
- }),
2896
- // Responses by status code (typically used for errors)
2897
- responseBodySchemasByStatusCode: {
2898
- 400: z.object({ error: z.string(), details: z.array(z.string()) }),
2899
- 404: z.object({ error: z.string(), resourceId: z.string() }),
2900
- },
2901
- serverSentEventSchemas: {
2902
- result: z.object({ success: z.boolean() }),
2903
- },
2904
- })
2905
- ```
2906
-
2907
- **Recommended: Use `sse.respond()` for strict type safety**
2908
-
2909
- In SSE handlers, use `sse.respond(code, body)` for non-2xx responses. This provides strict compile-time type enforcement - TypeScript ensures the body matches the exact schema for that status code:
2910
-
2911
- ```ts
2912
- handlers: buildHandler(resourceContract, {
2913
- sync: (request, reply) => {
2914
- if (!isValid(request.body.data)) {
2915
- reply.code(400)
2916
- return { error: 'Bad Request', details: ['Invalid data format'] }
2917
- }
2918
- return { success: true, data: 'OK' }
2919
- },
2920
- sse: async (request, sse) => {
2921
- const resource = findResource(request.params.id)
2922
- if (!resource) {
2923
- // Strict typing: TypeScript enforces exact schema for status 404
2924
- return sse.respond(404, { error: 'Not Found', resourceId: request.params.id })
2925
- }
2926
- if (!isValid(resource)) {
2927
- // Strict typing: TypeScript enforces exact schema for status 400
2928
- return sse.respond(400, { error: 'Bad Request', details: ['Invalid resource'] })
2929
- }
2930
-
2931
- const session = sse.start('autoClose')
2932
- await session.send('result', { success: true })
2933
- },
2934
- })
2935
- ```
2936
-
2937
- TypeScript enforces the exact schema for each status code at compile time:
2938
-
2939
- ```ts
2940
- sse.respond(404, { error: 'Not Found', resourceId: '123' }) // ✓ OK
2941
- sse.respond(404, { error: 'Not Found' }) // ✗ Error - missing resourceId
2942
- sse.respond(404, { error: 'Not Found', details: [] }) // ✗ Error - wrong schema for 404
2943
- sse.respond(500, { message: 'error' }) // ✗ Error - 500 not defined in schema
2944
- ```
2945
-
2946
- Only status codes defined in `responseBodySchemasByStatusCode` are allowed. To use an undefined status code, add it to the schema or use a type assertion.
2947
-
2948
- **Sync handlers (union typing with runtime validation):**
2949
-
2950
- For sync handlers, use `reply.code()` to set the status code and return the response. However, since `reply.code()` and `return` are separate statements, TypeScript cannot correlate them. The return type is a union of all possible response shapes, and runtime validation catches mismatches:
2951
-
2952
- ```ts
2953
- sync: (request, reply) => {
2954
- reply.code(404)
2955
- return { error: 'Not Found', resourceId: '123' } // ✓ OK - matches one of the union types
2956
- // Runtime validation ensures body matches the 404 schema
2957
- }
2958
-
2959
- // The sync handler return type is automatically:
2960
- // { success: boolean; data: string } // from successResponseBodySchema
2961
- // | { error: string; details: string[] } // from responseBodySchemasByStatusCode[400]
2962
- // | { error: string; resourceId: string } // from responseBodySchemasByStatusCode[404]
2963
- ```
2964
-
2965
- **Validation behavior:**
2966
-
2967
- - **Success responses (2xx)**: Validated against `successResponseBodySchema`
2968
- - **Non-2xx responses**: Validated against the matching schema in `responseBodySchemasByStatusCode` (if defined)
2969
- - **Validation failures**: Return 500 Internal Server Error (validation details are logged internally, not exposed to clients)
2970
-
2971
- **Validation priority for 2xx status codes:**
2972
-
2973
- - All 2xx responses (200, 201, 204, etc.) returned by the `sync` handler are validated against
2974
- `successResponseBodySchema`
2975
- - For the `sync` handler, `responseBodySchemasByStatusCode` is only used for non-2xx status codes,
2976
- so `successResponseBodySchema` takes precedence when the same 2xx code is defined in both
2977
- - `sse.respond(code, body)` is validated against `responseBodySchemasByStatusCode[code]` at every
2978
- status, 2xx included, because that is the schema its argument is typed from
2979
-
2980
- **OpenAPI output and serialization:**
2981
-
2982
- `buildFastifyRoute` fills in the route's `schema.response` from the contract, so the generated
2983
- spec describes each status instead of showing a bare "Default Response":
2984
-
2985
- - 200 carries `text/event-stream` with one `{ id?, event, data, retry? }` envelope per entry in
2986
- `serverSentEventSchemas`, rendered as a `oneOf` with the `event` name pinned to a `const` in
2987
- each branch, plus `application/json` for the JSON body
2988
- - Every status in `responseBodySchemasByStatusCode` gets its declared schema
2989
-
2990
- Because Fastify drives serialization from the same `schema.response`, a response body for a
2991
- status the contract declares is serialized against that schema. Keys the schema does not
2992
- declare are dropped from the body that goes out. Streamed SSE events are written directly by
2993
- `@fastify/sse` and bypass the serializer, so the 200 event schema documents the stream without
2994
- affecting it.
2995
-
2996
- A status can be reached by more than one body shape, and Fastify rejects anything the schema
2997
- does not accept, so each status accepts every shape the runtime can produce there:
2998
-
2999
- - On a 2xx of a dual-mode contract, `application/json` accepts `successResponseBodySchema` (what
3000
- the `sync` handler is validated against) as well as the schema declared for that status (what
3001
- `sse.respond()` is validated against), rather than one taking precedence over the other in the
3002
- spec
3003
- - On a non-2xx, the declared schema is joined by the framework error envelope
3004
- (`{ statusCode, message, error?, code?, ... }`), which is what Fastify sends for a failed
3005
- request validation and what an application-level error handler typically returns. Without it,
3006
- declaring a 400 body would turn every `FST_ERR_VALIDATION` on that route into a 500
3007
-
3008
- Both show up in the spec as an `anyOf`. Errors the SSE builders raise themselves, before
3009
- streaming starts, are sent pre-serialized and skip the schema entirely, so the thrown error's
3010
- message always reaches the client.
3011
-
3012
- ### Single Sync Handler
3013
-
3014
- Dual-mode contracts use a single `sync` handler that returns the response data. The framework validates the return value against the contract schema, then sends it. Do not call `reply.send()` — return the data directly instead. Use `reply.code()` to set status codes and `reply.header()` to set response headers.
3015
-
3016
- > **Note:** The `reply` parameter is typed as `SyncModeReply`, which omits `send()` to prevent accidental misuse. The framework handles sending the response after validation.
3017
-
3018
- ```ts
3019
- handlers: buildHandler(chatCompletionContract, {
3020
- sync: async (request, reply) => {
3021
- // Return the response data matching successResponseBodySchema
3022
- const result = await aiService.complete(request.body.message)
3023
- return {
3024
- reply: result.text,
3025
- usage: { tokens: result.tokenCount },
3026
- }
3027
- },
3028
- sse: async (request, sse) => {
3029
- // SSE streaming handler
3030
- const session = sse.start('autoClose')
3031
- // ... stream events ...
3032
- },
3033
- })
3034
- ```
3035
-
3036
- TypeScript enforces the correct handler structure:
3037
- - `successResponseBodySchema` contracts must use `sync` handler (returns response data)
3038
- - `serverSentEventSchemas` contracts must use `sse` handler (streams events)
3039
-
3040
- ### Implementing Dual-Mode Controllers
3041
-
3042
- Dual-mode controllers use `buildHandler` to define both sync and SSE handlers. The handler is returned directly from `buildDualModeRoutes`, with options passed as the third parameter to `buildHandler`:
3043
-
3044
- ```ts
3045
- import {
3046
- AbstractDualModeController,
3047
- buildHandler,
3048
- type BuildFastifyDualModeRoutesReturnType,
3049
- type DualModeControllerConfig,
3050
- } from 'opinionated-machine'
3051
-
3052
- type Contracts = {
3053
- chatCompletion: typeof chatCompletionContract
3054
- }
3055
-
3056
- type Dependencies = {
3057
- aiService: AIService
3058
- }
3059
-
3060
- export class ChatDualModeController extends AbstractDualModeController<Contracts> {
3061
- public static contracts = {
3062
- chatCompletion: chatCompletionContract,
3063
- } as const
3064
-
3065
- private readonly aiService: AIService
3066
-
3067
- constructor(deps: Dependencies, config?: DualModeControllerConfig) {
3068
- super(deps, config)
3069
- this.aiService = deps.aiService
3070
- }
3071
-
3072
- public buildDualModeRoutes(): BuildFastifyDualModeRoutesReturnType<Contracts> {
3073
- return {
3074
- chatCompletion: this.handleChatCompletion,
3075
- }
3076
- }
3077
-
3078
- // Handler with options as third parameter
3079
- private handleChatCompletion = buildHandler(chatCompletionContract, {
3080
- // Sync mode - return complete response
3081
- sync: async (request, _reply) => {
3082
- const result = await this.aiService.complete(request.body.message)
3083
- return {
3084
- reply: result.text,
3085
- usage: { tokens: result.tokenCount },
3086
- }
3087
- },
3088
- // SSE mode - stream response chunks
3089
- sse: async (request, sse) => {
3090
- const session = sse.start('autoClose')
3091
- let totalTokens = 0
3092
- for await (const chunk of this.aiService.stream(request.body.message)) {
3093
- await session.send('chunk', { delta: chunk.text })
3094
- totalTokens += chunk.tokenCount ?? 0
3095
- }
3096
- await session.send('done', { usage: { total: totalTokens } })
3097
- // Connection closes automatically when handler returns
3098
- },
3099
- }, {
3100
- // Optional: set SSE as default mode (instead of sync)
3101
- defaultMode: 'sse',
3102
- // Optional: route-level authentication
3103
- preHandler: (request, reply) => {
3104
- if (!request.headers.authorization) {
3105
- return Promise.resolve(reply.code(401).send({ error: 'Unauthorized' }))
3106
- }
3107
- },
3108
- // Optional: SSE lifecycle hooks
3109
- onConnect: (session) => console.log('Client connected:', session.id),
3110
- onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
3111
- // Optional: attach behavior driven by contract metadata
3112
- contractMetadataToRouteMapper: (metadata) => ({
3113
- config: { rateLimit: metadata.rateLimit },
3114
- onRequest: metadata.requiresAuth ? authHook : undefined,
3115
- }),
3116
- })
3117
- }
3118
- ```
3119
-
3120
- **Handler Signatures:**
2002
+ ```ts
2003
+ // Collect exactly 3 events
2004
+ const events = await client.collectEvents(3)
2005
+
2006
+ // Collect with custom timeout
2007
+ const events = await client.collectEvents(5, 10000) // 10s timeout
3121
2008
 
3122
- | Mode | Signature |
3123
- | ---- | --------- |
3124
- | `sync` | `(request, reply) => Response` |
3125
- | `sse` | `(request, sse) => SSEHandlerResult` |
2009
+ // Collect until a specific event type (the matching event IS included)
2010
+ const events = await client.collectEvents((event) => event.event === 'done')
3126
2011
 
3127
- The `sync` handler must return a value matching `successResponseBodySchema`. 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.
2012
+ // Collect until condition with timeout
2013
+ const events = await client.collectEvents(
2014
+ (event) => JSON.parse(event.data).status === 'complete',
2015
+ 30000,
2016
+ )
2017
+ ```
3128
2018
 
3129
- ### Registering Dual-Mode Controllers
2019
+ **`events(signal?)`**
3130
2020
 
3131
- Use `asDualModeControllerClass` in your module:
2021
+ Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
3132
2022
 
3133
2023
  ```ts
3134
- import {
3135
- AbstractModule,
3136
- type InferModuleDependencies,
3137
- asControllerClass,
3138
- asDualModeControllerClass,
3139
- asServiceClass,
3140
- } from 'opinionated-machine'
2024
+ // Basic iteration
2025
+ for await (const event of client.events()) {
2026
+ console.log(event.event, event.data)
2027
+ if (event.event === 'done') break
2028
+ }
3141
2029
 
3142
- export class ChatModule extends AbstractModule {
3143
- resolveDependencies() {
3144
- return {
3145
- aiService: asServiceClass(AIService),
3146
- }
3147
- }
2030
+ // With abort signal for timeout control
2031
+ const controller = new AbortController()
2032
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
3148
2033
 
3149
- resolveControllers(diOptions: DependencyInjectionOptions) {
3150
- return {
3151
- // REST controller
3152
- usersController: asControllerClass(UsersController),
3153
- // Dual-mode controller (auto-detected via isDualModeController flag)
3154
- chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
3155
- // Dual-mode controller with rooms enabled
3156
- dashboardController: asDualModeControllerClass(DashboardController, { diOptions, rooms: true }),
3157
- }
2034
+ try {
2035
+ for await (const event of client.events(controller.signal)) {
2036
+ console.log(event)
3158
2037
  }
2038
+ } finally {
2039
+ clearTimeout(timeoutId)
3159
2040
  }
3160
-
3161
- export type ChatModuleDependencies = InferModuleDependencies<ChatModule>
3162
2041
  ```
3163
2042
 
3164
- Register dual-mode routes after the `@fastify/sse` plugin:
3165
-
3166
- ```ts
3167
- const app = fastify()
3168
- app.setValidatorCompiler(validatorCompiler)
3169
- app.setSerializerCompiler(serializerCompiler)
3170
-
3171
- // Register @fastify/sse plugin
3172
- await app.register(FastifySSEPlugin)
2043
+ **When to omit `awaitServerConnection`**
3173
2044
 
3174
- // Register routes
3175
- context.registerRoutes(app) // REST routes
3176
- context.registerSSERoutes(app) // SSE-only routes
3177
- context.registerDualModeRoutes(app) // Dual-mode routes
2045
+ Omit `awaitServerConnection` only in these cases:
2046
+ - Testing against external SSE endpoints (not your own routes)
2047
+ - Routes you cannot pass the spy's hooks to
2048
+ - Simple smoke tests that only verify response headers/status without sending server events
2049
+ - Routes whose handler starts an `autoClose` session: it closes as the handler returns, so there is no live session left to wait for — assert on the received events instead
3178
2050
 
3179
- // Check if controllers exist before registration (optional)
3180
- if (context.hasDualModeControllers()) {
3181
- context.registerDualModeRoutes(app)
3182
- }
2051
+ **Consequence**: Without `awaitServerConnection`, `connect()` resolves as soon as HTTP headers are received. Server-side connection registration may not have completed yet, so you cannot reliably send events from the server immediately after `connect()` returns.
3183
2052
 
3184
- await app.ready()
2053
+ ```ts
2054
+ // Example: smoke test that only checks connection works
2055
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
2056
+ expect(client.response.ok).toBe(true)
2057
+ expect(client.response.headers.get('content-type')).toContain('text/event-stream')
2058
+ client.close()
3185
2059
  ```
3186
2060
 
3187
- ### Accept Header Routing
3188
-
3189
- Dual-mode routes are registered with @fastify/sse kind `'manual'`, so the
3190
- framework's `determineMode()` is the single Accept negotiator: q-value aware,
3191
- `defaultMode` applies for `*/*` or a missing `Accept` header (including
3192
- `defaultMode: 'sse'`). SSE-only routes use kind `'only'` — a missing header or
3193
- `*/*` streams, and a client that explicitly refuses `text/event-stream`
3194
- (e.g. `Accept: application/json`) receives a clean 406.
2061
+ **POST/PUT/PATCH endpoints**
3195
2062
 
3196
- The `Accept` header determines response mode:
2063
+ `connect()` also issues non-GET requests, so SSE endpoints that take a request body can be tested over real HTTP. Pass `method` and `body`. The method is accepted in either case, so the lowercase spelling your contracts already use (`method: 'post'`) works as-is:
3197
2064
 
3198
- ```bash
3199
- # JSON mode (complete response)
3200
- curl -X POST http://localhost:3000/api/chats/123/completions \
3201
- -H "Content-Type: application/json" \
3202
- -H "Accept: application/json" \
3203
- -d '{"message": "Hello world"}'
2065
+ ```ts
2066
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2067
+ method: 'POST',
2068
+ body: { message: 'Hello', stream: true },
2069
+ })
3204
2070
 
3205
- # SSE mode (streaming response)
3206
- curl -X POST http://localhost:3000/api/chats/123/completions \
3207
- -H "Content-Type: application/json" \
3208
- -H "Accept: text/event-stream" \
3209
- -d '{"message": "Hello world"}'
2071
+ const events = await client.collectEvents((event) => event.event === 'done')
3210
2072
  ```
3211
2073
 
3212
- **Quality values** are supported for content negotiation:
3213
-
3214
- ```bash
3215
- # Prefer JSON (higher quality value)
3216
- curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
2074
+ Bodies `fetch()` can send natively — strings, `URLSearchParams`, `FormData`, `Blob`/`File`, `ArrayBuffer`, typed arrays (`Buffer`, `Uint8Array`, …) and `ReadableStream` — are passed through untouched; anything else is JSON-stringified. `content-type: application/json` is defaulted for JSON-stringified and string bodies (so a raw string stays verbatim, which is handy for asserting on malformed payloads), unless you provide your own content type. Payloads that describe their own encoding — `URLSearchParams`, `FormData`, `Blob` — keep the content type `fetch()` gives them:
3217
2075
 
3218
- # Prefer SSE (higher quality value)
3219
- curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
2076
+ ```ts
2077
+ // Sent as application/x-www-form-urlencoded, not JSON-stringified into `{}`
2078
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2079
+ method: 'post',
2080
+ body: new URLSearchParams({ message: 'Hello' }),
2081
+ })
3220
2082
  ```
3221
2083
 
3222
- **Subtype wildcards** are supported for flexible content negotiation:
2084
+ A body without a non-GET `method` throws — `fetch()` cannot attach one to a GET request.
2085
+
2086
+ **Asserting on the wire before the handler finishes**
3223
2087
 
3224
- ```bash
3225
- # Accept any text format (matches text/plain, text/csv, etc.)
3226
- curl -H "Accept: text/*" ...
2088
+ `connect()` resolves as soon as HTTP headers arrive, and `client.response` is populated at that point. That is what makes the "open the stream before the slow work starts" behaviour testable: assert on status and headers while the handler is still awaiting its slow call, then let it proceed.
2089
+
2090
+ ```ts
2091
+ // Handler calls sse.start() and only then makes its slow LLM call
2092
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2093
+ method: 'POST',
2094
+ body: { message: 'Hello', stream: true },
2095
+ })
3227
2096
 
3228
- # Accept any application format (matches application/json, application/xml, etc.)
3229
- curl -H "Accept: application/*" ...
2097
+ // Already on the wire while the LLM call is still in flight
2098
+ expect(client.response.status).toBe(200)
2099
+ expect(client.response.headers.get('content-type')).toContain('text/event-stream')
3230
2100
 
3231
- # Combine with quality values
3232
- curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
2101
+ releaseSlowCall()
2102
+ const events = await client.collectEvents((event) => event.event === 'done')
3233
2103
  ```
3234
2104
 
3235
- The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
2105
+ The mirror case works too: a failure raised *before* `sse.start()` reaches the client as the JSON status the contract declares, not as a terminal `error` event. The response body is only locked once you start consuming events, so it can still be read as JSON:
3236
2106
 
3237
- ### Testing Dual-Mode Controllers
2107
+ ```ts
2108
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2109
+ method: 'POST',
2110
+ body: { message: 'Hello', stream: true },
2111
+ })
3238
2112
 
3239
- The testing approach depends on the SSE session mode:
2113
+ expect(client.response.status).toBe(503)
2114
+ expect(client.response.headers.get('content-type')).not.toContain('text/event-stream')
2115
+ expect(await client.response.json()).toEqual({ message: 'Upstream unavailable' })
2116
+ ```
3240
2117
 
3241
- | SSE Mode | Test Client | Why |
3242
- |----------|-------------|-----|
3243
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes the connection, so all events are available at once via inject |
3244
- | `keepAlive` | `SSEHttpClient` + `SSETestServer.start(app)` | Connection stays open after handler returns; events arrive incrementally from server pushes |
2118
+ Read that body *before* `close()`: closing aborts the request, so a body read after it (or from a `finally { client.close() }` block that runs first) rejects with an `AbortError`.
3245
2119
 
3246
- #### Testing autoClose dual-mode (request-response streaming)
2120
+ #### SSEInjectClient
3247
2121
 
3248
- Use `SSEInjectClient` for dual-mode controllers where the SSE handler uses `autoClose`. No real HTTP server needed - Fastify's inject returns all events after the handler completes:
2122
+ For testing `autoClose` SSE streams (like OpenAI completions). Uses Fastify's `inject()` - no `app.listen()` needed:
3249
2123
 
3250
2124
  ```ts
3251
2125
  import { SSEInjectClient } from 'opinionated-machine'
3252
2126
 
3253
- describe('ChatDualModeController', () => {
3254
- let app: AppInstance
3255
- let injectClient: SSEInjectClient
3256
-
3257
- beforeAll(async () => {
3258
- app = await getApp({ /* your test config */ })
3259
- injectClient = new SSEInjectClient(app)
3260
- })
3261
-
3262
- afterAll(async () => {
3263
- await app.diContainer.dispose()
3264
- })
2127
+ const client = new SSEInjectClient(app) // No server.listen() needed
3265
2128
 
3266
- it('returns sync response for Accept: application/json', async () => {
3267
- const response = await app.inject({
3268
- method: 'POST',
3269
- url: '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
3270
- headers: {
3271
- 'content-type': 'application/json',
3272
- accept: 'application/json',
3273
- authorization: 'Bearer token',
3274
- },
3275
- payload: { message: 'Hello' },
3276
- })
2129
+ // GET request
2130
+ const conn = await client.connect('/api/export/progress', {
2131
+ headers: { authorization: 'Bearer token' },
2132
+ })
3277
2133
 
3278
- expect(response.statusCode).toBe(200)
3279
- expect(response.headers['content-type']).toContain('application/json')
2134
+ // POST request with body (OpenAI-style)
2135
+ const conn = await client.connectWithBody(
2136
+ '/api/chat/completions',
2137
+ { model: 'gpt-4', messages: [...], stream: true },
2138
+ )
3280
2139
 
3281
- const body = JSON.parse(response.body)
3282
- expect(body).toHaveProperty('reply')
3283
- expect(body).toHaveProperty('usage')
3284
- })
2140
+ // Any other method inject() accepts works too - the option is typed as
2141
+ // `SSEInjectMethod`, exported so you never have to redeclare that union
2142
+ const conn = await client.connectWithBody(
2143
+ '/api/exports/42',
2144
+ { reason: 'cleanup' },
2145
+ { method: 'DELETE' },
2146
+ )
3285
2147
 
3286
- it('streams SSE for Accept: text/event-stream', async () => {
3287
- const conn = await injectClient.connectWithBody(
3288
- '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
3289
- { message: 'Hello' },
3290
- { headers: { authorization: 'Bearer token' } },
3291
- )
2148
+ // All events are available immediately (inject waits for handler to complete)
2149
+ expect(conn.getStatusCode()).toBe(200)
2150
+ const events = conn.getReceivedEvents()
2151
+ const chunks = events.filter(e => e.event === 'chunk')
2152
+ ```
3292
2153
 
3293
- expect(conn.getStatusCode()).toBe(200)
3294
- expect(conn.getHeaders()['content-type']).toContain('text/event-stream')
2154
+ When the route answers with a status code *before* streaming starts (auth failure,
2155
+ validation error, integration unavailable), it sends a JSON body rather than events.
2156
+ `getBody()` returns that body raw and `json()` parses it, mirroring Fastify's own
2157
+ inject response:
3295
2158
 
3296
- const events = conn.getReceivedEvents()
3297
- const chunks = events.filter((e) => e.event === 'chunk')
3298
- const doneEvents = events.filter((e) => e.event === 'done')
2159
+ ```ts
2160
+ const conn = await client.connect('/api/export/progress')
3299
2161
 
3300
- expect(chunks.length).toBeGreaterThan(0)
3301
- expect(doneEvents).toHaveLength(1)
3302
- })
2162
+ expect(conn.getStatusCode()).toBe(503)
2163
+ expect(conn.json<{ errorCode: string }>()).toMatchObject({
2164
+ errorCode: 'INTEGRATION_NOT_AVAILABLE',
3303
2165
  })
3304
2166
  ```
3305
2167
 
3306
- #### Testing keepAlive dual-mode (long-lived connections)
2168
+ `json()` throws if the body is empty or isn't valid JSON, so it only makes sense for
2169
+ these pre-stream responses - a `text/event-stream` body is not JSON. For contract-typed
2170
+ tests, `injectApiSSE` offers `bodyForStatus(status)`, which also validates the body against
2171
+ the contract's schema for that status.
3307
2172
 
3308
- Use `SSEHttpClient` against your running app, the same pattern as single-mode keepAlive SSE. For test lifecycle convenience, you can use `SSETestServer.start(app)` to start your pre-configured app on a random port:
2173
+ #### Contract-Aware Inject Helpers
3309
2174
 
3310
- ```ts
3311
- import { SSEHttpClient, SSETestServer } from 'opinionated-machine'
2175
+ `injectApiSSE(app, contract, params)` is the contract-typed counterpart of `SSEInjectClient`: the method, path, query params, headers and body come from the contract, and `events()` / `stream()` return events validated against the contract's SSE schemas. See [Testing autoClose SSE](#testing-autoclose-sse-request-response-streaming).
3312
2176
 
3313
- describe('DashboardDualModeController', () => {
3314
- let app: AppInstance
3315
- let server: SSETestServer
3316
- let controller: DashboardController
2177
+ #### Contract-Aware HTTP Helpers
3317
2178
 
3318
- beforeAll(async () => {
3319
- app = await getApp({ /* your test config */ })
3320
- controller = app.diContainer.resolve('dashboardController')
2179
+ `connectApiSSE` is the real-HTTP counterpart of `injectApiSSE`: the same typed, validated event union, over a connection that can stay open. Use it for `keepAlive` routes, whose response never completes, and wherever a suite needs a real socket.
3321
2180
 
3322
- // SSETestServer.start() takes your pre-configured app and starts it on a random port
3323
- server = await SSETestServer.start(app)
3324
- })
2181
+ ```ts
2182
+ import { connectApiSSE, SSETestServer } from 'opinionated-machine'
3325
2183
 
3326
- afterAll(async () => {
3327
- await app.diContainer.dispose()
3328
- await server.close()
3329
- })
2184
+ const server = await SSETestServer.start(app)
3330
2185
 
3331
- // Sync mode works the same as autoClose — use Fastify inject
3332
- it('returns JSON for sync requests', async () => {
3333
- const response = await app.inject({
3334
- method: 'get',
3335
- url: '/api/dashboard/updates',
3336
- headers: { accept: 'application/json' },
3337
- })
3338
- expect(response.statusCode).toBe(200)
3339
- expect(response.headers['content-type']).toContain('application/json')
3340
- })
2186
+ // Method, path, query params, headers and body all come from the contract
2187
+ const client = await connectApiSSE(server.baseUrl, lqaSegmentContract, {
2188
+ body: { segment: 'hello' },
2189
+ })
3341
2190
 
3342
- // keepAlive SSE requires SSEHttpClient with awaitServerConnection
3343
- it('receives server-pushed events over keepAlive SSE', async () => {
3344
- // 1. Connect with awaitServerConnection to eliminate race condition
3345
- const { client, serverConnection } = await SSEHttpClient.connect(
3346
- server.baseUrl,
3347
- '/api/dashboard/updates',
3348
- { awaitServerConnection: { controller } },
3349
- )
2191
+ expect(client.response.status).toBe(200) // asserted while the handler is still working
3350
2192
 
3351
- // 2. Start collecting events BEFORE pushing (they arrive asynchronously)
3352
- const eventsPromise = client.collectEvents(2)
2193
+ for await (const event of client.events()) {
2194
+ if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed by the contract
2195
+ if (event.event === 'review') break
2196
+ }
3353
2197
 
3354
- // 3. Push events from the server side
3355
- await controller.pushUpdate(serverConnection.id, {
3356
- event: 'update',
3357
- data: { type: 'metric', value: 42 },
3358
- })
3359
- await controller.pushUpdate(serverConnection.id, {
3360
- event: 'update',
3361
- data: { type: 'alert', value: 100 },
3362
- })
2198
+ client.close()
2199
+ await server.close()
2200
+ ```
3363
2201
 
3364
- // 4. Await collected events
3365
- const events = await eventsPromise
3366
- expect(events).toHaveLength(2)
3367
- expect(JSON.parse(events[0].data)).toEqual({ type: 'metric', value: 42 })
2202
+ - `client.events(signal?)` and `client.collectEvents(countOrPredicate, timeout?)` mirror `SSEHttpClient`'s readers, with each event validated against the contract's SSE schemas and typed as a union on `event` — the predicate sees the narrowed type too, and is invoked exactly once per event.
2203
+ - Both readers reject a response that isn't an event stream (a documented `400`/`401` raised before `sse.start()`, say) with its status and body, rather than reporting a stream that produced no events. `client.response` is still readable afterwards, so the JSON body can be asserted.
2204
+ - `client.response` is the fetch `Response`, available before any event is consumed.
2205
+ - `client.raw` is the underlying `SSEHttpClient`, for anything this wrapper doesn't cover.
2206
+ - Pass `{ awaitServerConnection: { spy } }` as a fourth argument (with a spy from `createSSESessionSpy()`) to also wait for the server-side session of a `keepAlive` route; the call then resolves to `{ client, serverConnection }`.
3368
2207
 
3369
- // 5. Always close the client to release the connection
3370
- client.close()
3371
- })
2208
+ On a connection you already have, the same typing is available per read: `client.apiEvents(contract)` and `client.collectApiEvents(contract, countOrPredicate)` on any `SSEHttpClient`.
3372
2209
 
3373
- // keepAlive SSE + rooms
3374
- it('receives room broadcasts over keepAlive SSE', async () => {
3375
- const { client } = await SSEHttpClient.connect(
3376
- server.baseUrl,
3377
- '/api/dashboard/updates',
3378
- {
3379
- query: { dashboardId: 'dash-1' },
3380
- awaitServerConnection: { controller },
3381
- },
3382
- )
2210
+ #### When a Handler Fails to Send an Event
3383
2211
 
3384
- // Broadcast to the room
3385
- const eventsPromise = client.collectEvents(1)
3386
- await controller.broadcastToRoom('dashboard:dash-1', 'update', {
3387
- type: 'room-update',
3388
- value: 99,
3389
- })
2212
+ `session.send(name, payload)` validates the payload against the contract's schema for that event and throws when it doesn't match. The throw happens inside the handler: the event never reaches the wire, the stream ends early (with HTTP 200, and possibly a terminal `error` event from the app's error handler), and the Zod error only lands in the server log — leaving the test to explain an event that is simply missing.
3390
2213
 
3391
- const events = await eventsPromise
3392
- expect(JSON.parse(events[0].data)).toEqual({ type: 'room-update', value: 99 })
2214
+ Routes built with `buildApiRoute` report those failures to whichever helper is reading the stream. When the failure is what cut the stream short — nothing caught it — `events()`, `stream()` and the `connectApiSSE` readers throw with the offending event name, its Zod issues and the rejected payload. That includes a stream that then ended with an `error` event the contract does not declare, sent by an SSE-aware error handler:
3393
2215
 
3394
- client.close()
3395
- })
2216
+ ```
2217
+ events() — 1 SSE send failure recorded for this request:
2218
+ - event "issue" was never sent: severity: Invalid option: expected one of "neutral"|"minor"|"major"|"critical"; payload: {"severity":"min"}
2219
+ ```
3396
2220
 
3397
- // Sync and SSE can coexist concurrently
3398
- it('sync requests work while keepAlive SSE connections are active', async () => {
3399
- // Establish keepAlive SSE connection
3400
- const { client: sseClient } = await SSEHttpClient.connect(
3401
- server.baseUrl,
3402
- '/api/dashboard/updates',
3403
- { awaitServerConnection: { controller } },
3404
- )
2221
+ A failure the route *recovered* from does not fail the read. A handler that catches its own best-effort send and streams a fallback instead produced exactly the response it meant to, so the readers deliver it and record the failure as context; the same goes for a `sendStream()` source that throws while producing its next message, which is reported as the source failing rather than blamed on the last event that did reach the client.
3405
2222
 
3406
- // Sync request works while SSE is connected
3407
- const response = await app.inject({
3408
- method: 'get',
3409
- url: '/api/dashboard/updates',
3410
- headers: { accept: 'application/json' },
3411
- })
3412
- expect(response.statusCode).toBe(200)
2223
+ Both `injectApiSSE(...).sendFailures()` and `connectApiSSE(...).sendFailures()` return every record (`{ eventName?, data?, message, issues?, error, handled }`) — including the recovered ones — for assertions the thrown message doesn't cover:
3413
2224
 
3414
- sseClient.close()
3415
- })
3416
- })
2225
+ ```ts
2226
+ const { closed, events, sendFailures } = injectApiSSE(app, lqaSegmentContract, { body })
2227
+ await closed
2228
+
2229
+ expect(await events()).toHaveLength(2) // the fallback stream is intact
2230
+ expect(sendFailures()).toMatchObject([{ eventName: 'issue', handled: true }])
2231
+ ```
2232
+
2233
+ This is test-only and costs production traffic nothing: the helpers tag their requests with an `x-om-sse-diagnostics-id` header, and a session is instrumented only when that header names a diagnostics scope open in the same process — something only those helpers create. A stale or forged header matches nothing.
3417
2234
 
3418
2235
  ## Gateway Configuration
3419
2236
 
@@ -3439,32 +2256,33 @@ A complete round-trip in two steps. First, annotate routes in your existing
3439
2256
  controller:
3440
2257
 
3441
2258
  ```ts
3442
- import { buildRestContract } from '@lokalise/api-contracts'
3443
- import { buildFastifyRoute } from '@lokalise/fastify-api-contracts'
2259
+ import { defineApiContract } from '@lokalise/api-contracts'
2260
+ import type { RouteOptions } from 'fastify'
3444
2261
  import {
3445
- AbstractController,
3446
- type BuildRoutesReturnType,
2262
+ AbstractApiController,
2263
+ buildApiRoute,
3447
2264
  type GatewayMetadataValue,
3448
- withGatewayMetadata,
3449
2265
  } from 'opinionated-machine'
3450
2266
  import { z } from 'zod/v4'
3451
2267
 
3452
- const getUser = buildRestContract({
2268
+ const getUser = defineApiContract({
3453
2269
  visibility: 'public',
3454
2270
  method: 'get',
3455
- successResponseBodySchema: z.object({ id: z.string() }),
2271
+ summary: 'Get user',
3456
2272
  requestPathParamsSchema: z.object({ userId: z.string() }),
3457
- pathResolver: (p) => `/users/${p.userId}`,
2273
+ pathResolver: ({ userId }) => `/users/${userId}`,
2274
+ responsesByStatusCode: { 200: z.object({ id: z.string() }) },
3458
2275
  })
3459
- const createUser = buildRestContract({
2276
+ const createUser = defineApiContract({
3460
2277
  visibility: 'public',
3461
2278
  method: 'post',
2279
+ summary: 'Create user',
3462
2280
  requestBodySchema: z.object({ name: z.string() }),
3463
- successResponseBodySchema: z.object({ id: z.string() }),
3464
2281
  pathResolver: () => '/users',
2282
+ responsesByStatusCode: { 201: z.object({ id: z.string() }) },
3465
2283
  })
3466
2284
 
3467
- export class UsersController extends AbstractController<typeof UsersController.contracts> {
2285
+ export class UsersController extends AbstractApiController<typeof UsersController.contracts> {
3468
2286
  static readonly contracts = { getUser, createUser } as const
3469
2287
 
3470
2288
  // Applies to every route in this controller; routes can override.
@@ -3474,18 +2292,13 @@ export class UsersController extends AbstractController<typeof UsersController.c
3474
2292
  auth: { required: true },
3475
2293
  }
3476
2294
 
3477
- private getUser = buildFastifyRoute(UsersController.contracts.getUser, async (req, reply) => { /* … */ })
3478
- private createUser = buildFastifyRoute(UsersController.contracts.createUser, async (req, reply) => { /* … */ })
3479
-
3480
- buildRoutes(): BuildRoutesReturnType<typeof UsersController.contracts> {
3481
- return {
3482
- getUser: withGatewayMetadata(UsersController.contracts.getUser, this.getUser, {
3483
- cache: { ttl: '60s' },
3484
- }),
3485
- createUser: withGatewayMetadata(UsersController.contracts.createUser, this.createUser, {
3486
- rateLimit: { requests: 10, per: '1m', key: 'ip' },
3487
- }),
3488
- }
2295
+ readonly routes = {
2296
+ getUser: buildApiRoute(UsersController.contracts.getUser, async (req) => /* … */, {
2297
+ gatewayMetadata: { cache: { ttl: '60s' } },
2298
+ }),
2299
+ createUser: buildApiRoute(UsersController.contracts.createUser, async (req) => /* … */, {
2300
+ gatewayMetadata: { rateLimit: { requests: 10, per: '1m', key: 'ip' } },
2301
+ }),
3489
2302
  }
3490
2303
  }
3491
2304
  ```
@@ -3528,8 +2341,7 @@ controller style — both validate the metadata at the call site, both stamp
3528
2341
  the same hidden symbol on the route, and both are read identically by the
3529
2342
  manifest builder.
3530
2343
 
3531
- **For `buildApiRoute`-built routes** (`AbstractApiController`), pass
3532
- `gatewayMetadata` inline via the options argument:
2344
+ Pass `gatewayMetadata` inline via the `buildApiRoute` options argument:
3533
2345
 
3534
2346
  ```ts
3535
2347
  class UsersApiController extends AbstractApiController<typeof UsersApiController.contracts> {
@@ -3548,17 +2360,18 @@ class UsersApiController extends AbstractApiController<typeof UsersApiController
3548
2360
  }
3549
2361
  ```
3550
2362
 
3551
- **For `buildFastifyRoute`-built routes** (`AbstractController`), or when you
3552
- prefer to keep all gateway annotations in one scannable block separate from
3553
- route construction, wrap with `withGatewayMetadata(contract, route, metadata)`:
2363
+ Or, to keep gateway annotations in one block separate from route
2364
+ construction, wrap a built route with `withGatewayMetadata(contract, route, metadata)`:
3554
2365
 
3555
2366
  ```ts
3556
- buildRoutes() {
3557
- return {
3558
- getUser: withGatewayMetadata(c.getUser, this.getUser, { cache: { ttl: '60s' } }),
3559
- createUser: withGatewayMetadata(c.createUser, this.createUser, { rateLimit: { requests: 10, per: '1m', key: 'ip' } }),
3560
- deleteUser: this.deleteUser, // no per-route policy; inherits defaults
3561
- }
2367
+ import { withGatewayMetadata } from 'opinionated-machine'
2368
+
2369
+ const c = UsersApiController.contracts
2370
+
2371
+ readonly routes = {
2372
+ getUser: withGatewayMetadata(c.getUser, buildApiRoute(c.getUser, this.getUser), { cache: { ttl: '60s' } }),
2373
+ createUser: withGatewayMetadata(c.createUser, buildApiRoute(c.createUser, this.createUser), { rateLimit: { requests: 10, per: '1m', key: 'ip' } }),
2374
+ deleteUser: buildApiRoute(c.deleteUser, this.deleteUser), // no per-route policy; inherits defaults
3562
2375
  }
3563
2376
  ```
3564
2377
 
@@ -3606,16 +2419,17 @@ context.buildGatewayManifest({
3606
2419
  become compile errors before you ever ship a config:
3607
2420
 
3608
2421
  ```ts
3609
- const getUser = buildRestContract({
2422
+ const getUser = defineApiContract({
3610
2423
  visibility: 'public',
3611
2424
  method: 'get',
3612
- successResponseBodySchema: ResponseBody,
2425
+ summary: 'Get user',
3613
2426
  requestHeaderSchema: z.object({ 'x-trace-id': z.string() }),
3614
2427
  requestPathParamsSchema: z.object({ userId: z.string() }),
3615
- pathResolver: (p) => `/users/${p.userId}`,
2428
+ pathResolver: ({ userId }) => `/users/${userId}`,
2429
+ responsesByStatusCode: { 200: ResponseBody },
3616
2430
  })
3617
2431
 
3618
- withGatewayMetadata(getUser, this.getUser, {
2432
+ withGatewayMetadata(getUser, buildApiRoute(getUser, this.getUser), {
3619
2433
  match: {
3620
2434
  headers: {
3621
2435
  'x-trace-id': { regex: '^[a-f0-9]+$' }, // ✅ type-checked against the contract
@@ -3751,9 +2565,10 @@ stamped with a streaming mode, and the manifest carries it as
3751
2565
  `streaming: 'sse' | 'dual'`.
3752
2566
 
3753
2567
  The marker describes the **success path**. An error status answers with a JSON
3754
- body on a streaming route too (including the early-return `sse.respond(404,
3755
- ...)` path), so generators size timeouts and buffering from it but must not
3756
- assume the content type of a failure.
2568
+ body on a streaming route too (including a handler that returns
2569
+ `{ status: 404, body }` before starting the stream), so generators size
2570
+ timeouts and buffering from it but must not assume the content type of a
2571
+ failure.
3757
2572
 
3758
2573
  - **Envoy** — streaming routes default to `timeout: 0s` and `idle_timeout: 0s`
3759
2574
  (declare `timeouts.idle` to reinstate a liveness bound; heartbeats are the
@@ -3777,14 +2592,16 @@ assume the content type of a failure.
3777
2592
  `<id>`, the catch-all. The declared timeouts are split between them rather
3778
2593
  than applied to both — `timeouts.idle` goes to the stream branch,
3779
2594
  `timeouts.request` to the JSON branch, which is the fallback poll path and
3780
- the one that most needs a bound. The split keys off the `Accept` header, the
3781
- same predicate `determineMode()` uses server-side, quality values included:
3782
- `text/event-stream;q=0` is a refusal, so it takes the JSON branch.
2595
+ the one that most needs a bound. The split keys off the `Accept` header,
2596
+ quality values included: `text/event-stream;q=0` is a refusal, so it takes
2597
+ the JSON branch.
3783
2598
 
3784
- A route declaring `defaultMode: 'sse'` inverts the split, because there the
3785
- server streams for a missing or wildcard `Accept` header. The manifest
2599
+ A route whose fallback branch is the stream inverts the split, because there
2600
+ the server streams for a missing or wildcard `Accept` header. The manifest
3786
2601
  carries the fallback branch as `streamingDefaultMode` (`'non-sse' | 'sse'`,
3787
- the `@lokalise/api-contracts` vocabulary), and Envoy makes the
2602
+ the `@lokalise/api-contracts` vocabulary). `buildApiRoute` does not set it;
2603
+ stamp it with `attachRouteStreamingMode(route, 'dual', 'sse')` on a route
2604
+ whose handler streams when `expectedContentType` is `null`. Envoy then makes the
3788
2605
  stream the catch-all with `<id>__json` as the narrow branch, so an
3789
2606
  unspecific request cannot land on the JSON branch's request timeout while the
3790
2607
  server is streaming. A request listing both media types resolves to JSON on
@@ -3804,16 +2621,8 @@ assume the content type of a failure.
3804
2621
  `timeouts.idle`; streaming routes with neither warn about KrakenD's 2s
3805
2622
  default endpoint timeout.
3806
2623
 
3807
- Routes declared through `AbstractApiController` are always included in the
3808
- manifest. Legacy `AbstractSSEController` / `AbstractDualModeController` routes
3809
- are included when you opt in:
3810
-
3811
- ```ts
3812
- const manifest = context.buildGatewayManifest({
3813
- service: 'users-api',
3814
- includeStreamingControllers: true, // default false — existing manifests don't silently grow
3815
- })
3816
- ```
2624
+ Every route of every registered controller is included in the manifest,
2625
+ streaming routes included.
3817
2626
 
3818
2627
  ### What's Not Covered
3819
2628
 
@@ -3863,27 +2672,36 @@ and the transport interface.
3863
2672
 
3864
2673
  One dual-mode `AbstractApiController` route serves both channels — the sync
3865
2674
  branch answers the fallback polls, the SSE branch joins a room that the domain
3866
- service broadcasts into:
3867
-
3868
- ```ts
3869
- readonly routes = {
3870
- jobStatus: buildApiRoute(
3871
- jobStatusContract,
3872
- (request, _reply, { expectedContentType, sse }) => {
3873
- // The push channel: join the job's room and stay open
3874
- if (expectedContentType === 'text/event-stream') {
3875
- const session = sse.start('keepAlive')
3876
- getSessionRooms(session).join(`job:${request.params.jobId}`)
3877
- return
3878
- }
3879
- // The fallback poll: return the current snapshot with its version
3880
- return { status: 200, body: this.jobs.get(request.params.jobId) }
3881
- },
3882
- {
3883
- // enables room membership + broadcast delivery for this route's sessions
3884
- sseRooms: this.sseRoomBroadcaster,
3885
- },
3886
- ),
2675
+ service broadcasts into. Build the routes in the constructor, so the injected
2676
+ broadcaster is available when the `sseRooms` option is evaluated:
2677
+
2678
+ ```ts
2679
+ constructor({ jobs, sseRoomBroadcaster }: Dependencies) {
2680
+ super()
2681
+ this.jobs = jobs
2682
+ this.routes = {
2683
+ jobStatus: buildApiRoute(
2684
+ JobController.contracts.jobStatus,
2685
+ (request, _reply, { expectedContentType, sse }) => {
2686
+ // The push channel: join the job's room and stay open
2687
+ if (expectedContentType === 'text/event-stream') {
2688
+ const session = sse.start('keepAlive')
2689
+ getSessionRooms(session).join(`job:${request.params.jobId}`)
2690
+ return
2691
+ }
2692
+ // The fallback poll: return the current snapshot with its version
2693
+ return {
2694
+ status: 200,
2695
+ contentType: 'application/json',
2696
+ body: this.jobs.get(request.params.jobId),
2697
+ }
2698
+ },
2699
+ {
2700
+ // enables room membership + broadcast delivery for this route's sessions
2701
+ sseRooms: sseRoomBroadcaster,
2702
+ },
2703
+ ),
2704
+ }
3887
2705
  }
3888
2706
  ```
3889
2707
 
@@ -4047,3 +2865,4 @@ dependencies are already built, so `pnpm run build` on its own fails until the g
4047
2865
  built once.
4048
2866
 
4049
2867
  See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full task table and caching notes.
2868
+