@anchor-secrets/sdk 0.0.0-stage → 0.5.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/dist/index.js ADDED
@@ -0,0 +1,564 @@
1
+ // src/cache.ts
2
+ import { createHash } from "node:crypto";
3
+
4
+ // src/errors.ts
5
+ var AnchorError = class extends Error {
6
+ status;
7
+ code;
8
+ /** Whether repeating the request may succeed (network, timeout, 429, 502, 503, 504). */
9
+ retryable;
10
+ /** The server's `Retry-After`, in milliseconds, when it sent one. */
11
+ retryAfterMs;
12
+ constructor(status, code, message, options = {}) {
13
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
14
+ this.name = "AnchorError";
15
+ this.status = status;
16
+ this.code = code;
17
+ this.retryable = options.retryable ?? false;
18
+ if (options.retryAfterMs !== void 0) this.retryAfterMs = options.retryAfterMs;
19
+ }
20
+ };
21
+ var abortedError = () => new AnchorError(0, "aborted", "The request to Anchor was cancelled");
22
+
23
+ // src/cache.ts
24
+ function memoryStore() {
25
+ const entries = /* @__PURE__ */ new Map();
26
+ return {
27
+ get: (key) => entries.get(key),
28
+ set: (key, entry) => {
29
+ entries.set(key, entry);
30
+ }
31
+ };
32
+ }
33
+ function cacheKey(origin, token, q) {
34
+ const fingerprint = createHash("sha256").update(token).digest("hex").slice(0, 16);
35
+ return JSON.stringify([
36
+ origin,
37
+ fingerprint,
38
+ q.project,
39
+ q.environment,
40
+ q.path,
41
+ q.expand,
42
+ q.includeImports
43
+ ]);
44
+ }
45
+ var newer = (a, b) => a && b ? b.fetchedAt > a.fetchedAt ? b : a : a ?? b;
46
+ function untilAborted(promise, signal) {
47
+ if (!signal) return promise;
48
+ if (signal.aborted) return Promise.reject(abortedError());
49
+ return new Promise((resolve, reject) => {
50
+ const onAbort = () => reject(abortedError());
51
+ signal.addEventListener("abort", onAbort, { once: true });
52
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
53
+ });
54
+ }
55
+ var SecretCache = class {
56
+ constructor(policy, emit, now = () => Date.now()) {
57
+ this.policy = policy;
58
+ this.emit = emit;
59
+ this.now = now;
60
+ }
61
+ policy;
62
+ emit;
63
+ now;
64
+ inflight = /* @__PURE__ */ new Map();
65
+ /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
66
+ failedAt = /* @__PURE__ */ new Map();
67
+ /** The newest entry this instance fetched per key, in case the store returns an older one. */
68
+ latest = /* @__PURE__ */ new Map();
69
+ /**
70
+ * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
71
+ * caller that asked for new values learns it did not get them.
72
+ */
73
+ async load(key, fetch, opts = {}) {
74
+ if (opts.signal?.aborted) throw abortedError();
75
+ let flight = opts.fresh ? void 0 : this.inflight.get(key);
76
+ if (!flight) {
77
+ const entry = newer(await this.read(key), this.latest.get(key));
78
+ if (entry && !opts.fresh && this.servable(key, entry)) return entry;
79
+ flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
80
+ }
81
+ flight.waiters++;
82
+ let outcome;
83
+ try {
84
+ outcome = await untilAborted(flight.promise, opts.signal);
85
+ } catch (error) {
86
+ if (opts.signal?.aborted && --flight.waiters === 0) {
87
+ flight.controller.abort();
88
+ if (this.inflight.get(key) === flight) this.inflight.delete(key);
89
+ }
90
+ throw error;
91
+ }
92
+ if (opts.strict && outcome.error) throw outcome.error;
93
+ return outcome.entry;
94
+ }
95
+ start(key, fetch, stale) {
96
+ const controller = new AbortController();
97
+ const promise = this.fetch(
98
+ key,
99
+ () => fetch(controller.signal, stale?.etag),
100
+ stale,
101
+ controller.signal
102
+ ).finally(() => {
103
+ if (this.inflight.get(key)?.promise === promise) this.inflight.delete(key);
104
+ });
105
+ const flight = { promise, controller, waiters: 0 };
106
+ this.inflight.set(key, flight);
107
+ return flight;
108
+ }
109
+ /** A store that fails is a miss: Anchor stays reachable when Redis (or a file) is not. */
110
+ async read(key) {
111
+ try {
112
+ return await this.policy.store.get(key);
113
+ } catch (error) {
114
+ this.emit({ type: "store_failed", operation: "get", error });
115
+ return void 0;
116
+ }
117
+ }
118
+ async write(key, entry) {
119
+ try {
120
+ await this.policy.store.set(key, entry);
121
+ } catch (error) {
122
+ this.emit({ type: "store_failed", operation: "set", error });
123
+ }
124
+ }
125
+ servable(key, entry) {
126
+ const now = this.now();
127
+ if (now - entry.fetchedAt < this.policy.ttlMs) return true;
128
+ const failed = this.failedAt.get(key);
129
+ return failed !== void 0 && now - failed < this.policy.ttlMs && this.usable(entry, now);
130
+ }
131
+ usable(entry, now) {
132
+ return now - entry.fetchedAt <= this.policy.ttlMs + this.policy.maxStaleMs;
133
+ }
134
+ async fetch(key, fetch, stale, signal) {
135
+ try {
136
+ const fetched = await fetch();
137
+ if (fetched === "unchanged" && !stale) {
138
+ throw new AnchorError(
139
+ 304,
140
+ "bad_response",
141
+ "Anchor answered 304 to a request without a tag"
142
+ );
143
+ }
144
+ const entry = fetched === "unchanged" ? { secrets: stale.secrets, fetchedAt: this.now(), etag: stale?.etag } : { secrets: fetched.secrets, fetchedAt: this.now(), etag: fetched.etag };
145
+ this.latest.set(key, entry);
146
+ await this.write(key, entry);
147
+ this.failedAt.delete(key);
148
+ return { entry };
149
+ } catch (error) {
150
+ if (signal.aborted) throw error;
151
+ const now = this.now();
152
+ if (!(error instanceof AnchorError) || !stale || !this.usable(stale, now)) throw error;
153
+ this.failedAt.set(key, now);
154
+ const staleForMs = Math.max(0, now - stale.fetchedAt - this.policy.ttlMs);
155
+ this.emit({ type: "refresh_failed", error, staleForMs });
156
+ return { entry: stale, error };
157
+ }
158
+ }
159
+ };
160
+
161
+ // src/retry.ts
162
+ var DEFAULT_RETRY = { attempts: 3, baseDelayMs: 250, maxDelayMs: 5e3 };
163
+ function delayFor(error, attempt, policy) {
164
+ if (error.retryAfterMs !== void 0) return Math.min(error.retryAfterMs, policy.maxDelayMs);
165
+ const cap = Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** (attempt - 1));
166
+ return Math.floor(Math.random() * cap);
167
+ }
168
+ function wait(ms, signal) {
169
+ return new Promise((resolve, reject) => {
170
+ if (signal?.aborted) return reject(abortedError());
171
+ const onAbort = () => {
172
+ clearTimeout(timer);
173
+ reject(abortedError());
174
+ };
175
+ const timer = setTimeout(() => {
176
+ signal?.removeEventListener("abort", onAbort);
177
+ resolve();
178
+ }, ms);
179
+ signal?.addEventListener("abort", onAbort, { once: true });
180
+ });
181
+ }
182
+ async function withRetry(run, policy, opts) {
183
+ const attempts = policy ? Math.max(1, policy.attempts) : 1;
184
+ for (let attempt = 1; ; attempt++) {
185
+ if (opts.signal?.aborted) throw abortedError();
186
+ try {
187
+ return await run();
188
+ } catch (error) {
189
+ if (!policy || !(error instanceof AnchorError) || !error.retryable || attempt >= attempts) {
190
+ throw error;
191
+ }
192
+ const delayMs = delayFor(error, attempt, policy);
193
+ opts.emit({ type: "retry", attempt, delayMs, error });
194
+ await wait(delayMs, opts.signal);
195
+ }
196
+ }
197
+ }
198
+
199
+ // src/config.ts
200
+ var DEFAULT_TIMEOUT_MS = 1e4;
201
+ function fromEnv(name) {
202
+ const value = typeof process === "undefined" ? void 0 : process.env[name];
203
+ return value === "" ? void 0 : value;
204
+ }
205
+ function resolveConfig(opts) {
206
+ const apiUrl = opts.apiUrl || fromEnv("ANCHOR_API_URL");
207
+ if (!apiUrl) {
208
+ throw new AnchorError(
209
+ 0,
210
+ "config",
211
+ "Anchor API URL is not set: pass apiUrl or set ANCHOR_API_URL"
212
+ );
213
+ }
214
+ let origin;
215
+ try {
216
+ origin = new URL(apiUrl).origin;
217
+ } catch {
218
+ throw new AnchorError(
219
+ 0,
220
+ "config",
221
+ "Anchor API URL is not a valid URL (apiUrl or ANCHOR_API_URL)"
222
+ );
223
+ }
224
+ const token = opts.token || fromEnv("ANCHOR_TOKEN");
225
+ if (!token) {
226
+ throw new AnchorError(0, "config", "Anchor token is not set: pass token or set ANCHOR_TOKEN");
227
+ }
228
+ const f = opts.fetch ?? globalThis.fetch;
229
+ if (typeof f !== "function") {
230
+ throw new AnchorError(
231
+ 0,
232
+ "config",
233
+ "No fetch implementation: use Node 20 or later, or pass fetch"
234
+ );
235
+ }
236
+ return {
237
+ base: apiUrl.replace(/\/+$/, ""),
238
+ origin,
239
+ token,
240
+ fetch: f,
241
+ timeoutMs: timeoutOption(opts.timeoutMs),
242
+ retry: retryPolicy(opts.retry),
243
+ onEvent: opts.onEvent
244
+ };
245
+ }
246
+ function numberOption(name, value, fallback, rule) {
247
+ if (value === void 0) return fallback;
248
+ const valid = typeof value === "number" && !Number.isNaN(value) && (rule.infinite || Number.isFinite(value)) && value >= rule.min && (!rule.integer || Number.isInteger(value));
249
+ if (!valid) {
250
+ const kind = rule.integer ? "an integer" : "a number";
251
+ throw new AnchorError(
252
+ 0,
253
+ "config",
254
+ `Anchor option ${name} must be ${kind} of at least ${rule.min}`
255
+ );
256
+ }
257
+ return value;
258
+ }
259
+ var timeoutOption = (value) => numberOption("timeoutMs", value, DEFAULT_TIMEOUT_MS, { min: 1 });
260
+ function retryPolicy(retry) {
261
+ if (retry === false) return false;
262
+ return {
263
+ attempts: numberOption("retry.attempts", retry?.attempts, DEFAULT_RETRY.attempts, {
264
+ min: 1,
265
+ integer: true
266
+ }),
267
+ baseDelayMs: numberOption("retry.baseDelayMs", retry?.baseDelayMs, DEFAULT_RETRY.baseDelayMs, {
268
+ min: 0
269
+ }),
270
+ maxDelayMs: numberOption("retry.maxDelayMs", retry?.maxDelayMs, DEFAULT_RETRY.maxDelayMs, {
271
+ min: 0
272
+ })
273
+ };
274
+ }
275
+ function validateOptions(opts) {
276
+ timeoutOption(opts.timeoutMs);
277
+ retryPolicy(opts.retry);
278
+ }
279
+ function emitSafely(onEvent, event) {
280
+ try {
281
+ onEvent?.(event);
282
+ } catch {
283
+ }
284
+ }
285
+ var DEFAULT_TTL_MS = 6e4;
286
+ function cachePolicy(cache) {
287
+ if (cache === false) return false;
288
+ return {
289
+ ttlMs: numberOption("cache.ttlMs", cache?.ttlMs, DEFAULT_TTL_MS, { min: 0 }),
290
+ maxStaleMs: numberOption("cache.maxStaleMs", cache?.maxStaleMs, Number.POSITIVE_INFINITY, {
291
+ min: 0,
292
+ infinite: true
293
+ }),
294
+ store: cache?.store ?? memoryStore()
295
+ };
296
+ }
297
+
298
+ // ../shared/src/value-decode.ts
299
+ function decodeTypedValue(type, value) {
300
+ switch (type) {
301
+ case "json":
302
+ case "list":
303
+ return JSON.parse(value);
304
+ case "number":
305
+ return Number(value);
306
+ case "boolean":
307
+ return value === "true";
308
+ default:
309
+ return value;
310
+ }
311
+ }
312
+
313
+ // src/decode.ts
314
+ function toSecret(s) {
315
+ if (typeof s.value !== "string") {
316
+ throw new AnchorError(200, "bad_response", `Anchor returned ${s.key} without a value`);
317
+ }
318
+ const out = {
319
+ key: s.key,
320
+ path: s.path,
321
+ type: s.type,
322
+ sensitive: s.sensitive,
323
+ version: s.version,
324
+ value: s.value
325
+ };
326
+ if (s.importedFrom) out.importedFrom = { ...s.importedFrom };
327
+ return out;
328
+ }
329
+ function decode(s) {
330
+ try {
331
+ return decodeTypedValue(s.type, s.value);
332
+ } catch {
333
+ throw new AnchorError(200, "bad_response", `Secret ${s.key} is not valid ${s.type}`);
334
+ }
335
+ }
336
+ function decodeAll(secrets) {
337
+ const entries = /* @__PURE__ */ new Map();
338
+ for (const s of secrets) {
339
+ if (!entries.has(s.key)) entries.set(s.key, decode(s));
340
+ }
341
+ return Object.fromEntries(entries);
342
+ }
343
+
344
+ // src/folder.ts
345
+ function deepFreeze(value) {
346
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
347
+ for (const child of Object.values(value)) deepFreeze(child);
348
+ Object.freeze(value);
349
+ }
350
+ return value;
351
+ }
352
+ var snapshotOf = (entry) => ({
353
+ entry,
354
+ values: deepFreeze(decodeAll(entry.secrets))
355
+ });
356
+ async function openFolder(core, query, options = {}) {
357
+ let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
358
+ let checkedAt = snapshot.entry.fetchedAt;
359
+ let failed = false;
360
+ let pending;
361
+ const accept = (entry) => {
362
+ failed = false;
363
+ if (entry.fetchedAt < snapshot.entry.fetchedAt) return;
364
+ if (entry.secrets === snapshot.entry.secrets) snapshot = { entry, values: snapshot.values };
365
+ else snapshot = snapshotOf(entry);
366
+ };
367
+ const refreshInBackground = () => {
368
+ pending = core.loadEntry(query).then(accept, (error) => {
369
+ failed = true;
370
+ if (error instanceof AnchorError) {
371
+ const staleForMs = Math.max(0, Date.now() - snapshot.entry.fetchedAt - core.ttlMs);
372
+ core.emit({ type: "refresh_failed", error, staleForMs });
373
+ }
374
+ }).finally(() => {
375
+ checkedAt = Date.now();
376
+ pending = void 0;
377
+ });
378
+ };
379
+ const values = () => {
380
+ if (!pending && Date.now() - checkedAt >= core.ttlMs) refreshInBackground();
381
+ if (failed && Date.now() - snapshot.entry.fetchedAt > core.ttlMs + core.maxStaleMs) {
382
+ throw new AnchorError(
383
+ 0,
384
+ "stale",
385
+ "Anchor values are older than ttlMs + maxStaleMs and refreshing them failed"
386
+ );
387
+ }
388
+ return snapshot.values;
389
+ };
390
+ const has = (key) => Object.hasOwn(values(), key);
391
+ return {
392
+ get: (key) => has(key) ? snapshot.values[key] : void 0,
393
+ getOrThrow(key) {
394
+ if (!has(key)) throw new Error(`Anchor secret ${key} is not set`);
395
+ return snapshot.values[key];
396
+ },
397
+ has,
398
+ keys: () => Object.keys(values()),
399
+ toObject: values,
400
+ async refresh() {
401
+ try {
402
+ accept(await core.loadEntry(query, { fresh: true, strict: true }));
403
+ } finally {
404
+ checkedAt = Date.now();
405
+ }
406
+ }
407
+ };
408
+ }
409
+
410
+ // src/transport.ts
411
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 502, 503, 504]);
412
+ function retryAfterMs(res) {
413
+ if (res.status !== 429 && res.status !== 503) return void 0;
414
+ const header = res.headers.get("retry-after")?.trim();
415
+ if (!header || !/^\d+$/.test(header)) return void 0;
416
+ return Number(header) * 1e3;
417
+ }
418
+ async function errorFromResponse(res) {
419
+ let body;
420
+ try {
421
+ body = await res.json();
422
+ } catch {
423
+ body = void 0;
424
+ }
425
+ const options = {
426
+ retryable: RETRYABLE_STATUSES.has(res.status),
427
+ retryAfterMs: retryAfterMs(res)
428
+ };
429
+ const error = body?.error;
430
+ if (typeof error?.code === "string" && typeof error.message === "string") {
431
+ return new AnchorError(res.status, error.code, error.message, options);
432
+ }
433
+ return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
434
+ }
435
+ async function fetchJson(cfg, url, signal, ifNoneMatch) {
436
+ if (signal?.aborted) throw abortedError();
437
+ const controller = new AbortController();
438
+ let timedOut = false;
439
+ const timer = setTimeout(() => {
440
+ timedOut = true;
441
+ controller.abort();
442
+ }, cfg.timeoutMs);
443
+ const onAbort = () => controller.abort();
444
+ signal?.addEventListener("abort", onAbort, { once: true });
445
+ try {
446
+ const headers = {
447
+ authorization: `Bearer ${cfg.token}`,
448
+ accept: "application/json"
449
+ };
450
+ if (ifNoneMatch) headers["if-none-match"] = ifNoneMatch;
451
+ const res = await cfg.fetch(url, { method: "GET", headers, signal: controller.signal });
452
+ const etag = res.headers.get("etag") ?? void 0;
453
+ if (res.status === 304) return { status: 304, etag, body: void 0 };
454
+ if (!res.ok) throw await errorFromResponse(res);
455
+ try {
456
+ return { status: res.status, etag, body: await res.json() };
457
+ } catch (cause) {
458
+ if (controller.signal.aborted) throw cause;
459
+ throw new AnchorError(
460
+ res.status,
461
+ "bad_response",
462
+ "Anchor returned a response that is not JSON"
463
+ );
464
+ }
465
+ } catch (cause) {
466
+ if (cause instanceof AnchorError) throw cause;
467
+ if (timedOut) {
468
+ throw new AnchorError(
469
+ 0,
470
+ "timeout",
471
+ `Anchor at ${cfg.origin} did not answer within ${cfg.timeoutMs} ms`,
472
+ { retryable: true }
473
+ );
474
+ }
475
+ if (signal?.aborted) throw abortedError();
476
+ const reason = cause instanceof Error ? cause.message : "request failed";
477
+ throw new AnchorError(0, "network", `Could not reach Anchor at ${cfg.origin}: ${reason}`, {
478
+ cause,
479
+ retryable: true
480
+ });
481
+ } finally {
482
+ clearTimeout(timer);
483
+ signal?.removeEventListener("abort", onAbort);
484
+ }
485
+ }
486
+
487
+ // src/client.ts
488
+ function normalize(q) {
489
+ return {
490
+ project: q.project,
491
+ environment: q.environment,
492
+ path: q.path ?? "/",
493
+ expand: q.expand ?? true,
494
+ includeImports: q.includeImports ?? true
495
+ };
496
+ }
497
+ async function fetchFolder(cfg, q, emit, signal, etag) {
498
+ const params = new URLSearchParams({
499
+ path: q.path,
500
+ reveal: "true",
501
+ expand: String(q.expand),
502
+ includeImports: String(q.includeImports)
503
+ });
504
+ const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
505
+ const res = await withRetry(() => fetchJson(cfg, url, signal, etag), cfg.retry, {
506
+ signal,
507
+ emit
508
+ });
509
+ if (res.status === 304) return "unchanged";
510
+ const body = res.body;
511
+ if (!body || !Array.isArray(body.secrets)) {
512
+ throw new AnchorError(res.status, "bad_response", "Anchor returned an unexpected response");
513
+ }
514
+ return { secrets: body.secrets.map(toSecret), etag: res.etag };
515
+ }
516
+ var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
517
+ function createClientCore(options) {
518
+ validateOptions(options);
519
+ const emit = (event) => emitSafely(options.onEvent, event);
520
+ const policy = cachePolicy(options.cache);
521
+ const cache = policy ? new SecretCache(policy, emit) : void 0;
522
+ return {
523
+ ttlMs: policy ? policy.ttlMs : DEFAULT_TTL_MS,
524
+ maxStaleMs: policy ? policy.maxStaleMs : Number.POSITIVE_INFINITY,
525
+ emit,
526
+ async loadEntry(q, o = {}) {
527
+ const cfg = resolveConfig(options);
528
+ const query = normalize(q);
529
+ if (!cache) {
530
+ const fetched = await fetchFolder(cfg, query, emit, o.signal);
531
+ if (fetched === "unchanged") {
532
+ throw new AnchorError(
533
+ 304,
534
+ "bad_response",
535
+ "Anchor answered 304 to a request without a tag"
536
+ );
537
+ }
538
+ return { secrets: fetched.secrets, fetchedAt: Date.now() };
539
+ }
540
+ return cache.load(
541
+ cacheKey(cfg.origin, cfg.token, query),
542
+ (signal, etag) => fetchFolder(cfg, query, emit, signal, etag),
543
+ o
544
+ );
545
+ }
546
+ };
547
+ }
548
+ function createAnchorClient(options = {}) {
549
+ const core = createClientCore(options);
550
+ return {
551
+ async listSecrets(q, o) {
552
+ return (await core.loadEntry(q, o)).secrets.map(copy);
553
+ },
554
+ async loadSecrets(q, o) {
555
+ return decodeAll((await core.loadEntry(q, o)).secrets);
556
+ },
557
+ folder: (q, o) => openFolder(core, q, o)
558
+ };
559
+ }
560
+ export {
561
+ AnchorError,
562
+ createAnchorClient,
563
+ memoryStore
564
+ };
@@ -0,0 +1,8 @@
1
+ import type { AnchorEvent, RetryOptions } from "./types.cjs";
2
+ export type RetryPolicy = Required<RetryOptions>;
3
+ export declare const DEFAULT_RETRY: RetryPolicy;
4
+ /** Runs `run` until it succeeds, fails with a non-retryable error, or runs out of attempts. */
5
+ export declare function withRetry<T>(run: () => Promise<T>, policy: RetryPolicy | false, opts: {
6
+ signal?: AbortSignal;
7
+ emit: (event: AnchorEvent) => void;
8
+ }): Promise<T>;
@@ -0,0 +1,8 @@
1
+ import type { AnchorEvent, RetryOptions } from "./types.js";
2
+ export type RetryPolicy = Required<RetryOptions>;
3
+ export declare const DEFAULT_RETRY: RetryPolicy;
4
+ /** Runs `run` until it succeeds, fails with a non-retryable error, or runs out of attempts. */
5
+ export declare function withRetry<T>(run: () => Promise<T>, policy: RetryPolicy | false, opts: {
6
+ signal?: AbortSignal;
7
+ emit: (event: AnchorEvent) => void;
8
+ }): Promise<T>;
@@ -0,0 +1,12 @@
1
+ import type { Config } from "./config.cjs";
2
+ export type JsonResponse = {
3
+ status: number;
4
+ etag: string | undefined;
5
+ body: unknown;
6
+ };
7
+ /**
8
+ * One GET: the parsed body of a 2xx (none for a 304 answering `ifNoneMatch`), or an
9
+ * `AnchorError` whose `retryable` says whether another attempt may succeed. Aborts after
10
+ * `cfg.timeoutMs`, or as soon as `signal` aborts.
11
+ */
12
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;
@@ -0,0 +1,12 @@
1
+ import type { Config } from "./config.js";
2
+ export type JsonResponse = {
3
+ status: number;
4
+ etag: string | undefined;
5
+ body: unknown;
6
+ };
7
+ /**
8
+ * One GET: the parsed body of a 2xx (none for a 304 answering `ifNoneMatch`), or an
9
+ * `AnchorError` whose `retryable` says whether another attempt may succeed. Aborts after
10
+ * `cfg.timeoutMs`, or as soon as `signal` aborts.
11
+ */
12
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;