@elmohq/sdk 0.0.0-claim → 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 (129) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +325 -2
  3. package/dist/calls/index.d.mts +288 -0
  4. package/dist/calls/index.d.mts.map +1 -0
  5. package/dist/calls/index.mjs +390 -0
  6. package/dist/calls/index.mjs.map +1 -0
  7. package/dist/index.d.mts +2 -0
  8. package/dist/index.mjs +1029 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/request-options-DWYI_Ldj.d.mts +1550 -0
  11. package/dist/request-options-DWYI_Ldj.d.mts.map +1 -0
  12. package/dist/tools-7Hs5J2xb.d.mts +1945 -0
  13. package/dist/tools-7Hs5J2xb.d.mts.map +1 -0
  14. package/dist/tools-SwP0sK8H.mjs +2458 -0
  15. package/dist/tools-SwP0sK8H.mjs.map +1 -0
  16. package/dist/types/index.d.mts +2 -0
  17. package/dist/types/index.mjs +1 -0
  18. package/package.json +59 -2
  19. package/src/calls/brands-analytics-get.ts +33 -0
  20. package/src/calls/brands-citations-domains-list.ts +36 -0
  21. package/src/calls/brands-citations-urls-list.ts +32 -0
  22. package/src/calls/brands-create.ts +19 -0
  23. package/src/calls/brands-get.ts +19 -0
  24. package/src/calls/brands-list.ts +18 -0
  25. package/src/calls/brands-opportunities-get.ts +42 -0
  26. package/src/calls/brands-prompt-performance-list.ts +39 -0
  27. package/src/calls/brands-query-fanout-get.ts +30 -0
  28. package/src/calls/brands-tags-list.ts +28 -0
  29. package/src/calls/brands-update.ts +20 -0
  30. package/src/calls/competitors-create.ts +23 -0
  31. package/src/calls/competitors-delete.ts +19 -0
  32. package/src/calls/competitors-get.ts +19 -0
  33. package/src/calls/competitors-list.ts +23 -0
  34. package/src/calls/competitors-update.ts +24 -0
  35. package/src/calls/index.ts +33 -0
  36. package/src/calls/me-get.ts +18 -0
  37. package/src/calls/models-list.ts +20 -0
  38. package/src/calls/organizations-billing-get.ts +37 -0
  39. package/src/calls/organizations-get.ts +24 -0
  40. package/src/calls/organizations-list.ts +27 -0
  41. package/src/calls/prompts-create.ts +27 -0
  42. package/src/calls/prompts-delete.ts +28 -0
  43. package/src/calls/prompts-get.ts +23 -0
  44. package/src/calls/prompts-list.ts +28 -0
  45. package/src/calls/prompts-runs-get.ts +27 -0
  46. package/src/calls/prompts-runs-list.ts +30 -0
  47. package/src/calls/prompts-snapshot-get.ts +28 -0
  48. package/src/calls/prompts-update.ts +29 -0
  49. package/src/calls/reports-create.ts +31 -0
  50. package/src/calls/reports-get.ts +27 -0
  51. package/src/calls/reports-list.ts +28 -0
  52. package/src/calls/tools-analyze.ts +26 -0
  53. package/src/client.ts +662 -0
  54. package/src/index.ts +21 -0
  55. package/src/internal/binding/rest/binding.ts +127 -0
  56. package/src/internal/binding/rest/errors.ts +107 -0
  57. package/src/internal/binding/rest/path.ts +9 -0
  58. package/src/internal/binding/rest/url.ts +97 -0
  59. package/src/internal/codec/json.ts +66 -0
  60. package/src/internal/codec/registry.ts +55 -0
  61. package/src/internal/core/backoff.ts +170 -0
  62. package/src/internal/core/body.ts +54 -0
  63. package/src/internal/core/browser.ts +11 -0
  64. package/src/internal/core/call-promise.ts +145 -0
  65. package/src/internal/core/callable.ts +11 -0
  66. package/src/internal/core/client.ts +89 -0
  67. package/src/internal/core/config.ts +84 -0
  68. package/src/internal/core/credentials.ts +40 -0
  69. package/src/internal/core/dispatch.ts +227 -0
  70. package/src/internal/core/env.ts +68 -0
  71. package/src/internal/core/errors.ts +431 -0
  72. package/src/internal/core/features.ts +60 -0
  73. package/src/internal/core/lazy-promise.ts +39 -0
  74. package/src/internal/core/metadata.ts +79 -0
  75. package/src/internal/core/params.ts +98 -0
  76. package/src/internal/core/protocol.ts +17 -0
  77. package/src/internal/core/read-reply.ts +7 -0
  78. package/src/internal/core/text.ts +7 -0
  79. package/src/internal/core/types.ts +556 -0
  80. package/src/internal/feature/auth.ts +201 -0
  81. package/src/internal/feature/interceptors.ts +36 -0
  82. package/src/internal/feature/logger.ts +222 -0
  83. package/src/internal/feature/retry.ts +165 -0
  84. package/src/internal/feature/rules.ts +54 -0
  85. package/src/internal/feature/timeout.ts +146 -0
  86. package/src/internal/feature/validate.ts +74 -0
  87. package/src/internal/page/page.ts +402 -0
  88. package/src/internal/transport/fetch.ts +82 -0
  89. package/src/resources/analytics.ts +363 -0
  90. package/src/resources/brands.ts +285 -0
  91. package/src/resources/competitors.ts +212 -0
  92. package/src/resources/identity.ts +48 -0
  93. package/src/resources/models.ts +32 -0
  94. package/src/resources/opportunities.ts +79 -0
  95. package/src/resources/organizations.ts +181 -0
  96. package/src/resources/prompts.ts +292 -0
  97. package/src/resources/reports.ts +211 -0
  98. package/src/resources/runs.ts +123 -0
  99. package/src/resources/shared/auth.ts +13 -0
  100. package/src/resources/shared/request-options.ts +26 -0
  101. package/src/resources/shared/resource.ts +23 -0
  102. package/src/resources/shared/rule.ts +25 -0
  103. package/src/resources/snapshots.ts +54 -0
  104. package/src/resources/tags.ts +46 -0
  105. package/src/resources/tools.ts +68 -0
  106. package/src/types/analytics.ts +497 -0
  107. package/src/types/brands.ts +228 -0
  108. package/src/types/competitors.ts +196 -0
  109. package/src/types/identity.ts +55 -0
  110. package/src/types/index.ts +163 -0
  111. package/src/types/models.ts +40 -0
  112. package/src/types/opportunities.ts +84 -0
  113. package/src/types/organizations.ts +157 -0
  114. package/src/types/prompts.ts +231 -0
  115. package/src/types/reports.ts +266 -0
  116. package/src/types/runs.ts +131 -0
  117. package/src/types/shared/brand-id-path.ts +2 -0
  118. package/src/types/shared/conflict-error.ts +7 -0
  119. package/src/types/shared/error.ts +29 -0
  120. package/src/types/shared/limit.ts +8 -0
  121. package/src/types/shared/mention-entry.ts +21 -0
  122. package/src/types/shared/model-filter.ts +15 -0
  123. package/src/types/shared/not-found-error.ts +4 -0
  124. package/src/types/shared/page.ts +7 -0
  125. package/src/types/shared/pagination.ts +7 -0
  126. package/src/types/shared/validation-error.ts +4 -0
  127. package/src/types/snapshots.ts +88 -0
  128. package/src/types/tags.ts +47 -0
  129. package/src/types/tools.ts +78 -0
