opinionated-machine 10.0.0 → 10.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +119 -2
- package/dist/lib/sse/SSESessionSpy.d.ts +35 -7
- package/dist/lib/sse/SSESessionSpy.js +30 -1
- package/dist/lib/sse/SSESessionSpy.js.map +1 -1
- package/dist/lib/sse/index.d.ts +1 -1
- package/dist/lib/sse/index.js.map +1 -1
- package/dist/lib/testing/apiSseInjectHelpers.d.ts +64 -0
- package/dist/lib/testing/apiSseInjectHelpers.js +212 -0
- package/dist/lib/testing/apiSseInjectHelpers.js.map +1 -0
- package/dist/lib/testing/apiSseTestTypes.d.ts +202 -0
- package/dist/lib/testing/apiSseTestTypes.js +2 -0
- package/dist/lib/testing/apiSseTestTypes.js.map +1 -0
- package/dist/lib/testing/index.d.ts +4 -1
- package/dist/lib/testing/index.js +2 -0
- package/dist/lib/testing/index.js.map +1 -1
- package/dist/lib/testing/sseHttpClient.d.ts +36 -4
- package/dist/lib/testing/sseHttpClient.js +29 -6
- package/dist/lib/testing/sseHttpClient.js.map +1 -1
- package/dist/lib/testing/sseInjectHelpers.js +1 -12
- package/dist/lib/testing/sseInjectHelpers.js.map +1 -1
- package/dist/lib/testing/sseInjectShared.d.ts +7 -0
- package/dist/lib/testing/sseInjectShared.js +19 -0
- package/dist/lib/testing/sseInjectShared.js.map +1 -0
- package/dist/lib/testing/sseSessionSpyFactory.d.ts +109 -0
- package/dist/lib/testing/sseSessionSpyFactory.js +100 -0
- package/dist/lib/testing/sseSessionSpyFactory.js.map +1 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# opinionated-machine
|
|
2
2
|
|
|
3
|
+
## 10.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 9a9a89d: Add `injectApiSSE`, a contract-typed SSE inject helper for contracts built with `defineApiContract` + `sseResponse`/`sseBody`. The existing `injectSSE`/`injectPayloadSSE` are typed against the legacy `SSEContractDefinition` and reject the newer contract shape. `injectApiSSE` covers every HTTP method from the contract, takes the same params as `injectByApiContract`, resolves `bodyForStatus` schemas from `responsesByStatusCode` (exact → range → `default` precedence), and adds `events()` for events parsed and validated against the contract's SSE schemas, merged across every declared status. The request always asks for `text/event-stream`, so statuses that declare a stream — dual-mode ones included — are excluded from `bodyForStatus`, and `events` is typed `never` for contracts that declare no SSE response.
|
|
8
|
+
|
|
9
|
+
## 10.1.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 45dd012: Add `createSSESessionSpy()` testing factory so `buildApiRoute` routes can use `SSEHttpClient`'s `awaitServerConnection`. It returns a standalone `SSESessionSpy`, `{ onConnect, onClose }` route options to spread into a route with no lifecycle hooks of its own, and a `withSpy()` helper that merges the spy into a route's existing options by chaining rather than replacing its `onConnect` / `onClose`. `awaitServerConnection` now accepts `{ spy }` alongside `{ controller }`, and `SSESessionSpy` is generic over the observed session type, defaulting to the previous one.
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 45dd012: Fix `SSEHttpClient.connect()` leaking the open SSE response when `awaitServerConnection` times out. The caller never received a client handle, so a keep-alive stream stayed open and hung the test's `app.close()`, hiding the original timeout behind a suite-level timeout. A `waitForConnection` timeout now also explains itself when matching connections were registered but had already closed, which is what an `autoClose` session looks like to the spy.
|
|
18
|
+
|
|
3
19
|
## 10.0.0
|
|
4
20
|
|
|
5
21
|
### Major Changes
|
package/README.md
CHANGED
|
@@ -1559,7 +1559,7 @@ describe('NotificationsSSEController', () => {
|
|
|
1559
1559
|
|
|
1560
1560
|
#### Testing autoClose SSE (request-response streaming)
|
|
1561
1561
|
|
|
1562
|
-
Use `SSEInjectClient` or the contract-aware `injectSSE`/`injectPayloadSSE` helpers. No real HTTP server needed - all events are available immediately after the handler completes:
|
|
1562
|
+
Use `SSEInjectClient` or the contract-aware `injectSSE`/`injectPayloadSSE` helpers (`injectApiSSE` for `defineApiContract` contracts). No real HTTP server needed - all events are available immediately after the handler completes:
|
|
1563
1563
|
|
|
1564
1564
|
```ts
|
|
1565
1565
|
import { SSEInjectClient } from 'opinionated-machine'
|
|
@@ -1613,6 +1613,49 @@ it('returns the documented 401 body when unauthenticated', async () => {
|
|
|
1613
1613
|
|
|
1614
1614
|
`bodyForStatus(status)` awaits the response, asserts the actual status matches, JSON-parses the body, and runs it through the Zod schema declared for that status. It throws — with the offending status and a truncated body snippet — if the status doesn't match, the contract declares no schema for that status, the body isn't valid JSON, or Zod parsing fails. The raw `closed` promise is still exposed for callers that want to read `body: string` directly.
|
|
1615
1615
|
|
|
1616
|
+
#### Contracts built with `defineApiContract`: `injectApiSSE`
|
|
1617
|
+
|
|
1618
|
+
`injectSSE` / `injectPayloadSSE` are typed against the legacy `SSEContractDefinition` from `buildSseContract`. For contracts built with the newer `defineApiContract` + `sseResponse` / `sseBody` API, use `injectApiSSE` instead — one function for every method, with `params` in the same shape `injectByApiContract` takes:
|
|
1619
|
+
|
|
1620
|
+
```ts
|
|
1621
|
+
import { defineApiContract, sseResponse } from '@lokalise/api-contracts'
|
|
1622
|
+
import { z } from 'zod/v4'
|
|
1623
|
+
import { injectApiSSE } from 'opinionated-machine'
|
|
1624
|
+
|
|
1625
|
+
const lqaSegmentContract = defineApiContract({
|
|
1626
|
+
visibility: 'internal',
|
|
1627
|
+
method: 'post',
|
|
1628
|
+
summary: 'Perform LQA on a text segment',
|
|
1629
|
+
pathResolver: () => '/v1/content/actions/lqa-text-segment',
|
|
1630
|
+
requestBodySchema: z.object({ segment: z.string() }),
|
|
1631
|
+
responsesByStatusCode: {
|
|
1632
|
+
200: sseResponse({ review: z.object({ score: z.number() }) }),
|
|
1633
|
+
400: z.object({ message: z.string() }),
|
|
1634
|
+
},
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
it('streams the review', async () => {
|
|
1638
|
+
const { events } = injectApiSSE(app, lqaSegmentContract, { body: { segment: 'hello' } })
|
|
1639
|
+
|
|
1640
|
+
// Events are validated against the contract and typed as a union on `event`.
|
|
1641
|
+
for (const event of await events()) {
|
|
1642
|
+
if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
|
|
1643
|
+
}
|
|
1644
|
+
})
|
|
1645
|
+
|
|
1646
|
+
it('returns the documented 400 body for an empty segment', async () => {
|
|
1647
|
+
const { bodyForStatus } = injectApiSSE(app, lqaSegmentContract, { body: { segment: '' } })
|
|
1648
|
+
|
|
1649
|
+
// `body` is typed as `{ message: string }` — the contract's 400 schema.
|
|
1650
|
+
const body = await bodyForStatus(400)
|
|
1651
|
+
expect(body.message).toBe('segment must not be empty')
|
|
1652
|
+
})
|
|
1653
|
+
```
|
|
1654
|
+
|
|
1655
|
+
`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
|
+
|
|
1657
|
+
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
|
+
|
|
1616
1659
|
### SSESessionSpy API
|
|
1617
1660
|
|
|
1618
1661
|
The `connectionSpy` is available when `isTestMode: true` is passed to `asSSEControllerClass`:
|
|
@@ -1642,6 +1685,77 @@ controller.connectionSpy.clear()
|
|
|
1642
1685
|
|
|
1643
1686
|
**Note**: `waitForConnection` tracks "claimed" sessions internally. Each call returns a unique unclaimed session, allowing sequential waits for the same URL path without returning the same session twice. This is used internally by `SSEHttpClient.connect()` with `awaitServerConnection`.
|
|
1644
1687
|
|
|
1688
|
+
#### Standalone spy for `buildApiRoute` routes
|
|
1689
|
+
|
|
1690
|
+
`connectionSpy` only exists on `AbstractSSEController`. For routes built with `buildApiRoute` there is no controller to read it off, so `createSSESessionSpy()` returns a spy plus the `onConnect` / `onClose` route hooks that drive it:
|
|
1691
|
+
|
|
1692
|
+
```ts
|
|
1693
|
+
import { createSSESessionSpy, SSEHttpClient } from 'opinionated-machine'
|
|
1694
|
+
|
|
1695
|
+
const { spy, routeOptions } = createSSESessionSpy()
|
|
1696
|
+
|
|
1697
|
+
// in the app under test — `routeOptions` is just `{ onConnect, onClose }`
|
|
1698
|
+
app.route(buildApiRoute(streamContract, handler, { ...routeOptions }))
|
|
1699
|
+
|
|
1700
|
+
// in the test — same race-free connect as with a controller
|
|
1701
|
+
const { client, serverConnection } = await SSEHttpClient.connect(baseUrl, '/api/stream', {
|
|
1702
|
+
awaitServerConnection: { spy },
|
|
1703
|
+
})
|
|
1704
|
+
await serverConnection.send('ping', { seq: 1 })
|
|
1705
|
+
```
|
|
1706
|
+
|
|
1707
|
+
`awaitServerConnection` accepts either `{ controller }` or `{ spy }`; both wait for the server-side handler to finish registering the session before `connect()` resolves.
|
|
1708
|
+
|
|
1709
|
+
**The hooks have to reach the `buildApiRoute()` call itself.** `buildApiRoute` captures `onConnect` / `onClose` when it builds the handler, so assigning them to the `RouteOptions` object it returns does nothing — the connect would just time out:
|
|
1710
|
+
|
|
1711
|
+
```ts
|
|
1712
|
+
// Does NOT work: the route was already built without the hooks
|
|
1713
|
+
const route = controller.routes.streamUpdates
|
|
1714
|
+
Object.assign(route, routeOptions) // no-op as far as SSE lifecycle hooks go
|
|
1715
|
+
```
|
|
1716
|
+
|
|
1717
|
+
A route owned by a controller therefore has to accept SSE route options for a test to be able to spy on it. Take them as a dependency and pass them through:
|
|
1718
|
+
|
|
1719
|
+
```ts
|
|
1720
|
+
export class StreamController extends AbstractApiController<typeof StreamController.contracts> {
|
|
1721
|
+
static contracts = { streamUpdates: streamUpdatesContract } as const
|
|
1722
|
+
|
|
1723
|
+
readonly routes: Record<keyof typeof StreamController.contracts, RouteOptions>
|
|
1724
|
+
|
|
1725
|
+
constructor({ sseRouteOptions }: StreamControllerDependencies) {
|
|
1726
|
+
super()
|
|
1727
|
+
this.routes = {
|
|
1728
|
+
streamUpdates: buildApiRoute(
|
|
1729
|
+
StreamController.contracts.streamUpdates,
|
|
1730
|
+
this.streamUpdates,
|
|
1731
|
+
sseRouteOptions,
|
|
1732
|
+
),
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
// production wiring registers no hooks; the test registers the spy's
|
|
1738
|
+
const { spy, routeOptions } = createSSESessionSpy()
|
|
1739
|
+
const controller = new StreamController({ sseRouteOptions: routeOptions })
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
If the route already declares lifecycle hooks of its own, use `withSpy()` rather than spreading `routeOptions` over them — spreading silently drops one side or the other, depending on the order. `withSpy()` keeps the route's hook (it runs first, and the spy is notified once it settles) and passes every other option through:
|
|
1743
|
+
|
|
1744
|
+
```ts
|
|
1745
|
+
const { spy, withSpy } = createSSESessionSpy()
|
|
1746
|
+
|
|
1747
|
+
app.route(
|
|
1748
|
+
buildApiRoute(streamContract, handler, withSpy({
|
|
1749
|
+
heartbeat: false,
|
|
1750
|
+
onConnect: (connection) => subscriptions.add(connection.id),
|
|
1751
|
+
})),
|
|
1752
|
+
)
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
**`keepAlive` sessions only.** An `autoClose` route closes its session as the handler returns, and `waitForConnection` only hands back sessions that are still open, since a closed one can no longer be sent events. Awaiting such a connection races and usually times out (with an error that says as much) — omit `awaitServerConnection` for `autoClose` routes and assert on the events the client received instead.
|
|
1756
|
+
|
|
1757
|
+
The spy is typed for the `SSESession` of `@lokalise/fastify-api-contracts`, which is what `buildApiRoute` passes to its hooks. To wire the same spy into a `buildFastifyRoute`-built route, parameterize it with this package's session type: `createSSESessionSpy<SSESession>()`.
|
|
1758
|
+
|
|
1645
1759
|
### Session Monitoring
|
|
1646
1760
|
|
|
1647
1761
|
Controllers have access to utility methods for monitoring sessions:
|
|
@@ -2127,7 +2241,7 @@ The library provides utilities for testing SSE endpoints.
|
|
|
2127
2241
|
| `autoClose` | `SSEInjectClient` or `injectSSE`/`injectPayloadSSE` | Handler completes and closes connection; all events available at once |
|
|
2128
2242
|
| `keepAlive` | `SSEHttpClient` | Connection stays open; events arrive incrementally via server push |
|
|
2129
2243
|
|
|
2130
|
-
`SSEInjectClient` and `injectSSE`/`injectPayloadSSE` do the same thing (Fastify inject), but `injectSSE`/`injectPayloadSSE` provide type safety via contracts while `SSEInjectClient` works with raw URLs.
|
|
2244
|
+
`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`.
|
|
2131
2245
|
|
|
2132
2246
|
#### Detailed Comparison
|
|
2133
2247
|
|
|
@@ -2240,6 +2354,9 @@ Omit `awaitServerConnection` only in these cases:
|
|
|
2240
2354
|
- Testing against external SSE endpoints (not your own controller)
|
|
2241
2355
|
- When `isTestMode: false` (connectionSpy not available)
|
|
2242
2356
|
- Simple smoke tests that only verify response headers/status without sending server events
|
|
2357
|
+
- Routes whose handler starts an `autoClose` session: it closes as the handler returns, so there is no live session left to wait for — assert on the received events instead
|
|
2358
|
+
|
|
2359
|
+
For `keepAlive` routes built with `buildApiRoute` (no controller, so no `connectionSpy`), pass a standalone spy instead of dropping the option: `awaitServerConnection: { spy }`, with the spy from [`createSSESessionSpy()`](#standalone-spy-for-buildapiroute-routes).
|
|
2243
2360
|
|
|
2244
2361
|
**Consequence**: Without `awaitServerConnection`, `connect()` resolves as soon as HTTP headers are received. Server-side connection registration may not have completed yet, so you cannot reliably send events from the server immediately after `connect()` returns.
|
|
2245
2362
|
|
|
@@ -1,21 +1,39 @@
|
|
|
1
1
|
import type { SSESession } from './AbstractSSEController.ts';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Minimal shape of an SSE session the spy needs in order to track it.
|
|
4
|
+
*
|
|
5
|
+
* Both the `SSESession` produced by this package's own SSE routes and the one
|
|
6
|
+
* produced by `@lokalise/fastify-api-contracts` (used by `buildApiRoute`)
|
|
7
|
+
* satisfy it, which lets a single spy be attached to either route style.
|
|
8
|
+
*/
|
|
9
|
+
export type SpiedSSESession = {
|
|
10
|
+
id: string;
|
|
11
|
+
request: {
|
|
12
|
+
url: string;
|
|
13
|
+
};
|
|
14
|
+
};
|
|
15
|
+
export type SSESessionEvent<TSession extends SpiedSSESession = SSESession> = {
|
|
3
16
|
type: 'connect' | 'disconnect';
|
|
4
17
|
connectionId: string;
|
|
5
|
-
connection?:
|
|
18
|
+
connection?: TSession;
|
|
6
19
|
};
|
|
7
20
|
/**
|
|
8
21
|
* Connection spy for testing SSE controllers.
|
|
9
22
|
* Tracks connection and disconnection events separately.
|
|
23
|
+
*
|
|
24
|
+
* @template TSession - The session type the spy observes. Defaults to this
|
|
25
|
+
* package's `SSESession`, which is what `AbstractSSEController` reports.
|
|
26
|
+
* Tests wiring the spy to `buildApiRoute` routes get it parameterized with
|
|
27
|
+
* the contracts package session type via `createSSESessionSpy()`.
|
|
10
28
|
*/
|
|
11
|
-
export declare class SSESessionSpy {
|
|
29
|
+
export declare class SSESessionSpy<TSession extends SpiedSSESession = SSESession> {
|
|
12
30
|
private events;
|
|
13
31
|
private activeConnections;
|
|
14
32
|
private claimedConnections;
|
|
15
33
|
private connectionWaiters;
|
|
16
34
|
private disconnectionWaiters;
|
|
17
35
|
/** @internal Called when a connection is established */
|
|
18
|
-
addConnection(connection:
|
|
36
|
+
addConnection(connection: TSession): void;
|
|
19
37
|
/** @internal Called when a connection is closed */
|
|
20
38
|
addDisconnection(connectionId: string): void;
|
|
21
39
|
/**
|
|
@@ -40,8 +58,18 @@ export declare class SSESessionSpy {
|
|
|
40
58
|
*/
|
|
41
59
|
waitForConnection(options?: {
|
|
42
60
|
timeout?: number;
|
|
43
|
-
predicate?: (connection:
|
|
44
|
-
}): Promise<
|
|
61
|
+
predicate?: (connection: TSession) => boolean;
|
|
62
|
+
}): Promise<TSession>;
|
|
63
|
+
/**
|
|
64
|
+
* Explain a timeout that had matching connections which were already gone.
|
|
65
|
+
*
|
|
66
|
+
* `waitForConnection` only hands back sessions that are still active, since a
|
|
67
|
+
* closed session can no longer be sent events. A route that streams with
|
|
68
|
+
* `autoClose` closes its session as the handler returns, so its connection is
|
|
69
|
+
* registered and closed before a test can claim it — without this hint that
|
|
70
|
+
* shows up as a bare timeout with no indication of what went wrong.
|
|
71
|
+
*/
|
|
72
|
+
private describeMissedConnections;
|
|
45
73
|
/** Wait for a specific connection to disconnect */
|
|
46
74
|
waitForDisconnection(connectionId: string, options?: {
|
|
47
75
|
timeout?: number;
|
|
@@ -49,7 +77,7 @@ export declare class SSESessionSpy {
|
|
|
49
77
|
/** Check if a connection is currently active */
|
|
50
78
|
isConnected(connectionId: string): boolean;
|
|
51
79
|
/** Get all connection events in order, optionally filtered by connectionId */
|
|
52
|
-
getEvents(connectionId?: string): SSESessionEvent[];
|
|
80
|
+
getEvents(connectionId?: string): SSESessionEvent<TSession>[];
|
|
53
81
|
/** Clear all events and cancel pending waiters */
|
|
54
82
|
clear(): void;
|
|
55
83
|
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Connection spy for testing SSE controllers.
|
|
3
3
|
* Tracks connection and disconnection events separately.
|
|
4
|
+
*
|
|
5
|
+
* @template TSession - The session type the spy observes. Defaults to this
|
|
6
|
+
* package's `SSESession`, which is what `AbstractSSEController` reports.
|
|
7
|
+
* Tests wiring the spy to `buildApiRoute` routes get it parameterized with
|
|
8
|
+
* the contracts package session type via `createSSESessionSpy()`.
|
|
4
9
|
*/
|
|
5
10
|
export class SSESessionSpy {
|
|
6
11
|
events = [];
|
|
@@ -75,11 +80,35 @@ export class SSESessionSpy {
|
|
|
75
80
|
if (index !== -1) {
|
|
76
81
|
this.connectionWaiters.splice(index, 1);
|
|
77
82
|
}
|
|
78
|
-
reject(new Error(`Timeout waiting for connection after ${timeout}ms`));
|
|
83
|
+
reject(new Error(`Timeout waiting for connection after ${timeout}ms${this.describeMissedConnections(predicate)}`));
|
|
79
84
|
}, timeout);
|
|
80
85
|
this.connectionWaiters.push({ resolve, reject, timeoutId, predicate });
|
|
81
86
|
});
|
|
82
87
|
}
|
|
88
|
+
/**
|
|
89
|
+
* Explain a timeout that had matching connections which were already gone.
|
|
90
|
+
*
|
|
91
|
+
* `waitForConnection` only hands back sessions that are still active, since a
|
|
92
|
+
* closed session can no longer be sent events. A route that streams with
|
|
93
|
+
* `autoClose` closes its session as the handler returns, so its connection is
|
|
94
|
+
* registered and closed before a test can claim it — without this hint that
|
|
95
|
+
* shows up as a bare timeout with no indication of what went wrong.
|
|
96
|
+
*/
|
|
97
|
+
describeMissedConnections(predicate) {
|
|
98
|
+
const missed = this.events.filter((e) => e.type === 'connect' &&
|
|
99
|
+
e.connection &&
|
|
100
|
+
!this.activeConnections.has(e.connection.id) &&
|
|
101
|
+
(!predicate || predicate(e.connection)));
|
|
102
|
+
if (missed.length === 0) {
|
|
103
|
+
return '';
|
|
104
|
+
}
|
|
105
|
+
const ids = missed.map((e) => e.connectionId).join(', ');
|
|
106
|
+
return (`. ${missed.length} matching connection(s) were registered but had already closed: ${ids}. ` +
|
|
107
|
+
'Only sessions that are still open can be awaited, since a closed one can no longer be sent ' +
|
|
108
|
+
'events. A session started with `autoClose` closes as its handler returns, so a route that ' +
|
|
109
|
+
'uses one cannot be awaited at all - omit `awaitServerConnection` there and assert on the ' +
|
|
110
|
+
'events the client received instead.');
|
|
111
|
+
}
|
|
83
112
|
/** Wait for a specific connection to disconnect */
|
|
84
113
|
waitForDisconnection(connectionId, options) {
|
|
85
114
|
const timeout = options?.timeout ?? 5000;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SSESessionSpy.js","sourceRoot":"","sources":["../../../lib/sse/SSESessionSpy.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"SSESessionSpy.js","sourceRoot":"","sources":["../../../lib/sse/SSESessionSpy.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/sse/index.d.ts
CHANGED
|
@@ -3,6 +3,6 @@ export { type BuildFastifySSERoutesReturnType, buildFastifyRoute, buildHandler,
|
|
|
3
3
|
export { AbstractSSEController, type SSEControllerConfig, type SSEEventSender, type SSELogger, type SSEMessage, } from './AbstractSSEController.js';
|
|
4
4
|
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
|
-
export { type SSESessionEvent, SSESessionSpy } from './SSESessionSpy.js';
|
|
6
|
+
export { type SpiedSSESession, type SSESessionEvent, SSESessionSpy } from './SSESessionSpy.js';
|
|
7
7
|
export { type ParsedSSEEvent, type ParseSSEBufferResult, parseSSEBuffer, parseSSEEvents, } from './sseParser.js';
|
|
8
8
|
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';
|
|
@@ -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,
|
|
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"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import type { AnyFastifyInstance } from './AnyFastifyInstance.ts';
|
|
3
|
+
import type { ApiSSEEventReader, InjectApiSSEParams, InjectApiSSEResult } from './apiSseTestTypes.ts';
|
|
4
|
+
import type { SSEResponse } from './sseTestTypes.ts';
|
|
5
|
+
/**
|
|
6
|
+
* Build a `bodyForStatus` accessor bound to one `injectApiSSE` call.
|
|
7
|
+
*
|
|
8
|
+
* @internal Exported only for unit testing — not part of the public API
|
|
9
|
+
* (the testing barrel re-exports `injectApiSSE` by name).
|
|
10
|
+
*/
|
|
11
|
+
export declare function bindApiBodyForStatus<Contract extends ApiContract>(contract: Contract, closed: Promise<SSEResponse>): InjectApiSSEResult<Contract>['bodyForStatus'];
|
|
12
|
+
/**
|
|
13
|
+
* Build an `events` accessor bound to one `injectApiSSE` call: parses the SSE body and
|
|
14
|
+
* validates every event against the contract's `sseBody` schemas.
|
|
15
|
+
*
|
|
16
|
+
* @internal Exported only for unit testing — not part of the public API.
|
|
17
|
+
*/
|
|
18
|
+
export declare function bindApiEvents<Contract extends ApiContract>(contract: Contract, closed: Promise<SSEResponse>): ApiSSEEventReader<Contract>;
|
|
19
|
+
/**
|
|
20
|
+
* Inject an SSE request using a contract built with `defineApiContract` + `sseResponse` /
|
|
21
|
+
* `sseBody` (the newer `@lokalise/api-contracts` API).
|
|
22
|
+
*
|
|
23
|
+
* The `defineApiContract` counterpart of `injectSSE` / `injectPayloadSSE`, which are typed
|
|
24
|
+
* against the legacy `SSEContractDefinition`. One function covers every method: the HTTP verb
|
|
25
|
+
* comes from the contract, and `params` (`pathParams` / `queryParams` / `headers` / `body` /
|
|
26
|
+
* `pathPrefix`) is the same shape `injectByApiContract` takes, so a body is required exactly
|
|
27
|
+
* when the contract declares `requestBodySchema`.
|
|
28
|
+
*
|
|
29
|
+
* The request always carries `accept: text/event-stream` (a caller-supplied `accept` still
|
|
30
|
+
* wins), so a status declaring a stream answers with it — dual-mode statuses included. Those
|
|
31
|
+
* statuses expose no JSON body through `bodyForStatus`; read them with `events()`, or use
|
|
32
|
+
* `injectByApiContract` when you want the JSON side.
|
|
33
|
+
*
|
|
34
|
+
* Best for SSE endpoints that complete — Fastify's `inject()` waits for the whole response.
|
|
35
|
+
* For long-lived connections, use `SSEHttpClient` against a real HTTP server.
|
|
36
|
+
*
|
|
37
|
+
* @param app - Fastify instance
|
|
38
|
+
* @param contract - Contract built with `defineApiContract`
|
|
39
|
+
* @param params - Request params derived from the contract
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```typescript
|
|
43
|
+
* const { closed, bodyForStatus, events } = injectApiSSE(app, lqaTextSegmentContract, {
|
|
44
|
+
* body: { segment: 'hello' },
|
|
45
|
+
* })
|
|
46
|
+
*
|
|
47
|
+
* // Typed events, validated against the contract's sseResponse schemas
|
|
48
|
+
* for (const event of await events()) {
|
|
49
|
+
* if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
|
|
50
|
+
* }
|
|
51
|
+
*
|
|
52
|
+
* // Or the raw body, for assertions the typed accessors don't cover
|
|
53
|
+
* expect((await closed).statusCode).toBe(200)
|
|
54
|
+
* ```
|
|
55
|
+
*
|
|
56
|
+
* @example
|
|
57
|
+
* ```typescript
|
|
58
|
+
* // A documented pre-stream error response, typed by the contract's 400 schema
|
|
59
|
+
* const { bodyForStatus } = injectApiSSE(app, lqaTextSegmentContract, { body: { segment: '' } })
|
|
60
|
+
* const error = await bodyForStatus(400)
|
|
61
|
+
* expect(error.message).toBe('segment must not be empty')
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
export declare function injectApiSSE<const Contract extends ApiContract>(app: AnyFastifyInstance, contract: Contract, params: InjectApiSSEParams<Contract>): InjectApiSSEResult<Contract>;
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { getSseSchemaByEventName, resolveResponseEntry, } from '@lokalise/api-contracts';
|
|
2
|
+
import { injectByApiContract } from '@lokalise/fastify-api-contracts';
|
|
3
|
+
import { parseSSEEvents } from "../sse/sseParser.js";
|
|
4
|
+
import { truncateBody } from "./sseInjectShared.js";
|
|
5
|
+
const SSE_CONTENT_TYPE = 'text/event-stream';
|
|
6
|
+
/** Read a response header that light-my-request may expose as an array. */
|
|
7
|
+
function readHeader(value) {
|
|
8
|
+
return Array.isArray(value) ? value[0] : value;
|
|
9
|
+
}
|
|
10
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
11
|
+
function mediaTypeOf(contentType) {
|
|
12
|
+
return contentType?.split(';')[0]?.trim().toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
const STATUS_RANGE_KEYS = ['1xx', '2xx', '3xx', '4xx', '5xx'];
|
|
15
|
+
/**
|
|
16
|
+
* The `responsesByStatusCode` key that serves a status, following the same
|
|
17
|
+
* exact → range → `'default'` precedence as `resolveResponseEntry`.
|
|
18
|
+
*
|
|
19
|
+
* Diagnostics only. `resolveResponseEntry` collapses "no entry for this status" and "an entry
|
|
20
|
+
* exists but none of its content-map descriptors matched the response's content-type" into a
|
|
21
|
+
* single `null`; this tells the two apart so the error names the actual problem.
|
|
22
|
+
*/
|
|
23
|
+
function findResponseKeyForStatus(responsesByStatusCode, statusCode) {
|
|
24
|
+
if (responsesByStatusCode[statusCode]) {
|
|
25
|
+
return String(statusCode);
|
|
26
|
+
}
|
|
27
|
+
const rangeKey = STATUS_RANGE_KEYS[Math.floor(statusCode / 100) - 1];
|
|
28
|
+
if (rangeKey && responsesByStatusCode[rangeKey]) {
|
|
29
|
+
return rangeKey;
|
|
30
|
+
}
|
|
31
|
+
return responsesByStatusCode.default ? 'default' : undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The JSON schema the contract declares for the status a response actually carries, or a
|
|
35
|
+
* thrown error naming why there isn't one.
|
|
36
|
+
*
|
|
37
|
+
* Resolution follows the same exact → range → `'default'` precedence (and content-type
|
|
38
|
+
* matching) the contract client uses, so a stream on one status and JSON bodies on the others
|
|
39
|
+
* resolve independently.
|
|
40
|
+
*/
|
|
41
|
+
function resolveJsonSchemaForStatus(responsesByStatusCode, statusCode, res) {
|
|
42
|
+
const contentType = readHeader(res.headers['content-type']);
|
|
43
|
+
// Non-strict resolution: a response without a content-type still resolves to the entry's
|
|
44
|
+
// declared kind, which keeps hand-rolled test handlers working.
|
|
45
|
+
const resolved = resolveResponseEntry(responsesByStatusCode, statusCode, contentType, false);
|
|
46
|
+
if (!resolved) {
|
|
47
|
+
const declaredKey = findResponseKeyForStatus(responsesByStatusCode, statusCode);
|
|
48
|
+
throw new Error(declaredKey === undefined
|
|
49
|
+
? `bodyForStatus(${statusCode}) — no response declared for status ${statusCode} in contract.responsesByStatusCode`
|
|
50
|
+
: `bodyForStatus(${statusCode}) — the '${declaredKey}' entry of contract.responsesByStatusCode declares no body for content-type '${mediaTypeOf(contentType) ?? 'absent'}'; body: ${truncateBody(res.body)}`);
|
|
51
|
+
}
|
|
52
|
+
if (resolved.kind !== 'json') {
|
|
53
|
+
// A dual-mode status lands here: `injectApiSSE` asks for the stream, so that is what the
|
|
54
|
+
// status resolved to. The type layer rules this out, so reaching it means a cast.
|
|
55
|
+
const hint = resolved.kind === 'sse'
|
|
56
|
+
? ` — injectApiSSE requests '${SSE_CONTENT_TYPE}', so a status declaring a stream always answers with it; read it with events()`
|
|
57
|
+
: '';
|
|
58
|
+
throw new Error(`bodyForStatus(${statusCode}) — the contract declares a '${resolved.kind}' response for status ${statusCode}, not a JSON body${hint}`);
|
|
59
|
+
}
|
|
60
|
+
return resolved.schema;
|
|
61
|
+
}
|
|
62
|
+
/** JSON-parse and schema-validate a response body, reporting either failure in context. */
|
|
63
|
+
function parseJsonBody(schema, statusCode, body) {
|
|
64
|
+
let parsedJson;
|
|
65
|
+
try {
|
|
66
|
+
parsedJson = JSON.parse(body);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
throw new Error(`bodyForStatus(${statusCode}) — body is not valid JSON: ${err.message}; body: ${truncateBody(body)}`);
|
|
70
|
+
}
|
|
71
|
+
const parsed = schema.safeParse(parsedJson);
|
|
72
|
+
if (!parsed.success) {
|
|
73
|
+
throw new Error(`bodyForStatus(${statusCode}) — body does not match the declared schema: ${parsed.error.message}; body: ${truncateBody(body)}`);
|
|
74
|
+
}
|
|
75
|
+
return parsed.data;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build a `bodyForStatus` accessor bound to one `injectApiSSE` call.
|
|
79
|
+
*
|
|
80
|
+
* @internal Exported only for unit testing — not part of the public API
|
|
81
|
+
* (the testing barrel re-exports `injectApiSSE` by name).
|
|
82
|
+
*/
|
|
83
|
+
export function bindApiBodyForStatus(contract, closed) {
|
|
84
|
+
// A generic arrow function can't be assigned directly to the generic method signature,
|
|
85
|
+
// so the whole closure is cast once. Keep in sync with `InjectApiSSEResult['bodyForStatus']`.
|
|
86
|
+
return (async (statusCode) => {
|
|
87
|
+
const res = await closed;
|
|
88
|
+
const expected = statusCode;
|
|
89
|
+
if (res.statusCode !== expected) {
|
|
90
|
+
throw new Error(`bodyForStatus(${expected}) — actual status ${res.statusCode}, body: ${truncateBody(res.body)}`);
|
|
91
|
+
}
|
|
92
|
+
const schema = resolveJsonSchemaForStatus(contract.responsesByStatusCode, expected, res);
|
|
93
|
+
return parseJsonBody(schema, expected, res.body);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build an `events` accessor bound to one `injectApiSSE` call: parses the SSE body and
|
|
98
|
+
* validates every event against the contract's `sseBody` schemas.
|
|
99
|
+
*
|
|
100
|
+
* @internal Exported only for unit testing — not part of the public API.
|
|
101
|
+
*/
|
|
102
|
+
export function bindApiEvents(contract, closed) {
|
|
103
|
+
return async () => {
|
|
104
|
+
const res = await closed;
|
|
105
|
+
// Merges the SSE schemas of every declared status, not just the successful ones.
|
|
106
|
+
const schemaByEventName = getSseSchemaByEventName(contract);
|
|
107
|
+
if (!schemaByEventName) {
|
|
108
|
+
throw new Error('events() — the contract declares no SSE response');
|
|
109
|
+
}
|
|
110
|
+
const contentType = mediaTypeOf(readHeader(res.headers['content-type']));
|
|
111
|
+
if (contentType !== SSE_CONTENT_TYPE) {
|
|
112
|
+
throw new Error(`events() — response is not an SSE stream (status ${res.statusCode}, content-type ${contentType ?? 'absent'}); use bodyForStatus(${res.statusCode}) for declared error responses. Body: ${truncateBody(res.body)}`);
|
|
113
|
+
}
|
|
114
|
+
return parseSSEEvents(res.body).map((event) => {
|
|
115
|
+
// An SSE event without an `event:` field is a `message` event per the spec.
|
|
116
|
+
const name = event.event ?? 'message';
|
|
117
|
+
const schema = schemaByEventName[name];
|
|
118
|
+
if (!schema) {
|
|
119
|
+
throw new Error(`events() — the contract declares no schema for event "${name}"`);
|
|
120
|
+
}
|
|
121
|
+
let parsedJson;
|
|
122
|
+
try {
|
|
123
|
+
parsedJson = JSON.parse(event.data);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new Error(`events() — data of event "${name}" is not valid JSON: ${err.message}; data: ${truncateBody(event.data)}`);
|
|
127
|
+
}
|
|
128
|
+
const parsed = schema.safeParse(parsedJson);
|
|
129
|
+
if (!parsed.success) {
|
|
130
|
+
throw new Error(`events() — data of event "${name}" does not match the declared schema: ${parsed.error.message}; data: ${truncateBody(event.data)}`);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
...(event.id !== undefined && { id: event.id }),
|
|
134
|
+
...(event.retry !== undefined && { retry: event.retry }),
|
|
135
|
+
event: name,
|
|
136
|
+
data: parsed.data,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Inject an SSE request using a contract built with `defineApiContract` + `sseResponse` /
|
|
143
|
+
* `sseBody` (the newer `@lokalise/api-contracts` API).
|
|
144
|
+
*
|
|
145
|
+
* The `defineApiContract` counterpart of `injectSSE` / `injectPayloadSSE`, which are typed
|
|
146
|
+
* against the legacy `SSEContractDefinition`. One function covers every method: the HTTP verb
|
|
147
|
+
* comes from the contract, and `params` (`pathParams` / `queryParams` / `headers` / `body` /
|
|
148
|
+
* `pathPrefix`) is the same shape `injectByApiContract` takes, so a body is required exactly
|
|
149
|
+
* when the contract declares `requestBodySchema`.
|
|
150
|
+
*
|
|
151
|
+
* The request always carries `accept: text/event-stream` (a caller-supplied `accept` still
|
|
152
|
+
* wins), so a status declaring a stream answers with it — dual-mode statuses included. Those
|
|
153
|
+
* statuses expose no JSON body through `bodyForStatus`; read them with `events()`, or use
|
|
154
|
+
* `injectByApiContract` when you want the JSON side.
|
|
155
|
+
*
|
|
156
|
+
* Best for SSE endpoints that complete — Fastify's `inject()` waits for the whole response.
|
|
157
|
+
* For long-lived connections, use `SSEHttpClient` against a real HTTP server.
|
|
158
|
+
*
|
|
159
|
+
* @param app - Fastify instance
|
|
160
|
+
* @param contract - Contract built with `defineApiContract`
|
|
161
|
+
* @param params - Request params derived from the contract
|
|
162
|
+
*
|
|
163
|
+
* @example
|
|
164
|
+
* ```typescript
|
|
165
|
+
* const { closed, bodyForStatus, events } = injectApiSSE(app, lqaTextSegmentContract, {
|
|
166
|
+
* body: { segment: 'hello' },
|
|
167
|
+
* })
|
|
168
|
+
*
|
|
169
|
+
* // Typed events, validated against the contract's sseResponse schemas
|
|
170
|
+
* for (const event of await events()) {
|
|
171
|
+
* if (event.event === 'review') expect(event.data.score).toBeGreaterThan(0)
|
|
172
|
+
* }
|
|
173
|
+
*
|
|
174
|
+
* // Or the raw body, for assertions the typed accessors don't cover
|
|
175
|
+
* expect((await closed).statusCode).toBe(200)
|
|
176
|
+
* ```
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```typescript
|
|
180
|
+
* // A documented pre-stream error response, typed by the contract's 400 schema
|
|
181
|
+
* const { bodyForStatus } = injectApiSSE(app, lqaTextSegmentContract, { body: { segment: '' } })
|
|
182
|
+
* const error = await bodyForStatus(400)
|
|
183
|
+
* expect(error.message).toBe('segment must not be empty')
|
|
184
|
+
* ```
|
|
185
|
+
*/
|
|
186
|
+
export function injectApiSSE(app, contract, params) {
|
|
187
|
+
// biome-ignore lint/suspicious/noExplicitAny: params shape depends on the contract
|
|
188
|
+
const requestParams = params;
|
|
189
|
+
const closed = injectByApiContract(app, contract, {
|
|
190
|
+
...requestParams,
|
|
191
|
+
// `accept` first so an explicit caller header still wins; headers may be a factory,
|
|
192
|
+
// which `injectByApiContract` resolves for us — resolve the caller's here too.
|
|
193
|
+
headers: async () => ({
|
|
194
|
+
accept: SSE_CONTENT_TYPE,
|
|
195
|
+
...(typeof requestParams.headers === 'function'
|
|
196
|
+
? await requestParams.headers()
|
|
197
|
+
: requestParams.headers),
|
|
198
|
+
}),
|
|
199
|
+
}).then((res) => ({
|
|
200
|
+
statusCode: res.statusCode,
|
|
201
|
+
headers: res.headers,
|
|
202
|
+
body: res.body,
|
|
203
|
+
}));
|
|
204
|
+
return {
|
|
205
|
+
closed,
|
|
206
|
+
bodyForStatus: bindApiBodyForStatus(contract, closed),
|
|
207
|
+
// `events` is typed `never` for contracts that declare no SSE response, which no concrete
|
|
208
|
+
// function satisfies — the binder returns the callable form and it is narrowed here.
|
|
209
|
+
events: bindApiEvents(contract, closed),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
//# sourceMappingURL=apiSseInjectHelpers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseInjectHelpers.js","sourceRoot":"","sources":["../../../lib/testing/apiSseInjectHelpers.ts"],"names":[],"mappings":"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"}
|