@kubb/plugin-fetch 5.0.0-beta.76 → 5.0.0-beta.79

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.
95
+ */
96
+ export type Deserializer<T = unknown> = (raw: unknown, contentType: string) => T | Promise<T>
97
+
98
+ /**
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.
100
+ */
101
+ export type ContentBodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
102
+
103
+ /**
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.
76
107
  */
77
- export type QuerySerializer = (params: Record<string, unknown>) => string
108
+ export type Codec = {
109
+ serialize?: ContentBodySerializer
110
+ deserialize?: Deserializer
111
+ }
78
112
 
79
113
  /**
80
- * Serializes the request body. JSON by default; `FormData`, `URLSearchParams`, `Blob`,
81
- * `ArrayBuffer`, and string bodies pass through untouched.
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`.
82
115
  */
83
- export type BodySerializer = (body: unknown, contentType?: string) => BodyInit | undefined
116
+ export type ContentType = string | { request?: string; response?: string }
84
117
 
85
118
  /**
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).
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).
89
123
  */
90
- export type Parser<T = unknown> = (value: T) => T | Promise<T>
124
+ export type Validator<T = unknown> = StandardSchemaValidator<T>
91
125
 
92
126
  /**
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.
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,20 +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
- * The request a generated function hands to the runtime. `body` / `headers` / `path` / `query` come
117
- * from the grouped options; everything else is plain request configuration.
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`.
148
+ */
149
+ export type FetchOptions = RequestInit & { next?: Record<string, unknown> }
150
+
151
+ /**
152
+ * The request a generated function hands to the runtime, with `body` / `headers` / `path` / `query` from the grouped options.
118
153
  */
119
154
  export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Response> = {
120
155
  baseURL?: string
@@ -123,18 +158,21 @@ export type RequestConfig<TBody = unknown, TRequest = Request, TResponse = Respo
123
158
  path?: Record<string, unknown>
124
159
  query?: unknown
125
160
  params?: unknown
161
+ cookies?: Record<string, unknown>
126
162
  body?: TBody
127
163
  headers?: HeadersInit
164
+ styles?: Styles
128
165
  signal?: AbortSignal
129
166
  credentials?: RequestCredentials
130
- contentType?: string
167
+ options?: FetchOptions
168
+ contentType?: ContentType
131
169
  responseType?: ResponseType
132
170
  throwOnError?: boolean
133
171
  client?: ClientInstance<TRequest, TResponse>
134
172
  transport?: Transport<TRequest, TResponse>
135
- querySerializer?: QuerySerializer
136
- bodySerializer?: BodySerializer
137
- parser?: { request?: Parser; response?: Parser; error?: Parser }
173
+ serializer?: Serializers
174
+ codecs?: Record<string, Codec>
175
+ validator?: { request?: Validator; response?: Validator; error?: Validator }
138
176
  security?: Array<Auth>
139
177
  auth?: AuthResolver
140
178
  }
@@ -153,23 +191,22 @@ export type Options<TData extends DataShape, ThrowOnError extends boolean = true
153
191
  }
154
192
 
155
193
  /**
156
- * Client-level configuration shared by every call an instance makes. Per-call `RequestConfig`
157
- * overrides these.
194
+ * Client-level configuration shared by every call an instance makes, overridden by the per-call `RequestConfig`.
158
195
  */
159
196
  export type ClientConfig<TRequest = Request, TResponse = Response> = {
160
197
  baseURL?: string
161
198
  headers?: HeadersInit
162
199
  credentials?: RequestCredentials
200
+ options?: FetchOptions
163
201
  throwOnError?: boolean
164
202
  transport?: Transport<TRequest, TResponse>
165
- querySerializer?: QuerySerializer
166
- bodySerializer?: BodySerializer
203
+ serializer?: Serializers
204
+ codecs?: Record<string, Codec>
167
205
  auth?: AuthResolver
168
206
  }
169
207
 
170
208
  /**
171
- * The normalized request the transport receives. The shared core does all serialization, auth, and
172
- * header work; the transport only performs the send.
209
+ * The normalized request the transport receives, with all serialization, auth, and header work already done.
173
210
  */
