@kubb/plugin-fetch 5.0.0-beta.77 → 5.0.0-beta.80

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.
@@ -1,33 +1,54 @@
1
+ import { applyHeaderStyles, defaultBodySerializer, defaultPathSerializer, defaultQuerySerializer, serializeCookies } from './serializers'
2
+ import type { HeadersInit, PathParamStyle, PathSerializer, Serializers, Styles } from './serializers'
3
+ import { type StandardSchemaValidator, validateStandardSchema } from './standardSchema.ts'
4
+
1
5
  /**
2
- * HTTP status codes treated as a success. A resolved call only ever carries a body from one of
3
- * these; everything else is an error (thrown by default, or surfaced on `error`).
6
+ * HTTP status codes treated as a success, everything else is an error.
4
7
  */
5
8
  export type SuccessStatusCode = '200' | '201' | '202' | '203' | '204' | '205' | '206' | '207' | '208' | '226'
6
9
 
7
10
  /**
8
- * The success members of a per-status responses record (`{ '200': ...; '404': ... }`).
11
+ * The success members of a per-status responses record.
9
12
  */
10
13
  export type SuccessOf<TResponses> = TResponses[Extract<keyof TResponses, SuccessStatusCode>]
11
14
 
12
15
  /**
13
- * The error members of a per-status responses record every documented status that is not a 2xx.
16
+ * The error members of a per-status responses record, every documented status that is not a 2xx.
14
17
  */
15
18
  export type ErrorOf<TResponses> = TResponses[Exclude<keyof TResponses, SuccessStatusCode>]
16
19
 
17
20
  /**
18
- * Converts a response record's string status key to its numeric literal (`'200'` becomes `200`).
19
- * Non-numeric keys such as the OpenAPI `default` response stay `number`.
21
+ * Converts a response record's string status key to its numeric literal, leaving non-numeric keys like `default` as `number`.
20
22
  */
21
23
  export type ToStatusNumber<TStatus> = TStatus extends `${infer TNumber extends number}` ? TNumber : number
22
24
 
