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/CHANGELOG.md CHANGED
@@ -1,5 +1,159 @@
1
1
  # opinionated-machine
2
2
 
3
+ ## 11.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9637451: Add `SSERoomEventPublisher`: fire-and-forget room broadcasting for domain code. It validates a payload against the event's own schema before broadcasting, and broadcasts the parsed value so schema defaults reach the wire. `publish` throws on a payload that violates its schema, since nobody receives that event and a producer should not believe otherwise; `safePublish` returns `Either<InternalError, true>` instead, for a caller that cannot absorb a throw. A failed broadcast is logged by both, as it happens after the call returns. Accepts the caller's context (anything with a `logger`, such as a `RequestContext`) so failures carry a correlation id.
8
+
9
+ ## 11.1.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 19b8ebb: Add `asDomainEventEmitterFunction` resolver, which disposes the domain event emitter after queue consumers and job workers but before the job queue manager.
14
+
15
+ ## 11.0.1
16
+
17
+ ### Patch Changes
18
+
19
+ - be94a1f: Move the framework's sources from the workspace root into `packages/opinionated-machine`, so every
20
+ package in the repo lives under `packages/*`.
21
+
22
+ The published contents are unchanged: same entry points, same `files`, same README and CHANGELOG.
23
+ `repository.directory` now points at the package, and the workspace root is private and holds only
24
+ the orchestration scripts (`build:all`, `lint:all`, `test:all`, `changeset`, `ci:publish`) plus the
25
+ shared `tsconfig.json`, `biome.jsonc` and `turbo.jsonc` every package extends.
26
+
27
+ `@opinionated-machine/sse-fallback` is now a declared devDependency of the framework package rather
28
+ than a relative path into a sibling directory, which is what lets turbo order and hash the suite
29
+ that integrates against it.
30
+ - be94a1f: Orchestrate workspace tasks with Turborepo.
31
+
32
+ `turbo.jsonc` declares each task's dependencies, inputs and outputs, so ordering follows the
33
+ workspace graph instead of hand-written `pnpm --filter` chains. The per-package `build` scripts
34
+ lost their `pnpm --filter @opinionated-machine/sse-parser run build` prefix and now compile
35
+ exactly one package; `pnpm run build:all` and `pnpm run lint:all` drive the whole graph.
36
+
37
+ Keeping the per-package `build` scripts single-package also keeps `prepublishOnly` safe: changesets
38
+ publishes chunk-mates concurrently, so a hook that rebuilt sibling packages would `rimraf` a `dist`
39
+ that another `pnpm publish` was packing at that moment.
40
+
41
+ ## 11.0.0
42
+
43
+ ### Major Changes
44
+
45
+ - f0999ee: Extract the SSE wire-format parser into `@opinionated-machine/sse-parser` and give it stream-shaped entry points.
46
+
47
+ The parser existed twice: once in `opinionated-machine` for the server-side test helpers, once vendored into `@opinionated-machine/sse-fallback` for the browser client. The copies had already drifted in documentation and in what they exported, and every spec fix had to be applied to both. There is now one implementation, dependency-free and browser-safe, that both packages depend on.
48
+
49
+ **New package `@opinionated-machine/sse-parser`**
50
+
51
+ - `parseSSEBuffer(buffer, lastEventId?)` and `parseSSEEvents(text)`, unchanged apart from the spec fixes below.
52
+ - `createSSEStreamParser({ lastEventId })` owns the partial-frame buffer, the reconnect cursor and the stream-start BOM across chunks. Every consumer was hand-rolling that bookkeeping, and `SSEHttpClient` was getting it wrong: it never fed the cursor back, so events carrying no `id:` of their own reported no `lastEventId`.
53
+ - `parseSSEStream(chunks, { onChunk })` frames an async iterable of decoded text. `onChunk` sees every chunk before framing, comment frames included, which is what byte-level liveness detection needs: framed events alone cannot tell a heartbeat-only connection from a dead one.
54
+ - `parseSSEResponse(response, options?)` reads a `fetch` response body: UTF-8 decoding across chunk boundaries, framing, and cancellation of the body when the consumer stops early.
55
+
56
+ **Spec fixes**
57
+
58
+ - A leading BOM is stripped at the start of a stream (`parseSSEEvents`, `createSSEStreamParser`, and therefore `parseSSEStream` and `parseSSEResponse`). `TextDecoder` and `Response.text()` already drop it, but `Buffer.toString('utf8')`, which is what `fastify.inject()` hands back, does not. An unstripped BOM turns the first field name into `data`, which the interpreter ignores, silently swallowing the first event.
59
+ - **Breaking:** `parseSSEEvents` no longer dispatches a trailing frame that no blank line terminated. The spec discards pending data at the end of a stream, and a body cut mid-frame (an aborted response, a killed stream, a progressive read) was surfacing its truncated payload as a delivered event. Call `parseSSEBuffer` directly when you need to inspect that leftover.
60
+
61
+ **`opinionated-machine`**
62
+
63
+ - Re-exports the whole parser surface, so `parseSSEBuffer` and `parseSSEEvents` keep working from the same import path, alongside the new stream helpers.
64
+ - `SSEHttpClient` frames with `createSSEStreamParser`, which fixes the reconnect cursor it was dropping.
65
+
66
+ **`@opinionated-machine/sse-fallback`**
67
+
68
+ - Depends on the parser package instead of vendoring it, and re-exports it so a transport author does not need a second dependency to frame a stream. The one runtime dependency is first-party, dependency-free and browser-safe.
69
+ - The subscription's chunk loop and the test transport's framing use the shared helpers.
70
+
71
+ ### Minor Changes
72
+
73
+ - f0999ee: Add SSE rooms support to the modern api-contracts path.
74
+
75
+ `buildApiRoute` accepts a new `sseRooms: SSERoomBroadcaster` option: sessions opened by the route's SSE handler are registered with the broadcaster (so `broadcastToRoom`/`broadcastMessage` reach them), get room operations via the new `getSessionRooms(session)` accessor, and are cleaned up (rooms left, dedup cache cleared) when the connection closes. This makes the dual-mode fallback pattern possible with `AbstractApiController`: the sync branch answers polls while a domain service broadcasts into a room the SSE branch joined. The new `ApiSseConnectionRegistry` bridges connections to a shared broadcaster, which can serve legacy controllers and `buildApiRoute` routes simultaneously.
76
+
77
+ Room operations live beside the session rather than on it (`getSessionRooms(session)` rather than `session.rooms`) because `@lokalise/fastify-api-contracts` owns the `SSESession` shape in this path.
78
+ - f0999ee: Mark streaming routes in the gateway manifest and map `timeouts.idle` in all generators.
79
+
80
+ - Routes built from SSE/dual-mode contracts are stamped with a streaming mode (non-enumerable `Symbol.for('opinionated-machine.route.streaming')`), and the manifest gains an optional `streaming: 'sse' | 'dual'` field. Legacy `AbstractSSEController`/`AbstractDualModeController` routes can be included via the new `buildGatewayManifest({ includeStreamingControllers: true })` opt-in.
81
+ - Envoy: `timeouts.idle` maps to route-level `idle_timeout` (previously silently ignored); streaming routes default to `timeout: 0s` and `idle_timeout: 0s` so Envoy's defaults (15s route timeout, 5m stream idle timeout) no longer reset SSE streams; new `EnvoyOptions.streamIdleTimeout` configures the listener-wide HCM value; declaring `timeouts.request` on an SSE-only route warns (it bounds total stream lifetime). A dual-mode route is emitted as two Envoy routes — `<id>__sse`, matched on `Accept: text/event-stream`, and `<id>`, the catch-all — with the declared timeouts split between them (`timeouts.idle` to the stream branch, `timeouts.request` to the JSON branch) rather than applied to both, so disabling the timeouts a stream needs no longer strips every bound from the JSON poll branch.
82
+ - Kong: `timeouts.idle` participates in the loosest-wins service `read_timeout`; streaming routes emit `response_buffering: false` (Kong ≥ 2.3); streaming routes without `timeouts.idle` warn that heartbeats must beat the effective `read_timeout`. Because that `read_timeout` is service-level, every co-located non-streaming route that inherits a raised value is now warned about by name, with the remedy (a separate `metadata.upstream` for streaming routes).
83
+ - KrakenD: the endpoint `timeout` uses the looser of `timeouts.request`/`timeouts.idle`; streaming routes with neither warn about KrakenD's 2s default endpoint timeout.
84
+ - The manifest's streaming fields follow the `@lokalise/api-contracts` vocabulary: `streaming: 'sse' | 'dual'` and `streamingDefaultMode: 'non-sse' | 'sse'`. The branch of a dual route that does not stream is a JSON body on most routes but can equally be a blob, and nothing in the manifest depends on which. Both fields describe the SUCCESS path: an error status answers with JSON on a streaming route too, including the early-return `sse.respond(404, ...)` path, so a generator sizes timeouts and buffering from them but must not assume the content type of a failure.
85
+ - f0999ee: Add monotonic event-id helpers for SSE streams: `createEventIdSequence()` produces lexicographically ordered ids (`"<epoch>-<zero-padded counter>"`) suitable for `Last-Event-ID` reconnection, client-side ordering, and the polling-fallback version gate; `compareEventIds()` orders ids within an epoch and returns `undefined` across epochs (a signal to resynchronize via a snapshot poll).
86
+ - f0999ee: Close the review findings on the SSE fallback stack: room authorization and revocation, bounded stream lifetimes, multi-writer event ids, and the client's give-up and re-auth paths.
87
+
88
+ **Rooms (`opinionated-machine`)**
89
+
90
+ - `sseRooms` accepts an options object (`{ broadcaster, authorizeJoin, maxSessionLifetimeMs }`) alongside the bare broadcaster. `authorizeJoin` declares the scope check once per route instead of trusting every handler body — a room name built from a path param was previously joined unchecked. `maxSessionLifetimeMs` closes the session after N ms, forcing a re-authorized reconnect.
91
+ - `ApiSseConnectionRegistry` gained `evict(connectionId)`, `evictFromRoom(room, connectionId)` and `closeRoom(room)`. Authorization is checked when a stream opens and then goes stale, so revocation now has something to call: before this a revoked user kept receiving broadcasts until the tab closed.
92
+
93
+ **Event ids (`opinionated-machine`, `@opinionated-machine/sse-rooms-redis`)**
94
+
95
+ - `createEventIdSequence()` validates `start` (integer, in range, below the id width) and `epoch` (non-empty), and refuses to widen the counter past its zero padding. `start: 999_999_999_999` used to produce a 13-digit id that no longer sorted after its 12-digit predecessor.
96
+ - New `createRedisEventIdSequence()` backs the counter with Redis `INCR` under one shared epoch. Per-process sequences are safe only for a single writer: two pods broadcasting into the same room use different epochs, and a client ordering by epoch first silently drops the older pod's events. The docs now state that hazard and recommend domain versions as the default id source. `@opinionated-machine/sse-rooms-redis` needs those exports, so its `opinionated-machine` peer range moves from `>=6.8.0` to `>=10.1.0` — a breaking change for anyone on an older framework version.
97
+ - `formatEventId()` rejects an empty epoch, the way `createEventIdSequence()` already did: `'-000000000001'` is an id `compareEventIds()` cannot parse, so nothing downstream can order it.
98
+
99
+ **Envoy (`@opinionated-machine/gateway-envoy`)**
100
+
101
+ - Streaming routes emit a route-level `max_stream_duration`, default 30 minutes, configurable via `EnvoyOptions.maxStreamDuration` or per route with the new `timeouts.maxDuration`. With both the route timeout and the idle timeout disabled, a streaming route had an unbounded lifetime, so the authorization checked at connect never expired.
102
+ - The dual-mode SSE branch honours `Accept` quality values: `text/event-stream;q=0` is a refusal and now takes the JSON branch, where `contains` alone matched it. A `defaultMode: 'sse'` route emits a second plain branch (`<id>__json_sse_refused`) for the same refusal, because there the stream is the catch-all and the JSON branch's `contains` exclusion cannot match a header that names the type only to refuse it.
103
+ - The manifest carries `streamingDefaultMode`, and a route declaring `defaultMode: 'sse'` inverts the Envoy split so the stream is the catch-all. Previously a request with a missing or wildcard `Accept` header streamed on the server but got the JSON branch's request timeout at the gateway.
104
+
105
+ **Fallback client (`@opinionated-machine/sse-fallback`)**
106
+
107
+ - Stops carry a reason: `sub.result`, `sub.onStop()`, a second `onStatusChange` argument, and `SubscriptionStoppedError` from `waitFor`. `'stopped'` alone could not tell a completed job from an auth failure or a caller's own `stop()`.
108
+ - `policy.subscriptionBudget` (`maxDurationMs` / `maxPolls`, unset by default) stops a subscription whose backend never leaves its pending state, with a `'budget-exhausted'` reason. Every individual wait was bounded; the subscription as a whole was not.
109
+ - `onAuthChallenge` makes an auth refusal recoverable once: refresh credentials and the refused poll or connect runs again. A 401 in a SPA is usually an expired token, and killing the subscription contradicted recovering without a page reload.
110
+ - `policy.mode: 'poll-only'` (and the `POLL_ONLY_POLICY` preset) never opens a stream, so a backend can adopt the binding, version gate and state machine before its SSE endpoint exists.
111
+ - `openStream` may resolve with `events: AsyncIterable<ParsedSseFrame>` instead of raw `chunks`, so adapters can wrap `EventSource` or an HTTP client that only exposes framed events. `staleConnectionTimeoutMs` degrades to event-level liveness in that mode; correctness is unaffected.
112
+ - New `createPollGate()` caps and staggers reconciliation polls across subscriptions sharing one origin. Per-subscription jitter did nothing about a fleet-wide reconnect firing every subscription's poll on the same tick.
113
+ - A throwing event/state/status listener no longer aborts the delivery loop or surfaces as a transport failure; faults go to `diagnostics.onListenerError`.
114
+ - A gap-suspended state layer is repaired by any snapshot, not only one whose version matches the watermark exactly. Live events kept advancing the watermark after a gap, so the repair snapshot arrived below it, `apply` stayed disabled and `getState()` froze at its pre-gap value forever. The suspension is now reported on the outcome and through `diagnostics.onStateSuspended` / `onStateRepaired`. Events delivered during the suspension that the repair snapshot predates are replayed onto it, so a repair at revision 4 no longer drops a revision 5 that was already delivered; if more arrive than the buffer holds, the suspension holds until a snapshot reaches the watermark rather than dropping them silently.
115
+ - Snapshot-synthesized events stop at the terminal event, matching `handleEvent` and `finishHydration`.
116
+ - Version comparison no longer coerces unsafe integer strings through `Number()`: `9007199254740992` and `9007199254740993` collapsed to the same double, so the second event was dropped as a duplicate.
117
+ - Abandoned hydration no longer reports a byte-less stream as `'live'`; bytes are what promote the subscription.
118
+ - f0999ee: Expose raw SSE chunks on `SSEHttpClient` and fix CRLF framing in `parseSSEBuffer`.
119
+
120
+ - `SSEHttpClient` gained an `onRawChunk` hook that observes raw stream chunks as they arrive, including the `: heartbeat` comment frames the SSE parser drops. Useful for asserting heartbeat delivery and for byte-level liveness checks in tests.
121
+ - `parseSSEBuffer` handles CRLF-framed streams: the blank separator line kept a trailing `\r`, so consecutive events merged into one with the wrong id and concatenated data.
122
+ - f0999ee: Close the third round of review findings on the SSE fallback stack.
123
+
124
+ **SSE parser**
125
+
126
+ - Field values follow the spec: exactly one leading space is removed after the colon and the rest is preserved. `trim()` was corrupting `data: keep spaces `, which matters for any decoder that reads the raw string instead of JSON.
127
+ - CR, LF and CRLF are all line terminators. A CR at the end of the buffer is held back until the next chunk says whether it was half of a CRLF.
128
+ - `retry:` accepts ASCII digits only. `parseInt` was reading `100x` as 100.
129
+ - A blank line dispatches even with no data, so `id: reset\n\n` moves the Last-Event-ID cursor instead of leaking its id onto the next event, and an empty `id:` clears the cursor. `parseSSEBuffer(buffer, lastEventId)` takes the cursor in and returns it, and each event reports the cursor as of its dispatch in `lastEventId`.
130
+
131
+ **Event ids (`opinionated-machine`, `@opinionated-machine/sse-rooms-redis`)**
132
+
133
+ - `createEventIdSequence()`, `formatEventId()` and `createRedisEventIdSequence()` require a numeric epoch. `epoch: 'deploy-blue'` produced `deploy-blue-000000000001`, which the client's default version extractor reads as versionless: the same id delivered twice was not a duplicate, so dedup, gap detection and stale-poll protection were silently off. Restricting the epoch is what makes `<digits>-<digits>` an unambiguous marker for a generated id, since a UUID matches `<anything>-<digits>` too.
134
+
135
+ **Fallback client (`@opinionated-machine/sse-fallback`)**
136
+
137
+ - An epoch change is reported as a gap with `reason: 'epoch-change'` instead of being swallowed, so the subscription polls and suspends delta state and repairs from a snapshot. Applying deltas across a writer restart was silent, and a busy stream kept the deadman moving so the repair never came. Gaps from a skipped counter carry `reason: 'sequence'`.
138
+ - A poll and a reconnect refused by the same expired token share one in-flight `onAuthChallenge` refresh. The second refusal used to see the retry already spent and stop the subscription while the refresh was still running; only a refusal after the refresh completes counts as the second failure now.
139
+
140
+ **Rooms (`opinionated-machine`)**
141
+
142
+ - A join whose async `authorizeJoin` verdict is still pending is cancelled by `leave`, `evictFromRoom`, `evict`, `closeRoom` and the session closing. The resolved verdict used to add the connection to a room it had just been removed from, so the revocation silently did not stick. `evictFromRoom` returns `true` when it cancels a pending join.
143
+
144
+ **Envoy (`@opinionated-machine/gateway-envoy`)**
145
+
146
+ - A dual-mode route emits a `<id>__negotiated` branch for an `Accept` header that names both `application/json` and `text/event-stream` as acceptable. The server ranks them by quality (and by header order on a tie), which RE2 header matchers cannot reproduce, so `application/json;q=0.9, text/event-stream;q=0.1` took the stream branch and ran the JSON poll with `timeout: 0s`. The stream branches are now narrowed to the cases the gateway can decide, and the negotiated branch carries bounds safe for either mode: no total-lifetime bound, plus an idle bound from `timeouts.idle` or `timeouts.request`. A route declaring `timeouts.request` warns that it cannot be enforced there.
147
+
148
+ ### Patch Changes
149
+
150
+ - f0999ee: Stop `ApiSseConnectionRegistry` from retaining pending-join state for dead connections.
151
+
152
+ `unregister` and `evict` cancelled a connection's in-flight `authorizeJoin` tokens but left the map entry behind. The entry is normally removed when the verdict settles, so a verdict that never resolves kept the connection id and its tokens alive after the session was gone, and `closeRoom` walked the retained keys. Both paths now delete the entry; cancellation rides on the token object the join closure captured, so a late verdict still settles as revoked.
153
+ - Updated dependencies [f0999ee]
154
+ - Updated dependencies [f0999ee]
155
+ - @opinionated-machine/sse-parser@0.1.0
156
+
3
157
  ## 10.5.0
4
158
 
5
159
  ### Minor Changes