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