opinionated-machine 6.8.0 → 6.8.2

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.
package/README.md CHANGED
@@ -1,1887 +1,1891 @@
1
- # opinionated-machine
2
- Very opinionated DI framework for fastify, built on top of awilix
3
-
4
- ## Table of Contents
5
-
6
- - [Basic usage](#basic-usage)
7
- - [Defining controllers](#defining-controllers)
8
- - [Putting it all together](#putting-it-all-together)
9
- - [Resolver Functions](#resolver-functions)
10
- - [Basic Resolvers](#basic-resolvers)
11
- - [`asSingletonClass`](#assingletonclasstype-opts)
12
- - [`asSingletonFunction`](#assingletonfunctionfn-opts)
13
- - [`asClassWithConfig`](#asclasswithconfigtype-config-opts)
14
- - [Domain Layer Resolvers](#domain-layer-resolvers)
15
- - [`asServiceClass`](#asserviceclasstype-opts)
16
- - [`asUseCaseClass`](#asusecaseclasstype-opts)
17
- - [`asRepositoryClass`](#asrepositoryclasstype-opts)
18
- - [`asControllerClass`](#ascontrollerclasstype-opts)
19
- - [`asSSEControllerClass`](#asssecontrollerclasstype-sseoptions-opts)
20
- - [`asDualModeControllerClass`](#asdualmodecontrollerclasstype-sseoptions-opts)
21
- - [Message Queue Resolvers](#message-queue-resolvers)
22
- - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
23
- - [Background Job Resolvers](#background-job-resolvers)
24
- - [`asEnqueuedJobWorkerClass`](#asenqueuedjobworkerclasstype-workeroptions-opts)
25
- - [`asPgBossProcessorClass`](#aspgbossprocessorclasstype-processoroptions-opts)
26
- - [`asPeriodicJobClass`](#asperiodicjobclasstype-workeroptions-opts)
27
- - [`asJobQueueClass`](#asjobqueueclasstype-queueoptions-opts)
28
- - [`asEnqueuedJobQueueManagerFunction`](#asenqueuedjobqueuemanagerfunctionfn-dioptions-opts)
29
- - [Server-Sent Events (SSE)](#server-sent-events-sse)
30
- - [Prerequisites](#prerequisites)
31
- - [Defining SSE Contracts](#defining-sse-contracts)
32
- - [Creating SSE Controllers](#creating-sse-controllers)
33
- - [Type-Safe SSE Handlers with buildHandler](#type-safe-sse-handlers-with-buildhandler)
34
- - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
35
- - [Registering SSE Controllers](#registering-sse-controllers)
36
- - [Registering SSE Routes](#registering-sse-routes)
37
- - [Broadcasting Events](#broadcasting-events)
38
- - [Controller-Level Hooks](#controller-level-hooks)
39
- - [Route-Level Options](#route-level-options)
40
- - [Graceful Shutdown](#graceful-shutdown)
41
- - [Error Handling](#error-handling)
42
- - [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
43
- - [SSE Parsing Utilities](#sse-parsing-utilities)
44
- - [parseSSEEvents](#parsesseevents)
45
- - [parseSSEBuffer](#parsessebuffer)
46
- - [ParsedSSEEvent Type](#parsedsseevent-type)
47
- - [Testing SSE Controllers](#testing-sse-controllers)
48
- - [SSESessionSpy API](#ssesessionspy-api)
49
- - [Session Monitoring](#session-monitoring)
50
- - [SSE Test Utilities](#sse-test-utilities)
51
- - [Quick Reference](#quick-reference)
52
- - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
53
- - [SSETestServer](#ssetestserver)
54
- - [SSEHttpClient](#ssehttpclient)
55
- - [SSEInjectClient](#sseinjectclient)
56
- - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
57
- - [Dual-Mode Controllers (SSE + Sync)](#dual-mode-controllers-sse--sync)
58
- - [Overview](#overview)
59
- - [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
60
- - [Response Headers (Sync Mode)](#response-headers-sync-mode)
61
- - [Status-Specific Response Schemas (responseSchemasByStatusCode)](#status-specific-response-schemas-responseschemasbystatuscode)
62
- - [Implementing Dual-Mode Controllers](#implementing-dual-mode-controllers)
63
- - [Registering Dual-Mode Controllers](#registering-dual-mode-controllers)
64
- - [Accept Header Routing](#accept-header-routing)
65
- - [Testing Dual-Mode Controllers](#testing-dual-mode-controllers)
66
-
67
- ## Basic usage
68
-
69
- Define a module, or several modules, that will be used for resolving dependency graphs, using awilix:
70
-
71
- ```ts
72
- import { AbstractModule, asSingletonClass, asMessageQueueHandlerClass, asJobWorkerClass, asJobQueueClass, asControllerClass } from 'opinionated-machine'
73
-
74
- export type ModuleDependencies = {
75
- service: Service
76
- messageQueueConsumer: MessageQueueConsumer
77
- jobWorker: JobWorker
78
- queueManager: QueueManager
79
- }
80
-
81
- export class MyModule extends AbstractModule<ModuleDependencies, ExternalDependencies> {
82
- resolveDependencies(
83
- diOptions: DependencyInjectionOptions,
84
- _externalDependencies: ExternalDependencies,
85
- ): MandatoryNameAndRegistrationPair<ModuleDependencies> {
86
- return {
87
- service: asSingletonClass(Service),
88
-
89
- // by default init and disposal methods from `message-queue-toolkit` consumers
90
- // will be assumed. If different values are necessary, pass second config object
91
- // and specify "asyncInit" and "asyncDispose" fields
92
- messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
93
- queueName: MessageQueueConsumer.QUEUE_ID,
94
- diOptions,
95
- }),
96
-
97
- // by default init and disposal methods from `background-jobs-commons` job workers
98
- // will be assumed. If different values are necessary, pass second config object
99
- // and specify "asyncInit" and "asyncDispose" fields
100
- jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
101
- queueName: JobWorker.QUEUE_ID,
102
- diOptions,
103
- }),
104
-
105
- // by default disposal methods from `background-jobs-commons` job queue manager
106
- // will be assumed. If different values are necessary, specify "asyncDispose" fields
107
- // in the second config object
108
- queueManager: asJobQueueClass(
109
- QueueManager,
110
- {
111
- diOptions,
112
- },
113
- {
114
- asyncInit: (manager) => manager.start(resolveJobQueuesEnabled(options)),
115
- },
116
- ),
117
- }
118
- }
119
-
120
- // controllers will be automatically registered on fastify app
121
- // both REST and SSE controllers go here - SSE controllers are auto-detected
122
- resolveControllers(diOptions: DependencyInjectionOptions) {
123
- return {
124
- controller: asControllerClass(MyController),
125
- }
126
- }
127
- }
128
- ```
129
-
130
- ## Defining controllers
131
-
132
- Controllers require using fastify-api-contracts and allow to define application routes.
133
-
134
- ```ts
135
- import { buildFastifyNoPayloadRoute } from '@lokalise/fastify-api-contracts'
136
- import { buildDeleteRoute } from '@lokalise/universal-ts-utils/api-contracts/apiContracts'
137
- import { z } from 'zod/v4'
138
- import { AbstractController } from 'opinionated-machine'
139
-
140
- const BODY_SCHEMA = z.object({})
141
- const PATH_PARAMS_SCHEMA = z.object({
142
- userId: z.string(),
143
- })
144
-
145
- const contract = buildDeleteRoute({
146
- successResponseBodySchema: BODY_SCHEMA,
147
- requestPathParamsSchema: PATH_PARAMS_SCHEMA,
148
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
149
- })
150
-
151
- export class MyController extends AbstractController<typeof MyController.contracts> {
152
- public static contracts = { deleteItem: contract } as const
153
- private readonly service: Service
154
-
155
- constructor({ service }: ModuleDependencies) {
156
- super()
157
- this.service = testService
158
- }
159
-
160
- private deleteItem = buildFastifyNoPayloadRoute(
161
- TestController.contracts.deleteItem,
162
- async (req, reply) => {
163
- req.log.info(req.params.userId)
164
- this.service.execute()
165
- await reply.status(204).send()
166
- },
167
- )
168
-
169
- public buildRoutes() {
170
- return {
171
- deleteItem: this.deleteItem,
172
- }
173
- }
174
- }
175
- ```
176
-
177
- ## Putting it all together
178
-
179
- Typical usage with a fastify app looks like this:
180
-
181
- ```ts
182
- import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
183
- import { createContainer } from 'awilix'
184
- import { fastify } from 'fastify'
185
- import { DIContext } from 'opinionated-machine'
186
-
187
- const module = new MyModule()
188
- const container = createContainer({
189
- injectionMode: 'PROXY',
190
- })
191
-
192
- type AppConfig = {
193
- DATABASE_URL: string
194
- // ...
195
- // everything related to app configuration
196
- }
197
-
198
- type ExternalDependencies = {
199
- logger: Logger // most likely you would like to reuse logger instance from fastify app
200
- }
201
-
202
- const context = new DIContext<ModuleDependencies, AppConfig, ExternalDependencies>(container, {
203
- messageQueueConsumersEnabled: [MessageQueueConsumer.QUEUE_ID],
204
- jobQueuesEnabled: false,
205
- jobWorkersEnabled: false,
206
- periodicJobsEnabled: false,
207
- })
208
-
209
- context.registerDependencies({
210
- modules: [module],
211
- dependencyOverrides: {}, // dependency overrides if necessary, usually for testing purposes
212
- configOverrides: {}, // config overrides if necessary, will be merged with value inside existing config
213
- configDependencyId?: string // what is the dependency id in the graph for the config entity. Only used for config overrides. Default value is `config`
214
- },
215
- // external dependencies that are instantiated outside of DI
216
- {
217
- logger: app.logger
218
- })
219
-
220
- const app = fastify()
221
- app.setValidatorCompiler(validatorCompiler)
222
- app.setSerializerCompiler(serializerCompiler)
223
-
224
- app.after(() => {
225
- context.registerRoutes(app)
226
- })
227
- await app.ready()
228
- ```
229
-
230
- ## Resolver Functions
231
-
232
- 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.
233
-
234
- ### Basic Resolvers
235
-
236
- #### `asSingletonClass(Type, opts?)`
237
- Basic singleton class resolver. Use for general-purpose dependencies that don't fit other categories.
238
-
239
- ```ts
240
- service: asSingletonClass(MyService)
241
- ```
242
-
243
- #### `asSingletonFunction(fn, opts?)`
244
- Basic singleton function resolver. Use when you need to resolve a dependency using a factory function.
245
-
246
- ```ts
247
- config: asSingletonFunction(() => loadConfig())
248
- ```
249
-
250
- #### `asClassWithConfig(Type, config, opts?)`
251
- Register a class with an additional config parameter passed to the constructor. Uses `asFunction` wrapper internally to pass the config as a second parameter. Requires PROXY injection mode.
252
-
253
- ```ts
254
- myService: asClassWithConfig(MyService, { enableFeature: true })
255
- ```
256
-
257
- The class constructor receives dependencies as the first parameter and config as the second:
258
-
259
- ```ts
260
- class MyService {
261
- constructor(deps: Dependencies, config: { enableFeature: boolean }) {
262
- // ...
263
- }
264
- }
265
- ```
266
-
267
- ### Domain Layer Resolvers
268
-
269
- #### `asServiceClass(Type, opts?)`
270
- For service classes. Marks the dependency as **public** (exposed when module is used as secondary).
271
-
272
- ```ts
273
- userService: asServiceClass(UserService)
274
- ```
275
-
276
- #### `asUseCaseClass(Type, opts?)`
277
- For use case classes. Marks the dependency as **public**.
278
-
279
- ```ts
280
- createUserUseCase: asUseCaseClass(CreateUserUseCase)
281
- ```
282
-
283
- #### `asRepositoryClass(Type, opts?)`
284
- For repository classes. Marks the dependency as **private** (not exposed when module is secondary).
285
-
286
- ```ts
287
- userRepository: asRepositoryClass(UserRepository)
288
- ```
289
-
290
- #### `asControllerClass(Type, opts?)`
291
- For REST controller classes. Marks the dependency as **private**. Use in `resolveControllers()`.
292
-
293
- ```ts
294
- userController: asControllerClass(UserController)
295
- ```
296
-
297
- #### `asSSEControllerClass(Type, sseOptions?, opts?)`
298
- 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.
299
-
300
- ```ts
301
- // In resolveControllers()
302
- resolveControllers(diOptions: DependencyInjectionOptions) {
303
- return {
304
- userController: asControllerClass(UserController),
305
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
306
- }
307
- }
308
- ```
309
-
310
- #### `asDualModeControllerClass(Type, sseOptions?, opts?)`
311
- 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.
312
-
313
- ```ts
314
- // In resolveControllers()
315
- resolveControllers(diOptions: DependencyInjectionOptions) {
316
- return {
317
- userController: asControllerClass(UserController),
318
- chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
319
- }
320
- }
321
- ```
322
-
323
- ### Message Queue Resolvers
324
-
325
- #### `asMessageQueueHandlerClass(Type, mqOptions, opts?)`
326
- For message queue consumers following `message-queue-toolkit` conventions. Automatically handles `start`/`close` lifecycle and respects `messageQueueConsumersEnabled` option.
327
-
328
- ```ts
329
- messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
330
- queueName: MessageQueueConsumer.QUEUE_ID,
331
- diOptions,
332
- })
333
- ```
334
-
335
- ### Background Job Resolvers
336
-
337
- #### `asEnqueuedJobWorkerClass(Type, workerOptions, opts?)`
338
- For enqueued job workers following `background-jobs-common` conventions. Automatically handles `start`/`dispose` lifecycle and respects `enqueuedJobWorkersEnabled` option.
339
-
340
- ```ts
341
- jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
342
- queueName: JobWorker.QUEUE_ID,
343
- diOptions,
344
- })
345
- ```
346
-
347
- #### `asPgBossProcessorClass(Type, processorOptions, opts?)`
348
- For pg-boss job processor classes. Similar to `asEnqueuedJobWorkerClass` but uses `start`/`stop` lifecycle methods and initializes after pgBoss (priority 20).
349
-
350
- ```ts
351
- enrichUserPresenceJob: asPgBossProcessorClass(EnrichUserPresenceJob, {
352
- queueName: EnrichUserPresenceJob.QUEUE_ID,
353
- diOptions,
354
- })
355
- ```
356
-
357
- #### `asPeriodicJobClass(Type, workerOptions, opts?)`
358
- For periodic job classes following `background-jobs-common` conventions. Uses eager injection via `register` method and respects `periodicJobsEnabled` option.
359
-
360
- ```ts
361
- cleanupJob: asPeriodicJobClass(CleanupJob, {
362
- jobName: CleanupJob.JOB_NAME,
363
- diOptions,
364
- })
365
- ```
366
-
367
- #### `asJobQueueClass(Type, queueOptions, opts?)`
368
- For job queue classes. Marks the dependency as **public**. Respects `jobQueuesEnabled` option.
369
-
370
- ```ts
371
- queueManager: asJobQueueClass(QueueManager, {
372
- diOptions,
373
- })
374
- ```
375
-
376
- #### `asEnqueuedJobQueueManagerFunction(fn, diOptions, opts?)`
377
- For job queue manager factory functions. Automatically calls `start()` with resolved enabled queues during initialization.
378
-
379
- ```ts
380
- jobQueueManager: asEnqueuedJobQueueManagerFunction(
381
- createJobQueueManager,
382
- diOptions,
383
- )
384
- ```
385
-
386
- ## Server-Sent Events (SSE)
387
-
388
- 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).
389
-
390
- ### Prerequisites
391
-
392
- Register the `@fastify/sse` plugin before using SSE controllers:
393
-
394
- ```ts
395
- import FastifySSEPlugin from '@fastify/sse'
396
-
397
- const app = fastify()
398
- await app.register(FastifySSEPlugin)
399
- ```
400
-
401
- ### Defining SSE Contracts
402
-
403
- Use `buildSseContract` from `@lokalise/api-contracts` to define SSE routes. The contract type is automatically determined based on the presence of `requestBody` and `syncResponseBody` fields. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
404
-
405
- ```ts
406
- import { z } from 'zod'
407
- import { buildSseContract } from '@lokalise/api-contracts'
408
-
409
- // GET-based SSE stream with path params (no body = GET)
410
- export const channelStreamContract = buildSseContract({
411
- pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
412
- params: z.object({ channelId: z.string() }),
413
- query: z.object({}),
414
- requestHeaders: z.object({}),
415
- sseEvents: {
416
- message: z.object({ content: z.string() }),
417
- },
418
- })
419
-
420
- // GET-based SSE stream without path params
421
- export const notificationsContract = buildSseContract({
422
- pathResolver: () => '/api/notifications/stream',
423
- params: z.object({}),
424
- query: z.object({ userId: z.string().optional() }),
425
- requestHeaders: z.object({}),
426
- sseEvents: {
427
- notification: z.object({
428
- id: z.string(),
429
- message: z.string(),
430
- }),
431
- },
432
- })
433
-
434
- // POST-based SSE stream (e.g., AI chat completions) - has requestBody = POST/PUT/PATCH
435
- export const chatCompletionContract = buildSseContract({
436
- method: 'post',
437
- pathResolver: () => '/api/chat/completions',
438
- params: z.object({}),
439
- query: z.object({}),
440
- requestHeaders: z.object({}),
441
- requestBody: z.object({
442
- message: z.string(),
443
- stream: z.literal(true),
444
- }),
445
- sseEvents: {
446
- chunk: z.object({ content: z.string() }),
447
- done: z.object({ totalTokens: z.number() }),
448
- },
449
- })
450
- ```
451
-
452
- For reusable event schema definitions, you can use the `SSEEventSchemas` type (requires TypeScript 4.9+ for `satisfies`):
453
-
454
- ```ts
455
- import { z } from 'zod'
456
- import type { SSEEventSchemas } from 'opinionated-machine'
457
-
458
- // Define reusable event schemas for multiple contracts
459
- const streamingEvents = {
460
- chunk: z.object({ content: z.string() }),
461
- done: z.object({ totalTokens: z.number() }),
462
- error: z.object({ code: z.number(), message: z.string() }),
463
- } satisfies SSEEventSchemas
464
- ```
465
-
466
- ### Creating SSE Controllers
467
-
468
- SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildHandler` for automatic type inference of request parameters:
469
-
470
- ```ts
471
- import {
472
- AbstractSSEController,
473
- buildHandler,
474
- type SSEControllerConfig,
475
- type SSESession
476
- } from 'opinionated-machine'
477
-
478
- type Contracts = {
479
- notificationsStream: typeof notificationsContract
480
- }
481
-
482
- type Dependencies = {
483
- notificationService: NotificationService
484
- }
485
-
486
- export class NotificationsSSEController extends AbstractSSEController<Contracts> {
487
- public static contracts = {
488
- notificationsStream: notificationsContract,
489
- } as const
490
-
491
- private readonly notificationService: NotificationService
492
-
493
- // Required: two-parameter constructor (deps object, optional SSE config)
494
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
495
- super(deps, sseConfig)
496
- this.notificationService = deps.notificationService
497
- }
498
-
499
- public buildSSERoutes() {
500
- return {
501
- notificationsStream: this.handleStream,
502
- }
503
- }
504
-
505
- // Handler with automatic type inference from contract
506
- // sse.start(mode) returns a session with type-safe event sending
507
- // Options (onConnect, onClose) are passed as the third parameter to buildHandler
508
- private handleStream = buildHandler(notificationsContract, {
509
- sse: async (request, sse) => {
510
- // request.query is typed from contract: { userId?: string }
511
- const userId = request.query.userId ?? 'anonymous'
512
-
513
- // Start streaming with 'keepAlive' mode - stays open for external events
514
- // Sends HTTP 200 + SSE headers immediately
515
- const session = sse.start('keepAlive', { context: { userId } })
516
-
517
- // For external triggers (subscriptions, timers, message queues), use sendEventInternal.
518
- // session.send is only available within this handler's scope - external callbacks
519
- // like subscription handlers execute later, outside this function, so they can't access session.
520
- // sendEventInternal is a controller method, so it's accessible from any callback.
521
- // It provides autocomplete for all event names defined in the controller's contracts.
522
- this.notificationService.subscribe(userId, async (notification) => {
523
- await this.sendEventInternal(session.id, {
524
- event: 'notification',
525
- data: notification,
526
- })
527
- })
528
-
529
- // For direct sending within the handler, use the session's send method.
530
- // It provides stricter per-route typing (only events from this specific contract).
531
- await session.send('notification', { id: 'welcome', message: 'Connected!' })
532
-
533
- // 'keepAlive' mode: handler returns, but connection stays open for subscription events
534
- // Connection closes when client disconnects or server calls closeConnection()
535
- },
536
- }, {
537
- onConnect: (session) => console.log('Client connected:', session.id),
538
- onClose: (session, reason) => {
539
- const userId = session.context?.userId as string
540
- this.notificationService.unsubscribe(userId)
541
- console.log(`Client disconnected (${reason}):`, session.id)
542
- },
543
- })
544
- }
545
- ```
546
-
547
- ### Type-Safe SSE Handlers with `buildHandler`
548
-
549
- For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildHandler`:
550
-
551
- ```ts
552
- import {
553
- AbstractSSEController,
554
- buildHandler,
555
- type SSEControllerConfig,
556
- type SSESession
557
- } from 'opinionated-machine'
558
-
559
- class ChatSSEController extends AbstractSSEController<Contracts> {
560
- public static contracts = {
561
- chatCompletion: chatCompletionContract,
562
- } as const
563
-
564
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
565
- super(deps, sseConfig)
566
- }
567
-
568
- // Handler with automatic type inference from contract
569
- // sse.start(mode) returns session with fully typed send()
570
- private handleChatCompletion = buildHandler(chatCompletionContract, {
571
- sse: async (request, sse) => {
572
- // request.body is typed as { message: string; stream: true }
573
- // request.query, request.params, request.headers all typed from contract
574
- const words = request.body.message.split(' ')
575
-
576
- // Start streaming with 'autoClose' mode - closes after handler completes
577
- // Sends HTTP 200 + SSE headers immediately
578
- const session = sse.start('autoClose')
579
-
580
- for (const word of words) {
581
- // session.send() provides compile-time type checking for event names and data
582
- await session.send('chunk', { content: word })
583
- }
584
-
585
- // 'autoClose' mode: connection closes automatically when handler returns
586
- },
587
- })
588
-
589
- public buildSSERoutes() {
590
- return {
591
- chatCompletion: this.handleChatCompletion,
592
- }
593
- }
594
- }
595
- ```
596
-
597
- You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
598
-
599
- ```ts
600
- import { type InferSSERequest, type SSEContext, type SSESession } from 'opinionated-machine'
601
-
602
- private handleStream = async (
603
- request: InferSSERequest<typeof chatCompletionContract>,
604
- sse: SSEContext<typeof chatCompletionContract['sseEvents']>,
605
- ) => {
606
- // request.body, request.params, etc. all typed from contract
607
- const session = sse.start('autoClose')
608
- // session.send() is typed based on contract sseEvents
609
- await session.send('chunk', { content: 'hello' })
610
- // 'autoClose' mode: connection closes when handler returns
611
- }
612
- ```
613
-
614
- ### SSE Controllers Without Dependencies
615
-
616
- For controllers without dependencies, still provide the two-parameter constructor:
617
-
618
- ```ts
619
- export class SimpleSSEController extends AbstractSSEController<Contracts> {
620
- constructor(deps: object, sseConfig?: SSEControllerConfig) {
621
- super(deps, sseConfig)
622
- }
623
-
624
- // ... implementation
625
- }
626
- ```
627
-
628
- ### Registering SSE Controllers
629
-
630
- 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:
631
-
632
- ```ts
633
- import { AbstractModule, asControllerClass, asSSEControllerClass, asServiceClass, type DependencyInjectionOptions } from 'opinionated-machine'
634
-
635
- export class NotificationsModule extends AbstractModule<Dependencies> {
636
- resolveDependencies() {
637
- return {
638
- notificationService: asServiceClass(NotificationService),
639
- }
640
- }
641
-
642
- resolveControllers(diOptions: DependencyInjectionOptions) {
643
- return {
644
- // REST controller
645
- usersController: asControllerClass(UsersController),
646
- // SSE controller (automatically detected and registered for SSE routes)
647
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
648
- }
649
- }
650
- }
651
- ```
652
-
653
- ### Registering SSE Routes
654
-
655
- Call `registerSSERoutes` after registering the `@fastify/sse` plugin:
656
-
657
- ```ts
658
- const app = fastify()
659
- app.setValidatorCompiler(validatorCompiler)
660
- app.setSerializerCompiler(serializerCompiler)
661
-
662
- // Register @fastify/sse plugin first
663
- await app.register(FastifySSEPlugin)
664
-
665
- // Then register SSE routes
666
- context.registerSSERoutes(app)
667
-
668
- // Optionally with global preHandler for authentication
669
- context.registerSSERoutes(app, {
670
- preHandler: async (request, reply) => {
671
- if (!request.headers.authorization) {
672
- reply.code(401).send({ error: 'Unauthorized' })
673
- }
674
- },
675
- })
676
-
677
- await app.ready()
678
- ```
679
-
680
- ### Broadcasting Events
681
-
682
- Send events to multiple connections using `broadcast()` or `broadcastIf()`:
683
-
684
- ```ts
685
- // Broadcast to ALL connected clients
686
- await this.broadcast({
687
- event: 'system',
688
- data: { message: 'Server maintenance in 5 minutes' },
689
- })
690
-
691
- // Broadcast to sessions matching a predicate
692
- await this.broadcastIf(
693
- { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
694
- (session) => session.context.channelId === '123',
695
- )
696
- ```
697
-
698
- Both methods return the number of clients the message was successfully sent to.
699
-
700
- ### Controller-Level Hooks
701
-
702
- Override these optional methods on your controller for global session handling:
703
-
704
- ```ts
705
- class MySSEController extends AbstractSSEController<Contracts> {
706
- // Called AFTER session is registered (for all routes)
707
- protected onConnectionEstablished(session: SSESession): void {
708
- this.metrics.incrementConnections()
709
- }
710
-
711
- // Called BEFORE session is unregistered (for all routes)
712
- protected onConnectionClosed(session: SSESession): void {
713
- this.metrics.decrementConnections()
714
- }
715
- }
716
- ```
717
-
718
- ### Route-Level Options
719
-
720
- Each route can have its own `preHandler`, lifecycle hooks, and logger. Pass these as the third parameter to `buildHandler`:
721
-
722
- ```ts
723
- public buildSSERoutes() {
724
- return {
725
- adminStream: this.handleAdminStream,
726
- }
727
- }
728
-
729
- private handleAdminStream = buildHandler(adminStreamContract, {
730
- sse: async (request, sse) => {
731
- const session = sse.start('keepAlive')
732
- // ... handler logic
733
- },
734
- }, {
735
- // Route-specific authentication
736
- preHandler: (request, reply) => {
737
- if (!request.user?.isAdmin) {
738
- reply.code(403).send({ error: 'Forbidden' })
739
- }
740
- },
741
- onConnect: (session) => console.log('Admin connected'),
742
- onClose: (session, reason) => console.log(`Admin disconnected (${reason})`),
743
- // Handle client reconnection with Last-Event-ID
744
- onReconnect: async (session, lastEventId) => {
745
- // Return events to replay, or handle manually
746
- return this.getEventsSince(lastEventId)
747
- },
748
- // Optional: logger for error handling (requires @lokalise/node-core)
749
- logger: this.logger,
750
- })
751
- ```
752
-
753
- **Available route options:**
754
-
755
- | Option | Description |
756
- | -------- | ------------- |
757
- | `preHandler` | Authentication/authorization hook that runs before SSE session |
758
- | `onConnect` | Called after client connects (SSE handshake complete) |
759
- | `onClose` | Called when session closes (client disconnect, network failure, or server close). Receives `(session, reason)` where reason is `'server'` or `'client'` |
760
- | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
761
- | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
762
- | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
763
- | `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
764
-
765
- **onClose reason parameter:**
766
- - `'server'`: Server explicitly closed the session (via `closeConnection()` or `autoClose` mode)
767
- - `'client'`: Client closed the session (EventSource.close(), navigation, network failure)
768
-
769
- ```ts
770
- options: {
771
- onConnect: (session) => console.log('Client connected'),
772
- onClose: (session, reason) => {
773
- console.log(`Session closed (${reason}):`, session.id)
774
- // reason is 'server' or 'client'
775
- },
776
- serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
777
- heartbeatInterval: 30000, // Send heartbeat every 30 seconds
778
- }
779
- ```
780
-
781
- ### SSE Session Methods
782
-
783
- The `session` object returned by `sse.start(mode)` provides several useful methods:
784
-
785
- ```ts
786
- private handleStream = buildHandler(streamContract, {
787
- sse: async (request, sse) => {
788
- const session = sse.start('autoClose')
789
-
790
- // Check if session is still active
791
- if (session.isConnected()) {
792
- await session.send('status', { connected: true })
793
- }
794
-
795
- // Get raw writable stream for advanced use cases (e.g., pipeline)
796
- const stream = session.getStream()
797
-
798
- // Stream messages from an async iterable with automatic validation
799
- async function* generateMessages() {
800
- yield { event: 'message' as const, data: { text: 'Hello' } }
801
- yield { event: 'message' as const, data: { text: 'World' } }
802
- }
803
- await session.sendStream(generateMessages())
804
-
805
- // 'autoClose' mode: connection closes when handler returns
806
- },
807
- })
808
- ```
809
-
810
- | Method | Description |
811
- | -------- | ------------- |
812
- | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
813
- | `isConnected()` | Check if the session is still active |
814
- | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
815
- | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
816
-
817
- ### Graceful Shutdown
818
-
819
- 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).
820
-
821
- ### Error Handling
822
-
823
- When `sendEvent()` fails (e.g., client disconnected), it:
824
- - Returns `false` to indicate failure
825
- - Automatically removes the dead connection from tracking
826
- - Prevents further send attempts to that connection
827
-
828
- ```ts
829
- const sent = await this.sendEvent(connectionId, { event: 'update', data })
830
- if (!sent) {
831
- // Connection was closed or failed - already removed from tracking
832
- this.cleanup(connectionId)
833
- }
834
- ```
835
-
836
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
837
- - All lifecycle hooks are wrapped in try/catch to prevent crashes
838
- - If a `logger` is provided in route options, errors are logged with context
839
- - If no logger is provided, errors are silently ignored
840
- - The session lifecycle continues even if a hook throws
841
-
842
- ```ts
843
- // Provide a logger to capture lifecycle errors
844
- public buildSSERoutes() {
845
- return {
846
- stream: this.handleStream,
847
- }
848
- }
849
-
850
- private handleStream = buildHandler(streamContract, {
851
- sse: async (request, sse) => {
852
- const session = sse.start('autoClose')
853
- // ... handler logic
854
- },
855
- }, {
856
- logger: this.logger, // pino-compatible logger
857
- onConnect: (session) => { /* may throw */ },
858
- onClose: (session, reason) => { /* may throw */ },
859
- })
860
- ```
861
-
862
- ### Long-lived Connections vs Request-Response Streaming
863
-
864
- SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
865
-
866
- ```ts
867
- // sse.start('autoClose') - close connection when handler returns (request-response pattern)
868
- // sse.start('keepAlive') - keep connection open for external events (subscription pattern)
869
- // sse.respond(code, body) - send HTTP response before streaming (early return)
870
- ```
871
-
872
- **Long-lived sessions** (notifications, live updates):
873
- - Handler starts streaming with `sse.start('keepAlive')`
874
- - Session stays open indefinitely after handler returns
875
- - Events are sent later via callbacks using `sendEventInternal()`
876
- - **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
877
- - Server cleans up via `onConnectionClosed()` hook
878
-
879
- ```ts
880
- private handleStream = buildHandler(streamContract, {
881
- sse: async (request, sse) => {
882
- // Start streaming with 'keepAlive' mode - stays open for external events
883
- const session = sse.start('keepAlive')
884
-
885
- // Set up subscription - events sent via callback AFTER handler returns
886
- this.service.subscribe(session.id, (data) => {
887
- this.sendEventInternal(session.id, { event: 'update', data })
888
- })
889
- // 'keepAlive' mode: handler returns, but connection stays open
890
- },
891
- })
892
-
893
- // Clean up when client disconnects
894
- protected onConnectionClosed(session: SSESession): void {
895
- this.service.unsubscribe(session.id)
896
- }
897
- ```
898
-
899
- **Request-response streaming** (AI completions):
900
- - Handler starts streaming with `sse.start('autoClose')`
901
- - Use `session.send()` for type-safe event sending within the handler
902
- - Session automatically closes when handler returns
903
-
904
- ```ts
905
- private handleChatCompletion = buildHandler(chatCompletionContract, {
906
- sse: async (request, sse) => {
907
- // Start streaming with 'autoClose' mode - closes when handler returns
908
- const session = sse.start('autoClose')
909
-
910
- const words = request.body.message.split(' ')
911
- for (const word of words) {
912
- await session.send('chunk', { content: word })
913
- }
914
- await session.send('done', { totalTokens: words.length })
915
-
916
- // 'autoClose' mode: connection closes automatically when handler returns
917
- },
918
- })
919
- ```
920
-
921
- **Error handling before streaming:**
922
-
923
- 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.
924
-
925
- ```ts
926
- private handleStream = buildHandler(streamContract, {
927
- sse: async (request, sse) => {
928
- // Early return BEFORE starting stream - can return any HTTP response
929
- const entity = await this.service.find(request.params.id)
930
- if (!entity) {
931
- return sse.respond(404, { error: 'Entity not found' })
932
- }
933
-
934
- // Validation passed - start streaming with autoClose mode
935
- const session = sse.start('autoClose')
936
- await session.send('data', entity)
937
- // Connection closes automatically when handler returns
938
- },
939
- })
940
-
941
- ### SSE Parsing Utilities
942
-
943
- The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
944
-
945
- | Function | Use Case |
946
- |----------|----------|
947
- | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
948
- | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
949
-
950
- #### parseSSEEvents
951
-
952
- Parse a complete SSE response body into an array of events.
953
-
954
- **When to use:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
955
-
956
- ```ts
957
- import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
958
-
959
- const responseBody = `event: notification
960
- data: {"id":"1","message":"Hello"}
961
-
962
- event: notification
963
- data: {"id":"2","message":"World"}
964
-
965
- `
966
-
967
- const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
968
- // Result:
969
- // [
970
- // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
971
- // { event: 'notification', data: '{"id":"2","message":"World"}' }
972
- // ]
973
-
974
- // Access parsed data
975
- const notifications = events.map(e => JSON.parse(e.data))
976
- ```
977
-
978
- #### parseSSEBuffer
979
-
980
- Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
981
-
982
- **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
983
-
984
- ```ts
985
- import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
986
-
987
- let buffer = ''
988
-
989
- // As chunks arrive from a stream...
990
- for await (const chunk of stream) {
991
- buffer += chunk
992
- const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
993
-
994
- // Process complete events
995
- for (const event of result.events) {
996
- console.log('Received:', event.event, event.data)
997
- }
998
-
999
- // Keep incomplete data for next chunk
1000
- buffer = result.remaining
1001
- }
1002
- ```
1003
-
1004
- **Production example with fetch:**
1005
-
1006
- ```ts
1007
- const response = await fetch(url)
1008
- const reader = response.body!.getReader()
1009
- const decoder = new TextDecoder()
1010
- let buffer = ''
1011
-
1012
- while (true) {
1013
- const { done, value } = await reader.read()
1014
- if (done) break
1015
-
1016
- buffer += decoder.decode(value, { stream: true })
1017
- const { events, remaining } = parseSSEBuffer(buffer)
1018
- buffer = remaining
1019
-
1020
- for (const event of events) {
1021
- console.log('Received:', event.event, JSON.parse(event.data))
1022
- }
1023
- }
1024
- ```
1025
-
1026
- #### ParsedSSEEvent Type
1027
-
1028
- Both functions return events with this structure:
1029
-
1030
- ```ts
1031
- type ParsedSSEEvent = {
1032
- id?: string // Event ID (from "id:" field)
1033
- event?: string // Event type (from "event:" field)
1034
- data: string // Event data (from "data:" field, always present)
1035
- retry?: number // Reconnection interval (from "retry:" field)
1036
- }
1037
- ```
1038
-
1039
- ### Testing SSE Controllers
1040
-
1041
- Enable the connection spy for testing by passing `isTestMode: true` in diOptions:
1042
-
1043
- ```ts
1044
- import { createContainer } from 'awilix'
1045
- import { DIContext, SSETestServer, SSEHttpClient } from 'opinionated-machine'
1046
-
1047
- describe('NotificationsSSEController', () => {
1048
- let server: SSETestServer
1049
- let controller: NotificationsSSEController
1050
-
1051
- beforeEach(async () => {
1052
- // Create test server with isTestMode enabled
1053
- server = await SSETestServer.create(
1054
- async (app) => {
1055
- // Register your SSE routes here
1056
- },
1057
- {
1058
- setup: async () => {
1059
- // Set up DI container and resources
1060
- return { context }
1061
- },
1062
- }
1063
- )
1064
-
1065
- controller = server.resources.context.diContainer.cradle.notificationsSSEController
1066
- })
1067
-
1068
- afterEach(async () => {
1069
- await server.resources.context.destroy()
1070
- await server.close()
1071
- })
1072
-
1073
- it('receives notifications over SSE', async () => {
1074
- // Connect with awaitServerConnection to eliminate race condition
1075
- const { client, serverConnection } = await SSEHttpClient.connect(
1076
- server.baseUrl,
1077
- '/api/notifications/stream',
1078
- {
1079
- query: { userId: 'test-user' },
1080
- awaitServerConnection: { controller },
1081
- },
1082
- )
1083
-
1084
- expect(client.response.ok).toBe(true)
1085
-
1086
- // Start collecting events
1087
- const eventsPromise = client.collectEvents(2)
1088
-
1089
- // Send events from server (serverConnection is ready immediately)
1090
- await controller.sendEvent(serverConnection.id, {
1091
- event: 'notification',
1092
- data: { id: '1', message: 'Hello!' },
1093
- })
1094
-
1095
- await controller.sendEvent(serverConnection.id, {
1096
- event: 'notification',
1097
- data: { id: '2', message: 'World!' },
1098
- })
1099
-
1100
- // Wait for events
1101
- const events = await eventsPromise
1102
-
1103
- expect(events).toHaveLength(2)
1104
- expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
1105
- expect(JSON.parse(events[1].data)).toEqual({ id: '2', message: 'World!' })
1106
-
1107
- // Clean up
1108
- client.close()
1109
- })
1110
- })
1111
- ```
1112
-
1113
- ### SSESessionSpy API
1114
-
1115
- The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
1116
-
1117
- ```ts
1118
- // Wait for a session to be established (with timeout)
1119
- const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1120
-
1121
- // Wait for a session matching a predicate (useful for multiple sessions)
1122
- const session = await controller.connectionSpy.waitForConnection({
1123
- timeout: 5000,
1124
- predicate: (s) => s.request.url.includes('/api/notifications'),
1125
- })
1126
-
1127
- // Check if a specific session is active
1128
- const isConnected = controller.connectionSpy.isConnected(sessionId)
1129
-
1130
- // Wait for a specific session to disconnect
1131
- await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
1132
-
1133
- // Get all session events (connect/disconnect history)
1134
- const events = controller.connectionSpy.getEvents()
1135
-
1136
- // Clear event history and claimed sessions between tests
1137
- controller.connectionSpy.clear()
1138
- ```
1139
-
1140
- **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`.
1141
-
1142
- ### Session Monitoring
1143
-
1144
- Controllers have access to utility methods for monitoring sessions:
1145
-
1146
- ```ts
1147
- // Get count of active sessions
1148
- const count = this.getConnectionCount()
1149
-
1150
- // Get all active sessions (for iteration/inspection)
1151
- const sessions = this.getConnections()
1152
-
1153
- // Check if session spy is enabled (useful for conditional logic)
1154
- if (this.hasConnectionSpy()) {
1155
- // ...
1156
- }
1157
- ```
1158
-
1159
- ### SSE Test Utilities
1160
-
1161
- The library provides utilities for testing SSE endpoints.
1162
-
1163
- **Two transport methods:**
1164
- - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the session for the request to complete.
1165
- - **Real HTTP** - Actual HTTP via `fetch()`. Requires the server to be listening. Supports long-lived sessions.
1166
-
1167
- #### Quick Reference
1168
-
1169
- | Utility | Connection | Requires Contract | Use Case |
1170
- |---------|------------|-------------------|----------|
1171
- | `SSEInjectClient` | Inject (in-memory) | No | Request-response SSE without contracts |
1172
- | `injectSSE` / `injectPayloadSSE` | Inject (in-memory) | **Yes** | Request-response SSE with type-safe contracts |
1173
- | `SSEHttpClient` | Real HTTP | No | Long-lived SSE connections |
1174
-
1175
- `SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
1176
-
1177
- #### Inject vs HTTP Comparison
1178
-
1179
- | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
1180
- |---------|----------------------------------------|------------------------|
1181
- | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
1182
- | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
1183
- | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
1184
- | **Server requirement** | No `listen()` needed | Requires running server |
1185
- | **Best for** | OpenAI-style streaming, batch exports | Notifications, live feeds, chat |
1186
-
1187
- #### SSETestServer
1188
-
1189
- Creates a test server with `@fastify/sse` pre-configured:
1190
-
1191
- ```ts
1192
- import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1193
-
1194
- // Basic usage
1195
- const server = await SSETestServer.create(async (app) => {
1196
- app.get('/api/events', async (request, reply) => {
1197
- reply.sse({ event: 'message', data: { hello: 'world' } })
1198
- reply.sse.close()
1199
- })
1200
- })
1201
-
1202
- // Connect and test
1203
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/events')
1204
- const events = await client.collectEvents(1)
1205
- expect(events[0].event).toBe('message')
1206
-
1207
- // Cleanup
1208
- client.close()
1209
- await server.close()
1210
- ```
1211
-
1212
- With custom resources (DI container, controllers):
1213
-
1214
- ```ts
1215
- const server = await SSETestServer.create(
1216
- async (app) => {
1217
- // Register routes using resources from setup
1218
- myController.registerRoutes(app)
1219
- },
1220
- {
1221
- configureApp: async (app) => {
1222
- app.setValidatorCompiler(validatorCompiler)
1223
- },
1224
- setup: async () => {
1225
- // Resources are available via server.resources
1226
- const container = createContainer()
1227
- return { container }
1228
- },
1229
- }
1230
- )
1231
-
1232
- const { container } = server.resources
1233
- ```
1234
-
1235
- #### SSEHttpClient
1236
-
1237
- For testing long-lived SSE connections using real HTTP:
1238
-
1239
- ```ts
1240
- import { SSEHttpClient } from 'opinionated-machine'
1241
-
1242
- // Connect to SSE endpoint with awaitServerConnection (recommended)
1243
- // This eliminates the race condition between client connect and server-side registration
1244
- const { client, serverConnection } = await SSEHttpClient.connect(
1245
- server.baseUrl,
1246
- '/api/stream',
1247
- {
1248
- query: { userId: 'test' },
1249
- headers: { authorization: 'Bearer token' },
1250
- awaitServerConnection: { controller }, // Pass your SSE controller
1251
- },
1252
- )
1253
-
1254
- // serverConnection is ready to use immediately
1255
- expect(client.response.ok).toBe(true)
1256
- await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1257
-
1258
- // Collect events by count with timeout
1259
- const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
1260
-
1261
- // Or collect until a predicate is satisfied
1262
- const events = await client.collectEvents(
1263
- (event) => event.event === 'done',
1264
- 5000,
1265
- )
1266
-
1267
- // Iterate over events as they arrive
1268
- for await (const event of client.events()) {
1269
- console.log(event.event, event.data)
1270
- if (event.event === 'done') break
1271
- }
1272
-
1273
- // Cleanup
1274
- client.close()
1275
- ```
1276
-
1277
- **`collectEvents(countOrPredicate, timeout?)`**
1278
-
1279
- Collects events until a count is reached or a predicate returns true.
1280
-
1281
- | Parameter | Type | Description |
1282
- |-----------|------|-------------|
1283
- | `countOrPredicate` | `number \| (event) => boolean` | Number of events to collect, or predicate that returns `true` when collection should stop |
1284
- | `timeout` | `number` | Maximum time to wait in milliseconds (default: 5000) |
1285
-
1286
- Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
1287
-
1288
- ```ts
1289
- // Collect exactly 3 events
1290
- const events = await client.collectEvents(3)
1291
-
1292
- // Collect with custom timeout
1293
- const events = await client.collectEvents(5, 10000) // 10s timeout
1294
-
1295
- // Collect until a specific event type (the matching event IS included)
1296
- const events = await client.collectEvents((event) => event.event === 'done')
1297
-
1298
- // Collect until condition with timeout
1299
- const events = await client.collectEvents(
1300
- (event) => JSON.parse(event.data).status === 'complete',
1301
- 30000,
1302
- )
1303
- ```
1304
-
1305
- **`events(signal?)`**
1306
-
1307
- Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
1308
-
1309
- ```ts
1310
- // Basic iteration
1311
- for await (const event of client.events()) {
1312
- console.log(event.event, event.data)
1313
- if (event.event === 'done') break
1314
- }
1315
-
1316
- // With abort signal for timeout control
1317
- const controller = new AbortController()
1318
- const timeoutId = setTimeout(() => controller.abort(), 5000)
1319
-
1320
- try {
1321
- for await (const event of client.events(controller.signal)) {
1322
- console.log(event)
1323
- }
1324
- } finally {
1325
- clearTimeout(timeoutId)
1326
- }
1327
- ```
1328
-
1329
- **When to omit `awaitServerConnection`**
1330
-
1331
- Omit `awaitServerConnection` only in these cases:
1332
- - Testing against external SSE endpoints (not your own controller)
1333
- - When `isTestMode: false` (connectionSpy not available)
1334
- - Simple smoke tests that only verify response headers/status without sending server events
1335
-
1336
- **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.
1337
-
1338
- ```ts
1339
- // Example: smoke test that only checks connection works
1340
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
1341
- expect(client.response.ok).toBe(true)
1342
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
1343
- client.close()
1344
- ```
1345
-
1346
- #### SSEInjectClient
1347
-
1348
- For testing request-response style SSE streams (like OpenAI completions):
1349
-
1350
- ```ts
1351
- import { SSEInjectClient } from 'opinionated-machine'
1352
-
1353
- const client = new SSEInjectClient(app) // No server.listen() needed
1354
-
1355
- // GET request
1356
- const conn = await client.connect('/api/export/progress', {
1357
- headers: { authorization: 'Bearer token' },
1358
- })
1359
-
1360
- // POST request with body (OpenAI-style)
1361
- const conn = await client.connectWithBody(
1362
- '/api/chat/completions',
1363
- { model: 'gpt-4', messages: [...], stream: true },
1364
- )
1365
-
1366
- // All events are available immediately (inject waits for complete response)
1367
- expect(conn.getStatusCode()).toBe(200)
1368
- const events = conn.getReceivedEvents()
1369
- const chunks = events.filter(e => e.event === 'chunk')
1370
- ```
1371
-
1372
- #### Contract-Aware Inject Helpers
1373
-
1374
- For typed testing with SSE contracts:
1375
-
1376
- ```ts
1377
- import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
1378
-
1379
- // For GET SSE endpoints with contracts
1380
- const { closed } = injectSSE(app, notificationsContract, {
1381
- query: { userId: 'test' },
1382
- })
1383
- const result = await closed
1384
- const events = parseSSEEvents(result.body)
1385
-
1386
- // For POST/PUT/PATCH SSE endpoints with contracts
1387
- const { closed } = injectPayloadSSE(app, chatCompletionContract, {
1388
- body: { message: 'Hello', stream: true },
1389
- })
1390
- const result = await closed
1391
- const events = parseSSEEvents(result.body)
1392
- ```
1393
-
1394
- ## Dual-Mode Controllers (SSE + Sync)
1395
-
1396
- 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.
1397
-
1398
- ### Overview
1399
-
1400
- | Accept Header | Response Mode |
1401
- | ------------- | ------------- |
1402
- | `text/event-stream` | SSE streaming |
1403
- | `application/json` | Sync response |
1404
- | `*/*` or missing | Sync (default, configurable) |
1405
-
1406
- 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.
1407
-
1408
- ### Defining Dual-Mode Contracts
1409
-
1410
- 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:
1411
-
1412
- - Clients may want immediate results (sync) or real-time updates (SSE)
1413
- - You're building OpenAI-style APIs where `stream: true` triggers SSE
1414
- - You need polling fallback for clients that don't support SSE
1415
-
1416
- To create a dual-mode contract, include a `syncResponseBody` schema in your `buildSseContract` call:
1417
- - Has `syncResponseBody` but no `requestBody` GET dual-mode route
1418
- - Has both `syncResponseBody` and `requestBody` → POST/PUT/PATCH dual-mode route
1419
-
1420
- ```ts
1421
- import { z } from 'zod'
1422
- import { buildSseContract } from '@lokalise/api-contracts'
1423
-
1424
- // GET dual-mode route (polling or streaming job status) - has syncResponseBody, no requestBody
1425
- export const jobStatusContract = buildSseContract({
1426
- pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1427
- params: z.object({ jobId: z.string().uuid() }),
1428
- query: z.object({ verbose: z.string().optional() }),
1429
- requestHeaders: z.object({}),
1430
- syncResponseBody: z.object({
1431
- status: z.enum(['pending', 'running', 'completed', 'failed']),
1432
- progress: z.number(),
1433
- result: z.string().optional(),
1434
- }),
1435
- sseEvents: {
1436
- progress: z.object({ percent: z.number(), message: z.string().optional() }),
1437
- done: z.object({ result: z.string() }),
1438
- },
1439
- })
1440
-
1441
- // POST dual-mode route (OpenAI-style chat completion) - has both syncResponseBody and requestBody
1442
- export const chatCompletionContract = buildSseContract({
1443
- method: 'post',
1444
- pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1445
- params: z.object({ chatId: z.string().uuid() }),
1446
- query: z.object({}),
1447
- requestHeaders: z.object({ authorization: z.string() }),
1448
- requestBody: z.object({ message: z.string() }),
1449
- syncResponseBody: z.object({
1450
- reply: z.string(),
1451
- usage: z.object({ tokens: z.number() }),
1452
- }),
1453
- sseEvents: {
1454
- chunk: z.object({ delta: z.string() }),
1455
- done: z.object({ usage: z.object({ total: z.number() }) }),
1456
- },
1457
- })
1458
- ```
1459
-
1460
- **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.
1461
-
1462
- ### Response Headers (Sync Mode)
1463
-
1464
- Dual-mode contracts support an optional `responseHeaders` schema 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:
1465
-
1466
- ```ts
1467
- export const rateLimitedContract = buildSseContract({
1468
- method: 'post',
1469
- pathResolver: () => '/api/rate-limited',
1470
- params: z.object({}),
1471
- query: z.object({}),
1472
- requestHeaders: z.object({}),
1473
- requestBody: z.object({ data: z.string() }),
1474
- syncResponseBody: z.object({ result: z.string() }),
1475
- // Define expected response headers
1476
- responseHeaders: z.object({
1477
- 'x-ratelimit-limit': z.string(),
1478
- 'x-ratelimit-remaining': z.string(),
1479
- 'x-ratelimit-reset': z.string(),
1480
- }),
1481
- sseEvents: {
1482
- result: z.object({ success: z.boolean() }),
1483
- },
1484
- })
1485
- ```
1486
-
1487
- In your handler, set headers using `reply.header()`:
1488
-
1489
- ```ts
1490
- handlers: buildHandler(rateLimitedContract, {
1491
- sync: async (request, reply) => {
1492
- reply.header('x-ratelimit-limit', '100')
1493
- reply.header('x-ratelimit-remaining', '99')
1494
- reply.header('x-ratelimit-reset', '1640000000')
1495
- return { result: 'success' }
1496
- },
1497
- sse: async (request, sse) => {
1498
- const session = sse.start('autoClose')
1499
- // ... send events ...
1500
- // Connection closes automatically when handler returns
1501
- },
1502
- })
1503
- ```
1504
-
1505
- If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
1506
-
1507
- ### Status-Specific Response Schemas (responseSchemasByStatusCode)
1508
-
1509
- Dual-mode and SSE contracts support `responseSchemasByStatusCode` 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:
1510
-
1511
- ```ts
1512
- export const resourceContract = buildSseContract({
1513
- method: 'post',
1514
- pathResolver: (params) => `/api/resources/${params.id}`,
1515
- params: z.object({ id: z.string() }),
1516
- query: z.object({}),
1517
- requestHeaders: z.object({}),
1518
- requestBody: z.object({ data: z.string() }),
1519
- // Success response (2xx)
1520
- syncResponseBody: z.object({
1521
- success: z.boolean(),
1522
- data: z.string(),
1523
- }),
1524
- // Responses by status code (typically used for errors)
1525
- responseSchemasByStatusCode: {
1526
- 400: z.object({ error: z.string(), details: z.array(z.string()) }),
1527
- 404: z.object({ error: z.string(), resourceId: z.string() }),
1528
- },
1529
- sseEvents: {
1530
- result: z.object({ success: z.boolean() }),
1531
- },
1532
- })
1533
- ```
1534
-
1535
- **Recommended: Use `sse.respond()` for strict type safety**
1536
-
1537
- 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:
1538
-
1539
- ```ts
1540
- handlers: buildHandler(resourceContract, {
1541
- sync: (request, reply) => {
1542
- if (!isValid(request.body.data)) {
1543
- reply.code(400)
1544
- return { error: 'Bad Request', details: ['Invalid data format'] }
1545
- }
1546
- return { success: true, data: 'OK' }
1547
- },
1548
- sse: async (request, sse) => {
1549
- const resource = findResource(request.params.id)
1550
- if (!resource) {
1551
- // Strict typing: TypeScript enforces exact schema for status 404
1552
- return sse.respond(404, { error: 'Not Found', resourceId: request.params.id })
1553
- }
1554
- if (!isValid(resource)) {
1555
- // Strict typing: TypeScript enforces exact schema for status 400
1556
- return sse.respond(400, { error: 'Bad Request', details: ['Invalid resource'] })
1557
- }
1558
-
1559
- const session = sse.start('autoClose')
1560
- await session.send('result', { success: true })
1561
- },
1562
- })
1563
- ```
1564
-
1565
- TypeScript enforces the exact schema for each status code at compile time:
1566
-
1567
- ```ts
1568
- sse.respond(404, { error: 'Not Found', resourceId: '123' }) // ✓ OK
1569
- sse.respond(404, { error: 'Not Found' }) // Error - missing resourceId
1570
- sse.respond(404, { error: 'Not Found', details: [] }) // ✗ Error - wrong schema for 404
1571
- sse.respond(500, { message: 'error' }) // ✗ Error - 500 not defined in schema
1572
- ```
1573
-
1574
- Only status codes defined in `responseSchemasByStatusCode` are allowed. To use an undefined status code, add it to the schema or use a type assertion.
1575
-
1576
- **Sync handlers (union typing with runtime validation):**
1577
-
1578
- 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:
1579
-
1580
- ```ts
1581
- sync: (request, reply) => {
1582
- reply.code(404)
1583
- return { error: 'Not Found', resourceId: '123' } // ✓ OK - matches one of the union types
1584
- // Runtime validation ensures body matches the 404 schema
1585
- }
1586
-
1587
- // The sync handler return type is automatically:
1588
- // { success: boolean; data: string } // from syncResponseBody
1589
- // | { error: string; details: string[] } // from responseSchemasByStatusCode[400]
1590
- // | { error: string; resourceId: string } // from responseSchemasByStatusCode[404]
1591
- ```
1592
-
1593
- **Validation behavior:**
1594
-
1595
- - **Success responses (2xx)**: Validated against `syncResponseBody` schema
1596
- - **Non-2xx responses**: Validated against the matching schema in `responseSchemasByStatusCode` (if defined)
1597
- - **Validation failures**: Return 500 Internal Server Error (validation details are logged internally, not exposed to clients)
1598
-
1599
- **Validation priority for 2xx status codes:**
1600
-
1601
- - All 2xx responses (200, 201, 204, etc.) are validated against `syncResponseBody`
1602
- - `responseSchemasByStatusCode` is only used for non-2xx status codes
1603
- - If you define the same 2xx code in both, `syncResponseBody` takes precedence
1604
-
1605
- ### Single Sync Handler
1606
-
1607
- Dual-mode contracts use a single `sync` handler that returns the response data. The framework handles content-type negotiation automatically:
1608
-
1609
- ```ts
1610
- handlers: buildHandler(chatCompletionContract, {
1611
- sync: async (request, reply) => {
1612
- // Return the response data matching syncResponseBody schema
1613
- const result = await aiService.complete(request.body.message)
1614
- return {
1615
- reply: result.text,
1616
- usage: { tokens: result.tokenCount },
1617
- }
1618
- },
1619
- sse: async (request, sse) => {
1620
- // SSE streaming handler
1621
- const session = sse.start('autoClose')
1622
- // ... stream events ...
1623
- },
1624
- })
1625
- ```
1626
-
1627
- TypeScript enforces the correct handler structure:
1628
- - `syncResponseBody` contracts must use `sync` handler (returns response data)
1629
- - `sseEvents` contracts must use `sse` handler (streams events)
1630
-
1631
- ### Implementing Dual-Mode Controllers
1632
-
1633
- 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`:
1634
-
1635
- ```ts
1636
- import {
1637
- AbstractDualModeController,
1638
- buildHandler,
1639
- type BuildFastifyDualModeRoutesReturnType,
1640
- type DualModeControllerConfig,
1641
- } from 'opinionated-machine'
1642
-
1643
- type Contracts = {
1644
- chatCompletion: typeof chatCompletionContract
1645
- }
1646
-
1647
- type Dependencies = {
1648
- aiService: AIService
1649
- }
1650
-
1651
- export class ChatDualModeController extends AbstractDualModeController<Contracts> {
1652
- public static contracts = {
1653
- chatCompletion: chatCompletionContract,
1654
- } as const
1655
-
1656
- private readonly aiService: AIService
1657
-
1658
- constructor(deps: Dependencies, config?: DualModeControllerConfig) {
1659
- super(deps, config)
1660
- this.aiService = deps.aiService
1661
- }
1662
-
1663
- public buildDualModeRoutes(): BuildFastifyDualModeRoutesReturnType<Contracts> {
1664
- return {
1665
- chatCompletion: this.handleChatCompletion,
1666
- }
1667
- }
1668
-
1669
- // Handler with options as third parameter
1670
- private handleChatCompletion = buildHandler(chatCompletionContract, {
1671
- // Sync mode - return complete response
1672
- sync: async (request, _reply) => {
1673
- const result = await this.aiService.complete(request.body.message)
1674
- return {
1675
- reply: result.text,
1676
- usage: { tokens: result.tokenCount },
1677
- }
1678
- },
1679
- // SSE mode - stream response chunks
1680
- sse: async (request, sse) => {
1681
- const session = sse.start('autoClose')
1682
- let totalTokens = 0
1683
- for await (const chunk of this.aiService.stream(request.body.message)) {
1684
- await session.send('chunk', { delta: chunk.text })
1685
- totalTokens += chunk.tokenCount ?? 0
1686
- }
1687
- await session.send('done', { usage: { total: totalTokens } })
1688
- // Connection closes automatically when handler returns
1689
- },
1690
- }, {
1691
- // Optional: set SSE as default mode (instead of sync)
1692
- defaultMode: 'sse',
1693
- // Optional: route-level authentication
1694
- preHandler: (request, reply) => {
1695
- if (!request.headers.authorization) {
1696
- return Promise.resolve(reply.code(401).send({ error: 'Unauthorized' }))
1697
- }
1698
- },
1699
- // Optional: SSE lifecycle hooks
1700
- onConnect: (session) => console.log('Client connected:', session.id),
1701
- onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
1702
- })
1703
- }
1704
- ```
1705
-
1706
- **Handler Signatures:**
1707
-
1708
- | Mode | Signature |
1709
- | ---- | --------- |
1710
- | `sync` | `(request, reply) => Response` |
1711
- | `sse` | `(request, sse) => SSEHandlerResult` |
1712
-
1713
- The `sync` handler must return a value matching `syncResponseBody` schema. The `sse` handler uses `sse.start(mode)` to begin streaming (`'autoClose'` for request-response, `'keepAlive'` for long-lived sessions) and `session.send()` for type-safe event sending.
1714
-
1715
- ### Registering Dual-Mode Controllers
1716
-
1717
- Use `asDualModeControllerClass` in your module:
1718
-
1719
- ```ts
1720
- import {
1721
- AbstractModule,
1722
- asControllerClass,
1723
- asDualModeControllerClass,
1724
- asServiceClass,
1725
- } from 'opinionated-machine'
1726
-
1727
- export class ChatModule extends AbstractModule<Dependencies> {
1728
- resolveDependencies() {
1729
- return {
1730
- aiService: asServiceClass(AIService),
1731
- }
1732
- }
1733
-
1734
- resolveControllers(diOptions: DependencyInjectionOptions) {
1735
- return {
1736
- // REST controller
1737
- usersController: asControllerClass(UsersController),
1738
- // Dual-mode controller (auto-detected via isDualModeController flag)
1739
- chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
1740
- }
1741
- }
1742
- }
1743
- ```
1744
-
1745
- Register dual-mode routes after the `@fastify/sse` plugin:
1746
-
1747
- ```ts
1748
- const app = fastify()
1749
- app.setValidatorCompiler(validatorCompiler)
1750
- app.setSerializerCompiler(serializerCompiler)
1751
-
1752
- // Register @fastify/sse plugin
1753
- await app.register(FastifySSEPlugin)
1754
-
1755
- // Register routes
1756
- context.registerRoutes(app) // REST routes
1757
- context.registerSSERoutes(app) // SSE-only routes
1758
- context.registerDualModeRoutes(app) // Dual-mode routes
1759
-
1760
- // Check if controllers exist before registration (optional)
1761
- if (context.hasDualModeControllers()) {
1762
- context.registerDualModeRoutes(app)
1763
- }
1764
-
1765
- await app.ready()
1766
- ```
1767
-
1768
- ### Accept Header Routing
1769
-
1770
- The `Accept` header determines response mode:
1771
-
1772
- ```bash
1773
- # JSON mode (complete response)
1774
- curl -X POST http://localhost:3000/api/chats/123/completions \
1775
- -H "Content-Type: application/json" \
1776
- -H "Accept: application/json" \
1777
- -d '{"message": "Hello world"}'
1778
-
1779
- # SSE mode (streaming response)
1780
- curl -X POST http://localhost:3000/api/chats/123/completions \
1781
- -H "Content-Type: application/json" \
1782
- -H "Accept: text/event-stream" \
1783
- -d '{"message": "Hello world"}'
1784
- ```
1785
-
1786
- **Quality values** are supported for content negotiation:
1787
-
1788
- ```bash
1789
- # Prefer JSON (higher quality value)
1790
- curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1791
-
1792
- # Prefer SSE (higher quality value)
1793
- curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1794
- ```
1795
-
1796
- **Subtype wildcards** are supported for flexible content negotiation:
1797
-
1798
- ```bash
1799
- # Accept any text format (matches text/plain, text/csv, etc.)
1800
- curl -H "Accept: text/*" ...
1801
-
1802
- # Accept any application format (matches application/json, application/xml, etc.)
1803
- curl -H "Accept: application/*" ...
1804
-
1805
- # Combine with quality values
1806
- curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
1807
- ```
1808
-
1809
- The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
1810
-
1811
- ### Testing Dual-Mode Controllers
1812
-
1813
- Test both sync and SSE modes:
1814
-
1815
- ```ts
1816
- import { createContainer } from 'awilix'
1817
- import { DIContext, SSETestServer, SSEInjectClient } from 'opinionated-machine'
1818
-
1819
- describe('ChatDualModeController', () => {
1820
- let server: SSETestServer
1821
- let injectClient: SSEInjectClient
1822
-
1823
- beforeEach(async () => {
1824
- const container = createContainer({ injectionMode: 'PROXY' })
1825
- const context = new DIContext(container, { isTestMode: true }, {})
1826
- context.registerDependencies({ modules: [new ChatModule()] }, undefined)
1827
-
1828
- server = await SSETestServer.create(
1829
- (app) => {
1830
- context.registerDualModeRoutes(app)
1831
- },
1832
- {
1833
- configureApp: (app) => {
1834
- app.setValidatorCompiler(validatorCompiler)
1835
- app.setSerializerCompiler(serializerCompiler)
1836
- },
1837
- setup: () => ({ context }),
1838
- },
1839
- )
1840
-
1841
- injectClient = new SSEInjectClient(server.app)
1842
- })
1843
-
1844
- afterEach(async () => {
1845
- await server.resources.context.destroy()
1846
- await server.close()
1847
- })
1848
-
1849
- it('returns sync response for Accept: application/json', async () => {
1850
- const response = await server.app.inject({
1851
- method: 'POST',
1852
- url: '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1853
- headers: {
1854
- 'content-type': 'application/json',
1855
- accept: 'application/json',
1856
- authorization: 'Bearer token',
1857
- },
1858
- payload: { message: 'Hello' },
1859
- })
1860
-
1861
- expect(response.statusCode).toBe(200)
1862
- expect(response.headers['content-type']).toContain('application/json')
1863
-
1864
- const body = JSON.parse(response.body)
1865
- expect(body).toHaveProperty('reply')
1866
- expect(body).toHaveProperty('usage')
1867
- })
1868
-
1869
- it('streams SSE for Accept: text/event-stream', async () => {
1870
- const conn = await injectClient.connectWithBody(
1871
- '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1872
- { message: 'Hello' },
1873
- { headers: { authorization: 'Bearer token' } },
1874
- )
1875
-
1876
- expect(conn.getStatusCode()).toBe(200)
1877
- expect(conn.getHeaders()['content-type']).toContain('text/event-stream')
1878
-
1879
- const events = conn.getReceivedEvents()
1880
- const chunks = events.filter((e) => e.event === 'chunk')
1881
- const doneEvents = events.filter((e) => e.event === 'done')
1882
-
1883
- expect(chunks.length).toBeGreaterThan(0)
1884
- expect(doneEvents).toHaveLength(1)
1885
- })
1886
- })
1887
-
1
+ # opinionated-machine
2
+ Very opinionated DI framework for fastify, built on top of awilix
3
+
4
+ ## Table of Contents
5
+
6
+ - [Basic usage](#basic-usage)
7
+ - [Defining controllers](#defining-controllers)
8
+ - [Putting it all together](#putting-it-all-together)
9
+ - [Resolver Functions](#resolver-functions)
10
+ - [Basic Resolvers](#basic-resolvers)
11
+ - [`asSingletonClass`](#assingletonclasstype-opts)
12
+ - [`asSingletonFunction`](#assingletonfunctionfn-opts)
13
+ - [`asClassWithConfig`](#asclasswithconfigtype-config-opts)
14
+ - [Domain Layer Resolvers](#domain-layer-resolvers)
15
+ - [`asServiceClass`](#asserviceclasstype-opts)
16
+ - [`asUseCaseClass`](#asusecaseclasstype-opts)
17
+ - [`asRepositoryClass`](#asrepositoryclasstype-opts)
18
+ - [`asControllerClass`](#ascontrollerclasstype-opts)
19
+ - [`asSSEControllerClass`](#asssecontrollerclasstype-sseoptions-opts)
20
+ - [`asDualModeControllerClass`](#asdualmodecontrollerclasstype-sseoptions-opts)
21
+ - [Message Queue Resolvers](#message-queue-resolvers)
22
+ - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
23
+ - [Background Job Resolvers](#background-job-resolvers)
24
+ - [`asEnqueuedJobWorkerClass`](#asenqueuedjobworkerclasstype-workeroptions-opts)
25
+ - [`asPgBossProcessorClass`](#aspgbossprocessorclasstype-processoroptions-opts)
26
+ - [`asPeriodicJobClass`](#asperiodicjobclasstype-workeroptions-opts)
27
+ - [`asJobQueueClass`](#asjobqueueclasstype-queueoptions-opts)
28
+ - [`asEnqueuedJobQueueManagerFunction`](#asenqueuedjobqueuemanagerfunctionfn-dioptions-opts)
29
+ - [Server-Sent Events (SSE)](#server-sent-events-sse)
30
+ - [Prerequisites](#prerequisites)
31
+ - [Defining SSE Contracts](#defining-sse-contracts)
32
+ - [Creating SSE Controllers](#creating-sse-controllers)
33
+ - [Type-Safe SSE Handlers with buildHandler](#type-safe-sse-handlers-with-buildhandler)
34
+ - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
35
+ - [Registering SSE Controllers](#registering-sse-controllers)
36
+ - [Registering SSE Routes](#registering-sse-routes)
37
+ - [Broadcasting Events](#broadcasting-events)
38
+ - [Controller-Level Hooks](#controller-level-hooks)
39
+ - [Route-Level Options](#route-level-options)
40
+ - [Graceful Shutdown](#graceful-shutdown)
41
+ - [Error Handling](#error-handling)
42
+ - [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
43
+ - [SSE Parsing Utilities](#sse-parsing-utilities)
44
+ - [parseSSEEvents](#parsesseevents)
45
+ - [parseSSEBuffer](#parsessebuffer)
46
+ - [ParsedSSEEvent Type](#parsedsseevent-type)
47
+ - [Testing SSE Controllers](#testing-sse-controllers)
48
+ - [SSESessionSpy API](#ssesessionspy-api)
49
+ - [Session Monitoring](#session-monitoring)
50
+ - [SSE Test Utilities](#sse-test-utilities)
51
+ - [Quick Reference](#quick-reference)
52
+ - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
53
+ - [SSETestServer](#ssetestserver)
54
+ - [SSEHttpClient](#ssehttpclient)
55
+ - [SSEInjectClient](#sseinjectclient)
56
+ - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
57
+ - [Dual-Mode Controllers (SSE + Sync)](#dual-mode-controllers-sse--sync)
58
+ - [Overview](#overview)
59
+ - [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
60
+ - [Response Headers (Sync Mode)](#response-headers-sync-mode)
61
+ - [Status-Specific Response Schemas (responseBodySchemasByStatusCode)](#status-specific-response-schemas-responsebodyschemasbystatuscode)
62
+ - [Implementing Dual-Mode Controllers](#implementing-dual-mode-controllers)
63
+ - [Registering Dual-Mode Controllers](#registering-dual-mode-controllers)
64
+ - [Accept Header Routing](#accept-header-routing)
65
+ - [Testing Dual-Mode Controllers](#testing-dual-mode-controllers)
66
+
67
+ ## Basic usage
68
+
69
+ Define a module, or several modules, that will be used for resolving dependency graphs, using awilix:
70
+
71
+ ```ts
72
+ import { AbstractModule, asSingletonClass, asMessageQueueHandlerClass, asJobWorkerClass, asJobQueueClass, asControllerClass } from 'opinionated-machine'
73
+
74
+ export type ModuleDependencies = {
75
+ service: Service
76
+ messageQueueConsumer: MessageQueueConsumer
77
+ jobWorker: JobWorker
78
+ queueManager: QueueManager
79
+ }
80
+
81
+ export class MyModule extends AbstractModule<ModuleDependencies, ExternalDependencies> {
82
+ resolveDependencies(
83
+ diOptions: DependencyInjectionOptions,
84
+ _externalDependencies: ExternalDependencies,
85
+ ): MandatoryNameAndRegistrationPair<ModuleDependencies> {
86
+ return {
87
+ service: asSingletonClass(Service),
88
+
89
+ // by default init and disposal methods from `message-queue-toolkit` consumers
90
+ // will be assumed. If different values are necessary, pass second config object
91
+ // and specify "asyncInit" and "asyncDispose" fields
92
+ messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
93
+ queueName: MessageQueueConsumer.QUEUE_ID,
94
+ diOptions,
95
+ }),
96
+
97
+ // by default init and disposal methods from `background-jobs-commons` job workers
98
+ // will be assumed. If different values are necessary, pass second config object
99
+ // and specify "asyncInit" and "asyncDispose" fields
100
+ jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
101
+ queueName: JobWorker.QUEUE_ID,
102
+ diOptions,
103
+ }),
104
+
105
+ // by default disposal methods from `background-jobs-commons` job queue manager
106
+ // will be assumed. If different values are necessary, specify "asyncDispose" fields
107
+ // in the second config object
108
+ queueManager: asJobQueueClass(
109
+ QueueManager,
110
+ {
111
+ diOptions,
112
+ },
113
+ {
114
+ asyncInit: (manager) => manager.start(resolveJobQueuesEnabled(options)),
115
+ },
116
+ ),
117
+ }
118
+ }
119
+
120
+ // controllers will be automatically registered on fastify app
121
+ // both REST and SSE controllers go here - SSE controllers are auto-detected
122
+ resolveControllers(diOptions: DependencyInjectionOptions) {
123
+ return {
124
+ controller: asControllerClass(MyController),
125
+ }
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Defining controllers
131
+
132
+ Controllers require using fastify-api-contracts and allow to define application routes.
133
+
134
+ ```ts
135
+ import { buildFastifyRoute } from '@lokalise/fastify-api-contracts'
136
+ import { buildRestContract } from '@lokalise/api-contracts'
137
+ import { z } from 'zod/v4'
138
+ import { AbstractController } from 'opinionated-machine'
139
+
140
+ const BODY_SCHEMA = z.object({})
141
+ const PATH_PARAMS_SCHEMA = z.object({
142
+ userId: z.string(),
143
+ })
144
+
145
+ const contract = buildRestContract({
146
+ method: 'delete',
147
+ successResponseBodySchema: BODY_SCHEMA,
148
+ requestPathParamsSchema: PATH_PARAMS_SCHEMA,
149
+ pathResolver: (pathParams) => `/users/${pathParams.userId}`,
150
+ })
151
+
152
+ export class MyController extends AbstractController<typeof MyController.contracts> {
153
+ public static contracts = { deleteItem: contract } as const
154
+ private readonly service: Service
155
+
156
+ constructor({ service }: ModuleDependencies) {
157
+ super()
158
+ this.service = testService
159
+ }
160
+
161
+ private deleteItem = buildFastifyRoute(
162
+ TestController.contracts.deleteItem,
163
+ async (req, reply) => {
164
+ req.log.info(req.params.userId)
165
+ this.service.execute()
166
+ await reply.status(204).send()
167
+ },
168
+ )
169
+
170
+ public buildRoutes() {
171
+ return {
172
+ deleteItem: this.deleteItem,
173
+ }
174
+ }
175
+ }
176
+ ```
177
+
178
+ ## Putting it all together
179
+
180
+ Typical usage with a fastify app looks like this:
181
+
182
+ ```ts
183
+ import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
184
+ import { createContainer } from 'awilix'
185
+ import { fastify } from 'fastify'
186
+ import { DIContext } from 'opinionated-machine'
187
+
188
+ const module = new MyModule()
189
+ const container = createContainer({
190
+ injectionMode: 'PROXY',
191
+ })
192
+
193
+ type AppConfig = {
194
+ DATABASE_URL: string
195
+ // ...
196
+ // everything related to app configuration
197
+ }
198
+
199
+ type ExternalDependencies = {
200
+ logger: Logger // most likely you would like to reuse logger instance from fastify app
201
+ }
202
+
203
+ const context = new DIContext<ModuleDependencies, AppConfig, ExternalDependencies>(container, {
204
+ messageQueueConsumersEnabled: [MessageQueueConsumer.QUEUE_ID],
205
+ jobQueuesEnabled: false,
206
+ jobWorkersEnabled: false,
207
+ periodicJobsEnabled: false,
208
+ })
209
+
210
+ context.registerDependencies({
211
+ modules: [module],
212
+ dependencyOverrides: {}, // dependency overrides if necessary, usually for testing purposes
213
+ configOverrides: {}, // config overrides if necessary, will be merged with value inside existing config
214
+ configDependencyId?: string // what is the dependency id in the graph for the config entity. Only used for config overrides. Default value is `config`
215
+ },
216
+ // external dependencies that are instantiated outside of DI
217
+ {
218
+ logger: app.logger
219
+ })
220
+
221
+ const app = fastify()
222
+ app.setValidatorCompiler(validatorCompiler)
223
+ app.setSerializerCompiler(serializerCompiler)
224
+
225
+ app.after(() => {
226
+ context.registerRoutes(app)
227
+ })
228
+ await app.ready()
229
+ ```
230
+
231
+ ## Resolver Functions
232
+
233
+ 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.
234
+
235
+ ### Basic Resolvers
236
+
237
+ #### `asSingletonClass(Type, opts?)`
238
+ Basic singleton class resolver. Use for general-purpose dependencies that don't fit other categories.
239
+
240
+ ```ts
241
+ service: asSingletonClass(MyService)
242
+ ```
243
+
244
+ #### `asSingletonFunction(fn, opts?)`
245
+ Basic singleton function resolver. Use when you need to resolve a dependency using a factory function.
246
+
247
+ ```ts
248
+ config: asSingletonFunction(() => loadConfig())
249
+ ```
250
+
251
+ #### `asClassWithConfig(Type, config, opts?)`
252
+ Register a class with an additional config parameter passed to the constructor. Uses `asFunction` wrapper internally to pass the config as a second parameter. Requires PROXY injection mode.
253
+
254
+ ```ts
255
+ myService: asClassWithConfig(MyService, { enableFeature: true })
256
+ ```
257
+
258
+ The class constructor receives dependencies as the first parameter and config as the second:
259
+
260
+ ```ts
261
+ class MyService {
262
+ constructor(deps: Dependencies, config: { enableFeature: boolean }) {
263
+ // ...
264
+ }
265
+ }
266
+ ```
267
+
268
+ ### Domain Layer Resolvers
269
+
270
+ #### `asServiceClass(Type, opts?)`
271
+ For service classes. Marks the dependency as **public** (exposed when module is used as secondary).
272
+
273
+ ```ts
274
+ userService: asServiceClass(UserService)
275
+ ```
276
+
277
+ #### `asUseCaseClass(Type, opts?)`
278
+ For use case classes. Marks the dependency as **public**.
279
+
280
+ ```ts
281
+ createUserUseCase: asUseCaseClass(CreateUserUseCase)
282
+ ```
283
+
284
+ #### `asRepositoryClass(Type, opts?)`
285
+ For repository classes. Marks the dependency as **private** (not exposed when module is secondary).
286
+
287
+ ```ts
288
+ userRepository: asRepositoryClass(UserRepository)
289
+ ```
290
+
291
+ #### `asControllerClass(Type, opts?)`
292
+ For REST controller classes. Marks the dependency as **private**. Use in `resolveControllers()`.
293
+
294
+ ```ts
295
+ userController: asControllerClass(UserController)
296
+ ```
297
+
298
+ #### `asSSEControllerClass(Type, sseOptions?, opts?)`
299
+ 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.
300
+
301
+ ```ts
302
+ // In resolveControllers()
303
+ resolveControllers(diOptions: DependencyInjectionOptions) {
304
+ return {
305
+ userController: asControllerClass(UserController),
306
+ notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
307
+ }
308
+ }
309
+ ```
310
+
311
+ #### `asDualModeControllerClass(Type, sseOptions?, opts?)`
312
+ 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.
313
+
314
+ ```ts
315
+ // In resolveControllers()
316
+ resolveControllers(diOptions: DependencyInjectionOptions) {
317
+ return {
318
+ userController: asControllerClass(UserController),
319
+ chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
320
+ }
321
+ }
322
+ ```
323
+
324
+ ### Message Queue Resolvers
325
+
326
+ #### `asMessageQueueHandlerClass(Type, mqOptions, opts?)`
327
+ For message queue consumers following `message-queue-toolkit` conventions. Automatically handles `start`/`close` lifecycle and respects `messageQueueConsumersEnabled` option.
328
+
329
+ ```ts
330
+ messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
331
+ queueName: MessageQueueConsumer.QUEUE_ID,
332
+ diOptions,
333
+ })
334
+ ```
335
+
336
+ ### Background Job Resolvers
337
+
338
+ #### `asEnqueuedJobWorkerClass(Type, workerOptions, opts?)`
339
+ For enqueued job workers following `background-jobs-common` conventions. Automatically handles `start`/`dispose` lifecycle and respects `enqueuedJobWorkersEnabled` option.
340
+
341
+ ```ts
342
+ jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
343
+ queueName: JobWorker.QUEUE_ID,
344
+ diOptions,
345
+ })
346
+ ```
347
+
348
+ #### `asPgBossProcessorClass(Type, processorOptions, opts?)`
349
+ For pg-boss job processor classes. Similar to `asEnqueuedJobWorkerClass` but uses `start`/`stop` lifecycle methods and initializes after pgBoss (priority 20).
350
+
351
+ ```ts
352
+ enrichUserPresenceJob: asPgBossProcessorClass(EnrichUserPresenceJob, {
353
+ queueName: EnrichUserPresenceJob.QUEUE_ID,
354
+ diOptions,
355
+ })
356
+ ```
357
+
358
+ #### `asPeriodicJobClass(Type, workerOptions, opts?)`
359
+ For periodic job classes following `background-jobs-common` conventions. Uses eager injection via `register` method and respects `periodicJobsEnabled` option.
360
+
361
+ ```ts
362
+ cleanupJob: asPeriodicJobClass(CleanupJob, {
363
+ jobName: CleanupJob.JOB_NAME,
364
+ diOptions,
365
+ })
366
+ ```
367
+
368
+ #### `asJobQueueClass(Type, queueOptions, opts?)`
369
+ For job queue classes. Marks the dependency as **public**. Respects `jobQueuesEnabled` option.
370
+
371
+ ```ts
372
+ queueManager: asJobQueueClass(QueueManager, {
373
+ diOptions,
374
+ })
375
+ ```
376
+
377
+ #### `asEnqueuedJobQueueManagerFunction(fn, diOptions, opts?)`
378
+ For job queue manager factory functions. Automatically calls `start()` with resolved enabled queues during initialization.
379
+
380
+ ```ts
381
+ jobQueueManager: asEnqueuedJobQueueManagerFunction(
382
+ createJobQueueManager,
383
+ diOptions,
384
+ )
385
+ ```
386
+
387
+ ## Server-Sent Events (SSE)
388
+
389
+ 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).
390
+
391
+ ### Prerequisites
392
+
393
+ Register the `@fastify/sse` plugin before using SSE controllers:
394
+
395
+ ```ts
396
+ import FastifySSEPlugin from '@fastify/sse'
397
+
398
+ const app = fastify()
399
+ await app.register(FastifySSEPlugin)
400
+ ```
401
+
402
+ ### Defining SSE Contracts
403
+
404
+ 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:
405
+
406
+ ```ts
407
+ import { z } from 'zod'
408
+ import { buildSseContract } from '@lokalise/api-contracts'
409
+
410
+ // GET-based SSE stream with path params
411
+ export const channelStreamContract = buildSseContract({
412
+ method: 'get',
413
+ pathResolver: (params) => `/api/channels/${params.channelId}/stream`,
414
+ requestPathParamsSchema: z.object({ channelId: z.string() }),
415
+ requestQuerySchema: z.object({}),
416
+ requestHeaderSchema: z.object({}),
417
+ serverSentEventSchemas: {
418
+ message: z.object({ content: z.string() }),
419
+ },
420
+ })
421
+
422
+ // GET-based SSE stream without path params
423
+ export const notificationsContract = buildSseContract({
424
+ method: 'get',
425
+ pathResolver: () => '/api/notifications/stream',
426
+ requestPathParamsSchema: z.object({}),
427
+ requestQuerySchema: z.object({ userId: z.string().optional() }),
428
+ requestHeaderSchema: z.object({}),
429
+ serverSentEventSchemas: {
430
+ notification: z.object({
431
+ id: z.string(),
432
+ message: z.string(),
433
+ }),
434
+ },
435
+ })
436
+
437
+ // POST-based SSE stream (e.g., AI chat completions)
438
+ export const chatCompletionContract = buildSseContract({
439
+ method: 'post',
440
+ pathResolver: () => '/api/chat/completions',
441
+ requestPathParamsSchema: z.object({}),
442
+ requestQuerySchema: z.object({}),
443
+ requestHeaderSchema: z.object({}),
444
+ requestBodySchema: z.object({
445
+ message: z.string(),
446
+ stream: z.literal(true),
447
+ }),
448
+ serverSentEventSchemas: {
449
+ chunk: z.object({ content: z.string() }),
450
+ done: z.object({ totalTokens: z.number() }),
451
+ },
452
+ })
453
+ ```
454
+
455
+ For reusable event schema definitions, you can use the `SSEEventSchemas` type (requires TypeScript 4.9+ for `satisfies`):
456
+
457
+ ```ts
458
+ import { z } from 'zod'
459
+ import type { SSEEventSchemas } from 'opinionated-machine'
460
+
461
+ // Define reusable event schemas for multiple contracts
462
+ const streamingEvents = {
463
+ chunk: z.object({ content: z.string() }),
464
+ done: z.object({ totalTokens: z.number() }),
465
+ error: z.object({ code: z.number(), message: z.string() }),
466
+ } satisfies SSEEventSchemas
467
+ ```
468
+
469
+ ### Creating SSE Controllers
470
+
471
+ SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildHandler` for automatic type inference of request parameters:
472
+
473
+ ```ts
474
+ import {
475
+ AbstractSSEController,
476
+ buildHandler,
477
+ type SSEControllerConfig,
478
+ type SSESession
479
+ } from 'opinionated-machine'
480
+
481
+ type Contracts = {
482
+ notificationsStream: typeof notificationsContract
483
+ }
484
+
485
+ type Dependencies = {
486
+ notificationService: NotificationService
487
+ }
488
+
489
+ export class NotificationsSSEController extends AbstractSSEController<Contracts> {
490
+ public static contracts = {
491
+ notificationsStream: notificationsContract,
492
+ } as const
493
+
494
+ private readonly notificationService: NotificationService
495
+
496
+ // Required: two-parameter constructor (deps object, optional SSE config)
497
+ constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
498
+ super(deps, sseConfig)
499
+ this.notificationService = deps.notificationService
500
+ }
501
+
502
+ public buildSSERoutes() {
503
+ return {
504
+ notificationsStream: this.handleStream,
505
+ }
506
+ }
507
+
508
+ // Handler with automatic type inference from contract
509
+ // sse.start(mode) returns a session with type-safe event sending
510
+ // Options (onConnect, onClose) are passed as the third parameter to buildHandler
511
+ private handleStream = buildHandler(notificationsContract, {
512
+ sse: async (request, sse) => {
513
+ // request.query is typed from contract: { userId?: string }
514
+ const userId = request.query.userId ?? 'anonymous'
515
+
516
+ // Start streaming with 'keepAlive' mode - stays open for external events
517
+ // Sends HTTP 200 + SSE headers immediately
518
+ const session = sse.start('keepAlive', { context: { userId } })
519
+
520
+ // For external triggers (subscriptions, timers, message queues), use sendEventInternal.
521
+ // session.send is only available within this handler's scope - external callbacks
522
+ // like subscription handlers execute later, outside this function, so they can't access session.
523
+ // sendEventInternal is a controller method, so it's accessible from any callback.
524
+ // It provides autocomplete for all event names defined in the controller's contracts.
525
+ this.notificationService.subscribe(userId, async (notification) => {
526
+ await this.sendEventInternal(session.id, {
527
+ event: 'notification',
528
+ data: notification,
529
+ })
530
+ })
531
+
532
+ // For direct sending within the handler, use the session's send method.
533
+ // It provides stricter per-route typing (only events from this specific contract).
534
+ await session.send('notification', { id: 'welcome', message: 'Connected!' })
535
+
536
+ // 'keepAlive' mode: handler returns, but connection stays open for subscription events
537
+ // Connection closes when client disconnects or server calls closeConnection()
538
+ },
539
+ }, {
540
+ onConnect: (session) => console.log('Client connected:', session.id),
541
+ onClose: (session, reason) => {
542
+ const userId = session.context?.userId as string
543
+ this.notificationService.unsubscribe(userId)
544
+ console.log(`Client disconnected (${reason}):`, session.id)
545
+ },
546
+ })
547
+ }
548
+ ```
549
+
550
+ ### Type-Safe SSE Handlers with `buildHandler`
551
+
552
+ For automatic type inference of request parameters (similar to `buildFastifyRoute` for regular controllers), use `buildHandler`:
553
+
554
+ ```ts
555
+ import {
556
+ AbstractSSEController,
557
+ buildHandler,
558
+ type SSEControllerConfig,
559
+ type SSESession
560
+ } from 'opinionated-machine'
561
+
562
+ class ChatSSEController extends AbstractSSEController<Contracts> {
563
+ public static contracts = {
564
+ chatCompletion: chatCompletionContract,
565
+ } as const
566
+
567
+ constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
568
+ super(deps, sseConfig)
569
+ }
570
+
571
+ // Handler with automatic type inference from contract
572
+ // sse.start(mode) returns session with fully typed send()
573
+ private handleChatCompletion = buildHandler(chatCompletionContract, {
574
+ sse: async (request, sse) => {
575
+ // request.body is typed as { message: string; stream: true }
576
+ // request.query, request.params, request.headers all typed from contract
577
+ const words = request.body.message.split(' ')
578
+
579
+ // Start streaming with 'autoClose' mode - closes after handler completes
580
+ // Sends HTTP 200 + SSE headers immediately
581
+ const session = sse.start('autoClose')
582
+
583
+ for (const word of words) {
584
+ // session.send() provides compile-time type checking for event names and data
585
+ await session.send('chunk', { content: word })
586
+ }
587
+
588
+ // 'autoClose' mode: connection closes automatically when handler returns
589
+ },
590
+ })
591
+
592
+ public buildSSERoutes() {
593
+ return {
594
+ chatCompletion: this.handleChatCompletion,
595
+ }
596
+ }
597
+ }
598
+ ```
599
+
600
+ You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
601
+
602
+ ```ts
603
+ import { type InferSSERequest, type SSEContext, type SSESession } from 'opinionated-machine'
604
+
605
+ private handleStream = async (
606
+ request: InferSSERequest<typeof chatCompletionContract>,
607
+ sse: SSEContext<typeof chatCompletionContract['serverSentEventSchemas']>,
608
+ ) => {
609
+ // request.body, request.params, etc. all typed from contract
610
+ const session = sse.start('autoClose')
611
+ // session.send() is typed based on contract serverSentEventSchemas
612
+ await session.send('chunk', { content: 'hello' })
613
+ // 'autoClose' mode: connection closes when handler returns
614
+ }
615
+ ```
616
+
617
+ ### SSE Controllers Without Dependencies
618
+
619
+ For controllers without dependencies, still provide the two-parameter constructor:
620
+
621
+ ```ts
622
+ export class SimpleSSEController extends AbstractSSEController<Contracts> {
623
+ constructor(deps: object, sseConfig?: SSEControllerConfig) {
624
+ super(deps, sseConfig)
625
+ }
626
+
627
+ // ... implementation
628
+ }
629
+ ```
630
+
631
+ ### Registering SSE Controllers
632
+
633
+ 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:
634
+
635
+ ```ts
636
+ import { AbstractModule, asControllerClass, asSSEControllerClass, asServiceClass, type DependencyInjectionOptions } from 'opinionated-machine'
637
+
638
+ export class NotificationsModule extends AbstractModule<Dependencies> {
639
+ resolveDependencies() {
640
+ return {
641
+ notificationService: asServiceClass(NotificationService),
642
+ }
643
+ }
644
+
645
+ resolveControllers(diOptions: DependencyInjectionOptions) {
646
+ return {
647
+ // REST controller
648
+ usersController: asControllerClass(UsersController),
649
+ // SSE controller (automatically detected and registered for SSE routes)
650
+ notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
651
+ }
652
+ }
653
+ }
654
+ ```
655
+
656
+ ### Registering SSE Routes
657
+
658
+ Call `registerSSERoutes` after registering the `@fastify/sse` plugin:
659
+
660
+ ```ts
661
+ const app = fastify()
662
+ app.setValidatorCompiler(validatorCompiler)
663
+ app.setSerializerCompiler(serializerCompiler)
664
+
665
+ // Register @fastify/sse plugin first
666
+ await app.register(FastifySSEPlugin)
667
+
668
+ // Then register SSE routes
669
+ context.registerSSERoutes(app)
670
+
671
+ // Optionally with global preHandler for authentication
672
+ context.registerSSERoutes(app, {
673
+ preHandler: async (request, reply) => {
674
+ if (!request.headers.authorization) {
675
+ reply.code(401).send({ error: 'Unauthorized' })
676
+ }
677
+ },
678
+ })
679
+
680
+ await app.ready()
681
+ ```
682
+
683
+ ### Broadcasting Events
684
+
685
+ Send events to multiple connections using `broadcast()` or `broadcastIf()`:
686
+
687
+ ```ts
688
+ // Broadcast to ALL connected clients
689
+ await this.broadcast({
690
+ event: 'system',
691
+ data: { message: 'Server maintenance in 5 minutes' },
692
+ })
693
+
694
+ // Broadcast to sessions matching a predicate
695
+ await this.broadcastIf(
696
+ { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
697
+ (session) => session.context.channelId === '123',
698
+ )
699
+ ```
700
+
701
+ Both methods return the number of clients the message was successfully sent to.
702
+
703
+ ### Controller-Level Hooks
704
+
705
+ Override these optional methods on your controller for global session handling:
706
+
707
+ ```ts
708
+ class MySSEController extends AbstractSSEController<Contracts> {
709
+ // Called AFTER session is registered (for all routes)
710
+ protected onConnectionEstablished(session: SSESession): void {
711
+ this.metrics.incrementConnections()
712
+ }
713
+
714
+ // Called BEFORE session is unregistered (for all routes)
715
+ protected onConnectionClosed(session: SSESession): void {
716
+ this.metrics.decrementConnections()
717
+ }
718
+ }
719
+ ```
720
+
721
+ ### Route-Level Options
722
+
723
+ Each route can have its own `preHandler`, lifecycle hooks, and logger. Pass these as the third parameter to `buildHandler`:
724
+
725
+ ```ts
726
+ public buildSSERoutes() {
727
+ return {
728
+ adminStream: this.handleAdminStream,
729
+ }
730
+ }
731
+
732
+ private handleAdminStream = buildHandler(adminStreamContract, {
733
+ sse: async (request, sse) => {
734
+ const session = sse.start('keepAlive')
735
+ // ... handler logic
736
+ },
737
+ }, {
738
+ // Route-specific authentication
739
+ preHandler: (request, reply) => {
740
+ if (!request.user?.isAdmin) {
741
+ reply.code(403).send({ error: 'Forbidden' })
742
+ }
743
+ },
744
+ onConnect: (session) => console.log('Admin connected'),
745
+ onClose: (session, reason) => console.log(`Admin disconnected (${reason})`),
746
+ // Handle client reconnection with Last-Event-ID
747
+ onReconnect: async (session, lastEventId) => {
748
+ // Return events to replay, or handle manually
749
+ return this.getEventsSince(lastEventId)
750
+ },
751
+ // Optional: logger for error handling (requires @lokalise/node-core)
752
+ logger: this.logger,
753
+ })
754
+ ```
755
+
756
+ **Available route options:**
757
+
758
+ | Option | Description |
759
+ | -------- | ------------- |
760
+ | `preHandler` | Authentication/authorization hook that runs before SSE session |
761
+ | `onConnect` | Called after client connects (SSE handshake complete) |
762
+ | `onClose` | Called when session closes (client disconnect, network failure, or server close). Receives `(session, reason)` where reason is `'server'` or `'client'` |
763
+ | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
764
+ | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
765
+ | `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
766
+ | `heartbeatInterval` | Interval in ms for heartbeat keep-alive messages |
767
+
768
+ **onClose reason parameter:**
769
+ - `'server'`: Server explicitly closed the session (via `closeConnection()` or `autoClose` mode)
770
+ - `'client'`: Client closed the session (EventSource.close(), navigation, network failure)
771
+
772
+ ```ts
773
+ options: {
774
+ onConnect: (session) => console.log('Client connected'),
775
+ onClose: (session, reason) => {
776
+ console.log(`Session closed (${reason}):`, session.id)
777
+ // reason is 'server' or 'client'
778
+ },
779
+ serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
780
+ heartbeatInterval: 30000, // Send heartbeat every 30 seconds
781
+ }
782
+ ```
783
+
784
+ ### SSE Session Methods
785
+
786
+ The `session` object returned by `sse.start(mode)` provides several useful methods:
787
+
788
+ ```ts
789
+ private handleStream = buildHandler(streamContract, {
790
+ sse: async (request, sse) => {
791
+ const session = sse.start('autoClose')
792
+
793
+ // Check if session is still active
794
+ if (session.isConnected()) {
795
+ await session.send('status', { connected: true })
796
+ }
797
+
798
+ // Get raw writable stream for advanced use cases (e.g., pipeline)
799
+ const stream = session.getStream()
800
+
801
+ // Stream messages from an async iterable with automatic validation
802
+ async function* generateMessages() {
803
+ yield { event: 'message' as const, data: { text: 'Hello' } }
804
+ yield { event: 'message' as const, data: { text: 'World' } }
805
+ }
806
+ await session.sendStream(generateMessages())
807
+
808
+ // 'autoClose' mode: connection closes when handler returns
809
+ },
810
+ })
811
+ ```
812
+
813
+ | Method | Description |
814
+ | -------- | ------------- |
815
+ | `send(event, data, options?)` | Send a typed event (validates against contract schema) |
816
+ | `isConnected()` | Check if the session is still active |
817
+ | `getStream()` | Get the underlying `WritableStream` for advanced use cases |
818
+ | `sendStream(messages)` | Stream messages from an `AsyncIterable` with validation |
819
+
820
+ ### Graceful Shutdown
821
+
822
+ 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).
823
+
824
+ ### Error Handling
825
+
826
+ When `sendEvent()` fails (e.g., client disconnected), it:
827
+ - Returns `false` to indicate failure
828
+ - Automatically removes the dead connection from tracking
829
+ - Prevents further send attempts to that connection
830
+
831
+ ```ts
832
+ const sent = await this.sendEvent(connectionId, { event: 'update', data })
833
+ if (!sent) {
834
+ // Connection was closed or failed - already removed from tracking
835
+ this.cleanup(connectionId)
836
+ }
837
+ ```
838
+
839
+ **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onClose`):
840
+ - All lifecycle hooks are wrapped in try/catch to prevent crashes
841
+ - If a `logger` is provided in route options, errors are logged with context
842
+ - If no logger is provided, errors are silently ignored
843
+ - The session lifecycle continues even if a hook throws
844
+
845
+ ```ts
846
+ // Provide a logger to capture lifecycle errors
847
+ public buildSSERoutes() {
848
+ return {
849
+ stream: this.handleStream,
850
+ }
851
+ }
852
+
853
+ private handleStream = buildHandler(streamContract, {
854
+ sse: async (request, sse) => {
855
+ const session = sse.start('autoClose')
856
+ // ... handler logic
857
+ },
858
+ }, {
859
+ logger: this.logger, // pino-compatible logger
860
+ onConnect: (session) => { /* may throw */ },
861
+ onClose: (session, reason) => { /* may throw */ },
862
+ })
863
+ ```
864
+
865
+ ### Long-lived Connections vs Request-Response Streaming
866
+
867
+ SSE session lifetime is determined by the mode passed to `sse.start(mode)`:
868
+
869
+ ```ts
870
+ // sse.start('autoClose') - close connection when handler returns (request-response pattern)
871
+ // sse.start('keepAlive') - keep connection open for external events (subscription pattern)
872
+ // sse.respond(code, body) - send HTTP response before streaming (early return)
873
+ ```
874
+
875
+ **Long-lived sessions** (notifications, live updates):
876
+ - Handler starts streaming with `sse.start('keepAlive')`
877
+ - Session stays open indefinitely after handler returns
878
+ - Events are sent later via callbacks using `sendEventInternal()`
879
+ - **Client closes session** when done (e.g., `eventSource.close()` or navigating away)
880
+ - Server cleans up via `onConnectionClosed()` hook
881
+
882
+ ```ts
883
+ private handleStream = buildHandler(streamContract, {
884
+ sse: async (request, sse) => {
885
+ // Start streaming with 'keepAlive' mode - stays open for external events
886
+ const session = sse.start('keepAlive')
887
+
888
+ // Set up subscription - events sent via callback AFTER handler returns
889
+ this.service.subscribe(session.id, (data) => {
890
+ this.sendEventInternal(session.id, { event: 'update', data })
891
+ })
892
+ // 'keepAlive' mode: handler returns, but connection stays open
893
+ },
894
+ })
895
+
896
+ // Clean up when client disconnects
897
+ protected onConnectionClosed(session: SSESession): void {
898
+ this.service.unsubscribe(session.id)
899
+ }
900
+ ```
901
+
902
+ **Request-response streaming** (AI completions):
903
+ - Handler starts streaming with `sse.start('autoClose')`
904
+ - Use `session.send()` for type-safe event sending within the handler
905
+ - Session automatically closes when handler returns
906
+
907
+ ```ts
908
+ private handleChatCompletion = buildHandler(chatCompletionContract, {
909
+ sse: async (request, sse) => {
910
+ // Start streaming with 'autoClose' mode - closes when handler returns
911
+ const session = sse.start('autoClose')
912
+
913
+ const words = request.body.message.split(' ')
914
+ for (const word of words) {
915
+ await session.send('chunk', { content: word })
916
+ }
917
+ await session.send('done', { totalTokens: words.length })
918
+
919
+ // 'autoClose' mode: connection closes automatically when handler returns
920
+ },
921
+ })
922
+ ```
923
+
924
+ **Error handling before streaming:**
925
+
926
+ 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.
927
+
928
+ ```ts
929
+ private handleStream = buildHandler(streamContract, {
930
+ sse: async (request, sse) => {
931
+ // Early return BEFORE starting stream - can return any HTTP response
932
+ const entity = await this.service.find(request.params.id)
933
+ if (!entity) {
934
+ return sse.respond(404, { error: 'Entity not found' })
935
+ }
936
+
937
+ // Validation passed - start streaming with autoClose mode
938
+ const session = sse.start('autoClose')
939
+ await session.send('data', entity)
940
+ // Connection closes automatically when handler returns
941
+ },
942
+ })
943
+
944
+ ### SSE Parsing Utilities
945
+
946
+ The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
947
+
948
+ | Function | Use Case |
949
+ |----------|----------|
950
+ | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
951
+ | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
952
+
953
+ #### parseSSEEvents
954
+
955
+ Parse a complete SSE response body into an array of events.
956
+
957
+ **When to use:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
958
+
959
+ ```ts
960
+ import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
961
+
962
+ const responseBody = `event: notification
963
+ data: {"id":"1","message":"Hello"}
964
+
965
+ event: notification
966
+ data: {"id":"2","message":"World"}
967
+
968
+ `
969
+
970
+ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
971
+ // Result:
972
+ // [
973
+ // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
974
+ // { event: 'notification', data: '{"id":"2","message":"World"}' }
975
+ // ]
976
+
977
+ // Access parsed data
978
+ const notifications = events.map(e => JSON.parse(e.data))
979
+ ```
980
+
981
+ #### parseSSEBuffer
982
+
983
+ Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
984
+
985
+ **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
986
+
987
+ ```ts
988
+ import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
989
+
990
+ let buffer = ''
991
+
992
+ // As chunks arrive from a stream...
993
+ for await (const chunk of stream) {
994
+ buffer += chunk
995
+ const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
996
+
997
+ // Process complete events
998
+ for (const event of result.events) {
999
+ console.log('Received:', event.event, event.data)
1000
+ }
1001
+
1002
+ // Keep incomplete data for next chunk
1003
+ buffer = result.remaining
1004
+ }
1005
+ ```
1006
+
1007
+ **Production example with fetch:**
1008
+
1009
+ ```ts
1010
+ const response = await fetch(url)
1011
+ const reader = response.body!.getReader()
1012
+ const decoder = new TextDecoder()
1013
+ let buffer = ''
1014
+
1015
+ while (true) {
1016
+ const { done, value } = await reader.read()
1017
+ if (done) break
1018
+
1019
+ buffer += decoder.decode(value, { stream: true })
1020
+ const { events, remaining } = parseSSEBuffer(buffer)
1021
+ buffer = remaining
1022
+
1023
+ for (const event of events) {
1024
+ console.log('Received:', event.event, JSON.parse(event.data))
1025
+ }
1026
+ }
1027
+ ```
1028
+
1029
+ #### ParsedSSEEvent Type
1030
+
1031
+ Both functions return events with this structure:
1032
+
1033
+ ```ts
1034
+ type ParsedSSEEvent = {
1035
+ id?: string // Event ID (from "id:" field)
1036
+ event?: string // Event type (from "event:" field)
1037
+ data: string // Event data (from "data:" field, always present)
1038
+ retry?: number // Reconnection interval (from "retry:" field)
1039
+ }
1040
+ ```
1041
+
1042
+ ### Testing SSE Controllers
1043
+
1044
+ Enable the connection spy for testing by passing `isTestMode: true` in diOptions:
1045
+
1046
+ ```ts
1047
+ import { createContainer } from 'awilix'
1048
+ import { DIContext, SSETestServer, SSEHttpClient } from 'opinionated-machine'
1049
+
1050
+ describe('NotificationsSSEController', () => {
1051
+ let server: SSETestServer
1052
+ let controller: NotificationsSSEController
1053
+
1054
+ beforeEach(async () => {
1055
+ // Create test server with isTestMode enabled
1056
+ server = await SSETestServer.create(
1057
+ async (app) => {
1058
+ // Register your SSE routes here
1059
+ },
1060
+ {
1061
+ setup: async () => {
1062
+ // Set up DI container and resources
1063
+ return { context }
1064
+ },
1065
+ }
1066
+ )
1067
+
1068
+ controller = server.resources.context.diContainer.cradle.notificationsSSEController
1069
+ })
1070
+
1071
+ afterEach(async () => {
1072
+ await server.resources.context.destroy()
1073
+ await server.close()
1074
+ })
1075
+
1076
+ it('receives notifications over SSE', async () => {
1077
+ // Connect with awaitServerConnection to eliminate race condition
1078
+ const { client, serverConnection } = await SSEHttpClient.connect(
1079
+ server.baseUrl,
1080
+ '/api/notifications/stream',
1081
+ {
1082
+ query: { userId: 'test-user' },
1083
+ awaitServerConnection: { controller },
1084
+ },
1085
+ )
1086
+
1087
+ expect(client.response.ok).toBe(true)
1088
+
1089
+ // Start collecting events
1090
+ const eventsPromise = client.collectEvents(2)
1091
+
1092
+ // Send events from server (serverConnection is ready immediately)
1093
+ await controller.sendEvent(serverConnection.id, {
1094
+ event: 'notification',
1095
+ data: { id: '1', message: 'Hello!' },
1096
+ })
1097
+
1098
+ await controller.sendEvent(serverConnection.id, {
1099
+ event: 'notification',
1100
+ data: { id: '2', message: 'World!' },
1101
+ })
1102
+
1103
+ // Wait for events
1104
+ const events = await eventsPromise
1105
+
1106
+ expect(events).toHaveLength(2)
1107
+ expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
1108
+ expect(JSON.parse(events[1].data)).toEqual({ id: '2', message: 'World!' })
1109
+
1110
+ // Clean up
1111
+ client.close()
1112
+ })
1113
+ })
1114
+ ```
1115
+
1116
+ ### SSESessionSpy API
1117
+
1118
+ The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
1119
+
1120
+ ```ts
1121
+ // Wait for a session to be established (with timeout)
1122
+ const session = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
1123
+
1124
+ // Wait for a session matching a predicate (useful for multiple sessions)
1125
+ const session = await controller.connectionSpy.waitForConnection({
1126
+ timeout: 5000,
1127
+ predicate: (s) => s.request.url.includes('/api/notifications'),
1128
+ })
1129
+
1130
+ // Check if a specific session is active
1131
+ const isConnected = controller.connectionSpy.isConnected(sessionId)
1132
+
1133
+ // Wait for a specific session to disconnect
1134
+ await controller.connectionSpy.waitForDisconnection(sessionId, { timeout: 5000 })
1135
+
1136
+ // Get all session events (connect/disconnect history)
1137
+ const events = controller.connectionSpy.getEvents()
1138
+
1139
+ // Clear event history and claimed sessions between tests
1140
+ controller.connectionSpy.clear()
1141
+ ```
1142
+
1143
+ **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`.
1144
+
1145
+ ### Session Monitoring
1146
+
1147
+ Controllers have access to utility methods for monitoring sessions:
1148
+
1149
+ ```ts
1150
+ // Get count of active sessions
1151
+ const count = this.getConnectionCount()
1152
+
1153
+ // Get all active sessions (for iteration/inspection)
1154
+ const sessions = this.getConnections()
1155
+
1156
+ // Check if session spy is enabled (useful for conditional logic)
1157
+ if (this.hasConnectionSpy()) {
1158
+ // ...
1159
+ }
1160
+ ```
1161
+
1162
+ ### SSE Test Utilities
1163
+
1164
+ The library provides utilities for testing SSE endpoints.
1165
+
1166
+ **Two transport methods:**
1167
+ - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the session for the request to complete.
1168
+ - **Real HTTP** - Actual HTTP via `fetch()`. Requires the server to be listening. Supports long-lived sessions.
1169
+
1170
+ #### Quick Reference
1171
+
1172
+ | Utility | Connection | Requires Contract | Use Case |
1173
+ |---------|------------|-------------------|----------|
1174
+ | `SSEInjectClient` | Inject (in-memory) | No | Request-response SSE without contracts |
1175
+ | `injectSSE` / `injectPayloadSSE` | Inject (in-memory) | **Yes** | Request-response SSE with type-safe contracts |
1176
+ | `SSEHttpClient` | Real HTTP | No | Long-lived SSE connections |
1177
+
1178
+ `SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
1179
+
1180
+ #### Inject vs HTTP Comparison
1181
+
1182
+ | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
1183
+ |---------|----------------------------------------|------------------------|
1184
+ | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
1185
+ | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
1186
+ | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
1187
+ | **Server requirement** | No `listen()` needed | Requires running server |
1188
+ | **Best for** | OpenAI-style streaming, batch exports | Notifications, live feeds, chat |
1189
+
1190
+ #### SSETestServer
1191
+
1192
+ Creates a test server with `@fastify/sse` pre-configured:
1193
+
1194
+ ```ts
1195
+ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1196
+
1197
+ // Basic usage
1198
+ const server = await SSETestServer.create(async (app) => {
1199
+ app.get('/api/events', async (request, reply) => {
1200
+ reply.sse({ event: 'message', data: { hello: 'world' } })
1201
+ reply.sse.close()
1202
+ })
1203
+ })
1204
+
1205
+ // Connect and test
1206
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/events')
1207
+ const events = await client.collectEvents(1)
1208
+ expect(events[0].event).toBe('message')
1209
+
1210
+ // Cleanup
1211
+ client.close()
1212
+ await server.close()
1213
+ ```
1214
+
1215
+ With custom resources (DI container, controllers):
1216
+
1217
+ ```ts
1218
+ const server = await SSETestServer.create(
1219
+ async (app) => {
1220
+ // Register routes using resources from setup
1221
+ myController.registerRoutes(app)
1222
+ },
1223
+ {
1224
+ configureApp: async (app) => {
1225
+ app.setValidatorCompiler(validatorCompiler)
1226
+ },
1227
+ setup: async () => {
1228
+ // Resources are available via server.resources
1229
+ const container = createContainer()
1230
+ return { container }
1231
+ },
1232
+ }
1233
+ )
1234
+
1235
+ const { container } = server.resources
1236
+ ```
1237
+
1238
+ #### SSEHttpClient
1239
+
1240
+ For testing long-lived SSE connections using real HTTP:
1241
+
1242
+ ```ts
1243
+ import { SSEHttpClient } from 'opinionated-machine'
1244
+
1245
+ // Connect to SSE endpoint with awaitServerConnection (recommended)
1246
+ // This eliminates the race condition between client connect and server-side registration
1247
+ const { client, serverConnection } = await SSEHttpClient.connect(
1248
+ server.baseUrl,
1249
+ '/api/stream',
1250
+ {
1251
+ query: { userId: 'test' },
1252
+ headers: { authorization: 'Bearer token' },
1253
+ awaitServerConnection: { controller }, // Pass your SSE controller
1254
+ },
1255
+ )
1256
+
1257
+ // serverConnection is ready to use immediately
1258
+ expect(client.response.ok).toBe(true)
1259
+ await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1260
+
1261
+ // Collect events by count with timeout
1262
+ const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
1263
+
1264
+ // Or collect until a predicate is satisfied
1265
+ const events = await client.collectEvents(
1266
+ (event) => event.event === 'done',
1267
+ 5000,
1268
+ )
1269
+
1270
+ // Iterate over events as they arrive
1271
+ for await (const event of client.events()) {
1272
+ console.log(event.event, event.data)
1273
+ if (event.event === 'done') break
1274
+ }
1275
+
1276
+ // Cleanup
1277
+ client.close()
1278
+ ```
1279
+
1280
+ **`collectEvents(countOrPredicate, timeout?)`**
1281
+
1282
+ Collects events until a count is reached or a predicate returns true.
1283
+
1284
+ | Parameter | Type | Description |
1285
+ |-----------|------|-------------|
1286
+ | `countOrPredicate` | `number \| (event) => boolean` | Number of events to collect, or predicate that returns `true` when collection should stop |
1287
+ | `timeout` | `number` | Maximum time to wait in milliseconds (default: 5000) |
1288
+
1289
+ Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
1290
+
1291
+ ```ts
1292
+ // Collect exactly 3 events
1293
+ const events = await client.collectEvents(3)
1294
+
1295
+ // Collect with custom timeout
1296
+ const events = await client.collectEvents(5, 10000) // 10s timeout
1297
+
1298
+ // Collect until a specific event type (the matching event IS included)
1299
+ const events = await client.collectEvents((event) => event.event === 'done')
1300
+
1301
+ // Collect until condition with timeout
1302
+ const events = await client.collectEvents(
1303
+ (event) => JSON.parse(event.data).status === 'complete',
1304
+ 30000,
1305
+ )
1306
+ ```
1307
+
1308
+ **`events(signal?)`**
1309
+
1310
+ Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
1311
+
1312
+ ```ts
1313
+ // Basic iteration
1314
+ for await (const event of client.events()) {
1315
+ console.log(event.event, event.data)
1316
+ if (event.event === 'done') break
1317
+ }
1318
+
1319
+ // With abort signal for timeout control
1320
+ const controller = new AbortController()
1321
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
1322
+
1323
+ try {
1324
+ for await (const event of client.events(controller.signal)) {
1325
+ console.log(event)
1326
+ }
1327
+ } finally {
1328
+ clearTimeout(timeoutId)
1329
+ }
1330
+ ```
1331
+
1332
+ **When to omit `awaitServerConnection`**
1333
+
1334
+ Omit `awaitServerConnection` only in these cases:
1335
+ - Testing against external SSE endpoints (not your own controller)
1336
+ - When `isTestMode: false` (connectionSpy not available)
1337
+ - Simple smoke tests that only verify response headers/status without sending server events
1338
+
1339
+ **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.
1340
+
1341
+ ```ts
1342
+ // Example: smoke test that only checks connection works
1343
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
1344
+ expect(client.response.ok).toBe(true)
1345
+ expect(client.response.headers.get('content-type')).toContain('text/event-stream')
1346
+ client.close()
1347
+ ```
1348
+
1349
+ #### SSEInjectClient
1350
+
1351
+ For testing request-response style SSE streams (like OpenAI completions):
1352
+
1353
+ ```ts
1354
+ import { SSEInjectClient } from 'opinionated-machine'
1355
+
1356
+ const client = new SSEInjectClient(app) // No server.listen() needed
1357
+
1358
+ // GET request
1359
+ const conn = await client.connect('/api/export/progress', {
1360
+ headers: { authorization: 'Bearer token' },
1361
+ })
1362
+
1363
+ // POST request with body (OpenAI-style)
1364
+ const conn = await client.connectWithBody(
1365
+ '/api/chat/completions',
1366
+ { model: 'gpt-4', messages: [...], stream: true },
1367
+ )
1368
+
1369
+ // All events are available immediately (inject waits for complete response)
1370
+ expect(conn.getStatusCode()).toBe(200)
1371
+ const events = conn.getReceivedEvents()
1372
+ const chunks = events.filter(e => e.event === 'chunk')
1373
+ ```
1374
+
1375
+ #### Contract-Aware Inject Helpers
1376
+
1377
+ For typed testing with SSE contracts:
1378
+
1379
+ ```ts
1380
+ import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
1381
+
1382
+ // For GET SSE endpoints with contracts
1383
+ const { closed } = injectSSE(app, notificationsContract, {
1384
+ query: { userId: 'test' },
1385
+ })
1386
+ const result = await closed
1387
+ const events = parseSSEEvents(result.body)
1388
+
1389
+ // For POST/PUT/PATCH SSE endpoints with contracts
1390
+ const { closed } = injectPayloadSSE(app, chatCompletionContract, {
1391
+ body: { message: 'Hello', stream: true },
1392
+ })
1393
+ const result = await closed
1394
+ const events = parseSSEEvents(result.body)
1395
+ ```
1396
+
1397
+ ## Dual-Mode Controllers (SSE + Sync)
1398
+
1399
+ 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.
1400
+
1401
+ ### Overview
1402
+
1403
+ | Accept Header | Response Mode |
1404
+ | ------------- | ------------- |
1405
+ | `text/event-stream` | SSE streaming |
1406
+ | `application/json` | Sync response |
1407
+ | `*/*` or missing | Sync (default, configurable) |
1408
+
1409
+ 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.
1410
+
1411
+ ### Defining Dual-Mode Contracts
1412
+
1413
+ 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:
1414
+
1415
+ - Clients may want immediate results (sync) or real-time updates (SSE)
1416
+ - You're building OpenAI-style APIs where `stream: true` triggers SSE
1417
+ - You need polling fallback for clients that don't support SSE
1418
+
1419
+ To create a dual-mode contract, include a `successResponseBodySchema` in your `buildSseContract` call:
1420
+ - Has `successResponseBodySchema` but no `requestBodySchema` → GET dual-mode route
1421
+ - Has both `successResponseBodySchema` and `requestBodySchema` → POST/PUT/PATCH dual-mode route
1422
+
1423
+ ```ts
1424
+ import { z } from 'zod'
1425
+ import { buildSseContract } from '@lokalise/api-contracts'
1426
+
1427
+ // GET dual-mode route (polling or streaming job status)
1428
+ export const jobStatusContract = buildSseContract({
1429
+ method: 'get',
1430
+ pathResolver: (params) => `/api/jobs/${params.jobId}/status`,
1431
+ requestPathParamsSchema: z.object({ jobId: z.string().uuid() }),
1432
+ requestQuerySchema: z.object({ verbose: z.string().optional() }),
1433
+ requestHeaderSchema: z.object({}),
1434
+ successResponseBodySchema: z.object({
1435
+ status: z.enum(['pending', 'running', 'completed', 'failed']),
1436
+ progress: z.number(),
1437
+ result: z.string().optional(),
1438
+ }),
1439
+ serverSentEventSchemas: {
1440
+ progress: z.object({ percent: z.number(), message: z.string().optional() }),
1441
+ done: z.object({ result: z.string() }),
1442
+ },
1443
+ })
1444
+
1445
+ // POST dual-mode route (OpenAI-style chat completion)
1446
+ export const chatCompletionContract = buildSseContract({
1447
+ method: 'post',
1448
+ pathResolver: (params) => `/api/chats/${params.chatId}/completions`,
1449
+ requestPathParamsSchema: z.object({ chatId: z.string().uuid() }),
1450
+ requestQuerySchema: z.object({}),
1451
+ requestHeaderSchema: z.object({ authorization: z.string() }),
1452
+ requestBodySchema: z.object({ message: z.string() }),
1453
+ successResponseBodySchema: z.object({
1454
+ reply: z.string(),
1455
+ usage: z.object({ tokens: z.number() }),
1456
+ }),
1457
+ serverSentEventSchemas: {
1458
+ chunk: z.object({ delta: z.string() }),
1459
+ done: z.object({ usage: z.object({ total: z.number() }) }),
1460
+ },
1461
+ })
1462
+ ```
1463
+
1464
+ **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.
1465
+
1466
+ ### Response Headers (Sync Mode)
1467
+
1468
+ 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:
1469
+
1470
+ ```ts
1471
+ export const rateLimitedContract = buildSseContract({
1472
+ method: 'post',
1473
+ pathResolver: () => '/api/rate-limited',
1474
+ requestPathParamsSchema: z.object({}),
1475
+ requestQuerySchema: z.object({}),
1476
+ requestHeaderSchema: z.object({}),
1477
+ requestBodySchema: z.object({ data: z.string() }),
1478
+ successResponseBodySchema: z.object({ result: z.string() }),
1479
+ // Define expected response headers
1480
+ responseHeaderSchema: z.object({
1481
+ 'x-ratelimit-limit': z.string(),
1482
+ 'x-ratelimit-remaining': z.string(),
1483
+ 'x-ratelimit-reset': z.string(),
1484
+ }),
1485
+ serverSentEventSchemas: {
1486
+ result: z.object({ success: z.boolean() }),
1487
+ },
1488
+ })
1489
+ ```
1490
+
1491
+ In your handler, set headers using `reply.header()`:
1492
+
1493
+ ```ts
1494
+ handlers: buildHandler(rateLimitedContract, {
1495
+ sync: async (request, reply) => {
1496
+ reply.header('x-ratelimit-limit', '100')
1497
+ reply.header('x-ratelimit-remaining', '99')
1498
+ reply.header('x-ratelimit-reset', '1640000000')
1499
+ return { result: 'success' }
1500
+ },
1501
+ sse: async (request, sse) => {
1502
+ const session = sse.start('autoClose')
1503
+ // ... send events ...
1504
+ // Connection closes automatically when handler returns
1505
+ },
1506
+ })
1507
+ ```
1508
+
1509
+ If the handler doesn't set the required headers, validation will fail with a `RESPONSE_HEADERS_VALIDATION_FAILED` error.
1510
+
1511
+ ### Status-Specific Response Schemas (responseBodySchemasByStatusCode)
1512
+
1513
+ 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:
1514
+
1515
+ ```ts
1516
+ export const resourceContract = buildSseContract({
1517
+ method: 'post',
1518
+ pathResolver: (params) => `/api/resources/${params.id}`,
1519
+ requestPathParamsSchema: z.object({ id: z.string() }),
1520
+ requestQuerySchema: z.object({}),
1521
+ requestHeaderSchema: z.object({}),
1522
+ requestBodySchema: z.object({ data: z.string() }),
1523
+ // Success response (2xx)
1524
+ successResponseBodySchema: z.object({
1525
+ success: z.boolean(),
1526
+ data: z.string(),
1527
+ }),
1528
+ // Responses by status code (typically used for errors)
1529
+ responseBodySchemasByStatusCode: {
1530
+ 400: z.object({ error: z.string(), details: z.array(z.string()) }),
1531
+ 404: z.object({ error: z.string(), resourceId: z.string() }),
1532
+ },
1533
+ serverSentEventSchemas: {
1534
+ result: z.object({ success: z.boolean() }),
1535
+ },
1536
+ })
1537
+ ```
1538
+
1539
+ **Recommended: Use `sse.respond()` for strict type safety**
1540
+
1541
+ 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:
1542
+
1543
+ ```ts
1544
+ handlers: buildHandler(resourceContract, {
1545
+ sync: (request, reply) => {
1546
+ if (!isValid(request.body.data)) {
1547
+ reply.code(400)
1548
+ return { error: 'Bad Request', details: ['Invalid data format'] }
1549
+ }
1550
+ return { success: true, data: 'OK' }
1551
+ },
1552
+ sse: async (request, sse) => {
1553
+ const resource = findResource(request.params.id)
1554
+ if (!resource) {
1555
+ // Strict typing: TypeScript enforces exact schema for status 404
1556
+ return sse.respond(404, { error: 'Not Found', resourceId: request.params.id })
1557
+ }
1558
+ if (!isValid(resource)) {
1559
+ // Strict typing: TypeScript enforces exact schema for status 400
1560
+ return sse.respond(400, { error: 'Bad Request', details: ['Invalid resource'] })
1561
+ }
1562
+
1563
+ const session = sse.start('autoClose')
1564
+ await session.send('result', { success: true })
1565
+ },
1566
+ })
1567
+ ```
1568
+
1569
+ TypeScript enforces the exact schema for each status code at compile time:
1570
+
1571
+ ```ts
1572
+ sse.respond(404, { error: 'Not Found', resourceId: '123' }) // ✓ OK
1573
+ sse.respond(404, { error: 'Not Found' }) // ✗ Error - missing resourceId
1574
+ sse.respond(404, { error: 'Not Found', details: [] }) // Error - wrong schema for 404
1575
+ sse.respond(500, { message: 'error' }) // ✗ Error - 500 not defined in schema
1576
+ ```
1577
+
1578
+ Only status codes defined in `responseBodySchemasByStatusCode` are allowed. To use an undefined status code, add it to the schema or use a type assertion.
1579
+
1580
+ **Sync handlers (union typing with runtime validation):**
1581
+
1582
+ 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:
1583
+
1584
+ ```ts
1585
+ sync: (request, reply) => {
1586
+ reply.code(404)
1587
+ return { error: 'Not Found', resourceId: '123' } // OK - matches one of the union types
1588
+ // Runtime validation ensures body matches the 404 schema
1589
+ }
1590
+
1591
+ // The sync handler return type is automatically:
1592
+ // { success: boolean; data: string } // from successResponseBodySchema
1593
+ // | { error: string; details: string[] } // from responseBodySchemasByStatusCode[400]
1594
+ // | { error: string; resourceId: string } // from responseBodySchemasByStatusCode[404]
1595
+ ```
1596
+
1597
+ **Validation behavior:**
1598
+
1599
+ - **Success responses (2xx)**: Validated against `successResponseBodySchema`
1600
+ - **Non-2xx responses**: Validated against the matching schema in `responseBodySchemasByStatusCode` (if defined)
1601
+ - **Validation failures**: Return 500 Internal Server Error (validation details are logged internally, not exposed to clients)
1602
+
1603
+ **Validation priority for 2xx status codes:**
1604
+
1605
+ - All 2xx responses (200, 201, 204, etc.) are validated against `successResponseBodySchema`
1606
+ - `responseBodySchemasByStatusCode` is only used for non-2xx status codes
1607
+ - If you define the same 2xx code in both, `successResponseBodySchema` takes precedence
1608
+
1609
+ ### Single Sync Handler
1610
+
1611
+ Dual-mode contracts use a single `sync` handler that returns the response data. The framework handles content-type negotiation automatically:
1612
+
1613
+ ```ts
1614
+ handlers: buildHandler(chatCompletionContract, {
1615
+ sync: async (request, reply) => {
1616
+ // Return the response data matching successResponseBodySchema
1617
+ const result = await aiService.complete(request.body.message)
1618
+ return {
1619
+ reply: result.text,
1620
+ usage: { tokens: result.tokenCount },
1621
+ }
1622
+ },
1623
+ sse: async (request, sse) => {
1624
+ // SSE streaming handler
1625
+ const session = sse.start('autoClose')
1626
+ // ... stream events ...
1627
+ },
1628
+ })
1629
+ ```
1630
+
1631
+ TypeScript enforces the correct handler structure:
1632
+ - `successResponseBodySchema` contracts must use `sync` handler (returns response data)
1633
+ - `serverSentEventSchemas` contracts must use `sse` handler (streams events)
1634
+
1635
+ ### Implementing Dual-Mode Controllers
1636
+
1637
+ 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`:
1638
+
1639
+ ```ts
1640
+ import {
1641
+ AbstractDualModeController,
1642
+ buildHandler,
1643
+ type BuildFastifyDualModeRoutesReturnType,
1644
+ type DualModeControllerConfig,
1645
+ } from 'opinionated-machine'
1646
+
1647
+ type Contracts = {
1648
+ chatCompletion: typeof chatCompletionContract
1649
+ }
1650
+
1651
+ type Dependencies = {
1652
+ aiService: AIService
1653
+ }
1654
+
1655
+ export class ChatDualModeController extends AbstractDualModeController<Contracts> {
1656
+ public static contracts = {
1657
+ chatCompletion: chatCompletionContract,
1658
+ } as const
1659
+
1660
+ private readonly aiService: AIService
1661
+
1662
+ constructor(deps: Dependencies, config?: DualModeControllerConfig) {
1663
+ super(deps, config)
1664
+ this.aiService = deps.aiService
1665
+ }
1666
+
1667
+ public buildDualModeRoutes(): BuildFastifyDualModeRoutesReturnType<Contracts> {
1668
+ return {
1669
+ chatCompletion: this.handleChatCompletion,
1670
+ }
1671
+ }
1672
+
1673
+ // Handler with options as third parameter
1674
+ private handleChatCompletion = buildHandler(chatCompletionContract, {
1675
+ // Sync mode - return complete response
1676
+ sync: async (request, _reply) => {
1677
+ const result = await this.aiService.complete(request.body.message)
1678
+ return {
1679
+ reply: result.text,
1680
+ usage: { tokens: result.tokenCount },
1681
+ }
1682
+ },
1683
+ // SSE mode - stream response chunks
1684
+ sse: async (request, sse) => {
1685
+ const session = sse.start('autoClose')
1686
+ let totalTokens = 0
1687
+ for await (const chunk of this.aiService.stream(request.body.message)) {
1688
+ await session.send('chunk', { delta: chunk.text })
1689
+ totalTokens += chunk.tokenCount ?? 0
1690
+ }
1691
+ await session.send('done', { usage: { total: totalTokens } })
1692
+ // Connection closes automatically when handler returns
1693
+ },
1694
+ }, {
1695
+ // Optional: set SSE as default mode (instead of sync)
1696
+ defaultMode: 'sse',
1697
+ // Optional: route-level authentication
1698
+ preHandler: (request, reply) => {
1699
+ if (!request.headers.authorization) {
1700
+ return Promise.resolve(reply.code(401).send({ error: 'Unauthorized' }))
1701
+ }
1702
+ },
1703
+ // Optional: SSE lifecycle hooks
1704
+ onConnect: (session) => console.log('Client connected:', session.id),
1705
+ onClose: (session, reason) => console.log(`Client disconnected (${reason}):`, session.id),
1706
+ })
1707
+ }
1708
+ ```
1709
+
1710
+ **Handler Signatures:**
1711
+
1712
+ | Mode | Signature |
1713
+ | ---- | --------- |
1714
+ | `sync` | `(request, reply) => Response` |
1715
+ | `sse` | `(request, sse) => SSEHandlerResult` |
1716
+
1717
+ 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.
1718
+
1719
+ ### Registering Dual-Mode Controllers
1720
+
1721
+ Use `asDualModeControllerClass` in your module:
1722
+
1723
+ ```ts
1724
+ import {
1725
+ AbstractModule,
1726
+ asControllerClass,
1727
+ asDualModeControllerClass,
1728
+ asServiceClass,
1729
+ } from 'opinionated-machine'
1730
+
1731
+ export class ChatModule extends AbstractModule<Dependencies> {
1732
+ resolveDependencies() {
1733
+ return {
1734
+ aiService: asServiceClass(AIService),
1735
+ }
1736
+ }
1737
+
1738
+ resolveControllers(diOptions: DependencyInjectionOptions) {
1739
+ return {
1740
+ // REST controller
1741
+ usersController: asControllerClass(UsersController),
1742
+ // Dual-mode controller (auto-detected via isDualModeController flag)
1743
+ chatController: asDualModeControllerClass(ChatDualModeController, { diOptions }),
1744
+ }
1745
+ }
1746
+ }
1747
+ ```
1748
+
1749
+ Register dual-mode routes after the `@fastify/sse` plugin:
1750
+
1751
+ ```ts
1752
+ const app = fastify()
1753
+ app.setValidatorCompiler(validatorCompiler)
1754
+ app.setSerializerCompiler(serializerCompiler)
1755
+
1756
+ // Register @fastify/sse plugin
1757
+ await app.register(FastifySSEPlugin)
1758
+
1759
+ // Register routes
1760
+ context.registerRoutes(app) // REST routes
1761
+ context.registerSSERoutes(app) // SSE-only routes
1762
+ context.registerDualModeRoutes(app) // Dual-mode routes
1763
+
1764
+ // Check if controllers exist before registration (optional)
1765
+ if (context.hasDualModeControllers()) {
1766
+ context.registerDualModeRoutes(app)
1767
+ }
1768
+
1769
+ await app.ready()
1770
+ ```
1771
+
1772
+ ### Accept Header Routing
1773
+
1774
+ The `Accept` header determines response mode:
1775
+
1776
+ ```bash
1777
+ # JSON mode (complete response)
1778
+ curl -X POST http://localhost:3000/api/chats/123/completions \
1779
+ -H "Content-Type: application/json" \
1780
+ -H "Accept: application/json" \
1781
+ -d '{"message": "Hello world"}'
1782
+
1783
+ # SSE mode (streaming response)
1784
+ curl -X POST http://localhost:3000/api/chats/123/completions \
1785
+ -H "Content-Type: application/json" \
1786
+ -H "Accept: text/event-stream" \
1787
+ -d '{"message": "Hello world"}'
1788
+ ```
1789
+
1790
+ **Quality values** are supported for content negotiation:
1791
+
1792
+ ```bash
1793
+ # Prefer JSON (higher quality value)
1794
+ curl -H "Accept: text/event-stream;q=0.5, application/json;q=1.0" ...
1795
+
1796
+ # Prefer SSE (higher quality value)
1797
+ curl -H "Accept: application/json;q=0.5, text/event-stream;q=1.0" ...
1798
+ ```
1799
+
1800
+ **Subtype wildcards** are supported for flexible content negotiation:
1801
+
1802
+ ```bash
1803
+ # Accept any text format (matches text/plain, text/csv, etc.)
1804
+ curl -H "Accept: text/*" ...
1805
+
1806
+ # Accept any application format (matches application/json, application/xml, etc.)
1807
+ curl -H "Accept: application/*" ...
1808
+
1809
+ # Combine with quality values
1810
+ curl -H "Accept: text/event-stream;q=0.9, application/*;q=0.5" ...
1811
+ ```
1812
+
1813
+ The matching priority is: `text/event-stream` (SSE) > exact matches > subtype wildcards > `*/*` > fallback.
1814
+
1815
+ ### Testing Dual-Mode Controllers
1816
+
1817
+ Test both sync and SSE modes:
1818
+
1819
+ ```ts
1820
+ import { createContainer } from 'awilix'
1821
+ import { DIContext, SSETestServer, SSEInjectClient } from 'opinionated-machine'
1822
+
1823
+ describe('ChatDualModeController', () => {
1824
+ let server: SSETestServer
1825
+ let injectClient: SSEInjectClient
1826
+
1827
+ beforeEach(async () => {
1828
+ const container = createContainer({ injectionMode: 'PROXY' })
1829
+ const context = new DIContext(container, { isTestMode: true }, {})
1830
+ context.registerDependencies({ modules: [new ChatModule()] }, undefined)
1831
+
1832
+ server = await SSETestServer.create(
1833
+ (app) => {
1834
+ context.registerDualModeRoutes(app)
1835
+ },
1836
+ {
1837
+ configureApp: (app) => {
1838
+ app.setValidatorCompiler(validatorCompiler)
1839
+ app.setSerializerCompiler(serializerCompiler)
1840
+ },
1841
+ setup: () => ({ context }),
1842
+ },
1843
+ )
1844
+
1845
+ injectClient = new SSEInjectClient(server.app)
1846
+ })
1847
+
1848
+ afterEach(async () => {
1849
+ await server.resources.context.destroy()
1850
+ await server.close()
1851
+ })
1852
+
1853
+ it('returns sync response for Accept: application/json', async () => {
1854
+ const response = await server.app.inject({
1855
+ method: 'POST',
1856
+ url: '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1857
+ headers: {
1858
+ 'content-type': 'application/json',
1859
+ accept: 'application/json',
1860
+ authorization: 'Bearer token',
1861
+ },
1862
+ payload: { message: 'Hello' },
1863
+ })
1864
+
1865
+ expect(response.statusCode).toBe(200)
1866
+ expect(response.headers['content-type']).toContain('application/json')
1867
+
1868
+ const body = JSON.parse(response.body)
1869
+ expect(body).toHaveProperty('reply')
1870
+ expect(body).toHaveProperty('usage')
1871
+ })
1872
+
1873
+ it('streams SSE for Accept: text/event-stream', async () => {
1874
+ const conn = await injectClient.connectWithBody(
1875
+ '/api/chats/550e8400-e29b-41d4-a716-446655440000/completions',
1876
+ { message: 'Hello' },
1877
+ { headers: { authorization: 'Bearer token' } },
1878
+ )
1879
+
1880
+ expect(conn.getStatusCode()).toBe(200)
1881
+ expect(conn.getHeaders()['content-type']).toContain('text/event-stream')
1882
+
1883
+ const events = conn.getReceivedEvents()
1884
+ const chunks = events.filter((e) => e.event === 'chunk')
1885
+ const doneEvents = events.filter((e) => e.event === 'done')
1886
+
1887
+ expect(chunks.length).toBeGreaterThan(0)
1888
+ expect(doneEvents).toHaveLength(1)
1889
+ })
1890
+ })
1891
+