opinionated-machine 12.0.0 → 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 +28 -0
  2. package/README.md +850 -2024
  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 +18 -77
  10. package/dist/lib/DIContext.js +59 -226
  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 +7 -7
  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,344 @@ 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 })
1147
- ```
1061
+ import { parseSSEStream } from 'opinionated-machine'
1148
1062
 
1149
- #### `kind` and `Accept` header negotiation
1063
+ for await (const event of parseSSEStream(chunks, {
1064
+ onChunk: () => resetStaleConnectionTimer(),
1065
+ })) {
1066
+ handle(event)
1067
+ }
1068
+ ```
1150
1069
 
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.
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.
1154
1073
 
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.
1074
+ #### parseSSEEvents
1162
1075
 
1163
- Each route type accepts only the kinds that can actually work for it:
1076
+ Parse a complete SSE response body into an array of events.
1164
1077
 
1165
- **SSE-only routes** - `'manual' | 'only'`
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):
1166
1080
 
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 |
1081
+ ```ts
1082
+ import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
1171
1083
 
1172
- **Dual-mode routes** - `'manual' | 'dual'`
1084
+ const responseBody = `event: notification
1085
+ data: {"id":"1","message":"Hello"}
1173
1086
 
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 |
1087
+ event: notification
1088
+ data: {"id":"2","message":"World"}
1178
1089
 
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.
1090
+ `
1182
1091
 
1183
- Override it per route when you want different semantics:
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
+ // ]
1184
1098
 
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
- })
1099
+ // Access parsed data
1100
+ const notifications = events.map(e => JSON.parse(e.data))
1195
1101
  ```
1196
1102
 
