@vegastack/design 0.8.38 → 0.8.40

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.
@@ -30,6 +30,7 @@ import {
30
30
  rewriteRegistryAliases,
31
31
  stripShadcnLeadingCommentPrologue,
32
32
  } from "./verify-registry-item.mjs";
33
+ import { fetchRegistryWithRetry } from "./registry-fetch.mjs";
33
34
 
34
35
  const ITEM_NAME_SOURCE = "[a-z0-9]+(?:-[a-z0-9]+)*";
35
36
  const VERSION_SOURCE =
@@ -319,13 +320,15 @@ function assertRegistryRequest(url, headers) {
319
320
  return parsed;
320
321
  }
321
322
 
323
+ // Transient failures (network errors, a cut-off body, 5xx, 429) are retried with backoff before
324
+ // the caller reports the registry unreachable; a 404 or any other answer returns at once.
322
325
  async function fetchRegistry(url, headers) {
323
326
  assertRegistryRequest(url, headers);
324
- return fetch(url, {
325
- headers: nonEmptyHeaders(headers),
326
- redirect: "error",
327
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
328
- });
327
+ return fetchRegistryWithRetry(
328
+ url,
329
+ { headers: nonEmptyHeaders(headers), redirect: "error" },
330
+ { timeoutMs: REQUEST_TIMEOUT_MS },
331
+ );
329
332
  }
330
333
 
331
334
  // Resolve { urlTemplate, headers } in precedence order: --registry > components.json > env.
