opinionated-machine 5.2.0 → 6.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,1237 +1,1242 @@
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
- - [Message Queue Resolvers](#message-queue-resolvers)
21
- - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
22
- - [Background Job Resolvers](#background-job-resolvers)
23
- - [`asEnqueuedJobWorkerClass`](#asenqueuedjobworkerclasstype-workeroptions-opts)
24
- - [`asPgBossProcessorClass`](#aspgbossprocessorclasstype-processoroptions-opts)
25
- - [`asPeriodicJobClass`](#asperiodicjobclasstype-workeroptions-opts)
26
- - [`asJobQueueClass`](#asjobqueueclasstype-queueoptions-opts)
27
- - [`asEnqueuedJobQueueManagerFunction`](#asenqueuedjobqueuemanagerfunctionfn-dioptions-opts)
28
- - [Server-Sent Events (SSE)](#server-sent-events-sse)
29
- - [Prerequisites](#prerequisites)
30
- - [Defining SSE Contracts](#defining-sse-contracts)
31
- - [Creating SSE Controllers](#creating-sse-controllers)
32
- - [Type-Safe SSE Handlers with buildSSEHandler](#type-safe-sse-handlers-with-buildssehandler)
33
- - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
34
- - [Registering SSE Controllers](#registering-sse-controllers)
35
- - [Registering SSE Routes](#registering-sse-routes)
36
- - [Broadcasting Events](#broadcasting-events)
37
- - [Controller-Level Hooks](#controller-level-hooks)
38
- - [Route-Level Options](#route-level-options)
39
- - [Graceful Shutdown](#graceful-shutdown)
40
- - [Error Handling](#error-handling)
41
- - [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
42
- - [SSE Parsing Utilities](#sse-parsing-utilities)
43
- - [parseSSEEvents](#parsesseevents)
44
- - [parseSSEBuffer](#parsessebuffer)
45
- - [ParsedSSEEvent Type](#parsedsseevent-type)
46
- - [Testing SSE Controllers](#testing-sse-controllers)
47
- - [SSEConnectionSpy API](#sseconnectionspy-api)
48
- - [Connection Monitoring](#connection-monitoring)
49
- - [SSE Test Utilities](#sse-test-utilities)
50
- - [Quick Reference](#quick-reference)
51
- - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
52
- - [SSETestServer](#ssetestserver)
53
- - [SSEHttpClient](#ssehttpclient)
54
- - [SSEInjectClient](#sseinjectclient)
55
- - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
56
-
57
- ## Basic usage
58
-
59
- Define a module, or several modules, that will be used for resolving dependency graphs, using awilix:
60
-
61
- ```ts
62
- import { AbstractModule, asSingletonClass, asMessageQueueHandlerClass, asJobWorkerClass, asJobQueueClass, asControllerClass } from 'opinionated-machine'
63
-
64
- export type ModuleDependencies = {
65
- service: Service
66
- messageQueueConsumer: MessageQueueConsumer
67
- jobWorker: JobWorker
68
- queueManager: QueueManager
69
- }
70
-
71
- export class MyModule extends AbstractModule<ModuleDependencies, ExternalDependencies> {
72
- resolveDependencies(
73
- diOptions: DependencyInjectionOptions,
74
- _externalDependencies: ExternalDependencies,
75
- ): MandatoryNameAndRegistrationPair<ModuleDependencies> {
76
- return {
77
- service: asSingletonClass(Service),
78
-
79
- // by default init and disposal methods from `message-queue-toolkit` consumers
80
- // will be assumed. If different values are necessary, pass second config object
81
- // and specify "asyncInit" and "asyncDispose" fields
82
- messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
83
- queueName: MessageQueueConsumer.QUEUE_ID,
84
- diOptions,
85
- }),
86
-
87
- // by default init and disposal methods from `background-jobs-commons` job workers
88
- // will be assumed. If different values are necessary, pass second config object
89
- // and specify "asyncInit" and "asyncDispose" fields
90
- jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
91
- queueName: JobWorker.QUEUE_ID,
92
- diOptions,
93
- }),
94
-
95
- // by default disposal methods from `background-jobs-commons` job queue manager
96
- // will be assumed. If different values are necessary, specify "asyncDispose" fields
97
- // in the second config object
98
- queueManager: asJobQueueClass(
99
- QueueManager,
100
- {
101
- diOptions,
102
- },
103
- {
104
- asyncInit: (manager) => manager.start(resolveJobQueuesEnabled(options)),
105
- },
106
- ),
107
- }
108
- }
109
-
110
- // controllers will be automatically registered on fastify app
111
- resolveControllers() {
112
- return {
113
- controller: asControllerClass(MyController),
114
- }
115
- }
116
- }
117
- ```
118
-
119
- ## Defining controllers
120
-
121
- Controllers require using fastify-api-contracts and allow to define application routes.
122
-
123
- ```ts
124
- import { buildFastifyNoPayloadRoute } from '@lokalise/fastify-api-contracts'
125
- import { buildDeleteRoute } from '@lokalise/universal-ts-utils/api-contracts/apiContracts'
126
- import { z } from 'zod/v4'
127
- import { AbstractController } from 'opinionated-machine'
128
-
129
- const BODY_SCHEMA = z.object({})
130
- const PATH_PARAMS_SCHEMA = z.object({
131
- userId: z.string(),
132
- })
133
-
134
- const contract = buildDeleteRoute({
135
- successResponseBodySchema: BODY_SCHEMA,
136
- requestPathParamsSchema: PATH_PARAMS_SCHEMA,
137
- pathResolver: (pathParams) => `/users/${pathParams.userId}`,
138
- })
139
-
140
- export class MyController extends AbstractController<typeof MyController.contracts> {
141
- public static contracts = { deleteItem: contract } as const
142
- private readonly service: Service
143
-
144
- constructor({ service }: ModuleDependencies) {
145
- super()
146
- this.service = testService
147
- }
148
-
149
- private deleteItem = buildFastifyNoPayloadRoute(
150
- TestController.contracts.deleteItem,
151
- async (req, reply) => {
152
- req.log.info(req.params.userId)
153
- this.service.execute()
154
- await reply.status(204).send()
155
- },
156
- )
157
-
158
- public buildRoutes() {
159
- return {
160
- deleteItem: this.deleteItem,
161
- }
162
- }
163
- }
164
- ```
165
-
166
- ## Putting it all together
167
-
168
- Typical usage with a fastify app looks like this:
169
-
170
- ```ts
171
- import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
172
- import { createContainer } from 'awilix'
173
- import { fastify } from 'fastify'
174
- import { DIContext } from 'opinionated-machine'
175
-
176
- const module = new MyModule()
177
- const container = createContainer({
178
- injectionMode: 'PROXY',
179
- })
180
-
181
- type AppConfig = {
182
- DATABASE_URL: string
183
- // ...
184
- // everything related to app configuration
185
- }
186
-
187
- type ExternalDependencies = {
188
- logger: Logger // most likely you would like to reuse logger instance from fastify app
189
- }
190
-
191
- const context = new DIContext<ModuleDependencies, AppConfig, ExternalDependencies>(container, {
192
- messageQueueConsumersEnabled: [MessageQueueConsumer.QUEUE_ID],
193
- jobQueuesEnabled: false,
194
- jobWorkersEnabled: false,
195
- periodicJobsEnabled: false,
196
- })
197
-
198
- context.registerDependencies({
199
- modules: [module],
200
- dependencyOverrides: {}, // dependency overrides if necessary, usually for testing purposes
201
- configOverrides: {}, // config overrides if necessary, will be merged with value inside existing config
202
- configDependencyId?: string // what is the dependency id in the graph for the config entity. Only used for config overrides. Default value is `config`
203
- },
204
- // external dependencies that are instantiated outside of DI
205
- {
206
- logger: app.logger
207
- })
208
-
209
- const app = fastify()
210
- app.setValidatorCompiler(validatorCompiler)
211
- app.setSerializerCompiler(serializerCompiler)
212
-
213
- app.after(() => {
214
- context.registerRoutes(app)
215
- })
216
- await app.ready()
217
- ```
218
-
219
- ## Resolver Functions
220
-
221
- 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.
222
-
223
- ### Basic Resolvers
224
-
225
- #### `asSingletonClass(Type, opts?)`
226
- Basic singleton class resolver. Use for general-purpose dependencies that don't fit other categories.
227
-
228
- ```ts
229
- service: asSingletonClass(MyService)
230
- ```
231
-
232
- #### `asSingletonFunction(fn, opts?)`
233
- Basic singleton function resolver. Use when you need to resolve a dependency using a factory function.
234
-
235
- ```ts
236
- config: asSingletonFunction(() => loadConfig())
237
- ```
238
-
239
- #### `asClassWithConfig(Type, config, opts?)`
240
- 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.
241
-
242
- ```ts
243
- myService: asClassWithConfig(MyService, { enableFeature: true })
244
- ```
245
-
246
- The class constructor receives dependencies as the first parameter and config as the second:
247
-
248
- ```ts
249
- class MyService {
250
- constructor(deps: Dependencies, config: { enableFeature: boolean }) {
251
- // ...
252
- }
253
- }
254
- ```
255
-
256
- ### Domain Layer Resolvers
257
-
258
- #### `asServiceClass(Type, opts?)`
259
- For service classes. Marks the dependency as **public** (exposed when module is used as secondary).
260
-
261
- ```ts
262
- userService: asServiceClass(UserService)
263
- ```
264
-
265
- #### `asUseCaseClass(Type, opts?)`
266
- For use case classes. Marks the dependency as **public**.
267
-
268
- ```ts
269
- createUserUseCase: asUseCaseClass(CreateUserUseCase)
270
- ```
271
-
272
- #### `asRepositoryClass(Type, opts?)`
273
- For repository classes. Marks the dependency as **private** (not exposed when module is secondary).
274
-
275
- ```ts
276
- userRepository: asRepositoryClass(UserRepository)
277
- ```
278
-
279
- #### `asControllerClass(Type, opts?)`
280
- For controller classes. Marks the dependency as **private**. Use in `resolveControllers()`.
281
-
282
- ```ts
283
- userController: asControllerClass(UserController)
284
- ```
285
-
286
- #### `asSSEControllerClass(Type, sseOptions?, opts?)`
287
- For SSE controller classes. Marks the dependency as **private**. Automatically configures `closeAllConnections` as the async dispose method for graceful shutdown. When `sseOptions.diOptions.isTestMode` is true, enables the connection spy for testing.
288
-
289
- ```ts
290
- // Without test mode
291
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController)
292
-
293
- // With test mode (enables connectionSpy)
294
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions })
295
- ```
296
-
297
- ### Message Queue Resolvers
298
-
299
- #### `asMessageQueueHandlerClass(Type, mqOptions, opts?)`
300
- For message queue consumers following `message-queue-toolkit` conventions. Automatically handles `start`/`close` lifecycle and respects `messageQueueConsumersEnabled` option.
301
-
302
- ```ts
303
- messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
304
- queueName: MessageQueueConsumer.QUEUE_ID,
305
- diOptions,
306
- })
307
- ```
308
-
309
- ### Background Job Resolvers
310
-
311
- #### `asEnqueuedJobWorkerClass(Type, workerOptions, opts?)`
312
- For enqueued job workers following `background-jobs-common` conventions. Automatically handles `start`/`dispose` lifecycle and respects `enqueuedJobWorkersEnabled` option.
313
-
314
- ```ts
315
- jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
316
- queueName: JobWorker.QUEUE_ID,
317
- diOptions,
318
- })
319
- ```
320
-
321
- #### `asPgBossProcessorClass(Type, processorOptions, opts?)`
322
- For pg-boss job processor classes. Similar to `asEnqueuedJobWorkerClass` but uses `start`/`stop` lifecycle methods and initializes after pgBoss (priority 20).
323
-
324
- ```ts
325
- enrichUserPresenceJob: asPgBossProcessorClass(EnrichUserPresenceJob, {
326
- queueName: EnrichUserPresenceJob.QUEUE_ID,
327
- diOptions,
328
- })
329
- ```
330
-
331
- #### `asPeriodicJobClass(Type, workerOptions, opts?)`
332
- For periodic job classes following `background-jobs-common` conventions. Uses eager injection via `register` method and respects `periodicJobsEnabled` option.
333
-
334
- ```ts
335
- cleanupJob: asPeriodicJobClass(CleanupJob, {
336
- jobName: CleanupJob.JOB_NAME,
337
- diOptions,
338
- })
339
- ```
340
-
341
- #### `asJobQueueClass(Type, queueOptions, opts?)`
342
- For job queue classes. Marks the dependency as **public**. Respects `jobQueuesEnabled` option.
343
-
344
- ```ts
345
- queueManager: asJobQueueClass(QueueManager, {
346
- diOptions,
347
- })
348
- ```
349
-
350
- #### `asEnqueuedJobQueueManagerFunction(fn, diOptions, opts?)`
351
- For job queue manager factory functions. Automatically calls `start()` with resolved enabled queues during initialization.
352
-
353
- ```ts
354
- jobQueueManager: asEnqueuedJobQueueManagerFunction(
355
- createJobQueueManager,
356
- diOptions,
357
- )
358
- ```
359
-
360
- ## Server-Sent Events (SSE)
361
-
362
- 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).
363
-
364
- ### Prerequisites
365
-
366
- Register the `@fastify/sse` plugin before using SSE controllers:
367
-
368
- ```ts
369
- import FastifySSEPlugin from '@fastify/sse'
370
-
371
- const app = fastify()
372
- await app.register(FastifySSEPlugin)
373
- ```
374
-
375
- ### Defining SSE Contracts
376
-
377
- Use `buildSSERoute` for GET-based SSE streams or `buildPayloadSSERoute` for POST/PUT/PATCH streams:
378
-
379
- ```ts
380
- import { z } from 'zod'
381
- import { buildSSERoute, buildPayloadSSERoute } from 'opinionated-machine'
382
-
383
- // GET-based SSE stream (e.g., notifications)
384
- export const notificationsContract = buildSSERoute({
385
- path: '/api/notifications/stream',
386
- params: z.object({}),
387
- query: z.object({ userId: z.string().optional() }),
388
- requestHeaders: z.object({}),
389
- events: {
390
- notification: z.object({
391
- id: z.string(),
392
- message: z.string(),
393
- }),
394
- },
395
- })
396
-
397
- // POST-based SSE stream (e.g., AI chat completions)
398
- export const chatCompletionContract = buildPayloadSSERoute({
399
- method: 'POST',
400
- path: '/api/chat/completions',
401
- params: z.object({}),
402
- query: z.object({}),
403
- requestHeaders: z.object({}),
404
- body: z.object({
405
- message: z.string(),
406
- stream: z.literal(true),
407
- }),
408
- events: {
409
- chunk: z.object({ content: z.string() }),
410
- done: z.object({ totalTokens: z.number() }),
411
- },
412
- })
413
- ```
414
-
415
- ### Creating SSE Controllers
416
-
417
- SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildSSEHandler` for automatic type inference of request parameters:
418
-
419
- ```ts
420
- import {
421
- AbstractSSEController,
422
- buildSSEHandler,
423
- type SSEControllerConfig,
424
- type SSEConnection
425
- } from 'opinionated-machine'
426
-
427
- type Contracts = {
428
- notificationsStream: typeof notificationsContract
429
- }
430
-
431
- type Dependencies = {
432
- notificationService: NotificationService
433
- }
434
-
435
- export class NotificationsSSEController extends AbstractSSEController<Contracts> {
436
- public static contracts = {
437
- notificationsStream: notificationsContract,
438
- } as const
439
-
440
- private readonly notificationService: NotificationService
441
-
442
- // Required: two-parameter constructor (deps object, optional SSE config)
443
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
444
- super(deps, sseConfig)
445
- this.notificationService = deps.notificationService
446
- }
447
-
448
- public buildSSERoutes() {
449
- return {
450
- notificationsStream: {
451
- contract: NotificationsSSEController.contracts.notificationsStream,
452
- handler: this.handleStream,
453
- options: {
454
- onConnect: (conn) => this.onConnect(conn),
455
- onDisconnect: (conn) => this.onDisconnect(conn),
456
- },
457
- },
458
- }
459
- }
460
-
461
- // Handler with automatic type inference from contract
462
- private handleStream = buildSSEHandler(
463
- notificationsContract,
464
- async (request, connection) => {
465
- // request.query is typed from contract: { userId?: string }
466
- const userId = request.query.userId ?? 'anonymous'
467
- connection.context = { userId }
468
-
469
- // Subscribe to notifications for this user
470
- this.notificationService.subscribe(userId, async (notification) => {
471
- await this.sendEvent(connection.id, {
472
- event: 'notification',
473
- data: notification,
474
- })
475
- })
476
- },
477
- )
478
-
479
- private onConnect = (connection: SSEConnection) => {
480
- console.log('Client connected:', connection.id)
481
- }
482
-
483
- private onDisconnect = (connection: SSEConnection) => {
484
- const userId = connection.context?.userId as string
485
- this.notificationService.unsubscribe(userId)
486
- console.log('Client disconnected:', connection.id)
487
- }
488
- }
489
- ```
490
-
491
- ### Type-Safe SSE Handlers with `buildSSEHandler`
492
-
493
- For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildSSEHandler`:
494
-
495
- ```ts
496
- import {
497
- AbstractSSEController,
498
- buildSSEHandler,
499
- type SSEControllerConfig,
500
- type SSEConnection
501
- } from 'opinionated-machine'
502
-
503
- class ChatSSEController extends AbstractSSEController<Contracts> {
504
- public static contracts = {
505
- chatCompletion: chatCompletionContract,
506
- } as const
507
-
508
- constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
509
- super(deps, sseConfig)
510
- }
511
-
512
- // Handler with automatic type inference from contract
513
- private handleChatCompletion = buildSSEHandler(
514
- chatCompletionContract,
515
- async (request, connection) => {
516
- // request.body is typed as { message: string; stream: true }
517
- // request.query, request.params, request.headers all typed from contract
518
- const words = request.body.message.split(' ')
519
-
520
- for (const word of words) {
521
- await this.sendEvent(connection.id, {
522
- event: 'chunk',
523
- data: { content: word },
524
- })
525
- }
526
-
527
- // Gracefully end the stream - all sent data is flushed before connection closes
528
- this.closeConnection(connection.id)
529
- },
530
- )
531
-
532
- public buildSSERoutes() {
533
- return {
534
- chatCompletion: {
535
- contract: ChatSSEController.contracts.chatCompletion,
536
- handler: this.handleChatCompletion,
537
- },
538
- }
539
- }
540
- }
541
- ```
542
-
543
- You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
544
-
545
- ```ts
546
- import { type InferSSERequest } from 'opinionated-machine'
547
-
548
- private handleStream = async (
549
- request: InferSSERequest<typeof chatCompletionContract>,
550
- connection: SSEConnection,
551
- ) => {
552
- // request.body, request.params, etc. all typed from contract
553
- }
554
- ```
555
-
556
- ### SSE Controllers Without Dependencies
557
-
558
- For controllers without dependencies, still provide the two-parameter constructor:
559
-
560
- ```ts
561
- export class SimpleSSEController extends AbstractSSEController<Contracts> {
562
- constructor(deps: object, sseConfig?: SSEControllerConfig) {
563
- super(deps, sseConfig)
564
- }
565
-
566
- // ... implementation
567
- }
568
- ```
569
-
570
- ### Registering SSE Controllers
571
-
572
- Use `asSSEControllerClass` in your module and implement `resolveSSEControllers`:
573
-
574
- ```ts
575
- import { AbstractModule, asSSEControllerClass, asServiceClass } from 'opinionated-machine'
576
-
577
- export class NotificationsModule extends AbstractModule<Dependencies> {
578
- resolveDependencies(diOptions: DependencyInjectionOptions) {
579
- return {
580
- notificationService: asServiceClass(NotificationService),
581
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
582
- }
583
- }
584
-
585
- resolveSSEControllers() {
586
- return {
587
- notificationsSSEController: asSSEControllerClass(NotificationsSSEController),
588
- }
589
- }
590
- }
591
- ```
592
-
593
- ### Registering SSE Routes
594
-
595
- Call `registerSSERoutes` after registering the `@fastify/sse` plugin:
596
-
597
- ```ts
598
- const app = fastify()
599
- app.setValidatorCompiler(validatorCompiler)
600
- app.setSerializerCompiler(serializerCompiler)
601
-
602
- // Register @fastify/sse plugin first
603
- await app.register(FastifySSEPlugin)
604
-
605
- // Then register SSE routes
606
- context.registerSSERoutes(app)
607
-
608
- // Optionally with global preHandler for authentication
609
- context.registerSSERoutes(app, {
610
- preHandler: async (request, reply) => {
611
- if (!request.headers.authorization) {
612
- reply.code(401).send({ error: 'Unauthorized' })
613
- }
614
- },
615
- })
616
-
617
- await app.ready()
618
- ```
619
-
620
- ### Broadcasting Events
621
-
622
- Send events to multiple connections using `broadcast()` or `broadcastIf()`:
623
-
624
- ```ts
625
- // Broadcast to ALL connected clients
626
- await this.broadcast({
627
- event: 'system',
628
- data: { message: 'Server maintenance in 5 minutes' },
629
- })
630
-
631
- // Broadcast to connections matching a predicate
632
- await this.broadcastIf(
633
- { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
634
- (connection) => connection.context.channelId === '123',
635
- )
636
- ```
637
-
638
- Both methods return the number of clients the message was successfully sent to.
639
-
640
- ### Controller-Level Hooks
641
-
642
- Override these optional methods on your controller for global connection handling:
643
-
644
- ```ts
645
- class MySSEController extends AbstractSSEController<Contracts> {
646
- // Called AFTER connection is registered (for all routes)
647
- protected onConnectionEstablished(connection: SSEConnection): void {
648
- this.metrics.incrementConnections()
649
- }
650
-
651
- // Called BEFORE connection is unregistered (for all routes)
652
- protected onConnectionClosed(connection: SSEConnection): void {
653
- this.metrics.decrementConnections()
654
- }
655
- }
656
- ```
657
-
658
- ### Route-Level Options
659
-
660
- Each route can have its own `preHandler`, lifecycle hooks, and logger:
661
-
662
- ```ts
663
- public buildSSERoutes() {
664
- return {
665
- adminStream: {
666
- contract: AdminSSEController.contracts.adminStream,
667
- handler: this.handleAdminStream,
668
- options: {
669
- // Route-specific authentication
670
- preHandler: (request, reply) => {
671
- if (!request.user?.isAdmin) {
672
- reply.code(403).send({ error: 'Forbidden' })
673
- }
674
- },
675
- onConnect: (conn) => console.log('Admin connected'),
676
- onDisconnect: (conn) => console.log('Admin disconnected'),
677
- // Handle client reconnection with Last-Event-ID
678
- onReconnect: async (conn, lastEventId) => {
679
- // Return events to replay, or handle manually
680
- return this.getEventsSince(lastEventId)
681
- },
682
- // Optional: logger for error handling (requires @lokalise/node-core)
683
- logger: this.logger,
684
- },
685
- },
686
- }
687
- }
688
- ```
689
-
690
- **Available route options:**
691
-
692
- | Option | Description |
693
- |--------|-------------|
694
- | `preHandler` | Authentication/authorization hook that runs before SSE connection |
695
- | `onConnect` | Called after client connects (SSE handshake complete) |
696
- | `onDisconnect` | Called when client disconnects |
697
- | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
698
- | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
699
-
700
- ### Graceful Shutdown
701
-
702
- 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).
703
-
704
- ### Error Handling
705
-
706
- When `sendEvent()` fails (e.g., client disconnected), it:
707
- - Returns `false` to indicate failure
708
- - Automatically removes the dead connection from tracking
709
- - Prevents further send attempts to that connection
710
-
711
- ```ts
712
- const sent = await this.sendEvent(connectionId, { event: 'update', data })
713
- if (!sent) {
714
- // Connection was closed or failed - already removed from tracking
715
- this.cleanup(connectionId)
716
- }
717
- ```
718
-
719
- **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onDisconnect`):
720
- - All lifecycle hooks are wrapped in try/catch to prevent crashes
721
- - If a `logger` is provided in route options, errors are logged with context
722
- - If no logger is provided, errors are silently ignored
723
- - The connection lifecycle continues even if a hook throws
724
-
725
- ```ts
726
- // Provide a logger to capture lifecycle errors
727
- public buildSSERoutes() {
728
- return {
729
- stream: {
730
- contract: streamContract,
731
- handler: this.handleStream,
732
- options: {
733
- logger: this.logger, // pino-compatible logger
734
- onConnect: (conn) => { /* may throw */ },
735
- onDisconnect: (conn) => { /* may throw */ },
736
- },
737
- },
738
- }
739
- }
740
- ```
741
-
742
- ### Long-lived Connections vs Request-Response Streaming
743
-
744
- **Long-lived connections** (notifications, live updates):
745
- - Handler sets up subscriptions and returns
746
- - Connection stays open until client disconnects
747
- - Events sent via `sendEvent()` from external triggers
748
-
749
- ```ts
750
- private handleStream = buildSSEHandler(streamContract, async (request, connection) => {
751
- // Set up subscription
752
- this.service.subscribe(connection.id, (data) => {
753
- this.sendEvent(connection.id, { event: 'update', data })
754
- })
755
- // Handler returns, connection stays open
756
- })
757
- ```
758
-
759
- **Request-response streaming** (AI completions):
760
- - Handler sends all events and closes connection
761
- - Similar to regular HTTP but with streaming body
762
-
763
- ```ts
764
- private handleChatCompletion = buildSSEHandler(chatCompletionContract, async (request, connection) => {
765
- // request.body is typed from contract
766
- const words = request.body.message.split(' ')
767
-
768
- for (const word of words) {
769
- await this.sendEvent(connection.id, {
770
- event: 'chunk',
771
- data: { content: word },
772
- })
773
- }
774
-
775
- await this.sendEvent(connection.id, {
776
- event: 'done',
777
- data: { totalTokens: words.length },
778
- })
779
-
780
- // Gracefully end the stream - all sent data is flushed before connection closes
781
- this.closeConnection(connection.id)
782
- })
783
- ```
784
-
785
- ### SSE Parsing Utilities
786
-
787
- The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
788
-
789
- | Function | Use Case |
790
- |----------|----------|
791
- | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
792
- | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
793
-
794
- #### parseSSEEvents
795
-
796
- Parse a complete SSE response body into an array of events.
797
-
798
- **When to use:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
799
-
800
- ```ts
801
- import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
802
-
803
- const responseBody = `event: notification
804
- data: {"id":"1","message":"Hello"}
805
-
806
- event: notification
807
- data: {"id":"2","message":"World"}
808
-
809
- `
810
-
811
- const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
812
- // Result:
813
- // [
814
- // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
815
- // { event: 'notification', data: '{"id":"2","message":"World"}' }
816
- // ]
817
-
818
- // Access parsed data
819
- const notifications = events.map(e => JSON.parse(e.data))
820
- ```
821
-
822
- #### parseSSEBuffer
823
-
824
- Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
825
-
826
- **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
827
-
828
- ```ts
829
- import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
830
-
831
- let buffer = ''
832
-
833
- // As chunks arrive from a stream...
834
- for await (const chunk of stream) {
835
- buffer += chunk
836
- const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
837
-
838
- // Process complete events
839
- for (const event of result.events) {
840
- console.log('Received:', event.event, event.data)
841
- }
842
-
843
- // Keep incomplete data for next chunk
844
- buffer = result.remaining
845
- }
846
- ```
847
-
848
- **Production example with fetch:**
849
-
850
- ```ts
851
- const response = await fetch(url)
852
- const reader = response.body!.getReader()
853
- const decoder = new TextDecoder()
854
- let buffer = ''
855
-
856
- while (true) {
857
- const { done, value } = await reader.read()
858
- if (done) break
859
-
860
- buffer += decoder.decode(value, { stream: true })
861
- const { events, remaining } = parseSSEBuffer(buffer)
862
- buffer = remaining
863
-
864
- for (const event of events) {
865
- console.log('Received:', event.event, JSON.parse(event.data))
866
- }
867
- }
868
- ```
869
-
870
- #### ParsedSSEEvent Type
871
-
872
- Both functions return events with this structure:
873
-
874
- ```ts
875
- type ParsedSSEEvent = {
876
- id?: string // Event ID (from "id:" field)
877
- event?: string // Event type (from "event:" field)
878
- data: string // Event data (from "data:" field, always present)
879
- retry?: number // Reconnection interval (from "retry:" field)
880
- }
881
- ```
882
-
883
- ### Testing SSE Controllers
884
-
885
- Enable the connection spy for testing by passing `isTestMode: true` in diOptions:
886
-
887
- ```ts
888
- import { createContainer } from 'awilix'
889
- import { DIContext, SSETestServer, SSEHttpClient } from 'opinionated-machine'
890
-
891
- describe('NotificationsSSEController', () => {
892
- let server: SSETestServer
893
- let controller: NotificationsSSEController
894
-
895
- beforeEach(async () => {
896
- // Create test server with isTestMode enabled
897
- server = await SSETestServer.create(
898
- async (app) => {
899
- // Register your SSE routes here
900
- },
901
- {
902
- setup: async () => {
903
- // Set up DI container and resources
904
- return { context }
905
- },
906
- }
907
- )
908
-
909
- controller = server.resources.context.diContainer.cradle.notificationsSSEController
910
- })
911
-
912
- afterEach(async () => {
913
- await server.resources.context.destroy()
914
- await server.close()
915
- })
916
-
917
- it('receives notifications over SSE', async () => {
918
- // Connect with awaitServerConnection to eliminate race condition
919
- const { client, serverConnection } = await SSEHttpClient.connect(
920
- server.baseUrl,
921
- '/api/notifications/stream',
922
- {
923
- query: { userId: 'test-user' },
924
- awaitServerConnection: { controller },
925
- },
926
- )
927
-
928
- expect(client.response.ok).toBe(true)
929
-
930
- // Start collecting events
931
- const eventsPromise = client.collectEvents(2)
932
-
933
- // Send events from server (serverConnection is ready immediately)
934
- await controller.sendEvent(serverConnection.id, {
935
- event: 'notification',
936
- data: { id: '1', message: 'Hello!' },
937
- })
938
-
939
- await controller.sendEvent(serverConnection.id, {
940
- event: 'notification',
941
- data: { id: '2', message: 'World!' },
942
- })
943
-
944
- // Wait for events
945
- const events = await eventsPromise
946
-
947
- expect(events).toHaveLength(2)
948
- expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
949
- expect(JSON.parse(events[1].data)).toEqual({ id: '2', message: 'World!' })
950
-
951
- // Clean up
952
- client.close()
953
- })
954
- })
955
- ```
956
-
957
- ### SSEConnectionSpy API
958
-
959
- The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
960
-
961
- ```ts
962
- // Wait for a connection to be established (with timeout)
963
- const connection = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
964
-
965
- // Wait for a connection matching a predicate (useful for multiple connections)
966
- const connection = await controller.connectionSpy.waitForConnection({
967
- timeout: 5000,
968
- predicate: (conn) => conn.request.url.includes('/api/notifications'),
969
- })
970
-
971
- // Check if a specific connection is active
972
- const isConnected = controller.connectionSpy.isConnected(connectionId)
973
-
974
- // Wait for a specific connection to disconnect
975
- await controller.connectionSpy.waitForDisconnection(connectionId, { timeout: 5000 })
976
-
977
- // Get all connection events (connect/disconnect history)
978
- const events = controller.connectionSpy.getEvents()
979
-
980
- // Clear event history and claimed connections between tests
981
- controller.connectionSpy.clear()
982
- ```
983
-
984
- **Note**: `waitForConnection` tracks "claimed" connections internally. Each call returns a unique unclaimed connection, allowing sequential waits for the same URL path without returning the same connection twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
985
-
986
- ### Connection Monitoring
987
-
988
- Controllers have access to utility methods for monitoring connections:
989
-
990
- ```ts
991
- // Get count of active connections
992
- const count = this.getConnectionCount()
993
-
994
- // Get all active connections (for iteration/inspection)
995
- const connections = this.getConnections()
996
-
997
- // Check if connection spy is enabled (useful for conditional logic)
998
- if (this.hasConnectionSpy()) {
999
- // ...
1000
- }
1001
- ```
1002
-
1003
- ### SSE Test Utilities
1004
-
1005
- The library provides utilities for testing SSE endpoints.
1006
-
1007
- **Two connection methods:**
1008
- - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the connection for the request to complete.
1009
- - **Real HTTP** - Actual HTTP connection via `fetch()`. Requires the server to be listening. Supports long-lived connections.
1010
-
1011
- #### Quick Reference
1012
-
1013
- | Utility | Connection | Requires Contract | Use Case |
1014
- |---------|------------|-------------------|----------|
1015
- | `SSEInjectClient` | Inject (in-memory) | No | Request-response SSE without contracts |
1016
- | `injectSSE` / `injectPayloadSSE` | Inject (in-memory) | **Yes** | Request-response SSE with type-safe contracts |
1017
- | `SSEHttpClient` | Real HTTP | No | Long-lived SSE connections |
1018
-
1019
- `SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
1020
-
1021
- #### Inject vs HTTP Comparison
1022
-
1023
- | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
1024
- |---------|----------------------------------------|------------------------|
1025
- | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
1026
- | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
1027
- | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
1028
- | **Server requirement** | No `listen()` needed | Requires running server |
1029
- | **Best for** | OpenAI-style streaming, batch exports | Notifications, live feeds, chat |
1030
-
1031
- #### SSETestServer
1032
-
1033
- Creates a test server with `@fastify/sse` pre-configured:
1034
-
1035
- ```ts
1036
- import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1037
-
1038
- // Basic usage
1039
- const server = await SSETestServer.create(async (app) => {
1040
- app.get('/api/events', async (request, reply) => {
1041
- reply.sse({ event: 'message', data: { hello: 'world' } })
1042
- reply.sseClose()
1043
- })
1044
- })
1045
-
1046
- // Connect and test
1047
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/events')
1048
- const events = await client.collectEvents(1)
1049
- expect(events[0].event).toBe('message')
1050
-
1051
- // Cleanup
1052
- client.close()
1053
- await server.close()
1054
- ```
1055
-
1056
- With custom resources (DI container, controllers):
1057
-
1058
- ```ts
1059
- const server = await SSETestServer.create(
1060
- async (app) => {
1061
- // Register routes using resources from setup
1062
- myController.registerRoutes(app)
1063
- },
1064
- {
1065
- configureApp: async (app) => {
1066
- app.setValidatorCompiler(validatorCompiler)
1067
- },
1068
- setup: async () => {
1069
- // Resources are available via server.resources
1070
- const container = createContainer()
1071
- return { container }
1072
- },
1073
- }
1074
- )
1075
-
1076
- const { container } = server.resources
1077
- ```
1078
-
1079
- #### SSEHttpClient
1080
-
1081
- For testing long-lived SSE connections using real HTTP:
1082
-
1083
- ```ts
1084
- import { SSEHttpClient } from 'opinionated-machine'
1085
-
1086
- // Connect to SSE endpoint with awaitServerConnection (recommended)
1087
- // This eliminates the race condition between client connect and server-side registration
1088
- const { client, serverConnection } = await SSEHttpClient.connect(
1089
- server.baseUrl,
1090
- '/api/stream',
1091
- {
1092
- query: { userId: 'test' },
1093
- headers: { authorization: 'Bearer token' },
1094
- awaitServerConnection: { controller }, // Pass your SSE controller
1095
- },
1096
- )
1097
-
1098
- // serverConnection is ready to use immediately
1099
- expect(client.response.ok).toBe(true)
1100
- await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1101
-
1102
- // Collect events by count with timeout
1103
- const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
1104
-
1105
- // Or collect until a predicate is satisfied
1106
- const events = await client.collectEvents(
1107
- (event) => event.event === 'done',
1108
- 5000,
1109
- )
1110
-
1111
- // Iterate over events as they arrive
1112
- for await (const event of client.events()) {
1113
- console.log(event.event, event.data)
1114
- if (event.event === 'done') break
1115
- }
1116
-
1117
- // Cleanup
1118
- client.close()
1119
- ```
1120
-
1121
- **`collectEvents(countOrPredicate, timeout?)`**
1122
-
1123
- Collects events until a count is reached or a predicate returns true.
1124
-
1125
- | Parameter | Type | Description |
1126
- |-----------|------|-------------|
1127
- | `countOrPredicate` | `number \| (event) => boolean` | Number of events to collect, or predicate that returns `true` when collection should stop |
1128
- | `timeout` | `number` | Maximum time to wait in milliseconds (default: 5000) |
1129
-
1130
- Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
1131
-
1132
- ```ts
1133
- // Collect exactly 3 events
1134
- const events = await client.collectEvents(3)
1135
-
1136
- // Collect with custom timeout
1137
- const events = await client.collectEvents(5, 10000) // 10s timeout
1138
-
1139
- // Collect until a specific event type (the matching event IS included)
1140
- const events = await client.collectEvents((event) => event.event === 'done')
1141
-
1142
- // Collect until condition with timeout
1143
- const events = await client.collectEvents(
1144
- (event) => JSON.parse(event.data).status === 'complete',
1145
- 30000,
1146
- )
1147
- ```
1148
-
1149
- **`events(signal?)`**
1150
-
1151
- Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
1152
-
1153
- ```ts
1154
- // Basic iteration
1155
- for await (const event of client.events()) {
1156
- console.log(event.event, event.data)
1157
- if (event.event === 'done') break
1158
- }
1159
-
1160
- // With abort signal for timeout control
1161
- const controller = new AbortController()
1162
- const timeoutId = setTimeout(() => controller.abort(), 5000)
1163
-
1164
- try {
1165
- for await (const event of client.events(controller.signal)) {
1166
- console.log(event)
1167
- }
1168
- } finally {
1169
- clearTimeout(timeoutId)
1170
- }
1171
- ```
1172
-
1173
- **When to omit `awaitServerConnection`**
1174
-
1175
- Omit `awaitServerConnection` only in these cases:
1176
- - Testing against external SSE endpoints (not your own controller)
1177
- - When `isTestMode: false` (connectionSpy not available)
1178
- - Simple smoke tests that only verify response headers/status without sending server events
1179
-
1180
- **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.
1181
-
1182
- ```ts
1183
- // Example: smoke test that only checks connection works
1184
- const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
1185
- expect(client.response.ok).toBe(true)
1186
- expect(client.response.headers.get('content-type')).toContain('text/event-stream')
1187
- client.close()
1188
- ```
1189
-
1190
- #### SSEInjectClient
1191
-
1192
- For testing request-response style SSE streams (like OpenAI completions):
1193
-
1194
- ```ts
1195
- import { SSEInjectClient } from 'opinionated-machine'
1196
-
1197
- const client = new SSEInjectClient(app) // No server.listen() needed
1198
-
1199
- // GET request
1200
- const conn = await client.connect('/api/export/progress', {
1201
- headers: { authorization: 'Bearer token' },
1202
- })
1203
-
1204
- // POST request with body (OpenAI-style)
1205
- const conn = await client.connectWithBody(
1206
- '/api/chat/completions',
1207
- { model: 'gpt-4', messages: [...], stream: true },
1208
- )
1209
-
1210
- // All events are available immediately (inject waits for complete response)
1211
- expect(conn.getStatusCode()).toBe(200)
1212
- const events = conn.getReceivedEvents()
1213
- const chunks = events.filter(e => e.event === 'chunk')
1214
- ```
1215
-
1216
- #### Contract-Aware Inject Helpers
1217
-
1218
- For typed testing with SSE contracts:
1219
-
1220
- ```ts
1221
- import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
1222
-
1223
- // For GET SSE endpoints with contracts
1224
- const { closed } = injectSSE(app, notificationsContract, {
1225
- query: { userId: 'test' },
1226
- })
1227
- const result = await closed
1228
- const events = parseSSEEvents(result.body)
1229
-
1230
- // For POST/PUT/PATCH SSE endpoints with contracts
1231
- const { closed } = injectPayloadSSE(app, chatCompletionContract, {
1232
- body: { message: 'Hello', stream: true },
1233
- })
1234
- const result = await closed
1235
- const events = parseSSEEvents(result.body)
1236
- ```
1237
-
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
+ - [Message Queue Resolvers](#message-queue-resolvers)
21
+ - [`asMessageQueueHandlerClass`](#asmessagequeuehandlerclasstype-mqoptions-opts)
22
+ - [Background Job Resolvers](#background-job-resolvers)
23
+ - [`asEnqueuedJobWorkerClass`](#asenqueuedjobworkerclasstype-workeroptions-opts)
24
+ - [`asPgBossProcessorClass`](#aspgbossprocessorclasstype-processoroptions-opts)
25
+ - [`asPeriodicJobClass`](#asperiodicjobclasstype-workeroptions-opts)
26
+ - [`asJobQueueClass`](#asjobqueueclasstype-queueoptions-opts)
27
+ - [`asEnqueuedJobQueueManagerFunction`](#asenqueuedjobqueuemanagerfunctionfn-dioptions-opts)
28
+ - [Server-Sent Events (SSE)](#server-sent-events-sse)
29
+ - [Prerequisites](#prerequisites)
30
+ - [Defining SSE Contracts](#defining-sse-contracts)
31
+ - [Creating SSE Controllers](#creating-sse-controllers)
32
+ - [Type-Safe SSE Handlers with buildSSEHandler](#type-safe-sse-handlers-with-buildssehandler)
33
+ - [SSE Controllers Without Dependencies](#sse-controllers-without-dependencies)
34
+ - [Registering SSE Controllers](#registering-sse-controllers)
35
+ - [Registering SSE Routes](#registering-sse-routes)
36
+ - [Broadcasting Events](#broadcasting-events)
37
+ - [Controller-Level Hooks](#controller-level-hooks)
38
+ - [Route-Level Options](#route-level-options)
39
+ - [Graceful Shutdown](#graceful-shutdown)
40
+ - [Error Handling](#error-handling)
41
+ - [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
42
+ - [SSE Parsing Utilities](#sse-parsing-utilities)
43
+ - [parseSSEEvents](#parsesseevents)
44
+ - [parseSSEBuffer](#parsessebuffer)
45
+ - [ParsedSSEEvent Type](#parsedsseevent-type)
46
+ - [Testing SSE Controllers](#testing-sse-controllers)
47
+ - [SSEConnectionSpy API](#sseconnectionspy-api)
48
+ - [Connection Monitoring](#connection-monitoring)
49
+ - [SSE Test Utilities](#sse-test-utilities)
50
+ - [Quick Reference](#quick-reference)
51
+ - [Inject vs HTTP Comparison](#inject-vs-http-comparison)
52
+ - [SSETestServer](#ssetestserver)
53
+ - [SSEHttpClient](#ssehttpclient)
54
+ - [SSEInjectClient](#sseinjectclient)
55
+ - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
56
+
57
+ ## Basic usage
58
+
59
+ Define a module, or several modules, that will be used for resolving dependency graphs, using awilix:
60
+
61
+ ```ts
62
+ import { AbstractModule, asSingletonClass, asMessageQueueHandlerClass, asJobWorkerClass, asJobQueueClass, asControllerClass } from 'opinionated-machine'
63
+
64
+ export type ModuleDependencies = {
65
+ service: Service
66
+ messageQueueConsumer: MessageQueueConsumer
67
+ jobWorker: JobWorker
68
+ queueManager: QueueManager
69
+ }
70
+
71
+ export class MyModule extends AbstractModule<ModuleDependencies, ExternalDependencies> {
72
+ resolveDependencies(
73
+ diOptions: DependencyInjectionOptions,
74
+ _externalDependencies: ExternalDependencies,
75
+ ): MandatoryNameAndRegistrationPair<ModuleDependencies> {
76
+ return {
77
+ service: asSingletonClass(Service),
78
+
79
+ // by default init and disposal methods from `message-queue-toolkit` consumers
80
+ // will be assumed. If different values are necessary, pass second config object
81
+ // and specify "asyncInit" and "asyncDispose" fields
82
+ messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
83
+ queueName: MessageQueueConsumer.QUEUE_ID,
84
+ diOptions,
85
+ }),
86
+
87
+ // by default init and disposal methods from `background-jobs-commons` job workers
88
+ // will be assumed. If different values are necessary, pass second config object
89
+ // and specify "asyncInit" and "asyncDispose" fields
90
+ jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
91
+ queueName: JobWorker.QUEUE_ID,
92
+ diOptions,
93
+ }),
94
+
95
+ // by default disposal methods from `background-jobs-commons` job queue manager
96
+ // will be assumed. If different values are necessary, specify "asyncDispose" fields
97
+ // in the second config object
98
+ queueManager: asJobQueueClass(
99
+ QueueManager,
100
+ {
101
+ diOptions,
102
+ },
103
+ {
104
+ asyncInit: (manager) => manager.start(resolveJobQueuesEnabled(options)),
105
+ },
106
+ ),
107
+ }
108
+ }
109
+
110
+ // controllers will be automatically registered on fastify app
111
+ // both REST and SSE controllers go here - SSE controllers are auto-detected
112
+ resolveControllers(diOptions: DependencyInjectionOptions) {
113
+ return {
114
+ controller: asControllerClass(MyController),
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
120
+ ## Defining controllers
121
+
122
+ Controllers require using fastify-api-contracts and allow to define application routes.
123
+
124
+ ```ts
125
+ import { buildFastifyNoPayloadRoute } from '@lokalise/fastify-api-contracts'
126
+ import { buildDeleteRoute } from '@lokalise/universal-ts-utils/api-contracts/apiContracts'
127
+ import { z } from 'zod/v4'
128
+ import { AbstractController } from 'opinionated-machine'
129
+
130
+ const BODY_SCHEMA = z.object({})
131
+ const PATH_PARAMS_SCHEMA = z.object({
132
+ userId: z.string(),
133
+ })
134
+
135
+ const contract = buildDeleteRoute({
136
+ successResponseBodySchema: BODY_SCHEMA,
137
+ requestPathParamsSchema: PATH_PARAMS_SCHEMA,
138
+ pathResolver: (pathParams) => `/users/${pathParams.userId}`,
139
+ })
140
+
141
+ export class MyController extends AbstractController<typeof MyController.contracts> {
142
+ public static contracts = { deleteItem: contract } as const
143
+ private readonly service: Service
144
+
145
+ constructor({ service }: ModuleDependencies) {
146
+ super()
147
+ this.service = testService
148
+ }
149
+
150
+ private deleteItem = buildFastifyNoPayloadRoute(
151
+ TestController.contracts.deleteItem,
152
+ async (req, reply) => {
153
+ req.log.info(req.params.userId)
154
+ this.service.execute()
155
+ await reply.status(204).send()
156
+ },
157
+ )
158
+
159
+ public buildRoutes() {
160
+ return {
161
+ deleteItem: this.deleteItem,
162
+ }
163
+ }
164
+ }
165
+ ```
166
+
167
+ ## Putting it all together
168
+
169
+ Typical usage with a fastify app looks like this:
170
+
171
+ ```ts
172
+ import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod'
173
+ import { createContainer } from 'awilix'
174
+ import { fastify } from 'fastify'
175
+ import { DIContext } from 'opinionated-machine'
176
+
177
+ const module = new MyModule()
178
+ const container = createContainer({
179
+ injectionMode: 'PROXY',
180
+ })
181
+
182
+ type AppConfig = {
183
+ DATABASE_URL: string
184
+ // ...
185
+ // everything related to app configuration
186
+ }
187
+
188
+ type ExternalDependencies = {
189
+ logger: Logger // most likely you would like to reuse logger instance from fastify app
190
+ }
191
+
192
+ const context = new DIContext<ModuleDependencies, AppConfig, ExternalDependencies>(container, {
193
+ messageQueueConsumersEnabled: [MessageQueueConsumer.QUEUE_ID],
194
+ jobQueuesEnabled: false,
195
+ jobWorkersEnabled: false,
196
+ periodicJobsEnabled: false,
197
+ })
198
+
199
+ context.registerDependencies({
200
+ modules: [module],
201
+ dependencyOverrides: {}, // dependency overrides if necessary, usually for testing purposes
202
+ configOverrides: {}, // config overrides if necessary, will be merged with value inside existing config
203
+ configDependencyId?: string // what is the dependency id in the graph for the config entity. Only used for config overrides. Default value is `config`
204
+ },
205
+ // external dependencies that are instantiated outside of DI
206
+ {
207
+ logger: app.logger
208
+ })
209
+
210
+ const app = fastify()
211
+ app.setValidatorCompiler(validatorCompiler)
212
+ app.setSerializerCompiler(serializerCompiler)
213
+
214
+ app.after(() => {
215
+ context.registerRoutes(app)
216
+ })
217
+ await app.ready()
218
+ ```
219
+
220
+ ## Resolver Functions
221
+
222
+ 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.
223
+
224
+ ### Basic Resolvers
225
+
226
+ #### `asSingletonClass(Type, opts?)`
227
+ Basic singleton class resolver. Use for general-purpose dependencies that don't fit other categories.
228
+
229
+ ```ts
230
+ service: asSingletonClass(MyService)
231
+ ```
232
+
233
+ #### `asSingletonFunction(fn, opts?)`
234
+ Basic singleton function resolver. Use when you need to resolve a dependency using a factory function.
235
+
236
+ ```ts
237
+ config: asSingletonFunction(() => loadConfig())
238
+ ```
239
+
240
+ #### `asClassWithConfig(Type, config, opts?)`
241
+ 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.
242
+
243
+ ```ts
244
+ myService: asClassWithConfig(MyService, { enableFeature: true })
245
+ ```
246
+
247
+ The class constructor receives dependencies as the first parameter and config as the second:
248
+
249
+ ```ts
250
+ class MyService {
251
+ constructor(deps: Dependencies, config: { enableFeature: boolean }) {
252
+ // ...
253
+ }
254
+ }
255
+ ```
256
+
257
+ ### Domain Layer Resolvers
258
+
259
+ #### `asServiceClass(Type, opts?)`
260
+ For service classes. Marks the dependency as **public** (exposed when module is used as secondary).
261
+
262
+ ```ts
263
+ userService: asServiceClass(UserService)
264
+ ```
265
+
266
+ #### `asUseCaseClass(Type, opts?)`
267
+ For use case classes. Marks the dependency as **public**.
268
+
269
+ ```ts
270
+ createUserUseCase: asUseCaseClass(CreateUserUseCase)
271
+ ```
272
+
273
+ #### `asRepositoryClass(Type, opts?)`
274
+ For repository classes. Marks the dependency as **private** (not exposed when module is secondary).
275
+
276
+ ```ts
277
+ userRepository: asRepositoryClass(UserRepository)
278
+ ```
279
+
280
+ #### `asControllerClass(Type, opts?)`
281
+ For REST controller classes. Marks the dependency as **private**. Use in `resolveControllers()`.
282
+
283
+ ```ts
284
+ userController: asControllerClass(UserController)
285
+ ```
286
+
287
+ #### `asSSEControllerClass(Type, sseOptions?, opts?)`
288
+ 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.
289
+
290
+ ```ts
291
+ // In resolveControllers()
292
+ resolveControllers(diOptions: DependencyInjectionOptions) {
293
+ return {
294
+ userController: asControllerClass(UserController),
295
+ notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
296
+ }
297
+ }
298
+ ```
299
+
300
+ ### Message Queue Resolvers
301
+
302
+ #### `asMessageQueueHandlerClass(Type, mqOptions, opts?)`
303
+ For message queue consumers following `message-queue-toolkit` conventions. Automatically handles `start`/`close` lifecycle and respects `messageQueueConsumersEnabled` option.
304
+
305
+ ```ts
306
+ messageQueueConsumer: asMessageQueueHandlerClass(MessageQueueConsumer, {
307
+ queueName: MessageQueueConsumer.QUEUE_ID,
308
+ diOptions,
309
+ })
310
+ ```
311
+
312
+ ### Background Job Resolvers
313
+
314
+ #### `asEnqueuedJobWorkerClass(Type, workerOptions, opts?)`
315
+ For enqueued job workers following `background-jobs-common` conventions. Automatically handles `start`/`dispose` lifecycle and respects `enqueuedJobWorkersEnabled` option.
316
+
317
+ ```ts
318
+ jobWorker: asEnqueuedJobWorkerClass(JobWorker, {
319
+ queueName: JobWorker.QUEUE_ID,
320
+ diOptions,
321
+ })
322
+ ```
323
+
324
+ #### `asPgBossProcessorClass(Type, processorOptions, opts?)`
325
+ For pg-boss job processor classes. Similar to `asEnqueuedJobWorkerClass` but uses `start`/`stop` lifecycle methods and initializes after pgBoss (priority 20).
326
+
327
+ ```ts
328
+ enrichUserPresenceJob: asPgBossProcessorClass(EnrichUserPresenceJob, {
329
+ queueName: EnrichUserPresenceJob.QUEUE_ID,
330
+ diOptions,
331
+ })
332
+ ```
333
+
334
+ #### `asPeriodicJobClass(Type, workerOptions, opts?)`
335
+ For periodic job classes following `background-jobs-common` conventions. Uses eager injection via `register` method and respects `periodicJobsEnabled` option.
336
+
337
+ ```ts
338
+ cleanupJob: asPeriodicJobClass(CleanupJob, {
339
+ jobName: CleanupJob.JOB_NAME,
340
+ diOptions,
341
+ })
342
+ ```
343
+
344
+ #### `asJobQueueClass(Type, queueOptions, opts?)`
345
+ For job queue classes. Marks the dependency as **public**. Respects `jobQueuesEnabled` option.
346
+
347
+ ```ts
348
+ queueManager: asJobQueueClass(QueueManager, {
349
+ diOptions,
350
+ })
351
+ ```
352
+
353
+ #### `asEnqueuedJobQueueManagerFunction(fn, diOptions, opts?)`
354
+ For job queue manager factory functions. Automatically calls `start()` with resolved enabled queues during initialization.
355
+
356
+ ```ts
357
+ jobQueueManager: asEnqueuedJobQueueManagerFunction(
358
+ createJobQueueManager,
359
+ diOptions,
360
+ )
361
+ ```
362
+
363
+ ## Server-Sent Events (SSE)
364
+
365
+ 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).
366
+
367
+ ### Prerequisites
368
+
369
+ Register the `@fastify/sse` plugin before using SSE controllers:
370
+
371
+ ```ts
372
+ import FastifySSEPlugin from '@fastify/sse'
373
+
374
+ const app = fastify()
375
+ await app.register(FastifySSEPlugin)
376
+ ```
377
+
378
+ ### Defining SSE Contracts
379
+
380
+ Use `buildSSERoute` for GET-based SSE streams or `buildPayloadSSERoute` for POST/PUT/PATCH streams:
381
+
382
+ ```ts
383
+ import { z } from 'zod'
384
+ import { buildSSERoute, buildPayloadSSERoute } from 'opinionated-machine'
385
+
386
+ // GET-based SSE stream (e.g., notifications)
387
+ export const notificationsContract = buildSSERoute({
388
+ path: '/api/notifications/stream',
389
+ params: z.object({}),
390
+ query: z.object({ userId: z.string().optional() }),
391
+ requestHeaders: z.object({}),
392
+ events: {
393
+ notification: z.object({
394
+ id: z.string(),
395
+ message: z.string(),
396
+ }),
397
+ },
398
+ })
399
+
400
+ // POST-based SSE stream (e.g., AI chat completions)
401
+ export const chatCompletionContract = buildPayloadSSERoute({
402
+ method: 'POST',
403
+ path: '/api/chat/completions',
404
+ params: z.object({}),
405
+ query: z.object({}),
406
+ requestHeaders: z.object({}),
407
+ body: z.object({
408
+ message: z.string(),
409
+ stream: z.literal(true),
410
+ }),
411
+ events: {
412
+ chunk: z.object({ content: z.string() }),
413
+ done: z.object({ totalTokens: z.number() }),
414
+ },
415
+ })
416
+ ```
417
+
418
+ ### Creating SSE Controllers
419
+
420
+ SSE controllers extend `AbstractSSEController` and must implement a two-parameter constructor. Use `buildSSEHandler` for automatic type inference of request parameters:
421
+
422
+ ```ts
423
+ import {
424
+ AbstractSSEController,
425
+ buildSSEHandler,
426
+ type SSEControllerConfig,
427
+ type SSEConnection
428
+ } from 'opinionated-machine'
429
+
430
+ type Contracts = {
431
+ notificationsStream: typeof notificationsContract
432
+ }
433
+
434
+ type Dependencies = {
435
+ notificationService: NotificationService
436
+ }
437
+
438
+ export class NotificationsSSEController extends AbstractSSEController<Contracts> {
439
+ public static contracts = {
440
+ notificationsStream: notificationsContract,
441
+ } as const
442
+
443
+ private readonly notificationService: NotificationService
444
+
445
+ // Required: two-parameter constructor (deps object, optional SSE config)
446
+ constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
447
+ super(deps, sseConfig)
448
+ this.notificationService = deps.notificationService
449
+ }
450
+
451
+ public buildSSERoutes() {
452
+ return {
453
+ notificationsStream: {
454
+ contract: NotificationsSSEController.contracts.notificationsStream,
455
+ handler: this.handleStream,
456
+ options: {
457
+ onConnect: (conn) => this.onConnect(conn),
458
+ onDisconnect: (conn) => this.onDisconnect(conn),
459
+ },
460
+ },
461
+ }
462
+ }
463
+
464
+ // Handler with automatic type inference from contract
465
+ private handleStream = buildSSEHandler(
466
+ notificationsContract,
467
+ async (request, connection) => {
468
+ // request.query is typed from contract: { userId?: string }
469
+ const userId = request.query.userId ?? 'anonymous'
470
+ connection.context = { userId }
471
+
472
+ // Subscribe to notifications for this user
473
+ this.notificationService.subscribe(userId, async (notification) => {
474
+ await this.sendEvent(connection.id, {
475
+ event: 'notification',
476
+ data: notification,
477
+ })
478
+ })
479
+ },
480
+ )
481
+
482
+ private onConnect = (connection: SSEConnection) => {
483
+ console.log('Client connected:', connection.id)
484
+ }
485
+
486
+ private onDisconnect = (connection: SSEConnection) => {
487
+ const userId = connection.context?.userId as string
488
+ this.notificationService.unsubscribe(userId)
489
+ console.log('Client disconnected:', connection.id)
490
+ }
491
+ }
492
+ ```
493
+
494
+ ### Type-Safe SSE Handlers with `buildSSEHandler`
495
+
496
+ For automatic type inference of request parameters (similar to `buildFastifyPayloadRoute` for regular controllers), use `buildSSEHandler`:
497
+
498
+ ```ts
499
+ import {
500
+ AbstractSSEController,
501
+ buildSSEHandler,
502
+ type SSEControllerConfig,
503
+ type SSEConnection
504
+ } from 'opinionated-machine'
505
+
506
+ class ChatSSEController extends AbstractSSEController<Contracts> {
507
+ public static contracts = {
508
+ chatCompletion: chatCompletionContract,
509
+ } as const
510
+
511
+ constructor(deps: Dependencies, sseConfig?: SSEControllerConfig) {
512
+ super(deps, sseConfig)
513
+ }
514
+
515
+ // Handler with automatic type inference from contract
516
+ private handleChatCompletion = buildSSEHandler(
517
+ chatCompletionContract,
518
+ async (request, connection) => {
519
+ // request.body is typed as { message: string; stream: true }
520
+ // request.query, request.params, request.headers all typed from contract
521
+ const words = request.body.message.split(' ')
522
+
523
+ for (const word of words) {
524
+ await this.sendEvent(connection.id, {
525
+ event: 'chunk',
526
+ data: { content: word },
527
+ })
528
+ }
529
+
530
+ // Gracefully end the stream - all sent data is flushed before connection closes
531
+ this.closeConnection(connection.id)
532
+ },
533
+ )
534
+
535
+ public buildSSERoutes() {
536
+ return {
537
+ chatCompletion: {
538
+ contract: ChatSSEController.contracts.chatCompletion,
539
+ handler: this.handleChatCompletion,
540
+ },
541
+ }
542
+ }
543
+ }
544
+ ```
545
+
546
+ You can also use `InferSSERequest<Contract>` for manual type annotation when needed:
547
+
548
+ ```ts
549
+ import { type InferSSERequest } from 'opinionated-machine'
550
+
551
+ private handleStream = async (
552
+ request: InferSSERequest<typeof chatCompletionContract>,
553
+ connection: SSEConnection,
554
+ ) => {
555
+ // request.body, request.params, etc. all typed from contract
556
+ }
557
+ ```
558
+
559
+ ### SSE Controllers Without Dependencies
560
+
561
+ For controllers without dependencies, still provide the two-parameter constructor:
562
+
563
+ ```ts
564
+ export class SimpleSSEController extends AbstractSSEController<Contracts> {
565
+ constructor(deps: object, sseConfig?: SSEControllerConfig) {
566
+ super(deps, sseConfig)
567
+ }
568
+
569
+ // ... implementation
570
+ }
571
+ ```
572
+
573
+ ### Registering SSE Controllers
574
+
575
+ 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:
576
+
577
+ ```ts
578
+ import { AbstractModule, asControllerClass, asSSEControllerClass, asServiceClass, type DependencyInjectionOptions } from 'opinionated-machine'
579
+
580
+ export class NotificationsModule extends AbstractModule<Dependencies> {
581
+ resolveDependencies() {
582
+ return {
583
+ notificationService: asServiceClass(NotificationService),
584
+ }
585
+ }
586
+
587
+ resolveControllers(diOptions: DependencyInjectionOptions) {
588
+ return {
589
+ // REST controller
590
+ usersController: asControllerClass(UsersController),
591
+ // SSE controller (automatically detected and registered for SSE routes)
592
+ notificationsSSEController: asSSEControllerClass(NotificationsSSEController, { diOptions }),
593
+ }
594
+ }
595
+ }
596
+ ```
597
+
598
+ ### Registering SSE Routes
599
+
600
+ Call `registerSSERoutes` after registering the `@fastify/sse` plugin:
601
+
602
+ ```ts
603
+ const app = fastify()
604
+ app.setValidatorCompiler(validatorCompiler)
605
+ app.setSerializerCompiler(serializerCompiler)
606
+
607
+ // Register @fastify/sse plugin first
608
+ await app.register(FastifySSEPlugin)
609
+
610
+ // Then register SSE routes
611
+ context.registerSSERoutes(app)
612
+
613
+ // Optionally with global preHandler for authentication
614
+ context.registerSSERoutes(app, {
615
+ preHandler: async (request, reply) => {
616
+ if (!request.headers.authorization) {
617
+ reply.code(401).send({ error: 'Unauthorized' })
618
+ }
619
+ },
620
+ })
621
+
622
+ await app.ready()
623
+ ```
624
+
625
+ ### Broadcasting Events
626
+
627
+ Send events to multiple connections using `broadcast()` or `broadcastIf()`:
628
+
629
+ ```ts
630
+ // Broadcast to ALL connected clients
631
+ await this.broadcast({
632
+ event: 'system',
633
+ data: { message: 'Server maintenance in 5 minutes' },
634
+ })
635
+
636
+ // Broadcast to connections matching a predicate
637
+ await this.broadcastIf(
638
+ { event: 'channel-update', data: { channelId: '123', newMessage: msg } },
639
+ (connection) => connection.context.channelId === '123',
640
+ )
641
+ ```
642
+
643
+ Both methods return the number of clients the message was successfully sent to.
644
+
645
+ ### Controller-Level Hooks
646
+
647
+ Override these optional methods on your controller for global connection handling:
648
+
649
+ ```ts
650
+ class MySSEController extends AbstractSSEController<Contracts> {
651
+ // Called AFTER connection is registered (for all routes)
652
+ protected onConnectionEstablished(connection: SSEConnection): void {
653
+ this.metrics.incrementConnections()
654
+ }
655
+
656
+ // Called BEFORE connection is unregistered (for all routes)
657
+ protected onConnectionClosed(connection: SSEConnection): void {
658
+ this.metrics.decrementConnections()
659
+ }
660
+ }
661
+ ```
662
+
663
+ ### Route-Level Options
664
+
665
+ Each route can have its own `preHandler`, lifecycle hooks, and logger:
666
+
667
+ ```ts
668
+ public buildSSERoutes() {
669
+ return {
670
+ adminStream: {
671
+ contract: AdminSSEController.contracts.adminStream,
672
+ handler: this.handleAdminStream,
673
+ options: {
674
+ // Route-specific authentication
675
+ preHandler: (request, reply) => {
676
+ if (!request.user?.isAdmin) {
677
+ reply.code(403).send({ error: 'Forbidden' })
678
+ }
679
+ },
680
+ onConnect: (conn) => console.log('Admin connected'),
681
+ onDisconnect: (conn) => console.log('Admin disconnected'),
682
+ // Handle client reconnection with Last-Event-ID
683
+ onReconnect: async (conn, lastEventId) => {
684
+ // Return events to replay, or handle manually
685
+ return this.getEventsSince(lastEventId)
686
+ },
687
+ // Optional: logger for error handling (requires @lokalise/node-core)
688
+ logger: this.logger,
689
+ },
690
+ },
691
+ }
692
+ }
693
+ ```
694
+
695
+ **Available route options:**
696
+
697
+ | Option | Description |
698
+ |--------|-------------|
699
+ | `preHandler` | Authentication/authorization hook that runs before SSE connection |
700
+ | `onConnect` | Called after client connects (SSE handshake complete) |
701
+ | `onDisconnect` | Called when client disconnects |
702
+ | `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
703
+ | `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
704
+
705
+ ### Graceful Shutdown
706
+
707
+ 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).
708
+
709
+ ### Error Handling
710
+
711
+ When `sendEvent()` fails (e.g., client disconnected), it:
712
+ - Returns `false` to indicate failure
713
+ - Automatically removes the dead connection from tracking
714
+ - Prevents further send attempts to that connection
715
+
716
+ ```ts
717
+ const sent = await this.sendEvent(connectionId, { event: 'update', data })
718
+ if (!sent) {
719
+ // Connection was closed or failed - already removed from tracking
720
+ this.cleanup(connectionId)
721
+ }
722
+ ```
723
+
724
+ **Lifecycle hook errors** (`onConnect`, `onReconnect`, `onDisconnect`):
725
+ - All lifecycle hooks are wrapped in try/catch to prevent crashes
726
+ - If a `logger` is provided in route options, errors are logged with context
727
+ - If no logger is provided, errors are silently ignored
728
+ - The connection lifecycle continues even if a hook throws
729
+
730
+ ```ts
731
+ // Provide a logger to capture lifecycle errors
732
+ public buildSSERoutes() {
733
+ return {
734
+ stream: {
735
+ contract: streamContract,
736
+ handler: this.handleStream,
737
+ options: {
738
+ logger: this.logger, // pino-compatible logger
739
+ onConnect: (conn) => { /* may throw */ },
740
+ onDisconnect: (conn) => { /* may throw */ },
741
+ },
742
+ },
743
+ }
744
+ }
745
+ ```
746
+
747
+ ### Long-lived Connections vs Request-Response Streaming
748
+
749
+ **Long-lived connections** (notifications, live updates):
750
+ - Handler sets up subscriptions and returns
751
+ - Connection stays open until client disconnects
752
+ - Events sent via `sendEvent()` from external triggers
753
+
754
+ ```ts
755
+ private handleStream = buildSSEHandler(streamContract, async (request, connection) => {
756
+ // Set up subscription
757
+ this.service.subscribe(connection.id, (data) => {
758
+ this.sendEvent(connection.id, { event: 'update', data })
759
+ })
760
+ // Handler returns, connection stays open
761
+ })
762
+ ```
763
+
764
+ **Request-response streaming** (AI completions):
765
+ - Handler sends all events and closes connection
766
+ - Similar to regular HTTP but with streaming body
767
+
768
+ ```ts
769
+ private handleChatCompletion = buildSSEHandler(chatCompletionContract, async (request, connection) => {
770
+ // request.body is typed from contract
771
+ const words = request.body.message.split(' ')
772
+
773
+ for (const word of words) {
774
+ await this.sendEvent(connection.id, {
775
+ event: 'chunk',
776
+ data: { content: word },
777
+ })
778
+ }
779
+
780
+ await this.sendEvent(connection.id, {
781
+ event: 'done',
782
+ data: { totalTokens: words.length },
783
+ })
784
+
785
+ // Gracefully end the stream - all sent data is flushed before connection closes
786
+ this.closeConnection(connection.id)
787
+ })
788
+ ```
789
+
790
+ ### SSE Parsing Utilities
791
+
792
+ The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
793
+
794
+ | Function | Use Case |
795
+ |----------|----------|
796
+ | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
797
+ | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
798
+
799
+ #### parseSSEEvents
800
+
801
+ Parse a complete SSE response body into an array of events.
802
+
803
+ **When to use:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
804
+
805
+ ```ts
806
+ import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
807
+
808
+ const responseBody = `event: notification
809
+ data: {"id":"1","message":"Hello"}
810
+
811
+ event: notification
812
+ data: {"id":"2","message":"World"}
813
+
814
+ `
815
+
816
+ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
817
+ // Result:
818
+ // [
819
+ // { event: 'notification', data: '{"id":"1","message":"Hello"}' },
820
+ // { event: 'notification', data: '{"id":"2","message":"World"}' }
821
+ // ]
822
+
823
+ // Access parsed data
824
+ const notifications = events.map(e => JSON.parse(e.data))
825
+ ```
826
+
827
+ #### parseSSEBuffer
828
+
829
+ Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
830
+
831
+ **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
832
+
833
+ ```ts
834
+ import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
835
+
836
+ let buffer = ''
837
+
838
+ // As chunks arrive from a stream...
839
+ for await (const chunk of stream) {
840
+ buffer += chunk
841
+ const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
842
+
843
+ // Process complete events
844
+ for (const event of result.events) {
845
+ console.log('Received:', event.event, event.data)
846
+ }
847
+
848
+ // Keep incomplete data for next chunk
849
+ buffer = result.remaining
850
+ }
851
+ ```
852
+
853
+ **Production example with fetch:**
854
+
855
+ ```ts
856
+ const response = await fetch(url)
857
+ const reader = response.body!.getReader()
858
+ const decoder = new TextDecoder()
859
+ let buffer = ''
860
+
861
+ while (true) {
862
+ const { done, value } = await reader.read()
863
+ if (done) break
864
+
865
+ buffer += decoder.decode(value, { stream: true })
866
+ const { events, remaining } = parseSSEBuffer(buffer)
867
+ buffer = remaining
868
+
869
+ for (const event of events) {
870
+ console.log('Received:', event.event, JSON.parse(event.data))
871
+ }
872
+ }
873
+ ```
874
+
875
+ #### ParsedSSEEvent Type
876
+
877
+ Both functions return events with this structure:
878
+
879
+ ```ts
880
+ type ParsedSSEEvent = {
881
+ id?: string // Event ID (from "id:" field)
882
+ event?: string // Event type (from "event:" field)
883
+ data: string // Event data (from "data:" field, always present)
884
+ retry?: number // Reconnection interval (from "retry:" field)
885
+ }
886
+ ```
887
+
888
+ ### Testing SSE Controllers
889
+
890
+ Enable the connection spy for testing by passing `isTestMode: true` in diOptions:
891
+
892
+ ```ts
893
+ import { createContainer } from 'awilix'
894
+ import { DIContext, SSETestServer, SSEHttpClient } from 'opinionated-machine'
895
+
896
+ describe('NotificationsSSEController', () => {
897
+ let server: SSETestServer
898
+ let controller: NotificationsSSEController
899
+
900
+ beforeEach(async () => {
901
+ // Create test server with isTestMode enabled
902
+ server = await SSETestServer.create(
903
+ async (app) => {
904
+ // Register your SSE routes here
905
+ },
906
+ {
907
+ setup: async () => {
908
+ // Set up DI container and resources
909
+ return { context }
910
+ },
911
+ }
912
+ )
913
+
914
+ controller = server.resources.context.diContainer.cradle.notificationsSSEController
915
+ })
916
+
917
+ afterEach(async () => {
918
+ await server.resources.context.destroy()
919
+ await server.close()
920
+ })
921
+
922
+ it('receives notifications over SSE', async () => {
923
+ // Connect with awaitServerConnection to eliminate race condition
924
+ const { client, serverConnection } = await SSEHttpClient.connect(
925
+ server.baseUrl,
926
+ '/api/notifications/stream',
927
+ {
928
+ query: { userId: 'test-user' },
929
+ awaitServerConnection: { controller },
930
+ },
931
+ )
932
+
933
+ expect(client.response.ok).toBe(true)
934
+
935
+ // Start collecting events
936
+ const eventsPromise = client.collectEvents(2)
937
+
938
+ // Send events from server (serverConnection is ready immediately)
939
+ await controller.sendEvent(serverConnection.id, {
940
+ event: 'notification',
941
+ data: { id: '1', message: 'Hello!' },
942
+ })
943
+
944
+ await controller.sendEvent(serverConnection.id, {
945
+ event: 'notification',
946
+ data: { id: '2', message: 'World!' },
947
+ })
948
+
949
+ // Wait for events
950
+ const events = await eventsPromise
951
+
952
+ expect(events).toHaveLength(2)
953
+ expect(JSON.parse(events[0].data)).toEqual({ id: '1', message: 'Hello!' })
954
+ expect(JSON.parse(events[1].data)).toEqual({ id: '2', message: 'World!' })
955
+
956
+ // Clean up
957
+ client.close()
958
+ })
959
+ })
960
+ ```
961
+
962
+ ### SSEConnectionSpy API
963
+
964
+ The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
965
+
966
+ ```ts
967
+ // Wait for a connection to be established (with timeout)
968
+ const connection = await controller.connectionSpy.waitForConnection({ timeout: 5000 })
969
+
970
+ // Wait for a connection matching a predicate (useful for multiple connections)
971
+ const connection = await controller.connectionSpy.waitForConnection({
972
+ timeout: 5000,
973
+ predicate: (conn) => conn.request.url.includes('/api/notifications'),
974
+ })
975
+
976
+ // Check if a specific connection is active
977
+ const isConnected = controller.connectionSpy.isConnected(connectionId)
978
+
979
+ // Wait for a specific connection to disconnect
980
+ await controller.connectionSpy.waitForDisconnection(connectionId, { timeout: 5000 })
981
+
982
+ // Get all connection events (connect/disconnect history)
983
+ const events = controller.connectionSpy.getEvents()
984
+
985
+ // Clear event history and claimed connections between tests
986
+ controller.connectionSpy.clear()
987
+ ```
988
+
989
+ **Note**: `waitForConnection` tracks "claimed" connections internally. Each call returns a unique unclaimed connection, allowing sequential waits for the same URL path without returning the same connection twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
990
+
991
+ ### Connection Monitoring
992
+
993
+ Controllers have access to utility methods for monitoring connections:
994
+
995
+ ```ts
996
+ // Get count of active connections
997
+ const count = this.getConnectionCount()
998
+
999
+ // Get all active connections (for iteration/inspection)
1000
+ const connections = this.getConnections()
1001
+
1002
+ // Check if connection spy is enabled (useful for conditional logic)
1003
+ if (this.hasConnectionSpy()) {
1004
+ // ...
1005
+ }
1006
+ ```
1007
+
1008
+ ### SSE Test Utilities
1009
+
1010
+ The library provides utilities for testing SSE endpoints.
1011
+
1012
+ **Two connection methods:**
1013
+ - **Inject** - Uses Fastify's built-in `inject()` to simulate HTTP requests directly in-memory, without network overhead. No `listen()` required. Handler must close the connection for the request to complete.
1014
+ - **Real HTTP** - Actual HTTP connection via `fetch()`. Requires the server to be listening. Supports long-lived connections.
1015
+
1016
+ #### Quick Reference
1017
+
1018
+ | Utility | Connection | Requires Contract | Use Case |
1019
+ |---------|------------|-------------------|----------|
1020
+ | `SSEInjectClient` | Inject (in-memory) | No | Request-response SSE without contracts |
1021
+ | `injectSSE` / `injectPayloadSSE` | Inject (in-memory) | **Yes** | Request-response SSE with type-safe contracts |
1022
+ | `SSEHttpClient` | Real HTTP | No | Long-lived SSE connections |
1023
+
1024
+ `SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
1025
+
1026
+ #### Inject vs HTTP Comparison
1027
+
1028
+ | Feature | Inject (`SSEInjectClient`, `injectSSE`) | HTTP (`SSEHttpClient`) |
1029
+ |---------|----------------------------------------|------------------------|
1030
+ | **Connection** | Fastify's `inject()` - in-memory | Real HTTP via `fetch()` |
1031
+ | **Event delivery** | All events returned at once (after handler closes) | Events arrive incrementally |
1032
+ | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
1033
+ | **Server requirement** | No `listen()` needed | Requires running server |
1034
+ | **Best for** | OpenAI-style streaming, batch exports | Notifications, live feeds, chat |
1035
+
1036
+ #### SSETestServer
1037
+
1038
+ Creates a test server with `@fastify/sse` pre-configured:
1039
+
1040
+ ```ts
1041
+ import { SSETestServer, SSEHttpClient } from 'opinionated-machine'
1042
+
1043
+ // Basic usage
1044
+ const server = await SSETestServer.create(async (app) => {
1045
+ app.get('/api/events', async (request, reply) => {
1046
+ reply.sse({ event: 'message', data: { hello: 'world' } })
1047
+ reply.sseClose()
1048
+ })
1049
+ })
1050
+
1051
+ // Connect and test
1052
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/events')
1053
+ const events = await client.collectEvents(1)
1054
+ expect(events[0].event).toBe('message')
1055
+
1056
+ // Cleanup
1057
+ client.close()
1058
+ await server.close()
1059
+ ```
1060
+
1061
+ With custom resources (DI container, controllers):
1062
+
1063
+ ```ts
1064
+ const server = await SSETestServer.create(
1065
+ async (app) => {
1066
+ // Register routes using resources from setup
1067
+ myController.registerRoutes(app)
1068
+ },
1069
+ {
1070
+ configureApp: async (app) => {
1071
+ app.setValidatorCompiler(validatorCompiler)
1072
+ },
1073
+ setup: async () => {
1074
+ // Resources are available via server.resources
1075
+ const container = createContainer()
1076
+ return { container }
1077
+ },
1078
+ }
1079
+ )
1080
+
1081
+ const { container } = server.resources
1082
+ ```
1083
+
1084
+ #### SSEHttpClient
1085
+
1086
+ For testing long-lived SSE connections using real HTTP:
1087
+
1088
+ ```ts
1089
+ import { SSEHttpClient } from 'opinionated-machine'
1090
+
1091
+ // Connect to SSE endpoint with awaitServerConnection (recommended)
1092
+ // This eliminates the race condition between client connect and server-side registration
1093
+ const { client, serverConnection } = await SSEHttpClient.connect(
1094
+ server.baseUrl,
1095
+ '/api/stream',
1096
+ {
1097
+ query: { userId: 'test' },
1098
+ headers: { authorization: 'Bearer token' },
1099
+ awaitServerConnection: { controller }, // Pass your SSE controller
1100
+ },
1101
+ )
1102
+
1103
+ // serverConnection is ready to use immediately
1104
+ expect(client.response.ok).toBe(true)
1105
+ await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
1106
+
1107
+ // Collect events by count with timeout
1108
+ const events = await client.collectEvents(3, 5000) // 3 events, 5s timeout
1109
+
1110
+ // Or collect until a predicate is satisfied
1111
+ const events = await client.collectEvents(
1112
+ (event) => event.event === 'done',
1113
+ 5000,
1114
+ )
1115
+
1116
+ // Iterate over events as they arrive
1117
+ for await (const event of client.events()) {
1118
+ console.log(event.event, event.data)
1119
+ if (event.event === 'done') break
1120
+ }
1121
+
1122
+ // Cleanup
1123
+ client.close()
1124
+ ```
1125
+
1126
+ **`collectEvents(countOrPredicate, timeout?)`**
1127
+
1128
+ Collects events until a count is reached or a predicate returns true.
1129
+
1130
+ | Parameter | Type | Description |
1131
+ |-----------|------|-------------|
1132
+ | `countOrPredicate` | `number \| (event) => boolean` | Number of events to collect, or predicate that returns `true` when collection should stop |
1133
+ | `timeout` | `number` | Maximum time to wait in milliseconds (default: 5000) |
1134
+
1135
+ Returns `Promise<ParsedSSEEvent[]>`. Throws an error if the timeout is reached before the condition is met.
1136
+
1137
+ ```ts
1138
+ // Collect exactly 3 events
1139
+ const events = await client.collectEvents(3)
1140
+
1141
+ // Collect with custom timeout
1142
+ const events = await client.collectEvents(5, 10000) // 10s timeout
1143
+
1144
+ // Collect until a specific event type (the matching event IS included)
1145
+ const events = await client.collectEvents((event) => event.event === 'done')
1146
+
1147
+ // Collect until condition with timeout
1148
+ const events = await client.collectEvents(
1149
+ (event) => JSON.parse(event.data).status === 'complete',
1150
+ 30000,
1151
+ )
1152
+ ```
1153
+
1154
+ **`events(signal?)`**
1155
+
1156
+ Async generator that yields events as they arrive. Accepts an optional `AbortSignal` for cancellation.
1157
+
1158
+ ```ts
1159
+ // Basic iteration
1160
+ for await (const event of client.events()) {
1161
+ console.log(event.event, event.data)
1162
+ if (event.event === 'done') break
1163
+ }
1164
+
1165
+ // With abort signal for timeout control
1166
+ const controller = new AbortController()
1167
+ const timeoutId = setTimeout(() => controller.abort(), 5000)
1168
+
1169
+ try {
1170
+ for await (const event of client.events(controller.signal)) {
1171
+ console.log(event)
1172
+ }
1173
+ } finally {
1174
+ clearTimeout(timeoutId)
1175
+ }
1176
+ ```
1177
+
1178
+ **When to omit `awaitServerConnection`**
1179
+
1180
+ Omit `awaitServerConnection` only in these cases:
1181
+ - Testing against external SSE endpoints (not your own controller)
1182
+ - When `isTestMode: false` (connectionSpy not available)
1183
+ - Simple smoke tests that only verify response headers/status without sending server events
1184
+
1185
+ **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.
1186
+
1187
+ ```ts
1188
+ // Example: smoke test that only checks connection works
1189
+ const client = await SSEHttpClient.connect(server.baseUrl, '/api/stream')
1190
+ expect(client.response.ok).toBe(true)
1191
+ expect(client.response.headers.get('content-type')).toContain('text/event-stream')
1192
+ client.close()
1193
+ ```
1194
+
1195
+ #### SSEInjectClient
1196
+
1197
+ For testing request-response style SSE streams (like OpenAI completions):
1198
+
1199
+ ```ts
1200
+ import { SSEInjectClient } from 'opinionated-machine'
1201
+
1202
+ const client = new SSEInjectClient(app) // No server.listen() needed
1203
+
1204
+ // GET request
1205
+ const conn = await client.connect('/api/export/progress', {
1206
+ headers: { authorization: 'Bearer token' },
1207
+ })
1208
+
1209
+ // POST request with body (OpenAI-style)
1210
+ const conn = await client.connectWithBody(
1211
+ '/api/chat/completions',
1212
+ { model: 'gpt-4', messages: [...], stream: true },
1213
+ )
1214
+
1215
+ // All events are available immediately (inject waits for complete response)
1216
+ expect(conn.getStatusCode()).toBe(200)
1217
+ const events = conn.getReceivedEvents()
1218
+ const chunks = events.filter(e => e.event === 'chunk')
1219
+ ```
1220
+
1221
+ #### Contract-Aware Inject Helpers
1222
+
1223
+ For typed testing with SSE contracts:
1224
+
1225
+ ```ts
1226
+ import { injectSSE, injectPayloadSSE, parseSSEEvents } from 'opinionated-machine'
1227
+
1228
+ // For GET SSE endpoints with contracts
1229
+ const { closed } = injectSSE(app, notificationsContract, {
1230
+ query: { userId: 'test' },
1231
+ })
1232
+ const result = await closed
1233
+ const events = parseSSEEvents(result.body)
1234
+
1235
+ // For POST/PUT/PATCH SSE endpoints with contracts
1236
+ const { closed } = injectPayloadSSE(app, chatCompletionContract, {
1237
+ body: { message: 'Hello', stream: true },
1238
+ })
1239
+ const result = await closed
1240
+ const events = parseSSEEvents(result.body)
1241
+ ```
1242
+