174
211
  export type ResolvedRequest = {
175
212
  url: string
@@ -178,25 +215,25 @@ export type ResolvedRequest = {
178
215
  body?: BodyInit
179
216
  signal?: AbortSignal
180
217
  credentials?: RequestCredentials
218
+ options?: FetchOptions
181
219
  responseType?: ResponseType
182
220
  }
183
221
 
184
222
  /**
185
- * What a transport returns: the parsed body plus the native request/response objects, kept reachable
186
- * 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.
187
224
  */
188
225
  export type TransportResult<TData = unknown, TRequest = Request, TResponse = Response> = {
189
226
  data: TData
190
227
  status: number
191
228
  statusText: string
192
229
  headers: Headers
230
+ contentType?: string
193
231
  request: TRequest
194
232
  response: TResponse
195
233
  }
196
234
 
197
235
  /**
198
- * The per-plugin send. plugin-fetch wraps `globalThis.fetch`, plugin-axios an axios instance, and
199
- * plugin-ky a ky instance. Supplied to `createClientCore` as `defaultTransport`.
236
+ * The per-plugin send, supplied to `createClientCore` as `defaultTransport`.
200
237
  */
201
238
  export type Transport<TRequest = Request, TResponse = Response> = (request: ResolvedRequest) => Promise<TransportResult<unknown, TRequest, TResponse>>
202
239
 
@@ -207,18 +244,15 @@ export type CallResult<TRequest = Request, TResponse = Response> = {
207
244
  status: number
208
245
  data: unknown
209
246
  error: unknown
247
+ contentType: string | undefined
210
248
  request: TRequest
211
249
  response: TResponse
212
250
  }
213
251
 
214
- /**
215
- * A registered interceptor with its ejection id.
216
- */
217
252
  export type InterceptorFn<T> = (value: T) => T | Promise<T>
218
253
 
219
254
  /**
220
- * A single interceptor channel request, response, or error with a transport-agnostic
221
- * `use` / `eject` / `update` API.
255
+ * A single interceptor channel with a transport-agnostic `use` / `eject` / `update` API.
222
256
  */
223
257
  export type InterceptorStack<T> = {
224
258
  use: (fn: InterceptorFn<T>) => number
@@ -250,22 +284,23 @@ export type ClientInstance<TRequest = Request, TResponse = Response> = {
250
284
  }
251
285
 
252
286
  /**
253
- * Thrown for responses outside the 2xx range, so a resolved call always means success. The parsed
254
- * 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.
255
288
  */
256
289
  export class ResponseError<TError = unknown, TRequest = Request, TResponse = Response> extends Error {
257
290
  data: TError
258
291
  status: number
259
292
  statusText: string
293
+ contentType: string | undefined
260
294
  request: TRequest
261
295
  response: TResponse
262
296
 
263
- 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 }) {
264
298
  super(`Request failed with status ${config.status}${config.statusText ? ` ${config.statusText}` : ''}`)
265
299
  this.name = 'ResponseError'
266
300
  this.data = config.data
267
301
  this.status = config.status
268
302
  this.statusText = config.statusText
303
+ this.contentType = config.contentType
269
304
  this.request = config.request
270
305
  this.response = config.response
271
306
  }
@@ -273,74 +308,6 @@ export class ResponseError<TError = unknown, TRequest = Request, TResponse = Res
273
308
 
274
309
  export type ResponseErrorConfig<TError = unknown> = ResponseError<TError>
275
310
 
276
- function isFormBody(body: unknown): body is BodyInit {
277
- return (
278
- body instanceof FormData ||
279
- body instanceof URLSearchParams ||
280
- body instanceof Blob ||
281
- body instanceof ArrayBuffer ||
282
- ArrayBuffer.isView(body) ||
283
- typeof body === 'string'
284
- )
285
- }
286
-
287
- function appendFormDataValue(formData: FormData, key: string, value: unknown): void {
288
- if (value === undefined || value === null) return
289
- if (value instanceof Blob) formData.append(key, value)
290
- else if (value instanceof Date) formData.append(key, value.toISOString())
291
- else if (typeof value === 'object') formData.append(key, JSON.stringify(value))
292
- else formData.append(key, String(value))
293
- }
294
-
295
- /**
296
- * Default body serializer: passes binary/form bodies through and JSON-serializes everything else.
297
- * For `multipart/form-data` plain objects become `FormData` and for
298
- * `application/x-www-form-urlencoded` they become `URLSearchParams`.
299
- */
300
- export const defaultBodySerializer: BodySerializer = (body, contentType) => {
301
- if (body === undefined || body === null) return undefined
302
- if (isFormBody(body)) return body as BodyInit
303
- if (contentType?.includes('multipart/form-data')) {
304
- const formData = new FormData()
305
- for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
306
- if (Array.isArray(value)) for (const item of value) appendFormDataValue(formData, key, item)
307
- else appendFormDataValue(formData, key, value)
308
- }
309
- return formData
310
- }
311
- if (contentType?.includes('application/x-www-form-urlencoded')) {
312
- return new URLSearchParams(body as Record<string, string>)
313
- }
314
- return JSON.stringify(body)
315
- }
316
-
317
- function appendQueryValue(search: URLSearchParams, key: string, value: unknown): void {
318
- if (value === undefined || value === null) return
319
- if (Array.isArray(value)) {
320
- for (const item of value) appendQueryValue(search, key, item)
321
- return
322
- }
323
- if (typeof value === 'object') {
324
- for (const [prop, propValue] of Object.entries(value as Record<string, unknown>)) {
325
- appendQueryValue(search, `${key}[${prop}]`, propValue)
326
- }
327
- return
328
- }
329
- search.append(key, String(value))
330
- }
331
-
332
- /**
333
- * Default query serializer: arrays explode into repeated keys and nested objects use the
334
- * `deepObject` style (`key[prop]=value`).
335
- */
336
- export const defaultQuerySerializer: QuerySerializer = (params) => {
337
- const search = new URLSearchParams()
338
- for (const [key, value] of Object.entries(params)) {
339
- appendQueryValue(search, key, value)
340
- }
341
- return search.toString()
342
- }
343
-
344
311
  function serializeHeaders(headers: HeadersInit | undefined): Record<string, string> {
345
312
  if (!headers) return {}
346
313
  const entries = Array.isArray(headers) ? headers : Object.entries(headers)
@@ -357,21 +324,30 @@ function mergeHeaders(...sources: Array<HeadersInit | undefined>): Record<string
357
324
  }
