opinionated-machine 10.5.0 → 11.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/README.md +513 -52
  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 +47 -5
  7. package/dist/lib/api-contracts/apiRouteBuilder.js +57 -8
  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 -2
  42. package/dist/lib/sse/index.js +6 -2
  43. package/dist/lib/sse/index.js.map +1 -1
  44. package/dist/lib/sse/rooms/SSERoomEventPublisher.d.ts +121 -0
  45. package/dist/lib/sse/rooms/SSERoomEventPublisher.js +119 -0
  46. package/dist/lib/sse/rooms/SSERoomEventPublisher.js.map +1 -0
  47. package/dist/lib/sse/rooms/index.d.ts +1 -0
  48. package/dist/lib/sse/rooms/index.js +1 -0
  49. package/dist/lib/sse/rooms/index.js.map +1 -1
  50. package/dist/lib/testing/apiSseEventValidation.d.ts +1 -1
  51. package/dist/lib/testing/apiSseInjectHelpers.js +9 -10
  52. package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
  53. package/dist/lib/testing/sseHttpClient.d.ts +10 -2
  54. package/dist/lib/testing/sseHttpClient.js +13 -6
  55. package/dist/lib/testing/sseHttpClient.js.map +1 -1
  56. package/dist/lib/testing/sseInjectClient.d.ts +1 -1
  57. package/dist/lib/testing/sseInjectClient.js +1 -1
  58. package/dist/lib/testing/sseInjectClient.js.map +1 -1
  59. package/dist/lib/testing/sseTestTypes.d.ts +1 -1
  60. package/package.json +13 -13
  61. package/dist/lib/sse/sseParser.d.ts +0 -167
  62. package/dist/lib/sse/sseParser.js +0 -225
  63. 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)
