@adobe/spacecat-shared-project-engine-client 1.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.
package/src/index.d.ts ADDED
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ import type { Client } from 'openapi-fetch';
14
+ import type { paths, components } from './generated/types.js';
15
+
16
+ /** Supplies the caller's IMS JWT — forwarded verbatim, never minted or exchanged. */
17
+ export type AuthTokenSource = string | (() => string | Promise<string>);
18
+
19
+ /**
20
+ * A fully-typed Project Engine client over every operation in the generated `paths`.
21
+ * The generated `paths` are already free of the legacy `Auth-Data-Jwt` header (the live API
22
+ * authenticates on `Authorization: Bearer`), so no runtime header narrowing is required here.
23
+ */
24
+ export type SerenityProjectEngineApiClient = Client<paths>;
25
+
26
+ export interface SerenityProjectEngineApiClientOptions {
27
+ /**
28
+ * Base URL of the Project Engine API — the origin of `SEMRUSH_PROJECTS_BASE_URL`, or the
29
+ * Counterfact mock's origin for E2E / local dev. Only `protocol//host` is used; the client
30
+ * appends the fixed `/enterprise/projects/api` prefix itself.
31
+ */
32
+ baseUrl: string;
33
+ /**
34
+ * The caller's IMS JWT, or a (sync/async) getter resolved per request. Sent as the
35
+ * `Authorization: Bearer <token>` header. The client performs NO token exchange or minting —
36
+ * Semrush accepts the IMS bearer token directly, so the caller's token is forwarded as-is.
37
+ */
38
+ authToken: AuthTokenSource;
39
+ /** Retry attempts on 429 / retryable 5xx / network error. Default 2 (3 tries total). */
40
+ maxRetries?: number;
41
+ /** Base backoff in ms; grows exponentially per attempt. Default 200. */
42
+ retryBaseDelayMs?: number;
43
+ /**
44
+ * Best-effort hook invoked before each retry sleep, for logging/metrics. A retry loop is
45
+ * otherwise silent. A throwing or rejecting hook is swallowed and never affects the request.
46
+ * May be async; it is fire-and-forget (never awaited) so it cannot delay a retry.
47
+ */
48
+ onRetry?: (info: {
49
+ attempt: number;
50
+ delayMs: number;
51
+ method: string;
52
+ status?: number;
53
+ error?: Error;
54
+ }) => void | Promise<void>;
55
+ /** Injectable fetch (tests, custom agents). Defaults to the global fetch. */
56
+ fetch?: typeof globalThis.fetch;
57
+ }
58
+
59
+ /**
60
+ * Creates a thin, typed client over the generated Project Engine `paths`. It owns the base
61
+ * URL (origin + `/enterprise/projects/api`), retries, and authenticating each request with the
62
+ * caller's IMS JWT as `Authorization: Bearer` — and nothing else; request/response shapes come
63
+ * straight from the generated types.
64
+ */
65
+ export declare function createSerenityProjectEngineApiClient(
66
+ options: SerenityProjectEngineApiClientOptions,
67
+ ): SerenityProjectEngineApiClient;
68
+
69
+ // Re-export the generated contract types for consumers that want them directly.
70
+ export type { paths, components };
package/src/index.js ADDED
@@ -0,0 +1,13 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ export { createSerenityProjectEngineApiClient } from './client.js';
@@ -0,0 +1,249 @@
1
+ /*
2
+ * Copyright 2026 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /**
14
+ * Framework-agnostic building blocks for the Project Engine client.
15
+ * Deliberately free of `openapi-fetch` and generated-type imports so they can be
16
+ * unit-tested without the generated spec output being present.
17
+ */
18
+
19
+ /**
20
+ * Supplies the caller's IMS JWT — forwarded verbatim, never minted or exchanged.
21
+ * @typedef {string | (() => string | Promise<string>)} AuthTokenSource
22
+ */
23
+
24
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);
25
+
26
+ /**
27
+ * Resolves the HTTP method for a fetch call the same way the platform does.
28
+ * @param {RequestInfo | URL} input
29
+ * @param {RequestInit} [init]
30
+ * @returns {string} the upper-cased method
31
+ */
32
+ export function methodOf(input, init) {
33
+ const method = init?.method ?? (input instanceof Request ? input.method : 'GET');
34
+ return method.toUpperCase();
35
+ }
36
+
37
+ /**
38
+ * @param {string} method
39
+ * @returns {boolean} whether the method is safe to replay
40
+ */
41
+ export function isIdempotent(method) {
42
+ return IDEMPOTENT_METHODS.has(method);
43
+ }
44
+
45
+ /**
46
+ * 429 is retried for ANY method, including non-idempotent ones (POST). The assumption: the
47
+ * Semrush gateway rejects a rate-limited request at the edge, before it reaches the handler, so
48
+ * the create never happened and replaying it cannot duplicate a resource. This holds for the
49
+ * deployed API today, but it IS an upstream contract: if Semrush ever rate-limits *after*
50
+ * partially processing a write, a 429-retried POST (e.g. bulk-create-projects, create-brand-urls,
51
+ * create-prompt) could double-create. There is no idempotency-key header on these endpoints to
52
+ * lean on; revisit this method-agnostic 429 retry if that upstream behaviour changes. A 5xx, by
53
+ * contrast, is retried only for idempotent methods, so a POST that may have already created a
54
+ * resource is never replayed.
55
+ * @param {string} method
56
+ * @param {number} status
57
+ * @returns {boolean}
58
+ */
59
+ export function isRetryableStatus(method, status) {
60
+ if (status === 429) {
61
+ return true;
62
+ }
63
+ if (status >= 500 && status <= 599) {
64
+ return isIdempotent(method);
65
+ }
66
+ return false;
67
+ }
68
+
69
+ /**
70
+ * @param {number} ms
71
+ * @returns {Promise<void>}
72
+ */
73
+ const sleep = (ms) => new Promise((resolve) => {
74
+ setTimeout(resolve, ms);
75
+ });
76
+
77
+ /**
78
+ * Upper bound on a single inter-attempt wait. Caps a hostile or fat-fingered `Retry-After`
79
+ * (and runaway exponential growth) so a retry can never hang a Lambda past a sane ceiling.
80
+ */
81
+ export const MAX_RETRY_DELAY_MS = 20_000;
82
+
83
+ /**
84
+ * Parses a `Retry-After` header into milliseconds. Supports both RFC 9110 forms: delta-seconds
85
+ * (e.g. `"5"`) and an HTTP-date. Returns null when the header is absent or unparseable, so the
86
+ * caller falls back to backoff.
87
+ * @param {Response} response
88
+ * @returns {number | null}
89
+ */
90
+ export function parseRetryAfterMs(response) {
91
+ const raw = response.headers.get('retry-after');
92
+ if (!raw) {
93
+ return null;
94
+ }
95
+ const seconds = Number(raw);
96
+ if (Number.isFinite(seconds)) {
97
+ return Math.max(0, Math.round(seconds * 1000));
98
+ }
99
+ const epochMs = Date.parse(raw);
100
+ if (Number.isNaN(epochMs)) {
101
+ return null;
102
+ }
103
+ return Math.max(0, epochMs - Date.now());
104
+ }
105
+
106
+ /**
107
+ * The wait before the next attempt: the larger of (a) exponential backoff with equal jitter —
108
+ * `baseDelayMs * 2 ** completedAttempt` scaled by a random factor in `[0.5, 1)` to de-correlate
109
+ * concurrent clients and avoid thundering-herd alignment on a shared 429/503 — and (b) the
110
+ * server's `Retry-After`, when present (so we never retry sooner than the server asked).
111
+ * Clamped to {@link MAX_RETRY_DELAY_MS}.
112
+ * @param {number} completedAttempt zero-based index of the attempt that just failed
113
+ * @param {number} baseDelayMs
114
+ * @param {Response | null} response the retryable response, if any (for `Retry-After`)
115
+ * @returns {number}
116
+ */
117
+ export function nextRetryDelayMs(completedAttempt, baseDelayMs, response) {
118
+ const backoff = baseDelayMs * 2 ** completedAttempt;
119
+ const jittered = backoff * (0.5 + Math.random() * 0.5);
120
+ const retryAfter = response ? parseRetryAfterMs(response) : null;
121
+ const delay = retryAfter == null ? jittered : Math.max(jittered, retryAfter);
122
+ return Math.min(delay, MAX_RETRY_DELAY_MS);
123
+ }
124
+
125
+ /**
126
+ * Invokes a best-effort {@link OnRetry} hook, swallowing both synchronous throws and asynchronous
127
+ * rejections so a broken observability callback can never break the retry loop or the request.
128
+ * The hook fires fire-and-forget (never awaited), so it cannot delay a retry. Its own failures are
129
+ * deliberately silent (no signal is emitted) — surfacing them would itself need an observability
130
+ * channel; observability must never affect the request outcome.
131
+ * @param {OnRetry} [onRetry]
132
+ * @param {object} info
133
+ */
134
+ function notifyRetry(onRetry, info) {
135
+ if (!onRetry) {
136
+ return;
137
+ }
138
+ try {
139
+ const result = onRetry(info);
140
+ // An async onRetry returns a promise; sink its rejection here so a rejecting hook can't escape
141
+ // as an unhandled promise rejection (which crashes the process in Node 18+). Not awaited.
142
+ if (result && typeof result.catch === 'function') {
143
+ result.catch(() => {});
144
+ }
145
+ } catch {
146
+ // best-effort: a throwing (sync) hook is swallowed, same as a rejecting (async) one above.
147
+ }
148
+ }
149
+
150
+ /**
151
+ * @callback OnRetry
152
+ * @param {object} info
153
+ * @param {number} info.attempt the 1-based number of the retry about to be made
154
+ * @param {number} info.delayMs the wait before this retry
155
+ * @param {string} info.method the HTTP method
156
+ * @param {number} [info.status] the retryable response status that triggered the retry, if any
157
+ * @param {Error} [info.error] the network error that triggered the retry, if any
158
+ * @returns {void | Promise<void>} may be async; the return is not awaited (fire-and-forget)
159
+ */
160
+
161
+ /**
162
+ * Wraps a fetch with bounded exponential-backoff retries. Retryable statuses follow
163
+ * {@link isRetryableStatus}; thrown network errors are retried only for idempotent methods.
164
+ * The wait between attempts is {@link nextRetryDelayMs} — jittered exponential backoff that also
165
+ * honours a `Retry-After` header. After exhausting retries it returns the last retryable response
166
+ * (so the caller still sees e.g. the final 503) or rethrows the last network error.
167
+ *
168
+ * An optional `onRetry` callback is invoked just before each retry sleep, so consumers can log or
169
+ * meter retry behaviour (otherwise a retry loop silently delays a response by up to
170
+ * `maxRetries * MAX_RETRY_DELAY_MS`). It is best-effort and fire-and-forget: a throwing (sync) or
171
+ * rejecting (async) `onRetry` is swallowed so a broken observability hook can never break the
172
+ * request itself.
173
+ * @param {typeof globalThis.fetch} baseFetch
174
+ * @param {number} maxRetries
175
+ * @param {number} baseDelayMs
176
+ * @param {OnRetry} [onRetry] optional best-effort retry-observability hook
177
+ * @returns {typeof globalThis.fetch}
178
+ */
179
+ export function createRetryingFetch(baseFetch, maxRetries, baseDelayMs, onRetry) {
180
+ return async function retryingFetch(input, init) {
181
+ const method = methodOf(input, init);
182
+ // Floor at 0: a negative maxRetries would skip the loop entirely, leaving both lastResponse
183
+ // and lastError undefined and ending in `throw undefined`. Degrade to a single attempt instead.
184
+ const attempts = Math.max(0, maxRetries);
185
+ // openapi-fetch calls us with a Request object; fetch() consumes its body on use, so a
186
+ // bare replay throws "Request ... already used". Clone per attempt and never touch the
187
+ // original, so every retry (incl. a 429 on a bodied POST) sends a fresh, unconsumed body.
188
+ // The clone preserves the request's headers — including the `Authorization` header the auth
189
+ // middleware set once for this logical request — so all attempts share that one token. The
190
+ // token is resolved per request, not per attempt; with the ceiling above the whole loop is
191
+ // bounded well under an IMS token's lifetime, so mid-loop expiry is a non-issue.
192
+ const forAttempt = () => (input instanceof Request ? input.clone() : input);
193
+ let lastResponse;
194
+ let lastError;
195
+ let nextDelayMs = 0;
196
+
197
+ for (let attempt = 0; attempt <= attempts; attempt += 1) {
198
+ if (attempt > 0) {
199
+ notifyRetry(onRetry, {
200
+ attempt, delayMs: nextDelayMs, method, status: lastResponse?.status, error: lastError,
201
+ });
202
+ // eslint-disable-next-line no-await-in-loop
203
+ await sleep(nextDelayMs);
204
+ }
205
+ try {
206
+ // eslint-disable-next-line no-await-in-loop
207
+ const response = await baseFetch(forAttempt(), init);
208
+ if (!isRetryableStatus(method, response.status)) {
209
+ return response;
210
+ }
211
+ lastResponse = response;
212
+ lastError = undefined;
213
+ nextDelayMs = nextRetryDelayMs(attempt, baseDelayMs, response);
214
+ } catch (error) {
215
+ if (!isIdempotent(method)) {
216
+ throw error;
217
+ }
218
+ lastError = error;
219
+ lastResponse = undefined;
220
+ nextDelayMs = nextRetryDelayMs(attempt, baseDelayMs, null);
221
+ }
222
+ }
223
+
224
+ if (lastResponse) {
225
+ return lastResponse;
226
+ }
227
+ throw lastError;
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Normalises an {@link AuthTokenSource} into a getter, so callers can pass either a static
233
+ * token or a (sync/async) function resolved per request. Rejects any other type at construction
234
+ * time — without this guard a stray `null`, number, or object would flow into the
235
+ * `Authorization` header (`Bearer [object Object]`) and surface only as an opaque upstream 401.
236
+ * @param {AuthTokenSource} source
237
+ * @returns {() => string | Promise<string>}
238
+ */
239
+ export function toTokenGetter(source) {
240
+ if (typeof source === 'function') {
241
+ return source;
242
+ }
243
+ if (typeof source === 'string') {
244
+ return () => source;
245
+ }
246
+ throw new Error(
247
+ `Project Engine client: authToken must be a string or a function, got ${typeof source}`,
248
+ );
249
+ }