Generate Fern TypeSscript SDK (#3785)

This commit is contained in:
Stanislav Novosad
2025-10-23 20:14:59 -06:00
committed by GitHub
parent d55b9637c4
commit 2062adac66
239 changed files with 14550 additions and 3 deletions

View File

@@ -0,0 +1,23 @@
import type { RawResponse } from "./RawResponse.js";
/**
* The response of an API call.
* It is a successful response or a failed response.
*/
export type APIResponse<Success, Failure> = SuccessfulResponse<Success> | FailedResponse<Failure>;
export interface SuccessfulResponse<T> {
ok: true;
body: T;
/**
* @deprecated Use `rawResponse` instead
*/
headers?: Record<string, any>;
rawResponse: RawResponse;
}
export interface FailedResponse<T> {
ok: false;
error: T;
rawResponse: RawResponse;
}

View File

@@ -0,0 +1,36 @@
import type { ResponseWithBody } from "./ResponseWithBody.js";
export type BinaryResponse = {
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bodyUsed) */
bodyUsed: boolean;
/**
* Returns a ReadableStream of the response body.
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body)
*/
stream: () => ReadableStream<Uint8Array>;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/arrayBuffer) */
arrayBuffer: () => Promise<ArrayBuffer>;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/blob) */
blob: () => Promise<Blob>;
/**
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/bytes)
* Some versions of the Fetch API may not support this method.
*/
bytes?(): Promise<Uint8Array>;
};
export function getBinaryResponse(response: ResponseWithBody): BinaryResponse {
const binaryResponse: BinaryResponse = {
get bodyUsed() {
return response.bodyUsed;
},
stream: () => response.body,
arrayBuffer: response.arrayBuffer.bind(response),
blob: response.blob.bind(response),
};
if ("bytes" in response && typeof response.bytes === "function") {
binaryResponse.bytes = response.bytes.bind(response);
}
return binaryResponse;
}

View File

@@ -0,0 +1,13 @@
export type SecuritySchemeKey = string;
/**
* A collection of security schemes, where the key is the name of the security scheme and the value is the list of scopes required for that scheme.
* All schemes in the collection must be satisfied for authentication to be successful.
*/
export type SecuritySchemeCollection = Record<SecuritySchemeKey, AuthScope[]>;
export type AuthScope = string;
export type EndpointMetadata = {
/**
* An array of security scheme collections. Each collection represents an alternative way to authenticate.
*/
security?: SecuritySchemeCollection[];
};

View File

@@ -0,0 +1,14 @@
import type { EndpointMetadata } from "./EndpointMetadata.js";
import type { Supplier } from "./Supplier.js";
type EndpointSupplierFn<T> = (arg: { endpointMetadata: EndpointMetadata }) => T | Promise<T>;
export type EndpointSupplier<T> = Supplier<T> | EndpointSupplierFn<T>;
export const EndpointSupplier = {
get: async <T>(supplier: EndpointSupplier<T>, arg: { endpointMetadata: EndpointMetadata }): Promise<T> => {
if (typeof supplier === "function") {
return (supplier as EndpointSupplierFn<T>)(arg);
} else {
return supplier;
}
},
};

View File