1197
- #### `contractMetadataToRouteMapper`
1103
+ A trailing frame with no blank line after it is discarded, which is what the
1104
+ spec requires at the end of a stream: a body cut mid-frame must not surface its
1105
+ truncated payload as a delivered event. Reach for `parseSSEBuffer` when you want
1106
+ to inspect that leftover.
1198
1107
 
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 controllers automatically close all connections during application shutdown. This is configured by `asSSEControllerClass` which sets `closeAllConnections` as the async dispose method with priority 5 (early in shutdown sequence).
1272
-
1273
- ### Error Handling
1274
-
1275
- When `sendEvent()` fails (e.g., client disconnected), it:
1276
- - Returns `false` to indicate failure
1277
- - Automatically removes the dead connection from tracking
1278
- - Prevents further send attempts to that connection
1279
-
1280
- ```ts
1281
- const sent = await this.sendEvent(connectionId, { event: 'update', data })
1282
- if (!sent) {
1283
- // Connection was closed or failed - already removed from tracking
1284
- this.cleanup(connectionId)
1285
- }
1286
- ```
1287
-
1288
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
1289
- - All lifecycle hooks are wrapped in try/catch to prevent crashes
1290
- - If a `logger` is provided in route options, errors are logged with context
1291
- - If no logger is provided, errors are silently ignored
1292
- - The session lifecycle continues even if a hook throws
1293
-
1294
- ```ts
1295
- // Provide a logger to capture lifecycle errors
1296
- public buildSSERoutes() {
1297
- return {
1298
- stream: this.handleStream,
1299
- }
1300
- }
1301
-
1302
- private handleStream = buildHandler(streamContract, {
1303
- sse: async (request, sse) => {
1304
- const session = sse.start('autoClose')
1305
- // ... handler logic
1306
- },
1307
- }, {
1308
- logger: this.logger, // pino-compatible logger
1309
- onConnect: (session) => { /* may throw */ },
1310
- onClose: (session, reason) => { /* may throw */ },
1311
- })
1312
- ```
1313
-
1314
- ### Long-lived Connections vs Request-Response Streaming
1315
-
1316
- SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
1317
-
1318
- ```ts
1319
- // sse.start('autoClose') - close connection when handler returns (request-response pattern)
1320
- // sse.start('keepAlive') - keep connection open for external events (subscription pattern)
1321
- // sse.respond(code, body) - send HTTP response before streaming (early return)
1322
- ```
1323
-
1324
- **Long-lived sessions** (notifications, live updates):
1325
- - Handler starts streaming with `sse.start('keepAlive')`
1326
- - Session stays open indefinitely after handler returns
1327
- - Events are sent later via callbacks using `sendEventInternal()`
1328
- - **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
1329
- - Server cleans up via `onConnectionClosed()` hook
1330
-
1331
- ```ts
1332
- private handleStream = buildHandler(streamContract, {
1333
- sse: async (request, sse) => {
1334
- // Start streaming with 'keepAlive' mode - stays open for external events
1335
- const session = sse.start('keepAlive')
1336
-
1337
- // Set up subscription - events sent via callback AFTER handler returns
1338
- this.service.subscribe(session.id, (data) => {
1339
- this.sendEventInternal(session.id, { event: 'update', data })
1340
- })
1341
- // 'keepAlive' mode: handler returns, but connection stays open
1342
- },
1343
- })
1344
-
1345
- // Clean up when client disconnects
1346
- protected onConnectionClosed(session: SSESession): void {
1347
- this.service.unsubscribe(session.id)
1348
- }
1349
- ```
1350
-
1351
- **Request-response streaming** (AI completions):
1352
- - Handler starts streaming with `sse.start('autoClose')`
1353
- - Use `session.send()` for type-safe event sending within the handler
1354
- - Session automatically closes when handler returns
1355
-
1356
- ```ts
1357
- private handleChatCompletion = buildHandler(chatCompletionContract, {
1358
- sse: async (request, sse) => {
1359
- // Start streaming with 'autoClose' mode - closes when handler returns
1360
- const session = sse.start('autoClose')
1361
-
1362
- const words = request.body.message.split(' ')
1363
- for (const word of words) {
1364
- await session.send('chunk', { content: word })
1365
- }
1366
- await session.send('done', { totalTokens: words.length })
1367
-
1368
- // 'autoClose' mode: connection closes automatically when handler returns
1369
- },
1370
- })
1371
- ```
1372
-
1373
- **Error handling before streaming:**
1374
-
1375
- 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.
1376
-
1377
- ```ts
1378
- private handleStream = buildHandler(streamContract, {
1379
- sse: async (request, sse) => {
1380
- // Early return BEFORE starting stream - can return any HTTP response
1381
- const entity = await this.service.find(request.params.id)
1382
- if (!entity) {
1383
- return sse.respond(404, { error: 'Entity not found' })
1384
- }
1385
-
1386
- // Validation passed - start streaming with autoClose mode
1387
- const session = sse.start('autoClose')
1388
- await session.send('data', entity)
1389
- // Connection closes automatically when handler returns
1390
- },
1391
- })
1392
- ```
1393
-
1394
- ### SSE Parsing Utilities
1395
-
1396
- Wire-format parsing lives in
1397
- [`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
1398
- re-exported here, so the server's test helpers and the browser client
1399
- (`@opinionated-machine/sse-fallback`) frame a stream with the same code.
1400
-
1401
- | Function | Use case |
1402
- |----------|----------|
1403
- | `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
1404
- | `parseSSEStream` | An async iterable of already-decoded text chunks |
1405
- | `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
1406
- | `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
1407
- | `parseSSEBuffer` | The primitive the others are built on |
1408
-
1409
- #### parseSSEResponse
1410
-
1411
- Consume a live SSE stream from `fetch`. Multi-byte characters split across
1412
- network chunks are held back, and breaking out of the loop cancels the
1413
- response body.
1414
-
1415
- ```ts
1416
- import { parseSSEResponse } from 'opinionated-machine'
1417
-
1418
- const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
1419
-
1420
- for await (const event of parseSSEResponse(response)) {
1421
- console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
1422
- if (event.event === 'done') break
1423
- }
1424
- ```
1425
-
1426
- Unlike `EventSource` the request is yours: custom headers, a POST body, an
1427
- `AbortSignal`, your own reconnect policy.
1428
-
1429
- #### createSSEStreamParser and parseSSEStream
1430
-
1431
- When the transport hands you decoded text rather than a `Response`, or when you
1432
- need the reconnect cursor after the stream ends.
1433
-
1434
- ```ts
1435
- import { createSSEStreamParser } from 'opinionated-machine'
1436
-
1437
- // One per connection: it holds the partial frame, the Last-Event-ID cursor and
1438
- // the BOM that may open the stream.
1439
- const parser = createSSEStreamParser({ lastEventId: resumeFrom })
1440
-
1441
- for await (const chunk of chunks) {
1442
- for (const event of parser.push(chunk)) {
1443
- console.log('Received:', event.event ?? 'message', event.data)
1444
- }
1445
- }
1446
-
1447
- reconnectWith(parser.lastEventId)
1448
- ```
1449
-
1450
- `parseSSEStream` wraps that loop when you only want the events:
1451
-
1452
- ```ts
1453
- import { parseSSEStream } from 'opinionated-machine'
1454
-
1455
- for await (const event of parseSSEStream(chunks, {
1456
- onChunk: () => resetStaleConnectionTimer(),
1457
- })) {
1458
- handle(event)
1459
- }
1460
- ```
1461
-
1462
- `onChunk` fires for every chunk before it is framed, comment frames included.
1463
- Framing consumes `: heartbeat` comments, so a consumer watching only events
1464
- cannot tell an idle-but-healthy connection from a dead one.
1465
-
1466
- #### parseSSEEvents
1467
-
1468
- Parse a complete SSE response body into an array of events.
1469
-
1470
- **When to use:** testing with Fastify's `inject()`, or when the full response is
1471
- available (request-response style SSE such as OpenAI completions):
1472
-
1473
- ```ts
1474
- import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
1475
-
1476
- const responseBody = `event: notification
1477
- data: {"id":"1","message":"Hello"}
1478
-
1479
- event: notification
1480
- data: {"id":"2","message":"World"}
1481
-
1482
- `
1483
-
1484
- const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
1485
- // Result:
1486
- // [
1487
- // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
1488
- // { event: 'notification', data: '{"id":"2","message":"World"}' }
1489
- // ]
1490
-
1491
- // Access parsed data
1492
- const notifications = events.map(e => JSON.parse(e.data))
1493
- ```
1494
-
1495
- A trailing frame with no blank line after it is discarded, which is what the
1496
- spec requires at the end of a stream: a body cut mid-frame must not surface its
1497
- truncated payload as a delivered event. Reach for `parseSSEBuffer` when you want
1498
- to inspect that leftover.
1499
-
1500
- #### parseSSEBuffer
1108
+ #### parseSSEBuffer
1501
1109
 
1502
1110
  One pass over a buffer: the events it completed, the bytes it could not, and the
1503
1111
  reconnect cursor. Prefer `createSSEStreamParser` for a live stream, which keeps
@@ -1544,39 +1152,104 @@ events that carry no `id:` of their own, so it is what you reconnect with;
1544
1152
  on. Ordering on the cursor instead makes every inheriting event look like a
1545
1153
  duplicate of the last id-bearing one.
1546
1154
 
1547
- ### Testing SSE Controllers
1155
+ ### Testing SSE Routes
1548
1156
 
1549
1157
  The test client depends on the session mode:
1550
1158
 
1551
1159
  | Session Mode | Test Client | Why |
1552
1160
  |-------------|-------------|--------|
1553
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
1554
- | `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 |
1555
1163
 
1556
- Enable the connection spy by passing `isTestMode: true` in diOptions (required for `awaitServerConnection`).
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:
1221
+
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.
1557
1232
 
1558
1233
  #### Testing keepAlive SSE (long-lived connections)
1559
1234
 
1560
- Use `SSEHttpClient` against your running app. The key pattern:
1235
+ A `keepAlive` response never completes, so it needs a real HTTP connection. The pattern:
1561
1236
 
1562
- 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
1563
1238
  2. Call `collectEvents()` **before** pushing events (they arrive asynchronously)
1564
- 3. Push events from the server via `sendEventInternal()` or `broadcastToRoom()`
1239
+ 3. Push events from the server, through the session or a room broadcast
1565
1240
  4. Await the collected events
1566
1241
  5. Always call `client.close()` to release the connection
1567
1242
 
1568
1243
  ```ts
1569
- import { SSEHttpClient, SSETestServer } from 'opinionated-machine'
1244
+ import { connectApiSSE, createSSESessionSpy, SSETestServer } from 'opinionated-machine'
1570
1245
 
1571
- describe('NotificationsSSEController', () => {
1572
- let app: AppInstance
1246
+ describe('notifications stream', () => {
1247
+ const { spy, routeOptions } = createSSESessionSpy()
1573
1248
  let server: SSETestServer
1574
- let controller: NotificationsSSEController
1575
1249
 
1576
1250
  beforeAll(async () => {
1577
- app = await getApp({ /* your test config */ })
1578
- controller = app.diContainer.resolve('notificationsSSEController')
1579
-
1251
+ // The spy's hooks must reach the buildApiRoute() call, see below
1252
+ const app = await getApp({ notificationsRouteOptions: routeOptions })
1580
1253
  // SSETestServer.start() starts your app on a random port and provides baseUrl
1581
1254
  server = await SSETestServer.start(app)
1582
1255
  })
@@ -1586,215 +1259,76 @@ describe('NotificationsSSEController', () => {
1586
1259
  })
1587
1260
 
1588
1261
  it('receives notifications over keepAlive SSE', async () => {
1589
- // 1. Connect with awaitServerConnection to eliminate race condition
1590
- const { client, serverConnection } = await SSEHttpClient.connect(
1262
+ const { client, serverConnection } = await connectApiSSE(
1591
1263
  server.baseUrl,
1592
- '/api/notifications/stream',
1593
- {
1594
- query: { userId: 'test-user' },
1595
- awaitServerConnection: { controller },
1596
- },
1264
+ notificationsContract,
1265
+ { queryParams: { userId: 'test-user' } },
1266
+ { awaitServerConnection: { spy } },
1597
1267
  )
1598
1268
 
1599
1269
  expect(client.response.ok).toBe(true)
1600
1270
 
1601
- // 2. Start collecting events BEFORE pushing (they arrive asynchronously)
1602
1271
  const eventsPromise = client.collectEvents(2)
1603
1272
 
1604
- // 3. Push events from server
1605
- await controller.sendEventInternal(serverConnection.id, {
1606
- event: 'notification',
1607
- data: { id: '1', message: 'Hello!' },
1608
- })
1609
- await controller.sendEventInternal(serverConnection.id, {
1610
- event: 'notification',
1611
- data: { id: '2', message: 'World!' },
1612
- })
1613
-
1614
- // 4. Await collected events
1615
- const events = await eventsPromise
1273
+ await serverConnection.send('notification', { id: '1', message: 'Hello!' })
1274
+ await serverConnection.send('notification', { id: '2', message: 'World!' })
1616
1275
 
1617
- expect(events).toHaveLength(2)
1618
- expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
1619
- 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
+ ])
1620
1281
 
1621
- // 5. Clean up
1622
1282
  client.close()
1623
1283
  })
1624
1284
  })
1625
1285
  ```
1626
1286
 
1627
- #### Testing autoClose SSE (request-response streaming)
1287
+ ### SSESessionSpy API
1628
1288
 
1629
- 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:
1289
+ `createSSESessionSpy()` returns a spy plus the `onConnect` / `onClose` route hooks that drive it:
1630
1290
 
1631
1291
  ```ts
1632
- import { SSEInjectClient } from 'opinionated-machine'
1292
+ import { buildApiRoute, createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
1633
1293
 
1634
- it('streams chat completions', async () => {
1635
- const client = new SSEInjectClient(app) // works without app.listen()
1636
-
1637
- const conn = await client.connectWithBody(
1638
- '/api/chat/completions',
1639
- { message: 'Hello world' },
1640
- )
1641
-
1642
- expect(conn.getStatusCode()).toBe(200)
1643
- const events = conn.getReceivedEvents()
1644
- const chunks = events.filter((e) => e.event === 'chunk')
1645
- expect(chunks.length).toBeGreaterThan(0)
1646
- })
1647
- ```
1648
-
1649
- 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)).
1650
-
1651
- #### Asserting documented error responses with `bodyForStatus`
1652
-
1653
- 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:
1654
-
1655
- ```ts
1656
- import { buildSseContract } from '@lokalise/api-contracts'
1657
- import { z } from 'zod'
1658
- import { injectSSE } from 'opinionated-machine'
1659
-
1660
- const streamContract = buildSseContract({
1661
- visibility: 'public',
1662
- method: 'get',
1663
- pathResolver: () => '/api/stream',
1664
- requestQuerySchema: z.object({}),
1665
- requestHeaderSchema: z.object({}),
1666
- responseBodySchemasByStatusCode: {
1667
- 401: z.object({ message: z.string() }),
1668
- 404: z.object({ resourceId: z.string() }),
1669
- },
1670
- serverSentEventSchemas: { message: z.object({ text: z.string() }) },
1671
- })
1672
-
1673
- it('returns the documented 401 body when unauthenticated', async () => {
1674
- const { bodyForStatus } = injectSSE(app, streamContract, {})
1675
-
1676
- // `body` is typed as `{ message: string }` — the 401 schema.
1677
- // TS rejects status codes the contract doesn't declare, e.g. bodyForStatus(500).
1678
- const body = await bodyForStatus(401)
1679
- expect(body.message).toBe('Unauthorized')
1680
- })
1681
- ```
1682
-
1683
- `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.
1684
-
1685
- #### Contracts built with `defineApiContract`: `injectApiSSE`
1686
-
1687
- `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:
1688
-
1689
- ```ts
1690
- import { defineApiContract, sseResponse } from '@lokalise/api-contracts'
1691
- import { z } from 'zod/v4'
1692
- import { injectApiSSE } from 'opinionated-machine'
1693
-
1694
- const lqaSegmentContract = defineApiContract({
1695
- visibility: 'internal',
1696
- method: 'post',
1697
- summary: 'Perform LQA on a text segment',
1698
- pathResolver: () => '/v1/content/actions/lqa-text-segment',
1699
- requestBodySchema: z.object({ segment: z.string() }),
1700
- responsesByStatusCode: {
1701
- 200: sseResponse({ review: z.object({ score: z.number() }) }),
1702
- 400: z.object({ message: z.string() }),
1703
- },
1704
- })
1705
-
1706
- it('streams the review', async () => {
1707
- const { events } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1708
-
1709
- // Events are validated against the contract and typed as a union on `event`.
1710
- for (const event of await events()) {
1711
- if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
1712
- }
1713
- })
1714
-
1715
- it('returns the documented 400 body for an empty segment', async () => {
1716
- const { bodyForStatus } = injectApiSSE(app, lqaSegmentContract, { body: { segment: '' } })
1717
-
1718
- // `body` is typed as `{ message: string }` — the contract's 400 schema.
1719
- const body = await bodyForStatus(400)
1720
- expect(body.message).toBe('segment must not be empty')
1721
- })
1722
- ```
1723
-
1724
- ```ts
1725
- it('sends each issue as soon as it is found', async () => {
1726
- const { head, stream } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1294
+ const { spy, routeOptions } = createSSESessionSpy()
1727
1295
 
1728
- // The head is on the wire as soon as the handler calls sse.start()
1729
- expect((await head).statusCode).toBe(200)
1296
+ // in the app under test — `routeOptions` is just `{ onConnect, onClose }`
1297
+ app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
1730
1298
 
1731
- for await (const event of stream()) {
1732
- // Each event is observed while the handler is still producing the next one
1733
- if (event.event === 'issue') expect(handlerFinished).toBe(false)
1734
- }
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 },
1735
1302
  })
1303
+ await serverConnection.send('ping', { seq: 1 })
1736
1304
  ```
1737
1305
 
1738
- `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.
1739
-
1740
- Two accessors read the response before it completes, so an inject-based suite can assert *progressive delivery* without opening a real port for it:
1741
-
1742
- - `head` — `{ statusCode, headers }`, resolved as soon as the response head is on the wire (for a streaming handler, at `sse.start()`).
1743
- - `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.
1744
-
1745
- `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).
1746
-
1747
- 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.
1748
-
1749
- ### SSESessionSpy API
1750
-
1751
- The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
1306
+ The spy can also be queried directly:
1752
1307
 
1753
1308
  ```ts
1754
1309
  // Wait for a session to be established (with timeout)
1755
- const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1310
+ const session = await spy.waitForConnection({ timeout: 5000 })
1756
1311
 
1757
1312
  // Wait for a session matching a predicate (useful for multiple sessions)
1758
- const session = await controller.connectionSpy.waitForConnection({
1313
+ const session = await spy.waitForConnection({
1759
1314
  timeout: 5000,
1760
1315
  predicate: (s) => s.request.url.includes('/api/notifications'),
1761
1316
  })
1762
1317
 
1763
1318
  // Check if a specific session is active
1764
- const isConnected = controller.connectionSpy.isConnected(sessionId)
1319
+ const isConnected = spy.isConnected(sessionId)
1765
1320
 
1766
1321
  // Wait for a specific session to disconnect
1767
- await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
1322
+ await spy.waitForDisconnection(sessionId, { timeout: 5000 })
1768
1323
 
1769
1324
  // Get all session events (connect/disconnect history)
1770
- const events = controller.connectionSpy.getEvents()
1325
+ const events = spy.getEvents()
1771
1326
 
1772
1327
  // Clear event history and claimed sessions between tests
1773
- controller.connectionSpy.clear()
1774
- ```
1775
-
1776
- **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`.
1777
-
1778
- #### Standalone spy for `buildApiRoute` routes
1779
-
1780
- `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:
1781
-
1782
- ```ts
1783
- import { createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
1784
-
1785
- const { spy, routeOptions } = createSSESessionSpy()
1786
-
1787
- // in the app under test — `routeOptions` is just `{ onConnect, onClose }`
1788
- app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
1789
-
1790
- // in the test — same race-free connect as with a controller
1791
- const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, '/api/stream', {
1792
- awaitServerConnection: { spy },
1793
- })
1794
- await serverConnection.send('ping', { seq: 1 })
1328
+ spy.clear()
1795
1329
  ```
1796
1330
 
1797
- `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.
1798
1332
 
1799
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:
1800
1334
 
@@ -1844,24 +1378,7 @@ app.route(
1844
1378
 
1845
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.
1846
1380
 
1847
- 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>()`.
1848
-
1849
- ### Session Monitoring
1850
-
1851
- Controllers have access to utility methods for monitoring sessions:
1852
-
1853
- ```ts
1854
- // Get count of active sessions
1855
- const count = this.getConnectionCount()
1856
-
1857
- // Get all active sessions (for iteration/inspection)
1858
- const sessions = this.getConnections()
1859
-
1860
- // Check if session spy is enabled (useful for conditional logic)
1861
- if (this.hasConnectionSpy()) {
1862
- // ...
1863
- }
1864
- ```
1381
+ The spy is typed for the `SSESession` of `@lokalise/fastify-api-contracts`, which is what `buildApiRoute` passes to its hooks.
1865
1382
 
1866
1383
  ### SSE Rooms
1867
1384
 
@@ -1874,15 +1391,18 @@ SSE Rooms provide Socket.IO-style room functionality for grouping connections an
1874
1391
 
1875
1392
  #### Enabling Rooms
1876
1393
 
1877
- 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:
1878
1395
 
1879
1396
  ```ts
1880
1397
  import { asValue } from 'awilix'
1398
+ import type { RouteOptions } from 'fastify'
1881
1399
  import {
1400
+ AbstractApiController,
1882
1401
  AbstractModule,
1883
- AbstractSSEController,
1402
+ asApiControllerClass,
1884
1403
  asSingletonClass,
1885
- asSSEControllerClass,
1404
+ buildApiRoute,
1405
+ getSessionRooms,
1886
1406
  SSERoomBroadcaster,
1887
1407
  SSERoomManager,
1888
1408
  } from 'opinionated-machine'
@@ -1890,123 +1410,75 @@ import {
1890
1410
  class DashboardModule extends AbstractModule {
1891
1411
  resolveDependencies() {
1892
1412
  return {
1893
- // Required: room infrastructure — registered once, shared across controllers
1413
+ // Registered once, shared by every route that uses rooms
1894
1414
  sseRoomManager: asValue(new SSERoomManager()),
1895
1415
  sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster), // expects 'sseRoomManager' in cradle — name must match exactly
1896
1416
  }
1897
1417
  }
1898
1418
 
1899
- resolveControllers(diOptions: DependencyInjectionOptions) {
1419
+ resolveControllers() {
1900
1420
  return {
1901
- // rooms: true → resolves 'sseRoomBroadcaster' from DI cradle
1902
- dashboardController: asSSEControllerClass(DashboardSSEController, {
1903
- diOptions,
1904
- rooms: true,
1905
- }),
1421
+ dashboardController: asApiControllerClass(DashboardController),
1906
1422
  }
1907
1423
  }
1908
1424
  }
1909
- ```
1910
1425
 
1911
- > **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.
1426
+ class DashboardController extends AbstractApiController<typeof DashboardController.contracts> {
1427
+ static contracts = { dashboardStream: dashboardStreamContract } as const
1912
1428
 
1913
- #### Session Room Operations
1914
-
1915
- When rooms are enabled, each SSE session has access to room operations via `session.rooms`:
1429
+ readonly routes: Record<keyof typeof DashboardController.contracts, RouteOptions>
1916
1430
 
1917
- ```ts
1918
- private handleDashboardStream = buildHandler(dashboardStreamContract, {
1919
- sse: async (request, sse) => {
1920
- 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
+ ```
1921
1448
 
1922
- // Join one or more rooms
1923
- session.rooms.join(`dashboard:${request.params.dashboardId}`)
1924
- 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.
1925
1450
 
1926
- // Leave rooms
1927
- session.rooms.leave('plan:enterprise')
1928
- },
1929
- })
1930
- ```
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).
1931
1452
 
1932
- #### Broadcasting to Rooms
1453
+ #### Session Room Operations
1933
1454
 
1934
- 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:
1935
1456
 
1936
1457
  ```ts
