207 lines
7.1 KiB
JavaScript
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);
|
|
});
|
|
}
|