@@ -0,0 +1,165 @@
import { toJson } from "../json.js";
import type { APIResponse } from "./APIResponse.js";
import { createRequestUrl } from "./createRequestUrl.js";
import type { EndpointMetadata } from "./EndpointMetadata.js";
import { EndpointSupplier } from "./EndpointSupplier.js";
import { getErrorResponseBody } from "./getErrorResponseBody.js";
import { getFetchFn } from "./getFetchFn.js";
import { getRequestBody } from "./getRequestBody.js";
import { getResponseBody } from "./getResponseBody.js";
import { makeRequest } from "./makeRequest.js";
import { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js";
import { requestWithRetries } from "./requestWithRetries.js";
export type FetchFunction = <R = unknown>(args: Fetcher.Args) => Promise<APIResponse<R, Fetcher.Error>>;
export declare namespace Fetcher {
export interface Args {
url: string;
method: string;
contentType?: string;
headers?: Record<string, string | EndpointSupplier<string | null | undefined> | null | undefined>;
queryParameters?: Record<string, unknown>;
body?: unknown;
timeoutMs?: number;
maxRetries?: number;
withCredentials?: boolean;
abortSignal?: AbortSignal;
requestType?: "json" | "file" | "bytes";
responseType?: "json" | "blob" | "sse" | "streaming" | "text" | "arrayBuffer" | "binary-response";
duplex?: "half";
endpointMetadata?: EndpointMetadata;
}
export type Error = FailedStatusCodeError | NonJsonError | TimeoutError | UnknownError;
export interface FailedStatusCodeError {
reason: "status-code";
statusCode: number;
body: unknown;
}
export interface NonJsonError {
reason: "non-json";
statusCode: number;
rawBody: string;
}
export interface TimeoutError {
reason: "timeout";
}
export interface UnknownError {
reason: "unknown";
errorMessage: string;
}
}
async function getHeaders(args: Fetcher.Args): Promise<Record<string, string>> {
const newHeaders: Record<string, string> = {};
if (args.body !== undefined && args.contentType != null) {
newHeaders["Content-Type"] = args.contentType;
}
if (args.headers == null) {
return newHeaders;
}
for (const [key, value] of Object.entries(args.headers)) {
const result = await EndpointSupplier.get(value, { endpointMetadata: args.endpointMetadata ?? {} });
if (typeof result === "string") {
newHeaders[key] = result;
continue;
}
if (result == null) {
continue;
}
newHeaders[key] = `${result}`;
}
return newHeaders;
}
export async function fetcherImpl<R = unknown>(args: Fetcher.Args): Promise<APIResponse<R, Fetcher.Error>> {
const url = createRequestUrl(args.url, args.queryParameters);
const requestBody: BodyInit | undefined = await getRequestBody({
body: args.body,
type: args.requestType === "json" ? "json" : "other",
});
const fetchFn = await getFetchFn();
try {
const response = await requestWithRetries(
async () =>
makeRequest(
fetchFn,
url,
args.method,
await getHeaders(args),
requestBody,
args.timeoutMs,
args.abortSignal,
args.withCredentials,
args.duplex,
),
args.maxRetries,
);
if (response.status >= 200 && response.status < 400) {
return {
ok: true,
body: (await getResponseBody(response, args.responseType)) as R,
headers: response.headers,
rawResponse: toRawResponse(response),
};
} else {
return {
ok: false,
error: {
reason: "status-code",
statusCode: response.status,
body: await getErrorResponseBody(response),
},
rawResponse: toRawResponse(response),
};
}
} catch (error) {
if (args.abortSignal?.aborted) {
return {
ok: false,
error: {
reason: "unknown",
errorMessage: "The user aborted a request",
},
rawResponse: abortRawResponse,
};
} else if (error instanceof Error && error.name === "AbortError") {
return {
ok: false,
error: {
reason: "timeout",
},
rawResponse: abortRawResponse,
};
} else if (error instanceof Error) {
return {
ok: false,
error: {
reason: "unknown",
errorMessage: error.message,
},
rawResponse: unknownRawResponse,
};
}
return {
ok: false,
error: {
reason: "unknown",
errorMessage: toJson(error),
},
rawResponse: unknownRawResponse,
};
}
}
export const fetcher: FetchFunction = fetcherImpl;

View File

@@ -0,0 +1,93 @@
let Headers: typeof globalThis.Headers;
if (typeof globalThis.Headers !== "undefined") {
Headers = globalThis.Headers;
} else {
Headers = class Headers implements Headers {
private headers: Map<string, string[]>;
constructor(init?: HeadersInit) {
this.headers = new Map();
if (init) {
if (init instanceof Headers) {
init.forEach((value, key) => this.append(key, value));
} else if (Array.isArray(init)) {
for (const [key, value] of init) {
if (typeof key === "string" && typeof value === "string") {
this.append(key, value);
} else {
throw new TypeError("Each header entry must be a [string, string] tuple");
}
}
} else {
for (const [key, value] of Object.entries(init)) {
if (typeof value === "string") {
this.append(key, value);
} else {
throw new TypeError("Header values must be strings");
}
}
}
}
}
append(name: string, value: string): void {
const key = name.toLowerCase();
const existing = this.headers.get(key) || [];
this.headers.set(key, [...existing, value]);
}
delete(name: string): void {
const key = name.toLowerCase();
this.headers.delete(key);
}
get(name: string): string | null {
const key = name.toLowerCase();
const values = this.headers.get(key);
return values ? values.join(", ") : null;
}
has(name: string): boolean {
const key = name.toLowerCase();
return this.headers.has(key);
}
set(name: string, value: string): void {
const key = name.toLowerCase();
this.headers.set(key, [value]);
}
forEach(callbackfn: (value: string, key: string, parent: Headers) => void, thisArg?: unknown): void {
const boundCallback = thisArg ? callbackfn.bind(thisArg) : callbackfn;
this.headers.forEach((values, key) => boundCallback(values.join(", "), key, this));
}
getSetCookie(): string[] {
return this.headers.get("set-cookie") || [];
}
*entries(): HeadersIterator<[string, string]> {
for (const [key, values] of this.headers.entries()) {
yield [key, values.join(", ")];
}
}
*keys(): HeadersIterator<string> {
yield* this.headers.keys();
}
*values(): HeadersIterator<string> {
for (const values of this.headers.values()) {
yield values.join(", ");
}
}
[Symbol.iterator](): HeadersIterator<[string, string]> {
return this.entries();
}
};
}
export { Headers };

View File

@@ -0,0 +1,116 @@
import type { WithRawResponse } from "./RawResponse.js";
/**
* A promise that returns the parsed response and lets you retrieve the raw response too.
*/
export class HttpResponsePromise<T> extends Promise<T> {
private innerPromise: Promise<WithRawResponse<T>>;
private unwrappedPromise: Promise<T> | undefined;
private constructor(promise: Promise<WithRawResponse<T>>) {
// Initialize with a no-op to avoid premature parsing
super((resolve) => {
resolve(undefined as unknown as T);
});
this.innerPromise = promise;
}
/**
* Creates an `HttpResponsePromise` from a function that returns a promise.
*
* @param fn - A function that returns a promise resolving to a `WithRawResponse` object.
* @param args - Arguments to pass to the function.
* @returns An `HttpResponsePromise` instance.
*/
public static fromFunction<F extends (...args: never[]) => Promise<WithRawResponse<T>>, T>(
fn: F,
...args: Parameters<F>
): HttpResponsePromise<T> {
return new HttpResponsePromise<T>(fn(...args));
}
/**
* Creates a function that returns an `HttpResponsePromise` from a function that returns a promise.
*
* @param fn - A function that returns a promise resolving to a `WithRawResponse` object.
* @returns A function that returns an `HttpResponsePromise` instance.
*/
public static interceptFunction<
F extends (...args: never[]) => Promise<WithRawResponse<T>>,
T = Awaited<ReturnType<F>>["data"],
>(fn: F): (...args: Parameters<F>) => HttpResponsePromise<T> {
return (...args: Parameters<F>): HttpResponsePromise<T> => {
return HttpResponsePromise.fromPromise<T>(fn(...args));
};
}
/**
* Creates an `HttpResponsePromise` from an existing promise.
*
* @param promise - A promise resolving to a `WithRawResponse` object.
* @returns An `HttpResponsePromise` instance.
*/
public static fromPromise<T>(promise: Promise<WithRawResponse<T>>): HttpResponsePromise<T> {
return new HttpResponsePromise<T>(promise);
}
/**
* Creates an `HttpResponsePromise` from an executor function.
*
* @param executor - A function that takes resolve and reject callbacks to create a promise.
* @returns An `HttpResponsePromise` instance.
*/
public static fromExecutor<T>(
executor: (resolve: (value: WithRawResponse<T>) => void, reject: (reason?: unknown) => void) => void,
): HttpResponsePromise<T> {
const promise = new Promise<WithRawResponse<T>>(executor);
return new HttpResponsePromise<T>(promise);
}
/**
* Creates an `HttpResponsePromise` from a resolved result.
*
* @param result - A `WithRawResponse` object to resolve immediately.
* @returns An `HttpResponsePromise` instance.
*/
public static fromResult<T>(result: WithRawResponse<T>): HttpResponsePromise<T> {
const promise = Promise.resolve(result);
return new HttpResponsePromise<T>(promise);
}
private unwrap(): Promise<T> {
if (!this.unwrappedPromise) {
this.unwrappedPromise = this.innerPromise.then(({ data }) => data);
}
return this.unwrappedPromise;
}
/** @inheritdoc */
public override then<TResult1 = T, TResult2 = never>(
onfulfilled?: ((value: T) => TResult1 | PromiseLike<TResult1>) | null,
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
): Promise<TResult1 | TResult2> {
return this.unwrap().then(onfulfilled, onrejected);
}
/** @inheritdoc */
public override catch<TResult = never>(
onrejected?: ((reason: unknown) => TResult | PromiseLike<TResult>) | null,
): Promise<T | TResult> {
return this.unwrap().catch(onrejected);
}
/** @inheritdoc */
public override finally(onfinally?: (() => void) | null): Promise<T> {
return this.unwrap().finally(onfinally);
}
/**
* Retrieves the data and raw response.
*
* @returns A promise resolving to a `WithRawResponse` object.
*/
public async withRawResponse(): Promise<WithRawResponse<T>> {
return await this.innerPromise;
}
}

View File

@@ -0,0 +1,61 @@
import { Headers } from "./Headers.js";
/**
* The raw response from the fetch call excluding the body.
*/
export type RawResponse = Omit<
{
[K in keyof Response as Response[K] extends Function ? never : K]: Response[K]; // strips out functions
},
"ok" | "body" | "bodyUsed"
>; // strips out body and bodyUsed
/**
* A raw response indicating that the request was aborted.
*/
export const abortRawResponse: RawResponse = {
headers: new Headers(),
redirected: false,
status: 499,
statusText: "Client Closed Request",
type: "error",
url: "",
} as const;
/**
* A raw response indicating an unknown error.
*/
export const unknownRawResponse: RawResponse = {
headers: new Headers(),
redirected: false,
status: 0,
statusText: "Unknown Error",
type: "error",
url: "",
} as const;
/**
* Converts a `RawResponse` object into a `RawResponse` by extracting its properties,
* excluding the `body` and `bodyUsed` fields.
*
* @param response - The `RawResponse` object to convert.
* @returns A `RawResponse` object containing the extracted properties of the input response.
*/
export function toRawResponse(response: Response): RawResponse {
return {
headers: response.headers,
redirected: response.redirected,
status: response.status,
statusText: response.statusText,
type: response.type,
url: response.url,
};
}
/**
* Creates a `RawResponse` from a standard `Response` object.
*/
export interface WithRawResponse<T> {
readonly data: T;
readonly rawResponse: RawResponse;
}

View File

@@ -0,0 +1,7 @@
export type ResponseWithBody = Response & {
body: ReadableStream<Uint8Array>;
};
export function isResponseWithBody(response: Response): response is ResponseWithBody {
return (response as ResponseWithBody).body != null;
}

View File

@@ -0,0 +1,11 @@
export type Supplier<T> = T | Promise<T> | (() => T | Promise<T>);
export const Supplier = {
get: async <T>(supplier: Supplier<T>): Promise<T> => {
if (typeof supplier === "function") {
return (supplier as () => T)();
} else {
return supplier;
}
},
};

View File

@@ -0,0 +1,6 @@
import { toQueryString } from "../url/qs.js";
export function createRequestUrl(baseUrl: string, queryParameters?: Record<string, unknown>): string {
const queryString = toQueryString(queryParameters, { arrayFormat: "repeat" });
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
}

View File

@@ -0,0 +1,33 @@
import { fromJson } from "../json.js";
import { getResponseBody } from "./getResponseBody.js";
export async function getErrorResponseBody(response: Response): Promise<unknown> {
let contentType = response.headers.get("Content-Type")?.toLowerCase();
if (contentType == null || contentType.length === 0) {
return getResponseBody(response);
}
if (contentType.indexOf(";") !== -1) {
contentType = contentType.split(";")[0]?.trim() ?? "";
}
switch (contentType) {
case "application/hal+json":
case "application/json":
case "application/ld+json":
case "application/problem+json":
case "application/vnd.api+json":
case "text/json": {
const text = await response.text();
return text.length > 0 ? fromJson(text) : undefined;
}
default:
if (contentType.startsWith("application/vnd.") && contentType.endsWith("+json")) {
const text = await response.text();
return text.length > 0 ? fromJson(text) : undefined;
}
// Fallback to plain text if content type is not recognized
// Even if no body is present, the response will be an empty string
return await response.text();
}
}

View File

@@ -0,0 +1,3 @@
export async function getFetchFn(): Promise<typeof fetch> {
return fetch;
}

View File

@@ -0,0 +1,8 @@
export function getHeader(headers: Record<string, any>, header: string): string | undefined {
for (const [headerKey, headerValue] of Object.entries(headers)) {
if (headerKey.toLowerCase() === header.toLowerCase()) {
return headerValue;
}
}
return undefined;
}

View File

@@ -0,0 +1,16 @@
import { toJson } from "../json.js";
export declare namespace GetRequestBody {
interface Args {
body: unknown;
type: "json" | "file" | "bytes" | "other";
}
}
export async function getRequestBody({ body, type }: GetRequestBody.Args): Promise<BodyInit | undefined> {
if (type.includes("json")) {
return toJson(body);
} else {
return body as BodyInit;
}
}

View File

@@ -0,0 +1,43 @@
import { fromJson } from "../json.js";
import { getBinaryResponse } from "./BinaryResponse.js";
import { isResponseWithBody } from "./ResponseWithBody.js";
export async function getResponseBody(response: Response, responseType?: string): Promise<unknown> {
if (!isResponseWithBody(response)) {
return undefined;
}
switch (responseType) {
case "binary-response":
return getBinaryResponse(response);
case "blob":
return await response.blob();
case "arrayBuffer":
return await response.arrayBuffer();
case "sse":
return response.body;
case "streaming":
return response.body;
case "text":
return await response.text();
}
// if responseType is "json" or not specified, try to parse as JSON
const text = await response.text();
if (text.length > 0) {
try {
const responseBody = fromJson(text);
return responseBody;
} catch (_err) {
return {
ok: false,
error: {
reason: "non-json",
statusCode: response.status,
rawBody: text,
},
};
}
}
return undefined;
}

View File

@@ -0,0 +1,11 @@
export type { APIResponse } from "./APIResponse.js";
export type { BinaryResponse } from "./BinaryResponse.js";
export type { EndpointMetadata } from "./EndpointMetadata.js";
export { EndpointSupplier } from "./EndpointSupplier.js";
export type { Fetcher, FetchFunction } from "./Fetcher.js";
export { fetcher } from "./Fetcher.js";
export { getHeader } from "./getHeader.js";
export { HttpResponsePromise } from "./HttpResponsePromise.js";
export type { RawResponse, WithRawResponse } from "./RawResponse.js";
export { abortRawResponse, toRawResponse, unknownRawResponse } from "./RawResponse.js";
export { Supplier } from "./Supplier.js";

View File

@@ -0,0 +1,44 @@
import { anySignal, getTimeoutSignal } from "./signals.js";
export const makeRequest = async (
fetchFn: (url: string, init: RequestInit) => Promise<Response>,
url: string,
method: string,
headers: Record<string, string>,
requestBody: BodyInit | undefined,
timeoutMs?: number,
abortSignal?: AbortSignal,
withCredentials?: boolean,
duplex?: "half",
): Promise<Response> => {
const signals: AbortSignal[] = [];
// Add timeout signal
let timeoutAbortId: NodeJS.Timeout | undefined;
if (timeoutMs != null) {
const { signal, abortId } = getTimeoutSignal(timeoutMs);
timeoutAbortId = abortId;
signals.push(signal);
}
// Add arbitrary signal
if (abortSignal != null) {
signals.push(abortSignal);
}
const newSignals = anySignal(signals);
const response = await fetchFn(url, {
method: method,
headers,
body: requestBody,
signal: newSignals,
credentials: withCredentials ? "include" : undefined,
// @ts-ignore
duplex,
});
if (timeoutAbortId != null) {
clearTimeout(timeoutAbortId);
}
return response;
};

View File

@@ -0,0 +1,73 @@
const INITIAL_RETRY_DELAY = 1000; // in milliseconds
const MAX_RETRY_DELAY = 60000; // in milliseconds
const DEFAULT_MAX_RETRIES = 2;
const JITTER_FACTOR = 0.2; // 20% random jitter
function addPositiveJitter(delay: number): number {
// Generate a random value between 0 and +JITTER_FACTOR
const jitterMultiplier = 1 + Math.random() * JITTER_FACTOR;
return delay * jitterMultiplier;
}
function addSymmetricJitter(delay: number): number {
// Generate a random value in a JITTER_FACTOR-sized percentage range around delay
const jitterMultiplier = 1 + (Math.random() - 0.5) * JITTER_FACTOR;
return delay * jitterMultiplier;
}
function getRetryDelayFromHeaders(response: Response, retryAttempt: number): number {
// Check for Retry-After header first (RFC 7231), with no jitter
const retryAfter = response.headers.get("Retry-After");
if (retryAfter) {
// Parse as number of seconds...
const retryAfterSeconds = parseInt(retryAfter, 10);
if (!Number.isNaN(retryAfterSeconds) && retryAfterSeconds > 0) {
return Math.min(retryAfterSeconds * 1000, MAX_RETRY_DELAY);
}
// ...or as an HTTP date; both are valid
const retryAfterDate = new Date(retryAfter);
if (!Number.isNaN(retryAfterDate.getTime())) {
const delay = retryAfterDate.getTime() - Date.now();
if (delay > 0) {
return Math.min(Math.max(delay, 0), MAX_RETRY_DELAY);
}
}
}
// Then check for industry-standard X-RateLimit-Reset header, with positive jitter
const rateLimitReset = response.headers.get("X-RateLimit-Reset");
if (rateLimitReset) {
const resetTime = parseInt(rateLimitReset, 10);
if (!Number.isNaN(resetTime)) {
// Assume Unix timestamp in epoch seconds
const delay = resetTime * 1000 - Date.now();
if (delay > 0) {
return addPositiveJitter(Math.min(delay, MAX_RETRY_DELAY));
}
}
}
// Fall back to exponential backoff, with symmetric jitter
return addSymmetricJitter(Math.min(INITIAL_RETRY_DELAY * 2 ** retryAttempt, MAX_RETRY_DELAY));
}
export async function requestWithRetries(
requestFn: () => Promise<Response>,
maxRetries: number = DEFAULT_MAX_RETRIES,
): Promise<Response> {
let response: Response = await requestFn();
for (let i = 0; i < maxRetries; ++i) {
if ([408, 429].includes(response.status) || response.status >= 500) {
// Get delay with appropriate jitter applied
const delay = getRetryDelayFromHeaders(response, i);
await new Promise((resolve) => setTimeout(resolve, delay));
response = await requestFn();
} else {
break;
}
}
return response!;
}

View File

@@ -0,0 +1,38 @@
const TIMEOUT = "timeout";
export function getTimeoutSignal(timeoutMs: number): { signal: AbortSignal; abortId: NodeJS.Timeout } {
const controller = new AbortController();
const abortId = setTimeout(() => controller.abort(TIMEOUT), timeoutMs);
return { signal: controller.signal, abortId };
}
/**
* Returns an abort signal that is getting aborted when
* at least one of the specified abort signals is aborted.
*
* Requires at least node.js 18.
*/
export function anySignal(...args: AbortSignal[] | [AbortSignal[]]): AbortSignal {
// Allowing signals to be passed either as array
// of signals or as multiple arguments.
const signals = (args.length === 1 && Array.isArray(args[0]) ? args[0] : args) as AbortSignal[];
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
// Exiting early if one of the signals
// is already aborted.
controller.abort((signal as any)?.reason);
break;
}
// Listening for signals and removing the listeners
// when at least one symbol is aborted.
signal.addEventListener("abort", () => controller.abort((signal as any)?.reason), {
signal: controller.signal,
});
}
return controller.signal;
}