1937
- class DashboardSSEController extends AbstractSSEController<typeof contracts> {
1938
- // Send metrics update to everyone viewing the dashboard
1939
- // Event name and data are type-checked against contract's sseEvents
1940
- async broadcastMetricsUpdate(dashboardId: string, metrics: DashboardMetrics) {
1941
- const count = await this.broadcastToRoom(
1942
- `dashboard:${dashboardId}`,
1943
- 'metricsUpdate', // Must be a valid event name from contracts
1944
- metrics, // Must match the schema for 'metricsUpdate'
1945
- )
1946
- console.log(`Metrics sent to ${count} viewers`)
1947
- }
1948
-
1949
- // Broadcast to a room
1950
- async broadcastChange(dashboardId: string, change: DashboardChange) {
1951
- await this.broadcastToRoom(
1952
- `dashboard:${dashboardId}`,
1953
- 'change',
1954
- change,
1955
- )
1956
- }
1458
+ const session = sse.start('keepAlive')
1459
+ const rooms = getSessionRooms(session)
1957
1460
 
1958
- // Broadcast to multiple rooms (connections in any room receive it, de-duplicated)
1959
- async announceFeature(feature: string) {
1960
- const count = await this.broadcastToRoom(
1961
- ['premium', 'beta-testers'],
1962
- 'featureFlag',
1963
- { flag: feature, enabled: true },
1964
- )
1965
- // Each connection receives the message only once, even if in multiple rooms
1966
- }
1461
+ // Join one or more rooms
1462
+ rooms.join(`dashboard:${request.params.dashboardId}`)
1463
+ rooms.join(['org:acme', 'plan:enterprise']) // Multiple rooms
1967
1464
 
1968
- // Local-only broadcast (skip Redis propagation in multi-node setups)
1969
- async localAnnouncement(room: string, message: string) {
1970
- await this.broadcastToRoom(room, 'announcement', { message }, { local: true })
1971
- }
1972
- }
1465
+ // Leave rooms
1466
+ rooms.leave('plan:enterprise')
1973
1467
  ```
1974
1468
 
1975
- #### Room Broadcaster (Decoupled Broadcasting)
1469
+ #### Broadcasting to Rooms
1976
1470
 
1977
- 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:
1978
1472
 
1979
1473
  ```ts
1980
1474
  import { defineEvent, type SSERoomBroadcaster } from 'opinionated-machine'
1981
- import { z } from 'zod'
1475
+ import { z } from 'zod/v4'
1982
1476
 
1983
- // 1. Define type-safe events with schemas
1984
1477
  const metricsUpdateEvent = defineEvent(
1985
1478
  'metricsUpdate',
1986
1479
  z.object({ cpu: z.number(), memory: z.number() }),
1987
1480
  )
1988
1481
 
1989
- // 2. Register domain service in resolveDependencies()
1990
- class DashboardModule extends AbstractModule {
1991
- resolveDependencies() {
1992
- return {
1993
- sseRoomManager: asValue(new SSERoomManager()),
1994
- sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster), // expects 'sseRoomManager' in cradle — name must match exactly
1995
- metricsService: asSingletonClass(MetricsService),
1996
- }
1997
- }
1998
-
1999
- resolveControllers(diOptions: DependencyInjectionOptions) {
2000
- return {
2001
- dashboardController: asSSEControllerClass(DashboardSSEController, {
2002
- diOptions,
2003
- rooms: true,
2004
- }),
2005
- }
2006
- }
2007
- }
2008
-
2009
- // 3. Inject broadcaster into domain services — no generic needed
2010
1482
  class MetricsService {
2011
1483
  private broadcaster: SSERoomBroadcaster
2012
1484
 
@@ -2015,17 +1487,30 @@ class MetricsService {
2015
1487
  }
2016
1488
 
2017
1489
  async onMetricsUpdate(dashboardId: string, metrics: { cpu: number; memory: number }) {
2018
- // Type-safe: data is validated against the event's schema at compile time
2019
- await this.broadcaster.broadcastToRoom(
1490
+ // Returns the number of local connections the event was delivered to
1491
+ const count = await this.broadcaster.broadcastToRoom(
2020
1492
  `dashboard:${dashboardId}`,
2021
1493
  metricsUpdateEvent,
2022
1494
  metrics,
2023
1495
  )
2024
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
+ }
2025
1510
  }
2026
1511
  ```
2027
1512
 
2028
- 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.
2029
1514
 
2030
1515
  #### Room Event Publisher (Fire-and-Forget)
2031
1516
 
@@ -2037,7 +1522,7 @@ without the promise.
2037
1522
 
2038
1523
  ```ts
2039
1524
  import { defineEvent, SSERoomEventPublisher } from 'opinionated-machine'
2040
- import { z } from 'zod'
1525
+ import { z } from 'zod/v4'
2041
1526
 
2042
1527
  const metricsUpdateEvent = defineEvent(
2043
1528
  'metricsUpdate',
@@ -2126,7 +1611,7 @@ count matters, or when a failed delivery is something the caller can act on.
2126
1611
 
2127
1612
  #### Room Name Helpers
2128
1613
 
2129
- 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:
2130
1615
 
2131
1616
  ```ts
2132
1617
  import { defineRoom } from 'opinionated-machine'
@@ -2140,48 +1625,36 @@ const projectChannelRoom = defineRoom<{ projectId: string; channelId: string }>(
2140
1625
  ({ projectId, channelId }) => `project:${projectId}:channel:${channelId}`,
2141
1626
  )
2142
1627
 
2143
- // In controller handler — params are type-checked
2144
- 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 }))
2145
1630
 
2146
- // In domain service — same resolver, same type safety
2147
- await broadcaster.broadcastToRoom(
2148
- dashboardRoom({ dashboardId }),
2149
- 'metricsUpdate',
2150
- metrics,
2151
- )
1631
+ // In a domain service — same resolver, same type safety
1632
+ await broadcaster.broadcastToRoom(dashboardRoom({ dashboardId }), metricsUpdateEvent, metrics)
2152
1633
  ```
2153
1634
 
2154
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.
2155
1636
 
2156
1637
  #### Room Query Methods
2157
1638
 
2158
- Controllers have access to room query methods:
1639
+ The broadcaster answers the common queries; the underlying `SSERoomManager` (`broadcaster.roomManager`) has the rest:
2159
1640
 
2160
1641
  ```ts
2161
- class DashboardSSEController extends AbstractSSEController<typeof contracts> {
2162
- // Get all connection IDs in a room
2163
- getDashboardViewers(dashboardId: string): string[] {
2164
- return this.getConnectionsInRoom(`dashboard:${dashboardId}`)
2165
- }
2166
-
2167
- // Get count of connections in a room
2168
- getDashboardViewerCount(dashboardId: string): number {
2169
- return this.getConnectionCountInRoom(`dashboard:${dashboardId}`)
2170
- }
1642
+ // Connection ids in a room, and their count
1643
+ broadcaster.getConnectionsInRoom(`dashboard:${dashboardId}`)
1644
+ broadcaster.getConnectionCountInRoom(`dashboard:${dashboardId}`)
2171
1645
 
2172
- // Get all rooms a specific connection is in
2173
- getConnectionRooms(connectionId: string): string[] {
2174
- return this.getRooms(connectionId)
2175
- }
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()
2176
1650
 
2177
- // Manually join/leave rooms from controller (useful for admin operations)
2178
- moveToRoom(connectionId: string, fromRoom: string, toRoom: string) {
2179
- this.leaveRoom(connectionId, fromRoom)
2180
- this.joinRoom(connectionId, toRoom)
2181
- }
2182
- }
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)
2183
1654
  ```
2184
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
+
2185
1658
  #### Auto-Leave on Disconnect
2186
1659
 
2187
1660
  When a connection closes (client disconnect or server close), it automatically leaves all rooms. No manual cleanup is required.
@@ -2209,19 +1682,10 @@ class InfraModule extends AbstractModule {
2209
1682
  }
2210
1683
  }
2211
1684
  }
