ts / src / http.ts
1,738 words
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/**
 * HTTP core: typed requests against the API base, requests against absolute
 * node URLs (upload grants, mailboxes), pagination and polling helpers.
 * Every function here is pure apart from the injected `fetch`/`sleep`.
 */
import type { Client } from './client.js';
import { apiError, configError, timeoutError, transportError } from './errors.js';
import type { Body, ElementOf, Method, MethodKey, Params, PathsWith, Query, Res } from './types.js';

export type HttpRequest = Readonly<{
  method: Method;
  url: string;
  headers: Readonly<Record<string, string>>;
  body?: BodyInit;
}>;

export type RequestOptions<P extends PathsWith<M>, M extends MethodKey> = Readonly<{
  /** Values for the `{name}` placeholders in the path. */
  params?: Params<P, M>;
  /** Query-string parameters; undefined/null values are skipped. */
  query?: Query<P, M>;
  /** JSON request body (some DELETEs carry one). */
  body?: Body<P, M>;
}>;

export type UrlRequestOptions = Readonly<{
  /** Bearer token for the endpoint (from the grant or the mint). */
  token?: string | null;
  query?: Readonly<Record<string, string | number | boolean | null | undefined>>;
  /** Extra headers, merged over the computed ones. */
  headers?: Readonly<Record<string, string>>;
  /** Raw body, sent as-is. */
  body?: BodyInit;
  fetch?: typeof fetch;
  timeoutMs?: number;
}>;

export type PollOptions = Readonly<{
  /** Initial interval between polls (default 1000). */
  intervalMs?: number;
  /** Backoff cap: the interval doubles up to this (default 8000). */
  maxIntervalMs?: number;
  /** Overall deadline (default 120000). */
  timeoutMs?: number;
  /** Message for the timeout error. */
  message?: string;
  sleep?: (ms: number) => Promise<void>;
}>;

export type UploadSource = Uint8Array | ArrayBuffer | Blob | ReadableStream<Uint8Array>;

/** Percent-encode one URL component, using %20 (never +) for spaces. */
export const encode = (value: string): string =>
  encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);

/** Build a query string with its leading `?`, or '' when nothing is set. */
export const queryString = (query?: object | null): string => {
  if (!query) return '';
  const pairs = Object.entries(query)
    .filter(([, value]) => value !== undefined && value !== null)
    .map(([key, value]) => `${encode(key)}=${encode(String(value))}`);
  return pairs.length === 0 ? '' : `?${pairs.join('&')}`;
};

const fillPath = (template: string, params?: object): string =>
  template.replace(/\{([^}]+)\}/g, (_, name: string) => {
    const value = (params as Record<string, unknown> | undefined)?.[name];
    if (value === undefined || value === null) {
      throw configError(`Missing path parameter {${name}} for ${template}`, { template, params });
    }
    return encode(String(value));
  });

const baseHeaders = (token: string | null | undefined, jsonBody: boolean): Record<string, string> => ({
  Accept: 'application/json',
  ...(token?.trim() ? { Authorization: `Bearer ${token}` } : {}),
  ...(jsonBody ? { 'Content-Type': 'application/json' } : {}),
});

/** Build the request for an API call without sending it. */
export const buildRequest = <M extends MethodKey, P extends PathsWith<M>>(
  client: Client,
  method: M,
  path: P,
  options: RequestOptions<P, M> = {},
): HttpRequest => {
  const hasBody = options.body !== undefined;
  return {
    method: method.toUpperCase() as Method,
    url: `${client.apiUrl}${fillPath(path, options.params)}${queryString(options.query)}`,
    headers: { ...baseHeaders(client.apiKey, hasBody), ...client.headers },
    ...(hasBody ? { body: JSON.stringify(options.body) } : {}),
  };
};

type ErrorEnvelope = { error?: string | { code?: string; message?: string } };

const envelope = (parsed: unknown): ErrorEnvelope['error'] =>
  typeof parsed === 'object' && parsed !== null ? (parsed as ErrorEnvelope).error : undefined;

/** The ird API envelope is {error:{code,message}}; the node-hosted mailbox uses a bare {error:"code"}. */
const errorMessage = (parsed: unknown, text: string): string => {
  const error = envelope(parsed);
  if (typeof error === 'string') return error;
  return error?.message ?? text;
};

const errorCode = (parsed: unknown): string | null => {
  const error = envelope(parsed);
  if (typeof error === 'string') return error;
  return error?.code ?? null;
};