View File

@@ -0,0 +1,33 @@
export function mergeHeaders<THeaderValue>(
...headersArray: (Record<string, THeaderValue> | null | undefined)[]
): Record<string, string | THeaderValue> {
const result: Record<string, THeaderValue> = {};
for (const [key, value] of headersArray
.filter((headers) => headers != null)
.flatMap((headers) => Object.entries(headers))) {
if (value != null) {
result[key] = value;
} else if (key in result) {
delete result[key];
}
}
return result;
}
export function mergeOnlyDefinedHeaders<THeaderValue>(
...headersArray: (Record<string, THeaderValue> | null | undefined)[]
): Record<string, THeaderValue> {
const result: Record<string, THeaderValue> = {};
for (const [key, value] of headersArray
.filter((headers) => headers != null)
.flatMap((headers) => Object.entries(headers))) {
if (value != null) {
result[key] = value;
}
}
return result;
}

View File

@@ -0,0 +1,3 @@
export * from "./fetcher/index.js";
export * from "./runtime/index.js";
export * as url from "./url/index.js";

View File

@@ -0,0 +1,27 @@
/**
* Serialize a value to JSON
* @param value A JavaScript value, usually an object or array, to be converted.
* @param replacer A function that transforms the results.
* @param space Adds indentation, white space, and line break characters to the return-value JSON text to make it easier to read.
* @returns JSON string
*/
export const toJson = (
value: unknown,
replacer?: (this: unknown, key: string, value: unknown) => unknown,
space?: string | number,
): string => {
return JSON.stringify(value, replacer, space);
};
/**
* Parse JSON string to object, array, or other type
* @param text A valid JSON string.
* @param reviver A function that transforms the results. This function is called for each member of the object. If a member contains nested objects, the nested objects are transformed before the parent object is.
* @returns Parsed object, array, or other type
*/
export function fromJson<T = unknown>(
text: string,
reviver?: (this: unknown, key: string, value: unknown) => unknown,
): T {
return JSON.parse(text, reviver);
}

