opinionated-machine 5.1.0 → 5.2.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 +1237 -278
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/AbstractController.d.ts +3 -3
- package/dist/lib/AbstractController.js.map +1 -1
- package/dist/lib/AbstractModule.d.ts +14 -0
- package/dist/lib/AbstractModule.js +16 -0
- package/dist/lib/AbstractModule.js.map +1 -1
- package/dist/lib/DIContext.d.ts +35 -0
- package/dist/lib/DIContext.js +99 -0
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/resolverFunctions.d.ts +33 -0
- package/dist/lib/resolverFunctions.js +46 -0
- package/dist/lib/resolverFunctions.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +163 -0
- package/dist/lib/sse/AbstractSSEController.js +228 -0
- package/dist/lib/sse/AbstractSSEController.js.map +1 -0
- package/dist/lib/sse/SSEConnectionSpy.d.ts +55 -0
- package/dist/lib/sse/SSEConnectionSpy.js +136 -0
- package/dist/lib/sse/SSEConnectionSpy.js.map +1 -0
- package/dist/lib/sse/index.d.ts +5 -0
- package/dist/lib/sse/index.js +6 -0
- package/dist/lib/sse/index.js.map +1 -0
- package/dist/lib/sse/sseContracts.d.ts +132 -0
- package/dist/lib/sse/sseContracts.js +102 -0
- package/dist/lib/sse/sseContracts.js.map +1 -0
- package/dist/lib/sse/sseParser.d.ts +167 -0
- package/dist/lib/sse/sseParser.js +225 -0
- package/dist/lib/sse/sseParser.js.map +1 -0
- package/dist/lib/sse/sseRouteBuilder.d.ts +47 -0
- package/dist/lib/sse/sseRouteBuilder.js +114 -0
- package/dist/lib/sse/sseRouteBuilder.js.map +1 -0
- package/dist/lib/sse/sseTypes.d.ts +164 -0
- package/dist/lib/sse/sseTypes.js +2 -0
- package/dist/lib/sse/sseTypes.js.map +1 -0
- package/dist/lib/testing/index.d.ts +5 -0
- package/dist/lib/testing/index.js +5 -0
- package/dist/lib/testing/index.js.map +1 -0
- package/dist/lib/testing/sseHttpClient.d.ts +203 -0
- package/dist/lib/testing/sseHttpClient.js +262 -0
- package/dist/lib/testing/sseHttpClient.js.map +1 -0
- package/dist/lib/testing/sseInjectClient.d.ts +173 -0
- package/dist/lib/testing/sseInjectClient.js +234 -0
- package/dist/lib/testing/sseInjectClient.js.map +1 -0
- package/dist/lib/testing/sseInjectHelpers.d.ts +59 -0
- package/dist/lib/testing/sseInjectHelpers.js +117 -0
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -0
- package/dist/lib/testing/sseTestServer.d.ts +93 -0
- package/dist/lib/testing/sseTestServer.js +108 -0
- package/dist/lib/testing/sseTestServer.js.map +1 -0
- package/dist/lib/testing/sseTestTypes.d.ts +106 -0
- package/dist/lib/testing/sseTestTypes.js +2 -0
- package/dist/lib/testing/sseTestTypes.js.map +1 -0
- package/package.json +80 -78
package/README.md
CHANGED
|
@@ -1,278 +1,1237 @@
|
|
|
1
|
-
# opinionated-machine
|
|
2
|
-
Very opinionated DI framework for fastify, built on top of awilix
|
|
3
|
-
|
|
4
|
-
##
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
```
|
|
165
|
-
|
|
166
|
-
##
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
)
|
|
277
|
-
```
|
|
278
|
-
|
|
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
|
+
|