Files
HiveGrid-Wireframes/hivegrid-cloud/scripts/cdn/bunny-purge.ts
T

207 lines
7.1 KiB
JavaScript

#!/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);
});
}