From 9e5457618c81ce4f371733ecceac4a67ef96113a Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 8 Oct 2026 23:08:10 +0800 Subject: [PATCH 1/3] fix(cli): refresh item manifests when bypassing registry cache --- packages/cli/src/registry/remote.test.ts | 57 ++++++++++++++++++++++ packages/cli/src/registry/remote.ts | 5 +- packages/cli/src/registry/resolver.test.ts | 43 ++++++++++++++-- packages/cli/src/registry/resolver.ts | 8 ++- 4 files changed, 106 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/registry/remote.test.ts b/packages/cli/src/registry/remote.test.ts index a40c25bbeb3..0f3512ff88a 100644 --- a/packages/cli/src/registry/remote.test.ts +++ b/packages/cli/src/registry/remote.test.ts @@ -167,6 +167,63 @@ describe("fetchRegistryManifest", () => { }); describe("fetchItemManifest", () => { + it("refreshes a fresh item cache when skipCache is requested", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); + await fetchItemManifest("count-up", "hyperframes:component"); + const updated = { + ...ITEM, + title: "Updated counter", + files: [{ path: "updated.html", target: "updated.html", type: "hyperframes:snippet" }], + }; + fetchSpy.mockResolvedValue(ok(updated)).mockClear(); + + await expect( + fetchItemManifest("count-up", "hyperframes:component", DEFAULT_REGISTRY_URL, { + skipCache: true, + }), + ).resolves.toEqual(updated); + expect(fetchSpy).toHaveBeenCalledOnce(); + await expect(fetchItemManifest("count-up", "hyperframes:component")).resolves.toEqual(updated); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it("keeps using a fresh item cache without skipCache", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); + await fetchItemManifest("count-up", "hyperframes:component"); + fetchSpy.mockResolvedValue(ok({ ...ITEM, title: "Updated counter" })).mockClear(); + + await expect(fetchItemManifest("count-up", "hyperframes:component")).resolves.toEqual(ITEM); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("serves the cached item when a forced refresh fails", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); + await fetchItemManifest("count-up", "hyperframes:component"); + fetchSpy.mockRejectedValue(new Error("HTTP 503")).mockClear(); + + await expect( + fetchItemManifest("count-up", "hyperframes:component", DEFAULT_REGISTRY_URL, { + skipCache: true, + }), + ).resolves.toEqual(ITEM); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + + it("keeps a valid cached item when a forced refresh returns invalid data", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); + await fetchItemManifest("count-up", "hyperframes:component"); + fetchSpy.mockResolvedValue(ok({ ...ITEM, name: "different-item" })).mockClear(); + + await expect( + fetchItemManifest("count-up", "hyperframes:component", DEFAULT_REGISTRY_URL, { + skipCache: true, + }), + ).resolves.toEqual(ITEM); + expect(fetchSpy).toHaveBeenCalledOnce(); + await expect(fetchItemManifest("count-up", "hyperframes:component")).resolves.toEqual(ITEM); + expect(fetchSpy).toHaveBeenCalledOnce(); + }); + it("rejects a cache escape name before fetching", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); await expect( diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index 4b6745b8f3a..a2de1e7cdea 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -143,7 +143,7 @@ export async function fetchRegistryManifest( /** * Fetch a single item's `registry-item.json` manifest. Same freshness policy as - * the top-level manifest: fresh from cache, else revalidate, else serve stale. + * the top-level manifest: fresh unless skipCache, else revalidate, else serve stale. * Throws on network failure only when nothing was ever cached for this item * (callers decide whether to degrade gracefully). */ @@ -151,6 +151,7 @@ export async function fetchItemManifest( name: string, type: ItemType, baseUrl: string = DEFAULT_REGISTRY_URL, + options?: { skipCache?: boolean }, ): Promise { if (!validRegistryName(name) || !Object.hasOwn(ITEM_TYPE_DIRS, type)) throw new Error("Invalid registry item name or type"); @@ -159,7 +160,7 @@ export async function fetchItemManifest( const url = registryPathUrl(baseUrl, dir, name, "registry-item.json"); const cacheFile = cachePath(baseUrl, `${dir}__${name}`); const cached = readCacheEntry(cacheFile, validate); - if (cached && isFresh(cached)) return cached.data; + if (!options?.skipCache && cached && isFresh(cached)) return cached.data; try { const item = await fetchJson(url, validate); diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts index d8f6b2611e6..8016ee8da9d 100644 --- a/packages/cli/src/registry/resolver.test.ts +++ b/packages/cli/src/registry/resolver.test.ts @@ -5,6 +5,7 @@ import { loadAllItems, resolveItem, resolveItemWithDependencies, + resolveItemsByTag, unreachableRegistryMessage, } from "./resolver.js"; @@ -54,6 +55,7 @@ function mockFetch( missing?: string[]; /** Inject `registryDependencies` into served items, keyed by item name. */ dependencies?: Record; + tags?: Record; } = {}, ): void { vi.stubGlobal( @@ -67,9 +69,8 @@ function mockFetch( if (m && !overrides.missing?.includes(m[2]!)) { const type = m[1] === "examples" ? "hyperframes:example" : "hyperframes:block"; const item = buildItem(m[2]!, type); - if (overrides.dependencies && item.name in overrides.dependencies) { - item.registryDependencies = overrides.dependencies[item.name]; - } + item.registryDependencies = overrides.dependencies?.[item.name]; + item.tags = overrides.tags?.[item.name]; return new Response(JSON.stringify(item), { status: 200 }); } return new Response("not found", { status: 404 }); @@ -167,6 +168,22 @@ describe("registry resolver", () => { }); describe("resolveItemWithDependencies", () => { + it("refreshes cached transitive dependencies when skipCache is requested", async () => { + const baseUrl = uniqueBaseUrl(); + mockFetch({ dependencies: { gamma: ["beta"] } }); + const cached = await resolveItemWithDependencies("gamma", { baseUrl }); + expect(cached.map((item) => item.name)).toEqual(["beta", "gamma"]); + mockFetch({ dependencies: { gamma: ["beta"], beta: ["alpha"] } }); + + const unchanged = await resolveItemWithDependencies("gamma", { baseUrl }); + expect(unchanged.map((item) => item.name)).toEqual(["beta", "gamma"]); + expect(fetch).not.toHaveBeenCalled(); + + const refreshed = await resolveItemWithDependencies("gamma", { baseUrl, skipCache: true }); + expect(refreshed.map((item) => item.name)).toEqual(["alpha", "beta", "gamma"]); + expect(fetch).toHaveBeenCalledTimes(4); + }); + it("returns dependencies first, then the requested item (linear chain)", async () => { mockFetch({ dependencies: { beta: ["alpha"], gamma: ["beta"] } }); const baseUrl = uniqueBaseUrl(); @@ -207,6 +224,26 @@ describe("registry resolver", () => { }); }); +describe("resolveItemsByTag", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("refreshes added and removed tags on already cached items", async () => { + const baseUrl = uniqueBaseUrl(); + mockFetch({ tags: { gamma: ["old-tag"] } }); + const original = await resolveItemsByTag("old-tag", { baseUrl }); + expect(original.map((item) => item.name)).toEqual(["gamma"]); + mockFetch({ tags: { gamma: ["new-tag"] } }); + + expect(await resolveItemsByTag("new-tag", { baseUrl })).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + const refreshed = await resolveItemsByTag("new-tag", { baseUrl, skipCache: true }); + expect(refreshed.map((item) => item.name)).toEqual(["gamma"]); + expect(fetch).toHaveBeenCalledTimes(4); + expect(await resolveItemsByTag("old-tag", { baseUrl })).toEqual([]); + expect(fetch).toHaveBeenCalledTimes(4); + }); +}); + describe("unreachableRegistryMessage", () => { it("names a private registry, so the reader looks at the right host", () => { // Same dead end the item-file failure used to be: without the host, a diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index a41502198c1..39dc13fb9d5 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -51,7 +51,9 @@ export async function loadAllItems( const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL; const warn = options.onWarn ?? defaultWarn; const results = await Promise.allSettled( - entries.map((e) => fetchItemManifest(e.name, e.type, baseUrl)), + entries.map((e) => + fetchItemManifest(e.name, e.type, baseUrl, { skipCache: options.skipCache }), + ), ); const items: RegistryItem[] = []; results.forEach((r, i) => { @@ -164,7 +166,9 @@ export async function resolveItemWithDependencies( ); } - const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl); + const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl, { + skipCache: options.skipCache, + }); itemCache.set(itemName, pending); return pending; }; From 6ba074c7470929b1ff026a5c54b1626ef5112551 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 14:15:56 +0800 Subject: [PATCH 2/3] chore: prepare conflict resolution against current main --- packages/cli/src/registry/remote.ts | 7 ++-- packages/cli/src/registry/resolver.ts | 51 +++++++++++++++------------ 2 files changed, 32 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index a2de1e7cdea..715cdf6f65a 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -143,7 +143,7 @@ export async function fetchRegistryManifest( /** * Fetch a single item's `registry-item.json` manifest. Same freshness policy as - * the top-level manifest: fresh unless skipCache, else revalidate, else serve stale. + * the top-level manifest: fresh from cache, else revalidate, else serve stale. * Throws on network failure only when nothing was ever cached for this item * (callers decide whether to degrade gracefully). */ @@ -151,7 +151,7 @@ export async function fetchItemManifest( name: string, type: ItemType, baseUrl: string = DEFAULT_REGISTRY_URL, - options?: { skipCache?: boolean }, + options?: { cacheOnly?: boolean }, ): Promise { if (!validRegistryName(name) || !Object.hasOwn(ITEM_TYPE_DIRS, type)) throw new Error("Invalid registry item name or type"); @@ -160,7 +160,8 @@ export async function fetchItemManifest( const url = registryPathUrl(baseUrl, dir, name, "registry-item.json"); const cacheFile = cachePath(baseUrl, `${dir}__${name}`); const cached = readCacheEntry(cacheFile, validate); - if (!options?.skipCache && cached && isFresh(cached)) return cached.data; + if (cached && (isFresh(cached) || options?.cacheOnly)) return cached.data; + if (options?.cacheOnly) throw new Error("not fetched after an earlier registry timeout"); try { const item = await fetchJson(url, validate); diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index 39dc13fb9d5..27e2c7a5f87 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -6,15 +6,16 @@ import type { ItemType, RegistryItem, RegistryManifestEntry } from "@hyperframes/core"; import { fetchItemManifest, fetchRegistryManifest, DEFAULT_REGISTRY_URL } from "./remote.js"; +import { runWithConcurrencyLimit } from "../utils/concurrency.js"; export interface ResolveOptions { baseUrl?: string; /** Bypass the 24h manifest cache and fetch fresh data from the registry. */ skipCache?: boolean; /** - * Called once per item that fails to load inside `loadAllItems`. Defaults - * to writing a diagnostic line to stderr. Pass a quieter implementation - * when rendering structured output (clack prompts, JSON, etc.). + * Called once with a summary when items fail to load inside `loadAllItems`. + * Defaults to writing a diagnostic line to stderr. Pass a quieter + * implementation when rendering structured output (clack prompts, JSON, etc.). */ onWarn?: (message: string) => void; } @@ -38,11 +39,13 @@ export async function listRegistryItems( return manifest.items.filter((item) => item.type === filter.type); } +// Hundreds of item manifests sharing one slow link all started their timeouts at once. +const ITEM_FETCH_CONCURRENCY = 32; + /** - * Load every item's full manifest in parallel. Used by the interactive init - * picker to populate titles/descriptions for all examples at once. Items that - * fail to load are skipped with a warning so one missing manifest doesn't - * break the picker. + * Load every item's full manifest, a bounded number at a time. Items that fail + * to load are skipped with one summary warning so one missing manifest doesn't + * break the picker; after a timeout the rest come from cache only. */ export async function loadAllItems( entries: RegistryManifestEntry[], @@ -50,21 +53,25 @@ export async function loadAllItems( ): Promise { const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_URL; const warn = options.onWarn ?? defaultWarn; - const results = await Promise.allSettled( - entries.map((e) => - fetchItemManifest(e.name, e.type, baseUrl, { skipCache: options.skipCache }), - ), - ); - const items: RegistryItem[] = []; - results.forEach((r, i) => { - if (r.status === "fulfilled") { - items.push(r.value); - } else { - const name = entries[i]?.name ?? ""; - warn(`skipped item "${name}": ${String(r.reason)}`); + const skipped: string[] = []; + let firstReason = ""; + let timedOut = false; + const loaded = await runWithConcurrencyLimit(entries, ITEM_FETCH_CONCURRENCY, async (e) => { + try { + return await fetchItemManifest(e.name, e.type, baseUrl, { cacheOnly: timedOut }); + } catch (err) { + if (err instanceof Error && err.name === "TimeoutError") timedOut = true; + skipped.push(`"${e.name}"`); + firstReason ||= String(err); + return undefined; } }); - return items; + if (skipped.length === 1) warn(`skipped item ${skipped[0]}: ${firstReason}`); + else if (skipped.length > 1) + warn( + `skipped ${skipped.length} items (${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", ..." : ""}): ${firstReason}`, + ); + return loaded.filter((item): item is RegistryItem => item !== undefined); } /** @@ -166,9 +173,7 @@ export async function resolveItemWithDependencies( ); } - const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl, { - skipCache: options.skipCache, - }); + const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl); itemCache.set(itemName, pending); return pending; }; From 914bcd0e92464d30dee3e063051e444630cf0298 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Sat, 24 Oct 2026 14:16:12 +0800 Subject: [PATCH 3/3] fix(cli): preserve registry refresh with bounded timeout fallback --- docs/contributing/catalog.mdx | 5 ++++ packages/cli/src/registry/remote.test.ts | 31 ++++++++++++++++++++++ packages/cli/src/registry/remote.ts | 7 ++--- packages/cli/src/registry/resolver.test.ts | 21 +++++++++++++++ packages/cli/src/registry/resolver.ts | 9 +++++-- 5 files changed, 68 insertions(+), 5 deletions(-) diff --git a/docs/contributing/catalog.mdx b/docs/contributing/catalog.mdx index df73733faae..cc8895edbf4 100644 --- a/docs/contributing/catalog.mdx +++ b/docs/contributing/catalog.mdx @@ -125,6 +125,11 @@ upgrade command. Set `deprecated` to a short migration message when an item has a replacement. The installer warns and keeps the item available for existing projects. +Installing by tag with `npx hyperframes add ` refreshes the registry index +and item manifests, so published tag changes take effect without waiting for +the 24-hour cache to expire. If the registry cannot be reached, previously +cached manifests remain available as a fallback. + ```json { "minCliVersion": "0.6.96", diff --git a/packages/cli/src/registry/remote.test.ts b/packages/cli/src/registry/remote.test.ts index 0f3512ff88a..6a93ade2cd8 100644 --- a/packages/cli/src/registry/remote.test.ts +++ b/packages/cli/src/registry/remote.test.ts @@ -196,6 +196,37 @@ describe("fetchItemManifest", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it.each([0, ONE_DAY_MS + 60_000])( + "keeps refresh requests offline in cache-only mode with cache age %d", + async (age) => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); + await fetchItemManifest("count-up", "hyperframes:component"); + vi.useFakeTimers(); + vi.setSystemTime(new Date(Date.now() + age)); + fetchSpy.mockRejectedValue(new Error("offline")).mockClear(); + + await expect( + fetchItemManifest("count-up", "hyperframes:component", DEFAULT_REGISTRY_URL, { + skipCache: true, + cacheOnly: true, + }), + ).resolves.toEqual(ITEM); + expect(fetchSpy).not.toHaveBeenCalled(); + }, + ); + + it("does not refresh an uncached item in cache-only mode", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(ok(ITEM)); + + await expect( + fetchItemManifest("count-up", "hyperframes:component", DEFAULT_REGISTRY_URL, { + skipCache: true, + cacheOnly: true, + }), + ).rejects.toThrow("not fetched after an earlier registry timeout"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + it("serves the cached item when a forced refresh fails", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(ok(ITEM)); await fetchItemManifest("count-up", "hyperframes:component"); diff --git a/packages/cli/src/registry/remote.ts b/packages/cli/src/registry/remote.ts index 715cdf6f65a..ae3d4ff7070 100644 --- a/packages/cli/src/registry/remote.ts +++ b/packages/cli/src/registry/remote.ts @@ -143,7 +143,7 @@ export async function fetchRegistryManifest( /** * Fetch a single item's `registry-item.json` manifest. Same freshness policy as - * the top-level manifest: fresh from cache, else revalidate, else serve stale. + * the top-level manifest: fresh unless skipCache, else revalidate, else serve stale. * Throws on network failure only when nothing was ever cached for this item * (callers decide whether to degrade gracefully). */ @@ -151,7 +151,7 @@ export async function fetchItemManifest( name: string, type: ItemType, baseUrl: string = DEFAULT_REGISTRY_URL, - options?: { cacheOnly?: boolean }, + options?: { skipCache?: boolean; cacheOnly?: boolean }, ): Promise { if (!validRegistryName(name) || !Object.hasOwn(ITEM_TYPE_DIRS, type)) throw new Error("Invalid registry item name or type"); @@ -160,7 +160,8 @@ export async function fetchItemManifest( const url = registryPathUrl(baseUrl, dir, name, "registry-item.json"); const cacheFile = cachePath(baseUrl, `${dir}__${name}`); const cached = readCacheEntry(cacheFile, validate); - if (cached && (isFresh(cached) || options?.cacheOnly)) return cached.data; + if (cached && (options?.cacheOnly || (!options?.skipCache && isFresh(cached)))) + return cached.data; if (options?.cacheOnly) throw new Error("not fetched after an earlier registry timeout"); try { diff --git a/packages/cli/src/registry/resolver.test.ts b/packages/cli/src/registry/resolver.test.ts index a1e4fce3b6d..910a0c065de 100644 --- a/packages/cli/src/registry/resolver.test.ts +++ b/packages/cli/src/registry/resolver.test.ts @@ -174,6 +174,27 @@ describe("registry resolver", () => { 'skipped 100 items ("item-0", "item-1", "item-2", ...): TimeoutError: The operation was aborted due to timeout', ]); }); + it("uses cached remaining items after a forced refresh times out", async () => { + const baseUrl = uniqueBaseUrl(); + const cached = await loadAllItems(entries.slice(32), { baseUrl }); + expect(cached).toHaveLength(68); + const fetchMock = vi.fn(async () => { + throw new DOMException("The operation was aborted due to timeout", "TimeoutError"); + }); + vi.stubGlobal("fetch", fetchMock); + const warnings: string[] = []; + + const items = await loadAllItems(entries, { + baseUrl, + skipCache: true, + onWarn: (m) => warnings.push(m), + }); + expect(items).toEqual(cached); + expect(fetchMock).toHaveBeenCalledTimes(32); + expect(warnings).toEqual([ + 'skipped 32 items ("item-0", "item-1", "item-2", ...): TimeoutError: The operation was aborted due to timeout', + ]); + }); }); describe("resolveItem", () => { diff --git a/packages/cli/src/registry/resolver.ts b/packages/cli/src/registry/resolver.ts index 27e2c7a5f87..f22e40909e5 100644 --- a/packages/cli/src/registry/resolver.ts +++ b/packages/cli/src/registry/resolver.ts @@ -58,7 +58,10 @@ export async function loadAllItems( let timedOut = false; const loaded = await runWithConcurrencyLimit(entries, ITEM_FETCH_CONCURRENCY, async (e) => { try { - return await fetchItemManifest(e.name, e.type, baseUrl, { cacheOnly: timedOut }); + return await fetchItemManifest(e.name, e.type, baseUrl, { + skipCache: options.skipCache, + cacheOnly: timedOut, + }); } catch (err) { if (err instanceof Error && err.name === "TimeoutError") timedOut = true; skipped.push(`"${e.name}"`); @@ -173,7 +176,9 @@ export async function resolveItemWithDependencies( ); } - const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl); + const pending = fetchItemManifest(registryEntry.name, registryEntry.type, options.baseUrl, { + skipCache: options.skipCache, + }); itemCache.set(itemName, pending); return pending; };