@@ -0,0 +1,115 @@
1
+ // Registry fetch with retries — shared by `check-updates` and `verify-registry-item`.
2
+ //
3
+ // A CI runner's network flakes several times a day: "fetch failed" (a reset socket, a DNS blip),
4
+ // "terminated" (the body stream cut off mid-read) or a 5xx or 429 from the edge. None of those says
5
+ // anything about drift, so one of them must not fail a `check-updates --fail-on-update` run. Each
6
+ // request gets a timeout and up to `attempts` tries with jittered exponential backoff (a 429's
7
+ // `Retry-After` is honoured, capped); the body is read inside the attempt, so a stream cut off
8
+ // mid-read is retried too. Only after the last try does the caller see the failure and report the
9
+ // registry unreachable. Anything else — a 404, a 401/403, a 4xx — is an answer, returned at once,
10
+ // and integrity checks run on the body exactly as before.
11
+
12
+ /** How many tries a request gets before its failure is reported. */
13
+ export const REGISTRY_FETCH_ATTEMPTS = 3;
14
+
15
+ /** The first retry's base delay; each later one doubles it (jittered ±50%). Read per retry. */
16
+ function baseDelayMs() {
17
+ const value = Number(process.env.VEGASTACK_REGISTRY_RETRY_BASE_MS);
18
+ return Number.isFinite(value) && value >= 0 ? value : 400;
19
+ }
20
+
21
+ /** The longest a `Retry-After` is waited for. */
22
+ const MAX_RETRY_AFTER_MS = 5_000;
23
+
24
+ /** A status worth another try: the edge or the origin had a moment, not an answer. */
25
+ export function isTransientStatus(status) {
26
+ return status === 429 || status === 408 || (status >= 500 && status <= 599);
27
+ }
28
+
29
+ /** A thrown fetch or body-read error worth another try (network, reset, cut-off stream, timeout). */
30
+ export function isTransientError(error) {
31
+ if (!error) return false;
32
+ if (error.name === "AbortError" || error.name === "TimeoutError") return true;
33
+ if (error.name === "TypeError") return true; // undici: "fetch failed"
34
+ const text = `${error.message ?? ""} ${error.cause?.code ?? ""} ${error.cause?.message ?? ""}`;
35
+ return /terminated|fetch failed|socket|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|EPIPE|UND_ERR/i.test(
36
+ text,
37
+ );
38
+ }
39
+
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ /** A `Retry-After` in milliseconds — delta-seconds or an HTTP date — or undefined when absent/bad. */
45
+ export function retryAfterMs(value, now = Date.now()) {
46
+ if (value == null || value === "") return undefined;
47
+ const seconds = Number(value);
48
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
49
+ const at = Date.parse(value);
50
+ return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
51
+ }
52
+
53
+ /** How long to wait before try `attempt + 1`: the server's `Retry-After` (capped), else backoff. */
54
+ export function backoff(attempt, retryAfter) {
55
+ const asked = retryAfterMs(retryAfter);
56
+ if (asked !== undefined) return Math.min(asked, MAX_RETRY_AFTER_MS);
57
+ return baseDelayMs() * 2 ** attempt * (0.5 + Math.random());
58
+ }
59
+
60
+ /** A Response-like whose body was read as `body` (or is read once, lazily, from `response`). */
61
+ function settled(response, body) {
62
+ let pending = body === undefined ? undefined : Promise.resolve(body);
63
+ const read = () => (pending ??= response.text().catch(() => ""));
64
+ return {
65
+ ok: response.ok,
66
+ status: response.status,
67
+ statusText: response.statusText,
68
+ headers: response.headers,
69
+ text: () => read(),
70
+ json: async () => JSON.parse(await read()),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Fetch `url`, retrying transient failures. Resolves to a small Response-like object: a 2xx's body
76
+ * is already read (so a cut-off body is retried); any other answer's is read once, lazily, and
77
+ * never retried. Rejects with the last error when every try failed at the network level.
78
+ *
79
+ * @param {string | URL} url
80
+ * @param {RequestInit} init headers and redirect policy; the timeout signal is added per try
81
+ * @param {{ timeoutMs?: number, attempts?: number }} [options]
82
+ */
83
+ export async function fetchRegistryWithRetry(url, init, options = {}) {
84
+ const attempts = options.attempts ?? REGISTRY_FETCH_ATTEMPTS;
85
+ const timeoutMs = options.timeoutMs ?? 15_000;
86
+ let lastError;
87
+ for (let attempt = 0; attempt < attempts; attempt++) {
88
+ const last = attempt === attempts - 1;
89
+ try {
90
+ const response = await fetch(url, {
91
+ ...init,
92
+ signal: AbortSignal.timeout(timeoutMs),
93
+ });
94
+ if (isTransientStatus(response.status) && !last) {
95
+ // Drain so the socket is released, then try again.
96
+ await response.text?.().catch(() => {});
97
+ await sleep(backoff(attempt, response.headers?.get?.("retry-after")));
98
+ continue;
99
+ }
100
+ // A stub (or an odd runtime) may offer only `json()`: hand it back untouched, so its parse
101
+ // still happens — and its errors are still redacted — at the caller.
102
+ if (typeof response.text !== "function") return response;
103
+ // Any other non-2xx (a 404, a 401/403, the last 5xx) is an answer: returned at once, its
104
+ // body read lazily and never retried — a body cut off mid-read can't turn it into a retry.
105
+ if (!response.ok) return settled(response);
106
+ // A 2xx's body is read inside the try, so a stream cut off mid-read ("terminated") retries.
107
+ return settled(response, await response.text());
108
+ } catch (error) {
109
+ lastError = error;
110
+ if (!isTransientError(error) || last) throw error;
111
+ await sleep(backoff(attempt));
112
+ }
113
+ }
114
+ throw lastError;
115
+ }
@@ -69,6 +69,7 @@ import { pathToFileURL } from "node:url";
69
69
  import { join, resolve, basename, relative, isAbsolute, sep } from "node:path";
70
70
  import { createHash } from "node:crypto";
71
71
  import tsconfigPaths from "tsconfig-paths";
72
+ import { fetchRegistryWithRetry } from "./registry-fetch.mjs";
72
73
 
73
74
  const { loadConfig: loadTsconfigPaths } = tsconfigPaths;
74
75
 
@@ -271,11 +272,12 @@ async function fetchRegistryText(url, headers, label) {
271
272
  assertRegistryRequest(url, headers);
272
273
  let response;
273
274
  try {
274
- response = await fetch(url, {
275
- headers,
276
- redirect: "error",
277
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
278
- });
275
+ // Transient failures are retried with backoff first (see registry-fetch.mjs).
276
+ response = await fetchRegistryWithRetry(
277
+ url,
278
+ { headers, redirect: "error" },
279
+ { timeoutMs: REQUEST_TIMEOUT_MS },
280
+ );
279
281
  } catch (error) {
280
282
  throw new RegistryAccessError(`${label} request failed: ${error.message}`);
281
283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vegastack/design",
3
- "version": "0.8.38",
3
+ "version": "0.8.40",
4
4
  "description": "VegaStack design system — cn utility, icon runtime, Tailwind v4 preset, and the vegastack-design CLI (tokens ship separately as @vegastack/design-tokens)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -67,7 +67,7 @@
67
67
  "lint": "eslint . && node ../../tooling/design-lint.mjs src && pnpm run verify",
68
68
  "verify": "node ../../tooling/verify-preset-source.mjs",
69
69
  "typecheck": "tsc --noEmit",
70
- "test": "node test/compare.test.mjs && node test/check-updates.test.mjs && node test/skills-install.test.mjs && node test/doctor.test.mjs && node --test test/merge-refs.test.mjs test/registry-credentials.test.mjs"
70
+ "test": "node test/compare.test.mjs && node test/check-updates.test.mjs && node test/skills-install.test.mjs && node test/doctor.test.mjs && node --test test/merge-refs.test.mjs test/registry-credentials.test.mjs test/registry-fetch.test.mjs"
71
71
  },
72
72
  "dependencies": {
73
73
  "@vegastack/design-tokens": "^0.8.38",