Generate Fern TypeSscript SDK (#3785)
This commit is contained in:
committed by
GitHub
parent
d55b9637c4
commit
2062adac66
23
skyvern-ts/client/src/core/fetcher/APIResponse.ts
Normal file
23
skyvern-ts/client/src/core/fetcher/APIResponse.ts
Normal 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;
|
||||
}
|
||||
36
skyvern-ts/client/src/core/fetcher/BinaryResponse.ts
Normal file
36
skyvern-ts/client/src/core/fetcher/BinaryResponse.ts
Normal 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;
|
||||
}
|
||||
13
skyvern-ts/client/src/core/fetcher/EndpointMetadata.ts
Normal file
13
skyvern-ts/client/src/core/fetcher/EndpointMetadata.ts
Normal 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[];
|
||||
};
|
||||
14
skyvern-ts/client/src/core/fetcher/EndpointSupplier.ts
Normal file
14
skyvern-ts/client/src/core/fetcher/EndpointSupplier.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
165
skyvern-ts/client/src/core/fetcher/Fetcher.ts
Normal file
165
skyvern-ts/client/src/core/fetcher/Fetcher.ts
Normal 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;
|
||||
93
skyvern-ts/client/src/core/fetcher/Headers.ts
Normal file
93
skyvern-ts/client/src/core/fetcher/Headers.ts
Normal 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 };
|
||||
116
skyvern-ts/client/src/core/fetcher/HttpResponsePromise.ts
Normal file
116
skyvern-ts/client/src/core/fetcher/HttpResponsePromise.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
61
skyvern-ts/client/src/core/fetcher/RawResponse.ts
Normal file
61
skyvern-ts/client/src/core/fetcher/RawResponse.ts
Normal 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;
|
||||
}
|
||||
7
skyvern-ts/client/src/core/fetcher/ResponseWithBody.ts
Normal file
7
skyvern-ts/client/src/core/fetcher/ResponseWithBody.ts
Normal 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;
|
||||
}
|
||||
11
skyvern-ts/client/src/core/fetcher/Supplier.ts
Normal file
11
skyvern-ts/client/src/core/fetcher/Supplier.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
6
skyvern-ts/client/src/core/fetcher/createRequestUrl.ts
Normal file
6
skyvern-ts/client/src/core/fetcher/createRequestUrl.ts
Normal 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;
|
||||
}
|
||||
33
skyvern-ts/client/src/core/fetcher/getErrorResponseBody.ts
Normal file
33
skyvern-ts/client/src/core/fetcher/getErrorResponseBody.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
3
skyvern-ts/client/src/core/fetcher/getFetchFn.ts
Normal file
3
skyvern-ts/client/src/core/fetcher/getFetchFn.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export async function getFetchFn(): Promise<typeof fetch> {
|
||||
return fetch;
|
||||
}
|
||||
8
skyvern-ts/client/src/core/fetcher/getHeader.ts
Normal file
8
skyvern-ts/client/src/core/fetcher/getHeader.ts
Normal 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;
|
||||
}
|
||||
16
skyvern-ts/client/src/core/fetcher/getRequestBody.ts
Normal file
16
skyvern-ts/client/src/core/fetcher/getRequestBody.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
43
skyvern-ts/client/src/core/fetcher/getResponseBody.ts
Normal file
43
skyvern-ts/client/src/core/fetcher/getResponseBody.ts
Normal 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;
|
||||
}
|
||||
11
skyvern-ts/client/src/core/fetcher/index.ts
Normal file
11
skyvern-ts/client/src/core/fetcher/index.ts
Normal 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";
|
||||
44
skyvern-ts/client/src/core/fetcher/makeRequest.ts
Normal file
44
skyvern-ts/client/src/core/fetcher/makeRequest.ts
Normal 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;
|
||||
};
|
||||
73
skyvern-ts/client/src/core/fetcher/requestWithRetries.ts
Normal file
73
skyvern-ts/client/src/core/fetcher/requestWithRetries.ts
Normal 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!;
|
||||
}
|
||||
38
skyvern-ts/client/src/core/fetcher/signals.ts
Normal file
38
skyvern-ts/client/src/core/fetcher/signals.ts
Normal 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;
|
||||
}
|
||||
33
skyvern-ts/client/src/core/headers.ts
Normal file
33
skyvern-ts/client/src/core/headers.ts
Normal 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;
|
||||
}
|
||||
3
skyvern-ts/client/src/core/index.ts
Normal file
3
skyvern-ts/client/src/core/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./fetcher/index.js";
|
||||
export * from "./runtime/index.js";
|
||||
export * as url from "./url/index.js";
|
||||
27
skyvern-ts/client/src/core/json.ts
Normal file
27
skyvern-ts/client/src/core/json.ts
Normal 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);
|
||||
}
|
||||
1
skyvern-ts/client/src/core/runtime/index.ts
Normal file
1
skyvern-ts/client/src/core/runtime/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { RUNTIME } from "./runtime.js";
|
||||
133
skyvern-ts/client/src/core/runtime/runtime.ts
Normal file
133
skyvern-ts/client/src/core/runtime/runtime.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
18
skyvern-ts/client/src/core/url/encodePathParam.ts
Normal file
18
skyvern-ts/client/src/core/url/encodePathParam.ts
Normal 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);
|
||||
}
|
||||
3
skyvern-ts/client/src/core/url/index.ts
Normal file
3
skyvern-ts/client/src/core/url/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { encodePathParam } from "./encodePathParam.js";
|
||||
export { join } from "./join.js";
|
||||
export { toQueryString } from "./qs.js";
|
||||
80
skyvern-ts/client/src/core/url/join.ts
Normal file
80
skyvern-ts/client/src/core/url/join.ts
Normal 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);
|
||||
}
|
||||
74
skyvern-ts/client/src/core/url/qs.ts
Normal file
74
skyvern-ts/client/src/core/url/qs.ts
Normal 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("&");
|
||||
}
|
||||
Reference in New Issue
Block a user