@vegastack/design 0.8.39 → 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.
- package/bin/check-updates.mjs +8 -5
- package/bin/registry-fetch.mjs +115 -0
- package/bin/verify-registry-item.mjs +7 -5
- package/package.json +2 -2
package/bin/check-updates.mjs
CHANGED
|
@@ -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
|
|
325
|
-
|
|
326
|
-
redirect: "error",
|
|
327
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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.
|
|
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",
|