From a7da27075ca0aace74a31167700cc21b4de03eec Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:06:10 +0100 Subject: [PATCH 1/9] feat(core): add an edge rule client --- packages/cli/src/core/edge-rules.ts | 67 +++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 packages/cli/src/core/edge-rules.ts diff --git a/packages/cli/src/core/edge-rules.ts b/packages/cli/src/core/edge-rules.ts new file mode 100644 index 00000000..af254f35 --- /dev/null +++ b/packages/cli/src/core/edge-rules.ts @@ -0,0 +1,67 @@ +import type { createCoreClient } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; + +type CoreClient = ReturnType; + +export type EdgeRule = components["schemas"]["EdgeRuleV2Model"]; +export type EdgeRuleTrigger = components["schemas"]["Trigger"]; + +// The generated schema types these as bare numbers; named constants keep call sites readable. +export const EdgeRuleAction = { + Redirect: 1, + OriginUrl: 2, + OverrideCacheTime: 3, + BlockRequest: 4, + SetResponseHeader: 5, + SetRequestHeader: 6, + OverrideBrowserCacheTime: 16, +} as const; + +export const EdgeRuleTriggerType = { + Url: 0, + RequestHeader: 1, + ResponseHeader: 2, + UrlExtension: 3, +} as const; + +export const EdgeRuleMatch = { + Any: 0, + All: 1, + None: 2, +} as const; + +/** The pull zone's edge rules; the zone GET is the only endpoint that returns them. */ +export async function fetchEdgeRules( + client: CoreClient, + pullZoneId: number, +): Promise { + const { data } = await client.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + return data?.EdgeRules ?? []; +} + +// Upsert keyed on Description: addOrUpdate creates a duplicate unless the existing rule's Guid is passed, so the description doubles as the rule's identity. +export async function upsertEdgeRule( + client: CoreClient, + pullZoneId: number, + rule: EdgeRule & { Description: string }, + existingRules?: EdgeRule[], +): Promise { + const rules = existingRules ?? (await fetchEdgeRules(client, pullZoneId)); + const existing = rules.find((r) => r.Description === rule.Description); + await client.POST("/pullzone/{pullZoneId}/edgerules/addOrUpdate", { + params: { path: { pullZoneId } }, + body: { ...rule, ...(existing?.Guid ? { Guid: existing.Guid } : {}) }, + }); +} + +export async function deleteEdgeRule( + client: CoreClient, + pullZoneId: number, + guid: string, +): Promise { + await client.DELETE("/pullzone/{pullZoneId}/edgerules/{edgeRuleId}", { + params: { path: { pullZoneId, edgeRuleId: guid } }, + }); +} From 9af1bdfa28c4c13774b29d7f4b7f3c104c0a7cab Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:26:28 +0100 Subject: [PATCH 2/9] feat(sites): serve sites with edge rules instead of the router --- packages/cli/README.md | 9 +- packages/cli/src/commands/sites/api.test.ts | 374 +++++++++--------- packages/cli/src/commands/sites/api.ts | 369 +++++++++++------ .../cli/src/commands/sites/constants.test.ts | 10 +- packages/cli/src/commands/sites/constants.ts | 49 ++- packages/cli/src/commands/sites/create.ts | 11 +- packages/cli/src/commands/sites/delete.ts | 8 +- packages/cli/src/commands/sites/deploy.ts | 34 +- .../src/commands/sites/deployments/publish.ts | 18 +- .../src/commands/sites/domains/index.test.ts | 1 - .../cli/src/commands/sites/domains/index.ts | 2 +- packages/cli/src/commands/sites/index.ts | 2 - packages/cli/src/commands/sites/list.ts | 1 - packages/cli/src/commands/sites/open.test.ts | 1 - packages/cli/src/commands/sites/provision.ts | 4 - .../src/commands/sites/router/source.test.ts | 86 ---- .../cli/src/commands/sites/router/source.ts | 75 ---- packages/cli/src/commands/sites/show.ts | 1 - .../cli/src/commands/sites/upgrade-router.ts | 79 ---- packages/cli/src/core/hostnames/client.ts | 6 +- 20 files changed, 489 insertions(+), 651 deletions(-) delete mode 100644 packages/cli/src/commands/sites/router/source.test.ts delete mode 100644 packages/cli/src/commands/sites/router/source.ts delete mode 100644 packages/cli/src/commands/sites/upgrade-router.ts diff --git a/packages/cli/README.md b/packages/cli/README.md index 474ad0d9..a8a9ed4c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -908,9 +908,9 @@ bunny scripts docs > **Experimental**: hidden from `--help` and the landing page while it stabilizes. -Host static sites on bunny.net. Each site is three resources provisioned and wired together for you: a **storage zone** holding the files, a **pull zone** serving them over the CDN, and a **middleware router** (an Edge Script) that maps incoming requests to the deploy that should answer them. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. +Host static sites on bunny.net. Each site is two resources provisioned and wired together for you: a **storage zone** holding the files and a **pull zone** serving them over the CDN, with edge rules that route requests to the deploy that should answer them. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. -Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and then goes live. Publishing flips the router's `CURRENT_DEPLOY` variable and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op. +Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with `max-age=0` so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op. Commands take the site as an optional positional (`[site]`), except `deploy`, `ci init`, and `deployments publish`, which use `--site`. Either accepts the site name or its storage zone ID. When omitted, the site resolves from the directory's linked site (`.bunny/site.json`, written by `sites link` or by `create`/`deploy`), then `sites.name` in `bunny.jsonc`, then an interactive picker that offers to link. Non-interactive runs (`--output json`, no TTY, or `--force` on a destructive command) error instead of prompting. @@ -953,13 +953,12 @@ bunny sites ci init # GitHub Actions: push to bunny sites ci init --framework astro bunny sites link my-site bunny sites unlink -bunny sites upgrade-router # republish the router with this CLI's version (deploy also does this automatically) bunny sites delete my-site --keep-storage # typed-name confirmation; keeps the deploy files ``` Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. -Every deploy publishes: the files land in an immutable `deploys//` directory and the router is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. Content is root-served, so client-side routing and absolute asset paths work as-is. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. +Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. Content is root-served, so client-side routing and absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. | Flag | Commands | Description | | -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | @@ -974,7 +973,7 @@ Every deploy publishes: the files land in an immutable `deploys//` directory | `--framework` | `ci init` | Framework preset for the workflow's build steps (default: detected) | | `--print` | `open` | Print the URL instead of opening a browser | | `--link` | `create`, `deploy`, `show`, `ci init`, `deployments` | Link the directory to the site; `--no-link` never links | -| `--keep-storage` | `delete` | Delete the pull zone and router but keep the storage zone and its deploy files | +| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files | | `--force`, `-f` | `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | ### `bunny sandbox` diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 10e91f71..06c424a2 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -1,25 +1,29 @@ import { afterAll, beforeEach, expect, test } from "bun:test"; +import type { EdgeRule } from "../../core/edge-rules.ts"; import { ApiError } from "../../core/errors.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, createSite, deleteSiteResources, - ensureRouterCurrent, fetchSites, promoteDeploy, promoteVerification, readRemoteState, + requireRulesSite, siteContextFromZone, siteFiles, writeRemoteState, } from "./api.ts"; import { + GATE_RULE_DESC, + HOP_HEADER, + PLACEHOLDER_DEPLOY, REMOTE_STATE_PATH, + REWRITE_RULE_DESC, type RemoteSiteState, STATE_VERSION, } from "./constants.ts"; -import { ROUTER_VERSION } from "./router/source.ts"; // ---- in-memory storage-file store (replaces the storage SDK) ---- @@ -31,7 +35,12 @@ beforeEach(() => { store.clear(); // Promote probes the CDN and sleeps between attempts; keep tests offline and fast. promoteVerification.wait = async () => {}; - promoteVerification.probe = async () => 200; + promoteVerification.probe = async (url) => ({ + status: 200, + deploy: + new URL(url).searchParams.get("__bunny_promote")?.replace(/-\d+$/, "") ?? + null, + }); siteFiles.connect = (zone) => ({ zoneName: zone.Name }) as unknown as ReturnType< typeof siteFiles.connect @@ -87,7 +96,6 @@ function fakeState(overrides?: Partial): RemoteSiteState { name: "my-site", storageZoneId: 10, pullZoneId: 30, - scriptId: 20, deploys: [], ...overrides, }; @@ -113,7 +121,9 @@ function fakeCoreClient(opts: { }): CoreClient { const zones = opts.storageZones ?? []; const pullZones = opts.pullZones ?? []; + const edgeRules = new Map(); let nextPullZoneId = 30; + let nextGuid = 1; return { GET: async ( path: string, @@ -128,11 +138,17 @@ function fakeCoreClient(opts: { return { data: zone }; } if (path === "/pullzone/{id}") { - const pz = pullZones.find((p) => p.Id === options?.params?.path?.id); + const id = options?.params?.path?.id as number; + const pz = pullZones.find((p) => p.Id === id); return { - data: pz ?? { - Id: options?.params?.path?.id, - Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], + data: { + ...(pz ?? { + Id: id, + Hostnames: [ + { IsSystemHostname: true, Value: "my-site.b-cdn.net" }, + ], + }), + EdgeRules: edgeRules.get(id) ?? [], }, }; } @@ -181,16 +197,31 @@ function fakeCoreClient(opts: { return { data: zone }; } if (path === "/pullzone") { - const name = (options?.body as { Name: string }).Name; + const body = options?.body as { Name: string; StorageZoneId?: number }; + const name = body.Name; const pz = { Id: nextPullZoneId++, Name: name, + StorageZoneId: body.StorageZoneId, Hostnames: [{ IsSystemHostname: true, Value: `${name}.b-cdn.net` }], }; pullZones.push(pz); return { data: pz }; } if (path === "/pullzone/{id}") return { data: {} }; + if (path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") { + const id = (options?.params as { path: { pullZoneId: number } }).path + .pullZoneId; + const rule = options?.body as EdgeRule; + const rules = edgeRules.get(id) ?? []; + const existing = rule.Guid + ? rules.findIndex((r) => r.Guid === rule.Guid) + : -1; + if (existing >= 0) rules[existing] = rule; + else rules.push({ ...rule, Guid: `guid-${nextGuid++}` }); + edgeRules.set(id, rules); + return { data: undefined }; + } if (path === "/pullzone/{id}/setForceSSL") return { data: undefined }; if (path === "/pullzone/{id}/purgeCache") return { data: undefined }; throw new Error(`unexpected POST ${path}`); @@ -405,24 +436,17 @@ test("writeRemoteState does not resurrect intentionally removed deploys on a pru // ---- provisioning ---- -test("createSite provisions storage zone → router → pull zone → state", async () => { +test("createSite provisions storage zone → pull zone → edge rules → state", async () => { const coreCalls: Call[] = []; - const computeCalls: Call[] = []; const coreClient = fakeCoreClient({ calls: coreCalls }); - const computeClient = fakeComputeClient({ calls: computeCalls }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", }); - expect(result.reused).toEqual({ - storageZone: false, - script: false, - pullZone: false, - }); + expect(result.reused).toEqual({ storageZone: false, pullZone: false }); // Zone names are globally unique, so both carry a shared random suffix. const zoneCreate = coreCalls.find( @@ -432,21 +456,10 @@ test("createSite provisions storage zone → router → pull zone → state", as expect(zoneName).toMatch(/^sites-my-site-[a-z0-9]{6}$/); expect(result.systemHostname).toBe(`${zoneName}.b-cdn.net`); - // The router script is uploaded, published, and gets CURRENT_DEPLOY="". - const computePaths = computeCalls.map((c) => `${c.method} ${c.path}`); - expect(computePaths).toContain("POST /compute/script"); - expect(computePaths).toContain("POST /compute/script/{id}/code"); - expect(computePaths).toContain("POST /compute/script/{id}/publish"); - const scriptCreate = computeCalls.find( - (c) => c.path === "/compute/script" && c.method === "POST", - ); - expect(scriptCreate?.body).toMatchObject({ Name: `${zoneName}-router` }); - const envSet = computeCalls.find( - (c) => c.path === "/compute/script/{id}/variables", - ); - expect(envSet?.body).toEqual({ Name: "CURRENT_DEPLOY", DefaultValue: "" }); + // The no-deploys page backs the initial rewrite target. + expect(store.has(`deploys/${PLACEHOLDER_DEPLOY}/index.html`)).toBe(true); - // Exactly one pull zone (production) is created; the router is attached. + // Exactly one pull zone (production) is created, plus the cache settings update. const pzCreates = coreCalls.filter( (c) => c.method === "POST" && c.path === "/pullzone", ); @@ -455,10 +468,31 @@ test("createSite provisions storage zone → router → pull zone → state", as Name: zoneName, StorageZoneId: 10, }); - const attach = coreCalls.find( + const settings = coreCalls.find( (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); - expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + expect(settings?.body).toEqual({ + CacheControlMaxAgeOverride: 2592000, + CacheControlPublicMaxAgeOverride: 0, + }); + + // All five rules land, the rewrite targets the placeholder, and the gate carries the rewrite's hop secret. + const rules = coreCalls + .filter((c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") + .map((c) => c.body as EdgeRule); + expect(rules).toHaveLength(5); + const rewrite = rules.find((r) => r.Description === REWRITE_RULE_DESC); + expect(rewrite?.ActionParameter1).toBe( + `https://${zoneName}.b-cdn.net/deploys/${PLACEHOLDER_DEPLOY}%{Url.Path}`, + ); + const secret = rewrite?.ExtraActions?.find( + (a) => a.ActionParameter1 === HOP_HEADER, + )?.ActionParameter2; + expect(secret).toMatch(/^[0-9a-f]{32}$/); + const gate = rules.find((r) => r.Description === GATE_RULE_DESC); + expect( + gate?.Triggers?.find((t) => t.Parameter1 === HOP_HEADER)?.PatternMatches, + ).toEqual([secret as string]); // The system host redirects HTTP → HTTPS out of the box. const forceSsl = coreCalls.find( @@ -469,24 +503,47 @@ test("createSite provisions storage zone → router → pull zone → state", as ForceSSL: true, }); - // Exactly one middleware script (the router) is created. - expect(computePaths.filter((p) => p === "POST /compute/script")).toHaveLength( - 1, - ); - - // Remote state marks the zone as a site. + // Remote state marks the zone as a site; no script in the rules era. const written = await readRemoteState(fakeConnection()); expect(written?.state).toMatchObject({ name: "my-site", storageZoneId: 10, pullZoneId: 30, - scriptId: 20, }); + expect(written?.state.scriptId).toBeUndefined(); +}); + +test("createSite re-run after a crash reuses the rules and their secret", async () => { + const coreCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + + await createSite({ coreClient, name: "my-site", region: "DE" }); + const secretOf = (rules: EdgeRule[]) => + rules + .find((r) => r.Description === REWRITE_RULE_DESC) + ?.ExtraActions?.find((a) => a.ActionParameter1 === HOP_HEADER) + ?.ActionParameter2; + const firstSecret = secretOf( + coreCalls + .filter((c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") + .map((c) => c.body as EdgeRule), + ); + + // Crash before the state write: the resume must upsert the same rules, not duplicate them or mint a new secret. + store.clear(); + coreCalls.length = 0; + await createSite({ coreClient, name: "my-site", region: "DE" }); + + const upserts = coreCalls + .filter((c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") + .map((c) => c.body as EdgeRule); + expect(upserts).toHaveLength(5); + expect(upserts.every((r) => r.Guid)).toBe(true); + expect(secretOf(upserts)).toBe(firstSecret as string); }); test("createSite re-run reuses existing resources and converges", async () => { const coreCalls: Call[] = []; - const computeCalls: Call[] = []; // Everything already exists; but no remote state (a half-finished create). const coreClient = fakeCoreClient({ calls: coreCalls, @@ -496,79 +553,60 @@ test("createSite re-run reuses existing resources and converges", async () => { Id: 30, Name: "sites-my-site-abc123", StorageZoneId: 10, - Hostnames: [], + Hostnames: [ + { IsSystemHostname: true, Value: "sites-my-site-abc123.b-cdn.net" }, + ], }, ], }); - const computeClient = fakeComputeClient({ - calls: computeCalls, - scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }], - }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", }); - expect(result.reused).toEqual({ - storageZone: true, - script: true, - pullZone: true, - }); + expect(result.reused).toEqual({ storageZone: true, pullZone: true }); // Nothing new was created… expect( coreCalls.filter((c) => c.method === "POST" && c.path === "/storagezone"), ).toHaveLength(0); - expect( - computeCalls.filter( - (c) => c.method === "POST" && c.path === "/compute/script", - ), - ).toHaveLength(0); - // …but the router republish and attach still ran (idempotent convergence). - expect(computeCalls.map((c) => c.path)).toContain( - "/compute/script/{id}/code", - ); + // …but the settings and rules still converge on the existing zone. expect(coreCalls.map((c) => `${c.method} ${c.path}`)).toContain( "POST /pullzone/{id}", ); + expect( + coreCalls.filter( + (c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate", + ), + ).toHaveLength(5); expect(await readRemoteState(fakeConnection())).not.toBeNull(); }); test("createSite resumes a half-created suffixed site", async () => { - const coreCalls: Call[] = []; - const computeCalls: Call[] = []; const suffixed = { ...ZONE, Name: "sites-my-site-abc123" }; const coreClient = fakeCoreClient({ - calls: coreCalls, + calls: [], storageZones: [suffixed], pullZones: [ { Id: 30, Name: "sites-my-site-abc123", StorageZoneId: 10, - Hostnames: [], + Hostnames: [ + { IsSystemHostname: true, Value: "sites-my-site-abc123.b-cdn.net" }, + ], }, ], }); - const computeClient = fakeComputeClient({ - calls: computeCalls, - scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }], - }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", }); - expect(result.reused).toEqual({ - storageZone: true, - script: true, - pullZone: true, - }); + expect(result.reused).toEqual({ storageZone: true, pullZone: true }); // The site keeps its clean display name; only the zones carry the suffix. expect(result.state.name).toBe("my-site"); }); @@ -576,33 +614,20 @@ test("createSite resumes a half-created suffixed site", async () => { test("createSite refuses to resume a half-created zone on another tier", async () => { const suffixed = { ...ZONE, Name: "sites-my-site-abc123" }; const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] }); - const computeClient = fakeComputeClient({ calls: [] }); // The zone is HDD, so an --tier ssd resume would silently finish on the wrong tier. await expect( - createSite({ - coreClient, - computeClient, - name: "my-site", - region: "DE", - tier: "ssd", - }), + createSite({ coreClient, name: "my-site", region: "DE", tier: "ssd" }), ).rejects.toThrow("but `--tier ssd` was requested"); }); test("createSite refuses to resume a half-created zone in another region", async () => { const suffixed = { ...ZONE, Name: "sites-my-site-abc123", Region: "LA" }; const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] }); - const computeClient = fakeComputeClient({ calls: [] }); // The zone lives in LA, so an explicit --region de resume would silently keep the files there. await expect( - createSite({ - coreClient, - computeClient, - name: "my-site", - region: "DE", - }), + createSite({ coreClient, name: "my-site", region: "DE" }), ).rejects.toThrow("but `--region DE` was requested"); }); @@ -616,20 +641,14 @@ test("createSite resumes a half-created zone in another region when none was req Id: 30, Name: "sites-my-site-abc123", StorageZoneId: 10, - Hostnames: [], + Hostnames: [ + { IsSystemHostname: true, Value: "sites-my-site-abc123.b-cdn.net" }, + ], }, ], }); - const computeClient = fakeComputeClient({ - calls: [], - scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }], - }); - const result = await createSite({ - coreClient, - computeClient, - name: "my-site", - }); + const result = await createSite({ coreClient, name: "my-site" }); expect(result.reused.storageZone).toBe(true); }); @@ -644,18 +663,15 @@ test("createSite resumes a half-created zone when the tier matches", async () => Id: 30, Name: "sites-my-site-abc123", StorageZoneId: 10, - Hostnames: [], + Hostnames: [ + { IsSystemHostname: true, Value: "sites-my-site-abc123.b-cdn.net" }, + ], }, ], }); - const computeClient = fakeComputeClient({ - calls: [], - scripts: [{ Id: 20, Name: "sites-my-site-abc123-router" }], - }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", tier: "hdd", @@ -668,10 +684,9 @@ test("createSite refuses to re-provision an existing suffixed site", async () => store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); const suffixed = { ...ZONE, Name: "sites-my-site-abc123" }; const coreClient = fakeCoreClient({ calls: [], storageZones: [suffixed] }); - const computeClient = fakeComputeClient({ calls: [] }); await expect( - createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + createSite({ coreClient, name: "my-site", region: "DE" }), ).rejects.toThrow('Site "my-site" already exists.'); }); @@ -689,10 +704,8 @@ test("createSite gives up after every storage zone suffix collides", async () => ), }, }); - const computeClient = fakeComputeClient({ calls: [] }); - await expect( - createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + createSite({ coreClient, name: "my-site", region: "DE" }), ).rejects.toThrow( 'Couldn\'t find an available storage zone name for "my-site".', ); @@ -715,11 +728,9 @@ test("createSite retries the pull zone with a fresh suffix when the name is take times: 1, }, }); - const computeClient = fakeComputeClient({ calls: [] }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", }); @@ -740,10 +751,8 @@ test("createSite gives up after every pull zone suffix collides", async () => { error: new ApiError("The name is already taken.", 400), }, }); - const computeClient = fakeComputeClient({ calls: [] }); - await expect( - createSite({ coreClient, computeClient, name: "my-site", region: "DE" }), + createSite({ coreClient, name: "my-site", region: "DE" }), ).rejects.toThrow( 'Couldn\'t find an available pull zone name for "my-site".', ); @@ -751,63 +760,58 @@ test("createSite gives up after every pull zone suffix collides", async () => { // ---- promote ---- -test("promoteDeploy sets CURRENT_DEPLOY and purges the pull zone cache", async () => { - const coreCalls: Call[] = []; - const computeCalls: Call[] = []; - const coreClient = fakeCoreClient({ calls: coreCalls }); - const computeClient = fakeComputeClient({ calls: computeCalls }); - - await promoteDeploy({ - computeClient, - coreClient, - state: fakeState(), - deployId: "a1b2c3d4", - }); - - const envSet = computeCalls.find((c) => c.method === "PUT"); - expect(envSet?.body).toEqual({ - Name: "CURRENT_DEPLOY", - DefaultValue: "a1b2c3d4", - }); - // Purged twice: once immediately, once after the edge picks up the new deploy. - const purges = coreCalls.filter( - (c) => c.path === "/pullzone/{id}/purgeCache", - ); - expect(purges).toHaveLength(2); - expect(purges[0]?.params).toEqual({ path: { id: 30 } }); -}); - -test("promoteDeploy waits for the edge to serve a deploy before the final purge", async () => { +test("promoteDeploy retargets the rewrite rule, probes the edge, and purges twice", async () => { const coreCalls: Call[] = []; const coreClient = fakeCoreClient({ calls: coreCalls }); - const computeClient = fakeComputeClient({ calls: [] }); - // The edge returns the 404 placeholder until CURRENT_DEPLOY propagates. - const statuses = [404, 404, 200]; + // The edge serves the outgoing deploy until the rule propagates. + const serving = ["old", "old", "a1b2c3d4"]; const probed: string[] = []; promoteVerification.probe = async (url) => { probed.push(url); - return statuses.shift() ?? 200; + return { status: 200, deploy: serving.shift() ?? "a1b2c3d4" }; }; await promoteDeploy({ - computeClient, coreClient, state: fakeState(), deployId: "a1b2c3d4", }); - // Kept probing past the placeholder, then purged a second time. + const upserts = coreCalls + .filter((c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") + .map((c) => c.body as EdgeRule); + const rewrite = upserts.find((r) => r.Description === REWRITE_RULE_DESC); + expect(rewrite?.ActionParameter1).toBe( + "https://my-site.b-cdn.net/deploys/a1b2c3d4%{Url.Path}", + ); + + // Kept probing until the edge reported the new deploy, then purged a second time. expect(probed.length).toBe(3); expect(probed[0]).toContain("my-site.b-cdn.net"); - expect( - coreCalls.filter((c) => c.path === "/pullzone/{id}/purgeCache"), - ).toHaveLength(2); + const purges = coreCalls.filter( + (c) => c.path === "/pullzone/{id}/purgeCache", + ); + expect(purges).toHaveLength(2); + expect(purges[0]?.params).toEqual({ path: { id: 30 } }); + + // A follow-up promote reuses the hop secret the first one minted. + const secretOf = (r?: EdgeRule) => + r?.ExtraActions?.find((a) => a.ActionParameter1 === HOP_HEADER) + ?.ActionParameter2; + coreCalls.length = 0; + promoteVerification.probe = async () => ({ status: 200, deploy: "e5f6" }); + await promoteDeploy({ coreClient, state: fakeState(), deployId: "e5f6" }); + const again = coreCalls + .filter((c) => c.path === "/pullzone/{pullZoneId}/edgerules/addOrUpdate") + .map((c) => c.body as EdgeRule) + .find((r) => r.Description === REWRITE_RULE_DESC); + expect(secretOf(again)).toBe(secretOf(rewrite) as string); }); // ---- discovery ---- -test("fetchSites keeps only middleware+storage pull zones with matching state", async () => { +test("fetchSites keeps only storage pull zones whose state names them", async () => { store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); const coreClient = fakeCoreClient({ calls: [], @@ -817,14 +821,13 @@ test("fetchSites keeps only middleware+storage pull zones with matching state", { Id: 30, Name: "my-site", - MiddlewareScriptId: 20, StorageZoneId: 10, Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], }, - // Plain storage pull zone; no middleware, never fetched. - { Id: 31, Name: "not-a-site", StorageZoneId: 10 }, - // Middleware pull zone whose state points elsewhere. - { Id: 32, Name: "other", MiddlewareScriptId: 9, StorageZoneId: 10 }, + // A storage pull zone whose state points elsewhere. + { Id: 32, Name: "other", StorageZoneId: 10 }, + // No storage origin: never a candidate. + { Id: 33, Name: "url-origin" }, ], }); @@ -834,9 +837,9 @@ test("fetchSites keeps only middleware+storage pull zones with matching state", expect(sites[0]?.systemHostname).toBe("my-site.b-cdn.net"); }); -// A pull zone can share a site's storage origin and router without being the site's own zone; only the state's pullZoneId decides. +// A pull zone can share a site's storage origin without being the site's own zone; only the state's pullZoneId decides. Router-era state (scriptId present) is still discovered for list/show/delete. test("fetchSites ignores another pull zone pointed at the site's storage zone", async () => { - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState({ scriptId: 20 }))); const coreClient = fakeCoreClient({ calls: [], storageZones: [ZONE], @@ -844,14 +847,12 @@ test("fetchSites ignores another pull zone pointed at the site's storage zone", { Id: 30, Name: "my-site", - MiddlewareScriptId: 20, StorageZoneId: 10, Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], }, { Id: 77, Name: "some-other-zone", - MiddlewareScriptId: 20, StorageZoneId: 10, }, ], @@ -862,27 +863,11 @@ test("fetchSites ignores another pull zone pointed at the site's storage zone", expect(sites[0]?.state.pullZoneId).toBe(30); }); -test("ensureRouterCurrent republishes an outdated router and stamps the version", async () => { - const calls: Call[] = []; - const computeClient = fakeComputeClient({ calls }); - const state = fakeState(); - - expect(await ensureRouterCurrent({ computeClient, state })).toBe(true); - expect(state.routerVersion).toBe(ROUTER_VERSION); - expect(calls.map((c) => c.path)).toEqual([ - "/compute/script/{id}/code", - "/compute/script/{id}/publish", - ]); - - // Already current: no calls at all. - const noCalls: Call[] = []; - expect( - await ensureRouterCurrent({ - computeClient: fakeComputeClient({ calls: noCalls }), - state, - }), - ).toBe(false); - expect(noCalls).toHaveLength(0); +test("requireRulesSite rejects router-era sites", () => { + expect(() => requireRulesSite(fakeState({ scriptId: 20 }))).toThrow( + "retired router architecture", + ); + expect(() => requireRulesSite(fakeState())).not.toThrow(); }); test("siteContextFromZone is null for a zone without site state", async () => { @@ -913,7 +898,7 @@ test("deleteSiteResources removes the site marker when keeping storage", async ( expect(store.has("deploys/aaa/index.html")).toBe(true); }); -test("deleteSiteResources deletes the pull zone, router, and storage zone", async () => { +test("deleteSiteResources deletes the pull zone and storage zone, plus a router-era script", async () => { const coreCalls: Call[] = []; const computeCalls: Call[] = []; const coreClient = fakeCoreClient({ calls: coreCalls }); @@ -924,17 +909,19 @@ test("deleteSiteResources deletes the pull zone, router, and storage zone", asyn computeClient, state: fakeState(), }); + expect(computeCalls).toHaveLength(0); + expect(results.filter((r) => r.deleted)).toHaveLength(2); - const deletedPullZoneIds = coreCalls - .filter((c) => c.method === "DELETE" && c.path === "/pullzone/{id}") - .map((c) => (c.params as { path: { id: number } }).path.id); - expect(deletedPullZoneIds).toEqual([30]); + const routerEra = await deleteSiteResources({ + coreClient, + computeClient, + state: fakeState({ scriptId: 20 }), + }); const deletedScriptIds = computeCalls .filter((c) => c.method === "DELETE" && c.path === "/compute/script/{id}") .map((c) => (c.params as { path: { id: number } }).path.id); expect(deletedScriptIds).toEqual([20]); - // Pull zone + router script + storage zone. - expect(results.filter((r) => r.deleted)).toHaveLength(3); + expect(routerEra.filter((r) => r.deleted)).toHaveLength(3); }); // Regression: the live API returns GET /pullzone as a paginated envelope @@ -943,11 +930,9 @@ test("deleteSiteResources deletes the pull zone, router, and storage zone", asyn test("createSite handles the paginated /pullzone envelope", async () => { const coreClient = fakeCoreClient({ calls: [], pullZoneEnvelope: true }); - const computeClient = fakeComputeClient({ calls: [] }); const result = await createSite({ coreClient, - computeClient, name: "my-site", region: "DE", }); @@ -962,11 +947,10 @@ test("fetchSites pages through the /pullzone envelope", async () => { calls: [], storageZones: [ZONE], pullZones: [ - { Id: 31, Name: "not-a-site", StorageZoneId: 10 }, + { Id: 31, Name: "not-a-site" }, { Id: 30, Name: "my-site", - MiddlewareScriptId: 20, StorageZoneId: 10, Hostnames: [{ IsSystemHostname: true, Value: "my-site.b-cdn.net" }], }, diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index bf70c4a0..ef59a673 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,6 +1,14 @@ import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; import { mapWithConcurrency } from "../../core/concurrency.ts"; +import { + type EdgeRule, + EdgeRuleAction, + EdgeRuleMatch, + EdgeRuleTriggerType, + fetchEdgeRules, + upsertEdgeRule, +} from "../../core/edge-rules.ts"; import { ApiError, errorMessage, UserError } from "../../core/errors.ts"; import { createPullZone, @@ -8,8 +16,6 @@ import { systemHostname, } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { fetchScripts } from "../scripts/api.ts"; -import { SCRIPT_TYPE_MIDDLEWARE } from "../scripts/constants.ts"; import { type CoreClient, fetchStorageZone, @@ -29,17 +35,25 @@ import { uploadFile, } from "../storage/files-api.ts"; import { - CURRENT_DEPLOY_VAR, + ASSET_BROWSER_TTL_SECONDS, + ASSET_EXTENSION_GROUPS, + ASSETS_RULE_DESC, + DEPLOY_HEADER, + DEPLOYS_DIR, deployPrefix, + GATE_RULE_DESC, + HOP_HEADER, + PLACEHOLDER_DEPLOY, parseRemoteState, REMOTE_STATE_PATH, + REWRITE_RULE_DESC, type RemoteSiteState, - routerScriptName, + randomHopSecret, + STATE_RULE_DESC, STATE_VERSION, siteResourcePattern, suffixedResourceName, } from "./constants.ts"; -import { ROUTER_VERSION, routerSource } from "./router/source.ts"; export type ComputeClient = ReturnType; type PullZone = components["schemas"]["PullZoneModel"]; @@ -208,10 +222,10 @@ async function fetchPullZones( } } -// Discover sites: a pull zone listing narrows to storage+middleware candidates, and only those get the per-zone `_bunny/site.json` read. A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. +// Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one. export async function fetchSites(client: CoreClient): Promise { const candidates = (await fetchPullZones(client)).filter( - (pz: PullZone) => pz.MiddlewareScriptId != null && pz.StorageZoneId != null, + (pz: PullZone) => pz.StorageZoneId != null, ); const summaries = await mapWithConcurrency( @@ -277,9 +291,137 @@ function isNameTaken(err: unknown): boolean { ); } +const NO_DEPLOYS_PAGE = ` +No deploys yet + +