2212
-
2213
- class DashboardModule extends AbstractModule {
2214
- resolveControllers(diOptions: DependencyInjectionOptions) {
2215
- return {
2216
- dashboardController: asSSEControllerClass(DashboardSSEController, {
2217
- diOptions,
2218
- rooms: true,
2219
- }),
2220
- }
2221
- }
2222
- }
2223
1685
  ```
2224
1686
 
1687
+ Routes need no change: they keep passing the same broadcaster as `sseRooms`.
1688
+
2225
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.
2226
1690
 
2227
1691
  See the [@opinionated-machine/sse-rooms-redis](../sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
@@ -2315,25 +1779,61 @@ const subscriptionManager = new SSESubscriptionManager<UserCtx, EventMetadata>(
2315
1779
 
2316
1780
  #### Integrating with a Controller
2317
1781
 
2318
- 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`:
2319
1783
 
2320
1784
  ```typescript
2321
- class NotificationController extends AbstractSSEController<Contracts> {
2322
- private handleStream = buildHandler(contract, {
2323
- sse: (request, sse) => {
2324
- const session = sse.start('keepAlive')
2325
- this.subscriptionManager.handleConnect(session).catch(() => {
2326
- // Handle connection setup failure (e.g., resolver threw)
2327
- })
2328
- },
2329
- }, {
2330
- onClose: (session) => {
2331
- this.subscriptionManager.handleDisconnect(session)
2332
- },
2333
- })
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
+ }
2334
1828
  }
2335
1829
  ```
2336
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
+
2337
1837
  #### Publishing Events
2338
1838
 
2339
1839
  ```typescript
@@ -2425,21 +1925,21 @@ The library provides utilities for testing SSE endpoints.
2425
1925
 
2426
1926
  | Session Mode | Test Client | Reason |
2427
1927
  |-------------|-------------|--------|
2428
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
2429
- | `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 |
2430
1930
 
2431
- `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.
2432
1932
 
2433
1933
  #### Detailed Comparison
2434
1934
 
2435
- | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
2436
- |---------|----------------------------------------|------------------------|
1935
+ | Feature | Inject (`injectApiSSE`, `SSEInjectClient`) | HTTP (`connectApiSSE`, `SSEHttpClient`) |
1936
+ |---------|--------------------------------------------|------------------------------------------|
2437
1937
  | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
2438
1938
  | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
2439
1939
  | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
2440
1940
  | **Server requirement** | No `listen()` needed | Requires a listening server (`SSETestServer.start(app)` or manual `app.listen()`) |
2441
- | **Request body** | `injectPayloadSSE` / `connectWithBody` | `method` + `body` connect options |
2442
- | **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 |
2443
1943
  | **Best for** | `autoClose` SSE (OpenAI-style, batch exports) | `keepAlive` SSE (notifications, live feeds, rooms), streams whose headers must be asserted mid-handler |
2444
1944
  | **Dual-mode sync** | Use `app.inject()` with `accept: 'application/json'` | Same |
2445
1945
 
@@ -2448,7 +1948,10 @@ The library provides utilities for testing SSE endpoints.
2448
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:
2449
1949
 
2450
1950
  ```ts
2451
- 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()
2452
1955
 
2453
1956
  // Connect to SSE endpoint with awaitServerConnection (recommended)
2454
1957
  // This eliminates the race condition between client connect and server-side registration
@@ -2458,13 +1961,13 @@ const { client, serverConnection } = await SSEHttpClient.connect(
2458
1961
  {
2459
1962
  query: { userId: 'test' },
2460
1963
  headers: { authorization: 'Bearer token' },
2461
- awaitServerConnection: { controller }, // Pass your SSE controller
1964
+ awaitServerConnection: { spy },
2462
1965
  },
2463
1966
  )
2464
1967
 
2465
- // serverConnection is ready to use immediately
1968
+ // serverConnection is the server-side session, ready to use immediately
2466
1969
  expect(client.response.ok).toBe(true)
2467
- await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1970
+ await serverConnection.send('test', {})
2468
1971
 
2469
1972
  // Collect events by count with timeout
2470
1973
  const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
@@ -2496,917 +1999,238 @@ Collects events until a count is reached or a predicate returns true.
2496
1999
 
2497
2000
  Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
2498
2001
 
