@anchor-secrets/sdk 0.4.0 → 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/README.md CHANGED
@@ -35,8 +35,11 @@ folder.get<string>("DATABASE_URL"); // synchronous, never waits for the network
35
35
  ```
36
36
 
37
37
  - `client.folder(query)` loads once and returns an `AnchorFolder`: `get`, `getOrThrow`, `has`,
38
- `keys`, `toObject` (frozen) and `refresh()`. A read of values older than the cache ttl returns
39
- them at once and starts one background refresh.
38
+ `keys`, `toObject` (frozen all the way down) and `refresh()`. A read of values older than the
39
+ cache ttl returns them at once and starts one background refresh; there is no timer.
40
+ - A refresh sends the folder's last `ETag` as `If-None-Match`. When nothing changed, Anchor
41
+ answers `304` with no body and the cached values are kept. `cache: false` sends no
42
+ `If-None-Match`.
40
43
  - `loadSecrets(query)` returns `{ KEY: value }` with typed values decoded: `number` and `boolean`
41
44
  become numbers and booleans, `json` and `list` are parsed, `string` and `pem` stay strings.
42
45
  `listSecrets(query)` returns the secrets with their metadata (`key`, `path`, `type`,
@@ -74,7 +77,8 @@ Every failure is an `AnchorError` with `status` (0 when there was no response),
74
77
  `aborted`, `stale`, or the server's error code such as `unauthenticated`, `forbidden` or `not_found`.
75
78
  Messages and events never contain tokens or values.
76
79
 
77
- Every value the client reads is recorded in the project's audit log under the token's name.
80
+ Every request that returns values is recorded in the project's audit log under the token's
81
+ name. A `304` returns no values and is not recorded.
78
82
 
79
83
  ## License
80
84
 
package/dist/cache.d.cts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { AnchorEvent, AnchorSecret, CacheEntry, CacheOptions, CacheStore, ListQuery } from "./types.cjs";
2
2
  export type { CacheEntry, CacheOptions, CacheStore } from "./types.cjs";
3
3
  export type CachePolicy = Required<CacheOptions>;
4
+ /** What a fetch got from Anchor: new secrets and their tag, or a 304 for the tag it sent. */
5
+ export type Fetched = {
6
+ secrets: AnchorSecret[];
7
+ etag?: string;
8
+ } | "unchanged";
9
+ type FetchFn = (signal: AbortSignal, etag: string | undefined) => Promise<Fetched>;
4
10
  export declare function memoryStore(): CacheStore;
5
11
  /**
6
12
  * The cache key of one folder as one token sees it. The token is reduced to a SHA-256
@@ -19,12 +25,14 @@ export declare class SecretCache {
19
25
  private readonly inflight;
20
26
  /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
21
27
  private readonly failedAt;
28
+ /** The newest entry this instance fetched per key, in case the store returns an older one. */
29
+ private readonly latest;
22
30
  constructor(policy: CachePolicy, emit: (event: AnchorEvent) => void, now?: () => number);
23
31
  /**
24
32
  * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
25
33
  * caller that asked for new values learns it did not get them.
26
34
  */
27
- load(key: string, fetch: (signal: AbortSignal) => Promise<AnchorSecret[]>, opts?: {
35
+ load(key: string, fetch: FetchFn, opts?: {
28
36
  fresh?: boolean;
29
37
  signal?: AbortSignal;
30
38
  strict?: boolean;
package/dist/cache.d.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { AnchorEvent, AnchorSecret, CacheEntry, CacheOptions, CacheStore, ListQuery } from "./types.js";
2
2
  export type { CacheEntry, CacheOptions, CacheStore } from "./types.js";
3
3
  export type CachePolicy = Required<CacheOptions>;
4
+ /** What a fetch got from Anchor: new secrets and their tag, or a 304 for the tag it sent. */
5
+ export type Fetched = {
6
+ secrets: AnchorSecret[];
7
+ etag?: string;
8
+ } | "unchanged";
9
+ type FetchFn = (signal: AbortSignal, etag: string | undefined) => Promise<Fetched>;
4
10
  export declare function memoryStore(): CacheStore;
5
11
  /**
6
12
  * The cache key of one folder as one token sees it. The token is reduced to a SHA-256
@@ -19,12 +25,14 @@ export declare class SecretCache {
19
25
  private readonly inflight;
20
26
  /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
21
27
  private readonly failedAt;
28
+ /** The newest entry this instance fetched per key, in case the store returns an older one. */
29
+ private readonly latest;
22
30
  constructor(policy: CachePolicy, emit: (event: AnchorEvent) => void, now?: () => number);
23
31
  /**
24
32
  * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
25
33
  * caller that asked for new values learns it did not get them.
26
34
  */
27
- load(key: string, fetch: (signal: AbortSignal) => Promise<AnchorSecret[]>, opts?: {
35
+ load(key: string, fetch: FetchFn, opts?: {
28
36
  fresh?: boolean;
29
37
  signal?: AbortSignal;
30
38
  strict?: boolean;
package/dist/folder.d.cts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ClientCore } from "./client.cjs";
2
2
  import type { AnchorFolder, ListQuery } from "./types.cjs";
3
+ /** Freezes plain objects and arrays all the way down: one folder's values are shared by every reader. */
4
+ export declare function deepFreeze<T>(value: T): T;
3
5
  export declare function openFolder(core: ClientCore, query: ListQuery, options?: {
4
6
  signal?: AbortSignal;
5
7
  }): Promise<AnchorFolder>;
package/dist/folder.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ClientCore } from "./client.js";
2
2
  import type { AnchorFolder, ListQuery } from "./types.js";
3
+ /** Freezes plain objects and arrays all the way down: one folder's values are shared by every reader. */
4
+ export declare function deepFreeze<T>(value: T): T;
3
5
  export declare function openFolder(core: ClientCore, query: ListQuery, options?: {
4
6
  signal?: AbortSignal;
5
7
  }): Promise<AnchorFolder>;
package/dist/index.cjs CHANGED
@@ -70,6 +70,7 @@ function cacheKey(origin, token, q) {
70
70
  q.includeImports
71
71
  ]);
72
72
  }
73
+ var newer = (a, b) => a && b ? b.fetchedAt > a.fetchedAt ? b : a : a ?? b;
73
74
  function untilAborted(promise, signal) {
74
75
  if (!signal) return promise;
75
76
  if (signal.aborted) return Promise.reject(abortedError());
@@ -91,15 +92,20 @@ var SecretCache = class {
91
92
  inflight = /* @__PURE__ */ new Map();
92
93
  /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
93
94
  failedAt = /* @__PURE__ */ new Map();
95
+ /** The newest entry this instance fetched per key, in case the store returns an older one. */
96
+ latest = /* @__PURE__ */ new Map();
94
97
  /**
95
98
  * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
96
99
  * caller that asked for new values learns it did not get them.
97
100
  */
98
101
  async load(key, fetch, opts = {}) {
99
102
  if (opts.signal?.aborted) throw abortedError();
100
- const entry = await this.read(key);
101
- if (entry && !opts.fresh && this.servable(key, entry)) return entry;
102
- const flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
103
+ let flight = opts.fresh ? void 0 : this.inflight.get(key);
104
+ if (!flight) {
105
+ const entry = newer(await this.read(key), this.latest.get(key));
106
+ if (entry && !opts.fresh && this.servable(key, entry)) return entry;
107
+ flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
108
+ }
103
109
  flight.waiters++;
104
110
  let outcome;
105
111
  try {
@@ -118,7 +124,7 @@ var SecretCache = class {
118
124
  const controller = new AbortController();
119
125
  const promise = this.fetch(
120
126
  key,
121
- () => fetch(controller.signal),
127
+ () => fetch(controller.signal, stale?.etag),
122
128
  stale,
123
129
  controller.signal
124
130
  ).finally(() => {
@@ -155,7 +161,16 @@ var SecretCache = class {
155
161
  }
156
162
  async fetch(key, fetch, stale, signal) {
157
163
  try {
158
- const entry = { secrets: await fetch(), fetchedAt: this.now() };
164
+ const fetched = await fetch();
165
+ if (fetched === "unchanged" && !stale) {
166
+ throw new AnchorError(
167
+ 304,
168
+ "bad_response",
169
+ "Anchor answered 304 to a request without a tag"
170
+ );
171
+ }
172
+ const entry = fetched === "unchanged" ? { secrets: stale.secrets, fetchedAt: this.now(), etag: stale?.etag } : { secrets: fetched.secrets, fetchedAt: this.now(), etag: fetched.etag };
173
+ this.latest.set(key, entry);
159
174
  await this.write(key, entry);
160
175
  this.failedAt.delete(key);
161
176
  return { entry };
@@ -180,6 +195,7 @@ function delayFor(error, attempt, policy) {
180
195
  }
181
196
  function wait(ms, signal) {
182
197
  return new Promise((resolve, reject) => {
198
+ if (signal?.aborted) return reject(abortedError());
183
199
  const onAbort = () => {
184
200
  clearTimeout(timer);
185
201
  reject(abortedError());
@@ -354,18 +370,27 @@ function decodeAll(secrets) {
354
370
  }
355
371
 
356
372
  // src/folder.ts
373
+ function deepFreeze(value) {
374
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
375
+ for (const child of Object.values(value)) deepFreeze(child);
376
+ Object.freeze(value);
377
+ }
378
+ return value;
379
+ }
357
380
  var snapshotOf = (entry) => ({
358
381
  entry,
359
- values: Object.freeze(decodeAll(entry.secrets))
382
+ values: deepFreeze(decodeAll(entry.secrets))
360
383
  });
361
384
  async function openFolder(core, query, options = {}) {
362
385
  let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
363
- let checkedAt = Date.now();
386
+ let checkedAt = snapshot.entry.fetchedAt;
364
387
  let failed = false;
365
388
  let pending;
366
389
  const accept = (entry) => {
367
390
  failed = false;
368
- if (entry !== snapshot.entry) snapshot = snapshotOf(entry);
391
+ if (entry.fetchedAt < snapshot.entry.fetchedAt) return;
392
+ if (entry.secrets === snapshot.entry.secrets) snapshot = { entry, values: snapshot.values };
393
+ else snapshot = snapshotOf(entry);
369
394
  };
370
395
  const refreshInBackground = () => {
371
396
  pending = core.loadEntry(query).then(accept, (error) => {
@@ -435,7 +460,7 @@ async function errorFromResponse(res) {
435
460
  }
436
461
  return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
437
462
  }
438
- async function fetchJson(cfg, url, signal) {
463
+ async function fetchJson(cfg, url, signal, ifNoneMatch) {
439
464
  if (signal?.aborted) throw abortedError();
440
465
  const controller = new AbortController();
441
466
  let timedOut = false;
@@ -446,14 +471,17 @@ async function fetchJson(cfg, url, signal) {
446
471
  const onAbort = () => controller.abort();
447
472
  signal?.addEventListener("abort", onAbort, { once: true });
448
473
  try {
449
- const res = await cfg.fetch(url, {
450
- method: "GET",
451
- headers: { authorization: `Bearer ${cfg.token}`, accept: "application/json" },
452
- signal: controller.signal
453
- });
474
+ const headers = {
475
+ authorization: `Bearer ${cfg.token}`,
476
+ accept: "application/json"
477
+ };
478
+ if (ifNoneMatch) headers["if-none-match"] = ifNoneMatch;
479
+ const res = await cfg.fetch(url, { method: "GET", headers, signal: controller.signal });
480
+ const etag = res.headers.get("etag") ?? void 0;
481
+ if (res.status === 304) return { status: 304, etag, body: void 0 };
454
482
  if (!res.ok) throw await errorFromResponse(res);
455
483
  try {
456
- return await res.json();
484
+ return { status: res.status, etag, body: await res.json() };
457
485
  } catch (cause) {
458
486
  if (controller.signal.aborted) throw cause;
459
487
  throw new AnchorError(
@@ -494,7 +522,7 @@ function normalize(q) {
494
522
  includeImports: q.includeImports ?? true
495
523
  };
496
524
  }
497
- async function fetchFolder(cfg, q, emit, signal) {
525
+ async function fetchFolder(cfg, q, emit, signal, etag) {
498
526
  const params = new URLSearchParams({
499
527
  path: q.path,
500
528
  reveal: "true",
@@ -502,14 +530,16 @@ async function fetchFolder(cfg, q, emit, signal) {
502
530
  includeImports: String(q.includeImports)
503
531
  });
504
532
  const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
505
- const body = await withRetry(() => fetchJson(cfg, url, signal), cfg.retry, {
533
+ const res = await withRetry(() => fetchJson(cfg, url, signal, etag), cfg.retry, {
506
534
  signal,
507
535
  emit
508
536
  });
537
+ if (res.status === 304) return "unchanged";
538
+ const body = res.body;
509
539
  if (!body || !Array.isArray(body.secrets)) {
510
- throw new AnchorError(200, "bad_response", "Anchor returned an unexpected response");
540
+ throw new AnchorError(res.status, "bad_response", "Anchor returned an unexpected response");
511
541
  }
512
- return body.secrets.map(toSecret);
542
+ return { secrets: body.secrets.map(toSecret), etag: res.etag };
513
543
  }
514
544
  var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
515
545
  function createClientCore(options) {
@@ -525,11 +555,19 @@ function createClientCore(options) {
525
555
  const cfg = resolveConfig(options);
526
556
  const query = normalize(q);
527
557
  if (!cache) {
528
- return { secrets: await fetchFolder(cfg, query, emit, o.signal), fetchedAt: Date.now() };
558
+ const fetched = await fetchFolder(cfg, query, emit, o.signal);
559
+ if (fetched === "unchanged") {
560
+ throw new AnchorError(
561
+ 304,
562
+ "bad_response",
563
+ "Anchor answered 304 to a request without a tag"
564
+ );
565
+ }
566
+ return { secrets: fetched.secrets, fetchedAt: Date.now() };
529
567
  }
530
568
  return cache.load(
531
569
  cacheKey(cfg.origin, cfg.token, query),
532
- (signal) => fetchFolder(cfg, query, emit, signal),
570
+ (signal, etag) => fetchFolder(cfg, query, emit, signal, etag),
533
571
  o
534
572
  );
535
573
  }
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ function cacheKey(origin, token, q) {
42
42
  q.includeImports
43
43
  ]);
44
44
  }
45
+ var newer = (a, b) => a && b ? b.fetchedAt > a.fetchedAt ? b : a : a ?? b;
45
46
  function untilAborted(promise, signal) {
46
47
  if (!signal) return promise;
47
48
  if (signal.aborted) return Promise.reject(abortedError());
@@ -63,15 +64,20 @@ var SecretCache = class {
63
64
  inflight = /* @__PURE__ */ new Map();
64
65
  /** When the last fetch of a key failed; the stale entry is served without asking for one ttl. */
65
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();
66
69
  /**
67
70
  * The folder's entry. With `strict`, a failed fetch rejects instead of falling back, so a
68
71
  * caller that asked for new values learns it did not get them.
69
72
  */
70
73
  async load(key, fetch, opts = {}) {
71
74
  if (opts.signal?.aborted) throw abortedError();
72
- const entry = await this.read(key);
73
- if (entry && !opts.fresh && this.servable(key, entry)) return entry;
74
- const flight = this.inflight.get(key) ?? this.start(key, fetch, entry);
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
+ }
75
81
  flight.waiters++;
76
82
  let outcome;
77
83
  try {
@@ -90,7 +96,7 @@ var SecretCache = class {
90
96
  const controller = new AbortController();
91
97
  const promise = this.fetch(
92
98
  key,
93
- () => fetch(controller.signal),
99
+ () => fetch(controller.signal, stale?.etag),
94
100
  stale,
95
101
  controller.signal
96
102
  ).finally(() => {
@@ -127,7 +133,16 @@ var SecretCache = class {
127
133
  }
128
134
  async fetch(key, fetch, stale, signal) {
129
135
  try {
130
- const entry = { secrets: await fetch(), fetchedAt: this.now() };
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);
131
146
  await this.write(key, entry);
132
147
  this.failedAt.delete(key);
133
148
  return { entry };
@@ -152,6 +167,7 @@ function delayFor(error, attempt, policy) {
152
167
  }
153
168
  function wait(ms, signal) {
154
169
  return new Promise((resolve, reject) => {
170
+ if (signal?.aborted) return reject(abortedError());
155
171
  const onAbort = () => {
156
172
  clearTimeout(timer);
157
173
  reject(abortedError());
@@ -326,18 +342,27 @@ function decodeAll(secrets) {
326
342
  }
327
343
 
328
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
+ }
329
352
  var snapshotOf = (entry) => ({
330
353
  entry,
331
- values: Object.freeze(decodeAll(entry.secrets))
354
+ values: deepFreeze(decodeAll(entry.secrets))
332
355
  });
333
356
  async function openFolder(core, query, options = {}) {
334
357
  let snapshot = snapshotOf(await core.loadEntry(query, { signal: options.signal }));
335
- let checkedAt = Date.now();
358
+ let checkedAt = snapshot.entry.fetchedAt;
336
359
  let failed = false;
337
360
  let pending;
338
361
  const accept = (entry) => {
339
362
  failed = false;
340
- if (entry !== snapshot.entry) snapshot = snapshotOf(entry);
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);
341
366
  };
342
367
  const refreshInBackground = () => {
343
368
  pending = core.loadEntry(query).then(accept, (error) => {
@@ -407,7 +432,7 @@ async function errorFromResponse(res) {
407
432
  }
408
433
  return new AnchorError(res.status, "http_error", `Anchor answered HTTP ${res.status}`, options);
409
434
  }
410
- async function fetchJson(cfg, url, signal) {
435
+ async function fetchJson(cfg, url, signal, ifNoneMatch) {
411
436
  if (signal?.aborted) throw abortedError();
412
437
  const controller = new AbortController();
413
438
  let timedOut = false;
@@ -418,14 +443,17 @@ async function fetchJson(cfg, url, signal) {
418
443
  const onAbort = () => controller.abort();
419
444
  signal?.addEventListener("abort", onAbort, { once: true });
420
445
  try {
421
- const res = await cfg.fetch(url, {
422
- method: "GET",
423
- headers: { authorization: `Bearer ${cfg.token}`, accept: "application/json" },
424
- signal: controller.signal
425
- });
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 };
426
454
  if (!res.ok) throw await errorFromResponse(res);
427
455
  try {
428
- return await res.json();
456
+ return { status: res.status, etag, body: await res.json() };
429
457
  } catch (cause) {
430
458
  if (controller.signal.aborted) throw cause;
431
459
  throw new AnchorError(
@@ -466,7 +494,7 @@ function normalize(q) {
466
494
  includeImports: q.includeImports ?? true
467
495
  };
468
496
  }
469
- async function fetchFolder(cfg, q, emit, signal) {
497
+ async function fetchFolder(cfg, q, emit, signal, etag) {
470
498
  const params = new URLSearchParams({
471
499
  path: q.path,
472
500
  reveal: "true",
@@ -474,14 +502,16 @@ async function fetchFolder(cfg, q, emit, signal) {
474
502
  includeImports: String(q.includeImports)
475
503
  });
476
504
  const url = `${cfg.base}/api/v1/projects/${encodeURIComponent(q.project)}/environments/${encodeURIComponent(q.environment)}/secrets?${params}`;
477
- const body = await withRetry(() => fetchJson(cfg, url, signal), cfg.retry, {
505
+ const res = await withRetry(() => fetchJson(cfg, url, signal, etag), cfg.retry, {
478
506
  signal,
479
507
  emit
480
508
  });
509
+ if (res.status === 304) return "unchanged";
510
+ const body = res.body;
481
511
  if (!body || !Array.isArray(body.secrets)) {
482
- throw new AnchorError(200, "bad_response", "Anchor returned an unexpected response");
512
+ throw new AnchorError(res.status, "bad_response", "Anchor returned an unexpected response");
483
513
  }
484
- return body.secrets.map(toSecret);
514
+ return { secrets: body.secrets.map(toSecret), etag: res.etag };
485
515
  }
486
516
  var copy = (s) => s.importedFrom ? { ...s, importedFrom: { ...s.importedFrom } } : { ...s };
487
517
  function createClientCore(options) {
@@ -497,11 +527,19 @@ function createClientCore(options) {
497
527
  const cfg = resolveConfig(options);
498
528
  const query = normalize(q);
499
529
  if (!cache) {
500
- return { secrets: await fetchFolder(cfg, query, emit, o.signal), fetchedAt: Date.now() };
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() };
501
539
  }
502
540
  return cache.load(
503
541
  cacheKey(cfg.origin, cfg.token, query),
504
- (signal) => fetchFolder(cfg, query, emit, signal),
542
+ (signal, etag) => fetchFolder(cfg, query, emit, signal, etag),
505
543
  o
506
544
  );
507
545
  }
@@ -1,6 +1,12 @@
1
1
  import type { Config } from "./config.cjs";
2
+ export type JsonResponse = {
3
+ status: number;
4
+ etag: string | undefined;
5
+ body: unknown;
6
+ };
2
7
  /**
3
- * One GET: the parsed body of a 2xx, or an `AnchorError` whose `retryable` says whether another
4
- * attempt may succeed. Aborts after `cfg.timeoutMs`, or as soon as `signal` aborts.
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.
5
11
  */
6
- export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<unknown>;
12
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;
@@ -1,6 +1,12 @@
1
1
  import type { Config } from "./config.js";
2
+ export type JsonResponse = {
3
+ status: number;
4
+ etag: string | undefined;
5
+ body: unknown;
6
+ };
2
7
  /**
3
- * One GET: the parsed body of a 2xx, or an `AnchorError` whose `retryable` says whether another
4
- * attempt may succeed. Aborts after `cfg.timeoutMs`, or as soon as `signal` aborts.
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.
5
11
  */
6
- export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal): Promise<unknown>;
12
+ export declare function fetchJson(cfg: Config, url: string, signal?: AbortSignal, ifNoneMatch?: string): Promise<JsonResponse>;
package/dist/types.d.cts CHANGED
@@ -109,6 +109,8 @@ export interface AnchorFolder {
109
109
  export type CacheEntry = {
110
110
  secrets: AnchorSecret[];
111
111
  fetchedAt: number;
112
+ /** The server's tag for these secrets, sent back as `If-None-Match`; absent before 0.5.0. */
113
+ etag?: string;
112
114
  };
113
115
  /**
114
116
  * Where cached folders live; the default keeps them in memory. A custom store (a file, Redis)
package/dist/types.d.ts CHANGED
@@ -109,6 +109,8 @@ export interface AnchorFolder {
109
109
  export type CacheEntry = {
110
110
  secrets: AnchorSecret[];
111
111
  fetchedAt: number;
112
+ /** The server's tag for these secrets, sent back as `If-None-Match`; absent before 0.5.0. */
113
+ etag?: string;
112
114
  };
113
115
  /**
114
116
  * Where cached folders live; the default keeps them in memory. A custom store (a file, Redis)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchor-secrets/sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Node client for Anchor, a self-hosted secrets manager on Cloudflare Workers",
5
5
  "keywords": [
6
6
  "secrets",
@@ -47,7 +47,7 @@
47
47
  "node": ">=20"
48
48
  },
49
49
  "devDependencies": {
50
- "@anchor/shared": "0.4.0",
50
+ "@anchor/shared": "0.5.0",
51
51
  "@arethetypeswrong/cli": "^0.18.5",
52
52
  "@types/node": "^22.20.5",
53
53
  "esbuild": "^0.28.2",