/**
 * Turn a raw status + body into the parsed JSON value, throwing an api error
 * on non-2xx statuses and a transport error on unparseable success bodies.
 * An empty 2xx body decodes to `{}`.
 */
export const decodeResponse = (status: number, body: string): unknown => {
  const ok = status >= 200 && status <= 299;
  const text = body.trim();
  if (text === '') {
    if (ok) return {};
    throw apiError('Empty error response', null, status, null);
  }
  let parsed: unknown;
  let parsable = true;
  try {
    parsed = JSON.parse(text);
  } catch (e) {
    if (ok) throw transportError('Failed to parse JSON response', text, e);
    parsable = false;
  }
  if (!ok) {
    const response = parsable && typeof parsed === 'object' ? parsed : null;
    throw apiError(errorMessage(response, text), errorCode(response), status, response);
  }
  return parsed;
};

const describe = (e: unknown): string => (e instanceof Error ? e.message : String(e));

const isStream = (body: BodyInit | undefined): boolean =>
  typeof ReadableStream !== 'undefined' && body instanceof ReadableStream;

const send = async (
  fetchFn: typeof fetch,
  req: HttpRequest,
  timeoutMs: number | undefined,
): Promise<unknown> => {
  const init: RequestInit & { duplex?: 'half' } = {
    method: req.method,
    headers: req.headers,
    ...(req.body === undefined ? {} : { body: req.body }),
    ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}),
    ...(isStream(req.body) ? { duplex: 'half' } : {}),
  };
  let response: Response;
  let text: string;
  try {
    response = await fetchFn(req.url, init);
    text = await response.text();
  } catch (e) {
    throw transportError(`Request failed: ${describe(e)}`, { url: req.url }, e);
  }
  return decodeResponse(response.status, text);
};

/**
 * Execute an API request. METHOD and PATH are spec keys, so the options and
 * the result are typed from the OpenAPI document:
 *
 *   await request(client, 'get', '/domains/check', { query: { domain: 'x.ro' } })
 *
 * Throws an api error on non-2xx responses and a transport error on
 * connection/parse failures.
 */
export const request = <M extends MethodKey, P extends PathsWith<M>>(
  client: Client,
  method: M,
  path: P,
  options?: RequestOptions<P, M>,
): Promise<Res<P, M>> =>
  send(client.fetch, buildRequest(client, method, path, options), client.timeoutMs) as Promise<Res<P, M>>;

export const get = <P extends PathsWith<'get'>>(client: Client, path: P, options?: RequestOptions<P, 'get'>) =>
  request(client, 'get', path, options);
export const post = <P extends PathsWith<'post'>>(client: Client, path: P, options?: RequestOptions<P, 'post'>) =>
  request(client, 'post', path, options);
export const put = <P extends PathsWith<'put'>>(client: Client, path: P, options?: RequestOptions<P, 'put'>) =>
  request(client, 'put', path, options);
export const patch = <P extends PathsWith<'patch'>>(client: Client, path: P, options?: RequestOptions<P, 'patch'>) =>
  request(client, 'patch', path, options);
export const del = <P extends PathsWith<'delete'>>(client: Client, path: P, options?: RequestOptions<P, 'delete'>) =>
  request(client, 'delete', path, options);

/**
 * Execute a request against an absolute URL rather than the API base — for
 * the node-hosted endpoints the API hands out (an IPFS upload grant, a p2p
 * app's mailbox). Throws like `request`.
 */
export const requestUrl = <T = unknown>(
  method: Method,
  url: string,
  options: UrlRequestOptions = {},
): Promise<T> =>
  send(
    options.fetch ?? globalThis.fetch,
    {
      method,
      url: `${url}${queryString(options.query)}`,
      headers: { ...baseHeaders(options.token, false), ...options.headers },
      ...(options.body === undefined ? {} : { body: options.body }),
    },
    options.timeoutMs,
  ) as Promise<T>;

type PageOptions<P extends PathsWith<'get'>> = Readonly<{
  params?: Params<P, 'get'>;
  query?: Query<P, 'get'>;
}>;

/**
 * Follow cursor pagination for a list endpoint, collecting every item under
 * KEY (e.g. 'registrations'). `cursor` is advanced from each response's
 * `next_cursor` until it is absent.
 */
