Track HiveGrid Cloud planning reference
This commit is contained in:
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/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user