@worldlabsai/client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +109 -0
  3. package/dist/client.d.ts +34 -0
  4. package/dist/client.d.ts.map +1 -0
  5. package/dist/client.js +31 -0
  6. package/dist/client.js.map +1 -0
  7. package/dist/core/assets.d.ts +138 -0
  8. package/dist/core/assets.d.ts.map +1 -0
  9. package/dist/core/assets.js +104 -0
  10. package/dist/core/assets.js.map +1 -0
  11. package/dist/core/index.d.ts +5 -0
  12. package/dist/core/index.d.ts.map +1 -0
  13. package/dist/core/index.js +5 -0
  14. package/dist/core/index.js.map +1 -0
  15. package/dist/core/operations.d.ts +52 -0
  16. package/dist/core/operations.d.ts.map +1 -0
  17. package/dist/core/operations.js +81 -0
  18. package/dist/core/operations.js.map +1 -0
  19. package/dist/core/sleep.d.ts +7 -0
  20. package/dist/core/sleep.d.ts.map +1 -0
  21. package/dist/core/sleep.js +23 -0
  22. package/dist/core/sleep.js.map +1 -0
  23. package/dist/core/tasks.d.ts +27 -0
  24. package/dist/core/tasks.d.ts.map +1 -0
  25. package/dist/core/tasks.js +26 -0
  26. package/dist/core/tasks.js.map +1 -0
  27. package/dist/core/transport.d.ts +84 -0
  28. package/dist/core/transport.d.ts.map +1 -0
  29. package/dist/core/transport.js +171 -0
  30. package/dist/core/transport.js.map +1 -0
  31. package/dist/generated/developer-api.gen.d.ts +2760 -0
  32. package/dist/generated/developer-api.gen.d.ts.map +1 -0
  33. package/dist/generated/developer-api.gen.js +6 -0
  34. package/dist/generated/developer-api.gen.js.map +1 -0
  35. package/dist/generated/tasks.gen.d.ts +153 -0
  36. package/dist/generated/tasks.gen.d.ts.map +1 -0
  37. package/dist/generated/tasks.gen.js +80 -0
  38. package/dist/generated/tasks.gen.js.map +1 -0
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +3 -0
  42. package/dist/index.js.map +1 -0
  43. package/examples/posing.ts +62 -0
  44. package/package.json +49 -0
  45. package/src/client.ts +55 -0
  46. package/src/core/assets.ts +162 -0
  47. package/src/core/index.ts +27 -0
  48. package/src/core/operations.ts +114 -0
  49. package/src/core/sleep.ts +22 -0
  50. package/src/core/tasks.ts +45 -0
  51. package/src/core/transport.ts +246 -0
  52. package/src/generated/developer-api.gen.ts +2760 -0
  53. package/src/generated/tasks.gen.ts +110 -0
  54. package/src/index.ts +24 -0
