opinionated-machine 10.3.0 → 10.5.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 (32) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +107 -3
  3. package/dist/lib/api-contracts/apiRouteBuilder.d.ts +1 -1
  4. package/dist/lib/api-contracts/apiRouteBuilder.js +36 -1
  5. package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
  6. package/dist/lib/sse/index.d.ts +1 -0
  7. package/dist/lib/sse/index.js +1 -0
  8. package/dist/lib/sse/index.js.map +1 -1
  9. package/dist/lib/sse/sseSendDiagnostics.d.ts +134 -0
  10. package/dist/lib/sse/sseSendDiagnostics.js +277 -0
  11. package/dist/lib/sse/sseSendDiagnostics.js.map +1 -0
  12. package/dist/lib/testing/apiSseEventValidation.d.ts +40 -0
  13. package/dist/lib/testing/apiSseEventValidation.js +78 -0
  14. package/dist/lib/testing/apiSseEventValidation.js.map +1 -0
  15. package/dist/lib/testing/apiSseHttpHelpers.d.ts +168 -0
  16. package/dist/lib/testing/apiSseHttpHelpers.js +214 -0
  17. package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -0
  18. package/dist/lib/testing/apiSseInjectHelpers.d.ts +17 -2
  19. package/dist/lib/testing/apiSseInjectHelpers.js +227 -56
  20. package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
  21. package/dist/lib/testing/apiSseTestTypes.d.ts +83 -3
  22. package/dist/lib/testing/index.d.ts +3 -2
  23. package/dist/lib/testing/index.js +1 -0
  24. package/dist/lib/testing/index.js.map +1 -1
  25. package/dist/lib/testing/sseHttpClient.d.ts +52 -0
  26. package/dist/lib/testing/sseHttpClient.js +77 -0
  27. package/dist/lib/testing/sseHttpClient.js.map +1 -1
  28. package/dist/lib/testing/sseInjectClient.d.ts +17 -0
  29. package/dist/lib/testing/sseInjectClient.js +31 -0
  30. package/dist/lib/testing/sseInjectClient.js.map +1 -1
  31. package/dist/lib/testing/sseTestTypes.d.ts +36 -2
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # opinionated-machine
2
2
 
3
+ ## 10.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - c2c7154: Read SSE responses as they are written, and with the contract's typing, on both test paths.
8
+
9
+ - `injectApiSSE` now injects with Fastify's `payloadAsStream` and exposes `head` (status and headers, as soon as the handler calls `sse.start()`) and `stream(signal?)`, which yields the contract's typed, validated events as the handler writes them. Progressive delivery can be asserted without `app.listen()`, a base URL or manual connection cleanup. `closed`, `events()` and `bodyForStatus()` are unchanged.
10
+ - `connectApiSSE(baseUrl, contract, params, options?)` connects over real HTTP using the contract for method, path, query params, headers and body, and reads the stream as the same discriminated union `injectApiSSE().events()` returns. `SSEHttpClient` gained `apiEvents(contract, signal?)` and `collectApiEvents(contract, countOrPredicate, timeout?)` for connections that already exist.
11
+ - A payload that fails its SSE event schema no longer reaches the test as an event that is simply missing: routes built with `buildApiRoute` report the failed send — event name, Zod issues and payload — to the helper reading the stream. A failure that ended the stream early is thrown by `events()` / `stream()` / the `connectApiSSE` readers; one the route caught and streamed around is recorded instead, so a handler with a working fallback keeps passing. `injectApiSSE(...).sendFailures()` and `connectApiSSE(...).sendFailures()` expose every record, `handled` flag included. Test-only, keyed on a header that only the helpers produce and that is ignored unless it names a diagnostics scope open in the same process.
12
+ - `connectApiSSE`'s readers reject a response that is not an event stream with its status and body, instead of waiting out the collection timeout on a stream that was never going to arrive, and invoke a caller's `collectEvents` predicate exactly once per event.
13
+
14
+ ## 10.4.0
15
+
16
+ ### Minor Changes
17
+
18
+ - 8a388e7: Expose the response body on `SSEInjectConnection`: `getBody()` returns the raw body string and `json<T>()` parses it as JSON, mirroring Fastify's inject response. This lets tests using the untyped `SSEInjectClient` assert on JSON error bodies that an SSE route sends before streaming starts (auth failures, validation errors, unavailable integrations), which previously were unreachable.
19
+
3
20
  ## 10.3.0
