opinionated-machine 10.0.0 → 10.3.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.
@@ -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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"}
@@ -0,0 +1,202 @@
1
+ import type { ApiContract, ClientErrorHttpStatusCode, ExpandStatusRangeKey, HttpStatusCode, HttpStatusCodeRange, InformationalHttpStatusCode, RedirectionHttpStatusCode, ServerErrorHttpStatusCode, SuccessfulHttpStatusCode } from '@lokalise/api-contracts';
2
+ import type { InjectByApiContractParams } from '@lokalise/fastify-api-contracts';
3
+ import type { z } from 'zod';
4
+ import type { SSEResponse } from './sseTestTypes.ts';
5
+ /**
6
+ * Request params for {@link injectApiSSE}, derived from a `defineApiContract` contract.
7
+ *
8
+ * Identical to the params of `injectByApiContract` from
9
+ * `@lokalise/fastify-api-contracts` — `pathParams`, `body`, `queryParams` and
10
+ * `headers` are each required only when the contract declares the matching
11
+ * request schema, `headers` also accepts a (sync or async) factory, and
12
+ * `pathPrefix` is always optional.
13
+ */
14
+ export type InjectApiSSEParams<Contract extends ApiContract> = InjectByApiContractParams<Contract>;
15
+ /** The `responsesByStatusCode` map of a contract. */
16
+ type Responses<Contract extends ApiContract> = Contract['responsesByStatusCode'];
17
+ /** The concrete status codes a contract declares exactly (non-wildcard keys). */
18
+ type ExactStatusCodes<Contract extends ApiContract> = keyof Responses<Contract> & HttpStatusCode;
19
+ /** Status codes covered by any range key (e.g. `'2xx'`, `'4xx'`) the contract declares. */
20
+ type RangeStatusCodes<Contract extends ApiContract> = {
21
+ [Key in keyof Responses<Contract> & HttpStatusCodeRange]: ExpandStatusRangeKey<Key>;
22
+ }[keyof Responses<Contract> & HttpStatusCodeRange];
23
+ /**
24
+ * Maps a `responsesByStatusCode` key to the statuses it covers, mirroring the runtime
25
+ * lookup precedence (exact → range → `'default'`): a concrete key stays as-is; a range key
26
+ * expands to its status class minus the exactly-declared codes; `'default'` expands to every
27
+ * status not covered by an exact or range key.
28
+ */
29
+ type StatusesForKey<Contract extends ApiContract, Key> = Key extends 'default' ? Exclude<HttpStatusCode, ExactStatusCodes<Contract> | RangeStatusCodes<Contract>> : Key extends HttpStatusCodeRange ? Exclude<ExpandStatusRangeKey<Key>, ExactStatusCodes<Contract>> : Key;
30
+ /** The range key a concrete status code falls into. */
31
+ type RangeKeyOf<Status extends HttpStatusCode> = Status extends InformationalHttpStatusCode ? '1xx' : Status extends SuccessfulHttpStatusCode ? '2xx' : Status extends RedirectionHttpStatusCode ? '3xx' : Status extends ClientErrorHttpStatusCode ? '4xx' : Status extends ServerErrorHttpStatusCode ? '5xx' : never;
32
+ /** The media type `injectApiSSE` always asks for — see {@link StreamSchemasOfEntry}. */
33
+ type SSEMediaType = 'text/event-stream';
34
+ /**
35
+ * The `event name -> schema` map of an SSE body descriptor, or `never` for any other
36
+ * descriptor (a bare Zod schema is JSON, `blobBody()` is opaque bytes).
37
+ */
38
+ type SseSchemasOfDescriptor<Descriptor> = Descriptor extends {
39
+ _tag: 'SseBody';
40
+ schemaByEventName: infer Schemas;
41
+ } ? Schemas : never;
42
+ /**
43
+ * Every SSE schema map a response entry declares, across all of its media types — the
44
+ * type-level counterpart of the maps `getSseSchemaByEventName` collects at runtime.
45
+ */
46
+ type SseSchemasOfEntry<Entry> = Entry extends {
47
+ content: infer Content;
48
+ } ? SseSchemasOfDescriptor<Content[keyof Content]> : never;
49
+ /**
50
+ * The SSE schema map an entry declares under `text/event-stream` specifically.
51
+ *
52
+ * `injectApiSSE` always sends `accept: text/event-stream`, so this is the descriptor a
53
+ * status resolves to whenever it declares one — even on a dual-mode content map that also
54
+ * carries a JSON schema.
55
+ */
56
+ type StreamSchemasOfEntry<Entry> = Entry extends {
57
+ content: infer Content;
58
+ } ? SseSchemasOfDescriptor<Content[SSEMediaType & keyof Content]> : never;
59
+ /**
60
+ * The JSON Zod schema of a single response entry, or `never` when this helper can't reach a
61
+ * JSON body there. A bare schema is JSON; a content-map entry contributes the schemas of its
62
+ * non-blob, non-SSE descriptors.
63
+ *
64
+ * An entry that declares a `text/event-stream` body has no reachable JSON side: the request
65
+ * asks for the stream, so a dual-mode status answers with SSE and `bodyForStatus` would throw.
66
+ * Read those with `events()` instead.
67
+ */
68
+ type JsonSchemaOfEntry<Entry> = [StreamSchemasOfEntry<Entry>] extends [never] ? Entry extends z.ZodType ? Entry : Entry extends {
69
+ content: infer Content;
70
+ } ? Extract<Content[keyof Content], z.ZodType> : never : never;
71
+ /** Indexes a responses map with a key that may not exist on it (yielding `never` if it doesn't). */
72
+ type EntryAt<Contract extends ApiContract, Key> = NonNullable<Responses<Contract>[Key & keyof Responses<Contract>]>;
73
+ /** The response entry that serves a concrete status, following exact → range → `'default'`. */
74
+ type EntryForStatus<Contract extends ApiContract, Status extends HttpStatusCode> = [
75
+ EntryAt<Contract, Status>
76
+ ] extends [never] ? [EntryAt<Contract, RangeKeyOf<Status>>] extends [never] ? EntryAt<Contract, 'default'> : EntryAt<Contract, RangeKeyOf<Status>> : EntryAt<Contract, Status>;
77
+ /**
78
+ * Status codes for which the contract declares a JSON response body.
79
+ *
80
+ * Resolves to `never` for contracts that declare none, so `bodyForStatus` is uncallable there.
81
+ * Range and `'default'` keys expand to the concrete statuses they serve, so a contract
82
+ * declaring only `4xx` still allows `bodyForStatus(404)`.
83
+ */
84
+ export type ApiDeclaredResponseStatus<Contract extends ApiContract> = {
85
+ [Key in keyof Responses<Contract>]: [JsonSchemaOfEntry<EntryAt<Contract, Key>>] extends [never] ? never : StatusesForKey<Contract, Key>;
86
+ }[keyof Responses<Contract>] & HttpStatusCode;
87
+ /** Infers a schema's output type, or `never` when there is no schema. */
88
+ type InferJsonBody<Schema> = Schema extends z.ZodType ? z.output<Schema> : never;
89
+ /** The parsed JSON response body a contract declares for a concrete status. */
90
+ export type ApiDeclaredResponseBody<Contract extends ApiContract, Status extends HttpStatusCode> = InferJsonBody<JsonSchemaOfEntry<EntryForStatus<Contract, Status>>>;
91
+ /**
92
+ * Union of the SSE schema maps a contract declares, over *every* status key.
93
+ *
94
+ * Deliberately not `InferSseSuccessResponses`, which only looks at success / `'2xx'` /
95
+ * `'default'` keys: the runtime `getSseSchemaByEventName` merges the maps of every entry in
96
+ * `responsesByStatusCode`, so a stream declared under e.g. `'4xx'` produces validated events
97
+ * too and has to be visible here.
98
+ */
99
+ type ApiSSEEventSchemas<Contract extends ApiContract> = SseSchemasOfEntry<NonNullable<Responses<Contract>[keyof Responses<Contract>]>>;
100
+ /**
101
+ * The event names of a union of schema maps.
102
+ *
103
+ * `keyof` a union yields only the keys shared by every member, which collapses to `never` as
104
+ * soon as two statuses declare different events — so distribute first and union the keys,
105
+ * mirroring the runtime merge.
106
+ */
107
+ type SseEventNamesOf<Schemas> = Schemas extends unknown ? keyof Schemas & string : never;
108
+ /**
109
+ * The schema(s) a union of maps declares for one event name. Maps that don't declare it drop
110
+ * out; two maps declaring it with different schemas yield a union, since the runtime merge
111
+ * keeps only one of them and the reader can't tell which.
112
+ */
113
+ type SseSchemaForEventName<Schemas, Name extends string> = Schemas extends Record<Name, infer Schema> ? Schema : never;
114
+ /** Builds the event union for an already-resolved set of schema maps. */
115
+ type ApiSSEEventOf<Schemas> = {
116
+ [Name in SseEventNamesOf<Schemas>]: {
117
+ /** Event ID, when the server sent an `id:` field. */
118
+ id?: string;
119
+ /** Event name, as sent in the `event:` field (defaults to `message`). */
120
+ event: Name;
121
+ /** Reconnection hint in milliseconds, when the server sent a `retry:` field. */
122
+ retry?: number;
123
+ /** `data:` payload, JSON-parsed and validated against the contract's schema. */
124
+ data: InferJsonBody<SseSchemaForEventName<Schemas, Name>>;
125
+ };
126
+ }[SseEventNamesOf<Schemas>];
127
+ /**
128
+ * Discriminated union of the SSE events a contract declares, with `data` parsed and typed
129
+ * per event name. `never` for a contract that declares no SSE response at all.
130
+ */
131
+ export type ApiSSEEvent<Contract extends ApiContract> = ApiSSEEventOf<ApiSSEEventSchemas<Contract>>;
132
+ /** Whether a contract declares an SSE response on any status. */
133
+ type HasApiSSEResponse<Contract extends ApiContract> = [ApiSSEEventSchemas<Contract>] extends [
134
+ never
135
+ ] ? false : true;
136
+ /**
137
+ * The callable form of {@link InjectApiSSEResult.events}.
138
+ *
139
+ * Always a function, so the internal binder has a type to return regardless of what the
140
+ * contract declares; the result type below hides it behind {@link HasApiSSEResponse}.
141
+ */
142
+ export type ApiSSEEventReader<Contract extends ApiContract> = () => Promise<ApiSSEEvent<Contract>[]>;
143
+ /**
144
+ * Result of an {@link injectApiSSE} call.
145
+ *
146
+ * The `defineApiContract` counterpart of `InjectSSEResult`: same `closed` promise and
147
+ * `bodyForStatus` accessor, plus `events()` for reading the stream typed against the
148
+ * contract's `sseResponse` / `sseBody` schemas.
149
+ */
150
+ export type InjectApiSSEResult<Contract extends ApiContract> = {
151
+ /**
152
+ * Resolves when the response completes with the full SSE body.
153
+ * Parse the body with `parseSSEEvents()` — or use `events()` for typed events.
154
+ */
155
+ closed: Promise<SSEResponse>;
156
+ /**
157
+ * Awaits the response, asserts the status code matches, parses the body against the
158
+ * contract's JSON schema for that status, and returns the parsed object. Useful for
159
+ * asserting on the documented error responses a handler emits before streaming starts.
160
+ *
161
+ * Throws (with the offending status and a truncated body snippet) if:
162
+ * - the actual status code doesn't match the expected one;
163
+ * - the contract declares no JSON body for that status;
164
+ * - the body isn't valid JSON;
165
+ * - the body doesn't match the declared Zod schema.
166
+ *
167
+ * At the type level, `statusCode` is constrained to the statuses the contract declares a
168
+ * JSON body for. Contracts without any can't call this method (`statusCode: never`).
169
+ *
170
+ * @example
171
+ * ```typescript
172
+ * const { bodyForStatus } = injectApiSSE(app, contract, { body })
173
+ * const error = await bodyForStatus(400) // typed as z.output<400-schema>
174
+ * expect(error.message).toBe('Bad request')
175
+ * ```
176
+ */
177
+ bodyForStatus<Status extends ApiDeclaredResponseStatus<Contract>>(statusCode: Status): Promise<ApiDeclaredResponseBody<Contract, Status>>;
178
+ /**
179
+ * Awaits the response, parses the SSE body, and validates every event against the
180
+ * contract's SSE schemas, returning them as a discriminated union on `event`.
181
+ *
182
+ * Events are typed from the SSE schemas of *every* status the contract declares, merged
183
+ * exactly as the runtime merges them — a contract streaming different events on two
184
+ * statuses yields the union of both.
185
+ *
186
+ * Throws if the response isn't an SSE stream (use `bodyForStatus` for the documented
187
+ * error statuses), if an event name isn't declared by the contract, or if an event payload
188
+ * doesn't match its schema.
189
+ *
190
+ * A contract that declares no SSE response at all types this as `never`, so calling it is
191
+ * a compile error rather than a guaranteed throw — reach for `injectByApiContract` there.
192
+ *
193
+ * @example
194
+ * ```typescript
195
+ * const events = await injectApiSSE(app, contract, { body }).events()
196
+ * const review = events.find((event) => event.event === 'review')
197
+ * expect(review?.data.score).toBe(42) // `data` typed by the `review` schema
198
+ * ```
199
+ */
200
+ events: HasApiSSEResponse<Contract> extends true ? ApiSSEEventReader<Contract> : never;
201
+ };
202
+ export {};
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=apiSseTestTypes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"apiSseTestTypes.js","sourceRoot":"","sources":["../../../lib/testing/apiSseTestTypes.ts"],"names":[],"mappings":""}
@@ -1,5 +1,8 @@
1
- export { type HasSessionSpy, SSEHttpClient, type SSEHttpConnectOptions, type SSEHttpConnectResult, type SSEHttpConnectWithSpyOptions, } from './sseHttpClient.js';
1
+ export { injectApiSSE } from './apiSseInjectHelpers.js';
2
+ export type { ApiDeclaredResponseBody, ApiDeclaredResponseStatus, ApiSSEEvent, ApiSSEEventReader, InjectApiSSEParams, InjectApiSSEResult, } from './apiSseTestTypes.js';
3
+ export { type HasSessionSpy, SSEHttpClient, type SSEHttpConnectOptions, type SSEHttpConnectResult, type SSEHttpConnectWithSessionSpyOptions, type SSEHttpConnectWithSpyOptions, type SSEHttpMethod, } from './sseHttpClient.js';
2
4
  export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