358
325
 
359
326
  /**
360
- * Joins the base and request URL parts, interpolates `{param}` segments from the path params
361
- * (URL-encoded), and appends the serialized query. Shared by the send path and `getUrl` so both
362
- * produce an identical URL.
363
- */
364
- 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 {
365
342
  const path = parts
366
343
  .filter(Boolean)
367
344
  .join('')
368
- .replace(/\{([^{}]+)\}/g, (_, key: string) => encodeURIComponent(String(pathParams[key] ?? '')))
345
+ .replace(/\{([^{}]+)\}/g, (_, key: string) => pathSerializer({ name: key, value: pathParams[key], options: pathStyles?.[key] }))
369
346
  return path + (search ? `?${search}` : '')
370
347
  }
371
348
 
372
349
  /**
373
- * Creates a transport-agnostic interceptor channel. Interceptors run in registration order; `eject`
374
- * 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.
375
351
  */
376
352
  export function createInterceptorStack<T>(): InterceptorStack<T> {
377
353
  let entries: Array<{ id: number; fn: InterceptorFn<T> }> = []
@@ -400,10 +376,7 @@ export function createInterceptorStack<T>(): InterceptorStack<T> {
400
376
  }
401
377
 
402
378
  /**
403
- * Walks the per-operation security in order and places the first resolved token on the request,
404
- * mutating `headers` / `query` in place. Bearer (and oauth2 / openIdConnect) tokens become a `Bearer`
405
- * Authorization header, basic credentials are base64-encoded, and an apiKey is placed under its
406
- * `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.
407
380
  */
408
381
  export async function resolveAuth(params: {
409
382
  security: Array<Auth> | undefined
@@ -430,14 +403,38 @@ export async function resolveAuth(params: {
430
403
  }
431
404
  }
432
405
 
433
- async function runParser<T>(parser: Parser | undefined, value: T): Promise<T> {
434
- if (!parser) return value
435
- 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)
409
+ }
410
+
411
+ /**
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)
436
426
  }
437
427
 
438
428
  /**
439
- * Builds the shared client core bound to a transport. Each plugin calls this with its
440
- * `defaultTransport` and exports the resulting instance as `client`, plus a `createClient` factory.
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.
441
438
  */
442
439
  export function createClientCore<TRequest = Request, TResponse = Response>(
443
440
  options: { defaultTransport: Transport<TRequest, TResponse> } & ClientConfig<TRequest, TResponse>,
@@ -453,11 +450,17 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
453
450
 
454
451
  const client = (async <TBody = unknown>(requestConfig: RequestConfig<TBody, TRequest, TResponse>): Promise<CallResult<TRequest, TResponse>> => {
455
452
  const transport = requestConfig.transport ?? config.transport ?? defaultTransport
456
- const querySerializer = requestConfig.querySerializer ?? config.querySerializer ?? defaultQuerySerializer
457
- const bodySerializer = requestConfig.bodySerializer ?? config.bodySerializer ?? defaultBodySerializer
458
-
459
- const headers = mergeHeaders(config.headers, requestConfig.headers)
460
- 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
+ }
461
464
 
462
465
  const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
463
466
 
@@ -468,17 +471,34 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
468
471
  query,
469
472
  })
470
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
+
471
479
  const rawBody = requestConfig.body
472
- const validatedBody = await runParser(requestConfig.parser?.request, rawBody)
473
- 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 })
474
486
  // A FormData body must keep its Content-Type unset so the runtime appends the multipart boundary.
