Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/contributing/catalog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <tag>` 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",
Expand Down
88 changes: 88 additions & 0 deletions packages/cli/src/registry/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,94 @@ 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.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");
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(
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/src/registry/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,15 @@ 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).
*/
export async function fetchItemManifest(
name: string,
type: ItemType,
baseUrl: string = DEFAULT_REGISTRY_URL,
options?: { cacheOnly?: boolean },
options?: { skipCache?: boolean; cacheOnly?: boolean },
): Promise<RegistryItem> {
if (!validRegistryName(name) || !Object.hasOwn(ITEM_TYPE_DIRS, type))
throw new Error("Invalid registry item name or type");
Expand All @@ -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 {
Expand Down
64 changes: 61 additions & 3 deletions packages/cli/src/registry/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
loadAllItems,
resolveItem,
resolveItemWithDependencies,
resolveItemsByTag,
unreachableRegistryMessage,
} from "./resolver.js";

Expand Down Expand Up @@ -54,6 +55,7 @@ function mockFetch(
missing?: string[];
/** Inject `registryDependencies` into served items, keyed by item name. */
dependencies?: Record<string, string[] | undefined>;
tags?: Record<string, string[]>;
} = {},
): void {
vi.stubGlobal(
Expand All @@ -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 });
Expand Down Expand Up @@ -173,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", () => {
Expand Down Expand Up @@ -210,6 +232,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();
Expand Down Expand Up @@ -250,6 +288,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
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/src/registry/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}"`);
Expand Down Expand Up @@ -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;
};
Expand Down
Loading