@@ -54,6 +56,7 @@ Very opinionated DI framework for fastify, built on top of awilix
54
56
  - [Session Room Operations](#session-room-operations)
55
57
  - [Broadcasting to Rooms](#broadcasting-to-rooms)
56
58
  - [Room Broadcaster (Decoupled Broadcasting)](#room-broadcaster-decoupled-broadcasting)
59
+ - [Room Event Publisher (Fire-and-Forget)](#room-event-publisher-fire-and-forget)
57
60
  - [Room Name Helpers](#room-name-helpers)
58
61
  - [Room Query Methods](#room-query-methods)
59
62
  - [Auto-Leave on Disconnect](#auto-leave-on-disconnect)
@@ -94,7 +97,13 @@ Very opinionated DI framework for fastify, built on top of awilix
94
97
  - [Field Reference](#field-reference)
95
98
  - [Generating Gateway Configs](#generating-gateway-configs)
96
99
  - [Inspecting the Manifest at Runtime](#inspecting-the-manifest-at-runtime)
100
+ - [Streaming Routes](#streaming-routes)
97
101
  - [What's Not Covered](#whats-not-covered)
102
+ - [Polling Fallback for SSE](#polling-fallback-for-sse)
103
+ - [Serving the Pattern](#serving-the-pattern)
104
+ - [Monotonic Event IDs](#monotonic-event-ids)
105
+ - [Server-Side Guarantees Checklist](#server-side-guarantees-checklist)
106
+ - [Development](#development)
98
107
 
99
108
  ## Basic usage
100
109
 
@@ -1380,21 +1389,86 @@ private handleStream = buildHandler(streamContract, {
1380
1389
  // Connection closes automatically when handler returns
1381
1390
  },
1382
1391
  })
1392
+ ```
1383
1393
 
1384
1394
  ### SSE Parsing Utilities
1385
1395
 
1386
- The library provides production-ready utilities for parsing SSE (Server-Sent Events) streams:
1396
+ Wire-format parsing lives in
1397
+ [`@opinionated-machine/sse-parser`](../sse-parser/README.md) and is
1398
+ re-exported here, so the server's test helpers and the browser client
1399
+ (`@opinionated-machine/sse-fallback`) frame a stream with the same code.
1387
1400
 
1388
- | Function | Use Case |
1401
+ | Function | Use case |
1389
1402
  |----------|----------|
1390
- | `parseSSEEvents` | **Testing & complete responses** - when you have the full response body |
1391
- | `parseSSEBuffer` | **Production streaming** - when data arrives incrementally in chunks |
1403
+ | `parseSSEResponse` | A `fetch` response: decodes the bytes and frames them for you |
1404
+ | `parseSSEStream` | An async iterable of already-decoded text chunks |
1405
+ | `createSSEStreamParser` | A stream you drive yourself, chunk by chunk |
1406
+ | `parseSSEEvents` | Testing and request-response streaming, when the full body is in hand |
1407
+ | `parseSSEBuffer` | The primitive the others are built on |
1408
+
1409
+ #### parseSSEResponse
1410
+
1411
+ Consume a live SSE stream from `fetch`. Multi-byte characters split across
1412
+ network chunks are held back, and breaking out of the loop cancels the
1413
+ response body.
1414
+
1415
+ ```ts
1416
+ import { parseSSEResponse } from 'opinionated-machine'
1417
+
1418
+ const response = await fetch(url, { headers: { accept: 'text/event-stream' } })
1419
+
1420
+ for await (const event of parseSSEResponse(response)) {
1421
+ console.log('Received:', event.event ?? 'message', JSON.parse(event.data))
1422
+ if (event.event === 'done') break
1423
+ }
1424
+ ```
1425
+
1426
+ Unlike `EventSource` the request is yours: custom headers, a POST body, an
1427
+ `AbortSignal`, your own reconnect policy.
1428
+
1429
+ #### createSSEStreamParser and parseSSEStream
1430
+
1431
+ When the transport hands you decoded text rather than a `Response`, or when you
1432
+ need the reconnect cursor after the stream ends.
1433
+
1434
+ ```ts
1435
+ import { createSSEStreamParser } from 'opinionated-machine'
1436
+
1437
+ // One per connection: it holds the partial frame, the Last-Event-ID cursor and
1438
+ // the BOM that may open the stream.
1439
+ const parser = createSSEStreamParser({ lastEventId: resumeFrom })
1440
+
1441
+ for await (const chunk of chunks) {
1442
+ for (const event of parser.push(chunk)) {
1443
+ console.log('Received:', event.event ?? 'message', event.data)
1444
+ }
1445
+ }
1446
+
1447
+ reconnectWith(parser.lastEventId)
1448
+ ```
1449
+
1450
+ `parseSSEStream` wraps that loop when you only want the events:
1451
+
1452
+ ```ts
1453
+ import { parseSSEStream } from 'opinionated-machine'
1454
+
1455
+ for await (const event of parseSSEStream(chunks, {
1456
+ onChunk: () => resetStaleConnectionTimer(),
1457
+ })) {
1458
+ handle(event)
1459
+ }
1460
+ ```
1461
+
1462
+ `onChunk` fires for every chunk before it is framed, comment frames included.
1463
+ Framing consumes `: heartbeat` comments, so a consumer watching only events
1464
+ cannot tell an idle-but-healthy connection from a dead one.
1392
1465
 
1393
1466
  #### parseSSEEvents
1394
1467
 
1395
1468
  Parse a complete SSE response body into an array of events.
1396
1469
 
1397
- **When to use:** Testing with Fastify's `inject()`, or when the full response is available (e.g., request-response style SSE like OpenAI completions):
1470
+ **When to use:** testing with Fastify's `inject()`, or when the full response is
1471
+ available (request-response style SSE such as OpenAI completions):
1398
1472
 
1399
1473
  ```ts
1400
1474
  import { parseSSEEvents, type ParsedSSEEvent } from 'opinionated-machine'
@@ -1418,67 +1492,58 @@ const events: ParsedSSEEvent[] = parseSSEEvents(responseBody)
1418
1492
  const notifications = events.map(e => JSON.parse(e.data))
1419
1493
  ```
1420
1494
 
1421
- #### parseSSEBuffer
1495
+ A trailing frame with no blank line after it is discarded, which is what the
1496
+ spec requires at the end of a stream: a body cut mid-frame must not surface its
1497
+ truncated payload as a delivered event. Reach for `parseSSEBuffer` when you want
1498
+ to inspect that leftover.
1422
1499
 
1423
- Parse a streaming SSE buffer, handling incomplete events at chunk boundaries.
1500
+ #### parseSSEBuffer
1424
1501
 
1425
- **When to use:** Production clients consuming real-time SSE streams (notifications, live feeds, chat) where events arrive incrementally:
1502
+ One pass over a buffer: the events it completed, the bytes it could not, and the
1503
+ reconnect cursor. Prefer `createSSEStreamParser` for a live stream, which keeps
1504
+ all three across chunks for you.
1426
1505
 
1427
1506
  ```ts
1428
1507
  import { parseSSEBuffer, type ParseSSEBufferResult } from 'opinionated-machine'
1429
1508
 
1430
1509
  let buffer = ''
1510
+ let cursor: string | undefined
1431
1511
 
1432
- // As chunks arrive from a stream...
1433
1512
  for await (const chunk of stream) {
1434
1513
  buffer += chunk
1435
- const result: ParseSSEBufferResult = parseSSEBuffer(buffer)
1514
+ // Feeding the cursor back is what makes Last-Event-ID survive: an event with
1515
+ // no `id:` of its own inherits the previous one, and an `id:` frame carrying
1516
+ // no data still moves it.
1517
+ const result: ParseSSEBufferResult = parseSSEBuffer(buffer, cursor)
1518
+ buffer = result.remaining
1519
+ cursor = result.lastEventId
1436
1520
 
1437
- // Process complete events
1438
1521
  for (const event of result.events) {
1439
1522
  console.log('Received:', event.event, event.data)
1440
1523
  }
1441
-
1442
- // Keep incomplete data for next chunk
1443
- buffer = result.remaining
1444
- }
1445
- ```
1446
-
1447
- **Production example with fetch:**
1448
-
1449
- ```ts
1450
- const response = await fetch(url)
1451
- const reader = response.body!.getReader()
1452
- const decoder = new TextDecoder()
1453
- let buffer = ''
1454
-
1455
- while (true) {
1456
- const { done, value } = await reader.read()
1457
- if (done) break
1458
-
1459
- buffer += decoder.decode(value, { stream: true })
1460
- const { events, remaining } = parseSSEBuffer(buffer)
1461
- buffer = remaining
1462
-
1463
- for (const event of events) {
1464
- console.log('Received:', event.event, JSON.parse(event.data))
1465
- }
1466
1524
  }
1467
1525
  ```
1468
1526
 
1469
1527
  #### ParsedSSEEvent Type
1470
1528
 
1471
- Both functions return events with this structure:
1529
+ Every entry point returns events with this structure:
1472
1530
 
1473
1531
  ```ts
1474
1532
  type ParsedSSEEvent = {
1475
- id?: string // Event ID (from "id:" field)
1476
- event?: string // Event type (from "event:" field)
1477
- data: string // Event data (from "data:" field, always present)
1478
- retry?: number // Reconnection interval (from "retry:" field)
1533
+ id?: string // The "id:" this event carried, if any
1534
+ event?: string // Event type from "event:"; absent means 'message'
1535
+ data: string // Event data from "data:", always present
1536
+ retry?: number // Reconnection interval from "retry:"
1537
+ lastEventId?: string // The reconnect cursor as of this event's dispatch
1479
1538
  }
1480
1539
  ```
1481
1540
 
1541
+ `id` and `lastEventId` are separate on purpose. The cursor persists across
1542
+ events that carry no `id:` of their own, so it is what you reconnect with;
1543
+ `id` is what the event itself carried, so it is what you order and deduplicate
1544
+ on. Ordering on the cursor instead makes every inheriting event look like a
1545
+ duplicate of the last id-bearing one.
1546
+
1482
1547
  ### Testing SSE Controllers
1483
1548
 
1484
1549
  The test client depends on the session mode:
@@ -1962,6 +2027,103 @@ class MetricsService {
1962
2027
 
1963
2028
  The broadcaster provides `broadcastToRoom()` (with `defineEvent()`-based type safety), `broadcastMessage()` (raw SSEMessage), plus room query methods (`getConnectionsInRoom`, `getConnectionCountInRoom`). Multiple controllers register their `sendEvent` with the same broadcaster — the first to recognize a connection handles delivery.
1964
2029
 
2030
+ #### Room Event Publisher (Fire-and-Forget)
2031
+
2032
+ `broadcastToRoom()` returns a promise, and most producers of a room event have nothing to do with
2033
+ it. An event listener or message queue handler has already committed its primary work by the time
2034
+ it broadcasts: it cannot retry a dropped hint, has nowhere to report one, and awaiting the fan-out
2035
+ would tie its latency to the number of open connections. `SSERoomEventPublisher` is the broadcaster
2036
+ without the promise.
2037
+
2038
+ ```ts
2039
+ import { defineEvent, SSERoomEventPublisher } from 'opinionated-machine'
2040
+ import { z } from 'zod'
2041
+
2042
+ const metricsUpdateEvent = defineEvent(
2043
+ 'metricsUpdate',
2044
+ z.object({ cpu: z.number(), memory: z.number() }),
2045
+ )
2046
+
2047
+ // Register alongside the broadcaster it wraps; it expects 'sseRoomBroadcaster' and 'logger'
2048
+ // in the cradle, so the names must match exactly.
2049
+ class DashboardModule extends AbstractModule {
2050
+ resolveDependencies() {
2051
+ return {
2052
+ sseRoomManager: asValue(new SSERoomManager()),
2053
+ sseRoomBroadcaster: asSingletonClass(SSERoomBroadcaster),
2054
+ sseRoomEventPublisher: asSingletonClass(SSERoomEventPublisher),
2055
+ metricsService: asSingletonClass(MetricsService),
2056
+ }
2057
+ }
2058
+ }
2059
+
2060
+ class MetricsService {
2061
+ private publisher: SSERoomEventPublisher
2062
+
2063
+ constructor(deps: { sseRoomEventPublisher: SSERoomEventPublisher }) {
2064
+ this.publisher = deps.sseRoomEventPublisher
2065
+ }
2066
+
2067
+ onMetricsUpdate(
2068
+ dashboardId: string,
2069
+ metrics: { cpu: number; memory: number },
2070
+ requestContext: RequestContext,
2071
+ ) {
2072
+ // No await: a failure is logged, not returned. The context is passed whole; only its
2073
+ // logger is read, so a dropped event carries the correlation id of whatever produced it.
2074
+ this.publisher.publish(
2075
+ `dashboard:${dashboardId}`,
2076
+ metricsUpdateEvent,
2077
+ metrics,
2078
+ requestContext,
2079
+ )
2080
+ }
2081
+ }
2082
+ ```
2083
+
2084
+ The context parameter is typed as `SSELogContext` (`{ logger: SSELogger }`) rather than any
2085
+ concrete context type, so `@lokalise/fastify-extras`' `RequestContext` satisfies it structurally
2086
+ and this package needs no dependency on it. A job or consumer context of your own works the same
2087
+ way, and a caller that has none omits the argument and falls back to the injected logger.
2088
+
2089
+ Two things it does beyond hiding the promise:
2090
+
2091
+ - **Validates before broadcasting, and throws.** A payload that violates its own event schema is
2092
+ a bug in the producer, and nobody receives the event, so dropping it quietly means believing
2093
+ you published something you did not. Delivery-time validation cannot give you this: it runs
2094
+ once per connection, so it reports the mismatch once per open connection on every node, names
2095
+ the event but not the code that produced it, does not run at all when nobody has joined the
2096
+ room, and by then the call has long returned.
2097
+ - **Puts the parsed value on the wire,** so a schema default is filled in once here rather than
2098
+ left to every client. Delivery-time validation discards its own result and serializes what it
2099
+ was handed, so `broadcastToRoom()` sends the unparsed input.
2100
+
2101
+ #### `publish` vs `safePublish`
2102
+
2103
+ They differ in one thing: what a malformed payload does.
2104
+
2105
+ | | malformed payload | failed broadcast |
2106
+ | --- | --- | --- |
2107
+ | `publish` | throws `InternalError` | logged |
2108
+ | `safePublish` | returns `{ error }`, and logs | logged |
2109
+
2110
+ Reach for `safePublish` in a producer that cannot absorb a throw: a message handler whose primary
2111
+ work has already committed would be retried in full and redo it, and the retry cannot succeed
2112
+ anyway, since a malformed payload fails the same way every time. Prefer `publish` everywhere
2113
+ else.
2114
+
2115
+ ```ts
2116
+ const outcome = this.publisher.safePublish(room, event, payload, requestContext)
2117
+ if (outcome.error) {
2118
+ // decide for yourself: metric, Bugsnag, a compensating write
2119
+ }
2120
+ ```
2121
+
2122
+ `{ result: true }` means the payload was validated and handed to the broadcaster. That is
2123
+ acceptance, not delivery: the fan-out has not run yet, and neither method reports its outcome,
2124
+ because it happens after the call has returned. Use the broadcaster directly when the delivered
2125
+ count matters, or when a failed delivery is something the caller can act on.
2126
+
1965
2127
  #### Room Name Helpers
1966
2128
 
1967
2129
  Room names are plain strings (like Socket.IO), but `defineRoom()` adds type-safe resolvers that ensure consistent naming across controllers and domain services:
@@ -2062,7 +2224,7 @@ class DashboardModule extends AbstractModule {
2062
2224
 
2063
2225
  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.
2064
2226
 
2065
- See the [@opinionated-machine/sse-rooms-redis](./packages/sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
2227
+ See the [@opinionated-machine/sse-rooms-redis](../sse-rooms-redis/README.md) package for detailed documentation on Redis adapter configuration and usage.
2066
2228
 
2067
2229
  ### SSE Subscriptions
2068
2230
 
@@ -3017,6 +3179,13 @@ await app.ready()
3017
3179
 
3018
3180
  ### Accept Header Routing
3019
3181
 
3182
+ Dual-mode routes are registered with @fastify/sse kind `'manual'`, so the
3183
+ framework's `determineMode()` is the single Accept negotiator: q-value aware,
3184
+ `defaultMode` applies for `*/*` or a missing `Accept` header (including
3185
+ `defaultMode: 'sse'`). SSE-only routes use kind `'only'` — a missing header or
3186
+ `*/*` streams, and a client that explicitly refuses `text/event-stream`
3187
+ (e.g. `Accept: application/json`) receives a clean 406.
3188
+
3020
3189
  The `Accept` header determines response mode:
3021
3190
 
3022
3191
  ```bash
@@ -3253,9 +3422,9 @@ them in:
3253
3422
 
3254
3423
  | Gateway | Package | Output |
3255
3424
  | ------- | ------- | ------ |
3256
- | Envoy | [`@opinionated-machine/gateway-envoy`](./packages/gateway-envoy) | static v3 YAML/JSON |
3257
- | KrakenD | [`@opinionated-machine/gateway-krakend`](./packages/gateway-krakend) | declarative v3 JSON |
3258
- | Kong | [`@opinionated-machine/gateway-kong`](./packages/gateway-kong) | DB-less declarative YAML/JSON |
3425
+ | Envoy | [`@opinionated-machine/gateway-envoy`](../gateway-envoy) | static v3 YAML/JSON |
3426
+ | KrakenD | [`@opinionated-machine/gateway-krakend`](../gateway-krakend) | declarative v3 JSON |
3427
+ | Kong | [`@opinionated-machine/gateway-kong`](../gateway-kong) | DB-less declarative YAML/JSON |
3259
3428
 
3260
3429
  ### Quick Start
3261
3430
 
@@ -3478,7 +3647,7 @@ time.
3478
3647
  | Field | Example | Notes |
3479
3648
  | ----- | ------- | ----- |
3480
3649
  | `upstream` | `'users-service'` | Logical cluster name; resolved to a host by the generator |
3481
- | `timeouts` | `{ request: '5s', idle: '60s', connect: '1s' }` | Duration units: `ms` / `s` / `m` / `h` |
3650
+ | `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) |
3482
3651
  | `retry` | `{ attempts: 2, on: ['5xx', 'connect-failure'], perTryTimeout: '2s' }` | |
3483
3652
  | `rateLimit` | `{ requests: 100, per: '1m', key: 'ip' }` | `key`: `'ip'`, `{ header }`, `{ customHeader }`, `{ query }`, `{ customQuery }` |
3484
3653
  | `cache` | `{ ttl: '60s', methods: ['GET'], vary: ['Accept-Language'] }` | |
@@ -3532,9 +3701,9 @@ generators merge that block onto the rendered route last.
3532
3701
 
3533
3702
  For each gateway's full mapping table and quirks:
3534
3703
 
3535
- - [`@opinionated-machine/gateway-envoy`](./packages/gateway-envoy/README.md)
3536
- - [`@opinionated-machine/gateway-krakend`](./packages/gateway-krakend/README.md)
3537
- - [`@opinionated-machine/gateway-kong`](./packages/gateway-kong/README.md)
3704
+ - [`@opinionated-machine/gateway-envoy`](../gateway-envoy/README.md)
3705
+ - [`@opinionated-machine/gateway-krakend`](../gateway-krakend/README.md)
3706
+ - [`@opinionated-machine/gateway-kong`](../gateway-kong/README.md)
3538
3707
 
3539
3708
  ### Inspecting the Manifest at Runtime
3540
3709
 
@@ -3565,11 +3734,82 @@ const manifest = app.buildGatewayManifest()
3565
3734
  The manifest is rebuilt on every call, so it always reflects the current set
3566
3735
  of registered controllers.
3567
3736
 
3737
+ ### Streaming Routes
3738
+
3739
+ SSE and dual-mode routes need gateway treatment that request-response routes
3740
+ must not get: Envoy's defaults (15s route timeout, 5-minute stream idle
3741
+ timeout) reset long-lived streams, and buffering proxies hold SSE frames until
3742
+ the response completes. Routes built from SSE-capable contracts are therefore
3743
+ stamped with a streaming mode, and the manifest carries it as
3744
+ `streaming: 'sse' | 'dual'`.
3745
+
3746
+ The marker describes the **success path**. An error status answers with a JSON
3747
+ body on a streaming route too (including the early-return `sse.respond(404,
3748
+ ...)` path), so generators size timeouts and buffering from it but must not
3749
+ assume the content type of a failure.
3750
+
3751
+ - **Envoy** — streaming routes default to `timeout: 0s` and `idle_timeout: 0s`
3752
+ (declare `timeouts.idle` to reinstate a liveness bound; heartbeats are the
3753
+ intended keep-alive). `EnvoyOptions.streamIdleTimeout` sets the listener-wide
3754
+ HCM `stream_idle_timeout` for everything else. Declaring `timeouts.request`
3755
+ on an SSE-only route warns — it bounds the stream's total lifetime.
3756
+
3757
+ With both of those timeouts off, a streaming route would otherwise have an
3758
+ **unbounded** lifetime, and the authorization checked when the stream opened
3759
+ would stay in force for as long as the connection lives — a principal removed
3760
+ from a scope keeps receiving events until they close the tab. Streaming
3761
+ routes therefore emit a route-level `max_stream_duration`, defaulting to
3762
+ 30 minutes. Configure it with `EnvoyOptions.maxStreamDuration` (`'off'` for
3763
+ the old unbounded behaviour) or per route with `timeouts.maxDuration`
3764
+ (`'0s'` to opt out). The ceiling is invisible to users when the client
3765
+ treats a server close as a routine reconnect, which
3766
+ `@opinionated-machine/sse-fallback` does.
3767
+
3768
+ A **dual-mode** route is emitted as *two* Envoy routes, because one route
3769
+ cannot be both: `<id>__sse`, matched on `Accept: text/event-stream`, and
3770
+ `<id>`, the catch-all. The declared timeouts are split between them rather
3771
+ than applied to both — `timeouts.idle` goes to the stream branch,
3772
+ `timeouts.request` to the JSON branch, which is the fallback poll path and
3773
+ the one that most needs a bound. The split keys off the `Accept` header, the
3774
+ same predicate `determineMode()` uses server-side, quality values included:
3775
+ `text/event-stream;q=0` is a refusal, so it takes the JSON branch.
3776
+
3777
+ A route declaring `defaultMode: 'sse'` inverts the split, because there the
3778
+ server streams for a missing or wildcard `Accept` header. The manifest
3779
+ carries the fallback branch as `streamingDefaultMode` (`'non-sse' | 'sse'`,
3780
+ the `@lokalise/api-contracts` vocabulary), and Envoy makes the
3781
+ stream the catch-all with `<id>__json` as the narrow branch, so an
3782
+ unspecific request cannot land on the JSON branch's request timeout while the
3783
+ server is streaming. A request listing both media types resolves to JSON on
3784
+ the server but takes the stream branch at the gateway; the renderer warns
3785
+ about that residual ambiguity.
3786
+ - **Kong** — streaming routes emit `response_buffering: false` (Kong ≥ 2.3);
3787
+ `timeouts.idle` joins the loosest-wins service `read_timeout`. Streaming
3788
+ routes without a declared idle warn: heartbeats must arrive within the
3789
+ effective `read_timeout` (Kong default 60s) or the stream is reset.
3790
+
3791
+ Kong CE's `read_timeout` is **service-level**, so a long streaming idle
3792
+ window loosens every route sharing that upstream. Each co-located
3793
+ non-streaming route that inherits a raised timeout is warned about by name;
3794
+ give streaming routes their own `metadata.upstream` when the plain routes
3795
+ beside them need to stay tightly bounded.
3796
+ - **KrakenD** — the endpoint `timeout` uses the looser of `timeouts.request` /
3797
+ `timeouts.idle`; streaming routes with neither warn about KrakenD's 2s
3798
+ default endpoint timeout.
3799
+
3800
+ Routes declared through `AbstractApiController` are always included in the
3801
+ manifest. Legacy `AbstractSSEController` / `AbstractDualModeController` routes
3802
+ are included when you opt in:
3803
+
3804
+ ```ts
3805
+ const manifest = context.buildGatewayManifest({
3806
+ service: 'users-api',
3807
+ includeStreamingControllers: true, // default false — existing manifests don't silently grow
3808
+ })
3809
+ ```
3810
+
3568
3811
  ### What's Not Covered
3569
3812
 
3570
- - **SSE and dual-mode controllers.** Only routes from `AbstractController` and
3571
- `AbstractApiController` appear in the manifest today. Streaming routes still
3572
- proxy through every gateway, but they aren't listed.
3573
3813
  - **Fields a particular gateway can't natively express.** They show up in
3574
3814
  `result.warnings` rather than disappearing. Reach for `extensions.<vendor>`
3575
3815
  to hand-write the missing piece on a per-route basis.
@@ -3577,3 +3817,224 @@ of registered controllers.
3577
3817
  gateway runs separately. The generators don't compare deployed gateway
3578
3818
  state against the manifest.
3579
3819
 
3820
+
3821
+ ## Polling Fallback for SSE
3822
+
3823
+ Push channels fail silently: connections die without an error event, proxies
3824
+ kill idle streams, a broadcast misses a rebalancing room. When the missed
3825
+ notification gates workflow progress ("upload finished"), the user is stuck.
3826
+
3827
+ [`@opinionated-machine/sse-fallback`](../sse-fallback/README.md) is a
3828
+ browser-safe, zero-dependency client core that makes **polling the correctness
3829
+ backbone** and SSE the latency optimization: the client subscribes to the SSE
3830
+ branch of a dual-mode route and keeps a deadman timer — when no data event
3831
+ arrives within the window, it polls the JSON branch of the same route. A
3832
+ version gate reconciles the two channels so app code sees exactly one uniform
3833
+ event stream:
3834
+
3835
+ ```ts
3836
+ // Shared contracts module — the binding is the reconciliation declaration
3837
+ export const uploadStatusBinding = defineFallbackBinding(uploadStatusContract, {
3838
+ snapshotToEvents: (s) =>
3839
+ s.status === 'completed' ? [{ event: 'uploadFinished', data: { result: s.result } }] : [],
3840
+ version: { ofSnapshot: (s) => s.version },
3841
+ terminalEvents: ['uploadFinished', 'uploadFailed'],
3842
+ })
3843
+
3844
+ // Client — identical result whether it traveled over SSE, replay, or a poll
3845
+ const sub = createResilientSubscription(uploadStatusBinding, { transport, params })
3846
+ const { result } = await sub.waitFor('uploadFinished')
3847
+ ```
3848
+
3849
+ See the [package README](../sse-fallback/README.md) for the state
3850
+ machine, reconciliation semantics, hydration (initial load + live updates),
3851
+ and the transport interface.
3852
+
3853
+ ### Serving the Pattern
3854
+
3855
+ One dual-mode `AbstractApiController` route serves both channels — the sync
3856
+ branch answers the fallback polls, the SSE branch joins a room that the domain
3857
+ service broadcasts into:
3858
+
3859
+ ```ts
3860
+ readonly routes = {
3861
+ jobStatus: buildApiRoute(
3862
+ jobStatusContract,
3863
+ (request, _reply, { expectedContentType, sse }) => {
3864
+ // The push channel: join the job's room and stay open
3865
+ if (expectedContentType === 'text/event-stream') {
3866
+ const session = sse.start('keepAlive')
3867
+ getSessionRooms(session).join(`job:${request.params.jobId}`)
3868
+ return
3869
+ }
3870
+ // The fallback poll: return the current snapshot with its version
3871
+ return { status: 200, body: this.jobs.get(request.params.jobId) }
3872
+ },
3873
+ {
3874
+ // enables room membership + broadcast delivery for this route's sessions
3875
+ sseRooms: this.sseRoomBroadcaster,
3876
+ },
3877
+ ),
3878
+ }
3879
+ ```
3880
+
3881
+ ```ts
3882
+ // Domain service — broadcast with a monotonic id so clients can order events
3883
+ await this.sseRoomBroadcaster.broadcastToRoom(`job:${jobId}`, doneEvent, { result }, {
3884
+ id: String(job.version),
3885
+ })
3886
+ ```
3887
+
3888
+ ### SSE Rooms Authorization
3889
+
3890
+ Room membership decides who receives a broadcast, so it is an authorization
3891
+ boundary. The handler above names the room from a path param; nothing in that
3892
+ line checks that the authenticated principal belongs to the job's scope. Pass
3893
+ an options object instead of the bare broadcaster to declare the check once
3894
+ per route:
3895
+
3896
+ ```ts
3897
+ {
3898
+ sseRooms: {
3899
+ broadcaster: this.sseRoomBroadcaster,
3900
+ // Refused joins are logged and dropped; the stream itself stays open.
3901
+ authorizeJoin: (session, room) => this.membership.canRead(session.request.user, room),
3902
+ // Close the session after 30 minutes, forcing a re-authorized reconnect.
3903
+ maxSessionLifetimeMs: 30 * 60_000,
3904
+ },
3905
+ }
3906
+ ```
3907
+
3908
+ A synchronous verdict is applied before `join()` returns; an async one is
3909
+ applied when it resolves, so the session joins a moment later and the client's
3910
+ reconciliation poll covers anything broadcast in between.
3911
+
3912
+ Authorization checked at connect goes stale, so revocation needs a termination
3913
+ path of its own:
3914
+
3915
+ ```ts
3916
+ const registry = getApiSseConnectionRegistry(this.sseRoomBroadcaster)
3917
+
3918
+ registry.evict(connectionId) // end one stream
3919
+ registry.evictFromRoom(room, connectionId) // drop one scope, keep the stream
3920
+ registry.closeRoom(`project:${projectId}`) // end every stream in a scope
3921
+ ```
3922
+
3923
+ Only connections on the current node are closed, so a revocation event has to
3924
+ reach every node. A client that reconnects (as
3925
+ `@opinionated-machine/sse-fallback` does) comes back through the route's own
3926
+ authorization, so evicting a still-authorized principal costs a reconnect
3927
+ rather than a broken surface — which is also why `maxSessionLifetimeMs` is
3928
+ cheap: it doubles as the token-refresh mechanism and the backstop for a
3929
+ revocation that never reached `evict()`.
3930
+
3931
+ `test/api-contracts/api.rooms.security.e2e.spec.ts` is the pattern to copy per
3932
+ endpoint: a negative cross-tenant join test, and a mid-stream revocation test.
3933
+
3934
+ ### Monotonic Event IDs
3935
+
3936
+ `Last-Event-ID` replay, client-side ordering, and the fallback version gate
3937
+ all need event ids a client can ORDER, not just deduplicate. Use
3938
+ `createEventIdSequence()` (one sequence per ordering scope — per room, per
3939
+ resource):
3940
+
3941
+ ```ts
3942
+ import { compareEventIds, createEventIdSequence } from 'opinionated-machine'
3943
+
3944
+ const seq = createEventIdSequence()
3945
+ await broadcaster.broadcastToRoom(room, statusEvent, data, { id: seq.next() })
3946
+
3947
+ // Ids order lexicographically within an epoch; across epochs (e.g. after a
3948
+ // process restart) compareEventIds returns undefined — clients resync via poll
3949
+ compareEventIds('e1-000000000001', 'e1-000000000002') // -1
3950
+ ```
3951
+
3952
+ **Prefer a domain version** (`job.version`, a revision column) over a generated
3953
+ sequence whenever the resource has one: it is per-scope and writer-independent
3954
+ for free, and the snapshot body has to carry it anyway for the client's version
3955
+ gate.
3956
+
3957
+ `createEventIdSequence()` is in-memory and per-process, which makes it safe
3958
+ only for a **single-writer** ordering scope. Its epoch defaults to the process
3959
+ start time, and the client's default extractor orders by epoch first. If two
3960
+ pods of the same service broadcast into the same room, each with its own
3961
+ sequence, their epochs differ: the events interleave, the client's watermark
3962
+ lands on the newer epoch, and every subsequent event from the older-epoch pod
3963
+ compares as stale and is **silently dropped**. The failure only shows up under
3964
+ horizontal scale, so it reaches production.
3965
+
3966
+ For a multi-writer scope use a domain version, a fixed shared `epoch` with a
3967
+ `start` handed out from shared storage, or the Redis-backed sequence:
3968
+
3969
+ ```ts
3970
+ import { createRedisEventIdSequence } from '@opinionated-machine/sse-rooms-redis'
3971
+
3972
+ // One counter per ordering scope, shared by every pod — one INCR per id.
3973
+ const seq = createRedisEventIdSequence({ client: redis, key: `sse:seq:job:${jobId}` })
3974
+ await broadcaster.broadcastToRoom(room, statusEvent, data, { id: await seq.next() })
3975
+ ```
3976
+
3977
+ A shared counter orders **allocation**, not delivery. Between `next()` and the
3978
+ broadcast a writer can be descheduled while another pod allocates the next id
3979
+ and publishes first, so the client sees the higher id and drops the lower one
3980
+ as stale. What to do about it depends on what the events carry:
3981
+
3982
+ - **Replacement-safe events** (the payload describes the state of the scope,
3983
+ or the id is a domain version read in the same transaction that wrote it):
3984
+ nothing. The dropped event is superseded by the one that overtook it, which
3985
+ is what the version gate is for.
3986
+ - **Delta events applied to client state** (`state.apply`): the drop is a real
3987
+ loss. Serialize allocation and publication per ordering scope — one writer
3988
+ per scope, or a per-scope lock or outbox that publishes in id order.
3989
+
3990
+ Either way, call `next()` immediately before the broadcast with nothing awaited
3991
+ in between: the window that reorders is exactly that gap.
3992
+
3993
+ ### Server-Side Guarantees Checklist
3994
+
3995
+ For a resource to participate in the fallback pattern:
3996
+
3997
+ 1. **Required** — a monotonic version per resource, present in both the
3998
+ snapshot body and each event, and truthful: a snapshot at version *v*
3999
+ reflects every event ≤ *v* (publish events after commit; read committed
4000
+ state in the poll handler). Snapshots must **subsume** prior events.
4001
+ 2. **Recommended** — stamp the SSE `id:` with that version; the client's
4002
+ default version extraction (bare integers and `createEventIdSequence()`
4003
+ ids alike) and `Last-Event-ID` replay then compose free. Make sure the id
4004
+ source is safe for the number of writers the scope has — see
4005
+ [Monotonic Event IDs](#monotonic-event-ids).
4006
+ 3. **Recommended** — a short heartbeat interval (~15s, configured once via
4007
+ `app.register(fastifySSE, { heartbeatInterval })`) so clients detect
4008
+ silently dead connections fast; correctness holds without heartbeats
4009
+ (polls bound staleness), detection latency improves with them.
4010
+ 4. Optional — dense (consecutive) versions enable client gap detection;
4011
+ `onReconnect` replay lets clients skip the post-reconnect poll
4012
+ (`replay: 'trusted'` in the binding).
4013
+ 5. Gateway — declare `timeouts.idle` on streaming routes (or rely on the
4014
+ streaming-route defaults) so proxies don't reset quiet streams; see
4015
+ [Streaming Routes](#streaming-routes).
4016
+ 6. Authorization — room membership decides who receives a broadcast, so it is
4017
+ an authorization boundary. Declare the scope check once per route with
4018
+ `sseRooms.authorizeJoin` rather than trusting every handler body, give
4019
+ sessions a `maxSessionLifetimeMs` so a check made at connect cannot stay in
4020
+ force forever, and call `getApiSseConnectionRegistry(broadcaster).evict()` /
4021
+ `.closeRoom()` when access is revoked mid-stream. See
4022
+ [SSE Rooms Authorization](#sse-rooms-authorization).
4023
+
4024
+ ## Development
4025
+
4026
+ Tasks are orchestrated by [Turborepo](https://turborepo.dev), which reads the workspace graph from
4027
+ `turbo.jsonc`:
4028
+
4029
+ ```bash
4030
+ pnpm run build:all # build every package in dependency order
4031
+ pnpm run lint:all # biome + tsc in every package
4032
+ pnpm exec turbo run test:ci # this package's suite with coverage
4033
+ ```
4034
+
4035
+ Run these from the workspace root. Start with `build:all` on a fresh clone: the per-package
4036
+ `build`, `lint` and `test` scripts each do one package's work and assume their workspace
4037
+ dependencies are already built, so `pnpm run build` on its own fails until the graph has been
4038
+ built once.
4039
+
4040
+ See [CONTRIBUTING.md](../../CONTRIBUTING.md) for the full task table and caching notes.