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
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request header carrying the id of an open diagnostics scope.
|
|
3
|
+
*
|
|
4
|
+
* The SSE test helpers (`injectApiSSE`, `connectApiSSE`) set it on every request they make;
|
|
5
|
+
* routes built with `buildApiRoute` honour it by instrumenting the session they hand to the
|
|
6
|
+
* handler. A value that does not name a scope opened in this process is ignored, so the
|
|
7
|
+
* header is inert outside a test run — a client cannot make a production server record
|
|
8
|
+
* anything by sending it.
|
|
9
|
+
*/
|
|
10
|
+
export const SSE_DIAGNOSTICS_HEADER = 'x-om-sse-diagnostics-id';
|
|
11
|
+
/** Cap per scope, so a handler failing in a loop can't grow the registry without bound. */
|
|
12
|
+
const MAX_FAILURES_PER_SCOPE = 50;
|
|
13
|
+
/** How far to walk an error's `cause` chain when matching it to a recorded failure. */
|
|
14
|
+
const MAX_CAUSE_DEPTH = 10;
|
|
15
|
+
/**
|
|
16
|
+
* The failures of one observed request, plus whether the route recovered from them.
|
|
17
|
+
*
|
|
18
|
+
* Split from {@link SSEDiagnosticsScope} because the two ends see different things: the scope
|
|
19
|
+
* is the reader's handle (it can only snapshot and unregister), while the recorder is what the
|
|
20
|
+
* instrumented route writes to.
|
|
21
|
+
*/
|
|
22
|
+
class SSEDiagnosticsRecorder {
|
|
23
|
+
failures = [];
|
|
24
|
+
settled = false;
|
|
25
|
+
/** Record a send that threw, and the Zod issues behind it when the payload explains it. */
|
|
26
|
+
recordSendFailure(schemaByEventName, eventName, data, error) {
|
|
27
|
+
if (this.failures.length >= MAX_FAILURES_PER_SCOPE) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const issues = issuesFor(schemaByEventName, eventName, data);
|
|
31
|
+
this.failures.push({
|
|
32
|
+
eventName,
|
|
33
|
+
data,
|
|
34
|
+
message: messageOf(error),
|
|
35
|
+
...(issues && { issues }),
|
|
36
|
+
error,
|
|
37
|
+
handled: false,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/** Record a `sendStream()` source that threw while producing its next message. */
|
|
41
|
+
recordSourceFailure(error) {
|
|
42
|
+
if (this.failures.length >= MAX_FAILURES_PER_SCOPE) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
this.failures.push({ message: messageOf(error), error, handled: false });
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The route handler settled: everything recorded so far that did not escape it was caught
|
|
49
|
+
* by the route, which went on to produce the rest of the response.
|
|
50
|
+
*
|
|
51
|
+
* Called once per request, before the response ends, so the helpers reading the stream see
|
|
52
|
+
* final `handled` flags. Failures recorded afterwards — a send on a `keepAlive` session the
|
|
53
|
+
* handler already returned from — stay unhandled: nothing observably recovered from them.
|
|
54
|
+
*
|
|
55
|
+
* @param escaped - The error the handler threw, if it threw
|
|
56
|
+
*/
|
|
57
|
+
settle(escaped) {
|
|
58
|
+
if (this.settled) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.settled = true;
|
|
62
|
+
for (const failure of this.failures) {
|
|
63
|
+
failure.handled = !causedBy(escaped, failure.error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const openScopes = new Map();
|
|
68
|
+
let nextScopeId = 0;
|
|
69
|
+
/**
|
|
70
|
+
* Open a diagnostics scope for a single request.
|
|
71
|
+
*
|
|
72
|
+
* @internal Used by the SSE test helpers; tests reach the failures through the helper they
|
|
73
|
+
* called, not through this registry.
|
|
74
|
+
*/
|
|
75
|
+
export function openSSEDiagnosticsScope() {
|
|
76
|
+
const id = `sse-diag-${++nextScopeId}`;
|
|
77
|
+
openScopes.set(id, new SSEDiagnosticsRecorder());
|
|
78
|
+
let snapshot;
|
|
79
|
+
return {
|
|
80
|
+
id,
|
|
81
|
+
headers: { [SSE_DIAGNOSTICS_HEADER]: id },
|
|
82
|
+
failures: () => snapshot ?? [...(openScopes.get(id)?.failures ?? [])],
|
|
83
|
+
dispose: () => {
|
|
84
|
+
if (!snapshot) {
|
|
85
|
+
snapshot = [...(openScopes.get(id)?.failures ?? [])];
|
|
86
|
+
openScopes.delete(id);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* How many diagnostics scopes are registered right now.
|
|
93
|
+
*
|
|
94
|
+
* @internal Exists so the helpers' own specs can prove that every path out of a request
|
|
95
|
+
* unregisters its scope: a leaked one keeps its records alive for the rest of the process and
|
|
96
|
+
* costs every later request the fast path below.
|
|
97
|
+
*/
|
|
98
|
+
export function countOpenSSEDiagnosticsScopes() {
|
|
99
|
+
return openScopes.size;
|
|
100
|
+
}
|
|
101
|
+
/** The recorder a request writes to, or `undefined` when it belongs to no open scope. */
|
|
102
|
+
function resolveRecorder(headers) {
|
|
103
|
+
// Fast path for production traffic: with no scope open the header can't match anything.
|
|
104
|
+
if (openScopes.size === 0) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
const header = headers[SSE_DIAGNOSTICS_HEADER];
|
|
108
|
+
const id = Array.isArray(header) ? header[0] : header;
|
|
109
|
+
return id === undefined ? undefined : openScopes.get(id);
|
|
110
|
+
}
|
|
111
|
+
/** Re-validate a payload to recover the structured issues the thrown error only carries as text. */
|
|
112
|
+
function issuesFor(schemaByEventName, eventName, data) {
|
|
113
|
+
const schema = schemaByEventName[eventName];
|
|
114
|
+
if (!schema) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
const result = schema.safeParse(data);
|
|
118
|
+
return result.success ? undefined : result.error.issues;
|
|
119
|
+
}
|
|
120
|
+
function messageOf(error) {
|
|
121
|
+
return error instanceof Error ? error.message : String(error);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Whether `error` is `candidate`, or was thrown wrapping it as a `cause`.
|
|
125
|
+
*
|
|
126
|
+
* A handler that rethrows the send error as-is is the common case; one that wraps it in its
|
|
127
|
+
* own error still did not recover from it, so the chain is walked (to a bounded depth, since
|
|
128
|
+
* a `cause` chain can be cyclic).
|
|
129
|
+
*/
|
|
130
|
+
function causedBy(error, candidate) {
|
|
131
|
+
let current = error;
|
|
132
|
+
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined && current !== null; depth++) {
|
|
133
|
+
if (current === candidate) {
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
current = current instanceof Error ? current.cause : undefined;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Instrument an SSE session so that failed sends are recorded against the diagnostics scope
|
|
142
|
+
* the request names, then rethrown unchanged.
|
|
143
|
+
*
|
|
144
|
+
* A no-op unless the request carries {@link SSE_DIAGNOSTICS_HEADER} with the id of a scope
|
|
145
|
+
* open in this process, which only the SSE test helpers ever produce.
|
|
146
|
+
*
|
|
147
|
+
* @param session - The session handed to the handler by `sse.start()`
|
|
148
|
+
* @param schemaByEventName - The contract's merged SSE event schemas, used to recover the
|
|
149
|
+
* Zod issues behind a validation failure
|
|
150
|
+
*
|
|
151
|
+
* @internal Called by `buildApiRoute`; not part of the application-facing API.
|
|
152
|
+
*/
|
|
153
|
+
export function attachSSESendDiagnostics(session, schemaByEventName) {
|
|
154
|
+
const recorder = resolveRecorder(session.request.headers);
|
|
155
|
+
if (!recorder) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const originalSend = session.send.bind(session);
|
|
159
|
+
session.send = async (eventName, data, options) => {
|
|
160
|
+
try {
|
|
161
|
+
return await originalSend(eventName, data, options);
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
recorder.recordSendFailure(schemaByEventName, eventName, data, error);
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
const originalSendStream = session.sendStream.bind(session);
|
|
169
|
+
session.sendStream = async (messages) => {
|
|
170
|
+
// The send that throws happens inside `sendStream`, which reports neither the event name
|
|
171
|
+
// nor the payload. Wrapping the source names it: `pending` holds the message handed to
|
|
172
|
+
// the sender, and is cleared when the sender comes back for the next one — which only
|
|
173
|
+
// happens once the previous send resolved. So a rejection with `pending` set is a failed
|
|
174
|
+
// send of that message, and one without is the source itself throwing.
|
|
175
|
+
let pending;
|
|
176
|
+
async function* tracked() {
|
|
177
|
+
for await (const message of messages) {
|
|
178
|
+
pending = message;
|
|
179
|
+
yield message;
|
|
180
|
+
pending = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
await originalSendStream(tracked());
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (pending) {
|
|
188
|
+
recorder.recordSendFailure(schemaByEventName, pending.event, pending.data, error);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
recorder.recordSourceFailure(error);
|
|
192
|
+
}
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Wrap a route handler so the diagnostics scope learns whether the route recovered from the
|
|
199
|
+
* sends it could not make.
|
|
200
|
+
*
|
|
201
|
+
* A send that throws is only a reason for a test to fail when nothing caught it: a handler
|
|
202
|
+
* that catches its own failed send and streams a fallback instead produced exactly the
|
|
203
|
+
* response it meant to. Observing how the handler settled is what tells the two apart —
|
|
204
|
+
* {@link SSESendFailure.handled}.
|
|
205
|
+
*
|
|
206
|
+
* A no-op for requests that name no open diagnostics scope: outside a test run this is one
|
|
207
|
+
* `Map.size` check per request on SSE routes.
|
|
208
|
+
*
|
|
209
|
+
* @internal Applied by `buildApiRoute` to SSE-capable routes.
|
|
210
|
+
*/
|
|
211
|
+
export function reportSSEHandlerOutcome(handler) {
|
|
212
|
+
const instrumented = function instrumentedHandler(request, reply) {
|
|
213
|
+
const recorder = resolveRecorder(request.headers);
|
|
214
|
+
if (!recorder) {
|
|
215
|
+
return handler.call(this, request, reply);
|
|
216
|
+
}
|
|
217
|
+
let result;
|
|
218
|
+
try {
|
|
219
|
+
result = handler.call(this, request, reply);
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// A handler that throws before returning a promise never opened a stream to recover in.
|
|
223
|
+
recorder.settle(error);
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
return Promise.resolve(result).then((value) => {
|
|
227
|
+
recorder.settle();
|
|
228
|
+
// Whatever the wrapped handler resolves to is what Fastify sends: pass it through.
|
|
229
|
+
return value;
|
|
230
|
+
}, (error) => {
|
|
231
|
+
recorder.settle(error);
|
|
232
|
+
throw error;
|
|
233
|
+
});
|
|
234
|
+
};
|
|
235
|
+
return instrumented;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* The failures the route did not recover from — the ones that truncated the response, and so
|
|
239
|
+
* explain an event a test waited for and never saw.
|
|
240
|
+
*
|
|
241
|
+
* @internal
|
|
242
|
+
*/
|
|
243
|
+
export function unhandledSendFailures(failures) {
|
|
244
|
+
return failures.filter((failure) => !failure.handled);
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Render recorded failures as the message of the error the test helpers throw.
|
|
248
|
+
*
|
|
249
|
+
* @internal
|
|
250
|
+
*/
|
|
251
|
+
export function describeSendFailures(failures) {
|
|
252
|
+
const lines = failures.map((failure) => ` - ${describeSendFailure(failure)}`);
|
|
253
|
+
const subject = failures.length === 1 ? 'failure' : 'failures';
|
|
254
|
+
return `${failures.length} SSE send ${subject} recorded for this request:\n${lines.join('\n')}`;
|
|
255
|
+
}
|
|
256
|
+
function describeSendFailure(failure) {
|
|
257
|
+
const recovered = failure.handled ? ' (caught by the route, which completed the response)' : '';
|
|
258
|
+
if (failure.eventName === undefined) {
|
|
259
|
+
return `the sendStream() source threw before the next event: ${failure.message}${recovered}`;
|
|
260
|
+
}
|
|
261
|
+
const detail = failure.issues
|
|
262
|
+
? failure.issues
|
|
263
|
+
.map((issue) => `${issue.path.join('.') || '<root>'}: ${issue.message}`)
|
|
264
|
+
.join('; ')
|
|
265
|
+
: failure.message;
|
|
266
|
+
return `event "${failure.eventName}" was never sent: ${detail}; payload: ${safeStringify(failure.data)}${recovered}`;
|
|
267
|
+
}
|
|
268
|
+
/** JSON for an error message, degrading to `String()` for anything JSON can't take. */
|
|
269
|
+
function safeStringify(value) {
|
|
270
|
+
try {
|
|
271
|
+
return JSON.stringify(value) ?? String(value);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
return String(value);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
//# sourceMappingURL=sseSendDiagnostics.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sseSendDiagnostics.js","sourceRoot":"","sources":["../../../lib/sse/sseSendDiagnostics.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-aware SSE event validation, shared by the inject helpers (`injectApiSSE`) and the
|
|
3
|
+
* real-HTTP ones (`connectApiSSE`, `SSEHttpClient.apiEvents`) so both paths produce the same
|
|
4
|
+
* discriminated union, validated against the same schemas and reporting the same errors.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import type { SSEEventSchemas } from '@lokalise/api-contracts';
|
|
9
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
10
|
+
import type { ParsedSSEEvent } from '../sse/sseParser.ts';
|
|
11
|
+
import type { ApiSSEEvent } from './apiSseTestTypes.ts';
|
|
12
|
+
/**
|
|
13
|
+
* The contract's SSE schemas, merged across every declared status, or a thrown error naming
|
|
14
|
+
* the reader that asked for them.
|
|
15
|
+
*/
|
|
16
|
+
export declare function resolveApiSseSchemas(contract: ApiContract, reader: string): SSEEventSchemas;
|
|
17
|
+
/**
|
|
18
|
+
* Validate one parsed event against the contract's schemas and return it as a member of the
|
|
19
|
+
* contract's event union.
|
|
20
|
+
*
|
|
21
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
22
|
+
* @throws if the contract declares no schema for the event name, if `data` isn't valid JSON,
|
|
23
|
+
* or if the payload doesn't match the declared schema
|
|
24
|
+
*/
|
|
25
|
+
export declare function validateApiSseEvent<Contract extends ApiContract>(schemaByEventName: SSEEventSchemas, event: ParsedSSEEvent, reader: string): ApiSSEEvent<Contract>;
|
|
26
|
+
/** Media type an SSE response must carry. */
|
|
27
|
+
export declare const SSE_CONTENT_TYPE = "text/event-stream";
|
|
28
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
29
|
+
export declare function mediaTypeOf(contentType: string | undefined): string | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Reject a response that is not an event stream, naming the reader that asked for one.
|
|
32
|
+
*
|
|
33
|
+
* Shared by both read paths so an endpoint answering with a JSON error (a 401 before
|
|
34
|
+
* `sse.start()`, say) fails with its status and body on either — rather than as zero events,
|
|
35
|
+
* which reads as a timeout on the HTTP path and an empty array on the inject one.
|
|
36
|
+
*
|
|
37
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
38
|
+
* @param body - Response body, when the caller can produce it without consuming a live stream
|
|
39
|
+
*/
|
|
40
|
+
export declare function assertSSEResponse(statusCode: number, contentType: string | undefined, reader: string, body?: string): void;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-aware SSE event validation, shared by the inject helpers (`injectApiSSE`) and the
|
|
3
|
+
* real-HTTP ones (`connectApiSSE`, `SSEHttpClient.apiEvents`) so both paths produce the same
|
|
4
|
+
* discriminated union, validated against the same schemas and reporting the same errors.
|
|
5
|
+
*
|
|
6
|
+
* @internal
|
|
7
|
+
*/
|
|
8
|
+
import { getSseSchemaByEventName } from '@lokalise/api-contracts';
|
|
9
|
+
import { truncateBody } from "./sseInjectShared.js";
|
|
10
|
+
/**
|
|
11
|
+
* The contract's SSE schemas, merged across every declared status, or a thrown error naming
|
|
12
|
+
* the reader that asked for them.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveApiSseSchemas(contract, reader) {
|
|
15
|
+
const schemaByEventName = getSseSchemaByEventName(contract);
|
|
16
|
+
if (!schemaByEventName) {
|
|
17
|
+
throw new Error(`${reader} — the contract declares no SSE response`);
|
|
18
|
+
}
|
|
19
|
+
return schemaByEventName;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Validate one parsed event against the contract's schemas and return it as a member of the
|
|
23
|
+
* contract's event union.
|
|
24
|
+
*
|
|
25
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
26
|
+
* @throws if the contract declares no schema for the event name, if `data` isn't valid JSON,
|
|
27
|
+
* or if the payload doesn't match the declared schema
|
|
28
|
+
*/
|
|
29
|
+
export function validateApiSseEvent(schemaByEventName, event, reader) {
|
|
30
|
+
// An SSE event without an `event:` field is a `message` event per the spec.
|
|
31
|
+
const name = event.event ?? 'message';
|
|
32
|
+
const schema = schemaByEventName[name];
|
|
33
|
+
if (!schema) {
|
|
34
|
+
throw new Error(`${reader} — the contract declares no schema for event "${name}"`);
|
|
35
|
+
}
|
|
36
|
+
let parsedJson;
|
|
37
|
+
try {
|
|
38
|
+
parsedJson = JSON.parse(event.data);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
throw new Error(`${reader} — data of event "${name}" is not valid JSON: ${err.message}; data: ${truncateBody(event.data)}`);
|
|
42
|
+
}
|
|
43
|
+
const parsed = schema.safeParse(parsedJson);
|
|
44
|
+
if (!parsed.success) {
|
|
45
|
+
throw new Error(`${reader} — data of event "${name}" does not match the declared schema: ${parsed.error.message}; data: ${truncateBody(event.data)}`);
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...(event.id !== undefined && { id: event.id }),
|
|
49
|
+
...(event.retry !== undefined && { retry: event.retry }),
|
|
50
|
+
event: name,
|
|
51
|
+
data: parsed.data,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Media type an SSE response must carry. */
|
|
55
|
+
export const SSE_CONTENT_TYPE = 'text/event-stream';
|
|
56
|
+
/** Strip `; charset=…` style parameters from a media type. */
|
|
57
|
+
export function mediaTypeOf(contentType) {
|
|
58
|
+
return contentType?.split(';')[0]?.trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Reject a response that is not an event stream, naming the reader that asked for one.
|
|
62
|
+
*
|
|
63
|
+
* Shared by both read paths so an endpoint answering with a JSON error (a 401 before
|
|
64
|
+
* `sse.start()`, say) fails with its status and body on either — rather than as zero events,
|
|
65
|
+
* which reads as a timeout on the HTTP path and an empty array on the inject one.
|
|
66
|
+
*
|
|
67
|
+
* @param reader - Name of the calling reader (`events()`, `stream()`, …), used as the error prefix
|
|
68
|
+
* @param body - Response body, when the caller can produce it without consuming a live stream
|
|
69
|
+
*/
|
|
70
|
+
export function assertSSEResponse(statusCode, contentType, reader, body) {
|
|
71
|
+
const mediaType = mediaTypeOf(contentType);
|
|
72
|
+
if (mediaType === SSE_CONTENT_TYPE) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const bodySuffix = body === undefined || body === '' ? '' : ` Body: ${truncateBody(body)}`;
|
|
76
|
+
throw new Error(`${reader} — response is not an SSE stream (status ${statusCode}, content-type ${mediaType ?? 'absent'}); use bodyForStatus(${statusCode}) for declared error responses.${bodySuffix}`);
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=apiSseEventValidation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apiSseEventValidation.js","sourceRoot":"","sources":["../../../lib/testing/apiSseEventValidation.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type ApiContract } from '@lokalise/api-contracts';
|
|
2
|
+
import type { SpiedSSESession, SSESessionSpy } from '../sse/SSESessionSpy.ts';
|
|
3
|
+
import { type SSEDiagnosticsScope, type SSESendFailure } from '../sse/sseSendDiagnostics.ts';
|
|
4
|
+
import type { ApiSSEEvent, InjectApiSSEParams } from './apiSseTestTypes.ts';
|
|
5
|
+
import { SSEHttpClient } from './sseHttpClient.ts';
|
|
6
|
+
/**
|
|
7
|
+
* Request params for {@link connectApiSSE}, derived from a `defineApiContract` contract.
|
|
8
|
+
*
|
|
9
|
+
* The same shape `injectApiSSE` takes — `pathParams`, `body`, `queryParams` and `headers` are
|
|
10
|
+
* each required only when the contract declares the matching request schema, `headers` also
|
|
11
|
+
* accepts a (sync or async) factory, and `pathPrefix` is always optional.
|
|
12
|
+
*/
|
|
13
|
+
export type ConnectApiSSEParams<Contract extends ApiContract> = InjectApiSSEParams<Contract>;
|
|
14
|
+
/** Options for waiting on server-side registration, driven by a standalone session spy. */
|
|
15
|
+
export type ConnectApiSSEWithSpyOptions<TSession extends SpiedSSESession> = {
|
|
16
|
+
/**
|
|
17
|
+
* Wait for server-side connection registration after HTTP headers are received, removing
|
|
18
|
+
* the race between `connect()` returning and the handler finishing its registration.
|
|
19
|
+
*
|
|
20
|
+
* Only meaningful for `keepAlive` sessions — an `autoClose` route closes its session as the
|
|
21
|
+
* handler returns, before the wait can claim it.
|
|
22
|
+
*/
|
|
23
|
+
awaitServerConnection: {
|
|
24
|
+
/** A standalone spy, wired to the route via `createSSESessionSpy()`'s `routeOptions` */
|
|
25
|
+
spy: SSESessionSpy<TSession>;
|
|
26
|
+
/** Timeout in milliseconds (default: 5000) */
|
|
27
|
+
timeout?: number;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/** Result of {@link connectApiSSE} when `awaitServerConnection` is used. */
|
|
31
|
+
export type ConnectApiSSEResult<Contract extends ApiContract, TSession extends SpiedSSESession> = {
|
|
32
|
+
client: ApiSSEHttpClient<Contract>;
|
|
33
|
+
serverConnection: TSession;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* A live SSE connection over real HTTP, read through the contract that declares it.
|
|
37
|
+
*
|
|
38
|
+
* The contract-typed counterpart of {@link SSEHttpClient}: events arrive incrementally, as
|
|
39
|
+
* they do there, but each one is JSON-parsed, validated against the contract's `sseResponse` /
|
|
40
|
+
* `sseBody` schemas and typed as a discriminated union on `event` — the same events
|
|
41
|
+
* `injectApiSSE` produces, so a suite can move an assertion between the two paths unchanged.
|
|
42
|
+
*/
|
|
43
|
+
export declare class ApiSSEHttpClient<Contract extends ApiContract> {
|
|
44
|
+
/** The underlying untyped client, for the parts of it this wrapper doesn't cover. */
|
|
45
|
+
readonly raw: SSEHttpClient;
|
|
46
|
+
private readonly contract;
|
|
47
|
+
private readonly scope;
|
|
48
|
+
/** @internal Built by {@link connectApiSSE}. */
|
|
49
|
+
constructor(raw: SSEHttpClient, contract: Contract, scope: SSEDiagnosticsScope);
|
|
50
|
+
/**
|
|
51
|
+
* The fetch `Response`, available before any event is consumed — so status and headers can
|
|
52
|
+
* be asserted while the handler is still producing events.
|
|
53
|
+
*/
|
|
54
|
+
get response(): Response;
|
|
55
|
+
/**
|
|
56
|
+
* Yield the contract's events as they arrive, typed and validated per event name.
|
|
57
|
+
*
|
|
58
|
+
* Throws — before yielding anything — if the endpoint answered with something other than an
|
|
59
|
+
* event stream (an error raised before `sse.start()`, say), naming its status and body
|
|
60
|
+
* instead of reporting an empty stream.
|
|
61
|
+
*
|
|
62
|
+
* @param signal - Optional `AbortSignal` to stop the generator early
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* for await (const event of client.events()) {
|
|
67
|
+
* if (event.event === 'issue') expect(event.data.severity).toBe('minor')
|
|
68
|
+
* if (event.event === 'review') break
|
|
69
|
+
* }
|
|
70
|
+
* ```
|
|
71
|
+
*/
|
|
72
|
+
events(signal?: AbortSignal): AsyncGenerator<ApiSSEEvent<Contract>, void, unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* Collect events until a count is reached or a predicate matches, each one typed and
|
|
75
|
+
* validated against the contract.
|
|
76
|
+
*
|
|
77
|
+
* A collection that ends short — the stream closed early, or the wait timed out — usually
|
|
78
|
+
* means the handler failed to send an event it was supposed to; when that is what happened,
|
|
79
|
+
* the thrown error names the event and its validation issues instead of leaving the test to
|
|
80
|
+
* report a missing event with no reason.
|
|
81
|
+
*
|
|
82
|
+
* Throws straight away if the endpoint answered with something other than an event stream,
|
|
83
|
+
* rather than waiting out the timeout on a stream that was never going to arrive.
|
|
84
|
+
*
|
|
85
|
+
* @param countOrPredicate - Number of events to collect, or a predicate that ends collection
|
|
86
|
+
* (the matching event is included). The predicate is invoked exactly once per event.
|
|
87
|
+
* @param timeout - Maximum time to wait in milliseconds (default: 5000)
|
|
88
|
+
*/
|
|
89
|
+
collectEvents(countOrPredicate: number | ((event: ApiSSEEvent<Contract>) => boolean), timeout?: number): Promise<ApiSSEEvent<Contract>[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Throw what the handler failed to send and did not recover from, if anything was recorded
|
|
92
|
+
* for this connection.
|
|
93
|
+
*
|
|
94
|
+
* A failure the route caught and streamed around left the response it meant to produce, so
|
|
95
|
+
* it is reported through {@link ApiSSEHttpClient.sendFailures} instead of failing the read.
|
|
96
|
+
*/
|
|
97
|
+
private assertNoSendFailures;
|
|
98
|
+
/**
|
|
99
|
+
* Reject a response that is not an event stream, with its status and body.
|
|
100
|
+
*
|
|
101
|
+
* Without this a `401` (or any other pre-stream error response) reads as a stream that
|
|
102
|
+
* never produced an event: `collectEvents` waits out its full timeout and reports "got 0",
|
|
103
|
+
* with the actual status nowhere in the failure.
|
|
104
|
+
*/
|
|
105
|
+
private assertStreamResponse;
|
|
106
|
+
/**
|
|
107
|
+
* Unregister the diagnostics scope once the stream is over, keeping what it recorded.
|
|
108
|
+
*
|
|
109
|
+
* `close()` is the usual trigger; a test that reads a stream to its end and never closes the
|
|
110
|
+
* client would otherwise leave the scope registered for the rest of the process.
|
|
111
|
+
*/
|
|
112
|
+
private releaseScopeIfClosed;
|
|
113
|
+
/**
|
|
114
|
+
* The sends the handler could not make on this connection — a payload that failed the
|
|
115
|
+
* contract's schema for its event, say — recorded instead of being left in the server log.
|
|
116
|
+
*
|
|
117
|
+
* Includes the failures the route recovered from (`handled: true`), which the readers pass
|
|
118
|
+
* over precisely because the response was still the one the route meant to produce.
|
|
119
|
+
*
|
|
120
|
+
* Only routes built with this package's `buildApiRoute` report them.
|
|
121
|
+
*/
|
|
122
|
+
sendFailures(): SSESendFailure[];
|
|
123
|
+
/** Close the connection from the client side. */
|
|
124
|
+
close(): void;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Connect to an SSE endpoint over real HTTP using a contract built with `defineApiContract`.
|
|
128
|
+
*
|
|
129
|
+
* The contract-aware counterpart of `SSEHttpClient.connect`: the method, path, query params,
|
|
130
|
+
* headers and body all come from the contract instead of being repeated as string literals
|
|
131
|
+
* next to it, and the events are typed and validated the way `injectApiSSE().events()` types
|
|
132
|
+
* them — so the tests that read a stream as it arrives keep the contract typing rather than
|
|
133
|
+
* casting `ParsedSSEEvent.data` by hand.
|
|
134
|
+
*
|
|
135
|
+
* Use this (over `injectApiSSE`) when the endpoint keeps its connection open: a `keepAlive`
|
|
136
|
+
* session never completes its response, so only a real HTTP connection can read it.
|
|
137
|
+
*
|
|
138
|
+
* @param baseUrl - Base URL of the running server (e.g. `SSETestServer.baseUrl`)
|
|
139
|
+
* @param contract - Contract built with `defineApiContract`
|
|
140
|
+
* @param params - Request params derived from the contract
|
|
141
|
+
* @param options - `awaitServerConnection`, to also wait for server-side registration
|
|
142
|
+
*
|
|
143
|
+
* @example
|
|
144
|
+
* ```typescript
|
|
145
|
+
* const client = await connectApiSSE(server.baseUrl, lqaTextSegmentContract, { body })
|
|
146
|
+
*
|
|
147
|
+
* expect(client.response.status).toBe(200) // asserted while the handler is still working
|
|
148
|
+
* for await (const event of client.events()) {
|
|
149
|
+
* if (event.event === 'issue') expect(event.data.severity).toBe('minor') // typed
|
|
150
|
+
* }
|
|
151
|
+
* client.close()
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```typescript
|
|
156
|
+
* // keepAlive route: wait for the server-side session, then drive it from the test
|
|
157
|
+
* const { spy, routeOptions } = createSSESessionSpy()
|
|
158
|
+
* const { client, serverConnection } = await connectApiSSE(
|
|
159
|
+
* server.baseUrl,
|
|
160
|
+
* tickStreamContract,
|
|
161
|
+
* { pathParams: { channelId: 'c1' }, queryParams: { count: 2 }, headers },
|
|
162
|
+
* { awaitServerConnection: { spy } },
|
|
163
|
+
* )
|
|
164
|
+
* await serverConnection.send('tick', { channelId: 'c1', n: 1 })
|
|
165
|
+
* ```
|
|
166
|
+
*/
|
|
167
|
+
export declare function connectApiSSE<const Contract extends ApiContract>(baseUrl: string, contract: Contract, params: ConnectApiSSEParams<Contract>): Promise<ApiSSEHttpClient<Contract>>;
|
|
168
|
+
export declare function connectApiSSE<const Contract extends ApiContract, TSession extends SpiedSSESession>(baseUrl: string, contract: Contract, params: ConnectApiSSEParams<Contract>, options: ConnectApiSSEWithSpyOptions<TSession>): Promise<ConnectApiSSEResult<Contract, TSession>>;
|