Nothing here yet 🐇

+

Run bunny sites deploy to publish this site.

+`; + +// Edge caches everything (purged on publish); browsers revalidate everything (max-age=0) except what the assets rule overrides. +const SITE_CACHE_SETTINGS = { + CacheControlMaxAgeOverride: 2592000, + CacheControlPublicMaxAgeOverride: 0, +}; + +// The four rules that serve a site; bodies are always rebuilt in full from these so a hand-edited rule heals on the next upsert. +function siteRules( + systemHost: string, + secret: string, + deployId: string, +): Array { + const deploysPattern = `*/${DEPLOYS_DIR}/*`; + return [ + { + Description: REWRITE_RULE_DESC, + Enabled: true, + ActionType: EdgeRuleAction.OriginUrl, + // The origin is the zone's own hostname: the request re-enters the CDN, where the gate rule admits it by the hop header. + ActionParameter1: `https://${systemHost}/${deployPrefix(deployId)}%{Url.Path}`, + ExtraActions: [ + { + ActionType: EdgeRuleAction.SetRequestHeader, + ActionParameter1: HOP_HEADER, + ActionParameter2: secret, + }, + { + ActionType: EdgeRuleAction.SetResponseHeader, + ActionParameter1: DEPLOY_HEADER, + ActionParameter2: deployId, + }, + ], + TriggerMatchingType: EdgeRuleMatch.Any, + Triggers: [ + { + Type: EdgeRuleTriggerType.Url, + PatternMatches: [deploysPattern], + PatternMatchingType: EdgeRuleMatch.None, + }, + ], + }, + { + Description: GATE_RULE_DESC, + Enabled: true, + ActionType: EdgeRuleAction.BlockRequest, + TriggerMatchingType: EdgeRuleMatch.All, + Triggers: [ + { + Type: EdgeRuleTriggerType.Url, + PatternMatches: [deploysPattern], + PatternMatchingType: EdgeRuleMatch.Any, + }, + { + Type: EdgeRuleTriggerType.RequestHeader, + Parameter1: HOP_HEADER, + PatternMatches: [secret], + PatternMatchingType: EdgeRuleMatch.None, + }, + ], + }, + { + Description: STATE_RULE_DESC, + Enabled: true, + ActionType: EdgeRuleAction.BlockRequest, + TriggerMatchingType: EdgeRuleMatch.Any, + Triggers: [ + { + Type: EdgeRuleTriggerType.Url, + PatternMatches: ["*/_bunny/*"], + PatternMatchingType: EdgeRuleMatch.Any, + }, + ], + }, + ...ASSET_EXTENSION_GROUPS.map((extensions, i) => ({ + Description: `${ASSETS_RULE_DESC} (${i + 1})`, + Enabled: true, + ActionType: EdgeRuleAction.OverrideBrowserCacheTime, + ActionParameter1: String(ASSET_BROWSER_TTL_SECONDS), + TriggerMatchingType: EdgeRuleMatch.Any, + Triggers: [ + { + Type: EdgeRuleTriggerType.UrlExtension, + PatternMatches: [...extensions], + PatternMatchingType: EdgeRuleMatch.Any, + }, + ], + })), + ]; +} + +// The secret lives only in the rules; recover it so re-runs and promotes never mint a second one (a mismatched gate would block the hop). +function recoverHopSecret(rules: EdgeRule[]): string | undefined { + const rewrite = rules.find((r) => r.Description === REWRITE_RULE_DESC); + const fromRewrite = rewrite?.ExtraActions?.find( + (a) => + a.ActionType === EdgeRuleAction.SetRequestHeader && + a.ActionParameter1 === HOP_HEADER, + )?.ActionParameter2; + if (fromRewrite) return fromRewrite; + const gate = rules.find((r) => r.Description === GATE_RULE_DESC); + return gate?.Triggers?.find( + (t) => + t.Type === EdgeRuleTriggerType.RequestHeader && + t.Parameter1 === HOP_HEADER, + )?.PatternMatches?.[0]; +} + +// Converge the pull zone's rules on `deployId`; create and promote both funnel through here, so a missing or stale rule heals on any run. +export async function ensureSiteRules(opts: { + coreClient: CoreClient; + pullZoneId: number; + systemHost: string; + deployId: string; +}): Promise { + const { coreClient, pullZoneId, systemHost, deployId } = opts; + const existing = await fetchEdgeRules(coreClient, pullZoneId); + const secret = recoverHopSecret(existing) ?? randomHopSecret(); + for (const rule of siteRules(systemHost, secret, deployId)) { + await upsertEdgeRule(coreClient, pullZoneId, rule, existing); + } +} + export interface CreateSiteOptions { coreClient: CoreClient; - computeClient: ComputeClient; name: string; /** Explicitly requested region; a fresh zone falls back to DE, and a resumed zone must already be in it. */ region?: string; @@ -292,16 +434,16 @@ export interface CreateSiteResult { state: RemoteSiteState; storageZone: StorageZoneModel; systemHostname?: string; - reused: { storageZone: boolean; script: boolean; pullZone: boolean }; + reused: { storageZone: boolean; pullZone: boolean }; } -// Provision a site (storage zone -> router script -> pull zone + router -> state); each step looks up by name first so a half-finished create re-runs cleanly, and a zone already carrying state is never re-provisioned. +// Provision a site (storage zone -> placeholder -> pull zone -> cache settings + edge rules -> state); each step looks up by name first so a half-finished create re-runs cleanly, and a zone already carrying state is never re-provisioned. export async function createSite( opts: CreateSiteOptions, ): Promise { - const { coreClient, computeClient, name, region, tier } = opts; + const { coreClient, name, region, tier } = opts; const step = opts.onStep ?? (() => {}); - const reused = { storageZone: false, script: false, pullZone: false }; + const reused = { storageZone: false, pullZone: false }; // 1. Storage zone; the site's identity. // A stateless name-pattern match is a half-finished create to resume; one carrying this site's state already is the site. @@ -367,47 +509,10 @@ export async function createSite( throw new UserError(`Storage zone "${name}" has no ID.`); } - // 2. Router script (middleware); code/publish/env-var are idempotent, so they always run and a resumed create converges. - // Named after the zone so a resume finds it. - step("Creating router script..."); - const resourceName = storageZone.Name ?? name; - const scriptName = routerScriptName(resourceName); - let scriptId = (await fetchScripts(computeClient)).find( - (s) => s.Name === scriptName, - )?.Id; - if (scriptId != null) { - reused.script = true; - } else { - const { data: script } = await computeClient.POST("/compute/script", { - body: { - Name: scriptName, - ScriptType: SCRIPT_TYPE_MIDDLEWARE, - CreateLinkedPullZone: false, - }, - }); - if (script?.Id == null) { - throw new UserError(`Failed to create router script "${scriptName}".`); - } - scriptId = script.Id; - } - - step("Publishing router..."); - await computeClient.POST("/compute/script/{id}/code", { - params: { path: { id: scriptId } }, - body: { Code: routerSource }, - }); - await computeClient.POST("/compute/script/{id}/publish", { - params: { path: { id: scriptId, uuid: null } }, - body: {}, - }); - await computeClient.PUT("/compute/script/{id}/variables", { - params: { path: { id: scriptId } }, - body: { Name: CURRENT_DEPLOY_VAR, DefaultValue: "" }, - }); - - // 3. Pull zone with the storage origin, router attached. - // Namd like the storage zone; a fresh suffix on collision keeps the create moving(nothing keys on the names matching). + // 2. Pull zone with the storage origin. + // Named like the storage zone; a fresh suffix on collision keeps the create moving (nothing keys on the names matching). step("Creating pull zone..."); + const resourceName = storageZone.Name ?? name; let pullZone = await findSitePullZone(coreClient, name, storageZoneId); if (pullZone) { reused.pullZone = true; @@ -415,14 +520,10 @@ export async function createSite( let pullZoneName = resourceName; for (let attempt = 0; !pullZone && attempt < 3; attempt++) { try { - // Router attached at creation time: an unrouted zone is already public and would serve the raw storage origin. pullZone = await createPullZone( coreClient, pullZoneName, storageZoneId, - { - middlewareScriptId: scriptId, - }, ); } catch (err) { if (!isNameTaken(err)) throw err; @@ -439,35 +540,51 @@ export async function createSite( if (pullZone.Id == null) { throw new UserError(`Pull zone "${name}" has no ID.`); } + + // 3. Cache settings + edge rules, immediately after the zone exists: an unruled zone serves the raw storage origin. + step("Configuring edge rules..."); + const systemHost = systemHostname(pullZone.Hostnames); + if (!systemHost) { + throw new UserError( + `Pull zone "${resourceName}" has no system hostname.`, + "Re-run the command to finish provisioning.", + ); + } await coreClient.POST("/pullzone/{id}", { params: { path: { id: pullZone.Id } }, - body: { MiddlewareScriptId: scriptId }, + body: SITE_CACHE_SETTINGS, + }); + await ensureSiteRules({ + coreClient, + pullZoneId: pullZone.Id, + systemHost, + deployId: PLACEHOLDER_DEPLOY, }); // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. - const systemHost = systemHostname(pullZone.Hostnames); - if (systemHost) { - try { - await setForceSsl(coreClient, pullZone.Id, systemHost, true); - } catch (err) { - logger.warn( - `Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`, - ); - } + try { + await setForceSsl(coreClient, pullZone.Id, systemHost, true); + } catch (err) { + logger.warn(`Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`); } - // 4. Remote state; from here on the zone identifies as a site. + // 4. The no-deploys page behind the initial rewrite target, then remote state; a fresh storage zone can briefly refuse writes, so the first upload sits late in the create rather than racing zone readiness. + step("Uploading placeholder..."); + const connection = siteFiles.connect(storageZone); + await siteFiles.upload( + connection, + `${deployPrefix(PLACEHOLDER_DEPLOY)}/index.html`, + textStream(NO_DEPLOYS_PAGE), + ); + step("Writing site state..."); const state: RemoteSiteState = { version: STATE_VERSION, name, storageZoneId, pullZoneId: pullZone.Id, - scriptId, - routerVersion: ROUTER_VERSION, deploys: [], }; - const connection = siteFiles.connect(storageZone); await writeRemoteState(connection, state); return { @@ -493,95 +610,95 @@ export async function fetchSystemHostname( } } -// Republish the site's router when its recorded source generation lags the CLI's. Mutates state.routerVersion; the caller's next state write persists it, and a missed write just re-runs this next time. -export async function ensureRouterCurrent(opts: { - computeClient: ComputeClient; - state: RemoteSiteState; -}): Promise { - const { computeClient, state } = opts; - // Only upgrade: a site touched by a newer CLI must not be downgraded to this binary's older source (e.g. a pinned CI action racing a newer local CLI). - if ((state.routerVersion ?? 0) >= ROUTER_VERSION) return false; - await computeClient.POST("/compute/script/{id}/code", { - params: { path: { id: state.scriptId } }, - body: { Code: routerSource }, - }); - await computeClient.POST("/compute/script/{id}/publish", { - params: { path: { id: state.scriptId, uuid: null } }, - body: {}, - }); - state.routerVersion = ROUTER_VERSION; - return true; +// Router-era sites (state version 1) predate the edge-rule architecture; there is no in-place migration. +export function requireRulesSite(state: RemoteSiteState): void { + if (state.scriptId == null) return; + throw new UserError( + `Site "${state.name}" was created with the retired router architecture.`, + `Delete it (\`bunny sites delete ${state.name}\`) and create it again to deploy with this CLI. The site keeps serving its current deploy until then.`, + ); } const PROBE_TIMEOUT_MS = 4000; const PROPAGATION_DEADLINE_MS = 20_000; const PROPAGATION_INTERVAL_MS = 1500; -const SETTLE_FLOOR_MS = 2500; +// Config syncs bundle in ~5s buckets, so the floor must outlast one bucket or the final purge can race a lagging node. +const SETTLE_FLOOR_MS = 7500; export const promoteVerification = { - /** Probe the live site through the CDN; resolves to the HTTP status code. */ - probe: async (url: string): Promise => { + /** Probe the live site through the CDN; resolves to the status and the serving deploy id. */ + probe: async ( + url: string, + ): Promise<{ status: number; deploy: string | null }> => { const res = await fetch(url, { cache: "no-store", redirect: "manual", signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); - return res.status; + return { + status: res.status, + deploy: res.headers.get(DEPLOY_HEADER), + }; }, wait: (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)), }; -// Wait until the edge serves a real deploy: the router's "no deploys yet" 404 means CURRENT_DEPLOY is unset/unpropagated, so any non-404 means it landed; best-effort (skip probing when the host can't be resolved). +// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header. async function waitForEdgePropagation( - coreClient: CoreClient, - state: RemoteSiteState, + host: string, deployId: string, ): Promise { - const host = await fetchSystemHostname(coreClient, state.pullZoneId); const start = Date.now(); - if (host) { - const deadline = start + PROPAGATION_DEADLINE_MS; - let attempt = 0; - while (Date.now() < deadline) { - try { - // A unique query per attempt keeps each probe out of the CDN cache so a stale placeholder can't mask a propagated deploy. - const status = await promoteVerification.probe( - `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, - ); - if (status !== 404) break; - } catch { - // Edge briefly unreachable (DNS/warmup); keep trying until the deadline. - } - await promoteVerification.wait(PROPAGATION_INTERVAL_MS); + const deadline = start + PROPAGATION_DEADLINE_MS; + let attempt = 0; + while (Date.now() < deadline) { + try { + // A unique query per attempt keeps each probe out of the CDN cache so a stale entry can't mask a propagated rule. + const { deploy } = await promoteVerification.probe( + `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, + ); + if (deploy === deployId) break; + } catch { + // Edge briefly unreachable (DNS/warmup); keep trying until the deadline. } + await promoteVerification.wait(PROPAGATION_INTERVAL_MS); } - // Let the env var reach every node before the follow-up purge, so re-promotes don't re-cache the outgoing deploy's assets. + // Let the rule reach every node before the follow-up purge, so re-promotes don't re-cache the outgoing deploy's files. const elapsed = Date.now() - start; if (elapsed < SETTLE_FLOOR_MS) { await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed); } } -// Point production at a deploy: set CURRENT_DEPLOY (no republish) and purge. Since the env var propagates async, we purge, wait for the edge to serve it, then purge again so nothing stale survives. +// Point production at a deploy: retarget the rewrite rule and purge. The rule propagates async, so purge, wait for the edge to serve it, then purge again so nothing stale survives (the header can confirm on a cached response, which is why the second purge is load-bearing). export async function promoteDeploy(opts: { - computeClient: ComputeClient; coreClient: CoreClient; state: RemoteSiteState; deployId: string; }): Promise { + const { coreClient, state, deployId } = opts; const purge = () => - opts.coreClient.POST("/pullzone/{id}/purgeCache", { - params: { path: { id: opts.state.pullZoneId } }, + coreClient.POST("/pullzone/{id}/purgeCache", { + params: { path: { id: state.pullZoneId } }, body: {}, }); - await opts.computeClient.PUT("/compute/script/{id}/variables", { - params: { path: { id: opts.state.scriptId } }, - body: { Name: CURRENT_DEPLOY_VAR, DefaultValue: opts.deployId }, + const host = await fetchSystemHostname(coreClient, state.pullZoneId); + if (!host) { + throw new UserError( + "Couldn't resolve the site's hostname to publish.", + "Re-run the command; the pull zone may still be provisioning.", + ); + } + await ensureSiteRules({ + coreClient, + pullZoneId: state.pullZoneId, + systemHost: host, + deployId, }); await purge(); - await waitForEdgePropagation(opts.coreClient, opts.state, opts.deployId); + await waitForEdgePropagation(host, deployId); await purge(); } @@ -622,11 +739,15 @@ export async function deleteSiteResources(opts: { params: { path: { id: state.pullZoneId } }, }), ); - await attempt("router script", state.scriptId, () => - computeClient.DELETE("/compute/script/{id}", { - params: { path: { id: state.scriptId } }, - }), - ); + // Router-era sites (state version 1) still carry a script to clean up. + const scriptId = state.scriptId; + if (scriptId != null) { + await attempt("router script", scriptId, () => + computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: scriptId } }, + }), + ); + } if (opts.keepStorage) { // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone and router are gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures. if (opts.connection && results.every((r) => r.deleted)) { diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 101b3c98..7e2b6780 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -10,16 +10,18 @@ import { } from "./constants.ts"; const validState: RemoteSiteState = { - version: 1, + version: 2, name: "my-site", storageZoneId: 1, pullZoneId: 2, - scriptId: 3, deploys: [], }; test("parseRemoteState round-trips a valid state", () => { expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); + // Router-era (version 1) states still parse, keyed by their scriptId. + const routerEra = { ...validState, version: 1, scriptId: 3 }; + expect(parseRemoteState(JSON.stringify(routerEra))).toEqual(routerEra); }); test("parseRemoteState rejects garbage", () => { @@ -35,6 +37,10 @@ test("parseRemoteState rejects garbage", () => { expect( parseRemoteState(JSON.stringify({ ...validState, deploys: {} })), ).toBeNull(); + // A future format is rejected rather than misread + expect( + parseRemoteState(JSON.stringify({ ...validState, version: 3 })), + ).toBeNull(); // A tampered name that isn't a legal zone name is rejected outright, so it // can't reach storage paths or generated CI YAML. expect( diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 0728e13e..93f0e80f 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -1,16 +1,14 @@ // `.bunny/site.json` is written by `bunny sites link`/`create` and resolved by sites commands. export const SITES_MANIFEST = "site.json"; -// Site state path; everything under `_bunny/` is router-blocked (403) so state is never served. +// Site state path; everything under `_bunny/` is blocked by an edge rule so state is never served. export const REMOTE_STATE_PATH = "_bunny/site.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; -// Router env var selecting the production deploy; updating it is the promote/rollback lever (no republish). -export const CURRENT_DEPLOY_VAR = "CURRENT_DEPLOY"; - -export const STATE_VERSION = 1; +// Version 2 dropped the router script; version-1 states (router-era) still parse so delete/list keep working on them. +export const STATE_VERSION = 2; export const DEFAULT_KEEP_DEPLOYS = 5; @@ -32,17 +30,16 @@ export interface DeployRecord { bytes: number; } -// Source of truth (at `_bunny/site.json`) for a site's resource triple and deploys; `.bunny/site.json` is just a local pointer to it. +// Source of truth (at `_bunny/site.json`) for a site's resource pair and deploys; `.bunny/site.json` is just a local pointer to it. export interface RemoteSiteState { version: number; name: string; storageZoneId: number; pullZoneId: number; - scriptId: number; + /** Router-era (version 1) sites only; presence marks a site the retired script architecture serves. */ + scriptId?: number; /** Custom production domain, when one has been attached. */ domain?: string; - /** The router source generation last published to the script; deploy republishes when it lags ROUTER_VERSION. */ - routerVersion?: number; current?: string; previous?: string; deploys: DeployRecord[]; @@ -76,12 +73,34 @@ export function pruneVictims( .filter((d) => d.id !== current && d.id !== previous); } -/** Router script name for a site; namespaced so `sites create` can find it on re-run. */ -export function routerScriptName(siteName: string): string { - return `${siteName}-router`; +// The underscore keeps it outside the deploy-id alphabet, so it can never collide with a real deploy. +export const PLACEHOLDER_DEPLOY = "_placeholder"; + +export const HOP_HEADER = "X-Bunny-Site-Hop"; +export const DEPLOY_HEADER = "X-Bunny-Deploy"; + +// Edge rules are identified by these descriptions; upserts key on them, so treat them as frozen. +export const REWRITE_RULE_DESC = "bunny sites: serve the published deploy"; +export const GATE_RULE_DESC = "bunny sites: block direct deploy access"; +export const STATE_RULE_DESC = "bunny sites: block site state access"; +export const ASSETS_RULE_DESC = "bunny sites: browser-cache static assets"; + +// Browser-cache TTL for static assets (1 day); HTML stays at the zone-level max-age=0 so new deploys show immediately. +export const ASSET_BROWSER_TTL_SECONDS = 86400; + +// The API caps a condition at 5 patterns, so extensions ship as one rule per group; anything uncovered just revalidates against the edge cache. +export const ASSET_EXTENSION_GROUPS = [ + ["css", "js", "mjs", "woff2", "svg"], + ["png", "jpg", "jpeg", "webp", "ico"], +]; + +/** Per-site secret authenticating the rewrite rule's internal hop to the deploy files. */ +export function randomHopSecret(): string { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } -// Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the router regex and storage paths rely on this. +// Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the edge rules and storage paths rely on this. const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/; export function isValidDeployId(id: string): boolean { @@ -135,12 +154,14 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { const s = data as Record; if ( typeof s.version !== "number" || + // A future format is rejected rather than misread; older CLIs lacked this bound, which is why version 2 couldn't rely on it. + s.version > STATE_VERSION || typeof s.name !== "string" || // Reject an illegal name: it would flow unquoted into storage paths and generated CI YAML. !isValidSiteName(s.name) || typeof s.storageZoneId !== "number" || typeof s.pullZoneId !== "number" || - typeof s.scriptId !== "number" || + (s.scriptId !== undefined && typeof s.scriptId !== "number") || !Array.isArray(s.deploys) ) { return null; diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 3028d584..a18d77e4 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -1,7 +1,4 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; +import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -65,7 +62,7 @@ async function attachDomainToCreatedSite(opts: { } } -// Create a static site: a storage zone (files), a pull zone (CDN), and a middleware router script mapping hosts to deploy dirs; state lives at `_bunny/site.json` in the storage zone. +// Create a static site: a storage zone (files) and a pull zone (CDN) whose edge rules serve the published deploy dir; state lives at `_bunny/site.json` in the storage zone. export const sitesCreateCommand = defineCommand({ command: "create [name]", describe: "Create a new static site.", @@ -139,11 +136,9 @@ export const sitesCreateCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); const result = await createSiteWithProgress({ coreClient, - computeClient, name, region: args.region, tier: args.tier, @@ -175,7 +170,6 @@ export const sitesCreateCommand = defineCommand({ name, storageZoneId: result.state.storageZoneId, pullZoneId: result.state.pullZoneId, - scriptId: result.state.scriptId, hostname: result.systemHostname ?? null, tier: zoneTierChoice(result.storageZone), domain: domain ?? null, @@ -201,7 +195,6 @@ export const sitesCreateCommand = defineCommand({ value: zoneTierLabel(result.storageZone, "long"), }, { key: "Pull zone", value: String(result.state.pullZoneId) }, - { key: "Router script", value: String(result.state.scriptId) }, ...(result.systemHostname ? [{ key: "URL", value: `https://${result.systemHostname}` }] : []), diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 0f2dc33a..0dee9666 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -26,7 +26,7 @@ interface DeleteArgs extends SiteSelectorArgs { "keep-storage"?: boolean; } -// Delete a site: its pull zone, router script, and (unless --keep-storage) the storage zone with every deploy; requires typing the name to confirm unless --force. +// Delete a site: its pull zone and (unless --keep-storage) the storage zone with every deploy; router-era sites also lose their script; requires typing the name to confirm unless --force. export const sitesDeleteCommand = defineCommand({ command: "delete [site]", describe: "Delete a site and its resources.", @@ -50,7 +50,7 @@ export const sitesDeleteCommand = defineCommand({ .option("keep-storage", { type: "boolean", default: false, - describe: "Delete the pull zone and router but keep the storage zone", + describe: "Delete the pull zone but keep the storage zone", }), handler: async (args) => { @@ -69,8 +69,8 @@ export const sitesDeleteCommand = defineCommand({ const { state } = site; const what = args["keep-storage"] - ? "its pull zone and router" - : "its pull zone, router, and ALL deploy files"; + ? "its pull zone" + : "its pull zone and ALL deploy files"; requireConfirmable(output, { force, message: `Deleting "${state.name}" needs a confirmation prompt.`, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 2609516e..0c80f10e 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -1,9 +1,6 @@ import { existsSync, statSync } from "node:fs"; import { resolve } from "node:path"; -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; +import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -14,9 +11,9 @@ import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; import { - ensureRouterCurrent, fetchSystemHostname, promoteDeploy, + requireRulesSite, writeRemoteState, } from "./api.ts"; import { @@ -142,7 +139,6 @@ export const sitesDeployCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); // No `force` here: deploy's --force only redeploys unchanged content, so the picker stays. const { site, offerLink } = await selectSite(coreClient, { @@ -151,28 +147,17 @@ export const sitesDeployCommand = defineCommand({ output, offerCreate: async () => { const name = await promptSiteName(undefined, true); - return createLinkedSite({ coreClient, computeClient, name }); + return createLinkedSite({ coreClient, name }); }, }); const { state, connection } = site; + requireRulesSite(state); // The site's first-ever deploy is the one moment we offer a custom domain; declining self-limits, since the list is never empty again. const firstDeploy = state.deploys.length === 0; let etag = site.etag; - // Republish an outdated router before deploying, so this deploy is served by the current source (state.routerVersion persists with this deploy's writes, including no-op runs, so it doesn't republish every time). A failure isn't fatal: the old router still resolves CURRENT_DEPLOY. - let routerUpgraded = false; - try { - routerUpgraded = await ensureRouterCurrent({ computeClient, state }); - if (routerUpgraded && output !== "json") { - logger.info("Republished the site's router."); - } - } catch (err) { - logger.warn(`Couldn't update the site's router: ${errorMessage(err)}`); - logger.dim(" Retry with `bunny sites upgrade-router`."); - } - let autoDir: string | undefined; if (requestedBuild) { if (requestedBuild.label) @@ -240,11 +225,7 @@ export const sitesDeployCommand = defineCommand({ : await fetchSystemHostname(coreClient, state.pullZoneId); const production = productionUrl(state, systemHost); - // Nothing to upload and it's already live: still persist a router upgrade so re-runs converge. if (skipUpload && alreadyLive) { - if (routerUpgraded) { - etag = await writeRemoteState(connection, state, etag); - } if (output === "json") { logger.log( JSON.stringify( @@ -298,12 +279,7 @@ export const sitesDeployCommand = defineCommand({ } await withSpinner("Publishing to production...", async () => { - await promoteDeploy({ - computeClient, - coreClient, - state, - deployId, - }); + await promoteDeploy({ coreClient, state, deployId }); markCurrent(state, deployId); etag = await writeRemoteState(connection, state, etag, { promotedTo: deployId, diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index a15477a3..272be151 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -1,14 +1,11 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; +import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../../config/index.ts"; import { clientOptions } from "../../../core/client-options.ts"; import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; -import { promoteDeploy, writeRemoteState } from "../api.ts"; +import { promoteDeploy, requireRulesSite, writeRemoteState } from "../api.ts"; import { markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, @@ -23,7 +20,7 @@ interface PublishArgs extends SiteSelectorArgs { force?: boolean; } -// Publish (promote) a past deploy as production: flips the router's env var and purges the cache, no files move (instant rollback). +// Publish (promote) a past deploy as production: retargets the rewrite rule and purges the cache, no files move (instant rollback). export const sitesDeploymentsPublishCommand = defineCommand({ command: "publish [id]", aliases: ["promote"], @@ -58,7 +55,6 @@ export const sitesDeploymentsPublishCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); const { site, offerLink } = await selectSite(coreClient, { site: args.site, @@ -67,6 +63,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ force: args.force, }); const { state, connection, etag } = site; + requireRulesSite(state); let targetId = args.id; if (args.previous) { @@ -131,12 +128,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ } await withSpinner("Publishing...", async () => { - await promoteDeploy({ - computeClient, - coreClient, - state, - deployId: targetId, - }); + await promoteDeploy({ coreClient, state, deployId: targetId }); markCurrent(state, targetId); await writeRemoteState(connection, state, etag, { promotedTo: targetId, diff --git a/packages/cli/src/commands/sites/domains/index.test.ts b/packages/cli/src/commands/sites/domains/index.test.ts index 285de671..93d0d6c5 100644 --- a/packages/cli/src/commands/sites/domains/index.test.ts +++ b/packages/cli/src/commands/sites/domains/index.test.ts @@ -39,7 +39,6 @@ function fakeSite(): SiteContext { name: "my-site", storageZoneId: 10, pullZoneId: 30, - scriptId: 20, deploys: [], }, etag: "etag", diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index b9d574b6..31e695c9 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -112,7 +112,7 @@ export const sitesDomainsCommands = createHostnamesCommands({ if (resolvedSite && !resolvedSite.state.domain) { await recordSiteDomain(resolvedSite, hostname); } - // A domain on a site with nothing published serves the router's 404; say so instead of letting the first visit read as breakage. + // A domain on a site with nothing published serves the no-deploys page; say so instead of letting the first visit read as breakage. if (args.output !== "json" && resolvedSite?.state.current === undefined) { logger.dim( " Nothing is published yet, so this domain serves a 404: publish with `bunny sites deploy`.", diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts index 7143f0ea..c24e3536 100644 --- a/packages/cli/src/commands/sites/index.ts +++ b/packages/cli/src/commands/sites/index.ts @@ -11,7 +11,6 @@ import { sitesOpenCommand } from "./open.ts"; import { sitesShowCommand } from "./show.ts"; import { sitesSslCommand } from "./ssl.ts"; import { sitesUnlinkCommand } from "./unlink.ts"; -import { sitesUpgradeRouterCommand } from "./upgrade-router.ts"; export const sitesNamespace = defineNamespace("sites", false, [ sitesCreateCommand, @@ -25,6 +24,5 @@ export const sitesNamespace = defineNamespace("sites", false, [ sitesCiNamespace, sitesLinkCommand, sitesUnlinkCommand, - sitesUpgradeRouterCommand, sitesDeleteCommand, ]); diff --git a/packages/cli/src/commands/sites/list.ts b/packages/cli/src/commands/sites/list.ts index 714d5b08..f38a2bf6 100644 --- a/packages/cli/src/commands/sites/list.ts +++ b/packages/cli/src/commands/sites/list.ts @@ -31,7 +31,6 @@ export const sitesListCommand = defineCommand({ name: s.state.name, storageZoneId: s.state.storageZoneId, pullZoneId: s.state.pullZoneId, - scriptId: s.state.scriptId, domain: s.state.domain ?? null, hostname: s.systemHostname ?? null, current: s.state.current ?? null, diff --git a/packages/cli/src/commands/sites/open.test.ts b/packages/cli/src/commands/sites/open.test.ts index 09db539c..c02b4600 100644 --- a/packages/cli/src/commands/sites/open.test.ts +++ b/packages/cli/src/commands/sites/open.test.ts @@ -9,7 +9,6 @@ function state(overrides?: Partial): RemoteSiteState { name: "my-site", storageZoneId: 10, pullZoneId: 30, - scriptId: 20, deploys: [], ...overrides, }; diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 4f5cf3ac..806904d2 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -9,7 +9,6 @@ import { type ZoneTierChoice, } from "../storage/constants.ts"; import { - type ComputeClient, type CreateSiteResult, createSite, type SiteContext, @@ -84,7 +83,6 @@ export function resolveSiteRegion( /** Run {@link createSite} under a spinner whose text tracks each provisioning step. */ export async function createSiteWithProgress(opts: { coreClient: CoreClient; - computeClient: ComputeClient; name: string; region?: string; tier?: ZoneTierChoice; @@ -93,7 +91,6 @@ export async function createSiteWithProgress(opts: { return withSpinner(`Creating site "${opts.name}"...`, (spin) => createSite({ coreClient: opts.coreClient, - computeClient: opts.computeClient, name: opts.name, region, tier: opts.tier, @@ -106,7 +103,6 @@ export async function createSiteWithProgress(opts: { export async function createLinkedSite(opts: { coreClient: CoreClient; - computeClient: ComputeClient; name: string; region?: string; tier?: ZoneTierChoice; diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts deleted file mode 100644 index 066b342f..00000000 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { expect, test } from "bun:test"; -import { routerSource } from "./source.ts"; - -// Extracts a top-level function from the generated script and evaluates it, so tests run the shipped code rather than a mirror of it. -function extractFn(name: string): (...args: unknown[]) => unknown { - const match = routerSource.match( - new RegExp(`function ${name}\\([^]*?\\n\\}`), - ); - if (!match) throw new Error(`function ${name} not found in routerSource`); - return new Function(`return (${match[0]});`)() as ( - ...args: unknown[] - ) => unknown; -} - -const indexRetryUrl = extractFn("indexRetryUrl") as ( - rawUrl: string, - host: string, -) => string | null; - -const clientHostname = extractFn("clientHostname") as (request: { - url: string; - headers: Map; -}) => string; - -// A minimal Headers-alike; the router only calls headers.get(). -function req(url: string, headers: Record) { - return { url, headers: new Map(Object.entries(headers)) }; -} - -test("routerSource wires up the deploy routing", () => { - const src = routerSource; - expect(src).toContain("bunny sites router"); - // Every host serves the promoted deploy; there is no per-hostname routing. - expect(src).toContain("process.env.CURRENT_DEPLOY"); - expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); - // Directory URLs expand to index.html before any branching. - expect(src).toContain( - 'if (url.pathname.endsWith("/")) url.pathname += "index.html";', - ); - // Slashless 404s probe the directory index and redirect to the slash URL, after the exact lookup misses. - expect(src).toContain('const RETRY_HEADER = "x-bunny-index-retry";'); - expect(src).toContain("if (retry && ctx.response.status === 404)"); - expect(src).toContain("{ status: 301, headers: { Location: retry } }"); - // The client-sent flag must be stripped, or it'd poison cached HTML. - expect(src).toContain("headers.delete(RETRY_HEADER);"); - // Internal state is never served. - expect(src).toContain('path.startsWith("/_bunny/")'); -}); - -// The raw URL at the edge is an internal origin address; the retry target must be rebuilt on the client host so the probe re-enters the CDN and this router. -test("indexRetryUrl targets the directory index on the client host for slashless paths only", () => { - expect(indexRetryUrl("http://203.0.113.10:9000/blog", "x.b-cdn.net")).toBe( - "https://x.b-cdn.net/blog/", - ); - // No dot heuristic: dotted segments retry too, so dotted directories stay reachable. - expect( - indexRetryUrl("http://203.0.113.10:9000/v2.1/docs", "x.b-cdn.net"), - ).toBe("https://x.b-cdn.net/v2.1/docs/"); - // Query strings survive the retry. - expect( - indexRetryUrl("http://203.0.113.10:9000/blog?page=2", "x.b-cdn.net"), - ).toBe("https://x.b-cdn.net/blog/?page=2"); - // Directory and root URLs already expand to an index; no retry. - expect( - indexRetryUrl("http://203.0.113.10:9000/blog/", "x.b-cdn.net"), - ).toBeNull(); - expect(indexRetryUrl("http://203.0.113.10:9000/", "x.b-cdn.net")).toBeNull(); -}); - -// ctx.request.url at the edge is the origin-facing address (`http://:9000/...`), so the client host must come from the platform's CDN-Host header; the index-retry probe has to land back on the CDN, not the origin. -test("clientHostname prefers CDN-Host, then Host, then the URL", () => { - expect( - clientHostname( - req("http://203.0.113.10:9000/", { - "cdn-host": "SITE.B-CDN.NET", - host: "other.example", - }), - ), - ).toBe("site.b-cdn.net"); - expect( - clientHostname(req("http://203.0.113.10:9000/", { host: "x.b-cdn.net" })), - ).toBe("x.b-cdn.net"); - expect(clientHostname(req("https://fallback.example/", {}))).toBe( - "fallback.example", - ); -}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts deleted file mode 100644 index 43f2f1c0..00000000 --- a/packages/cli/src/commands/sites/router/source.ts +++ /dev/null @@ -1,75 +0,0 @@ -export const ROUTER_VERSION = 5; - -export const routerSource = `// bunny sites router v${ROUTER_VERSION}, generated by the bunny CLI. Do not edit: -// \`bunny sites upgrade-router\` overwrites this script. -import * as BunnySDK from "@bunny.net/edgescript-sdk"; - -const RETRY_HEADER = "x-bunny-index-retry"; - -// ctx.request.url carries the ORIGIN address at the edge, not the requested host; the platform passes the client hostname in CDN-Host (Host covers local harnesses). -function clientHostname(request) { - const fromHeader = - request.headers.get("cdn-host") ?? request.headers.get("host"); - return (fromHeader ?? new URL(request.url).hostname).toLowerCase(); -} - -const NO_DEPLOYS_PAGE = \` -No deploys yet - -