2499
- ```ts
2500
- // Collect exactly 3 events
2501
- const events = await client.collectEvents(3)
2502
-
2503
- // Collect with custom timeout
2504
- const events = await client.collectEvents(5, 10000) // 10s timeout
2505
-
2506
- // Collect until a specific event type (the matching event IS included)
2507
- const events = await client.collectEvents((event) => event.event === 'done')
2508
-
2509
- // Collect until condition with timeout
2510
- const events = await client.collectEvents(
2511
- (event) => JSON.parse(event.data).status === 'complete',
2512
- 30000,
2513
- )
2514
- ```
2515
-
2516
- **`events(signal?)`**
2517
-
2518
- Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
2519
-
2520
- ```ts
2521
- // Basic iteration
2522
- for await (const event of client.events()) {
2523
- console.log(event.event, event.data)
2524
- if (event.event === 'done') break
2525
- }
2526
-
2527
- // With abort signal for timeout control
2528
- const controller = new AbortController()
2529
- const timeoutId = setTimeout(() => controller.abort(), 5000)
2530
-
2531
- try {
2532
- for await (const event of client.events(controller.signal)) {
2533
- console.log(event)
2534
- }
2535
- } finally {
2536
- clearTimeout(timeoutId)
2537
- }
2538
- ```
2539
-
2540
- **When to omit `awaitServerConnection`**
2541
-
2542
- Omit `awaitServerConnection` only in these cases:
2543
- - Testing against external SSE endpoints (not your own controller)
2544
- - When `isTestMode: false` (connectionSpy not available)
2545
- - Simple smoke tests that only verify response headers/status without sending server events
2546
- - 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
2547
-
2548
- 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).
2549
-
2550
- **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.
2551
-
2552
- ```ts
2553
- // Example: smoke test that only checks connection works
2554
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
2555
- expect(client.response.ok).toBe(true)
2556
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
2557
- client.close()
2558
- ```
2559
-
2560
- **POST/PUT/PATCH endpoints**
2561
-
2562
- `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:
2563
-
2564
- ```ts
2565
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2566
- method: 'POST',
2567
- body: { message: 'Hello', stream: true },
2568
- })
2569
-
2570
- const events = await client.collectEvents((event) => event.event === 'done')
2571
- ```
2572
-
2573
- 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:
2574
-
2575
- ```ts
2576
- // Sent as application/x-www-form-urlencoded, not JSON-stringified into `{}`
2577
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2578
- method: 'post',
2579
- body: new URLSearchParams({ message: 'Hello' }),
2580
- })
2581
- ```
2582
-
2583
- A body without a non-GET `method` throws — `fetch()` cannot attach one to a GET request.
2584
-
2585
- **Asserting on the wire before the handler finishes**
2586
-
2587
- `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.
2588
-
2589
- ```ts
2590
- // Handler calls sse.start() and only then makes its slow LLM call
2591
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2592
- method: 'POST',
2593
- body: { message: 'Hello', stream: true },
2594
- })
2595
-
2596
- // Already on the wire while the LLM call is still in flight
2597
- expect(client.response.status).toBe(200)
2598
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
2599
-
2600
- releaseSlowCall()
2601
- const events = await client.collectEvents((event) => event.event === 'done')
2602
- ```
2603
-
2604
- 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:
2605
-
2606
- ```ts
2607
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/chat/completions', {
2608
- method: 'POST',
2609
- body: { message: 'Hello', stream: true },
2610
- })
2611
-
2612
- expect(client.response.status).toBe(503)
2613
- expect(client.response.headers.get('content-type')).not.toContain('text/event-stream')
2614
- expect(await client.response.json()).toEqual({ message: 'Upstream unavailable' })
2615
- ```
2616
-
2617
- 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`.
2618
-
2619
- #### SSEInjectClient
2620
-
2621
- For testing `autoClose` SSE streams (like OpenAI completions). Uses Fastify's `inject()` - no `app.listen()` needed:
2622
-
2623
- ```ts
2624
- import { SSEInjectClient } from 'opinionated-machine'
2625
-
2626
- const client = new SSEInjectClient(app) // No server.listen() needed
2627
-
2628
- // GET request
2629
- const conn = await client.connect('/api/export/progress', {
2630
- headers: { authorization: 'Bearer token' },
2631
- })
2632
-
2633
- // POST request with body (OpenAI-style)
2634
- const conn = await client.connectWithBody(
2635
- '/api/chat/completions',
2636
- { model: 'gpt-4', messages: [...], stream: true },
2637
- )
2638
-
2639
- // Any other method inject() accepts works too - the option is typed as
2640
- // `SSEInjectMethod`, exported so you never have to redeclare that union
2641
- const conn = await client.connectWithBody(
2642
- '/api/exports/42',
2643
- { reason: 'cleanup' },
2644
- { method: 'DELETE' },
2645
- )
2646
-
2647
- // All events are available immediately (inject waits for handler to complete)
2648
- expect(conn.getStatusCode()).toBe(200)
2649
- const events = conn.getReceivedEvents()
2650
- const chunks = events.filter(e => e.event === 'chunk')
2651
- ```
2652
-
2653
- When the route answers with a status code *before* streaming starts (auth failure,
2654
- validation error, integration unavailable), it sends a JSON body rather than events.
2655
- `getBody()` returns that body raw and `json()` parses it, mirroring Fastify's own
2656
- inject response:
2657
-
2658
- ```ts
2659
- const conn = await client.connect('/api/export/progress')
2660
-
2661
- expect(conn.getStatusCode()).toBe(503)
2662
- expect(conn.json<{ errorCode: string }>()).toMatchObject({
2663
- errorCode: 'INTEGRATION_NOT_AVAILABLE',
2664
- })
2665
- ```
2666
-
2667
- `json()` throws if the body is empty or isn't valid JSON, so it only makes sense for
2668
- these pre-stream responses - a `text/event-stream` body is not JSON. For contract-typed
2669
- tests, `injectSSE`/`injectPayloadSSE`/`injectApiSSE` offer `bodyForStatus(status)`, which
2670
- also validates the body against the contract's schema for that status.
2671
-
2672
- #### Contract-Aware Inject Helpers
2673
-
2674
- For typed testing with SSE contracts:
2675
-
2676
- ```ts
2677
- import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
2678
-
2679
- // For GET SSE endpoints with contracts
2680
- const { closed } = injectSSE(app, notificationsContract, {
2681
- query: { userId: 'test' },
2682
- })
2683
- const result = await closed
2684
- const events = parseSSEEvents(result.body)
2685
-
2686
- // For POST/PUT/PATCH SSE endpoints with contracts
2687
- const { closed } = injectPayloadSSE(app, chatCompletionContract, {
2688
- body: { message: 'Hello', stream: true },
2689
- })
2690
- const result = await closed
2691
- const events = parseSSEEvents(result.body)
2692
- ```
2693
-
2694
- 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).
2695
-
2696
- #### Contract-Aware HTTP Helpers
2697
-
2698
- `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.
2699
-
2700
- ```ts
2701
- import { connectApiSSE, SSETestServer } from 'opinionated-machine'
2702
-
2703
- const server = await SSETestServer.start(app)
2704
-
2705
- // Method, path, query params, headers and body all come from the contract
2706
- const client = await connectApiSSE(server.baseUrl, lqaSegmentContract, {
2707
- body: { segment: 'hello' },
2708
- })
2709
-
2710
- expect(client.response.status).toBe(200) // asserted while the handler is still working
2711
-
2712
- for await (const event of client.events()) {
2713
- if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed by the contract
2714
- if (event.event === 'review') break
2715
- }
2716
-
2717
- client.close()
2718
- await server.close()
2719
- ```
2720
-
2721
- - `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.
2722
- - 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.
2723
- - `client.response` is the fetch `Response`, available before any event is consumed.
2724
- - `client.raw` is the underlying `SSEHttpClient`, for anything this wrapper doesn't cover.
2725
- - 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 }`.
2726
-
2727
- On a connection you already have, the same typing is available per read: `client.apiEvents(contract)` and `client.collectApiEvents(contract, countOrPredicate)` on any `SSEHttpClient`.
2728
-
2729
- #### When a Handler Fails to Send an Event
2730
-
2731
- `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.
2732
-
2733
- 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:
2734
-
2735
- ```
2736
- events() — 1 SSE send failure recorded for this request:
2737
- - event "issue" was never sent: severity: Invalid option: expected one of "neutral"|"minor"|"major"|"critical"; payload: {"severity":"min"}
2738
- ```
2739
-
2740
- 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.
2741
-
2742
- 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:
2743
-
2744
- ```ts
2745
- const { closed, events, sendFailures } = injectApiSSE(app, lqaSegmentContract, { body })
2746
- await closed
2747
-
2748
- expect(await events()).toHaveLength(2) // the fallback stream is intact
2749
- expect(sendFailures()).toMatchObject([{ eventName: 'issue', handled: true }])
2750
- ```
2751
-
2752
- 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.
2753
-
2754
- ## Dual-Mode Controllers (SSE + Sync)
2755
-
2756
- 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.
2757
-
2758
- ### Overview
2759
-
2760
- | Accept Header | Response Mode |
2761
- | ------------- | ------------- |
2762
- | `text/event-stream` | SSE streaming |
2763
- | `application/json` | Sync response |
2764
- | `*/*` or missing | Sync (default, configurable) |
2765
-
2766
- 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.
2767
-
2768
- ### Defining Dual-Mode Contracts
2769
-
2770
- 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:
2771
-
2772
- - Clients may want immediate results (sync) or real-time updates (SSE)
2773
- - You're building OpenAI-style APIs where `stream: true` triggers SSE
2774
- - You need polling fallback for clients that don't support SSE
2775
-
2776
- To create a dual-mode contract, include a `successResponseBodySchema` in your `buildSseContract` call:
2777
- - Has `successResponseBodySchema` but no `requestBodySchema` → GET dual-mode route
2778
- - Has both `successResponseBodySchema` and `requestBodySchema` → POST/PUT/PATCH dual-mode route
2779
-
2780
- ```ts
2781
- import { z } from 'zod'
2782
- import { buildSseContract } from '@lokalise/api-contracts'
2783
-
2784
- // GET dual-mode route (polling or streaming job status)
2785
- export const jobStatusContract = buildSseContract({
2786
- visibility: 'public',
2787
- method: 'get',
2788
- pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
2789
- requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
2790
- requestQuerySchema: z.object({ verbose: z.string().optional() }),
2791
- requestHeaderSchema: z.object({}),
2792
- successResponseBodySchema: z.object({
2793
- status: z.enum(['pending', 'running', 'completed', 'failed']),
2794
- progress: z.number(),
2795
- result: z.string().optional(),
2796
- }),
2797
- serverSentEventSchemas: {
2798
- progress: z.object({ percent: z.number(), message: z.string().optional() }),
2799
- done: z.object({ result: z.string() }),
2800
- },
2801
- })
2802
-
2803
- // POST dual-mode route (OpenAI-style chat completion)
2804
- export const chatCompletionContract = buildSseContract({
2805
- visibility: 'public',
2806
- method: 'post',
2807
- pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
2808
- requestPathParamsSchema: z.object({ chatId: z.string().uuid() }),
2809
- requestQuerySchema: z.object({}),
2810
- requestHeaderSchema: z.object({ authorization: z.string() }),
2811
- requestBodySchema: z.object({ message: z.string() }),
2812
- successResponseBodySchema: z.object({
2813
- reply: z.string(),
2814
- usage: z.object({ tokens: z.number() }),
2815
- }),
2816
- serverSentEventSchemas: {
2817
- chunk: z.object({ delta: z.string() }),
2818
- done: z.object({ usage: z.object({ total: z.number() }) }),
2819
- },
2820
- })
2821
- ```
2822
-
2823
- **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.
2824
-
2825
- ### Response Headers (Sync Mode)
2826
-
2827
- 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:
2828
-
2829
- ```ts
2830
- export const rateLimitedContract = buildSseContract({
2831
- visibility: 'public',
2832
- method: 'post',
2833
- pathResolver: () => '/api/rate-limited',
2834
- requestPathParamsSchema: z.object({}),
2835
- requestQuerySchema: z.object({}),
2836
- requestHeaderSchema: z.object({}),
2837
- requestBodySchema: z.object({ data: z.string() }),
2838
- successResponseBodySchema: z.object({ result: z.string() }),
2839
- // Define expected response headers
2840
- responseHeaderSchema: z.object({
2841
- 'x-ratelimit-limit': z.string(),
2842
- 'x-ratelimit-remaining': z.string(),
2843
- 'x-ratelimit-reset': z.string(),
2844
- }),
2845
- serverSentEventSchemas: {
2846
- result: z.object({ success: z.boolean() }),
2847
- },
2848
- })
2849
- ```
2850
-
2851
- In your handler, set headers using `reply.header()`:
2852
-
2853
- ```ts
2854
- handlers: buildHandler(rateLimitedContract, {
2855
- sync: async (request, reply) => {
2856
- reply.header('x-ratelimit-limit', '100')
2857
- reply.header('x-ratelimit-remaining', '99')
2858
- reply.header('x-ratelimit-reset', '1640000000')
2859
- return { result: 'success' }
2860
- },
2861
- sse: async (request, sse) => {
2862
- const session = sse.start('autoClose')
2863
- // ... send events ...
2864
- // Connection closes automatically when handler returns
2865
- },
2866
- })
2867
- ```
2868
-
2869
- If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
2870
-
2871
- ### Status-Specific Response Schemas (responseBodySchemasByStatusCode)
2872
-
2873
- 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:
2874
-
2875
- ```ts
2876
- export const resourceContract = buildSseContract({
2877
- visibility: 'public',
2878
- method: 'post',
2879
- pathResolver: (params) => `/api/resources/${params.id}`,
2880
- requestPathParamsSchema: z.object({ id: z.string() }),
2881
- requestQuerySchema: z.object({}),
2882
- requestHeaderSchema: z.object({}),
2883
- requestBodySchema: z.object({ data: z.string() }),
2884
- // Success response (2xx)
2885
- successResponseBodySchema: z.object({
2886
- success: z.boolean(),
2887
- data: z.string(),
2888
- }),
2889
- // Responses by status code (typically used for errors)
2890
- responseBodySchemasByStatusCode: {
2891
- 400: z.object({ error: z.string(), details: z.array(z.string()) }),
2892
- 404: z.object({ error: z.string(), resourceId: z.string() }),
2893
- },
2894
- serverSentEventSchemas: {
2895
- result: z.object({ success: z.boolean() }),
2896
- },
2897
- })
2898
- ```
2899
-
2900
- **Recommended: Use `sse.respond()` for strict type safety**
2901
-
2902
- 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:
2903
-
2904
- ```ts
2905
- handlers: buildHandler(resourceContract, {
2906
- sync: (request, reply) => {
2907
- if (!isValid(request.body.data)) {
2908
- reply.code(400)
2909
- return { error: 'Bad Request', details: ['Invalid data format'] }
2910
- }
2911
- return { success: true, data: 'OK' }
2912
- },
2913
- sse: async (request, sse) => {
2914
- const resource = findResource(request.params.id)
2915
- if (!resource) {
2916
- // Strict typing: TypeScript enforces exact schema for status 404
2917
- return sse.respond(404, { error: 'Not Found', resourceId: request.params.id })
2918
- }
2919
- if (!isValid(resource)) {
2920
- // Strict typing: TypeScript enforces exact schema for status 400
2921
- return sse.respond(400, { error: 'Bad Request', details: ['Invalid resource'] })
2922
- }
2923
-
2924
- const session = sse.start('autoClose')
2925
- await session.send('result', { success: true })
2926
- },
2927
- })
2928
- ```
2929
-
2930
- TypeScript enforces the exact schema for each status code at compile time:
2931
-
2932
- ```ts
2933
- sse.respond(404, { error: 'Not Found', resourceId: '123' }) // ✓ OK
2934
- sse.respond(404, { error: 'Not Found' }) // ✗ Error - missing resourceId
2935
- sse.respond(404, { error: 'Not Found', details: [] }) // ✗ Error - wrong schema for 404
2936
- sse.respond(500, { message: 'error' }) // ✗ Error - 500 not defined in schema
2937
- ```
2938
-
2939
- Only status codes defined in `responseBodySchemasByStatusCode` are allowed. To use an undefined status code, add it to the schema or use a type assertion.
2940
-
2941
- **Sync handlers (union typing with runtime validation):**
2942
-
2943
- 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:
2944
-
2945
- ```ts
2946
- sync: (request, reply) => {
2947
- reply.code(404)
2948
- return { error: 'Not Found', resourceId: '123' } // ✓ OK - matches one of the union types
2949
- // Runtime validation ensures body matches the 404 schema
2950
- }
2951
-
2952
- // The sync handler return type is automatically:
2953
- // { success: boolean; data: string } // from successResponseBodySchema
2954
- // | { error: string; details: string[] } // from responseBodySchemasByStatusCode[400]
2955
- // | { error: string; resourceId: string } // from responseBodySchemasByStatusCode[404]
2956
- ```
2957
-
2958
- **Validation behavior:**
2959
-
2960
- - **Success responses (2xx)**: Validated against `successResponseBodySchema`
2961
- - **Non-2xx responses**: Validated against the matching schema in `responseBodySchemasByStatusCode` (if defined)
2962
- - **Validation failures**: Return 500 Internal Server Error (validation details are logged internally, not exposed to clients)
2963
-
2964
- **Validation priority for 2xx status codes:**
2965
-
2966
- - All 2xx responses (200, 201, 204, etc.) returned by the `sync` handler are validated against
2967
- `successResponseBodySchema`
2968
- - For the `sync` handler, `responseBodySchemasByStatusCode` is only used for non-2xx status codes,
2969
- so `successResponseBodySchema` takes precedence when the same 2xx code is defined in both
2970
- - `sse.respond(code, body)` is validated against `responseBodySchemasByStatusCode[code]` at every
2971
- status, 2xx included, because that is the schema its argument is typed from
2972
-
2973
- **OpenAPI output and serialization:**
2974
-
2975
- `buildFastifyRoute` fills in the route's `schema.response` from the contract, so the generated
2976
- spec describes each status instead of showing a bare "Default Response":
2977
-
2978
- - 200 carries `text/event-stream` with one `{ id?, event, data, retry? }` envelope per entry in
2979
- `serverSentEventSchemas`, rendered as a `oneOf` with the `event` name pinned to a `const` in
2980
- each branch, plus `application/json` for the JSON body
2981
- - Every status in `responseBodySchemasByStatusCode` gets its declared schema
2982
-
2983
- Because Fastify drives serialization from the same `schema.response`, a response body for a
2984
- status the contract declares is serialized against that schema. Keys the schema does not
2985
- declare are dropped from the body that goes out. Streamed SSE events are written directly by
2986
- `@fastify/sse` and bypass the serializer, so the 200 event schema documents the stream without
2987
- affecting it.
2988
-
2989
- A status can be reached by more than one body shape, and Fastify rejects anything the schema
2990
- does not accept, so each status accepts every shape the runtime can produce there:
2991
-
2992
- - On a 2xx of a dual-mode contract, `application/json` accepts `successResponseBodySchema` (what
2993
- the `sync` handler is validated against) as well as the schema declared for that status (what
2994
- `sse.respond()` is validated against), rather than one taking precedence over the other in the
2995
- spec
2996
- - On a non-2xx, the declared schema is joined by the framework error envelope
2997
- (`{ statusCode, message, error?, code?, ... }`), which is what Fastify sends for a failed
2998
- request validation and what an application-level error handler typically returns. Without it,
2999
- declaring a 400 body would turn every `FST_ERR_VALIDATION` on that route into a 500
3000
-
3001
- Both show up in the spec as an `anyOf`. Errors the SSE builders raise themselves, before
3002
- streaming starts, are sent pre-serialized and skip the schema entirely, so the thrown error's
3003
- message always reaches the client.
3004
-
3005
- ### Single Sync Handler
3006
-
3007
- 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.
3008
-
3009
- > **Note:** The `reply` parameter is typed as `SyncModeReply`, which omits `send()` to prevent accidental misuse. The framework handles sending the response after validation.
3010
-
3011
- ```ts
3012
- handlers: buildHandler(chatCompletionContract, {
3013
- sync: async (request, reply) => {
3014
- // Return the response data matching successResponseBodySchema
3015
- const result = await aiService.complete(request.body.message)
3016
- return {
3017
- reply: result.text,
3018
- usage: { tokens: result.tokenCount },
3019
- }
3020
- },
3021
- sse: async (request, sse) => {
3022
- // SSE streaming handler
3023
- const session = sse.start('autoClose')
3024
- // ... stream events ...
3025
- },
3026
- })
3027
- ```
3028
-
3029
- TypeScript enforces the correct handler structure:
3030
- - `successResponseBodySchema` contracts must use `sync` handler (returns response data)
3031
- - `serverSentEventSchemas` contracts must use `sse` handler (streams events)
3032
-
3033
- ### Implementing Dual-Mode Controllers
3034
-
3035
- 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`:
3036
-
3037
- ```ts
3038
- import {
3039
- AbstractDualModeController,
3040
- buildHandler,
3041
- type BuildFastifyDualModeRoutesReturnType,
3042
- type DualModeControllerConfig,
3043
- } from 'opinionated-machine'
3044
-
3045
- type Contracts = {
3046
- chatCompletion: typeof chatCompletionContract
3047
- }
3048
-
3049
- type Dependencies = {
3050
- aiService: AIService
3051
- }
3052
-
3053
- export class ChatDualModeController extends AbstractDualModeController<Contracts> {
3054
- public static contracts = {
3055
- chatCompletion: chatCompletionContract,
3056
- } as const
3057
-
3058
- private readonly aiService: AIService
3059
-
3060
- constructor(deps: Dependencies, config?: DualModeControllerConfig) {
3061
- super(deps, config)
3062
- this.aiService = deps.aiService
3063
- }
3064
-
3065
- public buildDualModeRoutes(): BuildFastifyDualModeRoutesReturnType<Contracts> {
3066
- return {
3067
- chatCompletion: this.handleChatCompletion,
3068
- }
3069
- }
3070
-
3071
- // Handler with options as third parameter
3072
- private handleChatCompletion = buildHandler(chatCompletionContract, {
3073
- // Sync mode - return complete response
3074
- sync: async (request, _reply) => {
3075
- const result = await this.aiService.complete(request.body.message)
3076
- return {
3077
- reply: result.text,
3078
- usage: { tokens: result.tokenCount },
3079
- }
3080
- },
3081
- // SSE mode - stream response chunks
3082
- sse: async (request, sse) => {
3083
- const session = sse.start('autoClose')
3084
- let totalTokens = 0
3085
- for await (const chunk of this.aiService.stream(request.body.message)) {
3086
- await session.send('chunk', { delta: chunk.text })
3087
- totalTokens += chunk.tokenCount ?? 0
3088
- }
3089
- await session.send('done', { usage: { total: totalTokens } })
3090
- // Connection closes automatically when handler returns
3091
- },
3092
- }, {
3093
- // Optional: set SSE as default mode (instead of sync)
3094
- defaultMode: 'sse',
3095
- // Optional: route-level authentication
3096
- preHandler: (request, reply) => {
3097
- if (!request.headers.authorization) {
3098
- return Promise.resolve(reply.code(401).send({ error: 'Unauthorized' }))
3099
- }
3100
- },
3101
- // Optional: SSE lifecycle hooks
3102
- onConnect: (session) => console.log('Client connected:', session.id),
3103
- onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
3104
- // Optional: attach behavior driven by contract metadata
3105
- contractMetadataToRouteMapper: (metadata) => ({
3106
- config: { rateLimit: metadata.rateLimit },
3107
- onRequest: metadata.requiresAuth ? authHook : undefined,
3108
- }),
3109
- })
3110
- }
3111
- ```
3112
-
3113
- **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
3114
2008
 
3115
- | Mode | Signature |
3116
- | ---- | --------- |
3117
- | `sync` | `(request, reply) => Response` |
3118
- | `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')
3119
2011
 
3120
- 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
+ ```
3121
2018
 
3122
- ### Registering Dual-Mode Controllers
2019
+ **`events(signal?)`**
3123
2020
 
3124
- Use `asDualModeControllerClass` in your module:
2021
+ Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
3125
2022
 
3126
2023
  ```ts
3127
- import {
3128
- AbstractModule,
3129
- type InferModuleDependencies,
3130
- asControllerClass,
3131
- asDualModeControllerClass,
3132
- asServiceClass,
3133
- } 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
+ }
3134
2029
 
3135
- export class ChatModule extends AbstractModule {
3136
- resolveDependencies() {
3137
- return {
3138
- aiService: asServiceClass(AIService),
3139
- }
3140
- }
2030
+ // With abort signal for timeout control
2031
+ const controller = new AbortController()
2032
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
3141
2033
 
3142
- resolveControllers(diOptions: DependencyInjectionOptions) {
3143
- return {
3144
- // REST controller
3145
- usersController: asControllerClass(UsersController),
3146
- // Dual-mode controller (auto-detected via isDualModeController flag)
3147
- chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
3148
- // Dual-mode controller with rooms enabled
3149
- dashboardController: asDualModeControllerClass(DashboardController, { diOptions, rooms: true }),
3150
- }
2034
+ try {
2035
+ for await (const event of client.events(controller.signal)) {
2036
+ console.log(event)
3151
2037
  }
2038
+ } finally {
2039
+ clearTimeout(timeoutId)
3152
2040
  }
3153
-
3154
- export type ChatModuleDependencies = InferModuleDependencies<ChatModule>
3155
2041
  ```
3156
2042
 
3157
- Register dual-mode routes after the `@fastify/sse` plugin:
3158
-
3159
- ```ts
3160
- const app = fastify()
3161
- app.setValidatorCompiler(validatorCompiler)
3162
- app.setSerializerCompiler(serializerCompiler)
3163
-
3164
- // Register @fastify/sse plugin
3165
- await app.register(FastifySSEPlugin)
2043
+ **When to omit `awaitServerConnection`**
3166
2044
 
3167
- // Register routes
3168
- context.registerRoutes(app) // REST routes
3169
- context.registerSSERoutes(app) // SSE-only routes
3170
- 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
3171
2050
 
3172
- // Check if controllers exist before registration (optional)
3173
- if (context.hasDualModeControllers()) {
3174
- context.registerDualModeRoutes(app)
3175
- }
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.
3176
2052
 
3177
- 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()
3178
2059
  ```
3179
2060
 
3180
- ### Accept Header Routing
3181
-
3182
- Dual-mode routes are registered with @fastify/sse kind `'manual'`, so the
3183
- framework's `determineMode()` is the single Accept negotiator: q-value aware,
3184
- `defaultMode` applies for `*/*` or a missing `Accept` header (including
3185
- `defaultMode: 'sse'`). SSE-only routes use kind `'only'` — a missing header or
3186
- `*/*` streams, and a client that explicitly refuses `text/event-stream`
3187
- (e.g. `Accept: application/json`) receives a clean 406.
2061
+ **POST/PUT/PATCH endpoints**
3188
2062
 
3189
- 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:
3190
2064
 
3191
- ```bash
3192
- # JSON mode (complete response)
3193
- curl -X POST http://localhost:3000/api/chats/123/completions \
3194
- -H "Content-Type: application/json" \
3195
- -H "Accept: application/json" \
3196
- -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
+ })
3197
2070
 
3198
- # SSE mode (streaming response)
3199
- curl -X POST http://localhost:3000/api/chats/123/completions \
3200
- -H "Content-Type: application/json" \
3201
- -H "Accept: text/event-stream" \
3202
- -d '{"message": "Hello world"}'
2071
+ const events = await client.collectEvents((event) => event.event === 'done')
3203
2072
  ```
3204
2073
 
3205
- **Quality values** are supported for content negotiation:
3206
-
3207
- ```bash
3208
- # Prefer JSON (higher quality value)
3209
- 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:
3210
2075
 
3211
- # Prefer SSE (higher quality value)
3212
- 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
+ })
3213
2082
  ```
3214
2083
 
3215
- **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**
3216
2087
 
3217
- ```bash
3218
- # Accept any text format (matches text/plain, text/csv, etc.)
3219
- 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
+ })
3220
2096
 
3221
- # Accept any application format (matches application/json, application/xml, etc.)
3222
- 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')
3223
2100
 
3224
- # Combine with quality values
3225
- 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')
3226
2103
  ```
3227
2104
 
3228
- 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:
3229
2106
 
3230
- ### 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
+ })
3231
2112
 
3232
- 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
+ ```
3233
2117
 
3234
- | SSE Mode | Test Client | Why |
3235
- |----------|-------------|-----|
3236
- | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes the connection, so all events are available at once via inject |
3237
- | `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`.
3238
2119
 
3239
- #### Testing autoClose dual-mode (request-response streaming)
2120
+ #### SSEInjectClient
3240
2121
 
3241
- 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:
3242
2123
 
3243
2124
  ```ts
3244
2125
  import { SSEInjectClient } from 'opinionated-machine'
3245
2126
 
3246
- describe('ChatDualModeController', () => {
3247
- let app: AppInstance
3248
- let injectClient: SSEInjectClient
3249
-
3250
- beforeAll(async () => {
3251
- app = await getApp({ /* your test config */ })
3252
- injectClient = new SSEInjectClient(app)
3253
- })
3254
-
3255
- afterAll(async () => {
3256
- await app.diContainer.dispose()
3257
- })
2127
+ const client = new SSEInjectClient(app) // No server.listen() needed
3258
2128
 
