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.
Files changed (70) hide show
  1. package/CHANGELOG.md +159 -0
  2. package/README.md +501 -55
  3. package/dist/lib/DIContext.d.ts +13 -1
  4. package/dist/lib/DIContext.js +28 -8
  5. package/dist/lib/DIContext.js.map +1 -1
  6. package/dist/lib/api-contracts/apiRouteBuilder.d.ts +46 -4
  7. package/dist/lib/api-contracts/apiRouteBuilder.js +91 -7
  8. package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
  9. package/dist/lib/api-contracts/apiSseConnectionRegistry.d.ts +182 -0
  10. package/dist/lib/api-contracts/apiSseConnectionRegistry.js +332 -0
  11. package/dist/lib/api-contracts/apiSseConnectionRegistry.js.map +1 -0
  12. package/dist/lib/api-contracts/index.d.ts +1 -0
  13. package/dist/lib/api-contracts/index.js +1 -0
  14. package/dist/lib/api-contracts/index.js.map +1 -1
  15. package/dist/lib/gateway/gatewayMetadata.d.ts +1 -0
  16. package/dist/lib/gateway/gatewayMetadata.js +11 -0
  17. package/dist/lib/gateway/gatewayMetadata.js.map +1 -1
  18. package/dist/lib/gateway/index.d.ts +1 -0
  19. package/dist/lib/gateway/index.js +1 -0
  20. package/dist/lib/gateway/index.js.map +1 -1
  21. package/dist/lib/gateway/manifest/buildManifest.d.ts +18 -0
  22. package/dist/lib/gateway/manifest/buildManifest.js +31 -0
  23. package/dist/lib/gateway/manifest/buildManifest.js.map +1 -1
  24. package/dist/lib/gateway/manifest/manifestSchema.d.ts +18 -0
  25. package/dist/lib/gateway/manifest/manifestSchema.js +27 -0
  26. package/dist/lib/gateway/manifest/manifestSchema.js.map +1 -1
  27. package/dist/lib/gateway/routeStreaming.d.ts +79 -0
  28. package/dist/lib/gateway/routeStreaming.js +70 -0
  29. package/dist/lib/gateway/routeStreaming.js.map +1 -0
  30. package/dist/lib/resolverFunctions.d.ts +7 -0
  31. package/dist/lib/resolverFunctions.js +13 -0
  32. package/dist/lib/resolverFunctions.js.map +1 -1
  33. package/dist/lib/routes/fastifyRouteBuilder.js +9 -2
  34. package/dist/lib/routes/fastifyRouteBuilder.js.map +1 -1
  35. package/dist/lib/sse/AbstractSSEController.d.ts +10 -0
  36. package/dist/lib/sse/AbstractSSEController.js +9 -0
  37. package/dist/lib/sse/AbstractSSEController.js.map +1 -1
  38. package/dist/lib/sse/eventIds.d.ts +138 -0
  39. package/dist/lib/sse/eventIds.js +155 -0
  40. package/dist/lib/sse/eventIds.js.map +1 -0
  41. package/dist/lib/sse/index.d.ts +3 -1
  42. package/dist/lib/sse/index.js +6 -1
  43. package/dist/lib/sse/index.js.map +1 -1
  44. package/dist/lib/sse/sseSendDiagnostics.d.ts +134 -0
  45. package/dist/lib/sse/sseSendDiagnostics.js +277 -0
  46. package/dist/lib/sse/sseSendDiagnostics.js.map +1 -0
  47. package/dist/lib/testing/apiSseEventValidation.d.ts +40 -0
  48. package/dist/lib/testing/apiSseEventValidation.js +78 -0
  49. package/dist/lib/testing/apiSseEventValidation.js.map +1 -0
  50. package/dist/lib/testing/apiSseHttpHelpers.d.ts +168 -0
  51. package/dist/lib/testing/apiSseHttpHelpers.js +214 -0
  52. package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -0
  53. package/dist/lib/testing/apiSseInjectHelpers.d.ts +17 -2
  54. package/dist/lib/testing/apiSseInjectHelpers.js +226 -56
  55. package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
  56. package/dist/lib/testing/apiSseTestTypes.d.ts +83 -3
  57. package/dist/lib/testing/index.d.ts +3 -2
  58. package/dist/lib/testing/index.js +1 -0
  59. package/dist/lib/testing/index.js.map +1 -1
  60. package/dist/lib/testing/sseHttpClient.d.ts +62 -2
  61. package/dist/lib/testing/sseHttpClient.js +90 -6
  62. package/dist/lib/testing/sseHttpClient.js.map +1 -1
  63. package/dist/lib/testing/sseInjectClient.d.ts +1 -1
  64. package/dist/lib/testing/sseInjectClient.js +1 -1
  65. package/dist/lib/testing/sseInjectClient.js.map +1 -1
  66. package/dist/lib/testing/sseTestTypes.d.ts +9 -3
  67. package/package.json +11 -11
  68. package/dist/lib/sse/sseParser.d.ts +0 -167
  69. package/dist/lib/sse/sseParser.js +0 -225
  70. 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
- The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
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 Case |
1400
+ | Function | Use case |
1387
1401
  |----------|----------|
1388
- | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
1389
- | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
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:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
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
- #### parseSSEBuffer
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
- Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
1499
+ #### parseSSEBuffer
1422
1500
 
1423
- **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
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
- const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
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
- Both functions return events with this structure:
1528
+ Every entry point returns events with this structure:
1470
1529
 
1471
1530
  ```ts
1472
1531
  type ParsedSSEEvent = {
1473
- id?: string // Event ID (from "id:" field)
1474
- event?: string // Event type (from "event:" field)
1475
- data: string // Event data (from "data:" field, always present)
1476
- retry?: number // Reconnection interval (from "retry:" field)
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](./packages/sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
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** | Not possible (response is buffered) | `client.response` is available as soon as headers arrive |
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`](./packages/gateway-envoy) | static v3 YAML/JSON |
3174
- | KrakenD | [`@opinionated-machine/gateway-krakend`](./packages/gateway-krakend) | declarative v3 JSON |
3175
- | Kong | [`@opinionated-machine/gateway-kong`](./packages/gateway-kong) | DB-less declarative YAML/JSON |
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`](./packages/gateway-envoy/README.md)
3453
- - [`@opinionated-machine/gateway-krakend`](./packages/gateway-krakend/README.md)
3454
- - [`@opinionated-machine/gateway-kong`](./packages/gateway-kong/README.md)
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.