475
487
  if (body instanceof FormData) {
476
488
  delete headers['Content-Type']
477
489
  delete headers['content-type']
478
- } else if (requestConfig.contentType) {
479
- headers['Content-Type'] = requestConfig.contentType
490
+ } else if (requestContentTypeOption) {
491
+ headers['Content-Type'] = requestContentTypeOption
480
492
  }
481
- 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
+ })
500
+
501
+ const options = config.options || requestConfig.options ? { ...config.options, ...requestConfig.options } : undefined
482
502
 
483
503
  let resolvedRequest: ResolvedRequest = {
484
504
  url,
@@ -487,6 +507,7 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
487
507
  body,
488
508
  signal: requestConfig.signal,
489
509
  credentials: requestConfig.credentials,
510
+ options,
490
511
  responseType: requestConfig.responseType,
491
512
  }
492
513
  resolvedRequest = await interceptors.request.run(resolvedRequest)
@@ -497,11 +518,21 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
497
518
  const isSuccess = result.status >= 200 && result.status < 300
498
519
  const throwOnError = requestConfig.throwOnError ?? config.throwOnError ?? true
499
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
+
500
530
  if (!isSuccess && throwOnError) {
501
531
  const error = new ResponseError({
502
- data: result.data,
532
+ data: parsedErrorData,
503
533
  status: result.status,
504
534
  statusText: result.statusText,
535
+ contentType,
505
536
  request: result.request,
506
537
  response: result.response,
507
538
  })
@@ -509,13 +540,14 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
509
540
  throw error
510
541
  }
511
542
 
512
- const data = isSuccess ? await runParser(requestConfig.parser?.response, result.data) : undefined
513
- 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
514
545
 
515
546
  return {
516
547
  status: result.status,
517
548
  data,
518
549
  error,
550
+ contentType,
519
551
  request: result.request,
520
552
  response: result.response,
521
553
  }
@@ -527,9 +559,16 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
527
559
  return config
528
560
  }
529
561
  client.getUrl = (requestConfig) => {
530
- 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
531
564
  const query: Record<string, unknown> = { ...((requestConfig.query ?? requestConfig.params) as Record<string, unknown> | undefined) }
532
- 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
+ })
533
572
  }
534
573
  client.interceptors = interceptors
535
574
  client.createClient = (next) => createClientCore({ defaultTransport, ...config, ...next })
@@ -542,6 +581,7 @@ export function createClientCore<TRequest = Request, TResponse = Response>(
542
581
  */
543
582
  function detectResponseType(contentType: string | null): ResponseType | undefined {
544
583
  if (!contentType) return undefined
584
+ if (contentType.includes('text/event-stream')) return 'stream'
545
585
  if (contentType.includes('application/json') || contentType.includes('text/json')) return 'json'
546
586
  if (contentType.includes('text/')) return 'text'
547
587
  if (contentType.includes('image/') || contentType.includes('application/octet-stream')) return 'blob'
@@ -549,9 +589,7 @@ function detectResponseType(contentType: string | null): ResponseType | undefine
549
589
  }
550
590
 
551
591
  /**
552
- * Parses a `fetch` response body. Empty responses (204/205/304 or no body) resolve to `undefined`.
553
- * An explicit `responseType`, or one detected from the `Content-Type` header, forces the matching
554
- * `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.
555
593
  */
556
594
  async function parseResponse(response: Response, responseType?: ResponseType): Promise<unknown> {
557
595
  if (response.status === 204 || response.status === 205 || response.status === 304 || !response.body) {
@@ -585,11 +623,113 @@ async function parseResponse(response: Response, responseType?: ResponseType): P
585
623
  }
586
624
 
587
625
  /**
588
- * The default transport: builds a native `Request` from the resolved request, sends it through
589
- * `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.
590
729
  */
591
730
  const defaultTransport: Transport = async (request: ResolvedRequest): Promise<TransportResult> => {
592
731
  const init: RequestInit = {
732
+ ...request.options, // cache, mode, redirect, keepalive, duplex, next, …
593
733
  method: request.method,
594
734
  headers: request.headers,
595
735
  body: request.body,
@@ -606,6 +746,7 @@ const defaultTransport: Transport = async (request: ResolvedRequest): Promise<Tr
606
746
  status: response.status,
607
747
  statusText: response.statusText,
608
748
  headers: response.headers,
749
+ contentType: getResponseContentType(response.headers),
609
750
  request: nativeRequest,
610
751
  response,
611
752
  }