opinionated-machine 10.4.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 10.4.0
4
15
 
5
16
  ### 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
 
@@ -1654,8 +1656,29 @@ it('returns the documented 400 body for an empty segment', async () => {
1654
1656
  })
1655
1657
  ```
1656
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
+
1657
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.
1658
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
+
1659
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.
1660
1683
 
1661
1684
  ### SSESessionSpy API
@@ -2241,7 +2264,7 @@ The library provides utilities for testing SSE endpoints.
2241
2264
  | Session Mode | Test Client | Reason |
2242
2265
  |-------------|-------------|--------|
2243
2266
  | `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
2244
- | `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
2267
+ | `keepAlive` | `SSEHttpClient` (`connectApiSSE` for `defineApiContract` contracts) | Connection stays open; events arrive incrementally via server push |
2245
2268
 
2246
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`.
2247
2270
 
@@ -2254,7 +2277,7 @@ The library provides utilities for testing SSE endpoints.
2254
2277
  | **Connection lifecycle** | Handler must close for request to complete | Can stay open indefinitely |
2255
2278
  | **Server requirement** | No `listen()` needed | Requires a listening server (`SSETestServer.start(app)` or manual `app.listen()`) |
2256
2279
  | **Request body** | `injectPayloadSSE` / `connectWithBody` | `method` + `body` connect options |
2257
- | **Assertions before the handler finishes** | Not possible (response is buffered) | `client.response` is available as soon as headers arrive |
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 |
2258
2281
  | **Best for** | `autoClose` SSE (OpenAI-style, batch exports) | `keepAlive` SSE (notifications, live feeds, rooms), streams whose headers must be asserted mid-handler |
2259
2282
  | **Dual-mode sync** | Use `app.inject()` with `accept: 'application/json'` | Same |
2260
2283
 
@@ -2506,6 +2529,66 @@ const result = await closed
2506
2529
  const events = parseSSEEvents(result.body)
