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 +11 -0
- package/README.md +86 -3
- package/dist/lib/api-contracts/apiRouteBuilder.d.ts +1 -1
- package/dist/lib/api-contracts/apiRouteBuilder.js +36 -1
- package/dist/lib/api-contracts/apiRouteBuilder.js.map +1 -1
- package/dist/lib/sse/index.d.ts +1 -0
- package/dist/lib/sse/index.js +1 -0
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/sse/sseSendDiagnostics.d.ts +134 -0
- package/dist/lib/sse/sseSendDiagnostics.js +277 -0
- package/dist/lib/sse/sseSendDiagnostics.js.map +1 -0
- package/dist/lib/testing/apiSseEventValidation.d.ts +40 -0
- package/dist/lib/testing/apiSseEventValidation.js +78 -0
- package/dist/lib/testing/apiSseEventValidation.js.map +1 -0
- package/dist/lib/testing/apiSseHttpHelpers.d.ts +168 -0
- package/dist/lib/testing/apiSseHttpHelpers.js +214 -0
- package/dist/lib/testing/apiSseHttpHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +17 -2
- package/dist/lib/testing/apiSseInjectHelpers.js +227 -56
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/apiSseTestTypes.d.ts +83 -3
- package/dist/lib/testing/index.d.ts +3 -2
- package/dist/lib/testing/index.js +1 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +52 -0
- package/dist/lib/testing/sseHttpClient.js +77 -0
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +8 -2
- package/package.json +1 -1
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** |
|
|
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
|
|
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
|
|
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":"
|
|
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"}
|
package/dist/lib/sse/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/lib/sse/index.js
CHANGED
|
@@ -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
|