View File

@@ -0,0 +1 @@
export { RUNTIME } from "./runtime.js";

View File

@@ -0,0 +1,133 @@
interface DenoGlobal {
version: {
deno: string;
};
}
interface BunGlobal {
version: string;
}
declare const Deno: DenoGlobal | undefined;
declare const Bun: BunGlobal | undefined;
declare const EdgeRuntime: string | undefined;
declare const self: typeof globalThis.self & {
importScripts?: unknown;
};
/**
* A constant that indicates which environment and version the SDK is running in.
*/
export const RUNTIME: Runtime = evaluateRuntime();
export interface Runtime {
type: "browser" | "web-worker" | "deno" | "bun" | "node" | "react-native" | "unknown" | "workerd" | "edge-runtime";
version?: string;
parsedVersion?: number;
}
function evaluateRuntime(): Runtime {
/**
* A constant that indicates whether the environment the code is running is a Web Browser.
*/
const isBrowser = typeof window !== "undefined" && typeof window.document !== "undefined";
if (isBrowser) {
return {
type: "browser",
version: window.navigator.userAgent,
};
}
/**
* A constant that indicates whether the environment the code is running is Cloudflare.
* https://developers.cloudflare.com/workers/runtime-apis/web-standards/#navigatoruseragent
*/
const isCloudflare = typeof globalThis !== "undefined" && globalThis?.navigator?.userAgent === "Cloudflare-Workers";
if (isCloudflare) {
return {
type: "workerd",
};
}
/**
* A constant that indicates whether the environment the code is running is Edge Runtime.
* https://vercel.com/docs/functions/runtimes/edge-runtime#check-if-you're-running-on-the-edge-runtime
*/
const isEdgeRuntime = typeof EdgeRuntime === "string";
if (isEdgeRuntime) {
return {
type: "edge-runtime",
};
}
/**
* A constant that indicates whether the environment the code is running is a Web Worker.
*/
const isWebWorker =
typeof self === "object" &&
typeof self?.importScripts === "function" &&
(self.constructor?.name === "DedicatedWorkerGlobalScope" ||
self.constructor?.name === "ServiceWorkerGlobalScope" ||
self.constructor?.name === "SharedWorkerGlobalScope");
if (isWebWorker) {
return {
type: "web-worker",
};
}
/**
* A constant that indicates whether the environment the code is running is Deno.
* FYI Deno spoofs process.versions.node, see https://deno.land/std@0.177.0/node/process.ts?s=versions
*/
const isDeno =
typeof Deno !== "undefined" && typeof Deno.version !== "undefined" && typeof Deno.version.deno !== "undefined";
if (isDeno) {
return {
type: "deno",
version: Deno.version.deno,
};
}
/**
* A constant that indicates whether the environment the code is running is Bun.sh.
*/
const isBun = typeof Bun !== "undefined" && typeof Bun.version !== "undefined";
if (isBun) {
return {
type: "bun",
version: Bun.version,
};
}
/**
* A constant that indicates whether the environment the code is running is Node.JS.
*/
const isNode =
typeof process !== "undefined" &&
"version" in process &&
!!process.version &&
"versions" in process &&
!!process.versions?.node;
if (isNode) {
return {
type: "node",
version: process.versions.node,
parsedVersion: Number(process.versions.node.split(".")[0]),
};
}
/**
* A constant that indicates whether the environment the code is running is in React-Native.
* https://github.com/facebook/react-native/blob/main/packages/react-native/Libraries/Core/setUpNavigator.js
*/
const isReactNative = typeof navigator !== "undefined" && navigator?.product === "ReactNative";
if (isReactNative) {
return {
type: "react-native",
};
}
return {
type: "unknown",
};
}