@@ -0,0 +1,162 @@
1
+ import type {
2
+ Asset,
3
+ AssetCreate,
4
+ CompleteUploadRequest,
5
+ CreateAssetResponse,
6
+ CreateReadUrlResponse,
7
+ CreateUploadUrlResponse,
8
+ ListAssetsResponse,
9
+ TaskAsset,
10
+ } from "../generated/developer-api.gen.ts";
11
+ import { ApiError } from "./transport.ts";
12
+ import type { Transport } from "./transport.ts";
13
+
14
+ /** Settings for uploading asset bytes. */
15
+ export interface UploadOptions extends Pick<RequestInit, "signal"> {
16
+ /** Name shown for the asset. */
17
+ displayName: string;
18
+ /** Overrides `Blob.type`. Required for byte arrays or a `Blob` without a type. */
19
+ mimeType?: string;
20
+ }
21
+
22
+ /** Pagination and cancellation settings for listing assets. */
23
+ export interface ListAssetsOptions extends Pick<RequestInit, "signal"> {
24
+ /** Maximum number of assets to return. */
25
+ pageSize?: number;
26
+ /** `nextPageToken` from the previous list response. Omit for the first page. */
27
+ pageToken?: string;
28
+ }
29
+
30
+ /** Return the id an asset is addressed by in task requests. */
31
+ export const assetId = (asset: Asset): string => asset.id;
32
+
33
+ /** Convert an asset resource into a task media reference. */
34
+ export const assetRef = (asset: Asset): TaskAsset => ({ assetId: assetId(asset) });
35
+
36
+ const toBlob = (data: Blob | BufferSource, mimeType: string | undefined): Blob => {
37
+ if (data instanceof Blob) {
38
+ return mimeType === undefined || mimeType === data.type
39
+ ? data
40
+ : new Blob([data], { type: mimeType });
41
+ }
42
+ return new Blob([data], { type: mimeType ?? "" });
43
+ };
44
+
45
+ /**
46
+ * Create asset upload and management methods.
47
+ * @param transport - Authenticated API transport, with a separate fetch for signed uploads.
48
+ * @param assetsPath - Asset collection route. Item routes append an `{asset}` placeholder.
49
+ */
50
+ export const createAssets = (transport: Transport, assetsPath: string) => {
51
+ const itemPath = `${assetsPath}/{asset}`;
52
+
53
+ const create = async (asset: AssetCreate, signal?: AbortSignal | null) =>
54
+ await transport.request<CreateAssetResponse>(assetsPath, {
55
+ body: { asset },
56
+ method: "POST",
57
+ ...(signal ? { signal } : {}),
58
+ });
59
+
60
+ const completeUpload = async (
61
+ id: string,
62
+ body: CompleteUploadRequest = {},
63
+ options: Pick<RequestInit, "signal"> = {},
64
+ ) =>
65
+ await transport.request<Asset>(`${itemPath}:completeUpload`, {
66
+ body,
67
+ method: "POST",
68
+ params: { asset: id },
69
+ ...options,
70
+ });
71
+
72
+ return {
73
+ /**
74
+ * Finalize an asset after its bytes have been uploaded.
75
+ * @param id - Asset ID from {@link assetId}.
76
+ * @returns The updated asset resource.
77
+ */
78
+ completeUpload,
79
+
80
+ /** Create an asset record. Use `upload` to create and upload a single file in one call. */
81
+ create: async (asset: AssetCreate, options: Pick<RequestInit, "signal"> = {}) =>
82
+ await create(asset, options.signal),
83
+
84
+ /**
85
+ * Create a temporary signed URL for reading an asset's stored bytes.
86
+ * @param id - Asset ID from {@link assetId}.
87
+ */
88
+ createReadUrl: async (id: string, options: Pick<RequestInit, "signal"> = {}) =>
89
+ await transport.request<CreateReadUrlResponse>(`${itemPath}:createReadUrl`, {
90
+ method: "POST",
91
+ params: { asset: id },
92
+ ...options,
93
+ }),
94
+
95
+ /**
96
+ * Soft-delete an asset.
97
+ * @param id - Asset ID from {@link assetId}.
98
+ */
99
+ delete: async (id: string, options: Pick<RequestInit, "signal"> = {}) => {
100
+ await transport.request(itemPath, { method: "DELETE", params: { asset: id }, ...options });
101
+ },
102
+
103
+ /**
104
+ * Fetch asset metadata.
105
+ * @param id - Asset ID from {@link assetId}.
106
+ */
107
+ get: async (id: string, options: Pick<RequestInit, "signal"> = {}) =>
108
+ await transport.request<Asset>(itemPath, { params: { asset: id }, ...options }),
109
+
110
+ /** Return one page of assets. Pass the response's `nextPageToken` to retrieve the next page. */
111
+ list: async ({ pageSize, pageToken, ...options }: ListAssetsOptions = {}) =>
112
+ await transport.request<ListAssetsResponse>(assetsPath, {
113
+ query: { pageSize, pageToken },
114
+ ...options,
115
+ }),
116
+
117
+ /**
118
+ * Create an asset, upload the bytes, and mark the upload complete.
119
+ *
120
+ * @param data - Bytes to upload. A `Blob` supplies its own MIME type when present.
121
+ * @param options - Display name, MIME type, and cancellation settings.
122
+ * @returns The completed asset. Pass it to {@link assetRef} to use it in a task.
123
+ * @throws {Error} If neither `mimeType` nor the input `Blob` supplies a MIME type.
124
+ * @throws {ApiError} If an API request or storage upload fails with a non-2xx status.
125
+ */
126
+ upload: async (data: Blob | BufferSource, { displayName, mimeType, signal }: UploadOptions) => {
127
+ const blob = toBlob(data, mimeType);
128
+ if (blob.type === "") {
129
+ throw new Error("upload needs a mimeType when the data does not carry one");
130
+ }
131
+ const init = signal ? { signal } : {};
132
+
133
+ const created = await create({ displayName }, signal);
134
+ const id = assetId(created);
135
+
136
+ const ticket = await transport.request<CreateUploadUrlResponse>(
137
+ `${itemPath}:createUploadUrl`,
138
+ {
139
+ body: { contentLength: blob.size, contentType: blob.type },
140
+ method: "POST",
141
+ params: { asset: id },
142
+ ...init,
143
+ },
144
+ );
145
+
146
+ // The signed URL belongs to the storage host: no base URL, no API auth.
147
+ const stored = await transport.fetch(
148
+ new Request(ticket.uploadUrl, {
149
+ body: blob,
150
+ headers: ticket.headers ?? {},
151
+ method: ticket.method,
152
+ ...init,
153
+ }),
154
+ );
155
+ if (!stored.ok) {
156
+ throw new ApiError(stored, await stored.text());
157
+ }
158
+
159
+ return await completeUpload(id, { sizeBytes: blob.size }, init);
160
+ },
161
+ };
162
+ };
@@ -0,0 +1,27 @@
1
+ export {
2
+ assetId,
3
+ assetRef,
4
+ createAssets,
5
+ type ListAssetsOptions,
6
+ type UploadOptions,
7
+ } from "./assets.ts";
8
+ export {
9
+ createOperations,
10
+ type DoneOperation,
11
+ type Operation,
12
+ waitForOperation,
13
+ type WaitOptions,
14
+ } from "./operations.ts";
15
+ export { type SubmitOptions, submitTask } from "./tasks.ts";
16
+ export {
17
+ ApiError,
18
+ type ApiKey,
19
+ apiKeyAuth,
20
+ createTransport,
21
+ type Fetch,
22
+ type Middleware,
23
+ type RequestOptions,
24
+ type Transport,
25
+ type TransportOptions,
26
+ withHeader,
27
+ } from "./transport.ts";
@@ -0,0 +1,114 @@
1
+ import type { Operation as WireOperation } from "../generated/developer-api.gen.ts";
2
+ import { sleep } from "./sleep.ts";
3
+ import { ApiError } from "./transport.ts";
4
+ import type { Transport } from "./transport.ts";
5
+
6
+ /**
7
+ * A long-running task operation whose `response` is typed by the task that created it.
8
+ * A pending operation has `done: false`; a finished operation has `done: true` and
9
+ * may contain either a response or an error reported by the task.
10
+ */
11
+ export type Operation<TResponse = Record<string, unknown>> = Omit<WireOperation, "response"> & {
12
+ response?: TResponse | null;
13
+ };
14
+
15
+ /** A task operation known to have finished. Inspect `error` before using `response`. */
16
+ export type DoneOperation<TResponse> = Operation<TResponse> & { done: true };
17
+
18
+ /** Polling and local cancellation settings. Waiting has no default overall timeout. */
19
+ export interface WaitOptions extends Pick<RequestInit, "signal"> {
20
+ /** Delay after an incomplete poll, in milliseconds. Defaults to 1,000 ms. */
21
+ pollIntervalMs?: number;
22
+ }
23
+
24
+ const DEFAULT_POLL_INTERVAL_MS = 1000;
25
+
26
+ /**
27
+ * Poll an operation until it reports `done`.
28
+ * Checks for cancellation before each poll and passes the signal to `fetchOperation`.
29
+ *
30
+ * @param fetchOperation - Fetches the current state; honor its signal to abort an in-flight poll.
31
+ * @param options - Polling interval and cancellation settings.
32
+ * @returns The finished operation, which may contain `error` instead of `response`.
33
+ */
34
+ export const waitForOperation = async <TResponse>(
35
+ fetchOperation: (signal?: AbortSignal | null) => Promise<Operation<TResponse>>,
36
+ { pollIntervalMs = DEFAULT_POLL_INTERVAL_MS, signal }: WaitOptions = {},
37
+ ): Promise<DoneOperation<TResponse>> => {
38
+ // oxlint-disable no-await-in-loop -- polling is sequential by definition
39
+ for (;;) {
40
+ signal?.throwIfAborted();
41
+ const operation = await fetchOperation(signal);
42
+ if (operation.done) {
43
+ return { ...operation, done: true };
44
+ }
45
+ await sleep(pollIntervalMs, signal);
46
+ }
47
+ // oxlint-enable no-await-in-loop
48
+ };
49
+
50
+ /**
51
+ * Create operation lookup and polling methods.
52
+ * @param transport - Authenticated API transport.
53
+ * @param operationPath - Lookup route containing an `{operation}` placeholder.
54
+ */
55
+ export const createOperations = (transport: Transport, operationPath: string) => {
56
+ const get = async <TResponse = Record<string, unknown>>(
57
+ operationId: string,
58
+ { signal, wait }: Pick<RequestInit, "signal"> & { wait?: boolean } = {},
59
+ ) =>
60
+ await transport.request<Operation<TResponse>>(operationPath, {
61
+ params: { operation: operationId },
62
+ query: { wait },
63
+ ...(signal ? { signal } : {}),
64
+ });
65
+
66
+ return {
67
+ /** Fetch the current state of an operation by its id. */
68
+ get: async (operationId: string, options: Pick<RequestInit, "signal"> = {}) =>
69
+ await get(operationId, options),
70
+
71
+ /**
72
+ * Poll until an operation is done. The returned operation keeps the response type
73
+ * associated with the task that created it.
74
+ *
75
+ * An operation can finish with an API-reported task error; inspect `error` before
76
+ * reading `response`. Aborting stops local waiting without canceling the task.
77
+ * HTTP 502, 503, and 504 polling failures are retried up to three times in a row;
78
+ * a successful poll resets the retry count.
79
+ *
80
+ * @param operation - Operation returned by task submission. Completed operations return immediately.
81
+ * @param options - Polling interval and abort signal; no overall timeout by default.
82
+ * @throws {ApiError} If polling fails with an unretried status or exhausts gateway retries.
83
+ */
84
+ wait: async <TResponse>(operation: Operation<TResponse>, options?: WaitOptions) => {
85
+ options?.signal?.throwIfAborted();
86
+ if (operation.done) {
87
+ return { ...operation, done: true as const };
88
+ }
89
+ let gatewayErrors = 0;
90
+ return await waitForOperation<TResponse>(async (signal) => {
91
+ try {
92
+ const current = await get<TResponse>(operation.id, {
93
+ ...(signal ? { signal } : {}),
94
+ wait: true,
95
+ });
96
+ gatewayErrors = 0;
97
+ return current;
98
+ } catch (error) {
99
+ // A gateway timeout does not cancel the task. Resume the same read;
100
+ // the caller's signal still bounds the entire wait, including retries.
101
+ gatewayErrors += 1;
102
+ if (
103
+ !(error instanceof ApiError) ||
104
+ ![502, 503, 504].includes(error.response.status) ||
105
+ gatewayErrors > 3
106
+ ) {
107
+ throw error;
108
+ }
109
+ return operation;
110
+ }
111
+ }, options);
112
+ },
113
+ };
114
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Resolves after `ms`, or as soon as `signal` aborts. Uses `setTimeout` rather than
3
+ * `AbortSignal.timeout` because Node does not keep the event loop alive for the latter;
4
+ * a script whose only pending work is this sleep would exit before the next poll.
5
+ */
6
+ export const sleep = async (ms: number, signal?: AbortSignal | null): Promise<void> => {
7
+ if (signal?.aborted) {
8
+ return;
9
+ }
10
+ // oxlint-disable-next-line promise/avoid-new -- waiting on a timer has no promise-returning API
11
+ await new Promise<void>((resolve) => {
12
+ const finish = (): void => {
13
+ // oxlint-disable-next-line no-use-before-define -- finish runs after the timer is initialized
14
+ clearTimeout(timer);
15
+ signal?.removeEventListener("abort", finish);
16
+ // oxlint-disable-next-line promise/no-multiple-resolved -- cleanup does not invoke the callbacks
17
+ resolve();
18
+ };
19
+ const timer = setTimeout(finish, ms);
20
+ signal?.addEventListener("abort", finish, { once: true });
21
+ });
22
+ };
@@ -0,0 +1,45 @@
1
+ import type { Transport } from "./transport.ts";
2
+
3
+ export interface SubmitOptions extends Pick<RequestInit, "signal"> {
4
+ /**
5
+ * Ask the server to wait up to its time limit. The returned operation may still
6
+ * be running; use `operations.wait` to await completion. Defaults to false.
7
+ */
8
+ wait?: boolean;
9
+ /**
10
+ * Nonempty key shared by every attempt. Defaults to a fresh UUID per call;
11
+ * reuse your own key when retrying across calls or processes.
12
+ */
13
+ idempotencyKey?: string;
14
+ }
15
+
16
+ /**
17
+ * Submit a task request and return its operation resource.
18
+ * Retries network failures and HTTP 429/502/503/504 up to three times with the
19
+ * same key and body. Uses jittered exponential backoff and honors Retry-After.
20
+ *
21
+ * @param transport - Transport used to send the request.
22
+ * @param path - Task submission path.
23
+ * @param body - Task-specific request payload.
24
+ * @param options - Server wait, idempotency key, and local cancellation settings.
25
+ * @returns The operation created for the task.
26
+ * @throws {ApiError} If the API rejects the submission or retries are exhausted.
27
+ */
28
+ export const submitTask = async <TOperation>(
29
+ transport: Transport,
30
+ path: string,
31
+ body: unknown,
32
+ { idempotencyKey = crypto.randomUUID(), signal, wait }: SubmitOptions = {},
33
+ ): Promise<TOperation> => {
34
+ if (idempotencyKey.trim() === "") {
35
+ throw new Error("Idempotency key must not be empty");
36
+ }
37
+ return await transport.request<TOperation>(path, {
38
+ body,
39
+ headers: { "Idempotency-Key": idempotencyKey },
40
+ method: "POST",
41
+ query: { wait },
42
+ retry: true,
43
+ ...(signal ? { signal } : {}),
44
+ });
45
+ };
@@ -0,0 +1,246 @@
1
+ import { sleep } from "./sleep.ts";
2
+
3
+ /** Handles a `Request` for API calls and signed uploads; standard `fetch` is compatible. */
4
+ export type Fetch = (request: Request) => Promise<Response>;
5
+
6
+ /**
7
+ * Middleware around a request handler. It may alter the request, return a response
8
+ * without calling `next`, call `next` more than once for retries, or inspect the response.
9
+ * Clone requests before sending them if a retry may need to reuse the body.
10
+ */
11
+ export type Middleware = (request: Request, next: Fetch) => Promise<Response>;
12
+
13
+ /** Values accepted in a request query. `null` and `undefined` are omitted. */
14
+ export type QueryValue = string | number | boolean | null | undefined;
15
+
16
+ /**
17
+ * Options for a JSON API request. Other Fetch settings, including headers and an
18
+ * abort signal, pass through to the request.
19
+ */
20
+ export interface RequestOptions extends Omit<RequestInit, "body"> {
21
+ /** Values URL-encoded into `{name}` placeholders in the path. */
22
+ params?: Record<string, string>;
23
+ /** Query values. `null` and `undefined` entries are omitted. */
24
+ query?: Record<string, QueryValue>;
25
+ /** JSON-serializable request body. */
26
+ body?: unknown;
27
+ /** Retry transient failures up to three times. Requires an idempotent request. */
28
+ retry?: boolean;
29
+ }
30
+
31
+ /** Configuration for the fetch transport. */
32
+ export interface TransportOptions {
33
+ /** API origin used to resolve request paths. Trailing slashes are ignored. */
34
+ baseUrl: string | URL;
35
+ /** Fetch implementation for API requests and uploads. Defaults to `globalThis.fetch`. */
36
+ fetch?: Fetch;
37
+ /** Middleware applied to API requests, in outermost-first order. */
38
+ middleware?: Middleware[];
39
+ /** Path values used when a request does not provide its own value. */
40
+ pathParams?: Record<string, string>;
41
+ }
42
+
43
+ /** FastAPI puts its reason under `detail`; operation and storage errors use `message`. */
44
+ const errorDetail = (body: unknown): string | undefined => {
45
+ if (typeof body !== "object" || body === null) {
46
+ return undefined;
47
+ }
48
+ for (const key of ["detail", "message"]) {
49
+ const value = Reflect.get(body, key);
50
+ if (typeof value === "string" && value !== "") {
51
+ return value;
52
+ }
53
+ }
54
+ return undefined;
55
+ };
56
+
57
+ /** An API request or signed upload returned a non-2xx HTTP status. */
58
+ export class ApiError extends Error {
59
+ override name = "ApiError";
60
+ /** HTTP status and headers. The response body has already been consumed. */
61
+ readonly response: Response;
62
+ /**
63
+ * Parsed JSON or text for API failures; raw text for upload failures.
64
+ * Empty API response bodies are `undefined`.
65
+ */
66
+ readonly body: unknown;
67
+
68
+ constructor(response: Response, body: unknown) {
69
+ const where = response.url === "" ? "" : ` for ${response.url}`;
70
+ super(`${response.status} ${errorDetail(body) ?? response.statusText}${where}`);
71
+ this.response = response;
72
+ this.body = body;
73
+ }
74
+ }
75
+
76
+ const fillPath = (path: string, params: Record<string, string>): string =>
77
+ path.replaceAll(/\{(?<name>\w+)\}/gu, (_, name: string) => {
78
+ const value = params[name];
79
+ if (value === undefined) {
80
+ throw new Error(`Missing path parameter "${name}" for ${path}`);
81
+ }
82
+ return encodeURIComponent(value);
83
+ });
84
+
85
+ const compose = (middleware: Middleware[], fetch: Fetch): Fetch => {
86
+ let next = fetch;
87
+ for (const layer of middleware.toReversed()) {
88
+ const inner = next;
89
+ next = async (request) => await layer(request, inner);
90
+ }
91
+ return next;
92
+ };
93
+
94
+ const parseBody = async (response: Response): Promise<unknown> => {
95
+ const text = await response.text();
96
+ if (text === "") {
97
+ return undefined;
98
+ }
99
+ try {
100
+ return JSON.parse(text);
101
+ } catch {
102
+ return text;
103
+ }
104
+ };
105
+
106
+ const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]);
107
+
108
+ const retryDelay = (error: unknown, attempt: number): number => {
109
+ const backoff = 1000 * 2 ** attempt * (0.5 + Math.random() / 2);
110
+ const value =
111
+ error instanceof ApiError ? error.response.headers.get("retry-after")?.trim() : undefined;
112
+ if (!value) {
113
+ return backoff;
114
+ }
115
+ const delay = /^\d+$/u.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now();
116
+ return Math.max(backoff, Number.isFinite(delay) ? delay : 0);
117
+ };
118
+
119
+ export interface Transport {
120
+ /**
121
+ * Send a request and parse its response as JSON, falling back to text.
122
+ * Empty responses resolve to `undefined`; `T` is not validated at runtime.
123
+ * @throws {ApiError} If the response has a non-2xx status.
124
+ */
125
+ request: <T>(path: string, init?: RequestOptions) => Promise<T>;
126
+ /** Run middleware and fetch, leaving status handling and body parsing to the caller. */
127
+ send: Fetch;
128
+ /**
129
+ * Send a request without the API base URL, middleware, or auth. Use for URLs
130
+ * supplied by the API, such as signed upload URLs.
131
+ */
132
+ fetch: Fetch;
133
+ }
134
+
135
+ /**
136
+ * Create a JSON-over-fetch transport for an API surface.
137
+ *
138
+ * Requests use `baseUrl`, substitute path and query values, serialize `body` as JSON,
139
+ * and pass through middleware. Non-2xx responses reject with {@link ApiError}.
140
+ *
141
+ * @param options - Transport origin, fetch implementation, middleware, and bound paths.
142
+ * @returns JSON requests, raw requests through middleware, and the underlying fetch function.
143
+ */
144
+ export const createTransport = (options: TransportOptions): Transport => {
145
+ const fetch = options.fetch ?? (async (request: Request) => await globalThis.fetch(request));
146
+ const send = compose(options.middleware ?? [], fetch);
147
+ const baseUrl = String(options.baseUrl).replace(/\/+$/u, "");
148
+ const boundParams = options.pathParams ?? {};
149
+
150
+ return {
151
+ fetch,
152
+ send,
153
+ request: async <T>(
154
+ path: string,
155
+ { body, params, query, retry = false, ...init }: RequestOptions = {},
156
+ ): Promise<T> => {
157
+ const url = new URL(baseUrl + fillPath(path, { ...boundParams, ...params }));
158
+ for (const [key, value] of Object.entries(query ?? {})) {
159
+ if (value !== undefined && value !== null) {
160
+ url.searchParams.set(key, String(value));
161
+ }
162
+ }
163
+ const headers = new Headers(init.headers);
164
+ if (body !== undefined) {
165
+ headers.set("content-type", "application/json");
166
+ }
167
+ const request = new Request(url, {
168
+ ...init,
169
+ headers,
170
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
171
+ });
172
+
173
+ // Provider and middleware TypeErrors must not be mistaken for network failures.
174
+ let networkError: unknown;
175
+ const sendRequest = retry
176
+ ? compose(options.middleware ?? [], async (attempt) => {
177
+ try {
178
+ return await fetch(attempt);
179
+ } catch (error) {
180
+ networkError = error;
181
+ throw error;
182
+ }
183
+ })
184
+ : send;
185
+
186
+ // oxlint-disable no-await-in-loop -- retries are sequential and preserve the original request
187
+ for (let attempt = 0; ; attempt += 1) {
188
+ request.signal.throwIfAborted();
189
+ networkError = undefined;
190
+ try {
191
+ const response = await sendRequest(retry ? request.clone() : request);
192
+ let parsed: unknown;
193
+ try {
194
+ parsed = await parseBody(response);
195
+ } catch (error) {
196
+ if (!response.ok) {
197
+ throw new ApiError(response, undefined);
198
+ }
199
+ networkError = error;
200
+ throw error;
201
+ }
202
+ if (!response.ok) {
203
+ throw new ApiError(response, parsed);
204
+ }
205
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- JSON boundary
206
+ return parsed as T;
207
+ } catch (error) {
208
+ request.signal.throwIfAborted();
209
+ const transient =
210
+ error instanceof ApiError
211
+ ? RETRYABLE_STATUS_CODES.has(error.response.status)
212
+ : error instanceof TypeError && error === networkError;
213
+ if (!retry || attempt >= 3 || !transient) {
214
+ throw error;
215
+ }
216
+ await sleep(retryDelay(error, attempt), request.signal);
217
+ }
218
+ }
219
+ // oxlint-enable no-await-in-loop
220
+ },
221
+ };
222
+ };
223
+
224
+ /** Return a request with one header set, preserving its method, body, and other headers. */
225
+ export const withHeader = (request: Request, name: string, value: string): Request => {
226
+ const headers = new Headers(request.headers);
227
+ headers.set(name, value);
228
+ return new Request(request, { headers });
229
+ };
230
+
231
+ /** API key, or a provider resolved immediately before each API request. */
232
+ export type ApiKey = string | (() => string | Promise<string>);
233
+
234
+ /**
235
+ * Create middleware that resolves an API key and sends it as `WLT-Api-Key`.
236
+ *
237
+ * @param apiKey - Key or provider resolved before each request.
238
+ * @throws {Error} On a request whose key is empty. Provider failures also propagate before sending.
239
+ */
240
+ export const apiKeyAuth =
241
+ (apiKey: ApiKey): Middleware =>
242
+ async (request, next) => {
243
+ const key = typeof apiKey === "function" ? await apiKey() : apiKey;
244
+ if (!key) throw new Error("No API key is available for the Developer API request");
245
+ return await next(withHeader(request, "WLT-Api-Key", key));
246
+ };