3
5
  export { injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
6
+ export { type CreateSSESessionSpyResult, createSSESessionSpy, type SSESessionSpyHooks, type SSESessionSpyRouteOptions, } from './sseSessionSpyFactory.js';
4
7
  export { SSETestServer } from './sseTestServer.js';
5
- export type { InjectPayloadSSEOptions, InjectSSEOptions, InjectSSEResult, SSEConnectOptions, SSEResponse, SSETestConnection, } from './sseTestTypes.js';
8
+ export type { InjectPayloadSSEOptions, InjectSSEOptions, InjectSSEResult, SSEConnectOptions, SSEInjectMethod, SSEResponse, SSETestConnection, } from './sseTestTypes.js';
@@ -1,5 +1,7 @@
1
+ export { injectApiSSE } from './apiSseInjectHelpers.js';
1
2
  export { SSEHttpClient, } from './sseHttpClient.js';
2
3
  export { SSEInjectClient, SSEInjectConnection } from './sseInjectClient.js';
3
4
  export { injectPayloadSSE, injectSSE } from './sseInjectHelpers.js';
5
+ export { createSSESessionSpy, } from './sseSessionSpyFactory.js';
4
6
  export { SSETestServer } from './sseTestServer.js';
5
7
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,aAAa,GAId,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../lib/testing/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AASvD,OAAO,EAEL,aAAa,GAMd,MAAM,oBAAoB,CAAA;AAC3B,OAAO,EAAE,eAAe,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAC3E,OAAO,EAAE,gBAAgB,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAA;AACnE,OAAO,EAEL,mBAAmB,GAGpB,MAAM,2BAA2B,CAAA;AAClC,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA"}
@@ -1,5 +1,5 @@
1
1
  import type { SSESession } from '../routes/fastifyRouteTypes.ts';
2
- import type { SSESessionSpy } from '../sse/SSESessionSpy.ts';
2
+ import type { SpiedSSESession, SSESessionSpy } from '../sse/SSESessionSpy.ts';
3
3
  import { type ParsedSSEEvent } from '../sse/sseParser.ts';
4
4
  /**
5
5
  * Interface for objects that have a sessionSpy (e.g., SSE controllers in test mode).
@@ -7,6 +7,16 @@ import { type ParsedSSEEvent } from '../sse/sseParser.ts';
7
7
  export type HasSessionSpy = {
8
8
  connectionSpy: SSESessionSpy;
9
9
  };
10
+ /** Canonical, on-the-wire spelling of a supported method. */
11
+ type NormalizedSSEHttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH';
12
+ /**
13
+ * HTTP methods supported when connecting to an SSE endpoint.
14
+ *
15
+ * Both spellings are accepted and normalized internally, so the lowercase form
16
+ * used by route contracts (`buildContract({ method: 'post' })`) can be handed
17
+ * over as-is.
18
+ */
19
+ export type SSEHttpMethod = NormalizedSSEHttpMethod | Lowercase<NormalizedSSEHttpMethod>;
10
20
  /**
11
21
  * Options for connecting to an SSE endpoint via HTTP.
12
22
  */
@@ -15,9 +25,28 @@ export type SSEHttpConnectOptions = {
15
25
  query?: Record<string, string | undefined>;
16
26
  /** Additional headers to send with the request */
17
27
  headers?: Record<string, string>;
28
+ /** HTTP method to use, upper- or lowercase (default: 'GET') */
29
+ method?: SSEHttpMethod;
30
+ /**
31
+ * Request body.
32
+ *
33
+ * Values `fetch()` can send natively - strings, `URLSearchParams`, `FormData`,
34
+ * `Blob`/`File`, `ArrayBuffer`, typed arrays (`Buffer`, `Uint8Array`, ...) and
35
+ * `ReadableStream` - are passed through untouched. Anything else is
36
+ * JSON-stringified.
37
+ *
38
+ * `content-type: application/json` is defaulted for JSON-stringified and string
39
+ * bodies, unless `headers` already provides a content type. Bodies that describe
40
+ * their own encoding (`URLSearchParams`, `FormData`, `Blob`) are left for
41
+ * `fetch()` to label, so their content type is never overwritten with a wrong one.
42
+ *
43
+ * Requires a non-GET `method`.
44
+ */
45
+ body?: unknown;
18
46
  };
19
47
  /**
20
- * Options for connecting with automatic server-side connection waiting.
48
+ * Options for connecting with automatic server-side connection waiting,
49
+ * driven by a controller's built-in `connectionSpy`.
21
50
  */
22
51
  export type SSEHttpConnectWithSpyOptions = SSEHttpConnectOptions & {
23
52
  /**
@@ -32,12 +61,32 @@ export type SSEHttpConnectWithSpyOptions = SSEHttpConnectOptions & {
32
61
  timeout?: number;
33
62
  };
34
63
  };
64
+ /**
65
+ * Options for connecting with automatic server-side connection waiting,
66
+ * driven by a standalone spy from `createSSESessionSpy()`.
67
+ *
68
+ * Use this for routes built with `buildApiRoute`, which have no controller to
69
+ * read a `connectionSpy` off of.
70
+ */
71
+ export type SSEHttpConnectWithSessionSpyOptions<TSession extends SpiedSSESession> = SSEHttpConnectOptions & {
72
+ /**
73
+ * Wait for server-side connection registration after HTTP headers are received.
74
+ * This eliminates the race condition between `connect()` returning and the
75
+ * server-side handler completing connection registration.
76
+ */
77
+ awaitServerConnection: {
78
+ /** A standalone spy, wired to the route via `createSSESessionSpy()`'s `routeOptions` */
79
+ spy: SSESessionSpy<TSession>;
80
+ /** Timeout in milliseconds (default: 5000) */
81
+ timeout?: number;
82
+ };
83
+ };
35
84
  /**
36
85
  * Result when connecting with awaitServerConnection option.
37
86
  */
38
- export type SSEHttpConnectResult = {
87
+ export type SSEHttpConnectResult<TSession extends SpiedSSESession = SSESession> = {
39
88
  client: SSEHttpClient;
40
- serverConnection: SSESession;
89
+ serverConnection: TSession;
41
90
  };
42
91
  /**
43
92
  * SSE client for testing long-lived connections using real HTTP.
@@ -49,6 +98,13 @@ export type SSEHttpConnectResult = {
49
98
  * - **Long-lived connections** that stay open indefinitely
50
99
  * - **Real-time notifications** where events arrive over time
51
100
  * - **Push-based streaming** where the client waits for server-initiated events
101
+ * - **Assertions on the wire while the handler is still running** - `connect()`
102
+ * resolves as soon as headers arrive, so `response.status` / `response.headers`
103
+ * can be checked before the handler produces its first event
104
+ *
105
+ * GET, POST, PUT and PATCH are all supported (see `method` / `body` in
106
+ * {@link SSEHttpConnectOptions}), so POST SSE endpoints that take a request
107
+ * body can be tested over real HTTP too.
52
108
  *
53
109
  * **When to use SSEHttpClient vs SSEInjectClient:**
54
110
  *
@@ -92,14 +148,33 @@ export type SSEHttpConnectResult = {
92
148
  * ```
93
149
  */
94
150
  export declare class SSEHttpClient {
95
- /** The fetch Response object. Available immediately after connect() returns. */
151
+ /**
152
+ * The fetch Response object. Available immediately after connect() returns,
153
+ * before any event is consumed, so status and headers can be asserted while
154
+ * the handler is still running.
155
+ *
156
+ * The response body is only locked once events are first consumed, so
157
+ * `response.json()` / `response.text()` still work for endpoints that
158
+ * answered with a regular HTTP response instead of a stream (for example an
159
+ * error raised before `sse.start()`). Read that body *before* calling
160
+ * `close()` - `close()` aborts the request, which rejects any pending or
161
+ * subsequent body read with an `AbortError`.
162
+ */
96
163
  readonly response: Response;
97
164
  private readonly abortController;
98
- private readonly reader;
165
+ private readonly responseBody;
166
+ private streamReader;
99
167
  private readonly decoder;
100
168
  private buffer;
101
169
  private closed;
102
170
  private constructor();
171
+ /**
172
+ * Lazily acquire the stream reader, locking the response body on first use.
173
+ *
174
+ * A missing body is reported here rather than from the constructor, so a
175
+ * bodiless response (e.g. `204`) can still be inspected via `response`.
176
+ */
177
+ private get reader();
103
178
  /**
104
179
  * Connect to an SSE endpoint.
105
180
  *
@@ -109,7 +184,7 @@ export declare class SSEHttpClient {
109
184
  *
110
185
  * @param baseUrl - Base URL of the server (e.g., 'http://localhost:3000')
111
186
  * @param path - SSE endpoint path (e.g., '/api/notifications')
112
- * @param options - Connection options (query params, headers)
187
+ * @param options - Connection options (method, body, query params, headers)
113
188
  * @returns Connected SSE client ready to receive events
114
189
  *
115
190
  * @example
@@ -121,6 +196,16 @@ export declare class SSEHttpClient {
121
196
  * { query: { userId: '123' }, headers: { authorization: 'Bearer token' } }
122
197
  * )
123
198
  *
199
+ * // POST with a JSON body (content-type defaults to application/json)
200
+ * const client = await SSEHttpClient.connect(
201
+ * 'http://localhost:3000',
202
+ * '/api/chat/completions',
203
+ * { method: 'POST', body: { message: 'Hello', stream: true } }
204
+ * )
205
+ * // Headers are on the wire before the handler finished its slow work
206
+ * expect(client.response.status).toBe(200)
207
+ * expect(client.response.headers.get('content-type')).toContain('text/event-stream')
208
+ *
124
209
  * // With awaitServerConnection (waits for server-side registration)
125
210
  * const { client, serverConnection } = await SSEHttpClient.connect(
126
211
  * 'http://localhost:3000',
@@ -129,9 +214,20 @@ export declare class SSEHttpClient {
129
214
  * )
130
215
  * // serverConnection is ready to use immediately
131
216
  * await controller.sendEvent(serverConnection.id, { event: 'test', data: {} })
217
+ *
218
+ * // Same, for a `buildApiRoute` route with no controller: wire a standalone
219
+ * // spy into the route's hooks with createSSESessionSpy()
220
+ * const { spy, routeOptions } = createSSESessionSpy()
221
+ * const { client, serverConnection } = await SSEHttpClient.connect(
222
+ * 'http://localhost:3000',
223
+ * '/api/stream',
224
+ * { awaitServerConnection: { spy } }
225
+ * )
226
+ * await serverConnection.send('test', {})
132
227
  * ```
133
228
  */
134
229
  static connect(baseUrl: string, path: string, options: SSEHttpConnectWithSpyOptions): Promise<SSEHttpConnectResult>;
230
+ static connect<TSession extends SpiedSSESession>(baseUrl: string, path: string, options: SSEHttpConnectWithSessionSpyOptions<TSession>): Promise<SSEHttpConnectResult<TSession>>;
135
231
  static connect(baseUrl: string, path: string, options?: SSEHttpConnectOptions): Promise<SSEHttpClient>;
136
232
  /**
137
233
  * Async generator that yields parsed SSE events as they arrive.
@@ -198,6 +294,11 @@ export declare class SSEHttpClient {
198
294
  *
199
295
  * This aborts the underlying fetch request. Call this when done
200
296
  * consuming events to clean up resources.
297
+ *
298
+ * Aborting also discards any unread response body, so for a non-stream
299
+ * response (an error raised before `sse.start()`, say) read
300
+ * `response.json()` / `response.text()` before calling this.
201
301
  */
202
302
  close(): void;
203
303
  }
304
+ export {};