View File

@@ -0,0 +1,18 @@
export function encodePathParam(param: unknown): string {
if (param === null) {
return "null";
}
const typeofParam = typeof param;
switch (typeofParam) {
case "undefined":
return "undefined";
case "string":
case "number":
case "boolean":
break;
default:
param = String(param);
break;
}
return encodeURIComponent(param as string | number | boolean);
}

View File

@@ -0,0 +1,3 @@
export { encodePathParam } from "./encodePathParam.js";
export { join } from "./join.js";
export { toQueryString } from "./qs.js";

View File

@@ -0,0 +1,80 @@
export function join(base: string, ...segments: string[]): string {
if (!base) {
return "";
}
if (segments.length === 0) {
return base;
}
if (base.includes("://")) {
let url: URL;
try {
url = new URL(base);
} catch {
// Fallback to path joining if URL is malformed
return joinPath(base, ...segments);
}
const lastSegment = segments[segments.length - 1];
const shouldPreserveTrailingSlash = lastSegment?.endsWith("/");
for (const segment of segments) {
const cleanSegment = trimSlashes(segment);
if (cleanSegment) {
url.pathname = joinPathSegments(url.pathname, cleanSegment);
}
}
if (shouldPreserveTrailingSlash && !url.pathname.endsWith("/")) {
url.pathname += "/";
}
return url.toString();
}
return joinPath(base, ...segments);
}
function joinPath(base: string, ...segments: string[]): string {
if (segments.length === 0) {
return base;
}
let result = base;
const lastSegment = segments[segments.length - 1];
const shouldPreserveTrailingSlash = lastSegment?.endsWith("/");
for (const segment of segments) {
const cleanSegment = trimSlashes(segment);
if (cleanSegment) {
result = joinPathSegments(result, cleanSegment);
}
}
if (shouldPreserveTrailingSlash && !result.endsWith("/")) {
result += "/";
}
return result;
}
function joinPathSegments(left: string, right: string): string {
if (left.endsWith("/")) {
return left + right;
}
return `${left}/${right}`;
}
function trimSlashes(str: string): string {
if (!str) return str;
let start = 0;
let end = str.length;
if (str.startsWith("/")) start = 1;
if (str.endsWith("/")) end = str.length - 1;
return start === 0 && end === str.length ? str : str.slice(start, end);
}