Nothing here yet 🐇

-

Run bunny sites deploy to publish this site.

\`; - -// A slashless URL's directory-index retry target (/blog -> /blog/) on the client host, so the probe re-enters the CDN (and this router) instead of hitting the storage origin's unrouted paths; null when the path already ends with a slash. -function indexRetryUrl(rawUrl, host) { - const u = new URL(rawUrl); - if (u.pathname.endsWith("/")) return null; - return "https://" + host + u.pathname + "/" + u.search; -} - -BunnySDK.net.http - .servePullZone() - .onOriginRequest(async (ctx) => { - const url = new URL(ctx.request.url); - const host = clientHostname(ctx.request); - // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route. - if (url.pathname.endsWith("/")) url.pathname += "index.html"; - const path = url.pathname; - - // Internal site metadata (state, env) is never served. - if (path === "/_bunny" || path.startsWith("/_bunny/")) { - return new Response("Forbidden", { status: 403 }); - } - - // The flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. - const headers = new Headers(ctx.request.headers); - headers.delete(RETRY_HEADER); - - // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. - if (ctx.request.method === "GET" || ctx.request.method === "HEAD") { - const retry = indexRetryUrl(ctx.request.url, host); - if (retry) headers.set(RETRY_HEADER, retry); - } - - const deploy = process.env.CURRENT_DEPLOY || ""; - - if (!deploy) { - return new Response(NO_DEPLOYS_PAGE, { - status: 404, - headers: { "Content-Type": "text/html; charset=utf-8" }, - }); - } - - url.pathname = "/deploys/" + deploy + path; - return new Request(new Request(url.toString(), ctx.request), { headers }); - }) - .onOriginResponse(async (ctx) => { - // A flagged 404 probes its directory index and redirects to the slash URL when it exists (/blog -> /blog/), so relative references resolve against the right base; the probe re-enters this router and, slash-terminated, can never retry further. - const retry = ctx.request.headers.get(RETRY_HEADER); - if (retry && ctx.response.status === 404) { - const probe = await fetch(retry, { method: "HEAD" }); - if (probe.ok) { - return new Response(null, { status: 301, headers: { Location: retry } }); - } - } - }); -`; diff --git a/packages/cli/src/commands/sites/show.ts b/packages/cli/src/commands/sites/show.ts index d0cca8d6..eef36e51 100644 --- a/packages/cli/src/commands/sites/show.ts +++ b/packages/cli/src/commands/sites/show.ts @@ -72,7 +72,6 @@ export const sitesShowCommand = defineCommand({ { key: "Site", value: state.name }, { key: "Storage zone", value: String(state.storageZoneId) }, { key: "Pull zone", value: String(state.pullZoneId) }, - { key: "Router script", value: String(state.scriptId) }, { key: "Domain", value: state.domain ?? "-" }, { key: "Current deploy", value: state.current ?? "-" }, { diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts deleted file mode 100644 index 36c050a8..00000000 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; -import { resolveConfig } from "../../config/index.ts"; -import { clientOptions } from "../../core/client-options.ts"; -import { defineCommand } from "../../core/define-command.ts"; -import { errorMessage } from "../../core/errors.ts"; -import { logger } from "../../core/logger.ts"; -import { withSpinner } from "../../core/ui.ts"; -import { writeRemoteState } from "./api.ts"; -import { - type SiteSelectorArgs, - selectSite, - siteLinkOption, - sitePositionalBuilder, -} from "./interactive.ts"; -import { ROUTER_VERSION, routerSource } from "./router/source.ts"; - -type UpgradeArgs = SiteSelectorArgs; - -// Republish the site's router script with the CLI's current source; deploys and env vars are untouched, only the router code changes. -export const sitesUpgradeRouterCommand = defineCommand({ - command: "upgrade-router [site]", - describe: "Republish a site's router script with the latest version.", - examples: [ - ["$0 sites upgrade-router", "Republish the linked site's router"], - ["$0 sites upgrade-router my-site", "Republish a specific site's router"], - ], - - builder: (yargs) => siteLinkOption(sitePositionalBuilder(yargs)), - - handler: async (args) => { - const { profile, output, verbose, apiKey } = args; - const config = resolveConfig(profile, apiKey, verbose); - const options = clientOptions(config, verbose); - const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); - - const { site, offerLink } = await selectSite(coreClient, { - site: args.site, - link: args.link, - output, - }); - const { state } = site; - - await withSpinner("Republishing router...", async () => { - await computeClient.POST("/compute/script/{id}/code", { - params: { path: { id: state.scriptId } }, - body: { Code: routerSource }, - }); - await computeClient.POST("/compute/script/{id}/publish", { - params: { path: { id: state.scriptId, uuid: null } }, - body: {}, - }); - }); - - // Record the published generation so deploy stops re-upgrading; best-effort (a missed write just republishes next deploy). - if (state.routerVersion !== ROUTER_VERSION) { - state.routerVersion = ROUTER_VERSION; - try { - site.etag = await writeRemoteState(site.connection, state, site.etag); - } catch (err) { - logger.warn(`Couldn't record the router version: ${errorMessage(err)}`); - } - } - - if (output === "json") { - logger.log( - JSON.stringify({ site: state.name, republished: true }, null, 2), - ); - return; - } - - logger.success("Router republished."); - - await offerLink(); - }, -}); diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index 1df2d1c7..c70236b8 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -120,21 +120,17 @@ export function liveHostnames(hostnames: Hostname[]): { // PullZoneOriginType: 2 = StorageZone. const ORIGIN_TYPE_STORAGE_ZONE = 2; -/** Create a pull zone served from a storage zone, with delivery enabled in every geo region. Pass `middlewareScriptId` to attach a router in the same call: a zone is publicly reachable the moment it exists, so attaching afterwards leaves a window where it serves the raw storage origin. */ +/** Create a pull zone served from a storage zone, with delivery enabled in every geo region. */ export async function createPullZone( client: CoreClient, name: string, storageZoneId: number, - opts?: { middlewareScriptId?: number }, ): Promise { const { data } = await client.POST("/pullzone", { body: { Name: name, StorageZoneId: storageZoneId, OriginType: ORIGIN_TYPE_STORAGE_ZONE, - ...(opts?.middlewareScriptId != null - ? { MiddlewareScriptId: opts.middlewareScriptId } - : {}), EnableGeoZoneUS: true, EnableGeoZoneEU: true, EnableGeoZoneASIA: true, From 6ede231cf2d720e51a90840c40d62c7d84157f07 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:26:28 +0100 Subject: [PATCH 3/9] docs(sites): document the edge-rule architecture --- .changeset/sites-edge-rules.md | 5 +++++ AGENTS.md | 6 ++++-- README.md | 2 +- skills/bunny-cli/references/sites.md | 7 +++---- 4 files changed, 13 insertions(+), 7 deletions(-) create mode 100644 .changeset/sites-edge-rules.md diff --git a/.changeset/sites-edge-rules.md b/.changeset/sites-edge-rules.md new file mode 100644 index 00000000..d0cd7a9e --- /dev/null +++ b/.changeset/sites-edge-rules.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": minor +--- + +Sites are now served by pull zone edge rules instead of a router Edge Script; HTML revalidates in browsers on every view and deploy dirs are blocked at the edge diff --git a/AGENTS.md b/AGENTS.md index 2e876a76..716dc633 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -464,8 +464,10 @@ Scripts, apps, and sites are each backed by a pull zone, which has a large setti Hard-won, not inferable from the code, and expensive to rediscover. - **Magic Containers requires `linux/amd64`.** Builds must pass `--platform linux/amd64`; an arm64 image (the default on Apple Silicon) breaks the pull. -- **At the edge, `ctx.request.url` is origin-facing** (`http://:9000/...`), not the requested host. The client hostname must come from the `CDN-Host`/`Host` headers. Hostname routing on `url.hostname` never fires. The sites router's index-retry probe must be rebuilt on the client host so it re-enters the CDN instead of hitting unrouted storage paths. -- **Bump `ROUTER_VERSION` on any change to the sites router source.** It is recorded in site state, and `ensureRouterCurrent` republishes stale routers on deploy. +- **At the edge, `ctx.request.url` is origin-facing** (`http://:9000/...`), not the requested host. The client hostname must come from the `CDN-Host`/`Host` headers. Hostname routing on `url.hostname` never fires. +- **Edge rule "Change Origin URL" replaces the URL wholesale** unless a variable like `%{Url.Path}` (full path + query) appends the request path. `SetRequestHeader`/`SetResponseHeader` extra actions on the same rule apply to that rule's origin fetch (platform-confirmed); `BlockRequest` runs pre-cache, so it applies to cache hits too. Sites builds on all three. +- **The pull zone delivery layer rewrites `Cache-Control`** from the zone's cache settings; origin- or script-set values reach browsers only when both `CacheControlMaxAgeOverride` and `CacheControlPublicMaxAgeOverride` are `-1`, and a `no-cache` the CDN respects also stops it storing the object. Sites sidesteps this with a zone-level override plus per-extension browser-cache rules. +- **Sites edge rules are identified by their `Description` strings** (constants in `sites/constants.ts`); upserts key on them, so treat them as frozen. - **The `/storagezone/regions` endpoint is not reliable.** The region catalog is hand-maintained in the storage constants. The available set is a function of both tier and S3 support: Edge (SSD) zones can only be primaried in DE and the create API silently rewrites any other region rather than erroring, so reject a conflicting `--region` client-side. - **Storage zone tier and S3 support are create-time only.** The update API takes neither. - **Storage replication is irreversible.** There is no API to remove a replication region, so `update` models replication as additive: it offers only new regions, warns on omissions, and confirms before adding. diff --git a/README.md b/README.md index f62160b8..916f5ef6 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ bun ny storage zones add my-zone --tier ssd --s3 # create an Edge (SSD) zone ( bun ny storage files list # list files in the linked storage zone bun ny storage files download # browse the zone and pick a file to download; same picker on `files remove` bun ny storage files remove / # empty the zone; asks twice (yes/no, then type the zone name), and unattended runs need --force -bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) +bun ny sites create my-site # provision a static site (storage zone + pull zone with edge rules; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) bun ny sites create my-site --tier ssd # provision a site whose files live on the Edge (SSD) storage tier (always DE) bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys (a site's first deploy also offers to attach a custom domain) bun ny sites deploy ./dist # deploy a directory and publish it as the live site diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 25611300..ba99be2f 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -1,6 +1,6 @@ # Static Sites Commands -All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) with edge rules routing requests to the published deploy, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back retargets an edge rule and purges the cache; no files move, so it's instant. Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: @@ -9,7 +9,7 @@ Most commands accept an optional site (a trailing `[site]` positional, or the `- 3. `sites.name` in `bunny.jsonc` 4. Interactive prompt (suppressed in `--output json` mode, and on destructive commands run with `--force`; pass a site or link the directory in CI) -Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `upgrade-router`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`, including under `--output json`. The other site commands never write the manifest and don't take the flag. +Commands that can link the directory (`deploy`, `show`, `deployments list/publish`, `ci init`) take `--link`/`--no-link`: the picker prompts unless the flag decided it, and an explicit `--link` also links a site resolved from a ref or from `bunny.jsonc`, including under `--output json`. The other site commands never write the manifest and don't take the flag. ## Typical workflows @@ -36,7 +36,7 @@ bunny sites domains add example.com --wait # production vanity hostname + SSL This is the rule that shapes every other command here: - Every `deploy` becomes the live site. There is no separate preview URL and no unpublished deploy. -- Deploys stay immutable under their own ID, so `deployments publish ` rolls back to any earlier one by flipping the router's pointer; no files move and nothing is re-uploaded. +- Deploys stay immutable under their own ID, so `deployments publish ` rolls back to any earlier one by retargeting the edge rule; no files move and nothing is re-uploaded. - Custom domains are vanity hostnames on the site's pull zone; without one the site serves at `https://sites--.b-cdn.net`. Content is root-served, so client-side routers (TanStack Router, React Router, Vue Router in history mode) and root-absolute assets work as-is. Deploys are not individually addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. To review a change before it goes live, build and serve it locally, or deploy it to a separate site. @@ -149,7 +149,6 @@ bunny sites open # open the live URL in the browser ( bunny sites ssl --no-force-ssl # toggle Force HTTPS on the site's b-cdn.net system host bunny sites link my-site # .bunny/site.json bunny sites unlink -bunny sites upgrade-router # republish the router with the CLI's current source bunny sites delete my-site # typed-name confirmation; --keep-storage keeps files ``` From d0b49eeb85232a8bbadd5c9f910c428cdb2f9aa0 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:39:58 +0100 Subject: [PATCH 4/9] feat(sites): drop the no-deploys placeholder page --- packages/cli/src/commands/sites/api.test.ts | 3 --- packages/cli/src/commands/sites/api.ts | 18 ++---------------- packages/cli/src/commands/sites/constants.ts | 2 +- 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 06c424a2..eb881e8e 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -456,9 +456,6 @@ test("createSite provisions storage zone → pull zone → edge rules → state" expect(zoneName).toMatch(/^sites-my-site-[a-z0-9]{6}$/); expect(result.systemHostname).toBe(`${zoneName}.b-cdn.net`); - // The no-deploys page backs the initial rewrite target. - expect(store.has(`deploys/${PLACEHOLDER_DEPLOY}/index.html`)).toBe(true); - // Exactly one pull zone (production) is created, plus the cache settings update. const pzCreates = coreCalls.filter( (c) => c.method === "POST" && c.path === "/pullzone", diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index ef59a673..50092164 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -291,13 +291,6 @@ function isNameTaken(err: unknown): boolean { ); } -const NO_DEPLOYS_PAGE = ` -No deploys yet - -

Nothing here yet 🐇

-

Run bunny sites deploy to publish this site.

-`; - // Edge caches everything (purged on publish); browsers revalidate everything (max-age=0) except what the assets rule overrides. const SITE_CACHE_SETTINGS = { CacheControlMaxAgeOverride: 2592000, @@ -568,16 +561,9 @@ export async function createSite( logger.warn(`Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`); } - // 4. The no-deploys page behind the initial rewrite target, then remote state; a fresh storage zone can briefly refuse writes, so the first upload sits late in the create rather than racing zone readiness. - step("Uploading placeholder..."); - const connection = siteFiles.connect(storageZone); - await siteFiles.upload( - connection, - `${deployPrefix(PLACEHOLDER_DEPLOY)}/index.html`, - textStream(NO_DEPLOYS_PAGE), - ); - + // 4. Remote state; from here on the zone identifies as a site. This is the first storage write on purpose: a fresh zone can briefly refuse writes, so it must not race zone creation. step("Writing site state..."); + const connection = siteFiles.connect(storageZone); const state: RemoteSiteState = { version: STATE_VERSION, name, diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 93f0e80f..e301d1e7 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -73,7 +73,7 @@ export function pruneVictims( .filter((d) => d.id !== current && d.id !== previous); } -// The underscore keeps it outside the deploy-id alphabet, so it can never collide with a real deploy. +// The rewrite rule's initial target; nothing is uploaded there, so an undeployed site serves storage 404s. The underscore keeps it outside the deploy-id alphabet (IDs must start alphanumeric). export const PLACEHOLDER_DEPLOY = "_placeholder"; export const HOP_HEADER = "X-Bunny-Site-Hop"; From ffe7cf92d79d03c04df622ad5fb0b1821938f0a8 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:45:37 +0100 Subject: [PATCH 5/9] feat(sites): custom deploy id --- .changeset/sites-edge-rules.md | 2 +- README.md | 1 + packages/cli/README.md | 6 +- packages/cli/src/commands/sites/api.ts | 20 +- .../cli/src/commands/sites/constants.test.ts | 66 +++++- packages/cli/src/commands/sites/constants.ts | 36 +++- .../cli/src/commands/sites/deploy-id.test.ts | 33 +++ packages/cli/src/commands/sites/deploy-id.ts | 24 ++- .../cli/src/commands/sites/deploy.test.ts | 158 +++++++++++++- packages/cli/src/commands/sites/deploy.ts | 201 ++++++++++++++++-- .../src/commands/sites/deployments/delete.ts | 14 +- .../src/commands/sites/deployments/list.ts | 14 +- .../src/commands/sites/deployments/publish.ts | 40 +++- skills/bunny-cli/references/sites.md | 7 +- 14 files changed, 578 insertions(+), 44 deletions(-) diff --git a/.changeset/sites-edge-rules.md b/.changeset/sites-edge-rules.md index d0cd7a9e..911a5a82 100644 --- a/.changeset/sites-edge-rules.md +++ b/.changeset/sites-edge-rules.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -Sites are now served by pull zone edge rules instead of a router Edge Script; HTML revalidates in browsers on every view and deploy dirs are blocked at the edge +Sites are now served by pull zone edge rules instead of a router Edge Script (HTML revalidates in browsers on every view, deploy dirs are blocked at the edge), and `sites deploy --deploy-id` lets a deploy carry your own release identifier diff --git a/README.md b/README.md index 916f5ef6..ddce4747 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ bun ny sites create my-site --tier ssd # provision a site whose files live bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys (a site's first deploy also offers to attach a custom domain) bun ny sites deploy ./dist # deploy a directory and publish it as the live site bun ny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected framework's build), then deploy `sites.dir` (or the detected output dir) +bun ny sites deploy ./catalog --deploy-id 20260827-1433-r42 # identify the deploy with your own release ID instead of the git sha / content hash bun ny sites deployments list # list deploys with the live one marked bun ny sites deployments publish --previous # instant rollback to the previous deploy bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous) diff --git a/packages/cli/README.md b/packages/cli/README.md index a8a9ed4c..a8ed7c43 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -927,6 +927,7 @@ bunny sites deploy ./dist # deploy a directory and p bunny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected build), then deploy bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content +bunny sites deploy ./catalog --deploy-id 20260827-1433-r42 # your own release ID instead of the git sha / content hash # Deploys: list, publish (roll back), prune bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size @@ -958,14 +959,15 @@ bunny sites delete my-site --keep-storage # typed-name confirmation; Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. -Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. Content is root-served, so client-side routing and absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. +Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. The ID is the git short-sha when the tree is clean, a content hash otherwise, or whatever `--deploy-id` supplies (letters, digits, `-`, `_`, `.`; 4-64 chars; case-sensitive): a custom ID never aliases onto another deploy's content, and reusing one for different content asks before replacing (`--force` skips the prompt); a replacement clears the old files first, so nothing stale survives. The live deploy and the rollback target are never replaced in place; deploy those under a new ID. Content is root-served, so client-side routing and absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. | Flag | Commands | Description | | -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | | `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) | | `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | -| `--force` | `deploy` | Deploy even when the content is unchanged | +| `--force` | `deploy` | Deploy even when the content is unchanged, and replace an existing `--deploy-id` without asking | +| `--deploy-id` | `deploy` | Identify the deploy yourself (release tag, catalog ID); case-sensitive, used exactly as given | | `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) | | `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) | | `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working | diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 50092164..777edb8c 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -561,7 +561,7 @@ export async function createSite( logger.warn(`Couldn't force HTTPS on ${systemHost}: ${errorMessage(err)}`); } - // 4. Remote state; from here on the zone identifies as a site. This is the first storage write on purpose: a fresh zone can briefly refuse writes, so it must not race zone creation. + // 4. Remote state; from here on the zone identifies as a site. step("Writing site state..."); const connection = siteFiles.connect(storageZone); const state: RemoteSiteState = { @@ -571,7 +571,16 @@ export async function createSite( pullZoneId: pullZone.Id, deploys: [], }; - await writeRemoteState(connection, state); + // A fresh zone's credentials propagate asynchronously and refuse writes for the first seconds; retry briefly instead of failing the create. + for (let attempt = 0; ; attempt++) { + try { + await writeRemoteState(connection, state); + break; + } catch (err) { + if (attempt >= 5 || !/unauthorized/i.test(errorMessage(err))) throw err; + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } return { state, @@ -760,5 +769,10 @@ export async function deleteDeployFiles( connection: StorageZone, deployId: string, ): Promise { - await siteFiles.remove(connection, `${deployPrefix(deployId)}/`); + try { + await siteFiles.remove(connection, `${deployPrefix(deployId)}/`); + } catch (err) { + // An absent prefix is already the goal (a fresh ID, or a re-run after a partial delete). + if (!isNotFoundError(err)) throw err; + } } diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 7e2b6780..9674b3ab 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; +import type { DeployRecord } from "./constants.ts"; import { + deployIdError, deployPrefix, + findDeploy, isValidDeployId, isValidSiteName, parseRemoteState, @@ -54,11 +57,12 @@ test("deploy path helper", () => { expect(deployPrefix("a1b2c3d4")).toBe("deploys/a1b2c3d4"); }); -test("isValidDeployId accepts git shas and content hashes", () => { +test("isValidDeployId accepts git shas, content hashes and caller-supplied IDs", () => { expect(isValidDeployId("a1b2c3d4")).toBe(true); expect(isValidDeployId("0f9e8d7c6b5a4321")).toBe(true); + // Case is part of a caller-supplied ID, not something to normalize away. + expect(isValidDeployId("HAS-CAPS")).toBe(true); expect(isValidDeployId("ab")).toBe(false); // too short - expect(isValidDeployId("HAS-CAPS")).toBe(false); expect(isValidDeployId("has/slash")).toBe(false); expect(isValidDeployId("")).toBe(false); }); @@ -87,4 +91,60 @@ test("suffixed resource names round-trip through the site pattern", () => { expect(siteResourcePattern("other").test(zoneName)).toBe(false); }); -// Cleanup and site discovery key on the name shape, and the router parses the same shape from the hostname, so the round-trip must be exact and everything else rejected. +// Cleanup and site discovery key on the name shape, so the round-trip must be exact and everything else rejected. + +test("deployIdError accepts shas, hashes, and release-style IDs, case intact", () => { + for (const id of [ + "a1b2c3d4", + "0f1e2d3c4b5a", + "20260827-1433-r42", + "catalog_v3", + "2026.08.27-r42", + "v1.2.3", + "Release-42", + "a".repeat(64), + ]) { + expect(deployIdError(id)).toBeNull(); + } +}); + +// The ID is interpolated into a storage path and the rewrite rule's origin URL, so anything +// that could escape the deploy prefix or leave an empty/hidden segment has to be rejected. +test("deployIdError rejects path escapes and edge separators", () => { + for (const id of [ + "../etc/passwd", + "a/../b", + "foo..bar", + "a/b", + "a\\b", + "a b", + "a?b", + "a%2fb", + "-abc", + "abc.", + "_abc", + ]) { + expect(deployIdError(id)).not.toBeNull(); + } + expect(deployIdError("abc")).toBe("must be 4 to 64 characters"); + expect(deployIdError("a".repeat(65))).toBe("must be 4 to 64 characters"); +}); + +test("findDeploy matches exactly and surfaces a case variant for 'did you mean'", () => { + const deploys: DeployRecord[] = [ + { + id: "Release-42", + createdAt: "2026-08-27T00:00:00.000Z", + source: "custom", + contentHash: "hash1", + files: 1, + bytes: 10, + }, + ]; + + expect(findDeploy(deploys, "Release-42")).toEqual({ deploy: deploys[0] }); + expect(findDeploy(deploys, "release-42")).toEqual({ + caseVariant: deploys[0], + }); + expect(findDeploy(deploys, "r99")).toEqual({ caseVariant: undefined }); +}); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index e301d1e7..6b1acf22 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -21,7 +21,8 @@ export interface SiteManifest { export interface DeployRecord { id: string; createdAt: string; - source: "git" | "content"; + /** How the ID was chosen; "custom" means the caller supplied it with --deploy-id. */ + source: "git" | "content" | "custom"; gitSha?: string; dirty?: boolean; /** Hash of the deployed bytes; the no-op check keys on this. */ @@ -100,11 +101,38 @@ export function randomHopSecret(): string { return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); } -// Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the edge rules and storage paths rely on this. -const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/; +// A deploy ID becomes a storage path and the rewrite rule's origin target, so its charset is a boundary, not a style choice: alphanumerics plus `-`, `_` and `.`, bounded by an alphanumeric (which also keeps the `_placeholder` sentinel unreachable), and never a traversal sequence. Case is preserved rather than folded: a caller-supplied ID exists to match whatever produced the deploy, and the ID never reaches a client-facing URL (the rule builds the origin path itself), so nothing downstream needs it normalized. +const DEPLOY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{2,62}[A-Za-z0-9]$/; + +/** Why an ID is unusable, or null when it's fine. Phrased to complete "Deploy ID ...". */ +export function deployIdError(id: string): string | null { + if (id.length < 4 || id.length > 64) return "must be 4 to 64 characters"; + if (id.includes("..")) return 'must not contain ".."'; + if (!DEPLOY_ID_RE.test(id)) { + return "may use only letters, digits, and -, _ or ., and must start and end with a letter or digit"; + } + return null; +} export function isValidDeployId(id: string): boolean { - return DEPLOY_ID_RE.test(id); + return deployIdError(id) === null; +} + +/** + * Look up a deploy by ID, exactly. + * + * `caseVariant` is the deploy that differs only in case, so a caller can say + * "did you mean" instead of a bare not-found: IDs preserve the case they were + * given, and eyeballing `Release-42` against `release-42` in a list is no fun. + */ +export function findDeploy( + deploys: DeployRecord[], + id: string, +): { deploy?: DeployRecord; caseVariant?: DeployRecord } { + const deploy = deploys.find((d) => d.id === id); + if (deploy) return { deploy }; + const lower = id.toLowerCase(); + return { caseVariant: deploys.find((d) => d.id.toLowerCase() === lower) }; } // Site names become `sites-{name}-{suffix}` zone names; 3-47 chars keeps those within zone-name limits. diff --git a/packages/cli/src/commands/sites/deploy-id.test.ts b/packages/cli/src/commands/sites/deploy-id.test.ts index 76d53b2e..a9555896 100644 --- a/packages/cli/src/commands/sites/deploy-id.test.ts +++ b/packages/cli/src/commands/sites/deploy-id.test.ts @@ -82,3 +82,36 @@ test("clean git repo uses the short sha; dirty tree falls back to content", asyn expect(dirty.id).toBe(contentHashId(FILES)); expect(dirty.gitSha).toBe(clean.id); }); + +test("a custom id wins over git and content, but still records both", async () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-custom-")); + await run(dir, ["init", "-q"]); + await Bun.write(join(dir, "index.html"), "

hi

"); + await run(dir, ["add", "."]); + await run(dir, [ + "-c", + "user.email=test@example.com", + "-c", + "user.name=test", + "commit", + "-q", + "-m", + "init", + ]); + + const identity = await resolveDeployIdentity(dir, FILES, "20260827-1433-r42"); + expect(identity.id).toBe("20260827-1433-r42"); + expect(identity.source).toBe("custom"); + // Provenance survives: the git sha is still recorded, and the content hash still drives the no-op check. + expect(identity.gitSha).toMatch(/^[0-9a-f]{8}$/); + expect(identity.contentHash).toBe(contentHashId(FILES)); +}); + +test("a custom id works outside a git repo", async () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-custom-nogit-")); + const identity = await resolveDeployIdentity(dir, FILES, "catalog_v3"); + expect(identity.id).toBe("catalog_v3"); + expect(identity.source).toBe("custom"); + expect(identity.gitSha).toBeUndefined(); + expect(identity.contentHash).toBe(contentHashId(FILES)); +}); diff --git a/packages/cli/src/commands/sites/deploy-id.ts b/packages/cli/src/commands/sites/deploy-id.ts index b810348d..4f6b8d59 100644 --- a/packages/cli/src/commands/sites/deploy-id.ts +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -9,7 +9,7 @@ export interface HashedFile { export interface DeployIdentity { id: string; - source: "git" | "content"; + source: "git" | "content" | "custom"; gitSha?: string; dirty?: boolean; // Hash of the deployed bytes; the no-op check keys on this (not `id`), so a rebuilt `dist/` at the same git sha isn't wrongly skipped. @@ -40,13 +40,33 @@ export async function gitIdentity( }; } -// Resolve the deploy identity: display `id` is the git short-sha on a clean tree, else the content hash; `contentHash` always hashes what ships and drives the no-op check. +/** + * Resolve the deploy identity. + * + * `customId` wins when given, so a release can carry the same ID as whatever + * produced it. Otherwise the display `id` is the git short-sha on a clean tree + * and the content hash elsewhere. `contentHash` always hashes what ships and + * drives the no-op check, so an explicit ID never disturbs change detection; + * the git sha is still recorded when there is one, for provenance. + */ export async function resolveDeployIdentity( cwd: string, files: HashedFile[], + customId?: string, ): Promise { const contentHash = contentHashId(files); const gitInfo = await gitIdentity(cwd); + + if (customId) { + return { + id: customId, + source: "custom", + gitSha: gitInfo?.sha, + dirty: gitInfo?.dirty, + contentHash, + }; + } + if (gitInfo && !gitInfo.dirty) { return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha, contentHash }; } diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index ad2e303a..2aba7a64 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -1,7 +1,12 @@ import { expect, test } from "bun:test"; import { resolve } from "node:path"; -import type { RemoteSiteState } from "./constants.ts"; -import { productionUrl, resolveDeployDir } from "./deploy.ts"; +import type { DeployRecord, RemoteSiteState } from "./constants.ts"; +import { + productionUrl, + resolveDeployDir, + resolveDeployTarget, +} from "./deploy.ts"; +import type { DeployIdentity } from "./deploy-id.ts"; const ROOT = "/project/root"; @@ -39,3 +44,152 @@ test("productionUrl prefers the custom domain over the system host", () => { test("productionUrl is undefined when the site has neither a domain nor a system host", () => { expect(productionUrl(stateWithDomain(undefined), undefined)).toBeUndefined(); }); + +const deploy = ( + id: string, + contentHash: string, + source: DeployRecord["source"] = "content", +): DeployRecord => ({ + id, + createdAt: "2026-08-27T00:00:00.000Z", + source, + contentHash, + files: 1, + bytes: 10, +}); + +const identity = ( + id: string, + contentHash: string, + source: DeployIdentity["source"] = "content", +): DeployIdentity => ({ id, source, contentHash }); + +test("matching content skips the upload, aliasing onto the existing deploy's id", () => { + const deploys = [deploy("aaaa1111", "hash1")]; + + expect( + resolveDeployTarget({ + deploys, + identity: identity("aaaa1111", "hash1"), + force: false, + }), + ).toEqual({ deployId: "aaaa1111", skipUpload: true }); + + // A different git sha over identical bytes reuses the earlier deploy rather than duplicating it. + expect( + resolveDeployTarget({ + deploys, + identity: identity("bbbb2222", "hash1", "git"), + force: false, + }), + ).toEqual({ deployId: "aaaa1111", skipUpload: true }); +}); + +// A catalog release must keep its own ID even when the bytes match another deploy. +test("a custom id is used exactly as given and never aliases onto another deploy", () => { + expect( + resolveDeployTarget({ + deploys: [deploy("r41", "hash1")], + identity: identity("r42", "hash1", "custom"), + customId: "r42", + force: false, + }), + ).toEqual({ deployId: "r42", skipUpload: false }); + + // Same id, same content: a no-op redeploy. + expect( + resolveDeployTarget({ + deploys: [deploy("Release-42", "hash1", "custom")], + identity: identity("Release-42", "hash1", "custom"), + customId: "Release-42", + force: false, + }), + ).toEqual({ deployId: "Release-42", skipUpload: true }); +}); + +// The handler resolves a content conflict by asking (--force answers yes), so it is reported regardless of force. +test("reusing a custom id for different content conflicts instead of overwriting", () => { + const existing = deploy("r42", "hash1", "custom"); + for (const force of [false, true]) { + const target = resolveDeployTarget({ + deploys: [existing], + identity: identity("r42", "hash2", "custom"), + customId: "r42", + force, + }); + expect(target.conflict).toEqual({ record: existing, reason: "content" }); + } +}); + +// Two deploys whose storage paths differ only by case are indistinguishable to anything that folds case, +// so this one conflict stands even under --force. +test("an id differing only in case is refused, with or without --force", () => { + const existing = deploy("Release-42", "hash1", "custom"); + const args = { + deploys: [existing], + identity: identity("release-42", "hash2", "custom"), + customId: "release-42", + }; + const conflict = { record: existing, reason: "case" } as const; + + expect(resolveDeployTarget({ ...args, force: false }).conflict).toEqual( + conflict, + ); + expect(resolveDeployTarget({ ...args, force: true }).conflict).toEqual( + conflict, + ); +}); + +// Replacing the deploy production serves (or the rollback target) rewrites the prefix being served, so it is never forceable, custom ID or not. +test("replacing the live or rollback deploy's content is refused, even with --force", () => { + const live = deploy("r42", "hash1", "custom"); + const args = { + deploys: [live], + identity: identity("r42", "hash2", "custom"), + customId: "r42", + force: true, + }; + expect(resolveDeployTarget({ ...args, current: "r42" }).conflict).toEqual({ + record: live, + reason: "live", + }); + expect(resolveDeployTarget({ ...args, previous: "r42" }).conflict).toEqual({ + record: live, + reason: "rollback", + }); + + // Same git sha over different bytes lands on the same ID without --deploy-id. + const gitLive = deploy("aaaa1111", "hash1", "git"); + expect( + resolveDeployTarget({ + deploys: [gitLive], + identity: identity("aaaa1111", "hash2", "git"), + force: false, + current: "aaaa1111", + }).conflict, + ).toEqual({ record: gitLive, reason: "live" }); +}); + +// --force's "redeploy unchanged content" path: every write is byte-identical, so in-place is safe. +test("a forced same-content redeploy of the live deploy is allowed", () => { + expect( + resolveDeployTarget({ + deploys: [deploy("r42", "hash1", "custom")], + identity: identity("r42", "hash1", "custom"), + customId: "r42", + force: true, + current: "r42", + }), + ).toEqual({ deployId: "r42", skipUpload: false }); +}); + +test("a brand new custom id on an empty site just uploads", () => { + expect( + resolveDeployTarget({ + deploys: [], + identity: identity("20260827-1433-r42", "hash1", "custom"), + customId: "20260827-1433-r42", + force: false, + }), + ).toEqual({ deployId: "20260827-1433-r42", skipUpload: false }); +}); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 0c80f10e..1e2cc82e 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -9,8 +9,15 @@ import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; import { + confirm, + isInteractive, + prompts, + requireConfirmable, + withSpinner, +} from "../../core/ui.ts"; +import { + deleteDeployFiles, fetchSystemHostname, promoteDeploy, requireRulesSite, @@ -25,10 +32,12 @@ import { import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, + deployIdError, + findDeploy, markCurrent, type RemoteSiteState, } from "./constants.ts"; -import { resolveDeployIdentity } from "./deploy-id.ts"; +import { type DeployIdentity, resolveDeployIdentity } from "./deploy-id.ts"; import { setupSiteDomain } from "./domains/index.ts"; import { type SiteSelectorArgs, @@ -45,11 +54,107 @@ interface DeployArgs extends SiteSelectorArgs { env?: string[]; "env-file"?: string; force?: boolean; + "deploy-id"?: string; +} + +export interface DeployTarget { + /** The ID this deploy will live under in storage. */ + deployId: string; + /** True when these exact bytes are already uploaded under `deployId`. */ + skipUpload: boolean; + /** + * An existing deploy that blocks this one. + * + * `content`: the same ID already holds different bytes; the handler asks + * before replacing them (--force answers yes). + * `case`: an ID differing only in case exists. Never replaceable, because two + * deploys whose paths differ only by case are indistinguishable to anything + * that folds case, and the loser's files would back the winner's rollback. + * `live`/`rollback`: the ID holds different bytes AND is the production + * deploy or the rollback target. Never replaceable, because a replacement + * empties and rewrites the very prefix being served (or rolled back to). + */ + conflict?: { + record: DeployRecord; + reason: "content" | "case" | "live" | "rollback"; + }; +} + +/** + * Decide which ID this deploy lands under and whether the upload can be skipped. + * + * Change detection keys on content, not the display ID, so a rebuilt `dist/` at + * the same git sha is never wrongly skipped. An explicit ID is an assertion + * about identity, so it never aliases onto an earlier deploy that merely shares + * content: a catalog release keeps its own ID even when the bytes are identical + * to the last one. Reusing an ID for different bytes rewrites what a rollback + * to it would serve, so that is reported as a conflict rather than done quietly. + */ +export function resolveDeployTarget(opts: { + deploys: DeployRecord[]; + identity: DeployIdentity; + customId?: string; + force: boolean; + /** The production deploy and the rollback target; their content is never replaced in place. */ + current?: string; + previous?: string; +}): DeployTarget { + const { deploys, identity, customId, force, current, previous } = opts; + + const alreadyUploaded = force + ? undefined + : deploys.find((d) => + customId + ? d.id === customId && d.contentHash === identity.contentHash + : d.contentHash === identity.contentHash, + ); + // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. + const deployId = alreadyUploaded?.id ?? identity.id; + const skipUpload = alreadyUploaded !== undefined; + + if (customId && !skipUpload) { + const { caseVariant } = findDeploy(deploys, customId); + if (caseVariant) { + return { + deployId, + skipUpload, + conflict: { record: caseVariant, reason: "case" }, + }; + } + } + + if (!skipUpload) { + const existing = deploys.find((d) => d.id === deployId); + if (existing && existing.contentHash !== identity.contentHash) { + // Replacing the deploy production serves (or would roll back to) rewrites the prefix the edge is pulling from, so it is refused outright, before the confirmable content conflict (which would otherwise send the caller down a dead end). A same-bytes re-upload stays fine: every write is byte-identical. + if (deployId === current || deployId === previous) { + return { + deployId, + skipUpload, + conflict: { + record: existing, + reason: deployId === current ? "live" : "rollback", + }, + }; + } + if (customId) { + return { + deployId, + skipUpload, + conflict: { record: existing, reason: "content" }, + }; + } + } + } + return { deployId, skipUpload }; } const DOMAIN_HINT = " Add a custom production domain: bunny sites domains add "; +const DEPLOY_ID_HINT = + "IDs become storage paths, so they take letters, digits, and -, _ or . (e.g. 20260827-1433-r42)."; + // A site's live URL: the custom domain when it has one, else its b-cdn.net host. Always https (b-cdn.net hosts carry bunny's certificate). export function productionUrl( state: RemoteSiteState, @@ -82,6 +187,10 @@ export const sitesDeployCommand = defineCommand({ "Explicit build command", ], ["$0 sites deploy ./dist --site my-site", "Target a specific site"], + [ + "$0 sites deploy ./catalog --deploy-id 20260827-1433-r42", + "Identify the deploy with your own release ID", + ], ], builder: (yargs) => @@ -110,7 +219,13 @@ export const sitesDeployCommand = defineCommand({ .option("force", { type: "boolean", default: false, - describe: "Deploy even when the content is unchanged", + describe: + "Deploy even when the content is unchanged, and replace an existing --deploy-id's content without asking", + }) + .option("deploy-id", { + type: "string", + describe: + "Identify this deploy yourself (e.g. a release or catalog ID) instead of using the git sha or content hash; used exactly as given", }), ), @@ -208,15 +323,64 @@ export const sitesDeployCommand = defineCommand({ } const totalBytes = files.reduce((sum, f) => sum + f.size, 0); - const identity = await resolveDeployIdentity(dir, files); + const customId = args["deploy-id"]?.trim(); + if (customId) { + const problem = deployIdError(customId); + if (problem) { + throw new UserError( + `Deploy ID "${customId}" ${problem}.`, + DEPLOY_ID_HINT, + ); + } + } - // The no-op check keys on content, not the display id, so a rebuilt `dist/` at the same git sha isn't wrongly skipped. - const alreadyUploaded = args.force - ? undefined - : state.deploys.find((d) => d.contentHash === identity.contentHash); - const skipUpload = alreadyUploaded !== undefined; - // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. - const deployId = alreadyUploaded?.id ?? identity.id; + const identity = await resolveDeployIdentity(dir, files, customId); + const target = resolveDeployTarget({ + deploys: state.deploys, + identity, + customId, + force: args.force ?? false, + current: state.current, + previous: state.previous, + }); + + if ( + target.conflict?.reason === "live" || + target.conflict?.reason === "rollback" + ) { + const role = + target.conflict.reason === "live" + ? "the live production deploy" + : "the rollback target"; + throw new UserError( + `Deploy ${target.deployId} is ${role} for ${state.name}, and this content differs from what it holds.`, + "Replacing it in place would rewrite files while they are being served. Deploy under a new --deploy-id, or publish another deploy first and re-run.", + ); + } + if (target.conflict?.reason === "case") { + throw new UserError( + `Deploy ${target.conflict.record.id} already exists for ${state.name}, differing from "${customId}" only in case.`, + `Reuse that exact ID to redeploy it, or pick one that isn't a case variant.`, + ); + } + if (target.conflict?.reason === "content") { + // Rolling back to the ID would serve the new files instead of the originals, so replacing is opt-in. + requireConfirmable(output, { + force: args.force, + message: `Deploy ${customId} already exists for ${state.name} with different content; replacing it needs a confirmation prompt.`, + hint: "Pick another ID, or re-run with --force to replace it non-interactively.", + }); + const proceed = await confirm( + `Deploy ${customId} already exists for ${state.name} with different content. Replace it?`, + { force: args.force }, + ); + if (!proceed) { + logger.log("Cancelled."); + return; + } + } + + const { deployId, skipUpload } = target; const alreadyLive = state.current === deployId; // The production URL prefers the custom domain; only fetch the system host when there is none. @@ -252,6 +416,19 @@ export const sitesDeployCommand = defineCommand({ } if (!skipUpload) { + // Unless these exact bytes are already recorded under the ID, the upload starts from an empty prefix: emptying first is what keeps a replaced deploy free of files the new content dropped, and also clears half-written leftovers from an interrupted earlier run. + const existing = state.deploys.find((d) => d.id === deployId); + if (existing && existing.contentHash !== identity.contentHash) { + // Drop the record before deleting its files, so no record ever vouches for a prefix mid-rewrite (a concurrent publish re-reads state and refuses an ID without one), and a crashed replacement re-runs as a fresh upload. + state.deploys = state.deploys.filter((d) => d.id !== deployId); + etag = await writeRemoteState(connection, state, etag, { + removedIds: [deployId], + }); + } + if (existing?.contentHash !== identity.contentHash) { + await deleteDeployFiles(connection, deployId); + } + await withSpinner(`Uploading ${files.length} files...`, (spin) => uploadDeploy(connection, deployId, files, { onFileUploaded: (done, total) => { @@ -260,7 +437,7 @@ export const sitesDeployCommand = defineCommand({ }), ); - // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata; the promote below purges the zone, so its old bytes can't be served. + // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata; the purge on promote drops the old bytes from cache. const record: DeployRecord = { id: deployId, createdAt: new Date().toISOString(), diff --git a/packages/cli/src/commands/sites/deployments/delete.ts b/packages/cli/src/commands/sites/deployments/delete.ts index 14aab61c..6fc170a2 100644 --- a/packages/cli/src/commands/sites/deployments/delete.ts +++ b/packages/cli/src/commands/sites/deployments/delete.ts @@ -10,7 +10,11 @@ import { readRemoteState, writeRemoteState, } from "../api.ts"; -import { isValidDeployId, type RemoteSiteState } from "../constants.ts"; +import { + findDeploy, + isValidDeployId, + type RemoteSiteState, +} from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -86,7 +90,7 @@ export const sitesDeploymentsDeleteCommand = defineCommand({ // No etag here: the destructive phase re-reads state and writes with the fresh one. const { state, connection } = site; - const record = state.deploys.find((d) => d.id === id); + const { deploy: record, caseVariant } = findDeploy(state.deploys, id); if (!record) { // Idempotent for CI: a retry after a successful delete still exits 0. if (output === "json") { @@ -98,6 +102,12 @@ export const sitesDeploymentsDeleteCommand = defineCommand({ logger.info( `Deploy ${id} not found on ${state.name}; nothing to delete.`, ); + // A case typo would otherwise look like a successful no-op. + if (caseVariant) { + logger.dim( + ` Did you mean ${caseVariant.id}? Deploy IDs are case-sensitive.`, + ); + } return; } diff --git a/packages/cli/src/commands/sites/deployments/list.ts b/packages/cli/src/commands/sites/deployments/list.ts index e1dfc5b4..c5b7860f 100644 --- a/packages/cli/src/commands/sites/deployments/list.ts +++ b/packages/cli/src/commands/sites/deployments/list.ts @@ -8,6 +8,7 @@ import { formatTable, } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; +import type { DeployRecord } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -17,6 +18,15 @@ import { type ListArgs = SiteSelectorArgs; +// How a deploy got its ID, for the Source column. A custom ID still shows the git sha when one was recorded, since that's the only provenance it carries. +function deploySource(d: DeployRecord): string { + if (d.source === "git") return `git ${d.gitSha ?? d.id}`; + if (d.source === "custom") { + return d.gitSha ? `custom (git ${d.gitSha})` : "custom"; + } + return `content${d.dirty ? " (dirty tree)" : ""}`; +} + export const sitesDeploymentsListCommand = defineCommand({ command: "list [site]", aliases: ["ls"], @@ -76,9 +86,7 @@ export const sitesDeploymentsListCommand = defineCommand({ ? "○ Previous" : "○", formatDateTime(d.createdAt), - d.source === "git" - ? `git ${d.gitSha ?? d.id}` - : `content${d.dirty ? " (dirty tree)" : ""}`, + deploySource(d), String(d.files), formatBytes(d.bytes), ]), diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index 272be151..e8b63467 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -5,8 +5,13 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; -import { promoteDeploy, requireRulesSite, writeRemoteState } from "../api.ts"; -import { markCurrent } from "../constants.ts"; +import { + promoteDeploy, + readRemoteState, + requireRulesSite, + writeRemoteState, +} from "../api.ts"; +import { findDeploy, markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -62,7 +67,8 @@ export const sitesDeploymentsPublishCommand = defineCommand({ output, force: args.force, }); - const { state, connection, etag } = site; + // No etag kept from this read: the destructive phase re-reads state and writes with the fresh one. + const { state, connection } = site; requireRulesSite(state); let targetId = args.id; @@ -85,14 +91,15 @@ export const sitesDeploymentsPublishCommand = defineCommand({ ); } - const deploy = state.deploys.find((d) => d.id === targetId); + const { deploy, caseVariant } = findDeploy(state.deploys, targetId); if (!deploy) { throw new UserError( `Deploy ${targetId} not found for site ${state.name}.`, - "Run `bunny sites deployments list` to see available deploys.", + caseVariant + ? `Did you mean ${caseVariant.id}? Deploy IDs are case-sensitive.` + : "Run `bunny sites deployments list` to see available deploys.", ); } - if (state.current === targetId) { if (output === "json") { logger.log( @@ -128,9 +135,24 @@ export const sitesDeploymentsPublishCommand = defineCommand({ } await withSpinner("Publishing...", async () => { - await promoteDeploy({ coreClient, state, deployId: targetId }); - markCurrent(state, targetId); - await writeRemoteState(connection, state, etag, { + // Revalidate on fresh state right before promoting: the confirmation window is long enough for a concurrent replace to have dropped this deploy's record and started rewriting its files. + const fresh = await readRemoteState(connection); + if (!fresh) { + throw new UserError( + "Couldn't re-read the site state.", + "Retry the publish; nothing was changed.", + ); + } + const { state: latest, etag: latestEtag } = fresh; + if (!latest.deploys.some((d) => d.id === targetId)) { + throw new UserError( + `Deploy ${targetId} is gone from ${latest.name} (a concurrent replace or delete?) and can't be published.`, + "Run `bunny sites deployments list` and retry.", + ); + } + await promoteDeploy({ coreClient, state: latest, deployId: targetId }); + markCurrent(latest, targetId); + await writeRemoteState(connection, latest, latestEtag, { promotedTo: targetId, }); }); diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index ba99be2f..8e6c7802 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -43,7 +43,12 @@ Content is root-served, so client-side routers (TanStack Router, React Router, V ## Deploy IDs -- The deploy ID is the **git short-sha** when the working tree is clean, otherwise an 8-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). +- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). +- `--deploy-id ` sets the ID yourself, so a deploy can carry the same identifier as whatever produced it (a release tag, a catalog build, a timestamped artifact) and `deployments list` needs no cross-referencing. The ID is used **exactly as given**, case included: it exists to match your identifier, and it never appears in a client-facing URL (the edge rule builds the origin path from it server-side). IDs become storage paths, so they take letters, digits and `-`, `_` or `.`, 4 to 64 characters, starting and ending alphanumeric: `20260827-1433-r42`, `Catalog_V3`, `v1.2.3`. + - Deploy IDs are therefore **case-sensitive**. `publish`/`delete` match exactly and suggest a case variant when one exists, and deploying an ID that differs from an existing one only in case is refused (not even with `--force`), since two storage paths differing only by case are indistinguishable to anything that folds case. + - An explicit ID is an assertion about identity, so it is never aliased onto an earlier deploy that happens to share content: each release keeps its own ID and rollback target even when the bytes are unchanged. + - Reusing an ID for **different** content asks before replacing, because rolling back to that ID would then serve the new files instead of the originals (`--force` skips the prompt for CI). A replacement clears the old files first, so nothing stale survives. The **live deploy and the rollback target are never replaceable in place** (not even with `--force`): that would empty and rewrite the files being served. Deploy under a new ID, or publish another deploy first. + - The git sha is still recorded alongside a custom ID when the deploy came from a repo, so provenance is not lost; `deployments list` shows it as `custom (git abc12345)`. - Dotfiles and `node_modules` are never uploaded. --- From c0b3b2bb9d19c4943850a46c0f289949c8ff7f8e Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 18:55:55 +0100 Subject: [PATCH 6/9] feat(db): always generate an auth token on interactive create --- .changeset/sites-edge-rules.md | 2 +- packages/cli/README.md | 6 +++--- packages/cli/src/commands/db/create.ts | 18 ++++-------------- 3 files changed, 8 insertions(+), 18 deletions(-) diff --git a/.changeset/sites-edge-rules.md b/.changeset/sites-edge-rules.md index 911a5a82..89874b13 100644 --- a/.changeset/sites-edge-rules.md +++ b/.changeset/sites-edge-rules.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -Sites are now served by pull zone edge rules instead of a router Edge Script (HTML revalidates in browsers on every view, deploy dirs are blocked at the edge), and `sites deploy --deploy-id` lets a deploy carry your own release identifier +Sites are now served by pull zone edge rules instead of a router Edge Script (HTML revalidates in browsers on every view, deploy dirs are blocked at the edge), `sites deploy --deploy-id` lets a deploy carry your own release identifier, and interactive `db create` now always generates an auth token (use `--no-token` to skip) and asks to save it to `.env` diff --git a/packages/cli/README.md b/packages/cli/README.md index a8ed7c43..a6710c6f 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -143,7 +143,7 @@ For `db shell`, the CLI also reads `BUNNY_DATABASE_AUTH_TOKEN` from `.env` to sk #### `bunny db create` -Create a new database. Interactively prompts for name and region selection (automatic, single region, or manual) when flags are omitted. After creation, prompts to link the directory, generate an auth token, and save credentials to `.env`. +Create a new database. Interactively prompts for name and region selection (automatic, single region, or manual) when flags are omitted. After creation, prompts to link the directory, generates a full-access auth token, and prompts to save the credentials to `.env`. ```bash # Interactive — prompts for name and region mode @@ -166,8 +166,8 @@ bunny db create --name mydb --primary FR --link --token --save-env --output json | `--replicas` | Comma-separated replica region IDs (e.g. `UK,NY`) | | `--storage-region` | Override auto-detected storage region | | `--link` | Link the current directory to the new database (skips prompt). Use `--no-link` to skip. | -| `--token` | Generate a full-access auth token (skips prompt). Use `--no-token` to skip. | -| `--save-env` | Save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env`. Requires `--token`. | +| `--token` | Generate a full-access auth token (default in interactive mode). Use `--no-token` to skip. | +| `--save-env` | Save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env`. Needs a generated token. | In `--output json` mode, prompts are suppressed entirely — flags are the only way to opt in to linking, token creation, and `.env` writes. The JSON output gains `linked`, `token`, and `saved_to_env` fields reflecting what happened. diff --git a/packages/cli/src/commands/db/create.ts b/packages/cli/src/commands/db/create.ts index a1f1566d..d752d205 100644 --- a/packages/cli/src/commands/db/create.ts +++ b/packages/cli/src/commands/db/create.ts @@ -126,12 +126,12 @@ export const dbCreateCommand = defineCommand({ .option(ARG_TOKEN, { type: "boolean", describe: - "Generate a full-access auth token (skips prompt). Use --no-token to skip without prompting.", + "Generate a full-access auth token (default in interactive mode). Use --no-token to skip.", }) .option(ARG_SAVE_ENV, { type: "boolean", describe: - "Save BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN to .env (skips prompt). No effect without --token.", + "Save BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN to .env (skips prompt). No effect when no token is generated.", }), handler: async (args) => { @@ -380,19 +380,9 @@ export const dbCreateCommand = defineCommand({ } } - // Offer to create an auth token + // Generate an auth token (interactive default; --no-token opts out) const tokenArg = args[ARG_TOKEN]; - let shouldCreateToken: boolean; - if (tokenArg !== undefined) { - shouldCreateToken = tokenArg; - } else if (isInteractive) { - shouldCreateToken = await confirm("Create an auth token?", { - force: false, - optional: true, - }); - } else { - shouldCreateToken = false; - } + const shouldCreateToken = tokenArg ?? isInteractive; let token: string | null = null; let savedToEnv = false; From 8f4b75c6876764a6057edbae05487ad87b776254 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 19:22:18 +0100 Subject: [PATCH 7/9] revert(db): restore the auth token prompt on create --- .changeset/sites-edge-rules.md | 2 +- packages/cli/README.md | 6 +++--- packages/cli/src/commands/db/create.ts | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.changeset/sites-edge-rules.md b/.changeset/sites-edge-rules.md index 89874b13..911a5a82 100644 --- a/.changeset/sites-edge-rules.md +++ b/.changeset/sites-edge-rules.md @@ -2,4 +2,4 @@ "@bunny.net/cli": minor --- -Sites are now served by pull zone edge rules instead of a router Edge Script (HTML revalidates in browsers on every view, deploy dirs are blocked at the edge), `sites deploy --deploy-id` lets a deploy carry your own release identifier, and interactive `db create` now always generates an auth token (use `--no-token` to skip) and asks to save it to `.env` +Sites are now served by pull zone edge rules instead of a router Edge Script (HTML revalidates in browsers on every view, deploy dirs are blocked at the edge), and `sites deploy --deploy-id` lets a deploy carry your own release identifier diff --git a/packages/cli/README.md b/packages/cli/README.md index a6710c6f..a8ed7c43 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -143,7 +143,7 @@ For `db shell`, the CLI also reads `BUNNY_DATABASE_AUTH_TOKEN` from `.env` to sk #### `bunny db create` -Create a new database. Interactively prompts for name and region selection (automatic, single region, or manual) when flags are omitted. After creation, prompts to link the directory, generates a full-access auth token, and prompts to save the credentials to `.env`. +Create a new database. Interactively prompts for name and region selection (automatic, single region, or manual) when flags are omitted. After creation, prompts to link the directory, generate an auth token, and save credentials to `.env`. ```bash # Interactive — prompts for name and region mode @@ -166,8 +166,8 @@ bunny db create --name mydb --primary FR --link --token --save-env --output json | `--replicas` | Comma-separated replica region IDs (e.g. `UK,NY`) | | `--storage-region` | Override auto-detected storage region | | `--link` | Link the current directory to the new database (skips prompt). Use `--no-link` to skip. | -| `--token` | Generate a full-access auth token (default in interactive mode). Use `--no-token` to skip. | -| `--save-env` | Save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env`. Needs a generated token. | +| `--token` | Generate a full-access auth token (skips prompt). Use `--no-token` to skip. | +| `--save-env` | Save `BUNNY_DATABASE_URL` and `BUNNY_DATABASE_AUTH_TOKEN` to `.env`. Requires `--token`. | In `--output json` mode, prompts are suppressed entirely — flags are the only way to opt in to linking, token creation, and `.env` writes. The JSON output gains `linked`, `token`, and `saved_to_env` fields reflecting what happened. diff --git a/packages/cli/src/commands/db/create.ts b/packages/cli/src/commands/db/create.ts index d752d205..a1f1566d 100644 --- a/packages/cli/src/commands/db/create.ts +++ b/packages/cli/src/commands/db/create.ts @@ -126,12 +126,12 @@ export const dbCreateCommand = defineCommand({ .option(ARG_TOKEN, { type: "boolean", describe: - "Generate a full-access auth token (default in interactive mode). Use --no-token to skip.", + "Generate a full-access auth token (skips prompt). Use --no-token to skip without prompting.", }) .option(ARG_SAVE_ENV, { type: "boolean", describe: - "Save BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN to .env (skips prompt). No effect when no token is generated.", + "Save BUNNY_DATABASE_URL and BUNNY_DATABASE_AUTH_TOKEN to .env (skips prompt). No effect without --token.", }), handler: async (args) => { @@ -380,9 +380,19 @@ export const dbCreateCommand = defineCommand({ } } - // Generate an auth token (interactive default; --no-token opts out) + // Offer to create an auth token const tokenArg = args[ARG_TOKEN]; - const shouldCreateToken = tokenArg ?? isInteractive; + let shouldCreateToken: boolean; + if (tokenArg !== undefined) { + shouldCreateToken = tokenArg; + } else if (isInteractive) { + shouldCreateToken = await confirm("Create an auth token?", { + force: false, + optional: true, + }); + } else { + shouldCreateToken = false; + } let token: string | null = null; let savedToEnv = false; From 426fc313c527f4bf7e70ac2515c82bd7cca802a5 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 19:23:38 +0100 Subject: [PATCH 8/9] fix(sites): revalidate deploy pointers before replacing an ID and reject an empty --deploy-id --- packages/cli/src/commands/sites/deploy.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 1e2cc82e..2517b9d8 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -20,6 +20,7 @@ import { deleteDeployFiles, fetchSystemHostname, promoteDeploy, + readRemoteState, requireRulesSite, writeRemoteState, } from "./api.ts"; @@ -38,6 +39,7 @@ import { type RemoteSiteState, } from "./constants.ts"; import { type DeployIdentity, resolveDeployIdentity } from "./deploy-id.ts"; +import { deleteBlocker } from "./deployments/delete.ts"; import { setupSiteDomain } from "./domains/index.ts"; import { type SiteSelectorArgs, @@ -324,7 +326,8 @@ export const sitesDeployCommand = defineCommand({ const totalBytes = files.reduce((sum, f) => sum + f.size, 0); const customId = args["deploy-id"]?.trim(); - if (customId) { + // An explicitly supplied empty ID (e.g. --deploy-id "$UNSET_VAR" in CI) must error, not silently fall back to the derived ID. + if (customId !== undefined) { const problem = deployIdError(customId); if (problem) { throw new UserError( @@ -419,6 +422,21 @@ export const sitesDeployCommand = defineCommand({ // Unless these exact bytes are already recorded under the ID, the upload starts from an empty prefix: emptying first is what keeps a replaced deploy free of files the new content dropped, and also clears half-written leftovers from an interrupted earlier run. const existing = state.deploys.find((d) => d.id === deployId); if (existing && existing.contentHash !== identity.contentHash) { + // Revalidate on fresh state right before anything destructive: the confirmation window is long enough for a concurrent publish to have made this ID live. + const fresh = await readRemoteState(connection); + if (!fresh) { + throw new UserError( + "Couldn't re-read the site state.", + "Retry the deploy; nothing was replaced.", + ); + } + const blocker = deleteBlocker(fresh.state, deployId); + if (blocker) { + throw new UserError( + `Deploy ${deployId} became ${blocker} for ${state.name} while this deploy was being prepared.`, + "Publish another deploy first and re-run, or deploy under a new --deploy-id.", + ); + } // Drop the record before deleting its files, so no record ever vouches for a prefix mid-rewrite (a concurrent publish re-reads state and refuses an ID without one), and a crashed replacement re-runs as a fresh upload. state.deploys = state.deploys.filter((d) => d.id !== deployId); etag = await writeRemoteState(connection, state, etag, { From 519e2d7ba88d1502b3832dbfb01f52f77f416166 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Mon, 31 Aug 2026 19:53:51 +0100 Subject: [PATCH 9/9] refactor(sites): drop router-era state compatibility --- packages/cli/src/commands/sites/api.test.ts | 86 ++----------------- packages/cli/src/commands/sites/api.ts | 29 +------ .../cli/src/commands/sites/constants.test.ts | 4 +- packages/cli/src/commands/sites/constants.ts | 10 +-- packages/cli/src/commands/sites/delete.ts | 9 +- packages/cli/src/commands/sites/deploy.ts | 2 - .../src/commands/sites/deployments/publish.ts | 8 +- 7 files changed, 20 insertions(+), 128 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index eb881e8e..4b2449d4 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -3,14 +3,12 @@ import type { EdgeRule } from "../../core/edge-rules.ts"; import { ApiError } from "../../core/errors.ts"; import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { - type ComputeClient, createSite, deleteSiteResources, fetchSites, promoteDeploy, promoteVerification, readRemoteState, - requireRulesSite, siteContextFromZone, siteFiles, writeRemoteState, @@ -237,53 +235,6 @@ function fakeCoreClient(opts: { } as unknown as CoreClient; } -function fakeComputeClient(opts: { - calls: Call[]; - scripts?: Array<{ Id: number; Name: string }>; -}): ComputeClient { - const scripts = opts.scripts ?? []; - let nextScriptId = 20; - return { - GET: async (path: string) => { - opts.calls.push({ method: "GET", path }); - if (path === "/compute/script") return { data: { Items: scripts } }; - throw new Error(`unexpected GET ${path}`); - }, - POST: async (path: string, options?: { body?: unknown }) => { - opts.calls.push({ method: "POST", path, body: options?.body }); - if (path === "/compute/script") { - const script = { - Id: nextScriptId++, - Name: (options?.body as { Name: string }).Name, - }; - scripts.push(script); - return { data: script }; - } - return { data: {} }; - }, - PUT: async ( - path: string, - options?: { params?: unknown; body?: unknown }, - ) => { - opts.calls.push({ - method: "PUT", - path, - params: options?.params as Record, - body: options?.body, - }); - return { data: {} }; - }, - DELETE: async (path: string, options?: { params?: unknown }) => { - opts.calls.push({ - method: "DELETE", - path, - params: options?.params as Record, - }); - return { data: undefined }; - }, - } as unknown as ComputeClient; -} - // ---- remote state round-trip ---- test("writeRemoteState/readRemoteState round-trip with a stable etag", async () => { @@ -500,14 +451,13 @@ test("createSite provisions storage zone → pull zone → edge rules → state" ForceSSL: true, }); - // Remote state marks the zone as a site; no script in the rules era. + // Remote state marks the zone as a site. const written = await readRemoteState(fakeConnection()); expect(written?.state).toMatchObject({ name: "my-site", storageZoneId: 10, pullZoneId: 30, }); - expect(written?.state.scriptId).toBeUndefined(); }); test("createSite re-run after a crash reuses the rules and their secret", async () => { @@ -834,9 +784,9 @@ test("fetchSites keeps only storage pull zones whose state names them", async () expect(sites[0]?.systemHostname).toBe("my-site.b-cdn.net"); }); -// A pull zone can share a site's storage origin without being the site's own zone; only the state's pullZoneId decides. Router-era state (scriptId present) is still discovered for list/show/delete. +// A pull zone can share a site's storage origin without being the site's own zone; only the state's pullZoneId decides. test("fetchSites ignores another pull zone pointed at the site's storage zone", async () => { - store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState({ scriptId: 20 }))); + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); const coreClient = fakeCoreClient({ calls: [], storageZones: [ZONE], @@ -860,13 +810,6 @@ test("fetchSites ignores another pull zone pointed at the site's storage zone", expect(sites[0]?.state.pullZoneId).toBe(30); }); -test("requireRulesSite rejects router-era sites", () => { - expect(() => requireRulesSite(fakeState({ scriptId: 20 }))).toThrow( - "retired router architecture", - ); - expect(() => requireRulesSite(fakeState())).not.toThrow(); -}); - test("siteContextFromZone is null for a zone without site state", async () => { expect(await siteContextFromZone(ZONE)).toBeNull(); }); @@ -877,11 +820,9 @@ test("deleteSiteResources removes the site marker when keeping storage", async ( store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); store.set("deploys/aaa/index.html", "

hi

"); const coreClient = fakeCoreClient({ calls: [] }); - const computeClient = fakeComputeClient({ calls: [] }); const results = await deleteSiteResources({ coreClient, - computeClient, state: fakeState(), keepStorage: true, connection: fakeConnection(), @@ -895,30 +836,19 @@ test("deleteSiteResources removes the site marker when keeping storage", async ( expect(store.has("deploys/aaa/index.html")).toBe(true); }); -test("deleteSiteResources deletes the pull zone and storage zone, plus a router-era script", async () => { +test("deleteSiteResources deletes the pull zone and storage zone", async () => { const coreCalls: Call[] = []; - const computeCalls: Call[] = []; const coreClient = fakeCoreClient({ calls: coreCalls }); - const computeClient = fakeComputeClient({ calls: computeCalls }); const results = await deleteSiteResources({ coreClient, - computeClient, state: fakeState(), }); - expect(computeCalls).toHaveLength(0); expect(results.filter((r) => r.deleted)).toHaveLength(2); - - const routerEra = await deleteSiteResources({ - coreClient, - computeClient, - state: fakeState({ scriptId: 20 }), - }); - const deletedScriptIds = computeCalls - .filter((c) => c.method === "DELETE" && c.path === "/compute/script/{id}") - .map((c) => (c.params as { path: { id: number } }).path.id); - expect(deletedScriptIds).toEqual([20]); - expect(routerEra.filter((r) => r.deleted)).toHaveLength(3); + const deletedPaths = coreCalls + .filter((c) => c.method === "DELETE") + .map((c) => c.path); + expect(deletedPaths).toEqual(["/pullzone/{id}", "/storagezone/{id}"]); }); // Regression: the live API returns GET /pullzone as a paginated envelope diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 777edb8c..6bda468b 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -1,4 +1,3 @@ -import type { createComputeClient } from "@bunny.net/openapi-client"; import type { components } from "@bunny.net/openapi-client/generated/core.d.ts"; import { mapWithConcurrency } from "../../core/concurrency.ts"; import { @@ -55,7 +54,6 @@ import { suffixedResourceName, } from "./constants.ts"; -export type ComputeClient = ReturnType; type PullZone = components["schemas"]["PullZoneModel"]; // Storage-file IO seam; tests swap these for an in-memory store (bun's `mock.module` leaks across files, this doesn't). @@ -605,15 +603,6 @@ export async function fetchSystemHostname( } } -// Router-era sites (state version 1) predate the edge-rule architecture; there is no in-place migration. -export function requireRulesSite(state: RemoteSiteState): void { - if (state.scriptId == null) return; - throw new UserError( - `Site "${state.name}" was created with the retired router architecture.`, - `Delete it (\`bunny sites delete ${state.name}\`) and create it again to deploy with this CLI. The site keeps serving its current deploy until then.`, - ); -} - const PROBE_TIMEOUT_MS = 4000; const PROPAGATION_DEADLINE_MS = 20_000; const PROPAGATION_INTERVAL_MS = 1500; @@ -698,22 +687,21 @@ export async function promoteDeploy(opts: { } export interface TeardownResult { - resource: "pull zone" | "router script" | "storage zone"; + resource: "pull zone" | "storage zone"; id: number; deleted: boolean; error?: string; } -// Tear down a site's resources; the pull zone references the script and storage zone so it goes first, and each step is best-effort so a partial delete can be re-run. +// Tear down a site's resources; the pull zone references the storage zone so it goes first, and each step is best-effort so a partial delete can be re-run. export async function deleteSiteResources(opts: { coreClient: CoreClient; - computeClient: ComputeClient; state: RemoteSiteState; keepStorage?: boolean; /** The storage connection; needed to tombstone the site marker with --keep-storage. */ connection?: StorageZone; }): Promise { - const { coreClient, computeClient, state } = opts; + const { coreClient, state } = opts; const results: TeardownResult[] = []; const attempt = async ( @@ -734,17 +722,8 @@ export async function deleteSiteResources(opts: { params: { path: { id: state.pullZoneId } }, }), ); - // Router-era sites (state version 1) still carry a script to clean up. - const scriptId = state.scriptId; - if (scriptId != null) { - await attempt("router script", scriptId, () => - computeClient.DELETE("/compute/script/{id}", { - params: { path: { id: scriptId } }, - }), - ); - } if (opts.keepStorage) { - // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone and router are gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures. + // The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone is gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures. if (opts.connection && results.every((r) => r.deleted)) { try { await siteFiles.remove(opts.connection, REMOTE_STATE_PATH); diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 9674b3ab..a5881227 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -22,9 +22,9 @@ const validState: RemoteSiteState = { test("parseRemoteState round-trips a valid state", () => { expect(parseRemoteState(JSON.stringify(validState))).toEqual(validState); - // Router-era (version 1) states still parse, keyed by their scriptId. + // The router-era version 1 format was never released, so it no longer parses. const routerEra = { ...validState, version: 1, scriptId: 3 }; - expect(parseRemoteState(JSON.stringify(routerEra))).toEqual(routerEra); + expect(parseRemoteState(JSON.stringify(routerEra))).toBeNull(); }); test("parseRemoteState rejects garbage", () => { diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 6b1acf22..bbc41763 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -7,7 +7,7 @@ export const REMOTE_STATE_PATH = "_bunny/site.json"; // Deploys live at `deploys/{id}/...` inside the storage zone. export const DEPLOYS_DIR = "deploys"; -// Version 2 dropped the router script; version-1 states (router-era) still parse so delete/list keep working on them. +// State format version; the router-era version 1 was never released, so only this exact version parses. export const STATE_VERSION = 2; export const DEFAULT_KEEP_DEPLOYS = 5; @@ -37,8 +37,6 @@ export interface RemoteSiteState { name: string; storageZoneId: number; pullZoneId: number; - /** Router-era (version 1) sites only; presence marks a site the retired script architecture serves. */ - scriptId?: number; /** Custom production domain, when one has been attached. */ domain?: string; current?: string; @@ -181,15 +179,13 @@ export function parseRemoteState(raw: string): RemoteSiteState | null { if (!data || typeof data !== "object") return null; const s = data as Record; if ( - typeof s.version !== "number" || - // A future format is rejected rather than misread; older CLIs lacked this bound, which is why version 2 couldn't rely on it. - s.version > STATE_VERSION || + // Any other version is rejected rather than misread; the router-era version 1 was never released. + s.version !== STATE_VERSION || typeof s.name !== "string" || // Reject an illegal name: it would flow unquoted into storage paths and generated CI YAML. !isValidSiteName(s.name) || typeof s.storageZoneId !== "number" || typeof s.pullZoneId !== "number" || - (s.scriptId !== undefined && typeof s.scriptId !== "number") || !Array.isArray(s.deploys) ) { return null; diff --git a/packages/cli/src/commands/sites/delete.ts b/packages/cli/src/commands/sites/delete.ts index 0dee9666..5b7f0269 100644 --- a/packages/cli/src/commands/sites/delete.ts +++ b/packages/cli/src/commands/sites/delete.ts @@ -1,7 +1,4 @@ -import { - createComputeClient, - createCoreClient, -} from "@bunny.net/openapi-client"; +import { createCoreClient } from "@bunny.net/openapi-client"; import { resolveConfig } from "../../config/index.ts"; import { clientOptions } from "../../core/client-options.ts"; import { defineCommand } from "../../core/define-command.ts"; @@ -26,7 +23,7 @@ interface DeleteArgs extends SiteSelectorArgs { "keep-storage"?: boolean; } -// Delete a site: its pull zone and (unless --keep-storage) the storage zone with every deploy; router-era sites also lose their script; requires typing the name to confirm unless --force. +// Delete a site: its pull zone and (unless --keep-storage) the storage zone with every deploy; requires typing the name to confirm unless --force. export const sitesDeleteCommand = defineCommand({ command: "delete [site]", describe: "Delete a site and its resources.", @@ -58,7 +55,6 @@ export const sitesDeleteCommand = defineCommand({ const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); - const computeClient = createComputeClient(options); const { site } = await selectSite(coreClient, { site: args.site, @@ -89,7 +85,6 @@ export const sitesDeleteCommand = defineCommand({ const results = await withSpinner("Deleting site resources...", () => deleteSiteResources({ coreClient, - computeClient, state, keepStorage: args["keep-storage"], connection: site.connection, diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 2517b9d8..1232e7bc 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -21,7 +21,6 @@ import { fetchSystemHostname, promoteDeploy, readRemoteState, - requireRulesSite, writeRemoteState, } from "./api.ts"; import { @@ -268,7 +267,6 @@ export const sitesDeployCommand = defineCommand({ }, }); const { state, connection } = site; - requireRulesSite(state); // The site's first-ever deploy is the one moment we offer a custom domain; declining self-limits, since the list is never empty again. const firstDeploy = state.deploys.length === 0; diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index e8b63467..8a1cf095 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -5,12 +5,7 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; -import { - promoteDeploy, - readRemoteState, - requireRulesSite, - writeRemoteState, -} from "../api.ts"; +import { promoteDeploy, readRemoteState, writeRemoteState } from "../api.ts"; import { findDeploy, markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, @@ -69,7 +64,6 @@ export const sitesDeploymentsPublishCommand = defineCommand({ }); // No etag kept from this read: the destructive phase re-reads state and writes with the fresh one. const { state, connection } = site; - requireRulesSite(state); let targetId = args.id; if (args.previous) {