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.
- package/CHANGELOG.md +17 -0
- package/README.md +107 -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/sseInjectClient.d.ts +17 -0
- package/dist/lib/testing/sseInjectClient.js +31 -0
- package/dist/lib/testing/sseInjectClient.js.map +1 -1
- package/dist/lib/testing/sseTestTypes.d.ts +36 -2
- 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** |
|
|
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
|
|
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;
|