View File

@@ -0,0 +1,74 @@
interface QueryStringOptions {
arrayFormat?: "indices" | "repeat";
encode?: boolean;
}
const defaultQsOptions: Required<QueryStringOptions> = {
arrayFormat: "indices",
encode: true,
} as const;
function encodeValue(value: unknown, shouldEncode: boolean): string {
if (value === undefined) {
return "";
}
if (value === null) {
return "";
}
const stringValue = String(value);
return shouldEncode ? encodeURIComponent(stringValue) : stringValue;
}
function stringifyObject(obj: Record<string, unknown>, prefix = "", options: Required<QueryStringOptions>): string[] {
const parts: string[] = [];
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}[${key}]` : key;
if (value === undefined) {
continue;
}
if (Array.isArray(value)) {
if (value.length === 0) {
continue;
}
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (item === undefined) {
continue;
}
if (typeof item === "object" && !Array.isArray(item) && item !== null) {
const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey;
parts.push(...stringifyObject(item as Record<string, unknown>, arrayKey, options));
} else {
const arrayKey = options.arrayFormat === "indices" ? `${fullKey}[${i}]` : fullKey;
const encodedKey = options.encode ? encodeURIComponent(arrayKey) : arrayKey;
parts.push(`${encodedKey}=${encodeValue(item, options.encode)}`);
}
}
} else if (typeof value === "object" && value !== null) {
if (Object.keys(value as Record<string, unknown>).length === 0) {
continue;
}
parts.push(...stringifyObject(value as Record<string, unknown>, fullKey, options));
} else {
const encodedKey = options.encode ? encodeURIComponent(fullKey) : fullKey;
parts.push(`${encodedKey}=${encodeValue(value, options.encode)}`);
}
}
return parts;
}
export function toQueryString(obj: unknown, options?: QueryStringOptions): string {
if (obj == null || typeof obj !== "object") {
return "";
}
const parts = stringifyObject(obj as Record<string, unknown>, "", {
...defaultQsOptions,
...options,
});
return parts.join("&");
}