From 0f1697b2ab6e2401d0a3534323bb75d5002e8e32 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 22 Aug 2025 18:30:25 -0400 Subject: add sse streaming to sdk --- packages/sdk/js/src/gen/core/auth.gen.ts | 41 ++++ packages/sdk/js/src/gen/core/auth.ts | 39 ---- packages/sdk/js/src/gen/core/bodySerializer.gen.ts | 74 ++++++++ packages/sdk/js/src/gen/core/bodySerializer.ts | 70 ------- packages/sdk/js/src/gen/core/params.gen.ts | 144 ++++++++++++++ packages/sdk/js/src/gen/core/params.ts | 142 -------------- packages/sdk/js/src/gen/core/pathSerializer.gen.ts | 167 ++++++++++++++++ packages/sdk/js/src/gen/core/pathSerializer.ts | 165 ---------------- .../sdk/js/src/gen/core/serverSentEvents.gen.ts | 210 +++++++++++++++++++++ packages/sdk/js/src/gen/core/types.gen.ts | 91 +++++++++ packages/sdk/js/src/gen/core/types.ts | 89 --------- packages/sdk/js/src/gen/core/utils.gen.ts | 109 +++++++++++ 12 files changed, 836 insertions(+), 505 deletions(-) create mode 100644 packages/sdk/js/src/gen/core/auth.gen.ts delete mode 100644 packages/sdk/js/src/gen/core/auth.ts create mode 100644 packages/sdk/js/src/gen/core/bodySerializer.gen.ts delete mode 100644 packages/sdk/js/src/gen/core/bodySerializer.ts create mode 100644 packages/sdk/js/src/gen/core/params.gen.ts delete mode 100644 packages/sdk/js/src/gen/core/params.ts create mode 100644 packages/sdk/js/src/gen/core/pathSerializer.gen.ts delete mode 100644 packages/sdk/js/src/gen/core/pathSerializer.ts create mode 100644 packages/sdk/js/src/gen/core/serverSentEvents.gen.ts create mode 100644 packages/sdk/js/src/gen/core/types.gen.ts delete mode 100644 packages/sdk/js/src/gen/core/types.ts create mode 100644 packages/sdk/js/src/gen/core/utils.gen.ts (limited to 'packages/sdk/js/src/gen/core') diff --git a/packages/sdk/js/src/gen/core/auth.gen.ts b/packages/sdk/js/src/gen/core/auth.gen.ts new file mode 100644 index 000000000..bc7b230f4 --- /dev/null +++ b/packages/sdk/js/src/gen/core/auth.gen.ts @@ -0,0 +1,41 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: "header" | "query" | "cookie" + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string + scheme?: "basic" | "bearer" + type: "apiKey" | "http" +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = typeof callback === "function" ? await callback(auth) : callback + + if (!token) { + return + } + + if (auth.scheme === "bearer") { + return `Bearer ${token}` + } + + if (auth.scheme === "basic") { + return `Basic ${btoa(token)}` + } + + return token +} diff --git a/packages/sdk/js/src/gen/core/auth.ts b/packages/sdk/js/src/gen/core/auth.ts deleted file mode 100644 index e496d4557..000000000 --- a/packages/sdk/js/src/gen/core/auth.ts +++ /dev/null @@ -1,39 +0,0 @@ -export type AuthToken = string | undefined - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: "header" | "query" | "cookie" - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string - scheme?: "basic" | "bearer" - type: "apiKey" | "http" -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = typeof callback === "function" ? await callback(auth) : callback - - if (!token) { - return - } - - if (auth.scheme === "bearer") { - return `Bearer ${token}` - } - - if (auth.scheme === "basic") { - return `Basic ${btoa(token)}` - } - - return token -} diff --git a/packages/sdk/js/src/gen/core/bodySerializer.gen.ts b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts new file mode 100644 index 000000000..066061605 --- /dev/null +++ b/packages/sdk/js/src/gen/core/bodySerializer.gen.ts @@ -0,0 +1,74 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.gen.js" + +export type QuerySerializer = (query: Record) => string + +export type BodySerializer = (body: any) => any + +export interface QuerySerializerOptions { + allowReserved?: boolean + array?: SerializerOptions + object?: SerializerOptions +} + +const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { + if (typeof value === "string" || value instanceof Blob) { + data.append(key, value) + } else if (value instanceof Date) { + data.append(key, value.toISOString()) + } else { + data.append(key, JSON.stringify(value)) + } +} + +const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { + if (typeof value === "string") { + data.append(key, value) + } else { + data.append(key, JSON.stringify(value)) + } +} + +export const formDataBodySerializer = { + bodySerializer: | Array>>(body: T): FormData => { + const data = new FormData() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)) + } else { + serializeFormDataPair(data, key, value) + } + }) + + return data + }, +} + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), +} + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>(body: T): string => { + const data = new URLSearchParams() + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) + } else { + serializeUrlSearchParamsPair(data, key, value) + } + }) + + return data.toString() + }, +} diff --git a/packages/sdk/js/src/gen/core/bodySerializer.ts b/packages/sdk/js/src/gen/core/bodySerializer.ts deleted file mode 100644 index 8a4a13410..000000000 --- a/packages/sdk/js/src/gen/core/bodySerializer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { ArrayStyle, ObjectStyle, SerializerOptions } from "./pathSerializer.js" - -export type QuerySerializer = (query: Record) => string - -export type BodySerializer = (body: any) => any - -export interface QuerySerializerOptions { - allowReserved?: boolean - array?: SerializerOptions - object?: SerializerOptions -} - -const serializeFormDataPair = (data: FormData, key: string, value: unknown): void => { - if (typeof value === "string" || value instanceof Blob) { - data.append(key, value) - } else { - data.append(key, JSON.stringify(value)) - } -} - -const serializeUrlSearchParamsPair = (data: URLSearchParams, key: string, value: unknown): void => { - if (typeof value === "string") { - data.append(key, value) - } else { - data.append(key, JSON.stringify(value)) - } -} - -export const formDataBodySerializer = { - bodySerializer: | Array>>(body: T): FormData => { - const data = new FormData() - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)) - } else { - serializeFormDataPair(data, key, value) - } - }) - - return data - }, -} - -export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => (typeof value === "bigint" ? value.toString() : value)), -} - -export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>(body: T): string => { - const data = new URLSearchParams() - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)) - } else { - serializeUrlSearchParamsPair(data, key, value) - } - }) - - return data.toString() - }, -} diff --git a/packages/sdk/js/src/gen/core/params.gen.ts b/packages/sdk/js/src/gen/core/params.gen.ts new file mode 100644 index 000000000..68ad1a778 --- /dev/null +++ b/packages/sdk/js/src/gen/core/params.gen.ts @@ -0,0 +1,144 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = "body" | "headers" | "path" | "query" + +export type Field = + | { + in: Exclude + /** + * Field name. This is the name we want the user to see and use. + */ + key: string + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string + } + | { + in: Extract + /** + * Key isn't required for bodies. + */ + key?: string + map?: string + } + +export interface Fields { + allowExtra?: Partial> + args?: ReadonlyArray +} + +export type FieldsConfig = ReadonlyArray + +const extraPrefixesMap: Record = { + $body_: "body", + $headers_: "headers", + $path_: "path", + $query_: "query", +} +const extraPrefixes = Object.entries(extraPrefixesMap) + +type KeyMap = Map< + string, + { + in: Slot + map?: string + } +> + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map() + } + + for (const config of fields) { + if ("in" in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }) + } + } else if (config.args) { + buildKeyMap(config.args, map) + } + } + + return map +} + +interface Params { + body: unknown + headers: Record + path: Record + query: Record +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === "object" && !Object.keys(value).length) { + delete params[slot as Slot] + } + } +} + +export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + } + + const map = buildKeyMap(fields) + + let config: FieldsConfig[number] | undefined + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index] + } + + if (!config) { + continue + } + + if ("in" in config) { + if (config.key) { + const field = map.get(config.key)! + const name = field.map || config.key + ;(params[field.in] as Record)[name] = arg + } else { + params.body = arg + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key) + + if (field) { + const name = field.map || key + ;(params[field.in] as Record)[name] = value + } else { + const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) + + if (extra) { + const [prefix, slot] = extra + ;(params[slot] as Record)[key.slice(prefix.length)] = value + } else { + for (const [slot, allowed] of Object.entries(config.allowExtra ?? {})) { + if (allowed) { + ;(params[slot as Slot] as Record)[key] = value + break + } + } + } + } + } + } + } + + stripEmptySlots(params) + + return params +} diff --git a/packages/sdk/js/src/gen/core/params.ts b/packages/sdk/js/src/gen/core/params.ts deleted file mode 100644 index 0a09619d1..000000000 --- a/packages/sdk/js/src/gen/core/params.ts +++ /dev/null @@ -1,142 +0,0 @@ -type Slot = "body" | "headers" | "path" | "query" - -export type Field = - | { - in: Exclude - /** - * Field name. This is the name we want the user to see and use. - */ - key: string - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string - } - | { - in: Extract - /** - * Key isn't required for bodies. - */ - key?: string - map?: string - } - -export interface Fields { - allowExtra?: Partial> - args?: ReadonlyArray -} - -export type FieldsConfig = ReadonlyArray - -const extraPrefixesMap: Record = { - $body_: "body", - $headers_: "headers", - $path_: "path", - $query_: "query", -} -const extraPrefixes = Object.entries(extraPrefixesMap) - -type KeyMap = Map< - string, - { - in: Slot - map?: string - } -> - -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { - if (!map) { - map = new Map() - } - - for (const config of fields) { - if ("in" in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }) - } - } else if (config.args) { - buildKeyMap(config.args, map) - } - } - - return map -} - -interface Params { - body: unknown - headers: Record - path: Record - query: Record -} - -const stripEmptySlots = (params: Params) => { - for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === "object" && !Object.keys(value).length) { - delete params[slot as Slot] - } - } -} - -export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { - const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, - } - - const map = buildKeyMap(fields) - - let config: FieldsConfig[number] | undefined - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index] - } - - if (!config) { - continue - } - - if ("in" in config) { - if (config.key) { - const field = map.get(config.key)! - const name = field.map || config.key - ;(params[field.in] as Record)[name] = arg - } else { - params.body = arg - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key) - - if (field) { - const name = field.map || key - ;(params[field.in] as Record)[name] = value - } else { - const extra = extraPrefixes.find(([prefix]) => key.startsWith(prefix)) - - if (extra) { - const [prefix, slot] = extra - ;(params[slot] as Record)[key.slice(prefix.length)] = value - } else { - for (const [slot, allowed] of Object.entries(config.allowExtra ?? {})) { - if (allowed) { - ;(params[slot as Slot] as Record)[key] = value - break - } - } - } - } - } - } - } - - stripEmptySlots(params) - - return params -} diff --git a/packages/sdk/js/src/gen/core/pathSerializer.gen.ts b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts new file mode 100644 index 000000000..96be3bc5a --- /dev/null +++ b/packages/sdk/js/src/gen/core/pathSerializer.gen.ts @@ -0,0 +1,167 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean + name: string +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean + style: T +} + +export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle +type MatrixStyle = "label" | "matrix" | "simple" +export type ObjectStyle = "form" | "deepObject" +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case "form": + return "," + case "pipeDelimited": + return "|" + case "spaceDelimited": + return "%20" + default: + return "," + } +} + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case "label": + return "." + case "matrix": + return ";" + case "simple": + return "," + default: + return "&" + } +} + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[] +}) => { + if (!explode) { + const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( + separatorArrayNoExplode(style), + ) + switch (style) { + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + case "simple": + return joinedValues + default: + return `${name}=${joinedValues}` + } + } + + const separator = separatorArrayExplode(style) + const joinedValues = value + .map((v) => { + if (style === "label" || style === "simple") { + return allowReserved ? v : encodeURIComponent(v as string) + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }) + }) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} + +export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return "" + } + + if (typeof value === "object") { + throw new Error( + "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", + ) + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}` +} + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date + valueOnly?: boolean +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` + } + + if (style !== "deepObject" && !explode) { + let values: string[] = [] + Object.entries(value).forEach(([key, v]) => { + values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] + }) + const joinedValues = values.join(",") + switch (style) { + case "form": + return `${name}=${joinedValues}` + case "label": + return `.${joinedValues}` + case "matrix": + return `;${name}=${joinedValues}` + default: + return joinedValues + } + } + + const separator = separatorObjectExplode(style) + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === "deepObject" ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator) + return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues +} diff --git a/packages/sdk/js/src/gen/core/pathSerializer.ts b/packages/sdk/js/src/gen/core/pathSerializer.ts deleted file mode 100644 index 1e27c8d18..000000000 --- a/packages/sdk/js/src/gen/core/pathSerializer.ts +++ /dev/null @@ -1,165 +0,0 @@ -interface SerializeOptions extends SerializePrimitiveOptions, SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean - name: string -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean - style: T -} - -export type ArrayStyle = "form" | "spaceDelimited" | "pipeDelimited" -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle -type MatrixStyle = "label" | "matrix" | "simple" -export type ObjectStyle = "form" | "deepObject" -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," - default: - return "&" - } -} - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case "form": - return "," - case "pipeDelimited": - return "|" - case "spaceDelimited": - return "%20" - default: - return "," - } -} - -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { - switch (style) { - case "label": - return "." - case "matrix": - return ";" - case "simple": - return "," - default: - return "&" - } -} - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[] -}) => { - if (!explode) { - const joinedValues = (allowReserved ? value : value.map((v) => encodeURIComponent(v as string))).join( - separatorArrayNoExplode(style), - ) - switch (style) { - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` - case "simple": - return joinedValues - default: - return `${name}=${joinedValues}` - } - } - - const separator = separatorArrayExplode(style) - const joinedValues = value - .map((v) => { - if (style === "label" || style === "simple") { - return allowReserved ? v : encodeURIComponent(v as string) - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }) - }) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} - -export const serializePrimitiveParam = ({ allowReserved, name, value }: SerializePrimitiveParam) => { - if (value === undefined || value === null) { - return "" - } - - if (typeof value === "object") { - throw new Error( - "Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.", - ) - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}` -} - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date - valueOnly?: boolean -}) => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}` - } - - if (style !== "deepObject" && !explode) { - let values: string[] = [] - Object.entries(value).forEach(([key, v]) => { - values = [...values, key, allowReserved ? (v as string) : encodeURIComponent(v as string)] - }) - const joinedValues = values.join(",") - switch (style) { - case "form": - return `${name}=${joinedValues}` - case "label": - return `.${joinedValues}` - case "matrix": - return `;${name}=${joinedValues}` - default: - return joinedValues - } - } - - const separator = separatorObjectExplode(style) - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === "deepObject" ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator) - return style === "label" || style === "matrix" ? separator + joinedValues : joinedValues -} diff --git a/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts new file mode 100644 index 000000000..8f7fac549 --- /dev/null +++ b/packages/sdk/js/src/gen/core/serverSentEvents.gen.ts @@ -0,0 +1,210 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from "./types.gen.js" + +export type ServerSentEventsOptions = Omit & + Pick & { + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise + url: string + } + +export interface StreamEvent { + data: TData + event?: string + id?: string + retry?: number +} + +export type ServerSentEventsResult = { + stream: AsyncGenerator ? TData[keyof TData] : TData, TReturn, TNext> +} + +export const createSseClient = ({ + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined + + const sleep = sseSleepFn ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))) + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000 + let attempt = 0 + const signal = options.signal ?? new AbortController().signal + + while (true) { + if (signal.aborted) break + + attempt++ + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined) + + if (lastEventId !== undefined) { + headers.set("Last-Event-ID", lastEventId) + } + + try { + const response = await fetch(url, { ...options, headers, signal }) + + if (!response.ok) throw new Error(`SSE failed: ${response.status} ${response.statusText}`) + + if (!response.body) throw new Error("No body in SSE response") + + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader() + + let buffer = "" + + const abortHandler = () => { + try { + reader.cancel() + } catch { + // noop + } + } + + signal.addEventListener("abort", abortHandler) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += value + + const chunks = buffer.split("\n\n") + buffer = chunks.pop() ?? "" + + for (const chunk of chunks) { + const lines = chunk.split("\n") + const dataLines: Array = [] + let eventName: string | undefined + + for (const line of lines) { + if (line.startsWith("data:")) { + dataLines.push(line.replace(/^data:\s*/, "")) + } else if (line.startsWith("event:")) { + eventName = line.replace(/^event:\s*/, "") + } else if (line.startsWith("id:")) { + lastEventId = line.replace(/^id:\s*/, "") + } else if (line.startsWith("retry:")) { + const parsed = Number.parseInt(line.replace(/^retry:\s*/, ""), 10) + if (!Number.isNaN(parsed)) { + retryDelay = parsed + } + } + } + + let data: unknown + let parsedJson = false + + if (dataLines.length) { + const rawData = dataLines.join("\n") + try { + data = JSON.parse(rawData) + parsedJson = true + } catch { + data = rawData + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data) + } + + if (responseTransformer) { + data = await responseTransformer(data) + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }) + + if (dataLines.length) { + yield data as any + } + } + } + } finally { + signal.removeEventListener("abort", abortHandler) + reader.releaseLock() + } + + break // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error) + + if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) { + break // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000) + await sleep(backoff) + } + } + } + + const stream = createStream() + + return { stream } +} diff --git a/packages/sdk/js/src/gen/core/types.gen.ts b/packages/sdk/js/src/gen/core/types.gen.ts new file mode 100644 index 000000000..16408b2d0 --- /dev/null +++ b/packages/sdk/js/src/gen/core/types.gen.ts @@ -0,0 +1,91 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from "./auth.gen.js" +import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.gen.js" + +export interface Client { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn + connect: MethodFn + delete: MethodFn + get: MethodFn + getConfig: () => Config + head: MethodFn + options: MethodFn + patch: MethodFn + post: MethodFn + put: MethodFn + request: RequestFn + setConfig: (config: Config) => Config + trace: MethodFn +} + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit["headers"] + | Record + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE" + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] +} diff --git a/packages/sdk/js/src/gen/core/types.ts b/packages/sdk/js/src/gen/core/types.ts deleted file mode 100644 index 3a12e74c6..000000000 --- a/packages/sdk/js/src/gen/core/types.ts +++ /dev/null @@ -1,89 +0,0 @@ -import type { Auth, AuthToken } from "./auth.js" -import type { BodySerializer, QuerySerializer, QuerySerializerOptions } from "./bodySerializer.js" - -export interface Client { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn - connect: MethodFn - delete: MethodFn - get: MethodFn - getConfig: () => Config - head: MethodFn - options: MethodFn - patch: MethodFn - post: MethodFn - put: MethodFn - request: RequestFn - setConfig: (config: Config) => Config - trace: MethodFn -} - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit["headers"] - | Record - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: "CONNECT" | "DELETE" | "GET" | "HEAD" | "OPTIONS" | "PATCH" | "POST" | "PUT" | "TRACE" - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise -} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true ? never : K]: T[K] -} diff --git a/packages/sdk/js/src/gen/core/utils.gen.ts b/packages/sdk/js/src/gen/core/utils.gen.ts new file mode 100644 index 000000000..be18c608a --- /dev/null +++ b/packages/sdk/js/src/gen/core/utils.gen.ts @@ -0,0 +1,109 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { QuerySerializer } from "./bodySerializer.gen.js" +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from "./pathSerializer.gen.js" + +export interface PathSerializer { + path: Record + url: string +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url + const matches = _url.match(PATH_PARAM_RE) + if (matches) { + for (const match of matches) { + let explode = false + let name = match.substring(1, match.length - 1) + let style: ArraySeparatorStyle = "simple" + + if (name.endsWith("*")) { + explode = true + name = name.substring(0, name.length - 1) + } + + if (name.startsWith(".")) { + name = name.substring(1) + style = "label" + } else if (name.startsWith(";")) { + name = name.substring(1) + style = "matrix" + } + + const value = path[name] + + if (value === undefined || value === null) { + continue + } + + if (Array.isArray(value)) { + url = url.replace(match, serializeArrayParam({ explode, name, style, value })) + continue + } + + if (typeof value === "object") { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ) + continue + } + + if (style === "matrix") { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ) + continue + } + + const replaceValue = encodeURIComponent(style === "label" ? `.${value as string}` : (value as string)) + url = url.replace(match, replaceValue) + } + } + return url +} + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string + path?: Record + query?: Record + querySerializer: QuerySerializer + url: string +}) => { + const pathUrl = _url.startsWith("/") ? _url : `/${_url}` + let url = (baseUrl ?? "") + pathUrl + if (path) { + url = defaultPathSerializer({ path, url }) + } + let search = query ? querySerializer(query) : "" + if (search.startsWith("?")) { + search = search.substring(1) + } + if (search) { + url += `?${search}` + } + return url +} -- cgit v1.2.3