@elmohq/sdk 0.0.0-stage → 0.1.1

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 +326 -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 +1031 -0
  9. package/dist/index.mjs.map +1 -0
  10. package/dist/request-options-B9S9d24M.d.mts +1549 -0
  11. package/dist/request-options-B9S9d24M.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-B1lVtYAr.mjs +2459 -0
  15. package/dist/tools-B1lVtYAr.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 +68 -4
  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 +649 -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 +225 -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/page.ts +402 -0
  76. package/src/internal/core/params.ts +98 -0
  77. package/src/internal/core/protocol.ts +17 -0
  78. package/src/internal/core/read-reply.ts +7 -0
  79. package/src/internal/core/text.ts +7 -0
  80. package/src/internal/core/types.ts +554 -0
  81. package/src/internal/feature/auth.ts +201 -0
  82. package/src/internal/feature/interceptors.ts +36 -0
  83. package/src/internal/feature/logger.ts +222 -0
  84. package/src/internal/feature/retry.ts +168 -0
  85. package/src/internal/feature/rules.ts +54 -0
  86. package/src/internal/feature/timeout.ts +146 -0
  87. package/src/internal/feature/validate.ts +82 -0
  88. package/src/internal/transport/fetch.ts +82 -0
  89. package/src/resources/analytics.ts +364 -0
  90. package/src/resources/brands.ts +288 -0
  91. package/src/resources/competitors.ts +213 -0
  92. package/src/resources/identity.ts +49 -0
  93. package/src/resources/models.ts +32 -0
  94. package/src/resources/opportunities.ts +81 -0
  95. package/src/resources/organizations.ts +182 -0
  96. package/src/resources/prompts.ts +293 -0
  97. package/src/resources/reports.ts +212 -0
  98. package/src/resources/runs.ts +124 -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 +69 -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,201 @@
