opinionated-machine 10.4.0 → 11.1.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 +159 -0
- package/README.md +501 -55
- package/dist/lib/DIContext.d.ts +13 -1
- package/dist/lib/DIContext.js +28 -8
- package/dist/lib/DIContext.js.map +1 -1
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +46 -4
- package/dist/lib/api-contracts/apiRouteBuilder.js +91 -7
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
- package/dist/lib/api-contracts/apiSseConnectionRegistry.d.ts +182 -0
- package/dist/lib/api-contracts/apiSseConnectionRegistry.js +332 -0
- package/dist/lib/api-contracts/apiSseConnectionRegistry.js.map +1 -0
- package/dist/lib/api-contracts/index.d.ts +1 -0
- package/dist/lib/api-contracts/index.js +1 -0
- package/dist/lib/api-contracts/index.js.map +1 -1
- package/dist/lib/gateway/gatewayMetadata.d.ts +1 -0
- package/dist/lib/gateway/gatewayMetadata.js +11 -0
- package/dist/lib/gateway/gatewayMetadata.js.map +1 -1
- package/dist/lib/gateway/index.d.ts +1 -0
- package/dist/lib/gateway/index.js +1 -0
- package/dist/lib/gateway/index.js.map +1 -1
- package/dist/lib/gateway/manifest/buildManifest.d.ts +18 -0
- package/dist/lib/gateway/manifest/buildManifest.js +31 -0
- package/dist/lib/gateway/manifest/buildManifest.js.map +1 -1
- package/dist/lib/gateway/manifest/manifestSchema.d.ts +18 -0
- package/dist/lib/gateway/manifest/manifestSchema.js +27 -0
- package/dist/lib/gateway/manifest/manifestSchema.js.map +1 -1
- package/dist/lib/gateway/routeStreaming.d.ts +79 -0
- package/dist/lib/gateway/routeStreaming.js +70 -0
- package/dist/lib/gateway/routeStreaming.js.map +1 -0
- package/dist/lib/resolverFunctions.d.ts +7 -0
- package/dist/lib/resolverFunctions.js +13 -0
- package/dist/lib/resolverFunctions.js.map +1 -1
- package/dist/lib/routes/fastifyRouteBuilder.js +9 -2
- package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
- package/dist/lib/sse/AbstractSSEController.d.ts +10 -0
- package/dist/lib/sse/AbstractSSEController.js +9 -0
- package/dist/lib/sse/AbstractSSEController.js.map +1 -1
- package/dist/lib/sse/eventIds.d.ts +138 -0
- package/dist/lib/sse/eventIds.js +155 -0
- package/dist/lib/sse/eventIds.js.map +1 -0
- package/dist/lib/sse/index.d.ts +3 -1
- package/dist/lib/sse/index.js +6 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseSendDiagnostics.d.ts +134 -0
- package/dist/lib/sse/sseSendDiagnostics.js +277 -0
- package/dist/lib/sse/sseSendDiagnostics.js.map +1 -0
- package/dist/lib/testing/apiSseEventValidation.d.ts +40 -0
- package/dist/lib/testing/apiSseEventValidation.js +78 -0
- package/dist/lib/testing/apiSseEventValidation.js.map +1 -0
- package/dist/lib/testing/apiSseHttpHelpers.d.ts +168 -0
- package/dist/lib/testing/apiSseHttpHelpers.js +214 -0
- package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +17 -2
- package/dist/lib/testing/apiSseInjectHelpers.js +226 -56
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/apiSseTestTypes.d.ts +83 -3
- package/dist/lib/testing/index.d.ts +3 -2
- package/dist/lib/testing/index.js +1 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +62 -2
- package/dist/lib/testing/sseHttpClient.js +90 -6
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectClient.d.ts +1 -1
- package/dist/lib/testing/sseInjectClient.js +1 -1
- package/dist/lib/testing/sseInjectClient.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +9 -3
- package/package.json +11 -11
- package/dist/lib/sse/sseParser.d.ts +0 -167
- package/dist/lib/sse/sseParser.js +0 -225
- package/dist/lib/sse/sseParser.js.map +0 -1
package/README.md
CHANGED
|
@@ -43,6 +43,8 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
43
43
|
- [Error Handling](#error-handling)
|
|
44
44
|
- [Long-lived Connections vs Request-Response Streaming](#long-lived-connections-vs-request-response-streaming)
|
|
45
45
|
- [SSE Parsing Utilities](#sse-parsing-utilities)
|
|
46
|
+
- [parseSSEResponse](#parsesseresponse)
|
|
47
|
+
- [createSSEStreamParser and parseSSEStream](#createssestreamparser-and-parsessestream)
|
|
46
48
|
- [parseSSEEvents](#parsesseevents)
|
|
47
49
|
- [parseSSEBuffer](#parsessebuffer)
|
|
48
50
|
- [ParsedSSEEvent Type](#parsedsseevent-type)
|
|
@@ -75,6 +77,8 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
75
77
|
- [SSEHttpClient](#ssehttpclient)
|
|
76
78
|
- [SSEInjectClient](#sseinjectclient)
|
|
77
79
|
- [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
|
|
80
|
+
- [Contract-Aware HTTP Helpers](#contract-aware-http-helpers)
|
|
81
|
+
- [When a Handler Fails to Send an Event](#when-a-handler-fails-to-send-an-event)
|
|
78
82
|
- [Dual-Mode Controllers (SSE + Sync)](#dual-mode-controllers-sse--sync)
|
|
79
83
|
- [Overview](#overview)
|
|
80
84
|
- [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
|
|
@@ -92,7 +96,13 @@ Very opinionated DI framework for fastify, built on top of awilix
|
|
|
92
96
|
- [Field Reference](#field-reference)
|
|
93
97
|
- [Generating Gateway Configs](#generating-gateway-configs)
|
|
94
98
|
- [Inspecting the Manifest at Runtime](#inspecting-the-manifest-at-runtime)
|
|
99
|
+
- [Streaming Routes](#streaming-routes)
|
|
95
100
|
- [What's Not Covered](#whats-not-covered)
|
|
101
|
+
- [Polling Fallback for SSE](#polling-fallback-for-sse)
|
|
102
|
+
- [Serving the Pattern](#serving-the-pattern)
|
|
103
|
+
- [Monotonic Event IDs](#monotonic-event-ids)
|
|
104
|
+
- [Server-Side Guarantees Checklist](#server-side-guarantees-checklist)
|
|
105
|
+
- [Development](#development)
|
|
96
106
|
|
|
97
107
|
## Basic usage
|
|
98
108
|
|
|
@@ -1378,21 +1388,86 @@ private handleStream = buildHandler(streamContract, {
|
|
|
1378
1388
|
// Connection closes automatically when handler returns
|
|
1379
1389
|
},
|
|
1380
1390
|
})
|
|
1391
|
+
```
|
|
1381
1392
|
|
|
1382
1393
|
### SSE Parsing Utilities
|
|
1383
1394
|
|
|
1384
|
-
|
|
1395
|
+
Wire-format parsing lives in
|
|
1396
|
+
[`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
|
|
1397
|
+
re-exported here, so the server's test helpers and the browser client
|
|
1398
|
+
(`@opinionated-machine/sse-fallback`) frame a stream with the same code.
|
|
1385
1399
|
|
|
1386
|
-
| Function | Use
|
|
1400
|
+
| Function | Use case |
|
|
1387
1401
|
|----------|----------|
|
|
1388
|
-
| `
|
|
1389
|
-
| `
|
|
1402
|
+
| `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
|
|
1403
|
+
| `parseSSEStream` | An async iterable of already-decoded text chunks |
|
|
1404
|
+
| `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
|
|
1405
|
+
| `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
|
|
1406
|
+
| `parseSSEBuffer` | The primitive the others are built on |
|
|
1407
|
+
|
|
1408
|
+
#### parseSSEResponse
|
|
1409
|
+
|
|
1410
|
+
Consume a live SSE stream from `fetch`. Multi-byte characters split across
|
|
1411
|
+
network chunks are held back, and breaking out of the loop cancels the
|
|
1412
|
+
response body.
|
|
1413
|
+
|
|
1414
|
+
```ts
|
|
1415
|
+
import { parseSSEResponse } from 'opinionated-machine'
|
|
1416
|
+
|
|
1417
|
+
const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
|
|
1418
|
+
|
|
1419
|
+
for await (const event of parseSSEResponse(response)) {
|
|
1420
|
+
console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
|
|
1421
|
+
if (event.event === 'done') break
|
|
1422
|
+
}
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
Unlike `EventSource` the request is yours: custom headers, a POST body, an
|
|
1426
|
+
`AbortSignal`, your own reconnect policy.
|
|
1427
|
+
|
|
1428
|
+
#### createSSEStreamParser and parseSSEStream
|
|
1429
|
+
|
|
1430
|
+
When the transport hands you decoded text rather than a `Response`, or when you
|
|
1431
|
+
need the reconnect cursor after the stream ends.
|
|
1432
|
+
|
|
1433
|
+
```ts
|
|
1434
|
+
import { createSSEStreamParser } from 'opinionated-machine'
|
|
1435
|
+
|
|
1436
|
+
// One per connection: it holds the partial frame, the Last-Event-ID cursor and
|
|
1437
|
+
// the BOM that may open the stream.
|
|
1438
|
+
const parser = createSSEStreamParser({ lastEventId: resumeFrom })
|
|
1439
|
+
|
|
1440
|
+
for await (const chunk of chunks) {
|
|
1441
|
+
for (const event of parser.push(chunk)) {
|
|
1442
|
+
console.log('Received:', event.event ?? 'message', event.data)
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
reconnectWith(parser.lastEventId)
|
|
1447
|
+
```
|
|
1448
|
+
|
|
1449
|
+
`parseSSEStream` wraps that loop when you only want the events:
|
|
1450
|
+
|
|
1451
|
+
```ts
|
|
1452
|
+
import { parseSSEStream } from 'opinionated-machine'
|
|
1453
|
+
|
|
1454
|
+
for await (const event of parseSSEStream(chunks, {
|
|
1455
|
+
onChunk: () => resetStaleConnectionTimer(),
|
|
1456
|
+
})) {
|
|
1457
|
+
handle(event)
|
|
1458
|
+
}
|
|
1459
|
+
```
|
|
1460
|
+
|
|
1461
|
+
`onChunk` fires for every chunk before it is framed, comment frames included.
|
|
1462
|
+
Framing consumes `: heartbeat` comments, so a consumer watching only events
|
|
1463
|
+
cannot tell an idle-but-healthy connection from a dead one.
|
|
1390
1464
|
|
|
1391
1465
|
#### parseSSEEvents
|
|
1392
1466
|
|
|
1393
1467
|
Parse a complete SSE response body into an array of events.
|
|
1394
1468
|
|
|
1395
|
-
**When to use:**
|
|
1469
|
+
**When to use:** testing with Fastify's `inject()`, or when the full response is
|
|
1470
|
+
available (request-response style SSE such as OpenAI completions):
|
|
1396
1471
|
|
|
1397
1472
|
```ts
|
|
1398
1473
|
import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
|
|
@@ -1416,67 +1491,58 @@ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
|
|
|
1416
1491
|
const notifications = events.map(e => JSON.parse(e.data))
|
|
1417
1492
|
```
|
|
1418
1493
|
|
|
1419
|
-
|
|
1494
|
+
A trailing frame with no blank line after it is discarded, which is what the
|
|
1495
|
+
spec requires at the end of a stream: a body cut mid-frame must not surface its
|
|
1496
|
+
truncated payload as a delivered event. Reach for `parseSSEBuffer` when you want
|
|
1497
|
+
to inspect that leftover.
|
|
1420
1498
|
|
|
1421
|
-
|
|
1499
|
+
#### parseSSEBuffer
|
|
1422
1500
|
|
|
1423
|
-
|
|
1501
|
+
One pass over a buffer: the events it completed, the bytes it could not, and the
|
|
1502
|
+
reconnect cursor. Prefer `createSSEStreamParser` for a live stream, which keeps
|
|
1503
|
+
all three across chunks for you.
|
|
1424
1504
|
|
|
1425
1505
|
```ts
|
|
1426
1506
|
import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
|
|
1427
1507
|
|
|
1428
1508
|
let buffer = ''
|
|
1509
|
+
let cursor: string | undefined
|
|
1429
1510
|
|
|
1430
|
-
// As chunks arrive from a stream...
|
|
1431
1511
|
for await (const chunk of stream) {
|
|
1432
1512
|
buffer += chunk
|
|
1433
|
-
|
|
1513
|
+
// Feeding the cursor back is what makes Last-Event-ID survive: an event with
|
|
1514
|
+
// no `id:` of its own inherits the previous one, and an `id:` frame carrying
|
|
1515
|
+
// no data still moves it.
|
|
1516
|
+
const result: ParseSSEBufferResult = parseSSEBuffer(buffer, cursor)
|
|
1517
|
+
buffer = result.remaining
|
|
1518
|
+
cursor = result.lastEventId
|
|
1434
1519
|
|
|
1435
|
-
// Process complete events
|
|
1436
1520
|
for (const event of result.events) {
|
|
1437
1521
|
console.log('Received:', event.event, event.data)
|
|
1438
1522
|
}
|
|
1439
|
-
|
|
1440
|
-
// Keep incomplete data for next chunk
|
|
1441
|
-
buffer = result.remaining
|
|
1442
|
-
}
|
|
1443
|
-
```
|
|
1444
|
-
|
|
1445
|
-
**Production example with fetch:**
|
|
1446
|
-
|
|
1447
|
-
```ts
|
|
1448
|
-
const response = await fetch(url)
|
|
1449
|
-
const reader = response.body!.getReader()
|
|
1450
|
-
const decoder = new TextDecoder()
|
|
1451
|
-
let buffer = ''
|
|
1452
|
-
|
|
1453
|
-
while (true) {
|
|
1454
|
-
const { done, value } = await reader.read()
|
|
1455
|
-
if (done) break
|
|
1456
|
-
|
|
1457
|
-
buffer += decoder.decode(value, { stream: true })
|
|
1458
|
-
const { events, remaining } = parseSSEBuffer(buffer)
|
|
1459
|
-
buffer = remaining
|
|
1460
|
-
|
|
1461
|
-
for (const event of events) {
|
|
1462
|
-
console.log('Received:', event.event, JSON.parse(event.data))
|
|
1463
|
-
}
|
|
1464
1523
|
}
|
|
1465
1524
|
```
|
|
1466
1525
|
|
|
1467
1526
|
#### ParsedSSEEvent Type
|
|
1468
1527
|
|
|
1469
|
-
|
|
1528
|
+
Every entry point returns events with this structure:
|
|
1470
1529
|
|
|
1471
1530
|
```ts
|
|
1472
1531
|
type ParsedSSEEvent = {
|
|
1473
|
-
id?: string
|
|
1474
|
-
event?: string
|
|
1475
|
-
data: string
|
|
1476
|
-
retry?: number
|
|
1532
|
+
id?: string // The "id:" this event carried, if any
|
|
1533
|
+
event?: string // Event type from "event:"; absent means 'message'
|
|
1534
|
+
data: string // Event data from "data:", always present
|
|
1535
|
+
retry?: number // Reconnection interval from "retry:"
|
|
1536
|
+
lastEventId?: string // The reconnect cursor as of this event's dispatch
|
|
1477
1537
|
}
|
|
1478
1538
|
```
|
|
1479
1539
|
|
|
1540
|
+
`id` and `lastEventId` are separate on purpose. The cursor persists across
|
|
1541
|
+
events that carry no `id:` of their own, so it is what you reconnect with;
|
|
1542
|
+
`id` is what the event itself carried, so it is what you order and deduplicate
|
|
1543
|
+
on. Ordering on the cursor instead makes every inheriting event look like a
|
|
1544
|
+
duplicate of the last id-bearing one.
|
|
1545
|
+
|
|
1480
1546
|
### Testing SSE Controllers
|
|
1481
1547
|
|
|
1482
1548
|
The test client depends on the session mode:
|
|
@@ -1484,7 +1550,7 @@ The test client depends on the session mode:
|
|
|
1484
1550
|
| Session Mode | Test Client | Why |
|
|
1485
1551
|
|-------------|-------------|--------|
|
|
1486
1552
|
| `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
|
|
1487
|
-
| `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
|
|
1553
|
+
| `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
|
|
1488
1554
|
|
|
1489
1555
|
Enable the connection spy by passing `isTestMode: true` in diOptions (required for `awaitServerConnection`).
|
|
1490
1556
|
|
|
@@ -1654,8 +1720,29 @@ it('returns the documented 400 body for an empty segment', async () => {
|
|
|
1654
1720
|
})
|
|
1655
1721
|
```
|
|
1656
1722
|
|
|
1723
|
+
```ts
|
|
1724
|
+
it('sends each issue as soon as it is found', async () => {
|
|
1725
|
+
const { head, stream } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
|
|
1726
|
+
|
|
1727
|
+
// The head is on the wire as soon as the handler calls sse.start()
|
|
1728
|
+
expect((await head).statusCode).toBe(200)
|
|
1729
|
+
|
|
1730
|
+
for await (const event of stream()) {
|
|
1731
|
+
// Each event is observed while the handler is still producing the next one
|
|
1732
|
+
if (event.event === 'issue') expect(handlerFinished).toBe(false)
|
|
1733
|
+
}
|
|
1734
|
+
})
|
|
1735
|
+
```
|
|
1736
|
+
|
|
1657
1737
|
`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.
|
|
1658
1738
|
|
|
1739
|
+
Two accessors read the response before it completes, so an inject-based suite can assert *progressive delivery* without opening a real port for it:
|
|
1740
|
+
|
|
1741
|
+
- `head` — `{ statusCode, headers }`, resolved as soon as the response head is on the wire (for a streaming handler, at `sse.start()`).
|
|
1742
|
+
- `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.
|
|
1743
|
+
|
|
1744
|
+
`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).
|
|
1745
|
+
|
|
1659
1746
|
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.
|
|
1660
1747
|
|
|
1661
1748
|
### SSESessionSpy API
|
|
@@ -2039,7 +2126,7 @@ class DashboardModule extends AbstractModule {
|
|
|
2039
2126
|
|
|
2040
2127
|
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.
|
|
2041
2128
|
|
|
2042
|
-
See the [@opinionated-machine/sse-rooms-redis](
|
|
2129
|
+
See the [@opinionated-machine/sse-rooms-redis](../sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
|
|
2043
2130
|
|
|
2044
2131
|
### SSE Subscriptions
|
|
2045
2132
|
|
|
@@ -2241,7 +2328,7 @@ The library provides utilities for testing SSE endpoints.
|
|
|
2241
2328
|
| Session Mode | Test Client | Reason |
|
|
2242
2329
|
|-------------|-------------|--------|
|
|
2243
2330
|
| `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
|
|
2244
|
-
| `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
|
|
2331
|
+
| `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
|
|
2245
2332
|
|
|
2246
2333
|
`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`.
|
|
2247
2334
|
|
|
@@ -2254,7 +2341,7 @@ The library provides utilities for testing SSE endpoints.
|
|
|
2254
2341
|
| **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
|
|
2255
2342
|
| **Server requirement** | No `listen()` needed | Requires a listening server (`SSETestServer.start(app)` or manual `app.listen()`) |
|
|
2256
2343
|
| **Request body** | `injectPayloadSSE` / `connectWithBody` | `method` + `body` connect options |
|
|
2257
|
-
| **Assertions before the handler finishes** |
|
|
2344
|
+
| **Assertions before the handler finishes** | `injectApiSSE`'s `head` / `stream()` (other inject helpers buffer the whole response) | `client.response` is available as soon as headers arrive |
|
|
2258
2345
|
| **Best for** | `autoClose` SSE (OpenAI-style, batch exports) | `keepAlive` SSE (notifications, live feeds, rooms), streams whose headers must be asserted mid-handler |
|
|
2259
2346
|
| **Dual-mode sync** | Use `app.inject()` with `accept: 'application/json'` | Same |
|
|
2260
2347
|
|
|
@@ -2506,6 +2593,66 @@ const result = await closed
|
|
|
2506
2593
|
const events = parseSSEEvents(result.body)
|
|
2507
2594
|
```
|
|
2508
2595
|
|
|
2596
|
+
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).
|
|
2597
|
+
|
|
2598
|
+
#### Contract-Aware HTTP Helpers
|
|
2599
|
+
|
|
2600
|
+
`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.
|
|
2601
|
+
|
|
2602
|
+
```ts
|
|
2603
|
+
import { connectApiSSE, SSETestServer } from 'opinionated-machine'
|
|
2604
|
+
|
|
2605
|
+
const server = await SSETestServer.start(app)
|
|
2606
|
+
|
|
2607
|
+
// Method, path, query params, headers and body all come from the contract
|
|
2608
|
+
const client = await connectApiSSE(server.baseUrl, lqaSegmentContract, {
|
|
2609
|
+
body: { segment: 'hello' },
|
|
2610
|
+
})
|
|
2611
|
+
|
|
2612
|
+
expect(client.response.status).toBe(200) // asserted while the handler is still working
|
|
2613
|
+
|
|
2614
|
+
for await (const event of client.events()) {
|
|
2615
|
+
if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed by the contract
|
|
2616
|
+
if (event.event === 'review') break
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
client.close()
|
|
2620
|
+
await server.close()
|
|
2621
|
+
```
|
|
2622
|
+
|
|
2623
|
+
- `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.
|
|
2624
|
+
- 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.
|
|
2625
|
+
- `client.response` is the fetch `Response`, available before any event is consumed.
|
|
2626
|
+
- `client.raw` is the underlying `SSEHttpClient`, for anything this wrapper doesn't cover.
|
|
2627
|
+
- 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 }`.
|
|
2628
|
+
|
|
2629
|
+
On a connection you already have, the same typing is available per read: `client.apiEvents(contract)` and `client.collectApiEvents(contract, countOrPredicate)` on any `SSEHttpClient`.
|
|
2630
|
+
|
|
2631
|
+
#### When a Handler Fails to Send an Event
|
|
2632
|
+
|
|
2633
|
+
`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.
|
|
2634
|
+
|
|
2635
|
+
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:
|
|
2636
|
+
|
|
2637
|
+
```
|
|
2638
|
+
events() — 1 SSE send failure recorded for this request:
|
|
2639
|
+
- event "issue" was never sent: severity: Invalid option: expected one of "neutral"|"minor"|"major"|"critical"; payload: {"severity":"min"}
|
|
2640
|
+
```
|
|
2641
|
+
|
|
2642
|
+
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.
|
|
2643
|
+
|
|
2644
|
+
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:
|
|
2645
|
+
|
|
2646
|
+
```ts
|
|
2647
|
+
const { closed, events, sendFailures } = injectApiSSE(app, lqaSegmentContract, { body })
|
|
2648
|
+
await closed
|
|
2649
|
+
|
|
2650
|
+
expect(await events()).toHaveLength(2) // the fallback stream is intact
|
|
2651
|
+
expect(sendFailures()).toMatchObject([{ eventName: 'issue', handled: true }])
|
|
2652
|
+
```
|
|
2653
|
+
|
|
2654
|
+
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.
|
|
2655
|
+
|
|
2509
2656
|
## Dual-Mode Controllers (SSE + Sync)
|
|
2510
2657
|
|
|
2511
2658
|
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.
|
|
@@ -2934,6 +3081,13 @@ await app.ready()
|
|
|
2934
3081
|
|
|
2935
3082
|
### Accept Header Routing
|
|
2936
3083
|
|
|
3084
|
+
Dual-mode routes are registered with @fastify/sse kind `'manual'`, so the
|
|
3085
|
+
framework's `determineMode()` is the single Accept negotiator: q-value aware,
|
|
3086
|
+
`defaultMode` applies for `*/*` or a missing `Accept` header (including
|
|
3087
|
+
`defaultMode: 'sse'`). SSE-only routes use kind `'only'` — a missing header or
|
|
3088
|
+
`*/*` streams, and a client that explicitly refuses `text/event-stream`
|
|
3089
|
+
(e.g. `Accept: application/json`) receives a clean 406.
|
|
3090
|
+
|
|
2937
3091
|
The `Accept` header determines response mode:
|
|
2938
3092
|
|
|
2939
3093
|
```bash
|
|
@@ -3170,9 +3324,9 @@ them in:
|
|
|
3170
3324
|
|
|
3171
3325
|
| Gateway | Package | Output |
|
|
3172
3326
|
| ------- | ------- | ------ |
|
|
3173
|
-
| Envoy | [`@opinionated-machine/gateway-envoy`](
|
|
3174
|
-
| KrakenD | [`@opinionated-machine/gateway-krakend`](
|
|
3175
|
-
| Kong | [`@opinionated-machine/gateway-kong`](
|
|
3327
|
+
| Envoy | [`@opinionated-machine/gateway-envoy`](../gateway-envoy) | static v3 YAML/JSON |
|
|
3328
|
+
| KrakenD | [`@opinionated-machine/gateway-krakend`](../gateway-krakend) | declarative v3 JSON |
|
|
3329
|
+
| Kong | [`@opinionated-machine/gateway-kong`](../gateway-kong) | DB-less declarative YAML/JSON |
|
|
3176
3330
|
|
|
3177
3331
|
### Quick Start
|
|
3178
3332
|
|
|
@@ -3395,7 +3549,7 @@ time.
|
|
|
3395
3549
|
| Field | Example | Notes |
|
|
3396
3550
|
| ----- | ------- | ----- |
|
|
3397
3551
|
| `upstream` | `'users-service'` | Logical cluster name; resolved to a host by the generator |
|
|
3398
|
-
| `timeouts` | `{ request: '5s', idle: '60s', connect: '1s' }` | Duration units: `ms` / `s` / `m` / `h` |
|
|
3552
|
+
| `timeouts` | `{ request: '5s', idle: '60s', connect: '1s' }` | Duration units: `ms` / `s` / `m` / `h`. `idle` maps to Envoy route `idle_timeout`, joins Kong's loosest-wins `read_timeout`, and raises KrakenD's endpoint `timeout` — declare it on streaming routes to bound liveness (pair with heartbeats) |
|
|
3399
3553
|
| `retry` | `{ attempts: 2, on: ['5xx', 'connect-failure'], perTryTimeout: '2s' }` | |
|
|
3400
3554
|
| `rateLimit` | `{ requests: 100, per: '1m', key: 'ip' }` | `key`: `'ip'`, `{ header }`, `{ customHeader }`, `{ query }`, `{ customQuery }` |
|
|
3401
3555
|
| `cache` | `{ ttl: '60s', methods: ['GET'], vary: ['Accept-Language'] }` | |
|
|
@@ -3449,9 +3603,9 @@ generators merge that block onto the rendered route last.
|
|
|
3449
3603
|
|
|
3450
3604
|
For each gateway's full mapping table and quirks:
|
|
3451
3605
|
|
|
3452
|
-
- [`@opinionated-machine/gateway-envoy`](
|
|
3453
|
-
- [`@opinionated-machine/gateway-krakend`](
|
|
3454
|
-
- [`@opinionated-machine/gateway-kong`](
|
|
3606
|
+
- [`@opinionated-machine/gateway-envoy`](../gateway-envoy/README.md)
|
|
3607
|
+
- [`@opinionated-machine/gateway-krakend`](../gateway-krakend/README.md)
|
|
3608
|
+
- [`@opinionated-machine/gateway-kong`](../gateway-kong/README.md)
|
|
3455
3609
|
|
|
3456
3610
|
### Inspecting the Manifest at Runtime
|
|
3457
3611
|
|
|
@@ -3482,11 +3636,82 @@ const manifest = app.buildGatewayManifest()
|
|
|
3482
3636
|
The manifest is rebuilt on every call, so it always reflects the current set
|
|
3483
3637
|
of registered controllers.
|
|
3484
3638
|
|
|
3639
|
+
### Streaming Routes
|
|
3640
|
+
|
|
3641
|
+
SSE and dual-mode routes need gateway treatment that request-response routes
|
|
3642
|
+
must not get: Envoy's defaults (15s route timeout, 5-minute stream idle
|
|
3643
|
+
timeout) reset long-lived streams, and buffering proxies hold SSE frames until
|
|
3644
|
+
the response completes. Routes built from SSE-capable contracts are therefore
|
|
3645
|
+
stamped with a streaming mode, and the manifest carries it as
|
|
3646
|
+
`streaming: 'sse' | 'dual'`.
|
|
3647
|
+
|
|
3648
|
+
The marker describes the **success path**. An error status answers with a JSON
|
|
3649
|
+
body on a streaming route too (including the early-return `sse.respond(404,
|
|
3650
|
+
...)` path), so generators size timeouts and buffering from it but must not
|
|
3651
|
+
assume the content type of a failure.
|
|
3652
|
+
|
|
3653
|
+
- **Envoy** — streaming routes default to `timeout: 0s` and `idle_timeout: 0s`
|
|
3654
|
+
(declare `timeouts.idle` to reinstate a liveness bound; heartbeats are the
|
|
3655
|
+
intended keep-alive). `EnvoyOptions.streamIdleTimeout` sets the listener-wide
|
|
3656
|
+
HCM `stream_idle_timeout` for everything else. Declaring `timeouts.request`
|
|
3657
|
+
on an SSE-only route warns — it bounds the stream's total lifetime.
|
|
3658
|
+
|
|
3659
|
+
With both of those timeouts off, a streaming route would otherwise have an
|
|
3660
|
+
**unbounded** lifetime, and the authorization checked when the stream opened
|
|
3661
|
+
would stay in force for as long as the connection lives — a principal removed
|
|
3662
|
+
from a scope keeps receiving events until they close the tab. Streaming
|
|
3663
|
+
routes therefore emit a route-level `max_stream_duration`, defaulting to
|
|
3664
|
+
30 minutes. Configure it with `EnvoyOptions.maxStreamDuration` (`'off'` for
|
|
3665
|
+
the old unbounded behaviour) or per route with `timeouts.maxDuration`
|
|
3666
|
+
(`'0s'` to opt out). The ceiling is invisible to users when the client
|
|
3667
|
+
treats a server close as a routine reconnect, which
|
|
3668
|
+
`@opinionated-machine/sse-fallback` does.
|
|
3669
|
+
|
|
3670
|
+
A **dual-mode** route is emitted as *two* Envoy routes, because one route
|
|
3671
|
+
cannot be both: `<id>__sse`, matched on `Accept: text/event-stream`, and
|
|
3672
|
+
`<id>`, the catch-all. The declared timeouts are split between them rather
|
|
3673
|
+
than applied to both — `timeouts.idle` goes to the stream branch,
|
|
3674
|
+
`timeouts.request` to the JSON branch, which is the fallback poll path and
|
|
3675
|
+
the one that most needs a bound. The split keys off the `Accept` header, the
|
|
3676
|
+
same predicate `determineMode()` uses server-side, quality values included:
|
|
3677
|
+
`text/event-stream;q=0` is a refusal, so it takes the JSON branch.
|
|
3678
|
+
|
|
3679
|
+
A route declaring `defaultMode: 'sse'` inverts the split, because there the
|
|
3680
|
+
server streams for a missing or wildcard `Accept` header. The manifest
|
|
3681
|
+
carries the fallback branch as `streamingDefaultMode` (`'non-sse' | 'sse'`,
|
|
3682
|
+
the `@lokalise/api-contracts` vocabulary), and Envoy makes the
|
|
3683
|
+
stream the catch-all with `<id>__json` as the narrow branch, so an
|
|
3684
|
+
unspecific request cannot land on the JSON branch's request timeout while the
|
|
3685
|
+
server is streaming. A request listing both media types resolves to JSON on
|
|
3686
|
+
the server but takes the stream branch at the gateway; the renderer warns
|
|
3687
|
+
about that residual ambiguity.
|
|
3688
|
+
- **Kong** — streaming routes emit `response_buffering: false` (Kong ≥ 2.3);
|
|
3689
|
+
`timeouts.idle` joins the loosest-wins service `read_timeout`. Streaming
|
|
3690
|
+
routes without a declared idle warn: heartbeats must arrive within the
|
|
3691
|
+
effective `read_timeout` (Kong default 60s) or the stream is reset.
|
|
3692
|
+
|
|
3693
|
+
Kong CE's `read_timeout` is **service-level**, so a long streaming idle
|
|
3694
|
+
window loosens every route sharing that upstream. Each co-located
|
|
3695
|
+
non-streaming route that inherits a raised timeout is warned about by name;
|
|
3696
|
+
give streaming routes their own `metadata.upstream` when the plain routes
|
|
3697
|
+
beside them need to stay tightly bounded.
|
|
3698
|
+
- **KrakenD** — the endpoint `timeout` uses the looser of `timeouts.request` /
|
|
3699
|
+
`timeouts.idle`; streaming routes with neither warn about KrakenD's 2s
|
|
3700
|
+
default endpoint timeout.
|
|
3701
|
+
|
|
3702
|
+
Routes declared through `AbstractApiController` are always included in the
|
|
3703
|
+
manifest. Legacy `AbstractSSEController` / `AbstractDualModeController` routes
|
|
3704
|
+
are included when you opt in:
|
|
3705
|
+
|
|
3706
|
+
```ts
|
|
3707
|
+
const manifest = context.buildGatewayManifest({
|
|
3708
|
+
service: 'users-api',
|
|
3709
|
+
includeStreamingControllers: true, // default false — existing manifests don't silently grow
|
|
3710
|
+
})
|
|
3711
|
+
```
|
|
3712
|
+
|
|
3485
3713
|
### What's Not Covered
|
|
3486
3714
|
|
|
3487
|
-
- **SSE and dual-mode controllers.** Only routes from `AbstractController` and
|
|
3488
|
-
`AbstractApiController` appear in the manifest today. Streaming routes still
|
|
3489
|
-
proxy through every gateway, but they aren't listed.
|
|
3490
3715
|
- **Fields a particular gateway can't natively express.** They show up in
|
|
3491
3716
|
`result.warnings` rather than disappearing. Reach for `extensions.<vendor>`
|
|
3492
3717
|
to hand-write the missing piece on a per-route basis.
|
|
@@ -3494,3 +3719,224 @@ of registered controllers.
|
|
|
3494
3719
|
gateway runs separately. The generators don't compare deployed gateway
|
|
3495
3720
|
state against the manifest.
|
|
3496
3721
|
|
|
3722
|
+
|
|
3723
|
+
## Polling Fallback for SSE
|
|
3724
|
+
|
|
3725
|
+
Push channels fail silently: connections die without an error event, proxies
|
|
3726
|
+
kill idle streams, a broadcast misses a rebalancing room. When the missed
|
|
3727
|
+
notification gates workflow progress ("upload finished"), the user is stuck.
|
|
3728
|
+
|
|
3729
|
+
[`@opinionated-machine/sse-fallback`](../sse-fallback/README.md) is a
|
|
3730
|
+
browser-safe, zero-dependency client core that makes **polling the correctness
|
|
3731
|
+
backbone** and SSE the latency optimization: the client subscribes to the SSE
|
|
3732
|
+
branch of a dual-mode route and keeps a deadman timer — when no data event
|
|
3733
|
+
arrives within the window, it polls the JSON branch of the same route. A
|
|
3734
|
+
version gate reconciles the two channels so app code sees exactly one uniform
|
|
3735
|
+
event stream:
|
|
3736
|
+
|
|
3737
|
+
```ts
|
|
3738
|
+
// Shared contracts module — the binding is the reconciliation declaration
|
|
3739
|
+
export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
|
|
3740
|
+
snapshotToEvents: (s) =>
|
|
3741
|
+
s.status === 'completed' ? [{ event: 'uploadFinished', data: { result: s.result } }] : [],
|
|
3742
|
+
version: { ofSnapshot: (s) => s.version },
|
|
3743
|
+
terminalEvents: ['uploadFinished', 'uploadFailed'],
|
|
3744
|
+
})
|
|
3745
|
+
|
|
3746
|
+
// Client — identical result whether it traveled over SSE, replay, or a poll
|
|
3747
|
+
const sub = createResilientSubscription(uploadStatusBinding, { transport, params })
|
|
3748
|
+
const { result } = await sub.waitFor('uploadFinished')
|
|
3749
|
+
```
|
|
3750
|
+
|
|
3751
|
+
See the [package README](../sse-fallback/README.md) for the state
|
|
3752
|
+
machine, reconciliation semantics, hydration (initial load + live updates),
|
|
3753
|
+
and the transport interface.
|
|
3754
|
+
|
|
3755
|
+
### Serving the Pattern
|
|
3756
|
+
|
|
3757
|
+
One dual-mode `AbstractApiController` route serves both channels — the sync
|
|
3758
|
+
branch answers the fallback polls, the SSE branch joins a room that the domain
|
|
3759
|
+
service broadcasts into:
|
|
3760
|
+
|
|
3761
|
+
```ts
|
|
3762
|
+
readonly routes = {
|
|
3763
|
+
jobStatus: buildApiRoute(
|
|
3764
|
+
jobStatusContract,
|
|
3765
|
+
(request, _reply, { expectedContentType, sse }) => {
|
|
3766
|
+
// The push channel: join the job's room and stay open
|
|
3767
|
+
if (expectedContentType === 'text/event-stream') {
|
|
3768
|
+
const session = sse.start('keepAlive')
|
|
3769
|
+
getSessionRooms(session).join(`job:${request.params.jobId}`)
|
|
3770
|
+
return
|
|
3771
|
+
}
|
|
3772
|
+
// The fallback poll: return the current snapshot with its version
|
|
3773
|
+
return { status: 200, body: this.jobs.get(request.params.jobId) }
|
|
3774
|
+
},
|
|
3775
|
+
{
|
|
3776
|
+
// enables room membership + broadcast delivery for this route's sessions
|
|
3777
|
+
sseRooms: this.sseRoomBroadcaster,
|
|
3778
|
+
},
|
|
3779
|
+
),
|
|
3780
|
+
}
|
|
3781
|
+
```
|
|
3782
|
+
|
|
3783
|
+
```ts
|
|
3784
|
+
// Domain service — broadcast with a monotonic id so clients can order events
|
|
3785
|
+
await this.sseRoomBroadcaster.broadcastToRoom(`job:${jobId}`, doneEvent, { result }, {
|
|
3786
|
+
id: String(job.version),
|
|
3787
|
+
})
|
|
3788
|
+
```
|
|
3789
|
+
|
|
3790
|
+
### SSE Rooms Authorization
|
|
3791
|
+
|
|
3792
|
+
Room membership decides who receives a broadcast, so it is an authorization
|
|
3793
|
+
boundary. The handler above names the room from a path param; nothing in that
|
|
3794
|
+
line checks that the authenticated principal belongs to the job's scope. Pass
|
|
3795
|
+
an options object instead of the bare broadcaster to declare the check once
|
|
3796
|
+
per route:
|
|
3797
|
+
|
|
3798
|
+
```ts
|
|
3799
|
+
{
|
|
3800
|
+
sseRooms: {
|
|
3801
|
+
broadcaster: this.sseRoomBroadcaster,
|
|
3802
|
+
// Refused joins are logged and dropped; the stream itself stays open.
|
|
3803
|
+
authorizeJoin: (session, room) => this.membership.canRead(session.request.user, room),
|
|
3804
|
+
// Close the session after 30 minutes, forcing a re-authorized reconnect.
|
|
3805
|
+
maxSessionLifetimeMs: 30 * 60_000,
|
|
3806
|
+
},
|
|
3807
|
+
}
|
|
3808
|
+
```
|
|
3809
|
+
|
|
3810
|
+
A synchronous verdict is applied before `join()` returns; an async one is
|
|
3811
|
+
applied when it resolves, so the session joins a moment later and the client's
|
|
3812
|
+
reconciliation poll covers anything broadcast in between.
|
|
3813
|
+
|
|
3814
|
+
Authorization checked at connect goes stale, so revocation needs a termination
|
|
3815
|
+
path of its own:
|
|
3816
|
+
|
|
3817
|
+
```ts
|
|
3818
|
+
const registry = getApiSseConnectionRegistry(this.sseRoomBroadcaster)
|
|
3819
|
+
|
|
3820
|
+
registry.evict(connectionId) // end one stream
|
|
3821
|
+
registry.evictFromRoom(room, connectionId) // drop one scope, keep the stream
|
|
3822
|
+
registry.closeRoom(`project:${projectId}`) // end every stream in a scope
|
|
3823
|
+
```
|
|
3824
|
+
|
|
3825
|
+
Only connections on the current node are closed, so a revocation event has to
|
|
3826
|
+
reach every node. A client that reconnects (as
|
|
3827
|
+
`@opinionated-machine/sse-fallback` does) comes back through the route's own
|
|
3828
|
+
authorization, so evicting a still-authorized principal costs a reconnect
|
|
3829
|
+
rather than a broken surface — which is also why `maxSessionLifetimeMs` is
|
|
3830
|
+
cheap: it doubles as the token-refresh mechanism and the backstop for a
|
|
3831
|
+
revocation that never reached `evict()`.
|
|
3832
|
+
|
|
3833
|
+
`test/api-contracts/api.rooms.security.e2e.spec.ts` is the pattern to copy per
|
|
3834
|
+
endpoint: a negative cross-tenant join test, and a mid-stream revocation test.
|
|
3835
|
+
|
|
3836
|
+
### Monotonic Event IDs
|
|
3837
|
+
|
|
3838
|
+
`Last-Event-ID` replay, client-side ordering, and the fallback version gate
|
|
3839
|
+
all need event ids a client can ORDER, not just deduplicate. Use
|
|
3840
|
+
`createEventIdSequence()` (one sequence per ordering scope — per room, per
|
|
3841
|
+
resource):
|
|
3842
|
+
|
|
3843
|
+
```ts
|
|
3844
|
+
import { compareEventIds, createEventIdSequence } from 'opinionated-machine'
|
|
3845
|
+
|
|
3846
|
+
const seq = createEventIdSequence()
|
|
3847
|
+
await broadcaster.broadcastToRoom(room, statusEvent, data, { id: seq.next() })
|
|
3848
|
+
|
|
3849
|
+
// Ids order lexicographically within an epoch; across epochs (e.g. after a
|
|
3850
|
+
// process restart) compareEventIds returns undefined — clients resync via poll
|
|
3851
|
+
compareEventIds('e1-000000000001', 'e1-000000000002') // -1
|
|
3852
|
+
```
|
|
3853
|
+
|
|
3854
|
+
**Prefer a domain version** (`job.version`, a revision column) over a generated
|
|
3855
|
+
sequence whenever the resource has one: it is per-scope and writer-independent
|
|
3856
|
+
for free, and the snapshot body has to carry it anyway for the client's version
|
|
3857
|
+
gate.
|
|
3858
|
+
|
|
3859
|
+
`createEventIdSequence()` is in-memory and per-process, which makes it safe
|
|
3860
|
+
only for a **single-writer** ordering scope. Its epoch defaults to the process
|
|
3861
|
+
start time, and the client's default extractor orders by epoch first. If two
|
|
3862
|
+
pods of the same service broadcast into the same room, each with its own
|
|
3863
|
+
sequence, their epochs differ: the events interleave, the client's watermark
|
|
3864
|
+
lands on the newer epoch, and every subsequent event from the older-epoch pod
|
|
3865
|
+
compares as stale and is **silently dropped**. The failure only shows up under
|
|
3866
|
+
horizontal scale, so it reaches production.
|
|
3867
|
+
|
|
3868
|
+
For a multi-writer scope use a domain version, a fixed shared `epoch` with a
|
|
3869
|
+
`start` handed out from shared storage, or the Redis-backed sequence:
|
|
3870
|
+
|
|
3871
|
+
```ts
|
|
3872
|
+
import { createRedisEventIdSequence } from '@opinionated-machine/sse-rooms-redis'
|
|
3873
|
+
|
|
3874
|
+
// One counter per ordering scope, shared by every pod — one INCR per id.
|
|
3875
|
+
const seq = createRedisEventIdSequence({ client: redis, key: `sse:seq:job:${jobId}` })
|
|
3876
|
+
await broadcaster.broadcastToRoom(room, statusEvent, data, { id: await seq.next() })
|
|
3877
|
+
```
|
|
3878
|
+
|
|
3879
|
+
A shared counter orders **allocation**, not delivery. Between `next()` and the
|
|
3880
|
+
broadcast a writer can be descheduled while another pod allocates the next id
|
|
3881
|
+
and publishes first, so the client sees the higher id and drops the lower one
|
|
3882
|
+
as stale. What to do about it depends on what the events carry:
|
|
3883
|
+
|
|
3884
|
+
- **Replacement-safe events** (the payload describes the state of the scope,
|
|
3885
|
+
or the id is a domain version read in the same transaction that wrote it):
|
|
3886
|
+
nothing. The dropped event is superseded by the one that overtook it, which
|
|
3887
|
+
is what the version gate is for.
|
|
3888
|
+
- **Delta events applied to client state** (`state.apply`): the drop is a real
|
|
3889
|
+
loss. Serialize allocation and publication per ordering scope — one writer
|
|
3890
|
+
per scope, or a per-scope lock or outbox that publishes in id order.
|
|
3891
|
+
|
|
3892
|
+
Either way, call `next()` immediately before the broadcast with nothing awaited
|
|
3893
|
+
in between: the window that reorders is exactly that gap.
|
|
3894
|
+
|
|
3895
|
+
### Server-Side Guarantees Checklist
|
|
3896
|
+
|
|
3897
|
+
For a resource to participate in the fallback pattern:
|
|
3898
|
+
|
|
3899
|
+
1. **Required** — a monotonic version per resource, present in both the
|
|
3900
|
+
snapshot body and each event, and truthful: a snapshot at version *v*
|
|
3901
|
+
reflects every event ≤ *v* (publish events after commit; read committed
|
|
3902
|
+
state in the poll handler). Snapshots must **subsume** prior events.
|
|
3903
|
+
2. **Recommended** — stamp the SSE `id:` with that version; the client's
|
|
3904
|
+
default version extraction (bare integers and `createEventIdSequence()`
|
|
3905
|
+
ids alike) and `Last-Event-ID` replay then compose free. Make sure the id
|
|
3906
|
+
source is safe for the number of writers the scope has — see
|
|
3907
|
+
[Monotonic Event IDs](#monotonic-event-ids).
|
|
3908
|
+
3. **Recommended** — a short heartbeat interval (~15s, configured once via
|
|
3909
|
+
`app.register(fastifySSE, { heartbeatInterval })`) so clients detect
|
|
3910
|
+
silently dead connections fast; correctness holds without heartbeats
|
|
3911
|
+
(polls bound staleness), detection latency improves with them.
|
|
3912
|
+
4. Optional — dense (consecutive) versions enable client gap detection;
|
|
3913
|
+
`onReconnect` replay lets clients skip the post-reconnect poll
|
|
3914
|
+
(`replay: 'trusted'` in the binding).
|
|
3915
|
+
5. Gateway — declare `timeouts.idle` on streaming routes (or rely on the
|
|
3916
|
+
streaming-route defaults) so proxies don't reset quiet streams; see
|
|
3917
|
+
[Streaming Routes](#streaming-routes).
|
|
3918
|
+
6. Authorization — room membership decides who receives a broadcast, so it is
|
|
3919
|
+
an authorization boundary. Declare the scope check once per route with
|
|
3920
|
+
`sseRooms.authorizeJoin` rather than trusting every handler body, give
|
|
3921
|
+
sessions a `maxSessionLifetimeMs` so a check made at connect cannot stay in
|
|
3922
|
+
force forever, and call `getApiSseConnectionRegistry(broadcaster).evict()` /
|
|
3923
|
+
`.closeRoom()` when access is revoked mid-stream. See
|
|
3924
|
+
[SSE Rooms Authorization](#sse-rooms-authorization).
|
|
3925
|
+
|
|
3926
|
+
## Development
|
|
3927
|
+
|
|
3928
|
+
Tasks are orchestrated by [Turborepo](https://turborepo.dev), which reads the workspace graph from
|
|
3929
|
+
`turbo.jsonc`:
|
|
3930
|
+
|
|
3931
|
+
```bash
|
|
3932
|
+
pnpm run build:all # build every package in dependency order
|
|
3933
|
+
pnpm run lint:all # biome + tsc in every package
|
|
3934
|
+
pnpm exec turbo run test:ci # this package's suite with coverage
|
|
3935
|
+
```
|
|
3936
|
+
|
|
3937
|
+
Run these from the workspace root. Start with `build:all` on a fresh clone: the per-package
|
|
3938
|
+
`build`, `lint` and `test` scripts each do one package's work and assume their workspace
|
|
3939
|
+
dependencies are already built, so `pnpm run build` on its own fails until the graph has been
|
|
3940
|
+
built once.
|
|
3941
|
+
|
|
3942
|
+
See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full task table and caching notes.
|