3259
- it('returns sync response for Accept: application/json', async () => {
3260
- const response = await app.inject({
3261
- method: 'POST',
3262
- url: '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
3263
- headers: {
3264
- 'content-type': 'application/json',
3265
- accept: 'application/json',
3266
- authorization: 'Bearer token',
3267
- },
3268
- payload: { message: 'Hello' },
3269
- })
2129
+ // GET request
2130
+ const conn = await client.connect('/api/export/progress', {
2131
+ headers: { authorization: 'Bearer token' },
2132
+ })
3270
2133
 
3271
- expect(response.statusCode).toBe(200)
3272
- 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
+ )
3273
2139
 
3274
- const body = JSON.parse(response.body)
3275
- expect(body).toHaveProperty('reply')
3276
- expect(body).toHaveProperty('usage')
3277
- })
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
+ )
3278
2147
 
3279
- it('streams SSE for Accept: text/event-stream', async () => {
3280
- const conn = await injectClient.connectWithBody(
3281
- '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
3282
- { message: 'Hello' },
3283
- { headers: { authorization: 'Bearer token' } },
3284
- )
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
+ ```
3285
2153
 
3286
- expect(conn.getStatusCode()).toBe(200)
3287
- 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:
3288
2158
 
3289
- const events = conn.getReceivedEvents()
3290
- const chunks = events.filter((e) => e.event === 'chunk')
3291
- const doneEvents = events.filter((e) => e.event === 'done')
2159
+ ```ts
2160
+ const conn = await client.connect('/api/export/progress')
3292
2161
 
3293
- expect(chunks.length).toBeGreaterThan(0)
3294
- expect(doneEvents).toHaveLength(1)
3295
- })
2162
+ expect(conn.getStatusCode()).toBe(503)
2163
+ expect(conn.json<{ errorCode: string }>()).toMatchObject({
2164
+ errorCode: 'INTEGRATION_NOT_AVAILABLE',
3296
2165
  })