1
+ import { MissingCredentialError, unsendable } from '../core/errors';
2
+ import { isFieldValue } from '../core/metadata';
3
+ import type {
4
+ AuthRequirement,
5
+ AuthScheme,
6
+ AuthToken,
7
+ Credential,
8
+ CredentialSpec,
9
+ CredentialValue,
10
+ Feature,
11
+ PlacedCredential,
12
+ PreparedRequest,
13
+ ResolvedOptions,
14
+ } from '../core/types';
15
+
16
+ export interface AuthOptions {
17
+ readonly schemes?: Readonly<Record<string, CredentialSpec>>;
18
+ }
19
+
20
+ export function schemesOf(requirement: AuthRequirement): ReadonlyArray<AuthScheme> {
21
+ return Array.isArray(requirement) ? requirement : [requirement as AuthScheme];
22
+ }
23
+
24
+ export function candidates(
25
+ requirements: ReadonlyArray<AuthRequirement>,
26
+ ..._environment: ReadonlyArray<unknown>
27
+ ): ReadonlyArray<ReadonlyArray<AuthScheme>> {
28
+ return requirements.map(schemesOf);
29
+ }
30
+
31
+ type Ask = (scheme: AuthScheme) => Promise<AuthToken>;
32
+
33
+ function formatBearer(token: string): string {
34
+ return `Bearer ${token}`;
35
+ }
36
+
37
+ const SCHEME_FORMATS: Partial<
38
+ Readonly<Record<NonNullable<AuthScheme['scheme']>, (token: string) => string>>
39
+ > = { bearer: formatBearer };
40
+
41
+ export function formatToken(scheme: AuthScheme, token: AuthToken, prefix?: string): AuthToken {
42
+ if (!token) return undefined;
43
+ if (prefix !== undefined) return `${prefix}${token}`;
44
+ const format = scheme.scheme ? SCHEME_FORMATS[scheme.scheme] : undefined;
45
+ return format ? format(token) : token;
46
+ }
47
+
48
+ function labeled(key: string, options: AuthOptions | undefined): string {
49
+ const variable = options?.schemes?.[key]?.variable;
50
+ return variable ? `\`${key}\` (\`${variable}\`)` : `\`${key}\``;
51
+ }
52
+
53
+ export function credentialFor(
54
+ scheme: AuthScheme,
55
+ token: AuthToken,
56
+ options: AuthOptions | undefined,
57
+ ): Credential | undefined {
58
+ const prefix = scheme.key ? options?.schemes?.[scheme.key]?.prefix : undefined;
59
+ const value = formatToken(scheme, token?.trim(), prefix);
60
+ if (!value) return undefined;
61
+ const credential: Credential = {
62
+ in: scheme.in ?? 'header',
63
+ name: scheme.name ?? 'Authorization',
64
+ value,
65
+ };
66
+ if (credential.in !== 'query' && !isFieldValue(value)) {
67
+ const held = scheme.key
68
+ ? labeled(scheme.key, options)
69
+ : `The credential \`auth\` returned for \`${credential.name}\``;
70
+ throw new MissingCredentialError(unsendable(held), scheme.key ? [scheme.key] : []);
71
+ }
72
+ return credential;
73
+ }
74
+
75
+ async function credentialsFor(
76
+ schemes: ReadonlyArray<AuthScheme>,
77
+ ask: Ask,
78
+ options: AuthOptions | undefined,
79
+ ): Promise<ReadonlyArray<PlacedCredential> | undefined> {
80
+ const placed: Array<PlacedCredential> = [];
81
+ for (const scheme of schemes) {
82
+ const credential = credentialFor(scheme, await ask(scheme), options);
83
+ if (!credential) return undefined;
84
+ placed.push({ credential, scheme });
85
+ }
86
+ return placed;
87
+ }
88
+
89
+ function keysOf(open: ReadonlyArray<ReadonlyArray<string>>): ReadonlyArray<string> {
90
+ return [...new Set(open.flat())];
91
+ }
92
+
93
+ function conjoined(keys: ReadonlyArray<string>, options: AuthOptions | undefined): string {
94
+ return keys.map((key) => labeled(key, options)).join(' and ');
95
+ }
96
+
97
+ function either(alternatives: ReadonlyArray<string>): string {
98
+ if (alternatives.length <= 2) return alternatives.join(' or ');
99
+ return `${alternatives.slice(0, -1).join(', ')}, or ${alternatives.at(-1)}`;
100
+ }
101
+
102
+ function advice(
103
+ open: ReadonlyArray<ReadonlyArray<string>>,
104
+ options: AuthOptions | undefined,
105
+ ): string {
106
+ if (!open.length) return '';
107
+ const alternatives = open.map((keys) => conjoined(keys, options));
108
+ if (alternatives.length > 1 && open.some((keys) => keys.length > 1)) {
109
+ return ` Either ${alternatives.map((one) => `set ${one}`).join(', or ')}.`;
110
+ }
111
+ return ` Set ${either(alternatives)}.`;
112
+ }
113
+
114
+ function missing(
115
+ open: ReadonlyArray<ReadonlyArray<string>>,
116
+ options: AuthOptions | undefined,
117
+ ): string {
118
+ return `This call needs a credential that was not set.${advice(open, options)}`;
119
+ }
120
+
121
+ function offered(
122
+ alternatives: ReadonlyArray<ReadonlyArray<AuthScheme>>,
123
+ ): ReadonlyArray<ReadonlyArray<string>> {
124
+ return alternatives
125
+ .map((schemes) =>
126
+ schemes.map((scheme) => scheme.key).filter((key): key is string => key !== undefined),
127
+ )
128
+ .filter((keys) => keys.length > 0);
129
+ }
130
+
131
+ export function refuseRuledOut(..._ruling: ReadonlyArray<unknown>): void {}
132
+
133
+ export async function tokenFor(
134
+ scheme: AuthScheme,
135
+ held: ResolvedOptions,
136
+ options: AuthOptions | undefined,
137
+ ): Promise<AuthToken> {
138
+ const own = scheme.key !== undefined && options?.schemes?.[scheme.key]?.option !== false;
139
+ const stated = own ? (held[scheme.key!] as CredentialValue) : undefined;
140
+ const value = typeof stated === 'function' ? await stated() : stated;
141
+ return typeof held.auth === 'function' ? held.auth(scheme, value) : value;
142
+ }
143
+
144
+ function unauthenticated(
145
+ open: ReadonlyArray<ReadonlyArray<string>>,
146
+ options: AuthOptions | undefined,
147
+ ): string {
148
+ return `This call was sent with no credential.${advice(open, options)}`;
149
+ }
150
+
151
+ function writtenByHand(scheme: AuthScheme, request: PreparedRequest): boolean {
152
+ return (
153
+ (scheme.in ?? 'header') === 'header' &&
154
+ request.meta[(scheme.name ?? 'Authorization').toLowerCase()] !== undefined
155
+ );
156
+ }
157
+
158
+ export function authFeature(options?: AuthOptions): Feature {
159
+ return {
160
+ name: 'auth',
161
+ async onPrepare(request, ctx) {
162
+ const declared = request.callable.auth;
163
+ if (!declared?.length) return;
164
+
165
+ const { auth, environment } = request.options;
166
+ const alternatives = candidates(declared, environment, options).map((schemes) =>
167
+ schemes.filter((scheme) => !writtenByHand(scheme, request)),
168
+ );
169
+ const asked = new Map<unknown, Promise<AuthToken>>();
170
+
171
+ function ask(scheme: AuthScheme): Promise<AuthToken> {
172
+ const key = scheme.key ?? scheme;
173
+ let token = asked.get(key);
174
+ if (!token) {
175
+ token = tokenFor(scheme, request.options, options);
176
+ asked.set(key, token);
177
+ }
178
+ return token;
179
+ }
180
+
181
+ if (auth !== null) {
182
+ for (const schemes of alternatives) {
183
+ const placed = await credentialsFor(schemes, ask, options);
184
+ if (!placed) continue;
185
+ for (const { credential } of placed) ctx.binding.applyAuth(credential, request);
186
+ request.placed = placed;
187
+ return;
188
+ }
189
+ await refuseRuledOut(declared, alternatives, ask, environment, options);
190
+ }
191
+
192
+ const all = offered(declared.map(schemesOf));
193
+ if (auth === null || request.callable.authOptional) {
194
+ request.unauthenticated = () => unauthenticated(all, options);
195
+ return;
196
+ }
197
+
198
+ throw new MissingCredentialError(missing(all, options), keysOf(all));
199
+ },
200
+ };
201
+ }
@@ -0,0 +1,36 @@
1
+ import type { Feature, PreparedRequest, Result } from '../core/types';
2
+
3
+ /** Hooks of your own, run in order at three points in every call. */
4
+ export interface Interceptors {
5
+ /** Runs when a call fails. Return the error to raise, which may be another. */
6
+ error?: ReadonlyArray<(error: unknown, request: PreparedRequest) => unknown>;
7
+ /** Runs on the built request, last before it is sent. */
8
+ request?: ReadonlyArray<(request: PreparedRequest) => Promise<void> | void>;
9
+ /** Runs on the reply. Return the result to read, which may be another. */
10
+ response?: ReadonlyArray<(result: Result, request: PreparedRequest) => Promise<Result> | Result>;
11
+ }
12
+
13
+ function interceptorsOf(request: PreparedRequest): Interceptors | undefined {
14
+ return request.options['interceptors'] as Interceptors | undefined;
15
+ }
16
+
17
+ export function interceptorsFeature(): Feature {
18
+ return {
19
+ name: 'interceptors',
20
+ async onError(error, request) {
21
+ let current = error;
22
+ for (const fn of interceptorsOf(request)?.error ?? []) current = await fn(current, request);
23
+ return current;
24
+ },
25
+ async onRequest(request) {
26
+ for (const fn of interceptorsOf(request)?.request ?? []) await fn(request);
27
+ },
28
+ async onResult(result, request) {
29
+ let current = result;
30
+ for (const fn of interceptorsOf(request)?.response ?? []) {
31
+ current = await fn(current, request);
32
+ }
33
+ return current;
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,222 @@
1
+ import { safeAddress } from '../core/errors';
2
+ import type {
3
+ AuthScheme,
4
+ CallableDescriptor,
5
+ CallLog,
6
+ Feature,
7
+ PreparedRequest,
8
+ Result,
9
+ Send,
10
+ } from '../core/types';
11
+
12
+ /** One method of a {@link Logger}: a message, then the values logged with it. */
13
+ export type LogFn = (message: string, ...rest: ReadonlyArray<unknown>) => void;
14
+
15
+ /** Where log lines go, such as `console`. */
16
+ export interface Logger {
17
+ debug: LogFn;
18
+ error: LogFn;
19
+ info: LogFn;
20
+ warn: LogFn;
21
+ }
22
+
23
+ /**
24
+ * How much a client logs. `'error'` writes failed calls. `'warn'` adds a retry
25
+ * or a redirect. `'info'` adds a line per attempt, with its status and time.
26
+ * `'debug'` adds headers, with credentials hidden.
27
+ */
28
+ export type LogLevel = 'debug' | 'error' | 'info' | 'off' | 'warn';
29
+
30
+ const LEVELS: Readonly<Record<LogLevel, number>> = {
31
+ debug: 40,
32
+ error: 10,
33
+ info: 30,
34
+ off: 0,
35
+ warn: 20,
36
+ };
37
+
38
+ export interface LoggerOptions {
39
+ level?: LogLevel;
40
+ logger?: Logger;
41
+ }
42
+
43
+ function describeCall(request: PreparedRequest): string {
44
+ const method = request.callable.method?.toUpperCase();
45
+ const address = typeof request.address === 'string' ? safeAddress(request.address) : '';
46
+ return method ? `${method} ${address}` : address;
47
+ }
48
+
49
+ const ADDRESS_RE = /\b[a-z][a-z\d+.-]*:\/\/[^\s"'<>]+/gi;
50
+
51
+ export function failureText(error: unknown): string {
52
+ const messages: Array<string> = [];
53
+ const seen = new Set<unknown>();
54
+ let current = error;
55
+ while (current !== undefined && current !== null && !seen.has(current)) {
56
+ seen.add(current);
57
+ const failed = current instanceof Error;
58
+ messages.push(failed ? (current as Error).message : String(current));
59
+ current = failed ? (current as Error).cause : undefined;
60
+ }
61
+ return messages.join(' Caused by: ').replace(ADDRESS_RE, safeAddress);
62
+ }
63
+
64
+ function declaredHeaders(callable: CallableDescriptor): Set<string> {
65
+ const names = new Set<string>();
66
+ for (const requirement of callable.auth ?? []) {
67
+ for (const scheme of Array.isArray(requirement) ? requirement : [requirement as AuthScheme]) {
68
+ if (scheme.in === 'header' && scheme.name) names.add(scheme.name.toLowerCase());
69
+ }
70
+ }
71
+ return names;
72
+ }
73
+
74
+ const SECRET_HEADERS: ReadonlyArray<string> = [
75
+ 'authorization',
76
+ 'cookie',
77
+ 'proxy-authorization',
78
+ 'set-cookie',
79
+ ];
80
+
81
+ function isSecret(name: string, declared: ReadonlySet<string>): boolean {
82
+ const lower = name.toLowerCase();
83
+ return (
84
+ SECRET_HEADERS.includes(lower) ||
85
+ declared.has(lower) ||
86
+ lower.endsWith('-key') ||
87
+ lower.endsWith('-token') ||
88
+ lower.endsWith('-secret')
89
+ );
90
+ }
91
+
92
+ export function safeHeaders(
93
+ meta: Readonly<Record<string, unknown>>,
94
+ callable: CallableDescriptor,
95
+ ): Record<string, unknown> {
96
+ const declared = declaredHeaders(callable);
97
+ const safe: Record<string, unknown> = {};
98
+ for (const [name, value] of Object.entries(meta)) {
99
+ safe[name] = isSecret(name, declared) ? '***' : value;
100
+ }
101
+ return safe;
102
+ }
103
+
104
+ function callLog(id: string, request: PreparedRequest, at: number, sink: Logger): CallLog {
105
+ const label = describeCall(request);
106
+ let started = Date.now();
107
+ function line(text: string): string {
108
+ return `[${id}] ${label} ${text}`;
109
+ }
110
+
111
+ return {
112
+ ended(error) {
113
+ const ms = Date.now() - started;
114
+ if (error !== undefined) {
115
+ if (at >= LEVELS.error) {
116
+ sink.error(line(`stream failed after ${ms} ms: ${failureText(error)}`));
117
+ }
118
+ } else if (at >= LEVELS.info) {
119
+ sink.info(line(`stream ended after ${ms} ms`));
120
+ }
121
+ },
122
+ failed(error) {
123
+ if (at >= LEVELS.error) sink.error(line(`failed: ${failureText(error)}`));
124
+ },
125
+ replied(response) {
126
+ const ms = Date.now() - started;
127
+ if (response?.redirected && at >= LEVELS.warn) {
128
+ sink.warn(line(`was redirected to ${safeAddress(response.url)}`));
129
+ }
130
+ if (at >= LEVELS.info) sink.info(line(`-> ${response?.status ?? '?'} in ${ms} ms`));
131
+ if (at >= LEVELS.debug) {
132
+ sink.debug(`[${id}] received`, {
133
+ headers: safeHeaders(Object.fromEntries(response?.headers ?? []), request.callable),
134
+ ms,
135
+ status: response?.status,
136
+ });
137
+ }
138
+ },
139
+ retrying(wait, retry, retries, after) {
140
+ if (at < LEVELS.warn) return;
141
+ const again = `retrying in ${Math.round(wait)} ms (${retry} of ${retries})`;
142
+ if (after?.status !== undefined) {
143
+ sink.warn(line(`-> ${after.status}, ${again}`));
144
+ } else if (after?.error !== undefined) {
145
+ sink.warn(line(`failed: ${failureText(after.error)}, ${again}`));
146
+ } else {
147
+ sink.warn(line(again));
148
+ }
149
+ },
150
+ sending() {
151
+ started = Date.now();
152
+ if (at >= LEVELS.info) sink.info(`[${id}] ${label}`);
153
+ if (at >= LEVELS.debug)
154
+ sink.debug(`[${id}] sending`, { headers: safeHeaders(request.meta, request.callable) });
155
+ },
156
+ };
157
+ }
158
+
159
+ export function readLevel(named: string): LogLevel | undefined {
160
+ const spelled = named.toLowerCase();
161
+ if (spelled === 'warning') return 'warn';
162
+ return Object.prototype.hasOwnProperty.call(LEVELS, spelled) ? (spelled as LogLevel) : undefined;
163
+ }
164
+
165
+ function resolveLogging(
166
+ base: LoggerOptions,
167
+ request: PreparedRequest,
168
+ unknown: Set<string>,
169
+ ): { at: number; sink: Logger } | undefined {
170
+ const named = String(request.options['logLevel'] || base.level || 'off');
171
+ const level = readLevel(named);
172
+ const sink =
173
+ (request.options['logger'] as Logger | undefined) ??
174
+ base.logger ??
175
+ (globalThis as { console?: Logger }).console;
176
+ if (level === undefined && sink && !unknown.has(named)) {
177
+ unknown.add(named);
178
+ sink.warn(
179
+ `Unknown log level "${named}": nothing is logged. Use "debug", "info", "warn", "error" or "off".`,
180
+ );
181
+ }
182
+ if (level === undefined || level === 'off' || !sink) return undefined;
183
+ return { at: LEVELS[level], sink };
184
+ }
185
+
186
+ async function written(request: PreparedRequest, next: Send): Promise<Result> {
187
+ const { log } = request;
188
+ if (!log) return next(request);
189
+
190
+ const result = await next(request);
191
+ log.replied(result.response);
192
+ return result;
193
+ }
194
+
195
+ export function loggerFeature(options: LoggerOptions = {}): Feature {
196
+ let counter = 0;
197
+ const unknown = new Set<string>();
198
+ const warned = new Set<CallableDescriptor>();
199
+
200
+ return {
201
+ name: 'logger',
202
+ async onError(error, request) {
203
+ request.log?.failed(error);
204
+ return error;
205
+ },
206
+ onOpen: written,
207
+ onPrepare(request) {
208
+ const on = resolveLogging(options, request, unknown);
209
+ if (!on) return;
210
+ const id = `req_${++counter}`;
211
+ request.log = callLog(id, request, on.at, on.sink);
212
+ const { callable } = request;
213
+ if (callable.deprecated && on.at >= LEVELS.warn && !warned.has(callable)) {
214
+ warned.add(callable);
215
+ const method = callable.method?.toUpperCase();
216
+ const operation = method ? `${method} ${callable.address}` : callable.address;
217
+ on.sink.warn(`[${id}] ${operation} is deprecated`);
218
+ }
219
+ },
220
+ onSend: written,
221
+ };
222
+ }
@@ -0,0 +1,168 @@
1
+ import { backoffDelay, exceedsMaxRetryAfter, retryAfterDelay, sleep } from '../core/backoff';
2
+ import { discardBody, isStreamed } from '../core/body';
3
+ import { calledOffWith, classifyError, ElmoError, failureOf, TransportError } from '../core/errors';
4
+ import type { Feature, PreparedRequest, Result, RetryRules, Send } from '../core/types';
5
+
6
+ /** Which failed calls are sent again, how many times, and how long each waits first. */
7
+ export interface RetryOptions extends RetryRules {
8
+ /**
9
+ * How long to wait before the next attempt, in milliseconds, in place of the
10
+ * API's retry headers and the backoff. `undefined` keeps the usual wait.
11
+ * After a failure that got no reply, `result` holds only the `error`.
12
+ * {@link maxRetryAfter} does not limit what this returns.
13
+ */
14
+ retryDelay?(result: Result, request: PreparedRequest, attempt: number): number | undefined;
15
+ /**
16
+ * Whether to retry, in place of `statuses`, `x-should-retry` and
17
+ * `retryOnTimeout`. After a reply, the body is unread: read it from
18
+ * `response.clone()`. After a failure that got no reply, `result` holds only
19
+ * the `error`. A call the API may have acted on is never asked about: a
20
+ * method outside `methods` is sent again only after 408, 425 or 429, after
21
+ * a failure that answers `x-should-retry: true`, or when the request never
22
+ * reached the API.
23
+ */
24
+ retryOn?(result: Result, request: PreparedRequest): boolean;
25
+ }
26
+
27
+ export const DEFAULT_MAX_RETRIES = 2;
28
+
29
+ function askedFor(config: RetryOptions, result: Result): number | undefined {
30
+ const headers = config.retryAfter;
31
+ if (headers === false || !result.response) return undefined;
32
+ return retryAfterDelay(result.response, headers === true ? undefined : headers);
33
+ }
34
+
35
+ const IDEMPOTENT_METHODS: ReadonlyArray<string> = [
36
+ 'DELETE',
37
+ 'GET',
38
+ 'HEAD',
39
+ 'OPTIONS',
40
+ 'PUT',
41
+ 'TRACE',
42
+ ];
43
+
44
+ function isRepeatable(config: RetryOptions, request: PreparedRequest): boolean {
45
+ const method = request.callable.method?.toUpperCase();
46
+ if (!method) return false;
47
+ if ((config.methods ?? IDEMPOTENT_METHODS).includes(method)) return true;
48
+ const { idempotency } = request.callable;
49
+ return idempotency !== undefined && request.meta[idempotency] !== undefined;
50
+ }
51
+
52
+ function retriesFor(
53
+ base: RetryOptions,
54
+ override: RetryOptions | true | undefined,
55
+ request: PreparedRequest,
56
+ ): number {
57
+ if (override && override !== true && override.maxRetries !== undefined) {
58
+ return Math.max(0, override.maxRetries);
59
+ }
60
+ const retries = request.options['maxRetries'];
61
+ if (typeof retries === 'number' && Number.isFinite(retries)) return Math.max(0, retries);
62
+ return base.maxRetries ?? DEFAULT_MAX_RETRIES;
63
+ }
64
+
65
+ function resolveRetry(base: RetryOptions, request: PreparedRequest): RetryOptions | undefined {
66
+ const override = request.options['retry'] as RetryOptions | boolean | undefined;
67
+ if (override === false) return undefined;
68
+
69
+ const declared = request.callable.retry;
70
+ if (declared === false && override === undefined) return undefined;
71
+
72
+ const stated = declared ? { ...base, ...declared } : base;
73
+ const config = override && override !== true ? { ...stated, ...override } : stated;
74
+ const maxRetries = retriesFor(stated, override, request);
75
+ return maxRetries < 1 ? undefined : { ...config, maxRetries };
76
+ }
77
+
78
+ const REFUSED_STATUSES: ReadonlyArray<number> = [408, 425, 429];
79
+
80
+ const RETRY_HEADER = 'x-should-retry';
81
+
82
+ const PERMANENT_STATUSES: ReadonlyArray<number> = [501, 505, 506, 508, 510, 511];
83
+
84
+ function worthAnotherAttempt(status: number): boolean {
85
+ if (status >= 500) return !PERMANENT_STATUSES.includes(status);
86
+ return REFUSED_STATUSES.includes(status);
87
+ }
88
+
89
+ function shouldRetry(
90
+ config: RetryOptions,
91
+ result: Result,
92
+ request: PreparedRequest,
93
+ repeatable: boolean,
94
+ ): boolean {
95
+ const { response } = result;
96
+ if (!response) return false;
97
+ const asked = response.headers.get(RETRY_HEADER);
98
+ const invited = asked === 'true' && response.status >= 400;
99
+ if (!repeatable && !invited && !REFUSED_STATUSES.includes(response.status)) return false;
100
+ if (config.retryOn) return config.retryOn(result, request);
101
+ if (asked === 'false') return false;
102
+ if (invited) return true;
103
+ const { statuses } = config;
104
+ return statuses ? statuses.includes(response.status) : worthAnotherAttempt(response.status);
105
+ }
106
+
107
+ function shouldRetryError(
108
+ config: RetryOptions,
109
+ error: unknown,
110
+ request: PreparedRequest,
111
+ repeatable: boolean,
112
+ ): boolean {
113
+ if (error instanceof ElmoError && !(error instanceof TransportError)) return false;
114
+ const kind = classifyError(error);
115
+ if (kind === 'abort') return false;
116
+ if (failureOf(error).reachedServer !== 'no' && !repeatable) return false;
117
+ if (config.retryOn) return config.retryOn({ error }, request);
118
+ return kind !== 'timeout' || config.retryOnTimeout === true;
119
+ }
120
+
121
+ export function retryFeature(options: RetryOptions = {}): Feature {
122
+ async function repeated(request: PreparedRequest, next: Send): Promise<Result> {
123
+ const config = isStreamed(request.body) ? undefined : resolveRetry(options, request);
124
+ if (!config) return next(request);
125
+
126
+ const attempts = (config.maxRetries ?? DEFAULT_MAX_RETRIES) + 1;
127
+ const repeatable = isRepeatable(config, request);
128
+ const started = Date.now();
129
+
130
+ for (let attempt = 0; ; attempt++) {
131
+ const last = attempt >= attempts - 1;
132
+ let result: Result | undefined;
133
+ let threw = false;
134
+ let wait: number | undefined;
135
+
136
+ if (attempt) request.attempt = attempt;
137
+ try {
138
+ result = await next(request);
139
+ if (last || !shouldRetry(config, result, request, repeatable)) return result;
140
+
141
+ wait = config.retryDelay?.(result, request, attempt);
142
+ if (wait === undefined) {
143
+ wait = askedFor(config, result);
144
+ if (exceedsMaxRetryAfter(wait, config.maxRetryAfter)) return result;
145
+ }
146
+ } catch (error) {
147
+ if (last || !shouldRetryError(config, error, request, repeatable)) throw error;
148
+ threw = true;
149
+ result = { error };
150
+ wait = config.retryDelay?.(result, request, attempt);
151
+ }
152
+
153
+ wait ??= backoffDelay(attempt, config);
154
+ if (config.budget !== undefined && Date.now() - started + wait >= config.budget) {
155
+ if (threw) throw result.error;
156
+ return result;
157
+ }
158
+
159
+ discardBody(result);
160
+ request.log?.retrying(wait, attempt + 1, attempts - 1, result);
161
+ await sleep(wait, request.signal).catch((reason: unknown) => {
162
+ throw calledOffWith(reason);
163
+ });
164
+ }
165
+ }
166
+
167
+ return { name: 'retry', onOpen: repeated, onSend: repeated };
168
+ }
@@ -0,0 +1,54 @@
1
+ import { refusedInput } from '../core/errors';
2
+ import type { Feature, InputRule, ResolvedOptions } from '../core/types';
3
+
4
+ const COUNTED = { characters: 'character', entries: 'entry', items: 'item' } as const;
5
+
6
+ function brokenBound(rule: InputRule, size: bigint | number): [string, number] | undefined {
7
+ if (rule.min !== undefined && size < rule.min) return ['at least', rule.min];
8
+ if (rule.max !== undefined && size > rule.max) return ['at most', rule.max];
9
+ if (rule.above !== undefined && size <= rule.above) return ['more than', rule.above];
10
+ if (rule.below !== undefined && size >= rule.below) return ['less than', rule.below];
11
+ return undefined;
12
+ }
13
+
14
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
15
+ if (typeof value !== 'object' || value === null) return false;
16
+ const prototype = Object.getPrototypeOf(value) as unknown;
17
+ return prototype === Object.prototype || prototype === null;
18
+ }
19
+
20
+ function inputOf(options: ResolvedOptions, name: string | undefined): unknown {
21
+ if (name === undefined) return options.body;
22
+ return isPlainObject(options.body) ? options.body[name] : undefined;
23
+ }
24
+
25
+ function sizeOf(value: unknown, of: InputRule['of']): bigint | number | undefined {
26
+ if (of === undefined) {
27
+ return typeof value === 'number' || typeof value === 'bigint' ? value : undefined;
28
+ }
29
+ if (of === 'characters') return typeof value === 'string' ? Array.from(value).length : undefined;
30
+ if (of === 'items') return Array.isArray(value) ? value.length : undefined;
31
+ if (!isPlainObject(value)) return undefined;
32
+ return Object.values(value).filter((entry) => entry !== undefined).length;
33
+ }
34
+
35
+ export function rulesFeature(): Feature {
36
+ return {
37
+ name: 'rules',
38
+ onOptions(options, callable) {
39
+ for (const rule of callable.rules ?? []) {
40
+ const size = sizeOf(inputOf(options, rule.name), rule.of);
41
+ if (size === undefined) continue;
42
+ const broken = brokenBound(rule, size);
43
+ if (!broken) continue;
44
+ const [bound, limit] = broken;
45
+ const held = rule.name === undefined ? 'The request body' : `\`${rule.name}\``;
46
+ const stated =
47
+ rule.of === undefined
48
+ ? `is ${bound} ${limit}`
49
+ : `takes ${bound} ${limit} ${limit === 1 ? COUNTED[rule.of] : rule.of}`;
50
+ throw refusedInput(held, stated, size);
51
+ }
52
+ },
53
+ };
54
+ }