Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,154 @@
import { describe, expect, it, vi } from "vitest";
import { parsePurgeArgs, purgeBunnyCache, resolveAbsolutePurgeUrl } from "./bunny-purge";
describe("parsePurgeArgs", () => {
it("returns defaults for an empty argv", () => {
expect(parsePurgeArgs([])).toEqual({ paths: [], dryRun: false });
});
it("parses --dry-run", () => {
expect(parsePurgeArgs(["--dry-run"])).toEqual({ paths: [], dryRun: true });
});
it("parses a single --paths value", () => {
expect(parsePurgeArgs(["--paths", "/assets/app.js"])).toEqual({
paths: ["/assets/app.js"],
dryRun: false,
});
});
it("splits comma-separated --paths into multiple entries", () => {
expect(parsePurgeArgs(["--paths", "/a, /b,/c"])).toEqual({
paths: ["/a", "/b", "/c"],
dryRun: false,
});
});
it("accepts repeated --paths flags", () => {
expect(parsePurgeArgs(["--paths", "/a", "--paths", "/b"])).toEqual({
paths: ["/a", "/b"],
dryRun: false,
});
});
it("treats --path as an alias for --paths", () => {
expect(parsePurgeArgs(["--path", "/x"])).toEqual({ paths: ["/x"], dryRun: false });
});
it("throws on an unknown argument", () => {
expect(() => parsePurgeArgs(["--unknown"])).toThrow(/Unknown argument/);
});
it("throws when --paths is supplied without a value", () => {
expect(() => parsePurgeArgs(["--paths"])).toThrow(/expects a value/);
});
});
describe("resolveAbsolutePurgeUrl", () => {
it("returns the input unchanged when already absolute", () => {
expect(resolveAbsolutePurgeUrl("https://cdn.example.com/x.js")).toBe("https://cdn.example.com/x.js");
});
it("joins relative paths against the CDN base", () => {
expect(resolveAbsolutePurgeUrl("/assets/app.js", "https://cdn.example.com")).toBe(
"https://cdn.example.com/assets/app.js"
);
});
it("throws when relative paths are passed without a CDN base", () => {
expect(() => resolveAbsolutePurgeUrl("/assets/app.js")).toThrow(/relative/);
});
it("rejects empty paths", () => {
expect(() => resolveAbsolutePurgeUrl(" ")).toThrow(/Empty path/);
});
});
describe("purgeBunnyCache", () => {
function makeOkFetch() {
return vi.fn(async () => Promise.resolve({ ok: true, status: 200 } as Response));
}
it("calls the whole-zone purge endpoint by default", async () => {
const fetchImpl = makeOkFetch();
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledOnce();
const [url, init] = fetchImpl.mock.calls[0];
expect(String(url)).toBe("https://api.bunny.net/pullzone/12345/purgeCache");
expect(init?.method).toBe("POST");
expect((init?.headers as Record<string, string>).AccessKey).toBe("secret");
expect(result).toEqual({
url: "https://api.bunny.net/pullzone/12345/purgeCache",
requests: 1,
ok: true,
failedStatus: undefined,
});
});
it("calls /purge?url=... once per path when paths are supplied", async () => {
const fetchImpl = makeOkFetch();
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
paths: ["/assets/a.js", "/assets/b.js"],
cdnBaseUrl: "https://cdn.example.com",
fetchImpl,
});
expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(String(fetchImpl.mock.calls[0][0])).toBe(
"https://api.bunny.net/purge?url=https%3A%2F%2Fcdn.example.com%2Fassets%2Fa.js"
);
expect(String(fetchImpl.mock.calls[1][0])).toBe(
"https://api.bunny.net/purge?url=https%3A%2F%2Fcdn.example.com%2Fassets%2Fb.js"
);
expect(result.ok).toBe(true);
expect(result.requests).toBe(2);
});
it("surfaces the first non-2xx status as a failure", async () => {
const fetchImpl = vi.fn(async () => Promise.resolve({ ok: false, status: 403 } as Response));
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
fetchImpl,
});
expect(result.ok).toBe(false);
expect(result.failedStatus).toBe(403);
});
it("stops iterating paths after the first failure and reports the failed status", async () => {
let calls = 0;
const fetchImpl = vi.fn(async () => {
calls += 1;
if (calls === 2) return { ok: false, status: 502 } as Response;
return { ok: true, status: 200 } as Response;
});
const result = await purgeBunnyCache({
pullZoneId: "12345",
apiKey: "secret",
paths: ["/a", "/b", "/c"],
cdnBaseUrl: "https://cdn.example.com",
fetchImpl,
});
expect(result.ok).toBe(false);
expect(result.failedStatus).toBe(502);
expect(fetchImpl).toHaveBeenCalledTimes(2);
});
it("throws when pullZoneId is missing", async () => {
await expect(purgeBunnyCache({ pullZoneId: "", apiKey: "x", fetchImpl: makeOkFetch() })).rejects.toThrow(
/BUNNY_PULL_ZONE_ID/
);
});
it("throws when apiKey is missing", async () => {
await expect(purgeBunnyCache({ pullZoneId: "1", apiKey: "", fetchImpl: makeOkFetch() })).rejects.toThrow(
/BUNNY_API_KEY/
);
});
});
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env -S node --import tsx
/**
* scripts/cdn/bunny-purge.ts
*
* Cache-purge helper for the Bunny.net pull-zone fronting our static assets.
* Used as a post-deploy step so hash-named bundles published in the new build
* are visible at the edge immediately (otherwise users hit stale `index.html`
* referencing assets the edge hasn't pulled yet).
*
* Usage:
* pnpm exec tsx scripts/cdn/bunny-purge.ts # purge entire pull zone
* pnpm exec tsx scripts/cdn/bunny-purge.ts --paths /assets/x.js # purge specific URL(s)
* pnpm exec tsx scripts/cdn/bunny-purge.ts --dry-run # print plan, no API call
*
* Required env (provisioned in CI secrets — see docs/ops/asset-cdn.md):
* BUNNY_PULL_ZONE_ID — numeric id of the Bunny pull zone
* BUNNY_API_KEY — account-level API key (NOT the read-only password)
* ASSET_CDN_URL — base URL of the CDN (required for path-scoped purges)
*
* Exit codes:
* 0 — purge succeeded (or skipped because the credentials are intentionally absent)
* 1 — API returned a non-2xx status, or arguments were invalid
*
* Refs: docs/improvement-plan/03-scaling-infrastructure/asset-cdn.md
*/
const BUNNY_API_ROOT = "https://api.bunny.net";
export interface BunnyPurgeOptions {
pullZoneId: string;
apiKey: string;
/** Optional. When set, purges only the listed absolute URLs instead of the whole zone. */
paths?: readonly string[];
/** Optional. Base CDN URL used to expand relative `--paths` arguments. */
cdnBaseUrl?: string;
/** Optional fetch implementation — overridable for tests. Defaults to global fetch. */
fetchImpl?: typeof fetch;
}
export interface BunnyPurgeResult {
/** Final URL hit (zone purge URL or last per-path purge URL). */
url: string;
/** Number of HTTP requests made. */
requests: number;
/** Whether all calls returned a 2xx status. */
ok: boolean;
/** When `ok === false`, the first non-2xx response status; undefined otherwise. */
failedStatus?: number;
}
/**
* Resolve a `--paths` value (possibly relative like `/assets/app.js`) to an
* absolute URL against the CDN base. The Bunny `purgeUrl` endpoint requires
* a fully-qualified URL.
*/
export function resolveAbsolutePurgeUrl(rawPath: string, cdnBaseUrl?: string): string {
const trimmed = rawPath.trim();
if (!trimmed) throw new Error("Empty path supplied to --paths");
if (/^https?:\/\//i.test(trimmed)) return trimmed;
if (!cdnBaseUrl) {
throw new Error(`Path "${trimmed}" is relative but ASSET_CDN_URL is not set; cannot build absolute URL.`);
}
return new URL(trimmed, cdnBaseUrl).toString();
}
/**
* Parse argv after the script name. Supports:
* --paths a,b,c comma-separated list
* --paths a --paths b repeated flag
* --dry-run no API call
*/
export function parsePurgeArgs(argv: readonly string[]): { paths: string[]; dryRun: boolean } {
const paths: string[] = [];
let dryRun = false;
for (let index = 0; index < argv.length; index += 1) {
const token = argv[index];
if (token === "--dry-run") {
dryRun = true;
continue;
}
if (token === "--paths" || token === "--path") {
const next = argv[index + 1];
if (!next || next.startsWith("--")) {
throw new Error(`${token} expects a value`);
}
for (const part of next.split(",")) {
const trimmed = part.trim();
if (trimmed) paths.push(trimmed);
}
index += 1;
continue;
}
throw new Error(`Unknown argument: ${token}`);
}
return { paths, dryRun };
}
export async function purgeBunnyCache(options: BunnyPurgeOptions): Promise<BunnyPurgeResult> {
const fetchImpl = options.fetchImpl ?? fetch;
if (!options.pullZoneId) throw new Error("BUNNY_PULL_ZONE_ID is required");
if (!options.apiKey) throw new Error("BUNNY_API_KEY is required");
const headers = {
AccessKey: options.apiKey,
Accept: "application/json",
};
// Per-path purge — preferred when only a handful of files changed
// (avoids cold-cache penalty on the rest of the zone).
if (options.paths && options.paths.length > 0) {
let lastUrl = "";
for (const rawPath of options.paths) {
const absolute = resolveAbsolutePurgeUrl(rawPath, options.cdnBaseUrl);
const purgeUrl = `${BUNNY_API_ROOT}/purge?url=${encodeURIComponent(absolute)}`;
// eslint-disable-next-line no-await-in-loop
const response = await fetchImpl(purgeUrl, { method: "POST", headers });
lastUrl = purgeUrl;
if (!response.ok) {
return { url: purgeUrl, requests: options.paths.indexOf(rawPath) + 1, ok: false, failedStatus: response.status };
}
}
return { url: lastUrl, requests: options.paths.length, ok: true };
}
// Whole-zone purge — used by the default post-deploy step. Bunny accepts
// POST with no body; the AccessKey header authenticates the call.
const zoneUrl = `${BUNNY_API_ROOT}/pullzone/${encodeURIComponent(options.pullZoneId)}/purgeCache`;
const response = await fetchImpl(zoneUrl, { method: "POST", headers });
return {
url: zoneUrl,
requests: 1,
ok: response.ok,
failedStatus: response.ok ? undefined : response.status,
};
}
async function main(): Promise<number> {
const pullZoneId = process.env.BUNNY_PULL_ZONE_ID?.trim();
const apiKey = process.env.BUNNY_API_KEY?.trim();
const cdnBaseUrl = process.env.ASSET_CDN_URL?.trim();
if (!pullZoneId || !apiKey) {
// Intentional no-op so deploy workflows can wire this step unconditionally:
// when the operator has not yet provisioned the Bunny zone, the step
// logs and exits 0 instead of failing the deploy. See docs/ops/asset-cdn.md.
// eslint-disable-next-line no-console
console.log("[bunny-purge] BUNNY_PULL_ZONE_ID or BUNNY_API_KEY unset — skipping (no-op).");
return 0;
}
const args = parsePurgeArgs(process.argv.slice(2));
if (args.dryRun) {
// eslint-disable-next-line no-console
console.log(
JSON.stringify(
{
dryRun: true,
pullZoneId,
paths: args.paths,
target: args.paths.length > 0 ? "per-path" : "whole-zone",
},
null,
2
)
);
return 0;
}
const result = await purgeBunnyCache({
pullZoneId,
apiKey,
paths: args.paths.length > 0 ? args.paths : undefined,
cdnBaseUrl,
});
if (result.ok) {
// eslint-disable-next-line no-console
console.log(`[bunny-purge] success (${result.requests} request(s))`);
return 0;
}
// eslint-disable-next-line no-console
console.error(`[bunny-purge] failed: HTTP ${result.failedStatus} at ${result.url}`);
return 1;
}
// `import.meta.url` ends with this file path when invoked directly; the
// vitest runner imports the module instead and skips the side-effect.
const invokedDirectly =
typeof process !== "undefined" &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (invokedDirectly) {
main()
.then((code) => {
process.exit(code);
})
.catch((error: unknown) => {
// eslint-disable-next-line no-console
console.error("[bunny-purge] unexpected error:", error);
process.exit(1);
});
}