4
21
 
5
22
  ### Minor Changes
package/README.md CHANGED
@@ -75,6 +75,8 @@ Very opinionated DI framework for fastify, built on top of awilix
75
75
  - [SSEHttpClient](#ssehttpclient)
76
76
  - [SSEInjectClient](#sseinjectclient)
77
77
  - [Contract-Aware Inject Helpers](#contract-aware-inject-helpers)
78
+ - [Contract-Aware HTTP Helpers](#contract-aware-http-helpers)
79
+ - [When a Handler Fails to Send an Event](#when-a-handler-fails-to-send-an-event)
78
80
  - [Dual-Mode Controllers (SSE + Sync)](#dual-mode-controllers-sse--sync)
79
81
  - [Overview](#overview)
80
82
  - [Defining Dual-Mode Contracts](#defining-dual-mode-contracts)
@@ -1484,7 +1486,7 @@ The test client depends on the session mode:
1484
1486
  | Session Mode | Test Client | Why |
1485
1487
  |-------------|-------------|--------|
1486
1488
  | `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 |
1489
+ | `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
1488
1490
 
1489
1491
  Enable the connection spy by passing `isTestMode: true` in diOptions (required for `awaitServerConnection`).
1490
1492
 
@@ -1579,6 +1581,8 @@ it('streams chat completions', async () => {
1579
1581
  })
1580
1582
  ```
1581
1583
 
1584
+ If the route answers with an error status before streaming starts, the response carries a JSON body instead of events - read it with `conn.getBody()` or `conn.json()` (see [SSEInjectClient](#sseinjectclient)).
1585
+
1582
1586
  #### Asserting documented error responses with `bodyForStatus`
1583
1587
 
1584
1588
  When a contract declares `responseBodySchemasByStatusCode` for non-2xx responses (the shape the handler emits via `sse.respond(status, body)` before streaming starts), `injectSSE` / `injectPayloadSSE` expose a typed `bodyForStatus(status)` accessor:
@@ -1652,8 +1656,29 @@ it('returns the documented 400 body for an empty segment', async () => {
1652
1656
  })
1653
1657
  ```
1654
1658
 
1659
+ ```ts
1660
+ it('sends each issue as soon as it is found', async () => {
1661
+ const { head, stream } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
1662
+
1663
+ // The head is on the wire as soon as the handler calls sse.start()
1664
+ expect((await head).statusCode).toBe(200)
1665
+
1666
+ for await (const event of stream()) {
1667
+ // Each event is observed while the handler is still producing the next one
1668
+ if (event.event === 'issue') expect(handlerFinished).toBe(false)
1669
+ }
1670
+ })
1671
+ ```
1672
+
1655
1673
  `closed` and `bodyForStatus` behave as they do on `injectSSE`, except that `bodyForStatus` resolves its schema from `responsesByStatusCode`, following the same exact → range → `'default'` precedence as the contract client. `events()` is additionally available: it parses the SSE body and validates each event against the contract's SSE schemas, throwing when the response isn't a stream, when an event name isn't declared, or when a payload fails its schema.
1656
1674
 
1675
+ Two accessors read the response before it completes, so an inject-based suite can assert *progressive delivery* without opening a real port for it:
1676
+
1677
+ - `head` — `{ statusCode, headers }`, resolved as soon as the response head is on the wire (for a streaming handler, at `sse.start()`).
1678
+ - `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.
1679
+
1680
+ `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).
1681
+
1657
1682
  The request always carries `accept: text/event-stream`, so a status that declares a stream answers with it — including a dual-mode status whose content map also carries a JSON schema. Those statuses are therefore not callable through `bodyForStatus`; read them with `events()`, or use `injectByApiContract` when you want the JSON side. Conversely, a contract that declares no SSE response at all types `events` as `never`, so calling it is a compile error rather than a guaranteed throw. `events()` is typed from the SSE schemas of *every* declared status, merged the same way the runtime merges them, so a contract streaming on both `200` and `'4xx'` yields the union of both event sets. See [ApiContract controller docs](./lib/api-contracts/docs.md#testing) for the full testing guide.
1658
1683
 
1659
1684
  ### SSESessionSpy API
@@ -2239,7 +2264,7 @@ The library provides utilities for testing SSE endpoints.
2239
2264
  | Session Mode | Test Client | Reason |
2240
2265
  |-------------|-------------|--------|
2241
2266
  | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
2242
- | `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
2267
+ | `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
2243
2268
 
2244
2269
  `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`.
2245
2270
 
@@ -2252,7 +2277,7 @@ The library provides utilities for testing SSE endpoints.
2252
2277
  | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
2253
2278
  | **Server requirement** | No `listen()` needed | Requires a listening server (`SSETestServer.start(app)` or manual `app.listen()`) |
2254
2279
  | **Request body** | `injectPayloadSSE` / `connectWithBody` | `method` + `body` connect options |
2255
- | **Assertions before the handler finishes** | Not possible (response is buffered) | `client.response` is available as soon as headers arrive |
2280
+ | **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 |
2256
2281
  | **Best for** | `autoClose` SSE (OpenAI-style, batch exports) | `keepAlive` SSE (notifications, live feeds, rooms), streams whose headers must be asserted mid-handler |
2257
2282
  | **Dual-mode sync** | Use `app.inject()` with `accept: 'application/json'` | Same |
2258
2283
 
@@ -2463,6 +2488,25 @@ const events = conn.getReceivedEvents()
2463
2488
  const chunks = events.filter(e => e.event === 'chunk')
2464
2489
  ```
2465
2490
 
2491
+ When the route answers with a status code *before* streaming starts (auth failure,
2492
+ validation error, integration unavailable), it sends a JSON body rather than events.
2493
+ `getBody()` returns that body raw and `json()` parses it, mirroring Fastify's own
2494
+ inject response:
2495
+
2496
+ ```ts
2497
+ const conn = await client.connect('/api/export/progress')
2498
+
2499
+ expect(conn.getStatusCode()).toBe(503)
2500
+ expect(conn.json<{ errorCode: string }>()).toMatchObject({
2501
+ errorCode: 'INTEGRATION_NOT_AVAILABLE',
2502
+ })
2503
+ ```
2504
+
2505
+ `json()` throws if the body is empty or isn't valid JSON, so it only makes sense for
2506
+ these pre-stream responses - a `text/event-stream` body is not JSON. For contract-typed
2507
+ tests, `injectSSE`/`injectPayloadSSE`/`injectApiSSE` offer `bodyForStatus(status)`, which
2508
+ also validates the body against the contract's schema for that status.
2509
+
2466
2510
  #### Contract-Aware Inject Helpers
2467
2511
 
2468
2512
  For typed testing with SSE contracts:
@@ -2485,6 +2529,66 @@ const result = await closed
2485
2529
  const events = parseSSEEvents(result.body)
2486
2530
  ```
2487
2531
 
2532
+ 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).
2533
+
2534
+ #### Contract-Aware HTTP Helpers
2535
+
2536
+ `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.
2537
+
2538
+ ```ts
2539
+ import { connectApiSSE, SSETestServer } from 'opinionated-machine'
2540
+
2541
+ const server = await SSETestServer.start(app)
2542
+
2543
+ // Method, path, query params, headers and body all come from the contract
2544
+ const client = await connectApiSSE(server.baseUrl, lqaSegmentContract, {
2545
+ body: { segment: 'hello' },
2546
+ })
2547
+
2548
+ expect(client.response.status).toBe(200) // asserted while the handler is still working
2549
+
2550
+ for await (const event of client.events()) {
2551
+ if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed by the contract
2552
+ if (event.event === 'review') break
2553
+ }
2554
+
2555
+ client.close()
2556
+ await server.close()
2557
+ ```
2558
+
2559
+ - `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.
2560
+ - 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.
2561
+ - `client.response` is the fetch `Response`, available before any event is consumed.
2562
+ - `client.raw` is the underlying `SSEHttpClient`, for anything this wrapper doesn't cover.
2563
+ - 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 }`.
2564
+
2565
+ On a connection you already have, the same typing is available per read: `client.apiEvents(contract)` and `client.collectApiEvents(contract, countOrPredicate)` on any `SSEHttpClient`.
2566
+
2567
+ #### When a Handler Fails to Send an Event
2568
+
2569
+ `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.
2570
+
2571
+ 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:
2572
+
2573
+ ```
2574
+ events() — 1 SSE send failure recorded for this request:
2575
+ - event "issue" was never sent: severity: Invalid option: expected one of "neutral"|"minor"|"major"|"critical"; payload: {"severity":"min"}
2576
+ ```
2577
+
2578
+ 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.
2579
+
2580
+ 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:
2581
+
2582
+ ```ts
2583
+ const { closed, events, sendFailures } = injectApiSSE(app, lqaSegmentContract, { body })
2584
+ await closed
2585
+
2586
+ expect(await events()).toHaveLength(2) // the fallback stream is intact
2587
+ expect(sendFailures()).toMatchObject([{ eventName: 'issue', handled: true }])
2588
+ ```
2589
+
2590
+ 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.
2591
+
2488
2592
  ## Dual-Mode Controllers (SSE + Sync)
2489
2593
 
2490
2594
  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.
@@ -1,4 +1,4 @@
1
- import type { ApiContract } from '@lokalise/api-contracts';
1
+ import { type ApiContract } from '@lokalise/api-contracts';
2
2
  import { type ApiRouteOptions as FastifyApiRouteOptions, type InferApiHandler } from '@lokalise/fastify-api-contracts';
3
3
  import type { RouteOptions } from 'fastify';
4
4
  import type { GatewayMetadata } from '../gateway/gatewayTypes.ts';
@@ -1,5 +1,7 @@
1
+ import { getSseSchemaByEventName, } from '@lokalise/api-contracts';
1
2
  import { buildFastifyApiRoute, } from '@lokalise/fastify-api-contracts';
2
3
  import { attachGatewayMetadata } from "../gateway/withGatewayMetadata.js";
4
+ import { attachSSESendDiagnostics, reportSSEHandlerOutcome } from "../sse/sseSendDiagnostics.js";
3
5
  /**
4
6
  * Build a Fastify `RouteOptions` object from an `ApiContract` + handler.
5
7
  *
@@ -19,7 +21,40 @@ import { attachGatewayMetadata } from "../gateway/withGatewayMetadata.js";
19
21
  export function buildApiRoute(contract, handler, options) {
20
22
  // Gateway metadata is stamped via Symbol, not spread into Fastify options.
21
23
  const { gatewayMetadata, ...fastifyOptions } = options ?? {};
22
- const route = buildFastifyApiRoute(contract, handler, fastifyOptions);
24
+ const schemaByEventName = getSseSchemaByEventName(contract);
25
+ const built = buildFastifyApiRoute(contract, handler, schemaByEventName ? withSendDiagnostics(fastifyOptions, schemaByEventName) : fastifyOptions);
26
+ // Recording a failed send is only half of the diagnostic: whether the route recovered from
27
+ // it decides whether a test reading the stream should fail on it. That is what the handler's
28
+ // own outcome says, so it is observed here — for scoped requests only.
29
+ const route = schemaByEventName
30
+ ? { ...built, handler: reportSSEHandlerOutcome(built.handler) }
31
+ : built;
23
32
  return gatewayMetadata !== undefined ? attachGatewayMetadata(route, gatewayMetadata) : route;
24
33
  }
34
+ /**
35
+ * Instrument the sessions of an SSE route so a send the handler could not make is reported to
36
+ * the test that is reading the stream, instead of only to the server log.
37
+ *
38
+ * A payload that fails the contract's schema for its event makes `session.send()` throw from
39
+ * inside the handler: the event never reaches the wire, the stream just ends early, and the
40
+ * test sees a missing event with no reason attached. The SSE test helpers (`injectApiSSE`,
41
+ * `connectApiSSE`) tag their requests with a diagnostics header and surface what was recorded
42
+ * for them.
43
+ *
44
+ * Costs nothing outside a test run: the hook is only added for contracts that declare SSE
45
+ * events, and it does nothing unless the request names a diagnostics scope open in this
46
+ * process — something only those helpers produce.
47
+ */
48
+ function withSendDiagnostics(options, schemaByEventName) {
49
+ const { onConnect } = options;
50
+ return {
51
+ ...options,
52
+ // Called synchronously by `sse.start()` before the session reaches the handler, so the
53
+ // instrumentation is in place before the handler's first send.
54
+ onConnect: (session) => {
55
+ attachSSESendDiagnostics(session, schemaByEventName);
56
+ return onConnect?.(session);
57
+ },
58
+ };
59
+ }
25
60
  //# sourceMappingURL=apiRouteBuilder.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"apiRouteBuilder.js","sourceRoot":"","sources":["../../../lib/api-contracts/apiRouteBuilder.ts"],"names":[],"mappings":"AACA,OAAO,EACL,oBAAoB,GAGrB,MAAM,iCAAiC,CAAA;AAGxC,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAA;AAgDzE;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAkB,EAClB,OAAkC,EAClC,OAAmC;IAEnC,2EAA2E;IAC3E,MAAM,EAAE,eAAe,EAAE,GAAG,cAAc,EAAE,GAAG,OAAO,IAAI,EAAE,CAAA;IAC5D,MAAM,KAAK,GAAG,oBAAoB,CAAC,QAAQ,EAAE,OAAO,EAAE,cAAc,CAAC,CAAA;IACrE,OAAO,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,qBAAqB,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;AAC9F,CAAC"}
1
+ {"version":3,"file":"apiRouteBuilder.js","sourceRoot":"","sources":["../../../lib/api-contracts/apiRouteBuilder.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,uBAAuB,GAExB,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,oBAAoB,GAGrB,MAAM,iCAAiC,CAAA;AAGxC,OAAO,EAAE,qBAAqB,EAAE,MAAM,mCAAmC,CAAA;AACzE,OAAO,EAAE,wBAAwB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAA;AAgDhG;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAC3B,QAAkB,EAClB,OAAkC,EAClC,OAAmC;IAEnC,2EAA2E;IAC3E,MAAM,EAAE,eAAe,EAAE,GAAG,cAAc,EAAE,GAAG,OAAO,IAAI,EAAE,CAAA;IAC5D,MAAM,iBAAiB,GAAG,uBAAuB,CAAC,QAAQ,CAAC,CAAA;IAC3D,MAAM,KAAK,GAAG,oBAAoB,CAChC,QAAQ,EACR,OAAO,EACP,iBAAiB,CAAC,CAAC,CAAC,mBAAmB,CAAC,cAAc,EAAE,iBAAiB,CAAC,CAAC,CAAC,CAAC,cAAc,CAC5F,CAAA;IACD,2FAA2F;IAC3F,6FAA6F;IAC7F,uEAAuE;IACvE,MAAM,KAAK,GAAG,iBAAiB;QAC7B,CAAC,CAAC,EAAE,GAAG,KAAK,EAAE,OAAO,EAAE,uBAAuB,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE;QAC/D,CAAC,CAAC,KAAK,CAAA;IACT,OAAO,eAAe,KAAK,SAAS,CAAC,CAAC,CAAC,qBAAqB,CAAC,KAAK,EAAE,eAAe,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;AAC9F,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,SAAS,mBAAmB,CAC1B,OAA+B,EAC/B,iBAAkC;IAElC,MAAM,EAAE,SAAS,EAAE,GAAG,OAAO,CAAA;IAC7B,OAAO;QACL,GAAG,OAAO;QACV,uFAAuF;QACvF,+DAA+D;QAC/D,SAAS,EAAE,CAAC,OAAO,EAAE,EAAE;YACrB,wBAAwB,CAAC,OAAO,EAAE,iBAAiB,CAAC,CAAA;YACpD,OAAO,SAAS,EAAE,CAAC,OAAO,CAAC,CAAA;QAC7B,CAAC;KACF,CAAA;AACH,CAAC"}
@@ -5,4 +5,5 @@ export { defineEvent, type SSEEventDefinition } from './defineEvent.js';
5
5
  export { defineRoom, InMemoryAdapter, type PreDeliveryFilter, type RoomBroadcastOptions, type RoomNameResolver, type SSERoomAdapter, SSERoomBroadcaster, SSERoomManager, type SSERoomManagerConfig, type SSERoomMessageHandler, type SSERoomOperations, } from './rooms/index.js';
6
6
  export { type SpiedSSESession, type SSESessionEvent, SSESessionSpy } from './SSESessionSpy.js';
7
7
  export { type ParsedSSEEvent, type ParseSSEBufferResult, parseSSEBuffer, parseSSEEvents, } from './sseParser.js';
8
+ export { SSE_DIAGNOSTICS_HEADER, type SSEDiagnosticsScope, type SSESendFailure, } from './sseSendDiagnostics.js';
8
9
  export { defineEventMetadata, type ExtractMetadata, type FilterVerdict, type IncomingEvent, type MetadataGuard, type MetadataGuards, type PublishResult, type ResolverResult, SSESubscriptionManager, type SSESubscriptionManagerConfig, type SubscriptionContext, type SubscriptionPolicy, type SubscriptionResolver, } from './subscriptions/index.js';
@@ -6,6 +6,7 @@ export { defineEvent } from './defineEvent.js';
6
6
  export { defineRoom, InMemoryAdapter, SSERoomBroadcaster, SSERoomManager, } from './rooms/index.js';
7
7
  export { SSESessionSpy } from './SSESessionSpy.js';
8
8
  export { parseSSEBuffer, parseSSEEvents, } from './sseParser.js';
9
+ export { SSE_DIAGNOSTICS_HEADER, } from './sseSendDiagnostics.js';
9
10
  // SSE Subscriptions
10
11
  export { defineEventMetadata, SSESubscriptionManager, } from './subscriptions/index.js';
11
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/sse/index.ts"],"names":[],"mappings":"AAUA,2CAA2C;AAC3C,OAAO,EAEL,iBAAiB,EACjB,YAAY,GAUb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,qBAAqB,GAKtB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAA2B,MAAM,kBAAkB,CAAA;AACvE,mCAAmC;AACnC,OAAO,EACL,UAAU,EACV,eAAe,EAKf,kBAAkB,EAClB,cAAc,GAIf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAA8C,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAC9F,OAAO,EAGL,cAAc,EACd,cAAc,GACf,MAAM,gBAAgB,CAAA;AACvB,oBAAoB;AACpB,OAAO,EACL,mBAAmB,EAQnB,sBAAsB,GAKvB,MAAM,0BAA0B,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/sse/index.ts"],"names":[],"mappings":"AAUA,2CAA2C;AAC3C,OAAO,EAEL,iBAAiB,EACjB,YAAY,GAUb,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EACL,qBAAqB,GAKtB,MAAM,4BAA4B,CAAA;AACnC,OAAO,EAAE,WAAW,EAA2B,MAAM,kBAAkB,CAAA;AACvE,mCAAmC;AACnC,OAAO,EACL,UAAU,EACV,eAAe,EAKf,kBAAkB,EAClB,cAAc,GAIf,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAA8C,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAC9F,OAAO,EAGL,cAAc,EACd,cAAc,GACf,MAAM,gBAAgB,CAAA;AACvB,OAAO,EACL,sBAAsB,GAGvB,MAAM,yBAAyB,CAAA;AAChC,oBAAoB;AACpB,OAAO,EACL,mBAAmB,EAQnB,sBAAsB,GAKvB,MAAM,0BAA0B,CAAA"}
@@ -0,0 +1,134 @@
1
+ import type { SSEEventSchemas } from '@lokalise/api-contracts';
2
+ import type { SSESession } from '@lokalise/fastify-api-contracts';
3
+ import type { RouteHandlerMethod } from 'fastify';
4
+ import type { z } from 'zod';
5
+ /**
6
+ * Request header carrying the id of an open diagnostics scope.
7
+ *
8
+ * The SSE test helpers (`injectApiSSE`, `connectApiSSE`) set it on every request they make;
9
+ * routes built with `buildApiRoute` honour it by instrumenting the session they hand to the
10
+ * handler. A value that does not name a scope opened in this process is ignored, so the
11
+ * header is inert outside a test run — a client cannot make a production server record
12
+ * anything by sending it.
13
+ */
14
+ export declare const SSE_DIAGNOSTICS_HEADER = "x-om-sse-diagnostics-id";
15
+ /**
16
+ * One `session.send()` / `session.sendStream()` call that threw.
17
+ *
18
+ * Almost always a payload that failed the contract's schema for that event: the send throws,
19
+ * the event never reaches the wire, and the stream just ends early — so the event the test
20
+ * asserts on is missing while the reason lives in the server log.
21
+ */
22
+ export type SSESendFailure = {
23
+ /**
24
+ * Name of the event the handler tried to send.
25
+ *
26
+ * Absent when the failure came from the message source of `sendStream()` rather than from
27
+ * a send: the source threw while producing the next message, so no event was in flight.
28
+ */
29
+ eventName?: string;
30
+ /** Payload the handler passed, as-is. Absent together with {@link eventName}. */
31
+ data?: unknown;
32
+ /** Message of the thrown error. */
33
+ message: string;
34
+ /**
35
+ * Zod issues from re-validating `data` against the contract's schema for `eventName`.
36
+ * Absent when the contract declares no schema for the event, or when the send failed for
37
+ * a reason other than validation (a dead connection, say).
38
+ */
39
+ issues?: z.core.$ZodIssue[];
40
+ /** The thrown error itself, for assertions the fields above don't cover. */
41
+ error: unknown;
42
+ /**
43
+ * Whether the route recovered from this failure.
44
+ *
45
+ * `true` when the handler caught the error and went on to complete the response — the
46
+ * stream the test read is the one the route meant to produce, so the failure is context,
47
+ * not a verdict. `false` when the error escaped the handler (or was raised after it
48
+ * returned, on a `keepAlive` session): nothing recovered, and the stream ended where the
49
+ * send failed.
50
+ *
51
+ * Only final once the response completed; the test helpers only read it then.
52
+ */
53
+ handled: boolean;
54
+ };
55
+ /**
56
+ * A registered diagnostics scope: the header to send, and the failures recorded for it.
57
+ *
58
+ * Opened by the SSE test helpers, one per request they issue. Instrumented sessions match a
59
+ * request to a scope by the {@link SSE_DIAGNOSTICS_HEADER} value.
60
+ */
61
+ export type SSEDiagnosticsScope = {
62
+ /** Scope id, as sent in {@link SSE_DIAGNOSTICS_HEADER}. */
63
+ id: string;
64
+ /** Headers to merge into the request this scope observes. */
65
+ headers: Record<string, string>;
66
+ /**
67
+ * The failures recorded so far. After {@link SSEDiagnosticsScope.dispose}, the snapshot
68
+ * taken at that moment — so a result object can still report them long after the response
69
+ * completed, without keeping the scope registered.
70
+ */
71
+ failures(): SSESendFailure[];
72
+ /**
73
+ * Snapshot the failures and unregister the scope. Idempotent, and safe to call while a
74
+ * response is still in flight (nothing recorded afterwards is kept).
75
+ */
76
+ dispose(): void;
77
+ };
78
+ /**
79
+ * Open a diagnostics scope for a single request.
80
+ *
81
+ * @internal Used by the SSE test helpers; tests reach the failures through the helper they
82
+ * called, not through this registry.
83
+ */
84
+ export declare function openSSEDiagnosticsScope(): SSEDiagnosticsScope;
85
+ /**
86
+ * How many diagnostics scopes are registered right now.
87
+ *
88
+ * @internal Exists so the helpers' own specs can prove that every path out of a request
89
+ * unregisters its scope: a leaked one keeps its records alive for the rest of the process and
90
+ * costs every later request the fast path below.
91
+ */
92
+ export declare function countOpenSSEDiagnosticsScopes(): number;
93
+ /**
94
+ * Instrument an SSE session so that failed sends are recorded against the diagnostics scope
95
+ * the request names, then rethrown unchanged.
96
+ *
97
+ * A no-op unless the request carries {@link SSE_DIAGNOSTICS_HEADER} with the id of a scope
98
+ * open in this process, which only the SSE test helpers ever produce.
99
+ *
100
+ * @param session - The session handed to the handler by `sse.start()`
101
+ * @param schemaByEventName - The contract's merged SSE event schemas, used to recover the
102
+ * Zod issues behind a validation failure
103
+ *
104
+ * @internal Called by `buildApiRoute`; not part of the application-facing API.
105
+ */
106
+ export declare function attachSSESendDiagnostics(session: SSESession, schemaByEventName: SSEEventSchemas): void;
107
+ /**
108
+ * Wrap a route handler so the diagnostics scope learns whether the route recovered from the
109
+ * sends it could not make.
110
+ *
111
+ * A send that throws is only a reason for a test to fail when nothing caught it: a handler
112
+ * that catches its own failed send and streams a fallback instead produced exactly the
113
+ * response it meant to. Observing how the handler settled is what tells the two apart —
114
+ * {@link SSESendFailure.handled}.
115
+ *
116
+ * A no-op for requests that name no open diagnostics scope: outside a test run this is one
117
+ * `Map.size` check per request on SSE routes.
118
+ *
119
+ * @internal Applied by `buildApiRoute` to SSE-capable routes.
120
+ */
121
+ export declare function reportSSEHandlerOutcome(handler: RouteHandlerMethod): RouteHandlerMethod;
122
+ /**
123
+ * The failures the route did not recover from — the ones that truncated the response, and so
124
+ * explain an event a test waited for and never saw.
125
+ *
126
+ * @internal
127
+ */
128
+ export declare function unhandledSendFailures(failures: SSESendFailure[]): SSESendFailure[];
129
+ /**
130
+ * Render recorded failures as the message of the error the test helpers throw.
131
+ *
132
+ * @internal
133
+ */
134
+ export declare function describeSendFailures(failures: SSESendFailure[]): string;