opinionated-machine 9.0.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +85 -0
- package/README.md +222 -7
- package/dist/lib/DIContext.js +33 -27
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.js +79 -21
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/routes/fastifyRouteTypes.d.ts +97 -12
- package/dist/lib/routes/fastifyRouteTypes.js.map +1 -1
- package/dist/lib/routes/fastifyRouteUtils.js +15 -5
- package/dist/lib/routes/fastifyRouteUtils.js.map +1 -1
- package/dist/lib/routes/index.d.ts +2 -1
- package/dist/lib/routes/index.js +2 -0
- package/dist/lib/routes/index.js.map +1 -1
- package/dist/lib/routes/sseResponseSchema.d.ts +35 -0
- package/dist/lib/routes/sseResponseSchema.js +115 -0
- package/dist/lib/routes/sseResponseSchema.js.map +1 -0
- package/dist/lib/sse/SSESessionSpy.d.ts +35 -7
- package/dist/lib/sse/SSESessionSpy.js +30 -1
- package/dist/lib/sse/SSESessionSpy.js.map +1 -1
- package/dist/lib/sse/index.d.ts +1 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +64 -0
- package/dist/lib/testing/apiSseInjectHelpers.js +212 -0
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseTestTypes.d.ts +202 -0
- package/dist/lib/testing/apiSseTestTypes.js +2 -0
- package/dist/lib/testing/apiSseTestTypes.js.map +1 -0
- package/dist/lib/testing/index.d.ts +4 -1
- package/dist/lib/testing/index.js +2 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +36 -4
- package/dist/lib/testing/sseHttpClient.js +29 -6
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectHelpers.js +1 -12
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/sseInjectShared.d.ts +7 -0
- package/dist/lib/testing/sseInjectShared.js +19 -0
- package/dist/lib/testing/sseInjectShared.js.map +1 -0
- package/dist/lib/testing/sseSessionSpyFactory.d.ts +109 -0
- package/dist/lib/testing/sseSessionSpyFactory.js +100 -0
- package/dist/lib/testing/sseSessionSpyFactory.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# opinionated-machine
|
|
2
2
|
|
|
3
|
+
## 10.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9a9a89d: Add `injectApiSSE`, a contract-typed SSE inject helper for contracts built with `defineApiContract` + `sseResponse`/`sseBody`. The existing `injectSSE`/`injectPayloadSSE` are typed against the legacy `SSEContractDefinition` and reject the newer contract shape. `injectApiSSE` covers every HTTP method from the contract, takes the same params as `injectByApiContract`, resolves `bodyForStatus` schemas from `responsesByStatusCode` (exact → range → `default` precedence), and adds `events()` for events parsed and validated against the contract's SSE schemas, merged across every declared status. The request always asks for `text/event-stream`, so statuses that declare a stream — dual-mode ones included — are excluded from `bodyForStatus`, and `events` is typed `never` for contracts that declare no SSE response.
|
|
8
|
+
|
|
9
|
+
## 10.1.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 45dd012: Add `createSSESessionSpy()` testing factory so `buildApiRoute` routes can use `SSEHttpClient`'s `awaitServerConnection`. It returns a standalone `SSESessionSpy`, `{ onConnect, onClose }` route options to spread into a route with no lifecycle hooks of its own, and a `withSpy()` helper that merges the spy into a route's existing options by chaining rather than replacing its `onConnect` / `onClose`. `awaitServerConnection` now accepts `{ spy }` alongside `{ controller }`, and `SSESessionSpy` is generic over the observed session type, defaulting to the previous one.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 45dd012: Fix `SSEHttpClient.connect()` leaking the open SSE response when `awaitServerConnection` times out. The caller never received a client handle, so a keep-alive stream stayed open and hung the test's `app.close()`, hiding the original timeout behind a suite-level timeout. A `waitForConnection` timeout now also explains itself when matching connections were registered but had already closed, which is what an `autoClose` session looks like to the spy.
|
|
18
|
+
|
|
19
|
+
## 10.0.0
|
|
20
|
+
|
|
21
|
+
### Major Changes
|
|
22
|
+
|
|
23
|
+
- d06517a: Replace the silently-ignored per-route `heartbeatInterval` SSE option with `heartbeat: boolean`.
|
|
24
|
+
|
|
25
|
+
`@fastify/sse` has no per-route `heartbeatInterval`: the route-level knob is `heartbeat`, a boolean
|
|
26
|
+
that can only turn the heartbeat off, while the interval is a plugin-registration option shared by
|
|
27
|
+
all routes. The route builder was copying `heartbeatInterval` onto the route's `sse` option, where
|
|
28
|
+
the plugin never read it — so `buildHandler(contract, handlers, { heartbeatInterval: 5000 })`
|
|
29
|
+
type-checked, ran, and did nothing, and there was no way to disable the heartbeat for a single route.
|
|
30
|
+
|
|
31
|
+
Breaking changes:
|
|
32
|
+
|
|
33
|
+
- `FastifySSERouteOptions` / `FastifyDualModeRouteOptions`: `heartbeatInterval?: number` is replaced
|
|
34
|
+
by `heartbeat?: boolean`. Set `heartbeat: false` to suppress heartbeat comments on a route.
|
|
35
|
+
- `RegisterSSERoutesOptions` / `RegisterDualModeRoutesOptions`: `heartbeatInterval?: number` is
|
|
36
|
+
likewise replaced by `heartbeat?: boolean`. These options are applied to individual routes, not to
|
|
37
|
+
plugin registration, so they could never carry an interval either.
|
|
38
|
+
|
|
39
|
+
Configure the interval where it actually works, once for all routes:
|
|
40
|
+
`app.register(fastifySSE, { heartbeatInterval: 30000 })`.
|
|
41
|
+
|
|
42
|
+
Also fixes the registration-level `heartbeat` / `serializer` defaults from `registerSSERoutes()` and
|
|
43
|
+
`registerDualModeRoutes()`, which were written to `config.sse` — a location `@fastify/sse` never
|
|
44
|
+
reads — and are now merged into the top-level `sse` route option, with per-route values taking
|
|
45
|
+
precedence.
|
|
46
|
+
|
|
47
|
+
### Minor Changes
|
|
48
|
+
|
|
49
|
+
- e935a42: Register SSE and dual-mode routes with an explicit `@fastify/sse` kind of `'manual'` instead of falling back to the plugin's `'legacy'` kind.
|
|
50
|
+
|
|
51
|
+
Previously `buildFastifyRoute` emitted `sse: true` (or an options object without `kind`), which resolves to `'legacy'` and applies a strict `Accept` gate: a client that did not send an explicit `text/event-stream` token — a wildcard `Accept` header, `Accept: application/json`, or no `Accept` header at all — reached the SSE handler with `reply.sse` undefined, so the first `sse.start()` threw and the request returned a 500. The same applied to dual-mode routes configured with `defaultMode: 'sse'`. With `'manual'` there is no plugin-side negotiation: `reply.sse` is always attached and the handler decides whether to stream, which is what these route handlers already do.
|
|
52
|
+
|
|
53
|
+
Adds a `kind` route option so the default can be overridden, restricted per route type to the kinds that can actually work:
|
|
54
|
+
|
|
55
|
+
- SSE-only routes accept `'manual' | 'only'` (`SSEOnlyRouteKind`). `'only'` makes the plugin content-negotiate and answer `406 Not Acceptable` before the handler runs; note its gate admits a missing `Accept` header and the `*/*` and `text/*` wildcards but rejects every other concrete media type, `application/json` included.
|
|
56
|
+
- Dual-mode routes accept `'manual' | 'dual'` (`DualModeRouteKind`). Combining `kind: 'dual'` with `defaultMode: 'sse'` now throws at route-build time instead of returning a 500 per request, because the plugin's gate and the handler's own `determineMode()` disagree for wildcard or absent `Accept` headers.
|
|
57
|
+
|
|
58
|
+
Also stops the SSE teardown path from masking errors: `closeSSESession` re-read `reply.sse.isConnected` from inside its own `catch`, which raised a second `TypeError` and replaced the original failure, and `sse.start()` now marks the session started only after its first successful `reply.sse` access.
|
|
59
|
+
|
|
60
|
+
## 9.1.0
|
|
61
|
+
|
|
62
|
+
### Minor Changes
|
|
63
|
+
|
|
64
|
+
- a806edc: Document SSE and dual-mode route responses in the generated OpenAPI spec.
|
|
65
|
+
|
|
66
|
+
`buildFastifyRoute` left `schema.response` empty for `AbstractSSEController` and
|
|
67
|
+
`AbstractDualModeController` routes, so the spec showed a bare "Default Response" with no
|
|
68
|
+
event shapes and no error bodies, even though the same contract data was already used for
|
|
69
|
+
runtime validation. Both builders now derive `schema.response` from the contract: 200
|
|
70
|
+
describes the event stream under `text/event-stream` (one `{ id?, event, data, retry? }`
|
|
71
|
+
envelope per event, as a `oneOf` with the event name pinned to a `const`, matching what
|
|
72
|
+
`@lokalise/fastify-api-contracts` emits for `sseBody()`) plus the JSON body under
|
|
73
|
+
`application/json`, and each status in `responseBodySchemasByStatusCode` gets its declared
|
|
74
|
+
schema.
|
|
75
|
+
|
|
76
|
+
Statuses that more than one body shape can reach accept all of them, since Fastify rejects
|
|
77
|
+
anything the schema does not cover: a dual-mode 2xx accepts both `successResponseBodySchema`
|
|
78
|
+
(the `sync` handler) and the schema declared for that status (`sse.respond()`), and a non-2xx
|
|
79
|
+
accepts the framework error envelope alongside the declared body, so declaring a 400 no longer
|
|
80
|
+
turns a failed request validation into a 500. Errors the builders raise themselves before
|
|
81
|
+
streaming starts are sent pre-serialized and skip the schema, keeping the thrown error's
|
|
82
|
+
message intact.
|
|
83
|
+
|
|
84
|
+
This puts Fastify's serializer in the path for the status codes a contract declares. A
|
|
85
|
+
response body that previously went out through plain `JSON.stringify` is now serialized
|
|
86
|
+
against its contract schema, so keys the schema does not declare are dropped.
|
|
87
|
+
|
|
3
88
|
## 9.0.0
|
|
4
89
|
|
|
5
90
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -732,6 +732,13 @@ const app = fastify()
|
|
|
732
732
|
await app.register(FastifySSEPlugin)
|
|
733
733
|
```
|
|
734
734
|
|
|
735
|
+
Plugin-level options apply to every SSE route. The heartbeat interval is set here and only
|
|
736
|
+
here - it is not a per-route option:
|
|
737
|
+
|
|
738
|
+
```ts
|
|
739
|
+
await app.register(FastifySSEPlugin, { heartbeatInterval: 30000 })
|
|
740
|
+
```
|
|
741
|
+
|
|
735
742
|
### Defining SSE Contracts
|
|
736
743
|
|
|
737
744
|
Use `buildSseContract` from `@lokalise/api-contracts` to define SSE routes. The `method` field determines the HTTP method. Paths are defined using `pathResolver`, a type-safe function that receives typed params and returns the URL path:
|
|
@@ -1101,7 +1108,8 @@ private handleAdminStream = buildHandler(adminStreamContract, {
|
|
|
1101
1108
|
| `onReconnect` | Handle Last-Event-ID reconnection, return events to replay |
|
|
1102
1109
|
| `logger` | Optional `SSELogger` for error handling (compatible with pino and `@lokalise/node-core`). If not provided, errors in lifecycle hooks are silently ignored |
|
|
1103
1110
|
| `serializer` | Custom serializer for SSE data (e.g., for custom JSON encoding) |
|
|
1104
|
-
| `
|
|
1111
|
+
| `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 })` |
|
|
1112
|
+
| `kind` | `@fastify/sse` route kind - how the `Accept` header is negotiated. Defaults to `'manual'` (see below) |
|
|
1105
1113
|
| `contractMetadataToRouteMapper` | Maps contract metadata to Fastify route options (see below) |
|
|
1106
1114
|
|
|
1107
1115
|
**onClose reason parameter:**
|
|
@@ -1116,10 +1124,65 @@ options: {
|
|
|
1116
1124
|
// reason is 'server' or 'client'
|
|
1117
1125
|
},
|
|
1118
1126
|
serializer: (data) => JSON.stringify(data, null, 2), // Pretty-print JSON
|
|
1119
|
-
|
|
1127
|
+
heartbeat: false, // Disable heartbeat comments on this route
|
|
1120
1128
|
}
|
|
1121
1129
|
```
|
|
1122
1130
|
|
|
1131
|
+
The heartbeat *interval* is not a route option - `@fastify/sse` only exposes a boolean at route
|
|
1132
|
+
level, and reads the interval once, when the plugin is registered, applying it to every SSE route:
|
|
1133
|
+
|
|
1134
|
+
```ts
|
|
1135
|
+
await app.register(FastifySSEPlugin, { heartbeatInterval: 30000 })
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
#### `kind` and `Accept` header negotiation
|
|
1139
|
+
|
|
1140
|
+
Routes are registered with the `@fastify/sse` kind `'manual'`, which means the plugin performs **no**
|
|
1141
|
+
`Accept` header negotiation: `reply.sse` is always attached and the route handler decides at runtime
|
|
1142
|
+
whether to stream or to send a regular HTTP response.
|
|
1143
|
+
|
|
1144
|
+
This matters because SSE handlers built with `buildHandler` have a single code path that calls
|
|
1145
|
+
`sse.start()`. Clients that do not send an explicit `Accept: text/event-stream` token - a wildcard
|
|
1146
|
+
`Accept` header (the default for most non-browser HTTP clients), `Accept: application/json`, or no
|
|
1147
|
+
`Accept` header at all (typical of clients generated from the route's OpenAPI spec) - would otherwise
|
|
1148
|
+
reach the handler with `reply.sse` left undefined and get a `500` instead of a stream. It also keeps
|
|
1149
|
+
`sse.respond()` early returns available to every client. Dual-mode routes negotiate the `Accept`
|
|
1150
|
+
header themselves (honouring `defaultMode`), so they use the same kind.
|
|
1151
|
+
|
|
1152
|
+
Each route type accepts only the kinds that can actually work for it:
|
|
1153
|
+
|
|
1154
|
+
**SSE-only routes** - `'manual' | 'only'`
|
|
1155
|
+
|
|
1156
|
+
| Kind | Behavior |
|
|
1157
|
+
| ---- | -------- |
|
|
1158
|
+
| `'manual'` (default) | No negotiation. `reply.sse` is always attached, the handler decides |
|
|
1159
|
+
| `'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 |
|
|
1160
|
+
|
|
1161
|
+
**Dual-mode routes** - `'manual' | 'dual'`
|
|
1162
|
+
|
|
1163
|
+
| Kind | Behavior |
|
|
1164
|
+
| ---- | -------- |
|
|
1165
|
+
| `'manual'` (default) | No plugin-side negotiation. The route's own `determineMode()` picks the mode, honouring `defaultMode` |
|
|
1166
|
+
| `'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 |
|
|
1167
|
+
|
|
1168
|
+
The plugin's other kinds are deliberately not exposed: `'dual'` on an SSE-only route (and the
|
|
1169
|
+
`sse: true` `'legacy'` default) can only ever leave a single-code-path handler without `reply.sse`,
|
|
1170
|
+
and `'only'` on a dual-mode route would make the JSON half unreachable.
|
|
1171
|
+
|
|
1172
|
+
Override it per route when you want different semantics:
|
|
1173
|
+
|
|
1174
|
+
```ts
|
|
1175
|
+
private handleAdminStream = buildHandler(adminStreamContract, {
|
|
1176
|
+
sse: async (request, sse) => {
|
|
1177
|
+
const session = sse.start('keepAlive')
|
|
1178
|
+
// ... handler logic
|
|
1179
|
+
},
|
|
1180
|
+
}, {
|
|
1181
|
+
// Content-negotiate: anything that does not accept text/event-stream gets 406 Not Acceptable
|
|
1182
|
+
kind: 'only',
|
|
1183
|
+
})
|
|
1184
|
+
```
|
|
1185
|
+
|
|
1123
1186
|
#### `contractMetadataToRouteMapper`
|
|
1124
1187
|
|
|
1125
1188
|
Allows attaching cross-cutting behavior (auth, rate limiting, tracing, etc.) to a route based on metadata defined in the
|
|
@@ -1496,7 +1559,7 @@ describe('NotificationsSSEController', () => {
|
|
|
1496
1559
|
|
|
1497
1560
|
#### Testing autoClose SSE (request-response streaming)
|
|
1498
1561
|
|
|
1499
|
-
Use `SSEInjectClient` or the contract-aware `injectSSE`/`injectPayloadSSE` helpers. No real HTTP server needed - all events are available immediately after the handler completes:
|
|
1562
|
+
Use `SSEInjectClient` or the contract-aware `injectSSE`/`injectPayloadSSE` helpers (`injectApiSSE` for `defineApiContract` contracts). No real HTTP server needed - all events are available immediately after the handler completes:
|
|
1500
1563
|
|
|
1501
1564
|
```ts
|
|
1502
1565
|
import { SSEInjectClient } from 'opinionated-machine'
|
|
@@ -1550,6 +1613,49 @@ it('returns the documented 401 body when unauthenticated', async () => {
|
|
|
1550
1613
|
|
|
1551
1614
|
`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.
|
|
1552
1615
|
|
|
1616
|
+
#### Contracts built with `defineApiContract`: `injectApiSSE`
|
|
1617
|
+
|
|
1618
|
+
`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:
|
|
1619
|
+
|
|
1620
|
+
```ts
|
|
1621
|
+
import { defineApiContract, sseResponse } from '@lokalise/api-contracts'
|
|
1622
|
+
import { z } from 'zod/v4'
|
|
1623
|
+
import { injectApiSSE } from 'opinionated-machine'
|
|
1624
|
+
|
|
1625
|
+
const lqaSegmentContract = defineApiContract({
|
|
1626
|
+
visibility: 'internal',
|
|
1627
|
+
method: 'post',
|
|
1628
|
+
summary: 'Perform LQA on a text segment',
|
|
1629
|
+
pathResolver: () => '/v1/content/actions/lqa-text-segment',
|
|
1630
|
+
requestBodySchema: z.object({ segment: z.string() }),
|
|
1631
|
+
responsesByStatusCode: {
|
|
1632
|
+
200: sseResponse({ review: z.object({ score: z.number() }) }),
|
|
1633
|
+
400: z.object({ message: z.string() }),
|
|
1634
|
+
},
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
it('streams the review', async () => {
|
|
1638
|
+
const { events } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
|
|
1639
|
+
|
|
1640
|
+
// Events are validated against the contract and typed as a union on `event`.
|
|
1641
|
+
for (const event of await events()) {
|
|
1642
|
+
if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
|
|
1643
|
+
}
|
|
1644
|
+
})
|
|
1645
|
+
|
|
1646
|
+
it('returns the documented 400 body for an empty segment', async () => {
|
|
1647
|
+
const { bodyForStatus } = injectApiSSE(app, lqaSegmentContract, { body: { segment: '' } })
|
|
1648
|
+
|
|
1649
|
+
// `body` is typed as `{ message: string }` — the contract's 400 schema.
|
|
1650
|
+
const body = await bodyForStatus(400)
|
|
1651
|
+
expect(body.message).toBe('segment must not be empty')
|
|
1652
|
+
})
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
`closed` and `bodyForStatus` behave as they do on `injectSSE`, except that `bodyForStatus` resolves its schema from `responsesByStatusCode`, following the same exact → range → `'default'` precedence as the contract client. `events()` is additionally available: it parses the SSE body and validates each event against the contract's SSE schemas, throwing when the response isn't a stream, when an event name isn't declared, or when a payload fails its schema.
|
|
1656
|
+
|
|
1657
|
+
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.
|
|
1658
|
+
|
|
1553
1659
|
### SSESessionSpy API
|
|
1554
1660
|
|
|
1555
1661
|
The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
|
|
@@ -1579,6 +1685,77 @@ controller.connectionSpy.clear()
|
|
|
1579
1685
|
|
|
1580
1686
|
**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`.
|
|
1581
1687
|
|
|
1688
|
+
#### Standalone spy for `buildApiRoute` routes
|
|
1689
|
+
|
|
1690
|
+
`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:
|
|
1691
|
+
|
|
1692
|
+
```ts
|
|
1693
|
+
import { createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
|
|
1694
|
+
|
|
1695
|
+
const { spy, routeOptions } = createSSESessionSpy()
|
|
1696
|
+
|
|
1697
|
+
// in the app under test — `routeOptions` is just `{ onConnect, onClose }`
|
|
1698
|
+
app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
|
|
1699
|
+
|
|
1700
|
+
// in the test — same race-free connect as with a controller
|
|
1701
|
+
const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, '/api/stream', {
|
|
1702
|
+
awaitServerConnection: { spy },
|
|
1703
|
+
})
|
|
1704
|
+
await serverConnection.send('ping', { seq: 1 })
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
`awaitServerConnection` accepts either `{ controller }` or `{ spy }`; both wait for the server-side handler to finish registering the session before `connect()` resolves.
|
|
1708
|
+
|
|
1709
|
+
**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:
|
|
1710
|
+
|
|
1711
|
+
```ts
|
|
1712
|
+
// Does NOT work: the route was already built without the hooks
|
|
1713
|
+
const route = controller.routes.streamUpdates
|
|
1714
|
+
Object.assign(route, routeOptions) // no-op as far as SSE lifecycle hooks go
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
A route owned by a controller therefore has to accept SSE route options for a test to be able to spy on it. Take them as a dependency and pass them through:
|
|
1718
|
+
|
|
1719
|
+
```ts
|
|
1720
|
+
export class StreamController extends AbstractApiController<typeof StreamController.contracts> {
|
|
1721
|
+
static contracts = { streamUpdates: streamUpdatesContract } as const
|
|
1722
|
+
|
|
1723
|
+
readonly routes: Record<keyof typeof StreamController.contracts, RouteOptions>
|
|
1724
|
+
|
|
1725
|
+
constructor({ sseRouteOptions }: StreamControllerDependencies) {
|
|
1726
|
+
super()
|
|
1727
|
+
this.routes = {
|
|
1728
|
+
streamUpdates: buildApiRoute(
|
|
1729
|
+
StreamController.contracts.streamUpdates,
|
|
1730
|
+
this.streamUpdates,
|
|
1731
|
+
sseRouteOptions,
|
|
1732
|
+
),
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// production wiring registers no hooks; the test registers the spy's
|
|
1738
|
+
const { spy, routeOptions } = createSSESessionSpy()
|
|
1739
|
+
const controller = new StreamController({ sseRouteOptions: routeOptions })
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
If the route already declares lifecycle hooks of its own, use `withSpy()` rather than spreading `routeOptions` over them — spreading silently drops one side or the other, depending on the order. `withSpy()` keeps the route's hook (it runs first, and the spy is notified once it settles) and passes every other option through:
|
|
1743
|
+
|
|
1744
|
+
```ts
|
|
1745
|
+
const { spy, withSpy } = createSSESessionSpy()
|
|
1746
|
+
|
|
1747
|
+
app.route(
|
|
1748
|
+
buildApiRoute(streamContract, handler, withSpy({
|
|
1749
|
+
heartbeat: false,
|
|
1750
|
+
onConnect: (connection) => subscriptions.add(connection.id),
|
|
1751
|
+
})),
|
|
1752
|
+
)
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
**`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.
|
|
1756
|
+
|
|
1757
|
+
The spy is typed for the `SSESession` of `@lokalise/fastify-api-contracts`, which is what `buildApiRoute` passes to its hooks. To wire the same spy into a `buildFastifyRoute`-built route, parameterize it with this package's session type: `createSSESessionSpy<SSESession>()`.
|
|
1758
|
+
|
|
1582
1759
|
### Session Monitoring
|
|
1583
1760
|
|
|
1584
1761
|
Controllers have access to utility methods for monitoring sessions:
|
|
@@ -2064,7 +2241,7 @@ The library provides utilities for testing SSE endpoints.
|
|
|
2064
2241
|
| `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
|
|
2065
2242
|
| `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
|
|
2066
2243
|
|
|
2067
|
-
`SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
|
|
2244
|
+
`SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs. Contracts built with `defineApiContract` use `injectApiSSE` instead of `injectSSE`/`injectPayloadSSE`.
|
|
2068
2245
|
|
|
2069
2246
|
#### Detailed Comparison
|
|
2070
2247
|
|
|
@@ -2177,6 +2354,9 @@ Omit `awaitServerConnection` only in these cases:
|
|
|
2177
2354
|
- Testing against external SSE endpoints (not your own controller)
|
|
2178
2355
|
- When `isTestMode: false` (connectionSpy not available)
|
|
2179
2356
|
- Simple smoke tests that only verify response headers/status without sending server events
|
|
2357
|
+
- 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
|
|
2358
|
+
|
|
2359
|
+
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).
|
|
2180
2360
|
|
|
2181
2361
|
**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.
|
|
2182
2362
|
|
|
@@ -2448,9 +2628,44 @@ sync: (request, reply) => {
|
|
|
2448
2628
|
|
|
2449
2629
|
**Validation priority for 2xx status codes:**
|
|
2450
2630
|
|
|
2451
|
-
- All 2xx responses (200, 201, 204, etc.) are validated against
|
|
2452
|
-
|
|
2453
|
-
-
|
|
2631
|
+
- All 2xx responses (200, 201, 204, etc.) returned by the `sync` handler are validated against
|
|
2632
|
+
`successResponseBodySchema`
|
|
2633
|
+
- For the `sync` handler, `responseBodySchemasByStatusCode` is only used for non-2xx status codes,
|
|
2634
|
+
so `successResponseBodySchema` takes precedence when the same 2xx code is defined in both
|
|
2635
|
+
- `sse.respond(code, body)` is validated against `responseBodySchemasByStatusCode[code]` at every
|
|
2636
|
+
status, 2xx included, because that is the schema its argument is typed from
|
|
2637
|
+
|
|
2638
|
+
**OpenAPI output and serialization:**
|
|
2639
|
+
|
|
2640
|
+
`buildFastifyRoute` fills in the route's `schema.response` from the contract, so the generated
|
|
2641
|
+
spec describes each status instead of showing a bare "Default Response":
|
|
2642
|
+
|
|
2643
|
+
- 200 carries `text/event-stream` with one `{ id?, event, data, retry? }` envelope per entry in
|
|
2644
|
+
`serverSentEventSchemas`, rendered as a `oneOf` with the `event` name pinned to a `const` in
|
|
2645
|
+
each branch, plus `application/json` for the JSON body
|
|
2646
|
+
- Every status in `responseBodySchemasByStatusCode` gets its declared schema
|
|
2647
|
+
|
|
2648
|
+
Because Fastify drives serialization from the same `schema.response`, a response body for a
|
|
2649
|
+
status the contract declares is serialized against that schema. Keys the schema does not
|
|
2650
|
+
declare are dropped from the body that goes out. Streamed SSE events are written directly by
|
|
2651
|
+
`@fastify/sse` and bypass the serializer, so the 200 event schema documents the stream without
|
|
2652
|
+
affecting it.
|
|
2653
|
+
|
|
2654
|
+
A status can be reached by more than one body shape, and Fastify rejects anything the schema
|
|
2655
|
+
does not accept, so each status accepts every shape the runtime can produce there:
|
|
2656
|
+
|
|
2657
|
+
- On a 2xx of a dual-mode contract, `application/json` accepts `successResponseBodySchema` (what
|
|
2658
|
+
the `sync` handler is validated against) as well as the schema declared for that status (what
|
|
2659
|
+
`sse.respond()` is validated against), rather than one taking precedence over the other in the
|
|
2660
|
+
spec
|
|
2661
|
+
- On a non-2xx, the declared schema is joined by the framework error envelope
|
|
2662
|
+
(`{ statusCode, message, error?, code?, ... }`), which is what Fastify sends for a failed
|
|
2663
|
+
request validation and what an application-level error handler typically returns. Without it,
|
|
2664
|
+
declaring a 400 body would turn every `FST_ERR_VALIDATION` on that route into a 500
|
|
2665
|
+
|
|
2666
|
+
Both show up in the spec as an `anyOf`. Errors the SSE builders raise themselves, before
|
|
2667
|
+
streaming starts, are sent pre-serialized and skip the schema entirely, so the thrown error's
|
|
2668
|
+
message always reaches the client.
|
|
2454
2669
|
|
|
2455
2670
|
### Single Sync Handler
|
|
2456
2671
|
|
package/dist/lib/DIContext.js
CHANGED
|
@@ -1,8 +1,38 @@
|
|
|
1
1
|
import { AwilixManager } from 'awilix-manager';
|
|
2
|
-
import { merge } from 'ts-deepmerge';
|
|
3
2
|
import { mergeConfigAndDependencyOverrides } from './configUtils.js';
|
|
4
3
|
import { buildGatewayManifestFrom, } from './gateway/index.js';
|
|
5
4
|
import { buildFastifyRoute, } from './routes/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* Apply registration-level SSE defaults (`heartbeat`, `serializer`) to a route.
|
|
7
|
+
*
|
|
8
|
+
* `@fastify/sse` reads its per-route configuration from the top-level `sse` route
|
|
9
|
+
* option (not from `config.sse`), and only supports a boolean `heartbeat` there —
|
|
10
|
+
* the heartbeat interval is a plugin-registration option shared by all routes.
|
|
11
|
+
*
|
|
12
|
+
* Values already set on the route by the route builder win over the registration-level
|
|
13
|
+
* defaults.
|
|
14
|
+
*/
|
|
15
|
+
function applyGlobalSSEOptions(route, options) {
|
|
16
|
+
if (options?.heartbeat === undefined && options?.serializer === undefined) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const routeWithSSE = route;
|
|
20
|
+
const routeSSEOption = routeWithSSE.sse;
|
|
21
|
+
if (!routeSSEOption) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// The route option is either `true` (plain SSE), a bare kind string, or an options object.
|
|
25
|
+
const routeSSEConfig = typeof routeSSEOption === 'string'
|
|
26
|
+
? { kind: routeSSEOption }
|
|
27
|
+
: typeof routeSSEOption === 'object'
|
|
28
|
+
? routeSSEOption
|
|
29
|
+
: {};
|
|
30
|
+
routeWithSSE.sse = {
|
|
31
|
+
...(options.heartbeat !== undefined && { heartbeat: options.heartbeat }),
|
|
32
|
+
...(options.serializer !== undefined && { serializer: options.serializer }),
|
|
33
|
+
...routeSSEConfig,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
6
36
|
export class DIContext {
|
|
7
37
|
options;
|
|
8
38
|
awilixManager;
|
|
@@ -249,19 +279,7 @@ export class DIContext {
|
|
|
249
279
|
if (options?.rateLimit) {
|
|
250
280
|
this.applyRateLimit(route, options.rateLimit);
|
|
251
281
|
}
|
|
252
|
-
|
|
253
|
-
if (options?.heartbeatInterval !== undefined || options?.serializer !== undefined) {
|
|
254
|
-
// biome-ignore lint/suspicious/noExplicitAny: config types vary by plugins
|
|
255
|
-
const routeWithConfig = route;
|
|
256
|
-
routeWithConfig.config = merge(routeWithConfig.config || {}, {
|
|
257
|
-
sse: {
|
|
258
|
-
...(options.heartbeatInterval !== undefined && {
|
|
259
|
-
heartbeatInterval: options.heartbeatInterval,
|
|
260
|
-
}),
|
|
261
|
-
...(options.serializer !== undefined && { serializer: options.serializer }),
|
|
262
|
-
},
|
|
263
|
-
});
|
|
264
|
-
}
|
|
282
|
+
applyGlobalSSEOptions(route, options);
|
|
265
283
|
}
|
|
266
284
|
applySSERouteOptions(route, options) {
|
|
267
285
|
if (options?.preHandler) {
|
|
@@ -270,19 +288,7 @@ export class DIContext {
|
|
|
270
288
|
if (options?.rateLimit) {
|
|
271
289
|
this.applyRateLimit(route, options.rateLimit);
|
|
272
290
|
}
|
|
273
|
-
|
|
274
|
-
if (options?.heartbeatInterval !== undefined || options?.serializer !== undefined) {
|
|
275
|
-
// biome-ignore lint/suspicious/noExplicitAny: config types vary by plugins
|
|
276
|
-
const routeWithConfig = route;
|
|
277
|
-
routeWithConfig.config = merge(routeWithConfig.config || {}, {
|
|
278
|
-
sse: {
|
|
279
|
-
...(options.heartbeatInterval !== undefined && {
|
|
280
|
-
heartbeatInterval: options.heartbeatInterval,
|
|
281
|
-
}),
|
|
282
|
-
...(options.serializer !== undefined && { serializer: options.serializer }),
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
}
|
|
291
|
+
applyGlobalSSEOptions(route, options);
|
|
286
292
|
}
|
|
287
293
|
applyPreHandlers(route, globalPreHandler) {
|
|
288
294
|
const existingPreHandler = route.preHandler;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DIContext.js","sourceRoot":"","sources":["../../lib/DIContext.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"DIContext.js","sourceRoot":"","sources":["../../lib/DIContext.ts"],"names":[],"mappings":"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"}
|