3297
2166
  ```
3298
2167
 
3299
- #### 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.
3300
2172
 
3301
- 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
3302
2174
 
3303
- ```ts
3304
- 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).
3305
2176
 
3306
- describe('DashboardDualModeController', () => {
3307
- let app: AppInstance
3308
- let server: SSETestServer
3309
- let controller: DashboardController
2177
+ #### Contract-Aware HTTP Helpers
3310
2178
 
3311
- beforeAll(async () => {
3312
- app = await getApp({ /* your test config */ })
3313
- 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.
3314
2180
 
3315
- // SSETestServer.start() takes your pre-configured app and starts it on a random port
3316
- server = await SSETestServer.start(app)
3317
- })
2181
+ ```ts
2182
+ import { connectApiSSE, SSETestServer } from 'opinionated-machine'
3318
2183
 
3319
- afterAll(async () => {
3320
- await app.diContainer.dispose()
3321
- await server.close()
3322
- })
2184
+ const server = await SSETestServer.start(app)
3323
2185
 
3324
- // Sync mode works the same as autoClose — use Fastify inject
3325
- it('returns JSON for sync requests', async () => {
3326
- const response = await app.inject({
3327
- method: 'get',
3328
- url: '/api/dashboard/updates',
3329
- headers: { accept: 'application/json' },
3330
- })
3331
- expect(response.statusCode).toBe(200)
3332
- expect(response.headers['content-type']).toContain('application/json')
3333
- })
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
+ })
3334
2190
 
3335
- // keepAlive SSE requires SSEHttpClient with awaitServerConnection
3336
- it('receives server-pushed events over keepAlive SSE', async () => {
3337
- // 1. Connect with awaitServerConnection to eliminate race condition
3338
- const { client, serverConnection } = await SSEHttpClient.connect(
3339
- server.baseUrl,
3340
- '/api/dashboard/updates',
3341
- { awaitServerConnection: { controller } },
3342
- )
2191
+ expect(client.response.status).toBe(200) // asserted while the handler is still working
3343
2192
 
3344
- // 2. Start collecting events BEFORE pushing (they arrive asynchronously)
3345
- 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
+ }
3346
2197
 
3347
- // 3. Push events from the server side
3348
- await controller.pushUpdate(serverConnection.id, {
3349
- event: 'update',
3350
- data: { type: 'metric', value: 42 },
3351
- })
3352
- await controller.pushUpdate(serverConnection.id, {
3353
- event: 'update',
3354
- data: { type: 'alert', value: 100 },
3355
- })
2198
+ client.close()
2199
+ await server.close()
2200
+ ```
3356
2201
 
3357
- // 4. Await collected events
3358
- const events = await eventsPromise
3359
- expect(events).toHaveLength(2)
3360
- 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 }`.
3361
2207
 
3362
- // 5. Always close the client to release the connection
3363
- client.close()
3364
- })
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`.
3365
2209
 
3366
- // keepAlive SSE + rooms
3367
- it('receives room broadcasts over keepAlive SSE', async () => {
3368
- const { client } = await SSEHttpClient.connect(
3369
- server.baseUrl,
3370
- '/api/dashboard/updates',
3371
- {
3372
- query: { dashboardId: 'dash-1' },
3373
- awaitServerConnection: { controller },
3374
- },
3375
- )
2210
+ #### When a Handler Fails to Send an Event
3376
2211
 
3377
- // Broadcast to the room
3378
- const eventsPromise = client.collectEvents(1)
3379
- await controller.broadcastToRoom('dashboard:dash-1', 'update', {
3380
- type: 'room-update',
3381
- value: 99,
3382
- })
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.
3383
2213
 
3384
- const events = await eventsPromise
3385
- 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:
3386
2215
 
3387
- client.close()
3388
- })
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
+ ```
3389
2220
 
3390
- // Sync and SSE can coexist concurrently
3391
- it('sync requests work while keepAlive SSE connections are active', async () => {
3392
- // Establish keepAlive SSE connection
3393
- const { client: sseClient } = await SSEHttpClient.connect(
3394
- server.baseUrl,
3395
- '/api/dashboard/updates',
3396
- { awaitServerConnection: { controller } },
3397
- )
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.
3398
2222
 
3399
- // Sync request works while SSE is connected
3400
- const response = await app.inject({
3401
- method: 'get',
3402
- url: '/api/dashboard/updates',
3403
- headers: { accept: 'application/json' },
3404
- })
3405
- 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:
3406
2224
 
3407
- sseClient.close()
3408
- })
3409
- })
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.
3410
2234
 
3411
2235
  ## Gateway Configuration
3412
2236
 
@@ -3432,32 +2256,33 @@ A complete round-trip in two steps. First, annotate routes in your existing
3432
2256
  controller:
3433
2257
 
3434
2258
  ```ts
3435
- import { buildRestContract } from '@lokalise/api-contracts'
3436
- import { buildFastifyRoute } from '@lokalise/fastify-api-contracts'
2259
+ import { defineApiContract } from '@lokalise/api-contracts'
2260
+ import type { RouteOptions } from 'fastify'
3437
2261
  import {
3438
- AbstractController,
3439
- type BuildRoutesReturnType,
2262
+ AbstractApiController,
2263
+ buildApiRoute,
3440
2264
  type GatewayMetadataValue,
3441
- withGatewayMetadata,
3442
2265
  } from 'opinionated-machine'
3443
2266
  import { z } from 'zod/v4'
3444
2267
 
3445
- const getUser = buildRestContract({
2268
+ const getUser = defineApiContract({
3446
2269
  visibility: 'public',
3447
2270
  method: 'get',
3448
- successResponseBodySchema: z.object({ id: z.string() }),
2271
+ summary: 'Get user',
3449
2272
  requestPathParamsSchema: z.object({ userId: z.string() }),
3450
- pathResolver: (p) => `/users/${p.userId}`,
2273
+ pathResolver: ({ userId }) => `/users/${userId}`,
2274
+ responsesByStatusCode: { 200: z.object({ id: z.string() }) },
3451
2275
  })
3452
- const createUser = buildRestContract({
2276
+ const createUser = defineApiContract({
3453
2277
  visibility: 'public',
3454
2278
  method: 'post',
2279
+ summary: 'Create user',
3455
2280
  requestBodySchema: z.object({ name: z.string() }),
3456
- successResponseBodySchema: z.object({ id: z.string() }),
3457
2281
  pathResolver: () => '/users',
2282
+ responsesByStatusCode: { 201: z.object({ id: z.string() }) },
3458
2283
  })
3459
2284
 
3460
- export class UsersController extends AbstractController<typeof UsersController.contracts> {
2285
+ export class UsersController extends AbstractApiController<typeof UsersController.contracts> {
3461
2286
  static readonly contracts = { getUser, createUser } as const
3462
2287
 
3463
2288
  // Applies to every route in this controller; routes can override.
@@ -3467,18 +2292,13 @@ export class UsersController extends AbstractController<typeof UsersController.c
3467
2292
  auth: { required: true },
3468
2293
  }
3469
2294
 
3470
- private getUser = buildFastifyRoute(UsersController.contracts.getUser, async (req, reply) => { /* … */ })
3471
- private createUser = buildFastifyRoute(UsersController.contracts.createUser, async (req, reply) => { /* … */ })
3472
-
3473
- buildRoutes(): BuildRoutesReturnType<typeof UsersController.contracts> {
3474
- return {
3475
- getUser: withGatewayMetadata(UsersController.contracts.getUser, this.getUser, {
3476
- cache: { ttl: '60s' },
3477
- }),
3478
- createUser: withGatewayMetadata(UsersController.contracts.createUser, this.createUser, {
3479
- rateLimit: { requests: 10, per: '1m', key: 'ip' },
3480
- }),
3481
- }
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
+ }),
3482
2302
  }
3483
2303
  }
3484
2304
  ```
@@ -3521,8 +2341,7 @@ controller style — both validate the metadata at the call site, both stamp
3521
2341
  the same hidden symbol on the route, and both are read identically by the
3522
2342
  manifest builder.
3523
2343
 
3524
- **For `buildApiRoute`-built routes** (`AbstractApiController`), pass
3525
- `gatewayMetadata` inline via the options argument:
2344
+ Pass `gatewayMetadata` inline via the `buildApiRoute` options argument:
3526
2345
 
3527
2346
  ```ts
3528
2347
  class UsersApiController extends AbstractApiController<typeof UsersApiController.contracts> {
@@ -3541,17 +2360,18 @@ class UsersApiController extends AbstractApiController<typeof UsersApiController
3541
2360
  }
3542
2361
  ```
3543
2362
 
3544
- **For `buildFastifyRoute`-built routes** (`AbstractController`), or when you
3545
- prefer to keep all gateway annotations in one scannable block separate from
3546
- 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)`:
3547
2365
 
3548
2366
  ```ts
3549
- buildRoutes() {
3550
- return {
3551
- getUser: withGatewayMetadata(c.getUser, this.getUser, { cache: { ttl: '60s' } }),
3552
- createUser: withGatewayMetadata(c.createUser, this.createUser, { rateLimit: { requests: 10, per: '1m', key: 'ip' } }),
3553
- deleteUser: this.deleteUser, // no per-route policy; inherits defaults
3554
- }
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
3555
2375
  }
3556
2376
  ```
3557
2377
 
@@ -3599,16 +2419,17 @@ context.buildGatewayManifest({
3599
2419
  become compile errors before you ever ship a config:
3600
2420
 
3601
2421
  ```ts
3602
- const getUser = buildRestContract({
2422
+ const getUser = defineApiContract({
3603
2423
  visibility: 'public',
3604
2424
  method: 'get',
3605
- successResponseBodySchema: ResponseBody,
2425
+ summary: 'Get user',
3606
2426
  requestHeaderSchema: z.object({ 'x-trace-id': z.string() }),
3607
2427
  requestPathParamsSchema: z.object({ userId: z.string() }),
3608
- pathResolver: (p) => `/users/${p.userId}`,
2428
+ pathResolver: ({ userId }) => `/users/${userId}`,
2429
+ responsesByStatusCode: { 200: ResponseBody },
3609
2430
  })
3610
2431
 
3611
- withGatewayMetadata(getUser, this.getUser, {
2432
+ withGatewayMetadata(getUser, buildApiRoute(getUser, this.getUser), {
3612
2433
  match: {
3613
2434
  headers: {
3614
2435
  'x-trace-id': { regex: '^[a-f0-9]+$' }, // ✅ type-checked against the contract
@@ -3744,9 +2565,10 @@ stamped with a streaming mode, and the manifest carries it as
3744
2565
  `streaming: 'sse' | 'dual'`.
3745
2566
 
3746
2567
  The marker describes the **success path**. An error status answers with a JSON
3747
- body on a streaming route too (including the early-return `sse.respond(404,
3748
- ...)` path), so generators size timeouts and buffering from it but must not
3749
- 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.
3750
2572
 
3751
2573
  - **Envoy** — streaming routes default to `timeout: 0s` and `idle_timeout: 0s`
3752
2574
  (declare `timeouts.idle` to reinstate a liveness bound; heartbeats are the
@@ -3770,14 +2592,16 @@ assume the content type of a failure.
3770
2592
  `<id>`, the catch-all. The declared timeouts are split between them rather
3771
2593
  than applied to both — `timeouts.idle` goes to the stream branch,
3772
2594
  `timeouts.request` to the JSON branch, which is the fallback poll path and
3773
- the one that most needs a bound. The split keys off the `Accept` header, the
3774
- same predicate `determineMode()` uses server-side, quality values included:
3775
- `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.
3776
2598
 
3777
- A route declaring `defaultMode: 'sse'` inverts the split, because there the
3778
- 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
3779
2601
  carries the fallback branch as `streamingDefaultMode` (`'non-sse' | 'sse'`,
3780
- 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
3781
2605
  stream the catch-all with `<id>__json` as the narrow branch, so an
3782
2606
  unspecific request cannot land on the JSON branch's request timeout while the
3783
2607
  server is streaming. A request listing both media types resolves to JSON on
@@ -3797,16 +2621,8 @@ assume the content type of a failure.
3797
2621
  `timeouts.idle`; streaming routes with neither warn about KrakenD's 2s
3798
2622
  default endpoint timeout.
3799
2623
 
3800
- Routes declared through `AbstractApiController` are always included in the
3801
- manifest. Legacy `AbstractSSEController` / `AbstractDualModeController` routes
3802
- are included when you opt in:
3803
-
3804
- ```ts
3805
- const manifest = context.buildGatewayManifest({
3806
- service: 'users-api',
3807
- includeStreamingControllers: true, // default false — existing manifests don't silently grow
3808
- })
3809
- ```
2624
+ Every route of every registered controller is included in the manifest,
2625
+ streaming routes included.
3810
2626
 
3811
2627
  ### What's Not Covered
3812
2628
 
@@ -3856,27 +2672,36 @@ and the transport interface.
3856
2672
 
3857
2673
  One dual-mode `AbstractApiController` route serves both channels — the sync
3858
2674
  branch answers the fallback polls, the SSE branch joins a room that the domain
3859
- service broadcasts into:
3860
-
3861
- ```ts
3862
- readonly routes = {
3863
- jobStatus: buildApiRoute(
3864
- jobStatusContract,
3865
- (request, _reply, { expectedContentType, sse }) => {
3866
- // The push channel: join the job's room and stay open
3867
- if (expectedContentType === 'text/event-stream') {
3868
- const session = sse.start('keepAlive')
3869
- getSessionRooms(session).join(`job:${request.params.jobId}`)
3870
- return
3871
- }
3872
- // The fallback poll: return the current snapshot with its version
3873
- return { status: 200, body: this.jobs.get(request.params.jobId) }
3874
- },
3875
- {
3876
- // enables room membership + broadcast delivery for this route's sessions
3877
- sseRooms: this.sseRoomBroadcaster,
3878
- },
3879
- ),
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
+ }
3880
2705
  }
3881
2706
  ```
3882
2707
 
@@ -4040,3 +2865,4 @@ dependencies are already built, so `pnpm run build` on its own fails until the g
4040
2865
  built once.
4041
2866
 
4042
2867
  See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full task table and caching notes.
2868
+