export const paginate = async <P extends PathsWith<'get'>, K extends keyof Res<P, 'get'> & string>(
  client: Client,
  path: P,
  key: K,
  options: PageOptions<P> = {},
): Promise<readonly ElementOf<Res<P, 'get'>[K]>[]> => {
  const items: ElementOf<Res<P, 'get'>[K]>[] = [];
  let cursor: string | undefined;
  do {
    const query = { ...(options.query as object | undefined), ...(cursor ? { cursor } : {}) } as Query<P, 'get'>;
    const page = (await get(client, path, { params: options.params, query } as RequestOptions<P, 'get'>)) as Record<
      string,
      unknown
    >;
    items.push(...((page[key] as ElementOf<Res<P, 'get'>[K]>[] | undefined) ?? []));
    cursor = (page.next_cursor as string | null | undefined) ?? undefined;
  } while (cursor);
  return items;
};

/**
 * Follow offset pagination for a list endpoint, collecting every item under
 * KEY (e.g. 'pins'). The page `limit` defaults to 100; `offset` advances by
 * the rows each page actually returns and paging stops on the first short
 * or empty page. The response's `total` is deliberately ignored — it can
 * move while the pages are walked.
 */
export const paginateOffset = async <P extends PathsWith<'get'>, K extends keyof Res<P, 'get'> & string>(
  client: Client,
  path: P,
  key: K,
  options: PageOptions<P> = {},
): Promise<readonly ElementOf<Res<P, 'get'>[K]>[]> => {
  const base = (options.query ?? {}) as Record<string, unknown>;
  const limit = typeof base.limit === 'number' ? base.limit : 100;
  const items: ElementOf<Res<P, 'get'>[K]>[] = [];
  let offset = typeof base.offset === 'number' ? base.offset : 0;
  for (;;) {
    const query = { ...base, limit, offset } as Query<P, 'get'>;
    const page = (await get(client, path, { params: options.params, query } as RequestOptions<P, 'get'>)) as Record<
      string,
      unknown
    >;
    const rows = (page[key] as ElementOf<Res<P, 'get'>[K]>[] | undefined) ?? [];
    items.push(...rows);
    if (rows.length < limit) return items;
    offset += rows.length;
  }
};

const defaultSleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Poll STEP until DONE holds for its result, sleeping between attempts with
 * exponential backoff (the interval doubles up to `maxIntervalMs`). Returns
 * the first result that satisfies DONE; throws a timeout error (carrying the
 * last result) when the deadline passes first.
 */
export const pollUntil = async <T>(
  step: () => Promise<T>,
  done: (result: T) => boolean,
  options: PollOptions = {},
): Promise<T> => {
  const { intervalMs = 1000, maxIntervalMs = 8000, timeoutMs = 120000, sleep = defaultSleep } = options;
  const deadline = Date.now() + timeoutMs;
  let interval = intervalMs;
  for (;;) {
    const result = await step();
    if (done(result)) return result;
    if (Date.now() >= deadline) throw timeoutError(options.message ?? 'Timed out polling', result);
    await sleep(interval);
    interval = Math.min(maxIntervalMs, interval * 2);
  }
};

const isUploadSource = (source: unknown): source is UploadSource =>
  source instanceof Uint8Array ||
  source instanceof ArrayBuffer ||
  (typeof Blob !== 'undefined' && source instanceof Blob) ||
  (typeof ReadableStream !== 'undefined' && source instanceof ReadableStream);

/**
 * POST raw bytes to an IPFS upload-grant URL (from `ipfs.startUpload`). The
 * token is single-use and may be null once consumed. `format: 'car'`
 * appends ?format=car for a client-packed CAR archive.
 */
export const uploadBytes = (
  url: string,
  token: string | null | undefined,
  source: UploadSource,
  options: Readonly<{ format?: 'file' | 'car'; fetch?: typeof fetch; timeoutMs?: number }> = {},
): Promise<unknown> => {
  if (!isUploadSource(source)) {
    throw transportError('Unsupported upload source; expected bytes, an ArrayBuffer, a Blob, or a ReadableStream', {
      source: typeof source,
    });
  }
  return requestUrl('POST', url, {
    token,
    query: options.format === 'car' ? { format: 'car' } : undefined,
    headers: { 'Content-Type': 'application/octet-stream' },
    body: source as BodyInit,
    fetch: options.fetch,
    timeoutMs: options.timeoutMs,
  });
};

static mirror of HEAD · about · clone: git clone https://ird.cod-sursa.ro/ts.git