23
25
  /**
24
- * One result variant for a single documented status. `status` is the numeric literal at the top
25
- * level, so a `switch (result.status)` narrows `data` (a 2xx status) or `error` (everything else) to
26
- * that status's payload.
26
+ * The plain body of a per-status response, unwrapping the `{ contentType; data }` union so an error result keeps the bare body union on `error`.
27
+ */
28
+ export type DataOf<T> = T extends { contentType: string; data: infer TData } ? TData : T
29
+
30
+ /**
31
+ * The success variant for a single status, flattened so the negotiated `contentType` sits next to `data` and `switch (result.contentType)` narrows it.
32
+ */
33
+ export type SuccessVariant<TStatus, TEntry, TRequest, TResponse> = TEntry extends { contentType: string; data: unknown }
34
+ ? TEntry extends { contentType: infer TContentType; data: infer TData }
35
+ ? { status: ToStatusNumber<TStatus>; data: TData; error: undefined; contentType: TContentType; request: TRequest; response: TResponse }
36
+ : never
37
+ : { status: ToStatusNumber<TStatus>; data: TEntry; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
38
+
39
+ /**
40
+ * One result variant for a single documented status, keyed by the numeric `status` so a `switch (result.status)` narrows `data` or `error`.
27
41
  */
28
42
  export type ResultByStatus<TResponses, TStatus extends keyof TResponses, TRequest, TResponse> = TStatus extends SuccessStatusCode
29
- ? { status: ToStatusNumber<TStatus>; data: TResponses[TStatus]; error: undefined; request: TRequest; response: TResponse }
30
- : { status: ToStatusNumber<TStatus>; data: undefined; error: TResponses[TStatus]; request: TRequest; response: TResponse }
43
+ ? SuccessVariant<TStatus, TResponses[TStatus], TRequest, TResponse>
44
+ : {
45
+ status: ToStatusNumber<TStatus>
46
+ data: undefined
47
+ error: DataOf<TResponses[TStatus]>
48
+ contentType: string | undefined
49
+ request: TRequest
50
+ response: TResponse
51
+ }
31
52
 
32
53
  /**
33
54
  * The union of every documented status' result variant.
@@ -37,61 +58,73 @@ export type ResultUnion<TResponses, TRequest, TResponse> = {
37
58
  }[keyof TResponses]
38
59
 
39
60
  /**
40
- * The union of just the success (2xx) status variants. Selected by status code, not by `error`, so an
41
- * untyped (`any`) error payload can never widen a success result's `data`.
61
+ * The union of just the success (2xx) status variants, selected by status code so an untyped error payload can never widen `data`.
42
62
  */
43
63
  export type SuccessResultUnion<TResponses, TRequest, TResponse> = {
44
64
  [TStatus in Extract<keyof TResponses, SuccessStatusCode>]: ResultByStatus<TResponses, TStatus, TRequest, TResponse>
45
65
  }[Extract<keyof TResponses, SuccessStatusCode>]
46
66
 
47
67
  /**
48
- * The shape every generated function returns, discriminated by the top-level `status`. With
49
- * `throwOnError` (the default) a resolved call always means success, so the result is the union of the
50
- * 2xx variants and `error` is `undefined`; without it every documented status is a variant, so a
51
- * `switch (result.status)` (or narrowing on `error`) narrows `data` and `error` to that status's
52
- * payload. Operations with no typed responses fall back to a `status`/`request`/`response`-only result.
68
+ * The shape every generated function returns, discriminated by the top-level `status`, narrowing to the 2xx variants under `throwOnError` and to every documented status without it.
53
69
  */
54
70
  export type RequestResult<TResponses, ThrowOnError extends boolean = true, TRequest = Request, TResponse = Response> = ThrowOnError extends true
55
71
  ? [SuccessResultUnion<TResponses, TRequest, TResponse>] extends [never]
56
- ? { status: number; data: SuccessOf<TResponses>; error: undefined; request: TRequest; response: TResponse }
72
+ ? {
73
+ status: number
74
+ data: SuccessOf<TResponses>
75
+ error: undefined
76
+ contentType: string | undefined
77
+ request: TRequest
78
+ response: TResponse
79
+ }
57
80
  : SuccessResultUnion<TResponses, TRequest, TResponse>
58
81
  : [ResultUnion<TResponses, TRequest, TResponse>] extends [never]
59
- ? { status: number; data: undefined; error: undefined; request: TRequest; response: TResponse }
82
+ ? { status: number; data: undefined; error: undefined; contentType: string | undefined; request: TRequest; response: TResponse }
60
83
  : ResultUnion<TResponses, TRequest, TResponse>
61
84
 
62
85
  /**
63
- * The data-shaped keys of the grouped options object. `Options` subtracts these from the runtime
64
- * `RequestConfig` and adds them back, typed per operation, from the generated `<Name>Request` type.
86
+ * The data-shaped keys of the grouped options object, which `Options` re-adds typed per operation.
65
87
  */
66
- export type DataShape = { body?: unknown; headers?: unknown; path?: unknown; query?: unknown }
88
+ export type DataShape = { body?: unknown; cookies?: unknown; headers?: unknown; path?: unknown; query?: unknown }
67
89
 
68
- export type HeaderValue = string | number | boolean | null | undefined | object
69
- export type HeadersInit = Array<[string, HeaderValue]> | Record<string, HeaderValue>
70
90
  export type RequestCredentials = 'omit' | 'same-origin' | 'include'
71
91
  export type ResponseType = 'arraybuffer' | 'blob' | 'document' | 'json' | 'text' | 'stream'
72
92
 
73
93
  /**
74
- * Serializes the query object into a search string. Array and object members follow the configured
75
- * style (`form` with `explode` by default; `deepObject` for nested objects).
94
+ * Turns a raw response body into a parsed value, registered per media type as a codec's `deserialize` to handle formats the runtime does not decode itself.
76
95
  */
77
- export type QuerySerializer = (params: Record<string, unknown>) => string
96
+ export type Deserializer<T = unknown> = (raw: unknown, contentType: string) => T | Promise<T>
78
97
 
79
98
  /**
80
- * Serializes the request body. JSON by default; `FormData`, `URLSearchParams`, `Blob`,
81
- * `ArrayBuffer`, and string bodies pass through untouched.
99
+ * Serializes a request body for a single media type, registered per content type as a codec's `serialize` to encode formats the default serializer does not handle.
82
100
  */
83
- export type BodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
101
+ export type ContentBodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
84
102
 
85
103
  /**
86
- * Parses a value before it is sent or after it is received, returning the parsed (and optionally
87
- * transformed) value. Wires zod parsing through the per-call `parser.request` / `parser.response` /
88
- * `parser.error` hooks (`error` runs on the error body when a non-2xx call does not throw).
104
+ * A per-content-type codec registered on `codecs`, keyed by content type. `serialize` encodes the
105
+ * request body for that media type and `deserialize` decodes the response body. Either half is
106
+ * optional, so a codec can handle one direction.
89
107
  */
90
- export type Parser<T = unknown> = (value: T) => T | Promise<T>
108
+ export type Codec = {
109
+ serialize?: ContentBodySerializer
110
+ deserialize?: Deserializer
111
+ }
91
112
 
92
113
  /**
93
- * A resolved security scheme carried on each generated call's `security` array. The runtime passes it
94
- * to the configured `auth` resolver and places the returned token accordingly.
114
+ * The per-call content type selection, where a bare string sets the request content type and the object form also sets the response format sent as `Accept`.
115
+ */
116
+ export type ContentType = string | { request?: string; response?: string }
117
+
118
+ /**
119
+ * A Standard Schema validator (zod, valibot, arktype) that parses a value before it is sent or after
120
+ * it is received. `runValidator` runs it through `validateStandardSchema`. Wired through the per-call
121
+ * `validator.request` / `validator.response` / `validator.error` hooks (`error` runs on the error body when a
122
+ * non-2xx call does not throw).
123
+ */
124
+ export type Validator<T = unknown> = StandardSchemaValidator<T>
125
+
126
+ /**
127
+ * A resolved security scheme carried on each generated call's `security` array and passed to the `auth` resolver.
95
128
  */
96
129
  export type Auth = {
97
130
  type: 'http' | 'apiKey' | 'oauth2' | 'openIdConnect'
@@ -101,28 +134,22 @@ export type Auth = {
101
134
  }
102
135
 
103
136
  /**
104
- * The token a consumer returns for a scheme, or `undefined` to skip it. Bearer and basic schemes are
105
- * prefixed by the runtime (basic is base64-encoded), so return the raw token or `user:password`.
137
+ * The raw token a consumer returns for a scheme (or `user:password` for basic), or `undefined` to skip it.
106
138
  */
107
139
  export type AuthToken = string | undefined
108
140
 
109
141
  /**
110
- * Resolves the token for a security scheme: either a static token used for every scheme, or a
111
- * callback called once per scheme on a guarded operation until one returns a token.
142
+ * Resolves the token for a security scheme, either a static token or a callback called per scheme until one returns a token.
112
143
  */
113
144
  export type AuthResolver = AuthToken | ((auth: Auth) => AuthToken | Promise<AuthToken>)
114
145
 
115
146
  /**
116
- * Extra `fetch` init the transport spreads onto every `Request`, an escape hatch for the fields the
117
- * runtime does not set itself: `cache`, `mode`, `redirect`, `keepalive`, `duplex`, and Next.js's
118
- * non-standard `next` (`{ revalidate, tags }`). `method`, `headers`, `body`, `signal`, and
119
- * `credentials` are always controlled by the runtime and override anything set here.
147
+ * Extra `fetch` init the transport spreads onto every `Request`, an escape hatch for fields the runtime does not set itself such as `cache`, `redirect`, and Next.js's `next`.
120
148
  */
121
149
  export type FetchOptions = RequestInit & { next?: Record<string, unknown> }
122
150
 
123
151
  /**
124
- * The request a generated function hands to the runtime. `body` / `headers` / `path` / `query` come
125
- * from the grouped options; everything else is plain request configuration.
152
+ * The request a generated function hands to the runtime, with `body` / `headers` / `path` / `query` from the grouped options.
126
153
  */
127
154
  export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Response> = {
128
155
  baseURL?: string
@@ -131,19 +158,21 @@ export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Respo
131
158
  path?: Record<string, unknown>
132
159
  query?: unknown
133
160
  params?: unknown
161
+ cookies?: Record<string, unknown>
134
162
  body?: TBody
135
163
  headers?: HeadersInit
164
+ styles?: Styles
136
165
  signal?: AbortSignal
137
166
  credentials?: RequestCredentials
138
167
  options?: FetchOptions
139
- contentType?: string
168
+ contentType?: ContentType
140
169
  responseType?: ResponseType
141
170
  throwOnError?: boolean
142
171
  client?: ClientInstance<TRequest, TResponse>
143
172
  transport?: Transport<TRequest, TResponse>
144
- querySerializer?: QuerySerializer
145
- bodySerializer?: BodySerializer
146
- parser?: { request?: Parser; response?: Parser; error?: Parser }
173
+ serializer?: Serializers
174
+ codecs?: Record<string, Codec>
175
+ validator?: { request?: Validator; response?: Validator; error?: Validator }
147
176
  security?: Array<Auth>
148
177
  auth?: AuthResolver
149
178
  }
@@ -162,8 +191,7 @@ export type Options<TData extends DataShape, ThrowOnError extends boolean = true
162
191
  }
163
192
 
164
193
  /**
165
- * Client-level configuration shared by every call an instance makes. Per-call `RequestConfig`
166
- * overrides these.
194
+ * Client-level configuration shared by every call an instance makes, overridden by the per-call `RequestConfig`.
167
195
  */
168
196
  export type ClientConfig<TRequest = Request, TResponse = Response> = {
169
197
  baseURL?: string
@@ -172,14 +200,13 @@ export type ClientConfig<TRequest = Request, TResponse = Response> = {
172
200
  options?: FetchOptions
173
201
  throwOnError?: boolean
174
202
  transport?: Transport<TRequest, TResponse>
175
- querySerializer?: QuerySerializer
176
- bodySerializer?: BodySerializer
203
+ serializer?: Serializers
204
+ codecs?: Record<string, Codec>
177
205
  auth?: AuthResolver
178
206
  }
179
207
 
180
208
  /**
181
- * The normalized request the transport receives. The shared core does all serialization, auth, and
182
- * header work; the transport only performs the send.
209
+ * The normalized request the transport receives, with all serialization, auth, and header work already done.
183
210
  */
184
211
  export type ResolvedRequest = {
185
212
  url: string
@@ -193,21 +220,20 @@ export type ResolvedRequest = {
193
220
  }
194
221
 
195
222
  /**
196
- * What a transport returns: the parsed body plus the native request/response objects, kept reachable
197
- * so status, headers, and the raw body never have to grow the result type.
223
+ * What a transport returns: the parsed body plus the native request and response objects.
198
224
  */
199
225
  export type TransportResult<TData = unknown, TRequest = Request, TResponse = Response> = {
200
226
  data: TData
201
227
  status: number
202
228
  statusText: string
203
229
  headers: Headers
230
+ contentType?: string
204
231
  request: TRequest
205
232
  response: TResponse
206
233
  }
207
234
 
208
235
  /**
209
- * The per-plugin send. plugin-fetch wraps `globalThis.fetch`, plugin-axios an axios instance, and
210
- * plugin-ky a ky instance. Supplied to `createClientCore` as `defaultTransport`.
236
+ * The per-plugin send, supplied to `createClientCore` as `defaultTransport`.
211
237
  */
212
238
  export type Transport<TRequest = Request, TResponse = Response> = (request: ResolvedRequest) => Promise<TransportResult<unknown, TRequest, TResponse>>
213
239
 
@@ -218,18 +244,15 @@ export type CallResult<TRequest = Request, TResponse = Response> = {
218
244
  status: number
219
245
  data: unknown
220
246
  error: unknown
247
+ contentType: string | undefined
221
248
  request: TRequest
222
249
  response: TResponse
223
250
  }
224
251
 
225
- /**
226
- * A registered interceptor with its ejection id.
227
- */
228
252
  export type InterceptorFn<T> = (value: T) => T | Promise<T>
229
253
 
230
254
  /**
231
- * A single interceptor channel request, response, or error with a transport-agnostic
232
- * `use` / `eject` / `update` API.
255
+ * A single interceptor channel with a transport-agnostic `use` / `eject` / `update` API.
233
256
  */
234
257
  export type InterceptorStack<T> = {
235
258
  use: (fn: InterceptorFn<T>) => number
@@ -261,22 +284,23 @@ export type ClientInstance<TRequest = Request, TResponse = Response> = {
261
284
  }
262
285
 
263
286
  /**
264
- * Thrown for responses outside the 2xx range, so a resolved call always means success. The parsed
265
- * error body and the native request/response stay reachable on the error.
287
+ * Thrown for a non-2xx response, so a resolved call always means success.
266
288
  */
267
289
  export class ResponseError<TError = unknown, TRequest = Request, TResponse = Response> extends Error {
268
290
  data: TError
269
291
  status: number
270
292
  statusText: string
293
+ contentType: string | undefined
271
294
  request: TRequest
272
295
  response: TResponse
273
296
 
274
- constructor(config: { data: TError; status: number; statusText: string; request: TRequest; response: TResponse }) {
297
+ constructor(config: { data: TError; status: number; statusText: string; contentType?: string; request: TRequest; response: TResponse }) {
275
298
  super(`Request failed with status ${config.status}${config.statusText ? ` ${config.statusText}` : ''}`)
276
299
  this.name = 'ResponseError'
277
300
  this.data = config.data
278
301
  this.status = config.status
279
302
  this.statusText = config.statusText
303
+ this.contentType = config.contentType
280
304
  this.request = config.request
281
305
  this.response = config.response
282
306
  }
@@ -284,74 +308,6 @@ export class ResponseError<TError = unknown, TRequest = Request, TResponse = Res
284
308
 
285
309
  export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
286
310
 
287
- function isFormBody(body: unknown): body is BodyInit {
288
- return (
289
- body instanceof FormData ||
290
- body instanceof URLSearchParams ||
291
- body instanceof Blob ||
292
- body instanceof ArrayBuffer ||
293
- ArrayBuffer.isView(body) ||
294
- typeof body === 'string'
295
- )
296
- }
297
-
298
- function appendFormDataValue(formData: FormData, key: string, value: unknown): void {
299
- if (value === undefined || value === null) return
300
- if (value instanceof Blob) formData.append(key, value)
301
- else if (value instanceof Date) formData.append(key, value.toISOString())
302
- else if (typeof value === 'object') formData.append(key, JSON.stringify(value))
303
- else formData.append(key, String(value))
304
- }
305
-
306
- /**
307
- * Default body serializer: passes binary/form bodies through and JSON-serializes everything else.
308
- * For `multipart/form-data` plain objects become `FormData` and for
309
- * `application/x-www-form-urlencoded` they become `URLSearchParams`.
310
- */
311
- export const defaultBodySerializer: BodySerializer = (body, contentType) => {
312
- if (body === undefined || body === null) return undefined
313
- if (isFormBody(body)) return body as BodyInit
314
- if (contentType?.includes('multipart/form-data')) {
315
- const formData = new FormData()
316
- for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
317
- if (Array.isArray(value)) for (const item of value) appendFormDataValue(formData, key, item)
318
- else appendFormDataValue(formData, key, value)
319
- }
320
- return formData
321
- }
322
- if (contentType?.includes('application/x-www-form-urlencoded')) {
323
- return new URLSearchParams(body as Record<string, string>)
324
- }
325
- return JSON.stringify(body)
326
- }
327
-
328
- function appendQueryValue(search: URLSearchParams, key: string, value: unknown): void {
329
- if (value === undefined || value === null) return
330
- if (Array.isArray(value)) {
331
- for (const item of value) appendQueryValue(search, key, item)
332
- return
333
- }
334
- if (typeof value === 'object') {
335
- for (const [prop, propValue] of Object.entries(value as Record<string, unknown>)) {
336
- appendQueryValue(search, `${key}[${prop}]`, propValue)
337
- }
338
- return
339
- }
340
- search.append(key, String(value))
341
- }
342
-
343
- /**
344
- * Default query serializer: arrays explode into repeated keys and nested objects use the
345
- * `deepObject` style (`key[prop]=value`).
346
- */
347
- export const defaultQuerySerializer: QuerySerializer = (params) => {
348
- const search = new URLSearchParams()
349
- for (const [key, value] of Object.entries(params)) {
350
- appendQueryValue(search, key, value)
351
- }
352
- return search.toString()
353
- }
354
-
355
311
  function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
356
312
  if (!headers) return {}
357
313
  const entries = Array.isArray(headers) ? headers : Object.entries(headers)
@@ -368,21 +324,30 @@ function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string
368
324
  }
369
325
 
370
326
  /**
371
- * Joins the base and request URL parts, interpolates `{param}` segments from the path params
372
- * (URL-encoded), and appends the serialized query. Shared by the send path and `getUrl` so both
373
- * produce an identical URL.
374
- */
375
- function serializeUrl(parts: Array<string | undefined>, pathParams: Record<string, unknown>, search: string): string {
327
+ * Joins the URL parts, interpolates URL-encoded `{param}` segments, and appends the serialized query, shared by the send path and `getUrl`.
328
+ */
329
+ function serializeUrl({
330
+ parts,
331
+ pathParams,
332
+ search,
333
+ pathSerializer = defaultPathSerializer,
334
+ pathStyles,
335
+ }: {
336
+ parts: Array<string | undefined>
337
+ pathParams: Record<string, unknown>
338
+ search: string
339
+ pathSerializer?: PathSerializer
340
+ pathStyles?: Record<string, PathParamStyle>
341
+ }): string {
376
342
  const path = parts
377
343
  .filter(Boolean)
378
344
  .join('')
379
- .replace(/\{([^{}]+)\}/g, (_, key: string) => encodeURIComponent(String(pathParams[key] ?? '')))
345
+ .replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: pathStyles?.[key] }))
380
346
  return path + (search ? `?${search}` : '')
381
347
  }
382
348
 
383
349
  /**
384
- * Creates a transport-agnostic interceptor channel. Interceptors run in registration order; `eject`
385
- * removes one by id and `update` swaps its function in place without reordering.
350
+ * Creates a transport-agnostic interceptor channel that runs interceptors in registration order.
386
351
  */
387
352
  export function createInterceptorStack<T>(): InterceptorStack<T> {
388
353
  let entries: Array<{ id: number; fn: InterceptorFn<T> }> = []
@@ -411,10 +376,7 @@ export function createInterceptorStack<T>(): InterceptorStack<T> {
411
376
  }
412
377
 
413
378
  /**
414
- * Walks the per-operation security in order and places the first resolved token on the request,
415
- * mutating `headers` / `query` in place. Bearer (and oauth2 / openIdConnect) tokens become a `Bearer`
416
- * Authorization header, basic credentials are base64-encoded, and an apiKey is placed under its
417
- * `name` in the header, query, or cookie.
379
+ * Walks the per-operation security in order and places the first resolved token on the request, mutating `headers` / `query` in place.
418
380
  */
419
381
  export async function resolveAuth(params: {
420
382
  security: Array<Auth> | undefined
@@ -441,14 +403,38 @@ export async function resolveAuth(params: {
441
403
  }
442
404
  }
443
405
 
444
- async function runParser<T>(parser: Parser | undefined, value: T): Promise<T> {
445
- if (!parser) return value
446
- return (await parser(value)) as T
406
+ async function runValidator<T>(validator: Validator<T> | undefined, value: T): Promise<T> {
407
+ if (!validator) return value
408
+ return validateStandardSchema(validator, value)
447
409
  }
448
410
 
449
411
  /**
450
- * Builds the shared client core bound to a transport. Each plugin calls this with its
451
- * `defaultTransport` and exports the resulting instance as `client`, plus a `createClient` factory.
412
+ * The base media type of a `Content-Type` value, lowercased and stripped of any `; charset=...` parameters.
413
+ */
414
+ function baseContentType(value: string | null | undefined): string | undefined {
415
+ if (!value) return undefined
416
+ return value.split(';')[0]!.trim().toLowerCase() || undefined
417
+ }
418
+
419
+ /**
420
+ * Reads the negotiated response content type from the response headers as a base media type.
421
+ */
422
+ function getResponseContentType(headers: Headers | Record<string, string> | undefined): string | undefined {
423
+ if (!headers) return undefined
424
+ const value = headers instanceof Headers ? headers.get('Content-Type') : (headers['Content-Type'] ?? headers['content-type'])
425
+ return baseContentType(value)
426
+ }
427
+
428
+ /**
429
+ * Normalizes the `contentType` option to its `{ request, response }` form, treating a bare string as the request content type.
430
+ */
431
+ function resolveContentType(contentType: ContentType | undefined): { request?: string; response?: string } {
432
+ if (typeof contentType === 'string') return { request: contentType }
433
+ return contentType ?? {}
434
+ }
435
+
436
+ /**
437
+ * Builds the shared client core bound to a transport, exported by each plugin as `client` plus a `createClient` factory.
452
438
  */
453
439
  export function createClientCore<TRequest = Request, TResponse = Response>(
454
440
  options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
@@ -464,11 +450,17 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
464
450
 
465
451
  const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
466
452
  const transport = requestConfig.transport ?? config.transport ?? defaultTransport
467
- const querySerializer = requestConfig.querySerializer ?? config.querySerializer ?? defaultQuerySerializer
468
- const bodySerializer = requestConfig.bodySerializer ?? config.bodySerializer ?? defaultBodySerializer
469
-
470
- const headers = mergeHeaders(config.headers, requestConfig.headers)
471
- const requestContentType = requestConfig.contentType ?? headers['Content-Type'] ?? headers['content-type']
453
+ const querySerializer = requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer
454
+ const bodySerializer = requestConfig.serializer?.body ?? config.serializer?.body ?? defaultBodySerializer
455
+ const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
456
+ const codecs = { ...config.codecs, ...requestConfig.codecs }
457
+
458
+ const headers = mergeHeaders(config.headers, applyHeaderStyles(requestConfig.headers, requestConfig.styles?.header))
459
+ const { request: requestContentTypeOption, response: responseContentType } = resolveContentType(requestConfig.contentType)
460
+ const requestContentType = requestContentTypeOption ?? headers['Content-Type'] ?? headers['content-type']
461
+ if (responseContentType && headers['Accept'] === undefined && headers['accept'] === undefined) {
462
+ headers['Accept'] = responseContentType
463
+ }
472
464
 
473
465
  const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
474
466
 
@@ -479,17 +471,32 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
479
471
  query,
480
472
  })
481
473
 
474
+ if (requestConfig.cookies) {
475
+ const cookie = serializeCookies(requestConfig.cookies, requestConfig.styles?.cookie)
476
+ if (cookie) headers.Cookie = [headers.Cookie, cookie].filter(Boolean).join('; ')
477
+ }
478
+
482
479
  const rawBody = requestConfig.body
483
- const validatedBody = await runParser(requestConfig.parser?.request, rawBody)
484
- const body = bodySerializer(validatedBody, requestContentType)
480
+ const validatedBody = await runValidator(requestConfig.validator?.request, rawBody)
481
+ const requestContentTypeBase = baseContentType(requestContentType)
482
+ const contentCodec = requestContentTypeBase ? codecs[requestContentTypeBase] : undefined
483
+ const body = contentCodec?.serialize
484
+ ? contentCodec.serialize(validatedBody, requestContentType)
485
+ : bodySerializer({ body: validatedBody, contentType: requestContentType, encoding: requestConfig.styles?.body })
485
486
  // A FormData body must keep its Content-Type unset so the runtime appends the multipart boundary.
486
487
  if (body instanceof FormData) {
487
488
  delete headers['Content-Type']
488
489
  delete headers['content-type']
489
- } else if (requestConfig.contentType) {
490
- headers['Content-Type'] = requestConfig.contentType
490
+ } else if (requestContentTypeOption) {
491
+ headers['Content-Type'] = requestContentTypeOption
491
492
  }
492
- const url = serializeUrl([config.baseURL, requestConfig.baseURL, requestConfig.url], requestConfig.path ?? {}, querySerializer(query))
493
+ const url = serializeUrl({
494
+ parts: [config.baseURL, requestConfig.baseURL, requestConfig.url],
495
+ pathParams: requestConfig.path ?? {},
496
+ search: querySerializer(query, requestConfig.styles?.query),
497
+ pathSerializer,
498
+ pathStyles: requestConfig.styles?.path,
499
+ })
493
500
 
494
501
  const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
495
502
 
@@ -511,11 +518,21 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
511
518
  const isSuccess = result.status >= 200 && result.status < 300
512
519
  const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
513
520
 
521
+ const contentType = result.contentType ?? getResponseContentType(result.headers)
522
+ let decoded = result.data
523
+ if (contentType) {
524
+ const codec = codecs[contentType]
525
+ if (codec?.deserialize) decoded = await codec.deserialize(result.data, contentType)
526
+ }
527
+
528
+ const parsedErrorData = !isSuccess ? await runValidator(requestConfig.validator?.error, decoded) : undefined
529
+
514
530
  if (!isSuccess && throwOnError) {
515
531
  const error = new ResponseError({
516
- data: result.data,
532
+ data: parsedErrorData,
517
533
  status: result.status,
518
534
  statusText: result.statusText,
535
+ contentType,
519
536
  request: result.request,
520
537
  response: result.response,
521
538
  })
@@ -523,13 +540,14 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
523
540
  throw error
524
541
  }
525
542
 
526
- const data = isSuccess ? await runParser(requestConfig.parser?.response, result.data) : undefined
527
- const error = isSuccess ? undefined : await runParser(requestConfig.parser?.error, result.data)
543
+ const data = isSuccess ? await runValidator(requestConfig.validator?.response, decoded) : undefined
544
+ const error = isSuccess ? undefined : parsedErrorData
528
545
 
529
546
  return {
530
547
  status: result.status,
531
548
  data,
532
549
  error,
550
+ contentType,
533
551
  request: result.request,
534
552
  response: result.response,
535
553
  }
@@ -541,9 +559,16 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
541
559
  return config
542
560
  }
543
561
  client.getUrl = (requestConfig) => {
544
- const querySerializer = requestConfig.querySerializer ?? config.querySerializer ?? defaultQuerySerializer
562
+ const querySerializer = requestConfig.serializer?.query ?? config.serializer?.query ?? defaultQuerySerializer
563
+ const pathSerializer = requestConfig.serializer?.path ?? config.serializer?.path ?? defaultPathSerializer
545
564
  const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
546
- return serializeUrl([config.baseURL, requestConfig.baseURL, requestConfig.url], requestConfig.path ?? {}, querySerializer(query))
565
+ return serializeUrl({
566
+ parts: [config.baseURL, requestConfig.baseURL, requestConfig.url],
567
+ pathParams: requestConfig.path ?? {},
568
+ search: querySerializer(query, requestConfig.styles?.query),
569
+ pathSerializer,
570
+ pathStyles: requestConfig.styles?.path,
571
+ })
547
572
  }
548
573
  client.interceptors = interceptors
549
574
  client.createClient = (next) => createClientCore({ defaultTransport, ...config, ...next })
@@ -556,6 +581,7 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
556
581
  */
557
582
  function detectResponseType(contentType: string | null): ResponseType | undefined {
558
583
  if (!contentType) return undefined
584
+ if (contentType.includes('text/event-stream')) return 'stream'
559
585
  if (contentType.includes('application/json') || contentType.includes('text/json')) return 'json'
560
586
  if (contentType.includes('text/')) return 'text'
561
587
  if (contentType.includes('image/') || contentType.includes('application/octet-stream')) return 'blob'
@@ -563,9 +589,7 @@ function detectResponseType(contentType: string | null): ResponseType | undefine
563
589
  }
564
590
 
565
591
  /**
566
- * Parses a `fetch` response body. Empty responses (204/205/304 or no body) resolve to `undefined`.
567
- * An explicit `responseType`, or one detected from the `Content-Type` header, forces the matching
568
- * `Response` method; otherwise the body is read as text and `JSON.parse`d, falling back to raw text.
592
+ * Parses a `fetch` response body using the `responseType` (explicit or detected from the `Content-Type`), falling back to JSON-then-text.
569
593
  */
570
594
  async function parseResponse(response: Response, responseType?: ResponseType): Promise<unknown> {
571
595
  if (response.status === 204 || response.status === 205 || response.status === 304 || !response.body) {
@@ -599,8 +623,109 @@ async function parseResponse(response: Response, responseType?: ResponseType): P
599
623
  }
600
624
 
601
625
  /**
602
- * The default transport: builds a native `Request` from the resolved request, sends it through
603
- * `globalThis.fetch`, and returns the parsed body alongside the native request/response objects.
626
+ * One decoded Server-Sent Event, with `data` parsed as JSON when valid and kept as the raw string otherwise.
627
+ */
628
+ export type ServerSentEvent<TData = unknown> = {
629
+ data: TData
630
+ event?: string
631
+ id?: string
632
+ retry?: number
633
+ }
634
+
635
+ async function* readBytes(stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>): AsyncGenerator<Uint8Array> {
636
+ if (!('getReader' in stream)) {
637
+ yield* stream
638
+ return
639
+ }
640
+
641
+ const reader = stream.getReader()
642
+ try {
643
+ while (true) {
644
+ const { done, value } = await reader.read()
645
+ if (done) return
646
+ yield value
647
+ }
648
+ } finally {
649
+ await reader.cancel().catch(() => {})
650
+ }
651
+ }
652
+
653
+ function parseEvent<TData>(raw: string): ServerSentEvent<TData> | undefined {
654
+ const data: Array<string> = []
655
+ const event: ServerSentEvent<TData> = { data: undefined as TData }
656
+ let seen = false
657
+
658
+ for (const line of raw.split('\n')) {
659
+ if (!line || line.startsWith(':')) continue
660
+ seen = true
661
+ const index = line.indexOf(':')
662
+ const field = index === -1 ? line : line.slice(0, index)
663
+ const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, '')
664
+ if (field === 'data') data.push(value)
665
+ else if (field === 'event') event.event = value
666
+ else if (field === 'id') event.id = value
667
+ else if (field === 'retry' && Number.isFinite(Number(value))) event.retry = Number(value)
668
+ }
669
+
670
+ if (!seen) return undefined
671
+
672
+ if (data.length) {
673
+ const joined = data.join('\n')
674
+ try {
675
+ event.data = JSON.parse(joined) as TData
676
+ } catch {
677
+ event.data = joined as TData
678
+ }
679
+ }
680
+ return event
681
+ }
682
+
683
+ /**
684
+ * Parses a `text/event-stream` body into typed Server-Sent Events, consumed with `for await` and stopped early by breaking the loop.
685
+ */
686
+ export async function* parseEventStream<TData = unknown>(
687
+ stream: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>,
688
+ ): AsyncGenerator<ServerSentEvent<TData>> {
689
+ const decoder = new TextDecoder()
690
+ const normalize = (text: string) => text.replace(/\r\n|\r/g, '\n')
691
+ let buffer = ''
692
+
693
+ for await (const chunk of readBytes(stream)) {
694
+ const blocks = normalize(buffer + decoder.decode(chunk, { stream: true })).split('\n\n')
695
+ buffer = blocks.pop() ?? ''
696
+ for (const block of blocks) {
697
+ const event = parseEvent<TData>(block)
698
+ if (event) yield event
699
+ }
700
+ }
701
+
702
+ const event = parseEvent<TData>(normalize(buffer + decoder.decode()))
703
+ if (event) yield event
704
+ }
705
+
706
+ /**
707
+ * The resolved shape returned by a generated `text/event-stream` operation: the typed event
708
+ * `stream` plus the native `response`.
709
+ */
710
+ export type EventStreamResult<TData = unknown, TResponse = Response> = {
711
+ stream: AsyncGenerator<ServerSentEvent<TData>>
712
+ response: TResponse
713
+ }
714
+
715
+ /**
716
+ * Wraps a transport result whose `data` is a streaming body into an `EventStreamResult`, exposing
717
+ * the parsed events as a typed async iterator. Generated SSE operations call this.
718
+ */
719
+ export async function toEventStream<TData = unknown>(result: Promise<{ data: unknown; response: Response }>): Promise<EventStreamResult<TData>> {
720
+ const { data, response } = await result
721
+ return {
722
+ response,
723
+ stream: parseEventStream<TData>(data as ReadableStream<Uint8Array> | AsyncIterable<Uint8Array>),
724
+ }
725
+ }
726
+
727
+ /**
728
+ * The default transport that sends the resolved request through `globalThis.fetch` and returns the parsed body with the native request and response.
604
729
  */
605
730
  const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
606
731
  const init: RequestInit = {
@@ -621,6 +746,7 @@ const defaultTransport: Transport = async (request: ResolvedRequest): Promise<Tr
621
746
  status: response.status,
622
747
  statusText: response.statusText,
623
748
  headers: response.headers,
749
+ contentType: getResponseContentType(response.headers),
624
750
  request: nativeRequest,
625
751
  response,
626
752
  }