2507
2530
  ```
2508
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
+
2509
2592
  ## Dual-Mode Controllers (SSE + Sync)
2510
2593
 
2511
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;
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Request header carrying the id of an open diagnostics scope.
3
+ *
4
+ * The SSE test helpers (`injectApiSSE`, `connectApiSSE`) set it on every request they make;
5
+ * routes built with `buildApiRoute` honour it by instrumenting the session they hand to the
6
+ * handler. A value that does not name a scope opened in this process is ignored, so the
7
+ * header is inert outside a test run — a client cannot make a production server record
8
+ * anything by sending it.
9
+ */
10
+ export const SSE_DIAGNOSTICS_HEADER = 'x-om-sse-diagnostics-id';
11
+ /** Cap per scope, so a handler failing in a loop can't grow the registry without bound. */
12
+ const MAX_FAILURES_PER_SCOPE = 50;
13
+ /** How far to walk an error's `cause` chain when matching it to a recorded failure. */
14
+ const MAX_CAUSE_DEPTH = 10;
15
+ /**
16
+ * The failures of one observed request, plus whether the route recovered from them.
17
+ *
18
+ * Split from {@link SSEDiagnosticsScope} because the two ends see different things: the scope
19
+ * is the reader's handle (it can only snapshot and unregister), while the recorder is what the
20
+ * instrumented route writes to.
21
+ */
22
+ class SSEDiagnosticsRecorder {
23
+ failures = [];
24
+ settled = false;
25
+ /** Record a send that threw, and the Zod issues behind it when the payload explains it. */
26
+ recordSendFailure(schemaByEventName, eventName, data, error) {
27
+ if (this.failures.length >= MAX_FAILURES_PER_SCOPE) {
28
+ return;
29
+ }
30
+ const issues = issuesFor(schemaByEventName, eventName, data);
31
+ this.failures.push({
32
+ eventName,
33
+ data,
34
+ message: messageOf(error),
35
+ ...(issues && { issues }),
36
+ error,
37
+ handled: false,
38
+ });
39
+ }
40
+ /** Record a `sendStream()` source that threw while producing its next message. */
41
+ recordSourceFailure(error) {
42
+ if (this.failures.length >= MAX_FAILURES_PER_SCOPE) {
43
+ return;
44
+ }
45
+ this.failures.push({ message: messageOf(error), error, handled: false });
46
+ }
47
+ /**
48
+ * The route handler settled: everything recorded so far that did not escape it was caught
49
+ * by the route, which went on to produce the rest of the response.
50
+ *
51
+ * Called once per request, before the response ends, so the helpers reading the stream see
52
+ * final `handled` flags. Failures recorded afterwards — a send on a `keepAlive` session the
53
+ * handler already returned from — stay unhandled: nothing observably recovered from them.
54
+ *
55
+ * @param escaped - The error the handler threw, if it threw
56
+ */
57
+ settle(escaped) {
58
+ if (this.settled) {
59
+ return;
60
+ }
61
+ this.settled = true;
62
+ for (const failure of this.failures) {
63
+ failure.handled = !causedBy(escaped, failure.error);
64
+ }
65
+ }
66
+ }
67
+ const openScopes = new Map();
68
+ let nextScopeId = 0;
69
+ /**
70
+ * Open a diagnostics scope for a single request.
71
+ *
72
+ * @internal Used by the SSE test helpers; tests reach the failures through the helper they
73
+ * called, not through this registry.
74
+ */
75
+ export function openSSEDiagnosticsScope() {
76
+ const id = `sse-diag-${++nextScopeId}`;
77
+ openScopes.set(id, new SSEDiagnosticsRecorder());
78
+ let snapshot;
79
+ return {
80
+ id,
81
+ headers: { [SSE_DIAGNOSTICS_HEADER]: id },
82
+ failures: () => snapshot ?? [...(openScopes.get(id)?.failures ?? [])],
83
+ dispose: () => {
84
+ if (!snapshot) {
85
+ snapshot = [...(openScopes.get(id)?.failures ?? [])];
86
+ openScopes.delete(id);
87
+ }
88
+ },
89
+ };
90
+ }
91
+ /**
92
+ * How many diagnostics scopes are registered right now.
93
+ *
94
+ * @internal Exists so the helpers' own specs can prove that every path out of a request
95
+ * unregisters its scope: a leaked one keeps its records alive for the rest of the process and
96
+ * costs every later request the fast path below.
97
+ */
98
+ export function countOpenSSEDiagnosticsScopes() {
99
+ return openScopes.size;
100
+ }
101
+ /** The recorder a request writes to, or `undefined` when it belongs to no open scope. */
102
+ function resolveRecorder(headers) {
103
+ // Fast path for production traffic: with no scope open the header can't match anything.
104
+ if (openScopes.size === 0) {
105
+ return undefined;
106
+ }
107
+ const header = headers[SSE_DIAGNOSTICS_HEADER];
108
+ const id = Array.isArray(header) ? header[0] : header;
109
+ return id === undefined ? undefined : openScopes.get(id);
110
+ }
111
+ /** Re-validate a payload to recover the structured issues the thrown error only carries as text. */
112
+ function issuesFor(schemaByEventName, eventName, data) {
113
+ const schema = schemaByEventName[eventName];
114
+ if (!schema) {
115
+ return undefined;
116
+ }
117
+ const result = schema.safeParse(data);
118
+ return result.success ? undefined : result.error.issues;
119
+ }
120
+ function messageOf(error) {
121
+ return error instanceof Error ? error.message : String(error);
122
+ }
123
+ /**
124
+ * Whether `error` is `candidate`, or was thrown wrapping it as a `cause`.
125
+ *
126
+ * A handler that rethrows the send error as-is is the common case; one that wraps it in its
127
+ * own error still did not recover from it, so the chain is walked (to a bounded depth, since
128
+ * a `cause` chain can be cyclic).
129
+ */
130
+ function causedBy(error, candidate) {
131
+ let current = error;
132
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; depth++) {
133
+ if (current === candidate) {
134
+ return true;
135
+ }
136
+ current = current instanceof Error ? current.cause : undefined;
137
+ }
138
+ return false;
139
+ }
140
+ /**
141
+ * Instrument an SSE session so that failed sends are recorded against the diagnostics scope
142
+ * the request names, then rethrown unchanged.
143
+ *
144
+ * A no-op unless the request carries {@link SSE_DIAGNOSTICS_HEADER} with the id of a scope
145
+ * open in this process, which only the SSE test helpers ever produce.
146
+ *
147
+ * @param session - The session handed to the handler by `sse.start()`
148
+ * @param schemaByEventName - The contract's merged SSE event schemas, used to recover the
149
+ * Zod issues behind a validation failure
150
+ *
151
+ * @internal Called by `buildApiRoute`; not part of the application-facing API.
152
+ */
153
+ export function attachSSESendDiagnostics(session, schemaByEventName) {
154
+ const recorder = resolveRecorder(session.request.headers);
155
+ if (!recorder) {
156
+ return;
157
+ }
158
+ const originalSend = session.send.bind(session);
159
+ session.send = async (eventName, data, options) => {
160
+ try {
161
+ return await originalSend(eventName, data, options);
162
+ }
163
+ catch (error) {
164
+ recorder.recordSendFailure(schemaByEventName, eventName, data, error);
165
+ throw error;
166
+ }
167
+ };
168
+ const originalSendStream = session.sendStream.bind(session);
169
+ session.sendStream = async (messages) => {
170
+ // The send that throws happens inside `sendStream`, which reports neither the event name
171
+ // nor the payload. Wrapping the source names it: `pending` holds the message handed to
172
+ // the sender, and is cleared when the sender comes back for the next one — which only
173
+ // happens once the previous send resolved. So a rejection with `pending` set is a failed
174
+ // send of that message, and one without is the source itself throwing.
175
+ let pending;
176
+ async function* tracked() {
177
+ for await (const message of messages) {
178
+ pending = message;
179
+ yield message;
180
+ pending = undefined;
181
+ }
182
+ }
183
+ try {
184
+ await originalSendStream(tracked());
185
+ }
186
+ catch (error) {
187
+ if (pending) {
188
+ recorder.recordSendFailure(schemaByEventName, pending.event, pending.data, error);
189
+ }
190
+ else {
191
+ recorder.recordSourceFailure(error);
192
+ }
193
+ throw error;
194
+ }
195
+ };
196
+ }
197
+ /**
198
+ * Wrap a route handler so the diagnostics scope learns whether the route recovered from the
199
+ * sends it could not make.
200
+ *
201
+ * A send that throws is only a reason for a test to fail when nothing caught it: a handler
202
+ * that catches its own failed send and streams a fallback instead produced exactly the
203
+ * response it meant to. Observing how the handler settled is what tells the two apart —
204
+ * {@link SSESendFailure.handled}.
205
+ *
206
+ * A no-op for requests that name no open diagnostics scope: outside a test run this is one
207
+ * `Map.size` check per request on SSE routes.
208
+ *
209
+ * @internal Applied by `buildApiRoute` to SSE-capable routes.
210
+ */
211
+ export function reportSSEHandlerOutcome(handler) {
212
+ const instrumented = function instrumentedHandler(request, reply) {
213
+ const recorder = resolveRecorder(request.headers);
214
+ if (!recorder) {
215
+ return handler.call(this, request, reply);
216
+ }
217
+ let result;
218
+ try {
219
+ result = handler.call(this, request, reply);
220
+ }
221
+ catch (error) {
222
+ // A handler that throws before returning a promise never opened a stream to recover in.
223
+ recorder.settle(error);
224
+ throw error;
225
+ }
226
+ return Promise.resolve(result).then((value) => {
227
+ recorder.settle();
228
+ // Whatever the wrapped handler resolves to is what Fastify sends: pass it through.
229
+ return value;
230
+ }, (error) => {
231
+ recorder.settle(error);
232
+ throw error;
233
+ });
234
+ };
235
+ return instrumented;
236
+ }
237
+ /**
238
+ * The failures the route did not recover from — the ones that truncated the response, and so
239
+ * explain an event a test waited for and never saw.
240
+ *
241
+ * @internal
242
+ */
243
+ export function unhandledSendFailures(failures) {
244
+ return failures.filter((failure) => !failure.handled);
245
+ }
246
+ /**
247
+ * Render recorded failures as the message of the error the test helpers throw.
248
+ *
249
+ * @internal
250
+ */
251
+ export function describeSendFailures(failures) {
252
+ const lines = failures.map((failure) => ` - ${describeSendFailure(failure)}`);
253
+ const subject = failures.length === 1 ? 'failure' : 'failures';
254
+ return `${failures.length} SSE send ${subject} recorded for this request:\n${lines.join('\n')}`;
255
+ }
256
+ function describeSendFailure(failure) {
257
+ const recovered = failure.handled ? ' (caught by the route, which completed the response)' : '';
258
+ if (failure.eventName === undefined) {
259
+ return `the sendStream() source threw before the next event: ${failure.message}${recovered}`;
260
+ }
261
+ const detail = failure.issues
262
+ ? failure.issues
263
+ .map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
264
+ .join('; ')
265
+ : failure.message;
266
+ return `event "${failure.eventName}" was never sent: ${detail}; payload: ${safeStringify(failure.data)}${recovered}`;
267
+ }
268
+ /** JSON for an error message, degrading to `String()` for anything JSON can't take. */
269
+ function safeStringify(value) {
270
+ try {
271
+ return JSON.stringify(value) ?? String(value);
272
+ }
273
+ catch {
274
+ return String(value);
275
+ }
276
+ }
277
+ //# sourceMappingURL=sseSendDiagnostics.js.map