@@ -0,0 +1,127 @@
1
+ import { toAPIError, undeclaredBody, unreadableBody } from './errors';
2
+ import type { BuildURL } from './url';
3
+ import { essenceOf, NO_BODY, readText, selectCodec } from '../../codec/registry';
4
+ import { baseURLOf } from '../../core/config';
5
+ import { classifyError, ElmoError, isStructured, unansweredError } from '../../core/errors';
6
+ import { metadataValue } from '../../core/metadata';
7
+ import type {
8
+ Binding,
9
+ CallableDescriptor,
10
+ Codec,
11
+ Credential,
12
+ PreparedRequest,
13
+ ResolvedOptions,
14
+ Result,
15
+ } from '../../core/types';
16
+
17
+ const DEFAULT_ACCEPT = 'application/json';
18
+
19
+ function placeInHeader(credential: Credential, request: PreparedRequest<string>): void {
20
+ request.meta[credential.name.toLowerCase()] = credential.value;
21
+ }
22
+
23
+ type Placer = (credential: Credential, request: PreparedRequest<string>) => void;
24
+
25
+ const PLACERS: Partial<Readonly<Record<Credential['in'], Placer>>> = { header: placeInHeader };
26
+
27
+ function declaredReply(value: unknown, raw: Response, request: PreparedRequest<string>): unknown {
28
+ if (request.callable.returns !== 'json' || isStructured(value)) return value;
29
+ throw undeclaredBody(raw, value);
30
+ }
31
+
32
+ function parserFor(contentType: string | null): 'blob' | 'json' | 'text' {
33
+ if (!contentType) return 'text';
34
+ const essence = essenceOf(contentType);
35
+ if (essence === 'application/json' || essence.endsWith('+json')) return 'json';
36
+ if (essence.startsWith('text/')) return 'text';
37
+ return 'blob';
38
+ }
39
+
40
+ function asksForJSON(request: PreparedRequest<string>): boolean {
41
+ const accept = metadataValue(request.meta['accept']);
42
+ return !!accept && accept.split(',').every((one) => parserFor(one) === 'json');
43
+ }
44
+
45
+ function jsonOrText(text: string): unknown {
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch {
49
+ return text;
50
+ }
51
+ }
52
+
53
+ async function readBody(
54
+ raw: Response,
55
+ request: PreparedRequest<string>,
56
+ codecs: ReadonlyArray<Codec> = [],
57
+ ): Promise<unknown> {
58
+ if (raw.body === null || raw.headers.get('content-length') === '0') return NO_BODY;
59
+
60
+ const contentType = raw.headers.get('content-type');
61
+ const codec = selectCodec(codecs, contentType ?? undefined);
62
+ const parser = parserFor(contentType);
63
+ const copy = raw.ok ? undefined : raw.clone();
64
+
65
+ try {
66
+ if (codec?.decode) return await codec.decode(raw);
67
+ if (parser === 'blob') return await raw.blob();
68
+
69
+ const text = await readText(raw, parser === 'json');
70
+ if (text === '') return NO_BODY;
71
+ if (parser === 'json') return JSON.parse(text);
72
+ return asksForJSON(request) ? jsonOrText(text) : text;
73
+ } catch (error) {
74
+ if (error instanceof ElmoError) throw error;
75
+ if (classifyError(error) === 'abort') throw unansweredError(error);
76
+ if (copy) {
77
+ return readText(copy).catch(() => {
78
+ throw unreadableBody(raw, error);
79
+ });
80
+ }
81
+ throw unreadableBody(raw, error);
82
+ }
83
+ }
84
+
85
+ export function createRestBinding(buildURL: BuildURL): Binding<string> {
86
+ return {
87
+ accept: DEFAULT_ACCEPT,
88
+ applyAuth(credential: Credential, request: PreparedRequest<string>): void {
89
+ const place = PLACERS[credential.in];
90
+ if (!place) {
91
+ throw new ElmoError(
92
+ `This client places no credential in the ${credential.in}, so "${request.callable.address}" cannot be authenticated.`,
93
+ );
94
+ }
95
+ place(credential, request);
96
+ },
97
+ name: 'rest',
98
+ readError(result: Result, request: PreparedRequest<string>): unknown {
99
+ const { response } = result;
100
+ if (!response || (response.ok && result.error === undefined)) return;
101
+ return toAPIError(response.status, result.error, response, request.unauthenticated);
102
+ },
103
+ async readResult(
104
+ raw: Response,
105
+ request: PreparedRequest<string>,
106
+ codecs: ReadonlyArray<Codec>,
107
+ ): Promise<Result> {
108
+ if (request.options.unread) return { ok: raw.ok, response: raw, status: raw.status };
109
+
110
+ const value =
111
+ raw.status === 204 || raw.status === 205 ? NO_BODY : await readBody(raw, request, codecs);
112
+
113
+ return raw.ok
114
+ ? { data: declaredReply(value, raw, request), ok: true, response: raw, status: raw.status }
115
+ : { error: value, ok: false, response: raw, status: raw.status };
116
+ },
117
+ resolveAddress(callable: CallableDescriptor, options: ResolvedOptions): string {
118
+ return buildURL({
119
+ address: callable.address,
120
+ baseURL: baseURLOf(options, callable.baseURL),
121
+ path: options.path,
122
+ query: options.query,
123
+ serialization: callable.serialization,
124
+ });
125
+ },
126
+ };
127
+ }
@@ -0,0 +1,107 @@
1
+ import { APIError, DecodeError, safeAddress } from '../../core/errors';
2
+
3
+ export function undeclaredBody(response: Response, value: unknown): DecodeError {
4
+ const where = response.url ? ` for "${safeAddress(response.url)}"` : '';
5
+ const contentType = response.headers.get('content-type');
6
+ const said = contentType ? ` Its content type said "${contentType}".` : '';
7
+ const got =
8
+ value === null || value === undefined
9
+ ? 'no JSON object or list, where the API description declares one.'
10
+ : `a body that is not the JSON object or list the API description declares.${said}`;
11
+ return new DecodeError(`The API answered ${response.status}${where} with ${got}`, {
12
+ response,
13
+ value,
14
+ });
15
+ }
16
+
17
+ export function unreadableBody(response: Response, cause: unknown): DecodeError {
18
+ const where = response.url ? ` for "${safeAddress(response.url)}"` : '';
19
+ const contentType = response.headers.get('content-type');
20
+ const said = contentType ? ` Its content type said "${contentType}".` : '';
21
+ return new DecodeError(
22
+ `The API answered ${response.status}${where} with a body this client could not read.${said}`,
23
+ { cause, response },
24
+ );
25
+ }
26
+
27
+ /** The API could not read the request, so it did not act on it. */
28
+ export class BadRequestError extends APIError {
29
+ override readonly name = 'BadRequestError';
30
+ declare readonly status: 400;
31
+ }
32
+
33
+ /** No usable credential reached the API: it was missing, unreadable, or rejected. */
34
+ export class AuthenticationError extends APIError {
35
+ override readonly name = 'AuthenticationError';
36
+ declare readonly status: 401;
37
+ }
38
+
39
+ /** The plan or the quota on this account does not cover the call. */
40
+ export class PaymentRequiredError extends APIError {
41
+ override readonly name = 'PaymentRequiredError';
42
+ declare readonly status: 402;
43
+ }
44
+
45
+ /** The credential was accepted, but it does not grant this call. */
46
+ export class PermissionDeniedError extends APIError {
47
+ override readonly name = 'PermissionDeniedError';
48
+ declare readonly status: 403;
49
+ }
50
+
51
+ /** Nothing is at this address, or the credential may not see what is. */
52
+ export class NotFoundError extends APIError {
53
+ override readonly name = 'NotFoundError';
54
+ declare readonly status: 404;
55
+ }
56
+
57
+ /** The call collided with the resource's current state: a duplicate, or a concurrent change. */
58
+ export class ConflictError extends APIError {
59
+ override readonly name = 'ConflictError';
60
+ declare readonly status: 409;
61
+ }
62
+
63
+ /** The request was read, and its contents were rejected. */
64
+ export class UnprocessableEntityError extends APIError {
65
+ override readonly name = 'UnprocessableEntityError';
66
+ declare readonly status: 422;
67
+ }
68
+
69
+ /** Too many calls. {@link APIError.retryAfterMs} carries how long the API asked to wait. */
70
+ export class RateLimitError extends APIError {
71
+ override readonly name = 'RateLimitError';
72
+ declare readonly status: 429;
73
+ }
74
+
75
+ export type APIErrorClass = new (
76
+ status: number,
77
+ body: unknown,
78
+ response: Response,
79
+ note?: string,
80
+ ) => APIError;
81
+
82
+ const BY_STATUS: Readonly<Record<number, APIErrorClass>> = {
83
+ 400: BadRequestError,
84
+ 401: AuthenticationError,
85
+ 402: PaymentRequiredError,
86
+ 403: PermissionDeniedError,
87
+ 404: NotFoundError,
88
+ 409: ConflictError,
89
+ 422: UnprocessableEntityError,
90
+ 429: RateLimitError,
91
+ };
92
+
93
+ /** The API failed after accepting the call. Every status from 500 up arrives as this. */
94
+ export class InternalServerError extends APIError {
95
+ override readonly name = 'InternalServerError';
96
+ }
97
+
98
+ export function toAPIError(
99
+ status: number,
100
+ error: unknown,
101
+ response: Response,
102
+ unauthenticated?: () => string,
103
+ ): APIError {
104
+ const Failure = BY_STATUS[status] ?? (status >= 500 ? InternalServerError : APIError);
105
+ const note = status === 401 || status === 403 ? unauthenticated?.() : undefined;
106
+ return new Failure(status, error, response, note);
107
+ }
@@ -0,0 +1,9 @@
1
+ import { toText } from '../../core/text';
2
+
3
+ export function encodeValue(value: unknown): string {
4
+ return encodeURIComponent(toText(value));
5
+ }
6
+
7
+ export function encodeName(name: string): string {
8
+ return encodeURIComponent(name).replace(/%5B/g, '[').replace(/%5D/g, ']');
9
+ }
@@ -0,0 +1,97 @@
1
+ import { encodeName, encodeValue } from './path';
2
+ import { emptyPathParameter } from '../../core/errors';
3
+ import { scalarText } from '../../core/text';
4
+ import type { SerializationDescriptor } from '../../core/types';
5
+
6
+ export type BuildURL = (args: {
7
+ address: string;
8
+ baseURL?: string | undefined;
9
+ path?: Record<string, unknown> | undefined;
10
+ query?: Record<string, unknown> | undefined;
11
+ serialization?: SerializationDescriptor | undefined;
12
+ }) => string;
13
+
14
+ const NAMED_PATH_PARAM_RE = /\{([^{}]+)\}/g;
15
+
16
+ const ABSOLUTE_URL_RE = /^[a-z][a-z\d+.-]*:\/\//i;
17
+
18
+ function joinPath(base: string, path: string): string {
19
+ return base.endsWith('/') && path.startsWith('/') ? base + path.slice(1) : base + path;
20
+ }
21
+
22
+ export function joinURL(baseURL: string | undefined, path: string): string {
23
+ const base = baseURL ?? '';
24
+ const hash = base.indexOf('#');
25
+ const kept = hash === -1 ? base : base.slice(0, hash);
26
+ const mark = kept.indexOf('?');
27
+ if (mark === -1) return joinPath(kept, path);
28
+
29
+ const joined = joinPath(kept.slice(0, mark), path);
30
+ const query = kept.slice(mark + 1);
31
+ if (!query) return joined;
32
+ return `${joined}${joined.includes('?') ? '&' : '?'}${query}`;
33
+ }
34
+
35
+ function addressURL(address: string, baseURL: string | undefined): string {
36
+ const absolute = ABSOLUTE_URL_RE.test(address);
37
+ const pathURL = absolute || address.startsWith('/') ? address : `/${address}`;
38
+ return absolute ? pathURL : joinURL(baseURL, pathURL);
39
+ }
40
+
41
+ function pathValue(value: unknown): string {
42
+ if (Array.isArray(value)) return value.map(encodeValue).join(',');
43
+ if (typeof value === 'object' && value !== null && scalarText(value) === undefined) {
44
+ return Object.entries(value)
45
+ .flatMap(([key, item]) => [encodeURIComponent(key), encodeValue(item)])
46
+ .join(',');
47
+ }
48
+ return encodeValue(value);
49
+ }
50
+
51
+ function filledURL(
52
+ address: string,
53
+ baseURL: string | undefined,
54
+ path: Record<string, unknown> | undefined,
55
+ ): string {
56
+ const url = addressURL(address, baseURL);
57
+ if (!path) return url;
58
+ return url.replace(NAMED_PATH_PARAM_RE, (_match, name: string) => {
59
+ const value = path[name];
60
+ if (value === undefined || value === null || value === '') {
61
+ throw emptyPathParameter(name, address);
62
+ }
63
+ return pathValue(value);
64
+ });
65
+ }
66
+
67
+ function withQuery(url: string, query: Record<string, unknown> | undefined): string {
68
+ if (!query) return url;
69
+
70
+ const search: Array<string> = [];
71
+ for (const [name, value] of Object.entries(query)) {
72
+ if (value === undefined || value === null) continue;
73
+ if (Array.isArray(value)) {
74
+ for (const item of value) {
75
+ if (item !== undefined && item !== null) {
76
+ search.push(`${encodeName(name)}=${encodeValue(item)}`);
77
+ }
78
+ }
79
+ continue;
80
+ }
81
+ if (typeof value === 'object' && scalarText(value) === undefined) {
82
+ for (const [key, item] of Object.entries(value)) {
83
+ if (item !== undefined && item !== null) {
84
+ search.push(`${encodeName(key)}=${encodeValue(item)}`);
85
+ }
86
+ }
87
+ continue;
88
+ }
89
+ search.push(`${encodeName(name)}=${encodeValue(value)}`);
90
+ }
91
+
92
+ if (!search.length) return url;
93
+ return `${url}${url.includes('?') ? '&' : '?'}${search.join('&')}`;
94
+ }
95
+
96
+ export const simpleURL: BuildURL = ({ address, baseURL, path, query }) =>
97
+ withQuery(filledURL(address, baseURL, path), query);
@@ -0,0 +1,66 @@
1
+ import { NO_BODY, readText } from './registry';
2
+ import { ElmoError } from '../core/errors';
3
+ import type { Codec } from '../core/types';
4
+
5
+ function finite(value: number): number {
6
+ if (!Number.isFinite(value)) {
7
+ throw new ElmoError('NaN and Infinity cannot be written as JSON.');
8
+ }
9
+ return value;
10
+ }
11
+
12
+ function refused(value: unknown): ElmoError {
13
+ const name =
14
+ typeof value === 'object' && value !== null
15
+ ? (value.constructor?.name ?? 'Object')
16
+ : typeof value;
17
+ return new ElmoError(`A value of type ${name} cannot be written as JSON.`);
18
+ }
19
+
20
+ function mapKey(key: unknown): string {
21
+ if (typeof key === 'number') return String(finite(key));
22
+ if (typeof key === 'string' || typeof key === 'boolean' || typeof key === 'bigint') {
23
+ return String(key);
24
+ }
25
+ throw refused(key);
26
+ }
27
+
28
+ function jsonValue(_key: string, value: unknown): unknown {
29
+ if (typeof value === 'bigint') return value.toString();
30
+ if (typeof value === 'number') return finite(value);
31
+ if (typeof value !== 'object' || value === null) return value;
32
+ if (value instanceof Set) return [...value];
33
+ if (value instanceof Map) {
34
+ return Object.fromEntries([...value].map(([key, item]) => [mapKey(key), item]));
35
+ }
36
+ if (
37
+ value instanceof ArrayBuffer ||
38
+ ArrayBuffer.isView(value) ||
39
+ (typeof Blob !== 'undefined' && value instanceof Blob)
40
+ ) {
41
+ throw refused(value);
42
+ }
43
+ return value;
44
+ }
45
+
46
+ export function jsonText(value: unknown): string {
47
+ try {
48
+ return JSON.stringify(value, jsonValue);
49
+ } catch (error) {
50
+ if (error instanceof ElmoError) throw error;
51
+ throw new ElmoError(`A value cannot be written as JSON: ${(error as Error).message}`, {
52
+ cause: error,
53
+ });
54
+ }
55
+ }
56
+
57
+ export const jsonCodec: Codec = {
58
+ contentType: 'application/json',
59
+ decode: async (raw) => {
60
+ const text = await readText(raw, true);
61
+ return text === '' ? NO_BODY : JSON.parse(text);
62
+ },
63
+ encode: jsonText,
64
+ mediaTypes: ['application/json'],
65
+ name: 'json',
66
+ };
@@ -0,0 +1,55 @@
1
+ import type { BodyInput, Codec, EncodedBody, ResolvedOptions } from '../core/types';
2
+
3
+ export const NO_BODY = null;
4
+
5
+ function charsetOf(contentType: string | null): string | undefined {
6
+ return contentType?.match(/;\s*charset\s*=\s*"?([^";\s]+)/i)?.[1];
7
+ }
8
+
9
+ function decoderFor(label: string | undefined, fatal: boolean) {
10
+ try {
11
+ return new TextDecoder(label, { fatal });
12
+ } catch {
13
+ return new TextDecoder('utf-8', { fatal });
14
+ }
15
+ }
16
+
17
+ export async function readText(raw: Response, json = false): Promise<string> {
18
+ const label = json ? undefined : charsetOf(raw.headers.get('content-type'));
19
+ return decoderFor(label, raw.ok).decode(await raw.arrayBuffer());
20
+ }
21
+
22
+ export function essenceOf(mediaType: string): string {
23
+ return mediaType.split(';', 1)[0]!.trim().toLowerCase();
24
+ }
25
+
26
+ export function selectCodec(
27
+ codecs: ReadonlyArray<Codec>,
28
+ mediaType: string | undefined,
29
+ ): Codec | undefined {
30
+ if (!mediaType) return undefined;
31
+ const essence = essenceOf(mediaType);
32
+ const format = essence.replace(/\/.*\+/, '/');
33
+ return codecs.find((codec) =>
34
+ codec.mediaTypes.some((candidate) => [essence, format].includes(essenceOf(candidate))),
35
+ );
36
+ }
37
+
38
+ export function encodeNamedBody(
39
+ codecs: ReadonlyArray<Codec>,
40
+ mediaType: string | undefined,
41
+ options: ResolvedOptions,
42
+ ): EncodedBody | undefined {
43
+ if (options.body === undefined) return undefined;
44
+
45
+ if (options.bodySerializer) {
46
+ return { contentType: mediaType, payload: options.bodySerializer(options.body) };
47
+ }
48
+ if (options.bodySerializer === null) {
49
+ return { contentType: mediaType, payload: options.body as BodyInput };
50
+ }
51
+
52
+ const codec = selectCodec(codecs, mediaType);
53
+ if (!codec) return { contentType: mediaType, payload: options.body as BodyInput };
54
+ return { contentType: codec.contentType, payload: codec.encode(options.body) };
55
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * How much of each wait is random, so callers that failed together do not all
3
+ * come back at once.
4
+ *
5
+ * A number from 0 to 1 is the random share: `0.25` waits between three
6
+ * quarters of the computed wait and all of it. `'full'` is 1, `'equal'` is 0.5
7
+ * and `'none'` is 0. `true` is `'full'` and `false` is `'none'`.
8
+ */
9
+ export type Jitter = boolean | number | 'equal' | 'full' | 'none';
10
+
11
+ /** How long the wait before each retry is. */
12
+ export interface BackoffOptions {
13
+ /**
14
+ * Base delay in milliseconds.
15
+ *
16
+ * @default 500
17
+ */
18
+ delay?: number;
19
+ /**
20
+ * The share of each delay that is random.
21
+ *
22
+ * @default 'full'
23
+ */
24
+ jitter?: Jitter;
25
+ /**
26
+ * Upper bound on any single delay, in milliseconds.
27
+ *
28
+ * @default 30_000
29
+ */
30
+ maxDelay?: number;
31
+ /**
32
+ * How the wait grows. `'exponential'` doubles the base delay each time, up
33
+ * to `maxDelay`. `'constant'` waits the base delay every time.
34
+ *
35
+ * @default 'exponential'
36
+ */
37
+ strategy?: 'constant' | 'exponential';
38
+ }
39
+
40
+ /** A header the API may name its own wait in, and how to read its value. */
41
+ export interface RetryAfterHeader {
42
+ /**
43
+ * Whether a number counts forward from now, or names a moment.
44
+ *
45
+ * @default 'duration'
46
+ */
47
+ readonly kind?: 'duration' | 'moment';
48
+ /** Header name, matched case-insensitively. */
49
+ readonly name: string;
50
+ /**
51
+ * What a number in this header counts in.
52
+ *
53
+ * @default 'second'
54
+ */
55
+ readonly unit?: 'millisecond' | 'second';
56
+ }
57
+
58
+ function jitterShare(jitter: Jitter): number {
59
+ if (typeof jitter === 'number') return Math.min(1, Math.max(0, jitter));
60
+ if (jitter === 'equal') return 0.5;
61
+ return jitter === true || jitter === 'full' ? 1 : 0;
62
+ }
63
+
64
+ export function backoffDelay(retry: number, options: BackoffOptions = {}): number {
65
+ const { delay = 500, jitter = true, maxDelay = 30_000, strategy = 'exponential' } = options;
66
+ const growth = strategy === 'constant' ? delay : delay && delay * 2 ** retry;
67
+ const capped = Math.min(growth, maxDelay);
68
+ const share = jitterShare(jitter);
69
+ return share ? capped * (1 - share) + Math.random() * capped * share : capped;
70
+ }
71
+
72
+ export const MAX_RETRY_AFTER = 60_000;
73
+
74
+ export function exceedsMaxRetryAfter(delay: number | undefined, max?: number): boolean {
75
+ return delay !== undefined && delay > (max ?? MAX_RETRY_AFTER);
76
+ }
77
+
78
+ export const TIMER_LIMIT = 2_147_483_647;
79
+
80
+ export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
81
+ if (signal?.aborted) return Promise.reject(signal.reason);
82
+ if (ms <= 0) return Promise.resolve();
83
+
84
+ return new Promise((resolve, reject) => {
85
+ const timer = setTimeout(
86
+ () => {
87
+ signal?.removeEventListener('abort', onAbort);
88
+ resolve();
89
+ },
90
+ Math.min(ms, TIMER_LIMIT),
91
+ );
92
+
93
+ function onAbort(): void {
94
+ clearTimeout(timer);
95
+ reject(signal!.reason);
96
+ }
97
+
98
+ signal?.addEventListener('abort', onAbort, { once: true });
99
+ });
100
+ }
101
+
102
+ export const RETRY_AFTER_HEADERS: ReadonlyArray<RetryAfterHeader> = [
103
+ { name: 'retry-after-ms', unit: 'millisecond' },
104
+ { name: 'retry-after' },
105
+ { name: 'x-ratelimit-reset-after' },
106
+ { kind: 'moment', name: 'x-ratelimit-reset' },
107
+ { kind: 'moment', name: 'x-rate-limit-reset' },
108
+ ];
109
+
110
+ const ASCTIME = /^[a-z]{3} ([a-z]{3}) +(\d\d?) (\d\d:\d\d:\d\d) (\d{4})$/i;
111
+
112
+ const MOMENT =
113
+ /^(?:(\d{4})-(\d\d)-(\d\d)[t ]|(?:[a-z]+, ?)?(\d\d?)[ -]([a-z]{3})[ -](\d\d|\d{4}) )([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d|60)(?:\.(\d{1,3})\d*)?)? ?(?:z|gmt|utc?|([+-])([01]\d|2[0-3]):?([0-5]\d))$/i;
114
+
115
+ const MONTHS = 'janfebmaraprmayjunjulaugsepoctnovdec';
116
+
117
+ function moment(value: string, now: number): number | undefined {
118
+ const match = MOMENT.exec(value.replace(ASCTIME, '$2 $1 $4 $3 GMT'));
119
+ if (!match) return undefined;
120
+ const year = match[6] ?? match[1]!;
121
+ const day = +(match[4] ?? match[3]!);
122
+ const month = match[2] ? +match[2] - 1 : MONTHS.indexOf(match[5]!.toLowerCase()) / 3;
123
+ let full = +year;
124
+ if (year.length < 3) {
125
+ const present = new Date(now).getUTCFullYear();
126
+ full += present - (present % 100);
127
+ if (full > present + 50) full -= 100;
128
+ }
129
+ const date = new Date(0);
130
+ date.setUTCFullYear(full, month, day);
131
+ if (!full || date.getUTCMonth() !== month || date.getUTCDate() !== day) return undefined;
132
+ const zone = (match[11] === '-' ? -1 : 1) * (+(match[12] ?? 0) * 60 + +(match[13] ?? 0));
133
+ const seconds = (+match[7]! * 60 + +match[8]! - zone) * 60 + +(match[9] ?? 0);
134
+ return date.getTime() + seconds * 1000 + +(match[10] ?? '').padEnd(3, '0');
135
+ }
136
+
137
+ const NUMBER = /^[0-9]+(?:\.[0-9]+)?$/;
138
+
139
+ export function parseRetryAfter(
140
+ value: string | null | undefined,
141
+ header: RetryAfterHeader = { name: 'retry-after' },
142
+ now = Date.now(),
143
+ ): number | undefined {
144
+ if (!value) return undefined;
145
+
146
+ if (NUMBER.test(value)) {
147
+ const number = Number(value);
148
+ const ms = header.unit === 'millisecond' ? number : number * 1000;
149
+ return Math.max(0, header.kind === 'moment' ? ms - now : ms);
150
+ }
151
+
152
+ const date = moment(value, now);
153
+ if (date === undefined) return undefined;
154
+ return Math.max(0, date - now);
155
+ }
156
+
157
+ export function retryAfterDelay(
158
+ response: Response,
159
+ headers: ReadonlyArray<RetryAfterHeader> = RETRY_AFTER_HEADERS,
160
+ now = Date.now(),
161
+ ): number | undefined {
162
+ const clock = moment(response.headers.get('date') ?? '', now) ?? now;
163
+
164
+ for (const header of headers) {
165
+ const delay = parseRetryAfter(response.headers.get(header.name), header, clock);
166
+ if (delay !== undefined) return delay;
167
+ }
168
+
169
+ return undefined;
170
+ }
@@ -0,0 +1,54 @@
1
+ import type { Result } from './types';
2
+
3
+ export function discardBody(result: Result | undefined): void {
4
+ const { response } = result ?? {};
5
+ if (response && !response.bodyUsed) void response.body?.cancel().catch(() => {});
6
+ }
7
+
8
+ export function isStreamed(body: unknown): boolean {
9
+ if (typeof body !== 'object' || body === null) return false;
10
+ return typeof (body as ReadableStream).getReader === 'function' || Symbol.asyncIterator in body;
11
+ }
12
+
13
+ export function replyWith(body: ReadableStream<Uint8Array> | null, response: Response): Response {
14
+ const reply = new Response(body, {
15
+ headers: response.headers,
16
+ status: response.status,
17
+ statusText: response.statusText,
18
+ });
19
+ Object.defineProperty(reply, 'redirected', { value: response.redirected });
20
+ Object.defineProperty(reply, 'url', { value: response.url });
21
+ return reply;
22
+ }
23
+
24
+ export async function bufferReply(response: Response, signal?: AbortSignal): Promise<Response> {
25
+ if (response.body === null) return response;
26
+ const reader = response.body.getReader();
27
+
28
+ function cancel(): void {
29
+ void reader.cancel(signal?.reason).catch(() => {});
30
+ }
31
+
32
+ if (signal?.aborted) cancel();
33
+ signal?.addEventListener('abort', cancel, { once: true });
34
+
35
+ const parts: Array<Uint8Array> = [];
36
+ try {
37
+ for (;;) {
38
+ const step = await reader.read();
39
+ if (step.done) break;
40
+ parts.push(step.value);
41
+ }
42
+ } finally {
43
+ signal?.removeEventListener('abort', cancel);
44
+ }
45
+ if (signal?.aborted) throw signal.reason;
46
+
47
+ const body = new ReadableStream<Uint8Array>({
48
+ start(controller) {
49
+ for (const part of parts) controller.enqueue(part);
50
+ controller.close();
51
+ },
52
+ });
53
+ return replyWith(body, response);
54
+ }