@kubb/plugin-client 5.10.0-canary.20261105T162357 → 5.10.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,274 @@
1
+ import type { DataShape, NativeRequest, NativeResponse, Options, RequestResult, SuccessOf } from './client.ts'
2
+ import { isValidationEnabled, runValidator, type StandardSchemaValidator, type ValidateOption, type ValidationErrorHandler } from './standardSchema.ts'
3
+
4
+ /**
5
+ * One decoded Server-Sent Event. `data` is parsed as JSON when valid and kept as the raw string otherwise,
6
+ * `event` is `message` when the server sent no `event` field, and `id` is the last event ID the stream has set.
7
+ */
8
+ export type ServerSentEvent<TData = unknown> = {
9
+ data: TData
10
+ event: string
11
+ id?: string
12
+ retry?: number
13
+ }
14
+
15
+ /** An event the document types by name: the member's own `event` and `data`, plus the `id` and `retry` any event may carry. */
16
+ export type TypedEvent<TEvent> = TEvent & Pick<ServerSentEvent, 'id' | 'retry'>
17
+
18
+ /** Per-call options of a streamed operation, passed next to its request params. */
19
+ export type EventStreamOptions = {
20
+ /**
21
+ * The largest number of characters, line breaks included, one event may hold before the blank line that ends it.
22
+ * A larger event throws a `RangeError` and cancels the stream. Set `Infinity` to turn the limit off.
23
+ * @default 16777216
24
+ */
25
+ maxEventSize?: number
26
+ /**
27
+ * Ends the stream when an event's raw `data` equals this value, such as `[DONE]`.
28
+ */
29
+ terminator?: string
30
+ /**
31
+ * Maps each event before validation.
32
+ */
33
+ transform?: (event: ServerSentEvent) => ServerSentEvent
34
+ }
35
+
36
+ /** The options of a streamed operation: the call's `Options` with the per-call stream options, as `Options` is for a regular call. */
37
+ export type StreamOptions<TData extends DataShape, ThrowOnError extends boolean = true> = Options<TData, ThrowOnError> & EventStreamOptions
38
+
39
+ /**
40
+ * The validation settings a call or its client may set.
41
+ */
42
+ type ValidationSettings = { validate?: ValidateOption; onValidationError?: ValidationErrorHandler }
43
+
44
+ /**
45
+ * What the client resolves a streamed call to: the body as a stream or a decoded value, its content type, and the native response.
46
+ */
47
+ export type EventStreamCall<TResponse> = { data: unknown; error?: unknown; status?: number; contentType?: string; request?: NativeRequest; response: TResponse }
48
+
49
+ /** The payload wrapped in `ServerSentEvent`, or the item itself when the document types it as a whole event. */
50
+ type StreamEvent<TData> = TData extends { event: string } ? TData : ServerSentEvent<TData>
51
+
52
+ /** Parsed events with the fields a `RequestResult` carries. `TData` is the payload, or the typed event. Iterating the result reads `stream`. */
53
+ export type EventStreamResult<TData = unknown, TResponse = NativeResponse> = AsyncIterable<StreamEvent<TData>> & {
54
+ stream: AsyncGenerator<StreamEvent<TData>>
55
+ /** The same events as `stream` on a 2xx response, so `.unwrap()` and `returnType: 'data'` return them. */
56
+ data: AsyncGenerator<StreamEvent<TData>> | undefined
57
+ status: number | undefined
58
+ /** The error body of a failed call with `throwOnError: false`, whose `stream` then yields nothing. */
59
+ error: unknown
60
+ contentType: string | undefined
61
+ request: NativeRequest | undefined
62
+ response: TResponse
63
+ /**
64
+ * The last event ID the server sent, to resume from with a `Last-Event-ID` header on a new call. Empty until the server sends `id`.
65
+ */
66
+ readonly lastEventId: string
67
+ }
68
+
69
+ type StreamParts<TData> = AsyncIterable<StreamEvent<TData>> & { stream: AsyncGenerator<StreamEvent<TData>>; readonly lastEventId: string }
70
+
71
+ type WithEvents<TResult, TData> = TResult extends { error: undefined } ? Omit<TResult, 'data'> & { data: AsyncGenerator<StreamEvent<TData>> } : TResult
72
+
73
+ /** A stream call's result, as `RequestResult` is a regular call's: `status` narrows `error`, and the events are its `data`. Branching on `ThrowOnError` lets a generated call cast to it. */
74
+ export type StreamResult<TResponses, ThrowOnError extends boolean = true, TData = SuccessOf<TResponses>> = ThrowOnError extends true
75
+ ? WithEvents<RequestResult<TResponses, true>, TData> & StreamParts<TData>
76
+ : WithEvents<RequestResult<TResponses, false>, TData> & StreamParts<TData>
77
+
78
+ /** What a stream operation returns with `returnType: 'data'`, as `UnwrappedResult` is for a regular call. */
79
+ export type UnwrappedStreamResult<TResponses, ThrowOnError extends boolean = true, TData = SuccessOf<TResponses>> = ThrowOnError extends true
80
+ ? AsyncGenerator<StreamEvent<TData>>
81
+ : StreamResult<TResponses, false, TData>
82
+
83
+ /** The last event ID, shared with the result so it can be read after the loop. */
84
+ type Cursor = { lastEventId: string }
85
+
86
+ async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
87
+ if (!('getReader' in stream)) {
88
+ yield* stream
89
+ return
90
+ }
91
+
92
+ const reader = stream.getReader()
93
+ try {
94
+ while (true) {
95
+ const { done, value } = await reader.read()
96
+ if (done) return
97
+ yield value
98
+ }
99
+ } finally {
100
+ await reader.cancel().catch(() => {})
101
+ }
102
+ }
103
+
104
+ function parseData<TData>(raw: string): TData {
105
+ try {
106
+ return JSON.parse(raw) as TData
107
+ } catch {
108
+ return raw as TData
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Parses a `text/event-stream` body as the WHATWG event stream specification does: an event is dispatched at its blank line
114
+ * when it holds `data`, `event` defaults to `message`, `id` carries across events until an empty `id` resets it, `retry` needs
115
+ * all digits, and an unfinished event at the end is dropped. `data` is parsed as JSON when it is valid and kept as text
116
+ * otherwise. Consume with `for await`, and break the loop to cancel the stream.
117
+ */
118
+ type EventStreamInput = ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>
119
+
120
+ export async function* parseEventStream<TData = unknown>(
121
+ input: EventStreamInput | (Pick<EventStreamOptions, 'maxEventSize' | 'terminator'> & { stream: EventStreamInput; cursor?: Cursor }),
122
+ ): AsyncGenerator<ServerSentEvent<TData>> {
123
+ // The stream alone is the form earlier releases took.
124
+ const { stream, maxEventSize = 16 * 1024 * 1024, terminator, cursor = { lastEventId: '' } } = isStreamBody(input) ? { stream: input } : input
125
+ const decoder = new TextDecoder()
126
+ const tooLarge = `Server-sent event exceeds maxEventSize of ${maxEventSize} characters`
127
+ let type = ''
128
+ let data: Array<string> = []
129
+ let retry: number | undefined
130
+ let size = 0
131
+ let line = ''
132
+ let endsWithCR = false
133
+ let pendingId = cursor.lastEventId
134
+
135
+ const readField = (part: string) => {
136
+ const index = part.indexOf(':')
137
+ const name = index === -1 ? part : part.slice(0, index)
138
+ const value = index === -1 ? '' : part.slice(index + 1).replace(/^ /, '')
139
+ switch (name) {
140
+ case 'data':
141
+ data.push(value)
142
+ break
143
+ case 'event':
144
+ type = value
145
+ break
146
+ case 'id':
147
+ if (!value.includes('\u0000')) pendingId = value
148
+ break
149
+ case 'retry':
150
+ if (/^\d+$/.test(value)) retry = Number(value)
151
+ }
152
+ }
153
+
154
+ for await (const chunk of readBytes(stream)) {
155
+ const decoded = decoder.decode(chunk, { stream: true })
156
+ if (!decoded) continue
157
+ // A CRLF split across chunks already ended its line on the `\r`.
158
+ const text: string = endsWithCR && decoded.startsWith('\n') ? decoded.slice(1) : decoded
159
+ endsWithCR = text.endsWith('\r')
160
+
161
+ const parts = text.split(/\r\n|\r|\n/)
162
+ parts[0] = line + parts[0]
163
+ line = parts.pop() ?? ''
164
+ for (const part of parts) {
165
+ if (!part) {
166
+ // The ID is committed at the blank line, so a drop inside an event does not report an ID past the last full event.
167
+ cursor.lastEventId = pendingId
168
+ if (data.length) {
169
+ const raw = data.join('\n')
170
+ if (raw === terminator) return
171
+ yield {
172
+ data: parseData<TData>(raw),
173
+ event: type || 'message',
174
+ ...(cursor.lastEventId ? { id: cursor.lastEventId } : {}),
175
+ ...(retry !== undefined ? { retry } : {}),
176
+ }
177
+ }
178
+ type = ''
179
+ data = []
180
+ retry = undefined
181
+ size = 0
182
+ continue
183
+ }
184
+ // A comment line is dropped and does not count toward the event, but must fit the limit on its own.
185
+ if (part.startsWith(':')) {
186
+ if (part.length + 1 > maxEventSize) throw new RangeError(tooLarge)
187
+ continue
188
+ }
189
+ // A finished line counts one more character for its line break.
190
+ if (size + part.length + 1 > maxEventSize) throw new RangeError(tooLarge)
191
+ size += part.length + 1
192
+ readField(part)
193
+ }
194
+ if ((line.startsWith(':') ? line.length : size + line.length) > maxEventSize) throw new RangeError(tooLarge)
195
+ }
196
+ }
197
+
198
+ function isStreamBody(data: unknown): data is ReadableStream<Uint8Array> | AsyncIterable<Uint8Array> {
199
+ return !!data && typeof data === 'object' && (Symbol.asyncIterator in data || 'getReader' in data)
200
+ }
201
+
202
+ /**
203
+ * Reads a call as typed Server-Sent Events, transformed and validated one by one, until the terminator or the end of the
204
+ * stream. A successful call whose body is not a stream, such as a decoded JSON answer, throws a `TypeError` with the body
205
+ * as its `cause`. A dropped connection ends the loop with the transport's error; `lastEventId` on the result is the ID
206
+ * to resume from.
207
+ */
208
+ export async function toEventStream<TData = unknown, TResponse = NativeResponse>(
209
+ call: Promise<EventStreamCall<TResponse>>,
210
+ {
211
+ client,
212
+ config: options = {},
213
+ ...meta
214
+ }: {
215
+ /**
216
+ * The client the call went through, for the validation settings a call does not set itself.
217
+ */
218
+ client?: { getConfig: () => ValidationSettings }
219
+ config?: EventStreamOptions & ValidationSettings
220
+ /**
221
+ * `event` when the document types each event with its own `event` and `data`, or `data` when it types only the payload.
222
+ * @default 'data'
223
+ */
224
+ shape?: 'event' | 'data'
225
+ /**
226
+ * Validates each event (`shape: 'event'`) or its `data` (`shape: 'data'`), after `transform`.
227
+ */
228
+ validator?: StandardSchemaValidator
229
+ } = {},
230
+ ): Promise<EventStreamResult<TData, TResponse>> {
231
+ const defaults = client?.getConfig() ?? {}
232
+ const onValidationError = options.onValidationError ?? defaults.onValidationError
233
+ const validator = isValidationEnabled(options.validate ?? defaults.validate, 'response') ? meta.validator : undefined
234
+ const cursor: Cursor = { lastEventId: '' }
235
+ const { data: body, error, status, contentType, request, response } = await call
236
+ const ok = status === undefined || (status >= 200 && status < 300)
237
+ // A transport decodes a JSON answer, so a success that is not a stream would otherwise end as an empty stream.
238
+ // A 204 or empty answer has no body, `undefined` or `null`, and still ends as an empty stream.
239
+ if (ok && body != null && !isStreamBody(body)) {
240
+ // `cause` is assigned, since the constructor option needs the ES2022 library a generated client cannot assume.
241
+ throw Object.assign(new TypeError(`Expected a text/event-stream body, got ${contentType ?? 'a body without a content type'}`), { cause: body })
242
+ }
243
+
244
+ const validateEvent = async (event: ServerSentEvent): Promise<ServerSentEvent> => {
245
+ if (!validator) return event
246
+ const value = meta.shape === 'event' ? event : event.data
247
+ const validated = await runValidator({ validator, value, context: { direction: 'response', source: 'body' }, onValidationError })
248
+ // The schema may not declare `id` and `retry`, so the parsed value is merged over the event instead of replacing it.
249
+ return meta.shape === 'event' ? { ...event, ...(validated as object) } : { ...event, data: validated }
250
+ }
251
+
252
+ async function* events(): AsyncGenerator<StreamEvent<TData>> {
253
+ if (!isStreamBody(body)) return
254
+ // A consumer `break` or a failed event closes this loop, which cancels the reader.
255
+ for await (const event of parseEventStream({ stream: body, maxEventSize: options.maxEventSize, terminator: options.terminator, cursor })) {
256
+ yield (await validateEvent(options.transform ? options.transform(event) : event)) as StreamEvent<TData>
257
+ }
258
+ }
259
+
260
+ const stream = events()
261
+ return {
262
+ stream,
263
+ data: ok ? stream : undefined,
264
+ status,
265
+ error,
266
+ contentType,
267
+ request,
268
+ response,
269
+ get lastEventId() {
270
+ return cursor.lastEventId
271
+ },
272
+ [Symbol.asyncIterator]: () => stream,
273
+ }
274
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * A Standard Schema-compatible validator: a minimal duck-type covering Zod v3/v4, valibot, and
3
+ * arktype schemas. Only the `~standard.validate` method is required at runtime.
4
+ */
5
+ export type StandardSchemaValidator<TOutput = unknown> = {
6
+ readonly '~standard': {
7
+ validate(value: unknown): StandardSchemaResult<TOutput> | Promise<StandardSchemaResult<TOutput>>
8
+ }
9
+ }
10
+
11
+ /**
12
+ * The two possible outcomes of a Standard Schema `validate` call. A successful result carries
13
+ * `value`; a failed result carries `issues`.
14
+ */
15
+ export type StandardSchemaResult<TOutput> = { readonly value: TOutput; readonly issues?: undefined } | { readonly issues: ReadonlyArray<StandardSchemaIssue> }
16
+
17
+ /**
18
+ * One validation issue from a Standard Schema `validate` call.
19
+ */
20
+ export type StandardSchemaIssue = {
21
+ readonly message?: string
22
+ readonly path?: ReadonlyArray<PropertyKey | { readonly key: PropertyKey }>
23
+ }
24
+
25
+ /**
26
+ * Thrown by `validateStandardSchema` when validation fails. Carries the raw `issues` array from
27
+ * the schema's `validate` result so callers receive a uniform error shape regardless of which
28
+ * schema library is in use.
29
+ */
30
+ export class ParseError extends Error {
31
+ readonly issues: ReadonlyArray<StandardSchemaIssue>
32
+
33
+ constructor({ issues, message }: { issues: ReadonlyArray<StandardSchemaIssue>; message?: string }) {
34
+ super(message ?? 'Validation failed')
35
+ this.name = 'ParseError'
36
+ this.issues = issues
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Validates `value` against a Standard Schema-compatible `schema`. Returns the parsed output on
42
+ * success; throws `ParseError` with the schema's `issues` on failure. Handles both sync and async
43
+ * `validate` implementations transparently.
44
+ *
45
+ * @example
46
+ * const pet = await validateStandardSchema(PetSchema, rawData)
47
+ */
48
+ export async function validateStandardSchema<TOutput>(schema: StandardSchemaValidator<TOutput>, value: unknown): Promise<TOutput> {
49
+ const result = await schema['~standard'].validate(value)
50
+ if (result.issues) {
51
+ throw new ParseError({ issues: result.issues })
52
+ }
53
+ return (result as { value: TOutput }).value
54
+ }
55
+
56
+ /**
57
+ * Wraps an object schema so its keys match case-insensitively.
58
+ * Keys the schema does not declare pass through untouched.
59
+ * The declared keys are validated with `schema` under their declared names.
60
+ * Every key, and every issue path, keeps the casing the caller used.
61
+ *
62
+ * @param schema Standard Schema-compatible object schema.
63
+ * @param entries The record of keys the passed object schema validates.
64
+ * @example
65
+ * ```ts
66
+ * validator: { headers: toHeadersStandardSchema(putEventHeadersSchema, putEventHeadersSchema.shape) }
67
+ * ```
68
+ */
69
+ export function toHeadersStandardSchema(schema: StandardSchemaValidator, entries: Record<string, unknown>): StandardSchemaValidator {
70
+ const declared = new Map(Object.keys(entries).map((name) => [name.toLowerCase(), name]))
71
+
72
+ function validate(value: unknown): StandardSchemaResult<unknown> | Promise<StandardSchemaResult<unknown>> {
73
+ if (typeof value !== 'object' || value === null) {
74
+ return { issues: [{ message: 'Expected an object' }] }
75
+ }
76
+
77
+ const originalKeys = new Map<string, string>()
78
+ const declaredValues: Record<string, unknown> = {}
79
+ const undeclaredValues: Record<string, unknown> = {}
80
+ for (const [key, entry] of Object.entries(value)) {
81
+ const name = declared.get(key.toLowerCase())
82
+ if (name === undefined) {
83
+ undeclaredValues[key] = entry
84
+ continue
85
+ }
86
+ originalKeys.set(name, key)
87
+ declaredValues[name] = entry
88
+ }
89
+
90
+ const toOriginal = (key: string) => originalKeys.get(key) ?? key
91
+ const settle = (result: StandardSchemaResult<unknown>): StandardSchemaResult<unknown> => {
92
+ if (result.issues) {
93
+ return {
94
+ issues: result.issues.map((issue): StandardSchemaIssue => ({
95
+ ...issue,
96
+ path: issue.path?.map((segment, index) => (index === 0 && typeof segment === 'string' ? toOriginal(segment) : segment)),
97
+ })),
98
+ }
99
+ }
100
+ const validated = Object.entries((result.value ?? {}) as Record<string, unknown>).map(([key, entry]) => [toOriginal(key), entry])
101
+ return { value: { ...undeclaredValues, ...Object.fromEntries(validated) } }
102
+ }
103
+
104
+ const result = schema['~standard'].validate(declaredValues)
105
+ return result instanceof Promise ? result.then(settle) : settle(result)
106
+ }
107
+
108
+ return { '~standard': { validate } }
109
+ }
110
+
111
+ /**
112
+ * Identifies the value and request that failed validation in `onValidationError`.
113
+ */
114
+ export type ValidationErrorContext = {
115
+ value: unknown
116
+ direction: 'request' | 'response' | 'error'
117
+ source: 'body' | 'path' | 'query' | 'headers'
118
+ method?: string
119
+ url?: string
120
+ status?: number
121
+ }
122
+
123
+ /**
124
+ * Return `{ value }` to substitute a failed value, including `undefined`. Returning nothing rethrows the `ParseError`.
125
+ */
126
+ export type ValidationErrorHandler = (error: ParseError, context: ValidationErrorContext) => { value: unknown } | void | Promise<{ value: unknown } | void>
127
+
128
+ /**
129
+ * Turns generated validation on or off. `false` skips every validator, an object skips only the directions set to `false`:
130
+ * `request` is the body, `params` are path, query, and header params, `response` and `error` are the response bodies.
131
+ * @default true
132
+ */
133
+ export type ValidateOption = boolean | { request?: boolean; params?: boolean; response?: boolean; error?: boolean }
134
+
135
+ export function isValidationEnabled(validate: ValidateOption | undefined, direction: 'request' | 'params' | 'response' | 'error'): boolean {
136
+ return typeof validate === 'object' ? validate[direction] !== false : validate !== false
137
+ }
138
+
139
+ /**
140
+ * Runs a validator when there is one, handing a `ParseError` to `onValidationError` first, which may substitute the value.
141
+ */
142
+ export async function runValidator<T>({
143
+ validator,
144
+ value,
145
+ context,
146
+ onValidationError,
147
+ }: {
148
+ validator: StandardSchemaValidator<T> | undefined
149
+ value: T
150
+ context: Omit<ValidationErrorContext, 'value'>
151
+ onValidationError: ValidationErrorHandler | undefined
152
+ }): Promise<T> {
153
+ if (!validator) return value
154
+ try {
155
+ return await validateStandardSchema(validator, value)
156
+ } catch (error) {
157
+ if (!onValidationError || !(error instanceof ParseError)) throw error
158
+ const handled = await onValidationError(error, { ...context, value })
159
+ if (!handled) throw error
160
+ return handled.value as T
161
+ }
162
+ }