From da4cc7ec99d73a5fa5c302b16c0ee0f21c05fa59 Mon Sep 17 00:00:00 2001 From: Jamie Barton Date: Thu, 8 Oct 2026 09:37:23 +0000 Subject: [PATCH 1/3] feat(sites): add bunny sites optimizer and sites.optimizer config --- .changeset/sites-optimizer.md | 5 + packages/cli/README.md | 11 +- packages/cli/src/commands/sites/create.ts | 12 ++ packages/cli/src/commands/sites/deploy.ts | 19 ++ packages/cli/src/commands/sites/index.ts | 2 + .../src/commands/sites/optimizer/disable.ts | 35 +++ .../src/commands/sites/optimizer/enable.ts | 92 ++++++++ .../cli/src/commands/sites/optimizer/index.ts | 14 ++ .../src/commands/sites/optimizer/shared.ts | 56 +++++ .../src/commands/sites/optimizer/site.test.ts | 37 ++++ .../cli/src/commands/sites/optimizer/site.ts | 128 +++++++++++ .../src/commands/sites/optimizer/status.ts | 41 ++++ .../src/commands/sites/optimizer/sync.test.ts | 14 ++ .../cli/src/commands/sites/optimizer/sync.ts | 141 +++++++++++++ packages/config/generated/schema.json | 3 + packages/config/src/schema.ts | 3 +- packages/tools/src/pullzones/index.ts | 14 ++ .../tools/src/pullzones/optimizer.test.ts | 110 ++++++++++ packages/tools/src/pullzones/optimizer.ts | 199 ++++++++++++++++++ 19 files changed, 934 insertions(+), 2 deletions(-) create mode 100644 .changeset/sites-optimizer.md create mode 100644 packages/cli/src/commands/sites/optimizer/disable.ts create mode 100644 packages/cli/src/commands/sites/optimizer/enable.ts create mode 100644 packages/cli/src/commands/sites/optimizer/index.ts create mode 100644 packages/cli/src/commands/sites/optimizer/shared.ts create mode 100644 packages/cli/src/commands/sites/optimizer/site.test.ts create mode 100644 packages/cli/src/commands/sites/optimizer/site.ts create mode 100644 packages/cli/src/commands/sites/optimizer/status.ts create mode 100644 packages/cli/src/commands/sites/optimizer/sync.test.ts create mode 100644 packages/cli/src/commands/sites/optimizer/sync.ts create mode 100644 packages/tools/src/pullzones/optimizer.test.ts create mode 100644 packages/tools/src/pullzones/optimizer.ts diff --git a/.changeset/sites-optimizer.md b/.changeset/sites-optimizer.md new file mode 100644 index 00000000..c494c995 --- /dev/null +++ b/.changeset/sites-optimizer.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny sites optimizer enable|disable|status` turns Bunny Optimizer on for a site, and `sites.optimizer` in bunny.jsonc keeps it in line on every deploy diff --git a/packages/cli/README.md b/packages/cli/README.md index f87bd5ce..0de114b5 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1075,6 +1075,12 @@ bunny sites show # resources, domains, SSL bunny sites open --print bunny sites ssl --no-force-ssl +# Bunny Optimizer: images served as WebP and sized for each device ($9.50/month per site) +bunny sites optimizer status +bunny sites optimizer enable # shows the price, confirms, configures the pull zone, purges +bunny sites optimizer enable my-site --yes # accept the price non-interactively +bunny sites optimizer disable + # CI, linking, maintenance bunny sites ci init # GitHub Actions: push to main goes live bunny sites ci init --framework astro @@ -1085,7 +1091,9 @@ bunny sites delete my-site --keep-storage # typed-name confirmation; **Client-side routing and 404s.** Single-page apps serve `index.html` for extensionless paths that aren't files (a refresh on `/products/42` returns the app with a 200), while missing assets still 404. This is on automatically when the detected framework is a single-page one (Vite, Create React App, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has a root `index.html` and no `404.html` (a built not-found page wins over the framework heuristic); set `sites.spa` to `true` or `false` in `bunny.jsonc`, or pass `--spa`/`--no-spa` on the deploy, to decide yourself. When neither applies but the output looks client-routed (a single root `index.html` plus scripts), an interactive deploy asks once and saves the answer to `sites.spa`; the flags answer it in scripts. Otherwise a root `404.html` in the output (as Astro, Eleventy, Hugo, and most static generators emit) becomes the site's not-found page, served with a 404 status. The choice is recorded per deploy and follows rollbacks; `sites show` prints the live one. -Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) 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, a Blazor WebAssembly `.csproj`, `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. +**Bunny Optimizer.** With Optimizer on, a plain `` is served as WebP and scaled down for mobile, with no code changes. `sites optimizer enable` turns it on with automatic optimization, WebP and the Dynamic Image API (`?width=`, `?quality=`, ... for framework image loaders), keys the cache on those image parameters only (so `utm_source` and friends don't split it), turns minification off (frameworks already minify), and purges the cache so old full-size files stop being served. It is billed monthly per site, so it asks before turning on; `--yes` accepts the price in scripts. `disable` turns it off and restores a cache key that ignores query strings. To keep it managed from the repo, set `sites.optimizer` to `true` or `false` in `bunny.jsonc`: every `sites deploy` (including CI from `sites ci init`) brings the pull zone in line before publishing. The first time it turns Optimizer on, an interactive deploy asks; unattended, the config value is the consent. Leaving it unset leaves the pull zone alone, and an interactive deploy with over 1 MB of PNG, JPEG, GIF or WebP images prints a one-line suggestion (set `sites.optimizer: false` to hide it). `sites create` offers it next to the custom domain and saves the answer. + +Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`, `optimizer`) 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, a Blazor WebAssembly `.csproj`, `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. **Importing an existing zone.** `sites create --from-zone ` turns a storage zone you already serve through a pull zone (for example one deployed by a community storage action) into a site, so custom hostnames, certificates, and DNS stay as they are. The import only writes the site state and the edge rules that block `/_bunny/` and `/deploys/`; nothing else visitors see changes until the first `sites deploy`, which adds the rest of the site's rules and cache settings and goes live on the existing hostnames (a wildcard hostname never becomes the production domain). Files already at the zone root stay in storage but stop being served after that deploy; delete them yourself once you're happy. The edge blocks `_bunny/` and `deploys/` at any depth, so a nested path such as `docs/_bunny/` or `assets/deploys/` stops being served at import. The import refuses a zone that already has a root `deploys` or `_bunny` entry, a pull zone running an edge script, or anything but exactly one storage-backed pull zone; `--region`, `--tier` and `--domain` don't apply. Other edge rules on the pull zone are left in place. `sites delete` treats an imported site like any other and deletes both zones with everything in them. @@ -1104,6 +1112,7 @@ Every deploy publishes: the files land in an immutable `deploys//` directory | `--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 | | `--force-ssl` | `ssl` | Force HTTP→HTTPS on the system host; `--no-force-ssl` allows plain HTTP | +| `--yes`, `-y` | `optimizer enable` | Accept the Optimizer price without the confirmation prompt (alias of `--force`) | | `--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 | diff --git a/packages/cli/src/commands/sites/create.ts b/packages/cli/src/commands/sites/create.ts index 7a6a239d..6763cce8 100644 --- a/packages/cli/src/commands/sites/create.ts +++ b/packages/cli/src/commands/sites/create.ts @@ -13,6 +13,7 @@ import { errorMessage, UserError } from "@/core/errors.ts"; import { formatKeyValue } from "@/core/format.ts"; import { normalizeHostname } from "@/core/hostnames/index.ts"; import { logger } from "@/core/logger.ts"; +import { toolContext } from "@/core/tool-context.ts"; import type { OutputFormat } from "@/core/types.ts"; import { confirm, @@ -33,6 +34,7 @@ import { loadSiteConfig } from "./config.ts"; import { isValidSiteName } from "./constants.ts"; import { setupSiteDomain } from "./domains/index.ts"; import { saveSiteLink } from "./interactive.ts"; +import { offerOptimizerOnCreate } from "./optimizer/sync.ts"; import { createSiteWithProgress, promptSiteName } from "./provision.ts"; interface CreateArgs { @@ -382,6 +384,16 @@ export const sitesCreateCommand = defineCommand({ } } + // Optimizer is billed, so it's only offered, defaulting to no; an answer is saved so deploys keep the zone in line with it. + if (interactive && siteConfig?.optimizer === undefined) { + logger.log(); + await offerOptimizerOnCreate({ + ctx: toolContext(config, { verbose }), + pullZone: result.state.pullZoneId, + site: name, + }); + } + // GitHub deployments: offer the workflow scaffold when this is a GitHub repo. if (interactive) { const root = await gitTopLevel(process.cwd()); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 96293a4e..098c43a6 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -9,6 +9,7 @@ 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 { toolContext } from "@/core/tool-context.ts"; import { confirm, isInteractive, @@ -48,6 +49,7 @@ import { siteLinkOption, siteOptionBuilder, } from "./interactive.ts"; +import { offerOptimizer, syncOptimizer } from "./optimizer/sync.ts"; import { createLinkedSite, promptSiteName } from "./provision.ts"; import { collectFiles, hashFiles, uploadDeploy } from "./uploader.ts"; @@ -459,6 +461,19 @@ export const sitesDeployCommand = defineCommand({ : await fetchSystemHostname(coreClient, state.pullZoneId); const production = productionUrl(state, systemHost); + // Before publishing, so the publish's purge also drops files cached under the old settings. + const tools = toolContext(config, { verbose }); + const optimizer = siteConfig?.config.optimizer; + if (optimizer !== undefined) { + await syncOptimizer({ + ctx: tools, + pullZone: state.pullZoneId, + site: state.name, + enabled: optimizer, + output, + }); + } + if (skipUpload && alreadyLive) { await withSpinner("Checking routing...", () => promoteDeploy({ coreClient, state, deployId }), @@ -579,6 +594,10 @@ export const sitesDeployCommand = defineCommand({ if (production) logger.info(`Production: ${production}`); if (notFoundNote) logger.info(notFoundNote); + if (optimizer === undefined && isInteractive(output)) { + await offerOptimizer({ ctx: tools, pullZone: state.pullZoneId, files }); + } + // Domainless sites: the first deploy offers a custom production domain, later ones just hint. if (!state.domain) { logger.log(); diff --git a/packages/cli/src/commands/sites/index.ts b/packages/cli/src/commands/sites/index.ts index cd5978d4..86c706cc 100644 --- a/packages/cli/src/commands/sites/index.ts +++ b/packages/cli/src/commands/sites/index.ts @@ -9,6 +9,7 @@ import { sitesLinkCommand } from "./link.ts"; import { sitesListCommand } from "./list.ts"; import { sitesMigrateCommand } from "./migrate.ts"; import { sitesOpenCommand } from "./open.ts"; +import { sitesOptimizerNamespace } from "./optimizer/index.ts"; import { sitesShowCommand } from "./show.ts"; import { sitesSslCommand } from "./ssl.ts"; import { sitesUnlinkCommand } from "./unlink.ts"; @@ -25,6 +26,7 @@ export const sitesNamespace = defineNamespace( sitesDeploymentsNamespace, ...sitesDomainsCommands, sitesSslCommand, + sitesOptimizerNamespace, sitesCiNamespace, sitesLinkCommand, sitesUnlinkCommand, diff --git a/packages/cli/src/commands/sites/optimizer/disable.ts b/packages/cli/src/commands/sites/optimizer/disable.ts new file mode 100644 index 00000000..c9801ff5 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/disable.ts @@ -0,0 +1,35 @@ +import { pullZonesOptimizerSet } from "@bunny.net/tools/pullzones"; +import { defineToolCommand } from "@/core/define-tool-command.ts"; +import { logger } from "@/core/logger.ts"; +import { sitePositionalBuilder } from "../interactive.ts"; +import { syncOptimizerConfig } from "./shared.ts"; +import { resolveSitePullZone, type SitePullZone } from "./site.ts"; + +const sites = new WeakMap(); + +export const sitesOptimizerDisableCommand = defineToolCommand({ + tool: pullZonesOptimizerSet, + command: "disable [site]", + describe: "Turn off Bunny Optimizer and restore the site's cache settings.", + examples: [["$0 sites optimizer disable", "Turn it off for the linked site"]], + progress: "Turning off Bunny Optimizer...", + + builder: (yargs) => sitePositionalBuilder(yargs), + + prepare: async (args, ctx) => { + const site = await resolveSitePullZone(ctx, args); + sites.set(args, site); + return { input: { pullZone: site.pullZone, enabled: false } }; + }, + + after: (_, args) => syncOptimizerConfig(sites.get(args), false, args.output), + + render: (result, args) => { + const name = sites.get(args)?.name ?? `pull zone ${result.pullZone}`; + logger.success( + result.changed + ? `Bunny Optimizer is off for ${name}${result.purged ? " (cache purged)" : ""}.` + : `Bunny Optimizer is already off for ${name}.`, + ); + }, +}); diff --git a/packages/cli/src/commands/sites/optimizer/enable.ts b/packages/cli/src/commands/sites/optimizer/enable.ts new file mode 100644 index 00000000..b1f387e2 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/enable.ts @@ -0,0 +1,92 @@ +import { + pullZonesOptimizerGet, + pullZonesOptimizerSet, +} from "@bunny.net/tools/pullzones"; +import { DONE, defineToolCommand } from "@/core/define-tool-command.ts"; +import { logger } from "@/core/logger.ts"; +import { confirm, requireConfirmable, withSpinner } from "@/core/ui.ts"; +import { loadSiteConfig } from "../config.ts"; +import { sitePositionalBuilder } from "../interactive.ts"; +import { formatPrice, syncOptimizerConfig } from "./shared.ts"; +import { resolveSitePullZone, type SitePullZone } from "./site.ts"; + +const sites = new WeakMap(); + +export const sitesOptimizerEnableCommand = defineToolCommand({ + tool: pullZonesOptimizerSet, + command: "enable [site]", + describe: + "Turn on Bunny Optimizer: images served as WebP and sized for each device.", + examples: [ + ["$0 sites optimizer enable", "Turn it on for the linked site"], + ["$0 sites optimizer enable my-site --yes", "Skip the price confirmation"], + ], + epilogue: + "Optimizer is billed monthly per site. Turning it on also adds the Dynamic Image API (?width=, ?quality=, ...) for srcset loaders, keys the cache on those parameters only, turns minification off (frameworks already minify), and purges the cache.", + progress: "Turning on Bunny Optimizer...", + + builder: (yargs) => + sitePositionalBuilder(yargs).option("force", { + type: "boolean", + alias: ["yes", "y"], + default: false, + describe: "Skip the price confirmation", + }), + + prepare: async (args, ctx) => { + const site = await resolveSitePullZone(ctx, args); + sites.set(args, site); + const status = await withSpinner("Fetching Optimizer status...", () => + pullZonesOptimizerGet.invoke(ctx, { pullZone: site.pullZone }), + ); + if (status.enabled && status.configured) { + if (args.output === "json") { + logger.log( + JSON.stringify({ ...status, changed: false, purged: false }, null, 2), + ); + } else { + logger.info(`Bunny Optimizer is already on for ${site.name}.`); + } + syncOptimizerConfig(site, true, args.output); + return DONE; + } + // Already paying: re-applying the settings needs no consent. + if (status.enabled) { + return { input: { pullZone: site.pullZone, enabled: true } }; + } + + const price = formatPrice(status.monthlyPrice); + requireConfirmable(args.output, { + force: args.force, + message: `Bunny Optimizer costs ${price} for ${site.name}; turning it on needs a confirmation prompt.`, + hint: "Re-run with --yes to accept the price non-interactively.", + }); + return { + input: { pullZone: site.pullZone, enabled: true }, + confirm: () => + confirm( + `Turn on Bunny Optimizer for ${site.name}? It costs ${price}.`, + { force: args.force, initial: true }, + ), + }; + }, + + after: (_, args) => syncOptimizerConfig(sites.get(args), true, args.output), + + render: (result, args) => { + const name = sites.get(args)?.name ?? `pull zone ${result.pullZone}`; + logger.success( + result.changed + ? `Bunny Optimizer is on for ${name}${result.purged ? " (cache purged)" : ""}.` + : `Bunny Optimizer is already on for ${name}.`, + ); + logger.dim( + " Images are now served as WebP and sized for each device, with no code changes.", + ); + if (loadSiteConfig()?.config.optimizer === undefined) { + logger.dim( + " Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc", + ); + } + }, +}); diff --git a/packages/cli/src/commands/sites/optimizer/index.ts b/packages/cli/src/commands/sites/optimizer/index.ts new file mode 100644 index 00000000..5f1f76a7 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/index.ts @@ -0,0 +1,14 @@ +import { defineNamespace } from "@/core/define-namespace.ts"; +import { sitesOptimizerDisableCommand } from "./disable.ts"; +import { sitesOptimizerEnableCommand } from "./enable.ts"; +import { sitesOptimizerStatusCommand } from "./status.ts"; + +export const sitesOptimizerNamespace = defineNamespace( + "optimizer", + "Serve a site's images as WebP sized for each device (Bunny Optimizer).", + [ + sitesOptimizerStatusCommand, + sitesOptimizerEnableCommand, + sitesOptimizerDisableCommand, + ], +); diff --git a/packages/cli/src/commands/sites/optimizer/shared.ts b/packages/cli/src/commands/sites/optimizer/shared.ts new file mode 100644 index 00000000..fc65c39c --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/shared.ts @@ -0,0 +1,56 @@ +import type { OptimizerStatus } from "@bunny.net/tools/pullzones"; +import { formatKeyValue } from "@/core/format.ts"; +import { logger } from "@/core/logger.ts"; +import type { OutputFormat } from "@/core/types.ts"; +import { loadSiteConfig, saveSiteConfig } from "../config.ts"; +import type { SitePullZone } from "./site.ts"; + +export function formatPrice(monthly: number): string { + return `$${monthly.toFixed(2)}/month`; +} + +export function renderOptimizerStatus( + site: string, + status: OptimizerStatus, + output: OutputFormat, +): void { + const onOff = (on: boolean) => (on ? "on" : "off"); + const cacheKey = + status.cacheKeyParameters === null + ? "all query parameters" + : status.cacheKeyParameters.length === 0 + ? "query strings ignored" + : status.cacheKeyParameters.join(", "); + logger.log( + formatKeyValue( + [ + { key: "Site", value: site }, + { key: "Optimizer", value: onOff(status.enabled) }, + { key: "WebP", value: onOff(status.webp) }, + { key: "Dynamic images", value: onOff(status.dynamicImages) }, + { + key: "Minify CSS/JS", + value: `${onOff(status.minify.css)}/${onOff(status.minify.js)}`, + }, + { key: "Cache key", value: cacheKey }, + { key: "Price", value: formatPrice(status.monthlyPrice) }, + ], + output, + ), + ); +} + +/** Keep `sites.optimizer` in bunny.jsonc from undoing a change on the next deploy; only touches a value that is already set, for the site this directory points at. */ +export function syncOptimizerConfig( + site: SitePullZone | undefined, + enabled: boolean, + output: OutputFormat, +): void { + if (!site?.fromDirectory) return; + const configured = loadSiteConfig()?.config.optimizer; + if (configured === undefined || configured === enabled) return; + const path = saveSiteConfig({ optimizer: enabled }); + if (output !== "json") { + logger.dim(` Updated sites.optimizer to ${enabled} in ${path}`); + } +} diff --git a/packages/cli/src/commands/sites/optimizer/site.test.ts b/packages/cli/src/commands/sites/optimizer/site.test.ts new file mode 100644 index 00000000..f1517af0 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/site.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { type CoreClient, createToolContext } from "@bunny.net/tools"; +import { resolveSitePullZone } from "./site.ts"; + +const zones = [ + { Id: 1, Name: "sites-blog-abc123", StorageZoneId: 10, Enabled: true }, + { Id: 2, Name: "sites-shop-xyz789", StorageZoneId: 20, Enabled: true }, + // An imported site keeps its own pull zone name. + { Id: 3, Name: "legacy-cdn", StorageZoneId: 30, Enabled: true }, + { Id: 4, Name: "api", StorageZoneId: null, Enabled: true }, +]; + +const ctx = createToolContext({ + clients: { + core: { + GET: () => Promise.resolve({ data: zones }), + } as unknown as CoreClient, + }, +}); + +test("resolves a site name to its pull zone", async () => { + expect( + await resolveSitePullZone(ctx, { site: "shop", output: "json" }), + ).toEqual({ name: "shop", pullZone: 2, fromDirectory: false }); +}); + +test("resolves a storage zone ID, including an imported site's", async () => { + expect( + await resolveSitePullZone(ctx, { site: "30", output: "json" }), + ).toMatchObject({ pullZone: 3 }); +}); + +test("an unknown site names the fix", async () => { + await expect( + resolveSitePullZone(ctx, { site: "nope", output: "json" }), + ).rejects.toThrow('No site found for "nope".'); +}); diff --git a/packages/cli/src/commands/sites/optimizer/site.ts b/packages/cli/src/commands/sites/optimizer/site.ts new file mode 100644 index 00000000..ebf57c99 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/site.ts @@ -0,0 +1,128 @@ +import type { ToolContext } from "@bunny.net/tools"; +import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones"; +import { UserError } from "@/core/errors.ts"; +import { loadManifest } from "@/core/manifest.ts"; +import type { OutputFormat } from "@/core/types.ts"; +import { isInteractive, prompts, withSpinner } from "@/core/ui.ts"; +import { loadSiteConfig } from "../config.ts"; +import { + SITES_MANIFEST, + type SiteManifest, + siteResourcePattern, +} from "../constants.ts"; + +export interface SitePullZone { + name: string; + pullZone: number; + /** Resolved from `.bunny/site.json` or `sites.name`, so bunny.jsonc describes this site. */ + fromDirectory: boolean; +} + +// Created sites name their zones `sites-{name}-{suffix}`. +const CREATED_SITE_ZONE = /^sites-(.+)-[a-z0-9]{6}$/i; + +function displayName(zone: PullZone): string { + return CREATED_SITE_ZONE.exec(zone.name)?.[1] ?? zone.name; +} + +function pick( + zones: PullZone[], + name: string | undefined, +): PullZone | undefined { + if (zones.length <= 1) return zones[0]; + // An imported storage zone can have other pull zones in front of it; the site's own carries its name. + const pattern = name ? siteResourcePattern(name) : CREATED_SITE_ZONE; + const named = zones.filter((z) => pattern.test(z.name)); + return named.length === 1 ? named[0] : undefined; +} + +function byStorageZone( + zones: PullZone[], + id: number, + name?: string, +): PullZone | undefined { + return pick( + zones.filter((z) => z.storageZoneId === id), + name, + ); +} + +function byName(zones: PullZone[], name: string): PullZone | undefined { + const pattern = siteResourcePattern(name); + const created = zones.filter((z) => pattern.test(z.name)); + if (created.length > 1) { + throw new UserError( + `Multiple sites are named "${name}".`, + "Pass the site's storage zone ID instead (see `bunny sites list`).", + ); + } + return created[0] ?? zones.find((z) => z.name === name); +} + +/** + * Resolve a site to its pull zone through the pull zone list, in the same order as every sites command: explicit ref, `.bunny/site.json`, `sites.name`, then a picker. + * + * Sites are storage-backed pull zones, so this needs no storage credentials; an imported site whose pull zone kept its own name resolves by its storage zone ID. + */ +export async function resolveSitePullZone( + ctx: ToolContext, + args: { site?: string; output: OutputFormat }, +): Promise { + const zones = await withSpinner("Resolving site...", async () => + (await pullZonesList.invoke(ctx, {})).filter( + (z) => z.storageZoneId !== null, + ), + ); + const found = ( + zone: PullZone | undefined, + label: string, + fromDirectory = false, + ): SitePullZone => { + if (!zone) { + throw new UserError( + `No site found for "${label}".`, + "Run `bunny sites list` to see your sites, and pass a site name or storage zone ID.", + ); + } + return { name: displayName(zone), pullZone: zone.id, fromDirectory }; + }; + + const ref = args.site; + if (ref) { + const zone = /^\d+$/.test(ref) + ? byStorageZone(zones, Number(ref)) + : byName(zones, ref); + return found(zone, ref); + } + + const manifest = loadManifest(SITES_MANIFEST); + if (manifest.id) { + return found( + byStorageZone(zones, manifest.id, manifest.name), + manifest.name ?? String(manifest.id), + true, + ); + } + + const configured = loadSiteConfig()?.config.name; + if (configured) return found(byName(zones, configured), configured, true); + + const sites = zones.filter((z) => CREATED_SITE_ZONE.test(z.name)); + if (!isInteractive(args.output) || sites.length === 0) { + throw new UserError( + "No site specified and no linked site found.", + "Pass a site name or run `bunny sites link`.", + ); + } + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a site:", + choices: sites.map((z) => ({ + title: `${displayName(z)} (${z.storageZoneId})`, + value: z, + })), + }); + if (!selected) throw new UserError("A site is required."); + return found(selected as PullZone, ""); +} diff --git a/packages/cli/src/commands/sites/optimizer/status.ts b/packages/cli/src/commands/sites/optimizer/status.ts new file mode 100644 index 00000000..eba4a8a0 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/status.ts @@ -0,0 +1,41 @@ +import { pullZonesOptimizerGet } from "@bunny.net/tools/pullzones"; +import { defineToolCommand } from "@/core/define-tool-command.ts"; +import { logger } from "@/core/logger.ts"; +import { sitePositionalBuilder } from "../interactive.ts"; +import { formatPrice, renderOptimizerStatus } from "./shared.ts"; +import { resolveSitePullZone } from "./site.ts"; + +const siteNames = new WeakMap(); + +export const sitesOptimizerStatusCommand = defineToolCommand({ + tool: pullZonesOptimizerGet, + command: "status [site]", + describe: "Show whether Bunny Optimizer is on for a site.", + examples: [ + ["$0 sites optimizer status", "Status for the linked site"], + ["$0 sites optimizer status my-site --output json", "As JSON"], + ], + progress: "Fetching Optimizer status...", + + builder: (yargs) => sitePositionalBuilder(yargs), + + prepare: async (args, ctx) => { + const site = await resolveSitePullZone(ctx, args); + siteNames.set(args, site.name); + return { input: { pullZone: site.pullZone } }; + }, + + render: (status, args) => { + renderOptimizerStatus(siteNames.get(args) ?? "", status, args.output); + logger.log(); + if (!status.enabled) { + logger.dim( + ` Turn it on (${formatPrice(status.monthlyPrice)}): bunny sites optimizer enable`, + ); + } else if (!status.configured) { + logger.dim( + " Some settings differ from what sites expect; re-apply them: bunny sites optimizer enable", + ); + } + }, +}); diff --git a/packages/cli/src/commands/sites/optimizer/sync.test.ts b/packages/cli/src/commands/sites/optimizer/sync.test.ts new file mode 100644 index 00000000..ec28a55e --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/sync.test.ts @@ -0,0 +1,14 @@ +import { expect, test } from "bun:test"; +import { optimizableImages } from "./sync.ts"; + +test("optimizableImages counts only formats Optimizer converts", () => { + expect( + optimizableImages([ + { path: "images/hero.png", size: 500 }, + { path: "a.JPG", size: 100 }, + { path: "logo.svg", size: 50 }, + { path: "index.html", size: 10 }, + { path: "photo.webp", size: 20 }, + ]), + ).toEqual({ count: 3, bytes: 620 }); +}); diff --git a/packages/cli/src/commands/sites/optimizer/sync.ts b/packages/cli/src/commands/sites/optimizer/sync.ts new file mode 100644 index 00000000..065abb95 --- /dev/null +++ b/packages/cli/src/commands/sites/optimizer/sync.ts @@ -0,0 +1,141 @@ +import type { ToolContext } from "@bunny.net/tools"; +import { + OPTIMIZER_MONTHLY_PRICE, + pullZonesOptimizerGet, + pullZonesOptimizerSet, +} from "@bunny.net/tools/pullzones"; +import { errorMessage } from "@/core/errors.ts"; +import { formatBytes } from "@/core/format.ts"; +import { logger } from "@/core/logger.ts"; +import type { OutputFormat } from "@/core/types.ts"; +import { confirm, isInteractive, withSpinner } from "@/core/ui.ts"; +import { saveSiteConfig } from "../config.ts"; +import { formatPrice } from "./shared.ts"; + +/** + * Bring the site's pull zone in line with `sites.optimizer` before a deploy publishes (the publish purges, so no separate purge here). + * + * Turning it on costs money, so the first time asks in a terminal; unattended, the config value is the consent. Turning it off never asks. + */ +export async function syncOptimizer(opts: { + ctx: ToolContext; + pullZone: number; + site: string; + enabled: boolean; + output: OutputFormat; +}): Promise { + const { ctx, pullZone, site, enabled, output } = opts; + const quiet = output === "json"; + const status = await withSpinner("Checking Bunny Optimizer...", () => + pullZonesOptimizerGet.invoke(ctx, { pullZone }), + ); + if (status.enabled === enabled && status.configured) return; + + const price = formatPrice(status.monthlyPrice); + if (enabled && !status.enabled) { + if (isInteractive(output)) { + const accepted = await confirm( + `sites.optimizer is true in bunny.jsonc. Turn on Bunny Optimizer for ${site}? It costs ${price}.`, + { initial: true, optional: true }, + ); + if (!accepted) { + logger.warn("Bunny Optimizer stays off for this deploy."); + logger.dim( + " Set sites.optimizer to false in bunny.jsonc to stop this prompt.", + ); + return; + } + } else if (!quiet) { + logger.info( + `Turning on Bunny Optimizer (${price}) because sites.optimizer is true in bunny.jsonc.`, + ); + } + } + + await withSpinner( + enabled + ? "Turning on Bunny Optimizer..." + : "Turning off Bunny Optimizer...", + () => + pullZonesOptimizerSet.invoke(ctx, { pullZone, enabled, purge: false }), + ); + if (!quiet) { + logger.success( + enabled + ? status.enabled + ? "Re-applied the Bunny Optimizer settings." + : `Bunny Optimizer is on for ${site}.` + : `Bunny Optimizer is off for ${site}.`, + ); + } +} + +// Formats Optimizer converts and resizes. +const OPTIMIZABLE_IMAGE = /\.(png|jpe?g|gif|webp)$/i; + +/** Below this, the saving isn't worth a line of output. */ +export const OPTIMIZER_HINT_MIN_BYTES = 1024 * 1024; + +export function optimizableImages(files: { path: string; size: number }[]): { + count: number; + bytes: number; +} { + const images = files.filter((f) => OPTIMIZABLE_IMAGE.test(f.path)); + return { + count: images.length, + bytes: images.reduce((sum, f) => sum + f.size, 0), + }; +} + +/** One line after a deploy whose images Optimizer would shrink; never in CI, and gone once `sites.optimizer` is set either way. */ +export async function offerOptimizer(opts: { + ctx: ToolContext; + pullZone: number; + files: { path: string; size: number }[]; +}): Promise { + const { count, bytes } = optimizableImages(opts.files); + if (bytes < OPTIMIZER_HINT_MIN_BYTES) return; + try { + const status = await pullZonesOptimizerGet.invoke(opts.ctx, { + pullZone: opts.pullZone, + }); + if (status.enabled) return; + logger.log(); + logger.info( + `Your build has ${count} images (${formatBytes(bytes)}). Bunny Optimizer serves them as WebP sized for each device, for ${formatPrice(status.monthlyPrice)}: bunny sites optimizer enable`, + ); + logger.dim(" Hide this: set sites.optimizer to false in bunny.jsonc"); + } catch { + // A hint never fails a deploy. + } +} + +/** The create flow's offer, next to the custom domain one. Never fails the create: the site already exists. */ +export async function offerOptimizerOnCreate(opts: { + ctx: ToolContext; + pullZone: number; + site: string; +}): Promise { + const price = formatPrice(OPTIMIZER_MONTHLY_PRICE); + const accepted = await confirm( + `Turn on Bunny Optimizer? Images are served as WebP sized for each device, for ${price}.`, + { initial: false, optional: true }, + ); + if (!accepted) return; + try { + // A new site has nothing cached yet, so there's nothing to purge. + await withSpinner("Turning on Bunny Optimizer...", () => + pullZonesOptimizerSet.invoke(opts.ctx, { + pullZone: opts.pullZone, + enabled: true, + purge: false, + }), + ); + const path = saveSiteConfig({ optimizer: true }); + logger.success(`Bunny Optimizer is on for ${opts.site}.`); + logger.dim(` Saved sites.optimizer: true to ${path}`); + } catch (err) { + logger.warn(`Couldn't turn on Bunny Optimizer: ${errorMessage(err)}`); + logger.dim(` Retry later: bunny sites optimizer enable ${opts.site}`); + } +} diff --git a/packages/config/generated/schema.json b/packages/config/generated/schema.json index d7dd304b..1e48c424 100644 --- a/packages/config/generated/schema.json +++ b/packages/config/generated/schema.json @@ -225,6 +225,9 @@ }, "spa": { "type": "boolean" + }, + "optimizer": { + "type": "boolean" } }, "additionalProperties": false diff --git a/packages/config/src/schema.ts b/packages/config/src/schema.ts index c49e1b44..b5501e69 100644 --- a/packages/config/src/schema.ts +++ b/packages/config/src/schema.ts @@ -67,12 +67,13 @@ export const RegionsConfigSchema = z.union([ }), ]); -// Static-site config (`bunny sites`), all optional: `name` links the directory to a site, `dir` is the deploy root, `build` is the command `sites deploy --build` runs, `spa` serves index.html for client-side routes (default: detected from the framework). +// Static-site config (`bunny sites`), all optional: `name` links the directory to a site, `dir` is the deploy root, `build` is the command `sites deploy --build` runs, `spa` serves index.html for client-side routes (default: detected from the framework), `optimizer` turns Bunny Optimizer on or off on every deploy (unset leaves the pull zone alone). export const SiteConfigSchema = z.object({ name: z.string().optional(), dir: z.string().optional(), build: z.string().optional(), spa: z.boolean().optional(), + optimizer: z.boolean().optional(), }); // The `app` block: Magic Containers deploy intent. diff --git a/packages/tools/src/pullzones/index.ts b/packages/tools/src/pullzones/index.ts index a8e23f26..be232a13 100644 --- a/packages/tools/src/pullzones/index.ts +++ b/packages/tools/src/pullzones/index.ts @@ -4,9 +4,21 @@ import type { CoreClient } from "../context.ts"; import type { Tool } from "../define-tool.ts"; import { defineTool } from "../define-tool.ts"; import { type PullZone, PullZoneSchema, toPullZone } from "./model.ts"; +import { pullZonesOptimizerGet, pullZonesOptimizerSet } from "./optimizer.ts"; export type { PullZone, PullZoneModel } from "./model.ts"; export { PullZoneSchema, toPullZone } from "./model.ts"; +export { + OPTIMIZER_MONTHLY_PRICE, + OPTIMIZER_QUERY_PARAMETERS, + type OptimizerStatus, + OptimizerStatusSchema, + type OptimizerUpdate, + OptimizerUpdateSchema, + pullZonesOptimizerGet, + pullZonesOptimizerSet, + toOptimizerStatus, +} from "./optimizer.ts"; const pullZoneRef = z .number() @@ -153,4 +165,6 @@ export const pullZonesTools: Tool[] = [ pullZonesCreate, pullZonesDelete, pullZonesPurge, + pullZonesOptimizerGet, + pullZonesOptimizerSet, ]; diff --git a/packages/tools/src/pullzones/optimizer.test.ts b/packages/tools/src/pullzones/optimizer.test.ts new file mode 100644 index 00000000..34639536 --- /dev/null +++ b/packages/tools/src/pullzones/optimizer.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from "bun:test"; +import { type CoreClient, createToolContext } from "../context.ts"; +import { + OPTIMIZER_QUERY_PARAMETERS, + pullZonesOptimizerGet, + pullZonesOptimizerSet, +} from "./optimizer.ts"; + +function fakeCore(zone: Record) { + const calls: Array<{ path: string; body?: unknown }> = []; + const core = { + GET: () => Promise.resolve({ data: { Id: 7, ...zone } }), + POST: (path: string, opts: { body?: Record }) => { + calls.push({ path, body: opts.body }); + if (path === "/pullzone/{id}") Object.assign(zone, opts.body); + return Promise.resolve({ data: {} }); + }, + } as unknown as CoreClient; + return { ctx: createToolContext({ clients: { core } }), calls }; +} + +const ENABLED_ZONE = { + OptimizerEnabled: true, + OptimizerAutomaticOptimizationEnabled: true, + OptimizerEnableWebP: true, + OptimizerEnableManipulationEngine: true, + OptimizerMinifyCSS: false, + OptimizerMinifyJavaScript: false, + IgnoreQueryStrings: false, + QueryStringVaryParameters: [...OPTIMIZER_QUERY_PARAMETERS].reverse(), + OptimizerPricing: 9.5, +}; + +test("pullzones.optimizer.set turns Optimizer on, keys the cache on image parameters only, and purges", async () => { + const { ctx, calls } = fakeCore({ + OptimizerEnabled: false, + IgnoreQueryStrings: true, + OptimizerMinifyCSS: true, + }); + const result = await pullZonesOptimizerSet.invoke(ctx, { + pullZone: 7, + enabled: true, + }); + expect(calls.map((c) => c.path)).toEqual([ + "/pullzone/{id}", + "/pullzone/{id}/purgeCache", + ]); + expect(calls[0]?.body).toMatchObject({ + OptimizerEnabled: true, + OptimizerEnableWebP: true, + OptimizerEnableManipulationEngine: true, + OptimizerMinifyCSS: false, + OptimizerMinifyJavaScript: false, + IgnoreQueryStrings: false, + QueryStringVaryParameters: OPTIMIZER_QUERY_PARAMETERS, + }); + expect(result).toMatchObject({ + enabled: true, + configured: true, + changed: true, + purged: true, + }); +}); + +test("pullzones.optimizer.set leaves a zone that already matches alone", async () => { + const { ctx, calls } = fakeCore({ ...ENABLED_ZONE }); + const result = await pullZonesOptimizerSet.invoke(ctx, { + pullZone: 7, + enabled: true, + }); + expect(calls).toEqual([]); + expect(result).toMatchObject({ changed: false, purged: false }); +}); + +test("pullzones.optimizer.set re-applies drifted settings when Optimizer is already on", async () => { + const { ctx, calls } = fakeCore({ + ...ENABLED_ZONE, + OptimizerMinifyCSS: true, + }); + await pullZonesOptimizerSet.invoke(ctx, { + pullZone: 7, + enabled: true, + purge: false, + }); + expect(calls.map((c) => c.path)).toEqual(["/pullzone/{id}"]); +}); + +test("pullzones.optimizer.set off restores a cache key that ignores query strings", async () => { + const { ctx, calls } = fakeCore({ ...ENABLED_ZONE }); + const result = await pullZonesOptimizerSet.invoke(ctx, { + pullZone: 7, + enabled: false, + }); + expect(calls[0]?.body).toEqual({ + OptimizerEnabled: false, + IgnoreQueryStrings: true, + QueryStringVaryParameters: [], + }); + expect(result).toMatchObject({ + enabled: false, + configured: true, + cacheKeyParameters: [], + }); +}); + +test("pullzones.optimizer.get falls back to the list price", async () => { + const { ctx } = fakeCore({ OptimizerEnabled: false, OptimizerPricing: 0 }); + const status = await pullZonesOptimizerGet.invoke(ctx, { pullZone: 7 }); + expect(status).toMatchObject({ enabled: false, monthlyPrice: 9.5 }); +}); diff --git a/packages/tools/src/pullzones/optimizer.ts b/packages/tools/src/pullzones/optimizer.ts new file mode 100644 index 00000000..6dc698f2 --- /dev/null +++ b/packages/tools/src/pullzones/optimizer.ts @@ -0,0 +1,199 @@ +import { UserError } from "@bunny.net/openapi-client"; +import type { components } from "@bunny.net/openapi-client/core"; +import { z } from "zod"; +import type { CoreClient } from "../context.ts"; +import { defineTool } from "../define-tool.ts"; + +type PullZoneModel = components["schemas"]["PullZoneModel"]; +type PullZoneSettings = components["schemas"]["PullZoneSettingsModel"]; + +/** List price per pull zone, used when the API doesn't report one. */ +export const OPTIMIZER_MONTHLY_PRICE = 9.5; + +// The Dynamic Image API's query parameters. Only these vary the cache, so junk like `utm_source` doesn't split it. +export const OPTIMIZER_QUERY_PARAMETERS = [ + "width", + "height", + "aspect_ratio", + "quality", + "sharpen", + "blur", + "crop", + "crop_gravity", + "flip", + "flop", + "brightness", + "saturation", + "hue", + "contrast", + "sepia", + "auto_optimize", + "output", +]; + +// On: automatic WebP and per-device sizing, plus the Dynamic Image API for srcset loaders. Minification stays off because every framework already minifies. +const ENABLED_SETTINGS = { + OptimizerEnabled: true, + OptimizerAutomaticOptimizationEnabled: true, + OptimizerEnableWebP: true, + OptimizerEnableManipulationEngine: true, + OptimizerMinifyCSS: false, + OptimizerMinifyJavaScript: false, + IgnoreQueryStrings: false, + QueryStringVaryParameters: OPTIMIZER_QUERY_PARAMETERS, +} satisfies PullZoneSettings; + +// Off: back to a site's default cache key, which ignores query strings. +const DISABLED_SETTINGS = { + OptimizerEnabled: false, + IgnoreQueryStrings: true, + QueryStringVaryParameters: [], +} satisfies PullZoneSettings; + +export const OptimizerStatusSchema = z.object({ + pullZone: z.number(), + enabled: z.boolean(), + webp: z.boolean(), + dynamicImages: z.boolean(), + minify: z.object({ css: z.boolean(), js: z.boolean() }), + /** Query parameters in the cache key; empty when query strings are ignored, null when every parameter varies it. */ + cacheKeyParameters: z.array(z.string()).nullable(), + /** True when every setting matches what `enabled` implies. */ + configured: z.boolean(), + monthlyPrice: z.number(), +}); +export type OptimizerStatus = z.infer; + +function sameMembers(a: readonly string[], b: readonly string[]): boolean { + const set = new Set(a.map((p) => p.toLowerCase())); + return a.length === b.length && b.every((p) => set.has(p.toLowerCase())); +} + +type OptimizerSettings = typeof ENABLED_SETTINGS | typeof DISABLED_SETTINGS; + +function matches(zone: PullZoneModel, desired: OptimizerSettings): boolean { + return Object.entries(desired).every(([key, want]) => { + const have = zone[key as keyof PullZoneModel]; + if (Array.isArray(want)) { + return sameMembers((have as string[] | null | undefined) ?? [], want); + } + return (have ?? false) === want; + }); +} + +export function toOptimizerStatus(zone: PullZoneModel): OptimizerStatus { + const enabled = zone.OptimizerEnabled ?? false; + const vary = zone.QueryStringVaryParameters ?? []; + return { + pullZone: zone.Id ?? 0, + enabled, + webp: zone.OptimizerEnableWebP ?? false, + dynamicImages: zone.OptimizerEnableManipulationEngine ?? false, + minify: { + css: zone.OptimizerMinifyCSS ?? false, + js: zone.OptimizerMinifyJavaScript ?? false, + }, + cacheKeyParameters: zone.IgnoreQueryStrings + ? [] + : vary.length + ? vary + : null, + configured: matches(zone, enabled ? ENABLED_SETTINGS : DISABLED_SETTINGS), + monthlyPrice: zone.OptimizerPricing || OPTIMIZER_MONTHLY_PRICE, + }; +} + +async function fetchZone( + client: CoreClient, + id: number, + signal?: AbortSignal, +): Promise { + const { data } = await client.GET("/pullzone/{id}", { + params: { path: { id } }, + signal, + }); + if (!data) throw new UserError(`Pull zone ${id} not found.`); + return data; +} + +const pullZoneRef = z + .number() + .int() + .positive() + .describe("Pull zone ID, e.g. `12345`."); + +export const pullZonesOptimizerGet = defineTool({ + name: "pullzones.optimizer.get", + title: "Get Bunny Optimizer status", + description: + "Show whether Bunny Optimizer is on for a pull zone, the settings it runs with, and its monthly price.", + schema: z.strictObject({ pullZone: pullZoneRef }), + kind: "read", + resultSchema: OptimizerStatusSchema, + examples: [[{ pullZone: 12345 }, "Show Optimizer status"]], + run: async (ctx, { pullZone }): Promise => { + ctx.progress("Fetching Optimizer status..."); + return toOptimizerStatus( + await fetchZone(ctx.clients.core, pullZone, ctx.signal), + ); + }, +}); + +export const OptimizerUpdateSchema = OptimizerStatusSchema.extend({ + changed: z.boolean(), + purged: z.boolean(), +}); +export type OptimizerUpdate = z.infer; + +export const pullZonesOptimizerSet = defineTool({ + name: "pullzones.optimizer.set", + title: "Turn Bunny Optimizer on or off", + description: + "Turn Bunny Optimizer on (automatic WebP and per-device sizing, the Dynamic Image API, image parameters in the cache key, minification off) or off for a pull zone, then purge the cache so no unoptimized files are served. Optimizer is billed monthly per pull zone. Settings already in place are left alone.", + schema: z.strictObject({ + pullZone: pullZoneRef, + enabled: z.boolean().describe("`true` turns Optimizer on, `false` off."), + purge: z + .boolean() + .default(true) + .describe("Purge the cache after a change (default: true)."), + }), + kind: "write", + resultSchema: OptimizerUpdateSchema, + examples: [ + [{ pullZone: 12345, enabled: true }, "Turn Optimizer on"], + [{ pullZone: 12345, enabled: false }, "Turn Optimizer off"], + ], + run: async (ctx, { pullZone, enabled, purge }): Promise => { + const client = ctx.clients.core; + ctx.progress("Fetching Optimizer status..."); + const zone = await fetchZone(client, pullZone, ctx.signal); + const desired: OptimizerSettings = enabled + ? ENABLED_SETTINGS + : DISABLED_SETTINGS; + if (matches(zone, desired)) { + return { ...toOptimizerStatus(zone), changed: false, purged: false }; + } + + ctx.progress( + enabled + ? "Turning on Bunny Optimizer..." + : "Turning off Bunny Optimizer...", + ); + await client.POST("/pullzone/{id}", { + params: { path: { id: pullZone } }, + body: desired, + signal: ctx.signal, + }); + // Cached responses were built under the old settings, e.g. full-size PNGs. + if (purge) { + ctx.progress("Purging cache..."); + await client.POST("/pullzone/{id}/purgeCache", { + params: { path: { id: pullZone } }, + signal: ctx.signal, + }); + } + const updated = await fetchZone(client, pullZone, ctx.signal); + return { ...toOptimizerStatus(updated), changed: true, purged: purge }; + }, +}); From 5e8b02717715246389d69d6d173f5a2a83b096ea Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 13 Oct 2026 15:50:20 +0100 Subject: [PATCH 2/3] feat(optimizer): top-level bunny optimizer for any pull zone, shared with sites optimizer --- .changeset/sites-optimizer.md | 2 +- packages/cli/README.md | 18 ++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/optimizer/index.ts | 102 ++++++++ .../cli/src/commands/sites/interactive.ts | 2 +- .../src/commands/sites/optimizer/disable.ts | 35 --- .../src/commands/sites/optimizer/enable.ts | 92 ------- .../cli/src/commands/sites/optimizer/index.ts | 32 ++- .../src/commands/sites/optimizer/shared.ts | 41 +-- .../src/commands/sites/optimizer/status.ts | 41 --- .../cli/src/commands/sites/optimizer/sync.ts | 2 +- packages/cli/src/core/optimizer/commands.ts | 239 ++++++++++++++++++ packages/cli/src/core/optimizer/index.ts | 7 + 13 files changed, 392 insertions(+), 223 deletions(-) create mode 100644 packages/cli/src/commands/optimizer/index.ts delete mode 100644 packages/cli/src/commands/sites/optimizer/disable.ts delete mode 100644 packages/cli/src/commands/sites/optimizer/enable.ts delete mode 100644 packages/cli/src/commands/sites/optimizer/status.ts create mode 100644 packages/cli/src/core/optimizer/commands.ts create mode 100644 packages/cli/src/core/optimizer/index.ts diff --git a/.changeset/sites-optimizer.md b/.changeset/sites-optimizer.md index c494c995..fe02265c 100644 --- a/.changeset/sites-optimizer.md +++ b/.changeset/sites-optimizer.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -`bunny sites optimizer enable|disable|status` turns Bunny Optimizer on for a site, and `sites.optimizer` in bunny.jsonc keeps it in line on every deploy +`bunny optimizer enable|disable|status` turns Bunny Optimizer on for any pull zone or site, with `bunny sites optimizer` and `sites.optimizer` in bunny.jsonc for sites diff --git a/packages/cli/README.md b/packages/cli/README.md index 0de114b5..b38fcf22 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1119,6 +1119,24 @@ Every deploy publishes: the files land in an immutable `deploys//` directory | `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files | | `--force`, `-f` | `create --from-zone`, `deployments publish`, `prune`, `domains remove`, `delete` | Skip the confirmation prompts | +### `bunny optimizer` + +Turn Bunny Optimizer on or off for any pull zone: images are served as WebP and sized for each device ($9.50/month per pull zone). Turning it on also enables the Dynamic Image API (`?width=`, `?quality=`, ...), keys the cache on those parameters only, turns minification off, and purges the cache. + +The pull zone is an optional positional (ID or name). When omitted it resolves from the directory's linked pull zone (`.bunny/pullzone.json`), then a linked site, then an interactive picker that offers to link the directory. For a site, `bunny sites optimizer` does the same by site name and keeps `sites.optimizer` in `bunny.jsonc` in step. + +```bash +bunny optimizer status # linked pull zone or site +bunny optimizer status 12345 --output json +bunny optimizer enable my-zone # shows the price, confirms, configures the pull zone, purges +bunny optimizer enable 12345 --yes # accept the price non-interactively +bunny optimizer disable +``` + +| Flag | Commands | Description | +| -------------- | -------- | ------------------------------------------------------------------------------ | +| `--yes`, `-y` | `enable` | Accept the Optimizer price without the confirmation prompt (alias of `--force`) | + ### `bunny stream` > **Experimental**: hidden from `--help` and the landing page while it stabilizes. diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index c5555329..ba434f53 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { dbNamespace } from "./commands/db/index.ts"; import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; import { openCommand } from "./commands/open.ts"; +import { optimizerNamespace } from "./commands/optimizer/index.ts"; import { pzNamespace } from "./commands/pz/index.ts"; import { registryNamespace } from "./commands/registry/index.ts"; import { sandboxNamespace } from "./commands/sandbox/index.ts"; @@ -37,6 +38,7 @@ const commands: CommandModule[] = [ sandboxNamespace, storageNamespace, sitesNamespace, + optimizerNamespace, configNamespace, skillsNamespace, docsCommand, diff --git a/packages/cli/src/commands/optimizer/index.ts b/packages/cli/src/commands/optimizer/index.ts new file mode 100644 index 00000000..0862ce36 --- /dev/null +++ b/packages/cli/src/commands/optimizer/index.ts @@ -0,0 +1,102 @@ +import type { ToolContext } from "@bunny.net/tools"; +import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones"; +import { + PULL_ZONE_MANIFEST, + type PullZoneManifest, +} from "@/commands/pz/constants.ts"; +import { loadSiteConfig } from "@/commands/sites/config.ts"; +import { + SITES_MANIFEST, + type SiteManifest, +} from "@/commands/sites/constants.ts"; +import { syncOptimizerConfig } from "@/commands/sites/optimizer/shared.ts"; +import { + resolveSitePullZone, + type SitePullZone, +} from "@/commands/sites/optimizer/site.ts"; +import { UserError } from "@/core/errors.ts"; +import { loadManifest, saveManifest } from "@/core/manifest.ts"; +import { createOptimizerCommands } from "@/core/optimizer/index.ts"; +import type { OutputFormat } from "@/core/types.ts"; +import { confirm, isInteractive, prompts, withSpinner } from "@/core/ui.ts"; + +const target = (zone: PullZone): SitePullZone => ({ + name: zone.name, + pullZone: zone.id, + fromDirectory: false, +}); + +function find(zones: PullZone[], ref: string): PullZone { + const zone = /^\d+$/.test(ref) + ? zones.find((z) => z.id === Number(ref)) + : zones.find((z) => z.name === ref); + if (!zone) { + throw new UserError( + `No pull zone found for "${ref}".`, + "Pass a pull zone ID or name (see `bunny pz list`).", + ); + } + return zone; +} + +/** Explicit ref, then the linked pull zone, then the linked site, then a picker that offers to link. */ +export async function resolveOptimizerPullZone( + ctx: ToolContext, + ref: string | undefined, + output: OutputFormat, +): Promise { + const linked = ref ?? loadManifest(PULL_ZONE_MANIFEST).id; + const siteLinked = + loadManifest(SITES_MANIFEST).id || + loadSiteConfig()?.config.name; + if (linked === undefined && siteLinked) { + return resolveSitePullZone(ctx, { output }); + } + + const zones = await withSpinner("Fetching pull zones...", () => + pullZonesList.invoke(ctx, {}), + ); + if (linked !== undefined) return target(find(zones, String(linked))); + + if (!isInteractive(output) || zones.length === 0) { + throw new UserError( + "No pull zone specified and none linked.", + "Pass a pull zone ID or name, or run `bunny pz link`.", + ); + } + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a pull zone:", + choices: zones.map((z) => ({ title: `${z.name} (${z.id})`, value: z })), + }); + if (!selected) throw new UserError("A pull zone is required."); + const zone = selected as PullZone; + if ( + await confirm(`Link this directory to ${zone.name}?`, { + initial: true, + optional: true, + }) + ) { + saveManifest(PULL_ZONE_MANIFEST, { + id: zone.id, + name: zone.name, + }); + } + return target(zone); +} + +export const optimizerNamespace = createOptimizerCommands({ + commandPath: "optimizer", + describe: + "Serve images as WebP sized for each device on any pull zone (Bunny Optimizer).", + positional: { + name: "pull-zone", + describe: + "Pull zone ID or name (uses the linked pull zone or site if omitted)", + }, + label: "Pull zone", + resolve: (ref, args, ctx) => resolveOptimizerPullZone(ctx, ref, args.output), + // A linked site keeps `sites.optimizer` in step, as `sites optimizer` does. + onChanged: syncOptimizerConfig, +}); diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 80bbb0a2..b04f8506 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -13,7 +13,7 @@ import { fetchSites, type SiteContext, siteContextFromZone } from "./api.ts"; import { loadSiteConfig } from "./config.ts"; import { SITES_MANIFEST, type SiteManifest } from "./constants.ts"; -const ARG_SITE_DESCRIPTION = +export const ARG_SITE_DESCRIPTION = "Site name or storage zone ID (uses the linked site if omitted)"; const ARG_LINK_DESCRIPTION = "Link this directory to the site (prompted when picking one; --no-link never links)"; diff --git a/packages/cli/src/commands/sites/optimizer/disable.ts b/packages/cli/src/commands/sites/optimizer/disable.ts deleted file mode 100644 index c9801ff5..00000000 --- a/packages/cli/src/commands/sites/optimizer/disable.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { pullZonesOptimizerSet } from "@bunny.net/tools/pullzones"; -import { defineToolCommand } from "@/core/define-tool-command.ts"; -import { logger } from "@/core/logger.ts"; -import { sitePositionalBuilder } from "../interactive.ts"; -import { syncOptimizerConfig } from "./shared.ts"; -import { resolveSitePullZone, type SitePullZone } from "./site.ts"; - -const sites = new WeakMap(); - -export const sitesOptimizerDisableCommand = defineToolCommand({ - tool: pullZonesOptimizerSet, - command: "disable [site]", - describe: "Turn off Bunny Optimizer and restore the site's cache settings.", - examples: [["$0 sites optimizer disable", "Turn it off for the linked site"]], - progress: "Turning off Bunny Optimizer...", - - builder: (yargs) => sitePositionalBuilder(yargs), - - prepare: async (args, ctx) => { - const site = await resolveSitePullZone(ctx, args); - sites.set(args, site); - return { input: { pullZone: site.pullZone, enabled: false } }; - }, - - after: (_, args) => syncOptimizerConfig(sites.get(args), false, args.output), - - render: (result, args) => { - const name = sites.get(args)?.name ?? `pull zone ${result.pullZone}`; - logger.success( - result.changed - ? `Bunny Optimizer is off for ${name}${result.purged ? " (cache purged)" : ""}.` - : `Bunny Optimizer is already off for ${name}.`, - ); - }, -}); diff --git a/packages/cli/src/commands/sites/optimizer/enable.ts b/packages/cli/src/commands/sites/optimizer/enable.ts deleted file mode 100644 index b1f387e2..00000000 --- a/packages/cli/src/commands/sites/optimizer/enable.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - pullZonesOptimizerGet, - pullZonesOptimizerSet, -} from "@bunny.net/tools/pullzones"; -import { DONE, defineToolCommand } from "@/core/define-tool-command.ts"; -import { logger } from "@/core/logger.ts"; -import { confirm, requireConfirmable, withSpinner } from "@/core/ui.ts"; -import { loadSiteConfig } from "../config.ts"; -import { sitePositionalBuilder } from "../interactive.ts"; -import { formatPrice, syncOptimizerConfig } from "./shared.ts"; -import { resolveSitePullZone, type SitePullZone } from "./site.ts"; - -const sites = new WeakMap(); - -export const sitesOptimizerEnableCommand = defineToolCommand({ - tool: pullZonesOptimizerSet, - command: "enable [site]", - describe: - "Turn on Bunny Optimizer: images served as WebP and sized for each device.", - examples: [ - ["$0 sites optimizer enable", "Turn it on for the linked site"], - ["$0 sites optimizer enable my-site --yes", "Skip the price confirmation"], - ], - epilogue: - "Optimizer is billed monthly per site. Turning it on also adds the Dynamic Image API (?width=, ?quality=, ...) for srcset loaders, keys the cache on those parameters only, turns minification off (frameworks already minify), and purges the cache.", - progress: "Turning on Bunny Optimizer...", - - builder: (yargs) => - sitePositionalBuilder(yargs).option("force", { - type: "boolean", - alias: ["yes", "y"], - default: false, - describe: "Skip the price confirmation", - }), - - prepare: async (args, ctx) => { - const site = await resolveSitePullZone(ctx, args); - sites.set(args, site); - const status = await withSpinner("Fetching Optimizer status...", () => - pullZonesOptimizerGet.invoke(ctx, { pullZone: site.pullZone }), - ); - if (status.enabled && status.configured) { - if (args.output === "json") { - logger.log( - JSON.stringify({ ...status, changed: false, purged: false }, null, 2), - ); - } else { - logger.info(`Bunny Optimizer is already on for ${site.name}.`); - } - syncOptimizerConfig(site, true, args.output); - return DONE; - } - // Already paying: re-applying the settings needs no consent. - if (status.enabled) { - return { input: { pullZone: site.pullZone, enabled: true } }; - } - - const price = formatPrice(status.monthlyPrice); - requireConfirmable(args.output, { - force: args.force, - message: `Bunny Optimizer costs ${price} for ${site.name}; turning it on needs a confirmation prompt.`, - hint: "Re-run with --yes to accept the price non-interactively.", - }); - return { - input: { pullZone: site.pullZone, enabled: true }, - confirm: () => - confirm( - `Turn on Bunny Optimizer for ${site.name}? It costs ${price}.`, - { force: args.force, initial: true }, - ), - }; - }, - - after: (_, args) => syncOptimizerConfig(sites.get(args), true, args.output), - - render: (result, args) => { - const name = sites.get(args)?.name ?? `pull zone ${result.pullZone}`; - logger.success( - result.changed - ? `Bunny Optimizer is on for ${name}${result.purged ? " (cache purged)" : ""}.` - : `Bunny Optimizer is already on for ${name}.`, - ); - logger.dim( - " Images are now served as WebP and sized for each device, with no code changes.", - ); - if (loadSiteConfig()?.config.optimizer === undefined) { - logger.dim( - " Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc", - ); - } - }, -}); diff --git a/packages/cli/src/commands/sites/optimizer/index.ts b/packages/cli/src/commands/sites/optimizer/index.ts index 5f1f76a7..ae7228cd 100644 --- a/packages/cli/src/commands/sites/optimizer/index.ts +++ b/packages/cli/src/commands/sites/optimizer/index.ts @@ -1,14 +1,20 @@ -import { defineNamespace } from "@/core/define-namespace.ts"; -import { sitesOptimizerDisableCommand } from "./disable.ts"; -import { sitesOptimizerEnableCommand } from "./enable.ts"; -import { sitesOptimizerStatusCommand } from "./status.ts"; +import { createOptimizerCommands } from "@/core/optimizer/index.ts"; +import { loadSiteConfig } from "../config.ts"; +import { ARG_SITE_DESCRIPTION } from "../interactive.ts"; +import { syncOptimizerConfig } from "./shared.ts"; +import { resolveSitePullZone } from "./site.ts"; -export const sitesOptimizerNamespace = defineNamespace( - "optimizer", - "Serve a site's images as WebP sized for each device (Bunny Optimizer).", - [ - sitesOptimizerStatusCommand, - sitesOptimizerEnableCommand, - sitesOptimizerDisableCommand, - ], -); +export const sitesOptimizerNamespace = createOptimizerCommands({ + commandPath: "sites optimizer", + describe: + "Serve a site's images as WebP sized for each device (Bunny Optimizer).", + positional: { name: "site", describe: ARG_SITE_DESCRIPTION }, + label: "Site", + resolve: (site, args, ctx) => + resolveSitePullZone(ctx, { site, output: args.output }), + onChanged: syncOptimizerConfig, + enableHint: () => + loadSiteConfig()?.config.optimizer === undefined + ? "Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc" + : undefined, +}); diff --git a/packages/cli/src/commands/sites/optimizer/shared.ts b/packages/cli/src/commands/sites/optimizer/shared.ts index fc65c39c..f675d492 100644 --- a/packages/cli/src/commands/sites/optimizer/shared.ts +++ b/packages/cli/src/commands/sites/optimizer/shared.ts @@ -1,52 +1,15 @@ -import type { OptimizerStatus } from "@bunny.net/tools/pullzones"; -import { formatKeyValue } from "@/core/format.ts"; import { logger } from "@/core/logger.ts"; import type { OutputFormat } from "@/core/types.ts"; import { loadSiteConfig, saveSiteConfig } from "../config.ts"; import type { SitePullZone } from "./site.ts"; -export function formatPrice(monthly: number): string { - return `$${monthly.toFixed(2)}/month`; -} - -export function renderOptimizerStatus( - site: string, - status: OptimizerStatus, - output: OutputFormat, -): void { - const onOff = (on: boolean) => (on ? "on" : "off"); - const cacheKey = - status.cacheKeyParameters === null - ? "all query parameters" - : status.cacheKeyParameters.length === 0 - ? "query strings ignored" - : status.cacheKeyParameters.join(", "); - logger.log( - formatKeyValue( - [ - { key: "Site", value: site }, - { key: "Optimizer", value: onOff(status.enabled) }, - { key: "WebP", value: onOff(status.webp) }, - { key: "Dynamic images", value: onOff(status.dynamicImages) }, - { - key: "Minify CSS/JS", - value: `${onOff(status.minify.css)}/${onOff(status.minify.js)}`, - }, - { key: "Cache key", value: cacheKey }, - { key: "Price", value: formatPrice(status.monthlyPrice) }, - ], - output, - ), - ); -} - /** Keep `sites.optimizer` in bunny.jsonc from undoing a change on the next deploy; only touches a value that is already set, for the site this directory points at. */ export function syncOptimizerConfig( - site: SitePullZone | undefined, + site: SitePullZone, enabled: boolean, output: OutputFormat, ): void { - if (!site?.fromDirectory) return; + if (!site.fromDirectory) return; const configured = loadSiteConfig()?.config.optimizer; if (configured === undefined || configured === enabled) return; const path = saveSiteConfig({ optimizer: enabled }); diff --git a/packages/cli/src/commands/sites/optimizer/status.ts b/packages/cli/src/commands/sites/optimizer/status.ts deleted file mode 100644 index eba4a8a0..00000000 --- a/packages/cli/src/commands/sites/optimizer/status.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { pullZonesOptimizerGet } from "@bunny.net/tools/pullzones"; -import { defineToolCommand } from "@/core/define-tool-command.ts"; -import { logger } from "@/core/logger.ts"; -import { sitePositionalBuilder } from "../interactive.ts"; -import { formatPrice, renderOptimizerStatus } from "./shared.ts"; -import { resolveSitePullZone } from "./site.ts"; - -const siteNames = new WeakMap(); - -export const sitesOptimizerStatusCommand = defineToolCommand({ - tool: pullZonesOptimizerGet, - command: "status [site]", - describe: "Show whether Bunny Optimizer is on for a site.", - examples: [ - ["$0 sites optimizer status", "Status for the linked site"], - ["$0 sites optimizer status my-site --output json", "As JSON"], - ], - progress: "Fetching Optimizer status...", - - builder: (yargs) => sitePositionalBuilder(yargs), - - prepare: async (args, ctx) => { - const site = await resolveSitePullZone(ctx, args); - siteNames.set(args, site.name); - return { input: { pullZone: site.pullZone } }; - }, - - render: (status, args) => { - renderOptimizerStatus(siteNames.get(args) ?? "", status, args.output); - logger.log(); - if (!status.enabled) { - logger.dim( - ` Turn it on (${formatPrice(status.monthlyPrice)}): bunny sites optimizer enable`, - ); - } else if (!status.configured) { - logger.dim( - " Some settings differ from what sites expect; re-apply them: bunny sites optimizer enable", - ); - } - }, -}); diff --git a/packages/cli/src/commands/sites/optimizer/sync.ts b/packages/cli/src/commands/sites/optimizer/sync.ts index 065abb95..b43d99b9 100644 --- a/packages/cli/src/commands/sites/optimizer/sync.ts +++ b/packages/cli/src/commands/sites/optimizer/sync.ts @@ -7,10 +7,10 @@ import { import { errorMessage } from "@/core/errors.ts"; import { formatBytes } from "@/core/format.ts"; import { logger } from "@/core/logger.ts"; +import { formatPrice } from "@/core/optimizer/index.ts"; import type { OutputFormat } from "@/core/types.ts"; import { confirm, isInteractive, withSpinner } from "@/core/ui.ts"; import { saveSiteConfig } from "../config.ts"; -import { formatPrice } from "./shared.ts"; /** * Bring the site's pull zone in line with `sites.optimizer` before a deploy publishes (the publish purges, so no separate purge here). diff --git a/packages/cli/src/core/optimizer/commands.ts b/packages/cli/src/core/optimizer/commands.ts new file mode 100644 index 00000000..141b77be --- /dev/null +++ b/packages/cli/src/core/optimizer/commands.ts @@ -0,0 +1,239 @@ +import type { ToolContext } from "@bunny.net/tools"; +import { + type OptimizerStatus, + pullZonesOptimizerGet, + pullZonesOptimizerSet, +} from "@bunny.net/tools/pullzones"; +import type { Argv, CommandModule } from "yargs"; +import { defineNamespace } from "@/core/define-namespace.ts"; +import { DONE, defineToolCommand } from "@/core/define-tool-command.ts"; +import { formatKeyValue } from "@/core/format.ts"; +import { logger } from "@/core/logger.ts"; +import type { GlobalArgs, OutputFormat } from "@/core/types.ts"; +import { confirm, requireConfirmable, withSpinner } from "@/core/ui.ts"; + +/** The pull zone Optimizer acts on, named for output. */ +export interface OptimizerTarget { + pullZone: number; + name: string; +} + +export interface OptimizerMountOptions { + /** Command breadcrumb used in examples and hints, e.g. "sites optimizer". */ + commandPath: string; + describe: string; + /** Trailing positional that names the target, e.g. `site` or `pull-zone`. */ + positional: { name: string; describe: string }; + /** Row label for the target in `status`, e.g. "Site". */ + label: string; + /** Resolve the positional (or the linked directory) to a pull zone. */ + resolve: ( + ref: string | undefined, + args: GlobalArgs, + ctx: ToolContext, + ) => Promise; + /** Runs after Optimizer is turned on or off, e.g. to keep a config file in line. */ + onChanged?: (target: T, enabled: boolean, output: OutputFormat) => void; + /** Extra line printed after a successful enable. */ + enableHint?: (target: T) => string | undefined; +} + +export function formatPrice(monthly: number): string { + return `$${monthly.toFixed(2)}/month`; +} + +export function renderOptimizerStatus( + label: string, + name: string, + status: OptimizerStatus, + output: OutputFormat, +): void { + const onOff = (on: boolean) => (on ? "on" : "off"); + const cacheKey = + status.cacheKeyParameters === null + ? "all query parameters" + : status.cacheKeyParameters.length === 0 + ? "query strings ignored" + : status.cacheKeyParameters.join(", "); + logger.log( + formatKeyValue( + [ + { key: label, value: name }, + { key: "Optimizer", value: onOff(status.enabled) }, + { key: "WebP", value: onOff(status.webp) }, + { key: "Dynamic images", value: onOff(status.dynamicImages) }, + { + key: "Minify CSS/JS", + value: `${onOff(status.minify.css)}/${onOff(status.minify.js)}`, + }, + { key: "Cache key", value: cacheKey }, + { key: "Price", value: formatPrice(status.monthlyPrice) }, + ], + output, + ), + ); +} + +/** The `optimizer` namespace (status, enable, disable), mounted wherever a pull zone can be resolved. */ +export function createOptimizerCommands( + opts: OptimizerMountOptions, +): CommandModule { + const { commandPath, positional } = opts; + const targets = new WeakMap(); + const ref = (args: object) => + (args as Record)[positional.name] as string | undefined; + const resolve = async (args: GlobalArgs, ctx: ToolContext) => { + const target = await opts.resolve(ref(args), args, ctx); + targets.set(args, target); + return target; + }; + const nameOf = (args: object, pullZone: number) => + targets.get(args)?.name ?? `pull zone ${pullZone}`; + const withTarget = (yargs: Argv) => + yargs.positional(positional.name, { + type: "string", + describe: positional.describe, + }); + + const status = defineToolCommand({ + tool: pullZonesOptimizerGet, + command: `status [${positional.name}]`, + describe: "Show whether Bunny Optimizer is on.", + examples: [ + [`$0 ${commandPath} status`, "Status for the linked directory"], + [`$0 ${commandPath} status --output json`, "As JSON"], + ], + progress: "Fetching Optimizer status...", + builder: withTarget, + prepare: async (args, ctx) => { + const target = await resolve(args, ctx); + return { input: { pullZone: target.pullZone } }; + }, + render: (result, args) => { + renderOptimizerStatus( + opts.label, + nameOf(args, result.pullZone), + result, + args.output, + ); + logger.log(); + if (!result.enabled) { + logger.dim( + ` Turn it on (${formatPrice(result.monthlyPrice)}): bunny ${commandPath} enable`, + ); + } else if (!result.configured) { + logger.dim( + ` Some settings differ from what Optimizer needs; re-apply them: bunny ${commandPath} enable`, + ); + } + }, + }); + + const enable = defineToolCommand({ + tool: pullZonesOptimizerSet, + command: `enable [${positional.name}]`, + describe: + "Turn on Bunny Optimizer: images served as WebP and sized for each device.", + examples: [ + [`$0 ${commandPath} enable`, "Turn it on for the linked directory"], + [`$0 ${commandPath} enable --yes`, "Skip the price confirmation"], + ], + epilogue: + "Optimizer is billed monthly per pull zone. Turning it on also adds the Dynamic Image API (?width=, ?quality=, ...) for srcset loaders, keys the cache on those parameters only, turns minification off (frameworks already minify), and purges the cache.", + progress: "Turning on Bunny Optimizer...", + builder: (yargs) => + withTarget(yargs).option("force", { + type: "boolean", + alias: ["yes", "y"], + default: false, + describe: "Skip the price confirmation", + }), + prepare: async (args, ctx) => { + const target = await resolve(args, ctx); + const current = await withSpinner("Fetching Optimizer status...", () => + pullZonesOptimizerGet.invoke(ctx, { pullZone: target.pullZone }), + ); + if (current.enabled && current.configured) { + if (args.output === "json") { + logger.log( + JSON.stringify( + { ...current, changed: false, purged: false }, + null, + 2, + ), + ); + } else { + logger.info(`Bunny Optimizer is already on for ${target.name}.`); + } + opts.onChanged?.(target, true, args.output); + return DONE; + } + // Already paying: re-applying the settings needs no consent. + if (current.enabled) { + return { input: { pullZone: target.pullZone, enabled: true } }; + } + + const price = formatPrice(current.monthlyPrice); + requireConfirmable(args.output, { + force: args.force, + message: `Bunny Optimizer costs ${price} for ${target.name}; turning it on needs a confirmation prompt.`, + hint: "Re-run with --yes to accept the price non-interactively.", + }); + return { + input: { pullZone: target.pullZone, enabled: true }, + confirm: () => + confirm( + `Turn on Bunny Optimizer for ${target.name}? It costs ${price}.`, + { force: args.force, initial: true }, + ), + }; + }, + after: (_, args) => { + const target = targets.get(args); + if (target) opts.onChanged?.(target, true, args.output); + }, + render: (result, args) => { + const name = nameOf(args, result.pullZone); + logger.success( + result.changed + ? `Bunny Optimizer is on for ${name}${result.purged ? " (cache purged)" : ""}.` + : `Bunny Optimizer is already on for ${name}.`, + ); + logger.dim( + " Images are now served as WebP and sized for each device, with no code changes.", + ); + const target = targets.get(args); + const hint = target && opts.enableHint?.(target); + if (hint) logger.dim(` ${hint}`); + }, + }); + + const disable = defineToolCommand({ + tool: pullZonesOptimizerSet, + command: `disable [${positional.name}]`, + describe: "Turn off Bunny Optimizer and restore the cache settings.", + examples: [ + [`$0 ${commandPath} disable`, "Turn it off for the linked directory"], + ], + progress: "Turning off Bunny Optimizer...", + builder: withTarget, + prepare: async (args, ctx) => { + const target = await resolve(args, ctx); + return { input: { pullZone: target.pullZone, enabled: false } }; + }, + after: (_, args) => { + const target = targets.get(args); + if (target) opts.onChanged?.(target, false, args.output); + }, + render: (result, args) => { + const name = nameOf(args, result.pullZone); + logger.success( + result.changed + ? `Bunny Optimizer is off for ${name}${result.purged ? " (cache purged)" : ""}.` + : `Bunny Optimizer is already off for ${name}.`, + ); + }, + }); + + return defineNamespace("optimizer", opts.describe, [status, enable, disable]); +} diff --git a/packages/cli/src/core/optimizer/index.ts b/packages/cli/src/core/optimizer/index.ts new file mode 100644 index 00000000..658e0e05 --- /dev/null +++ b/packages/cli/src/core/optimizer/index.ts @@ -0,0 +1,7 @@ +export { + createOptimizerCommands, + formatPrice, + type OptimizerMountOptions, + type OptimizerTarget, + renderOptimizerStatus, +} from "./commands.ts"; From 3fa547f85286d643ed36ae5867e62d33af69e8a2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 14 Oct 2026 09:39:08 +0100 Subject: [PATCH 3/3] refactor(optimizer): owner-neutral tool and pluggable pull-zone sources --- AGENTS.md | 2 + packages/cli/README.md | 4 +- packages/cli/src/commands/optimizer/index.ts | 96 ++------------ packages/cli/src/commands/pz/target.test.ts | 29 +++++ packages/cli/src/commands/pz/target.ts | 103 +++++++++++++++ .../cli/src/commands/sites/optimizer/index.ts | 15 +-- .../src/commands/sites/optimizer/shared.ts | 38 +++++- .../cli/src/commands/sites/optimizer/sync.ts | 9 +- packages/cli/src/core/optimizer/commands.ts | 57 ++++---- packages/cli/src/core/optimizer/index.ts | 1 + packages/tools/src/pullzones/model.ts | 9 +- .../tools/src/pullzones/optimizer.test.ts | 63 +++++---- packages/tools/src/pullzones/optimizer.ts | 122 ++++++++++++------ 13 files changed, 358 insertions(+), 190 deletions(-) create mode 100644 packages/cli/src/commands/pz/target.test.ts create mode 100644 packages/cli/src/commands/pz/target.ts diff --git a/AGENTS.md b/AGENTS.md index 3488d94d..8e235a7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -550,6 +550,8 @@ Scripts, apps, and sites are each backed by a pull zone, which has a large setti - **A root `bunny pullzone`** (planned) is the canonical full surface for zones not backing anything, targeted by `--id`. - **Each setting area is a mountable factory** like `createHostnamesCommands` in `core/hostnames/`, taking `{ commandPath, target, targetPositional, resolve(args), hiddenAliases }`. The resolver is the only per-surface difference: `--id` at the root, the linked manifest for scripts, the CDN endpoint for apps. `targetPositional` appends an optional trailing positional so each mount matches its namespace's convention. - Canonical term is `pullzone` (matching the dashboard and API); `pz` is a hidden alias. +- **Separately billed CDN products get a root command** (`bunny optimizer`) as well as an owner mount, so they work on any pull zone. Root mounts resolve through `resolvePullZoneTarget` in `commands/pz/target.ts`: an explicit ID or name, then the first linked `PullZoneSource` (each owner exports one, e.g. `sitePullZoneSource`), then a picker that offers `pz link`. +- **Owner policy travels with the target**, not the mount: a site target carries minify-off and the `sites.optimizer` sync, so `bunny optimizer` in a site directory behaves like `bunny sites optimizer`. Tools stay owner-neutral and only ever add to a zone's existing settings (Optimizer adds its image parameters to the cache key; it never narrows it). --- diff --git a/packages/cli/README.md b/packages/cli/README.md index 87b8fa2c..6aa4b99e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1125,9 +1125,9 @@ Every deploy publishes: the files land in an immutable `deploys//` directory ### `bunny optimizer` -Turn Bunny Optimizer on or off for any pull zone: images are served as WebP and sized for each device ($9.50/month per pull zone). Turning it on also enables the Dynamic Image API (`?width=`, `?quality=`, ...), keys the cache on those parameters only, turns minification off, and purges the cache. +Turn Bunny Optimizer on or off for any pull zone: images are served as WebP and sized for each device ($9.50/month per pull zone). Turning it on also enables the Dynamic Image API (`?width=`, `?quality=`, ...), adds those parameters to the cache key (other query parameters cache exactly as before), and purges the cache. Minification is left as it is; turning Optimizer off removes the image parameters from the cache key only when Optimizer added them. -The pull zone is an optional positional (ID or name). When omitted it resolves from the directory's linked pull zone (`.bunny/pullzone.json`), then a linked site, then an interactive picker that offers to link the directory. For a site, `bunny sites optimizer` does the same by site name and keeps `sites.optimizer` in `bunny.jsonc` in step. +The pull zone is an optional positional (ID or name). When omitted it resolves from the directory's linked pull zone (`.bunny/pullzone.json`), then a linked site, then an interactive picker that offers to link the directory. A site, whichever command reaches it, also gets minification turned off and `sites.optimizer` in `bunny.jsonc` kept in step; `bunny sites optimizer` takes a site name instead of a pull zone. ```bash bunny optimizer status # linked pull zone or site diff --git a/packages/cli/src/commands/optimizer/index.ts b/packages/cli/src/commands/optimizer/index.ts index 0862ce36..fca59ec1 100644 --- a/packages/cli/src/commands/optimizer/index.ts +++ b/packages/cli/src/commands/optimizer/index.ts @@ -1,90 +1,12 @@ -import type { ToolContext } from "@bunny.net/tools"; -import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones"; import { - PULL_ZONE_MANIFEST, - type PullZoneManifest, -} from "@/commands/pz/constants.ts"; -import { loadSiteConfig } from "@/commands/sites/config.ts"; -import { - SITES_MANIFEST, - type SiteManifest, -} from "@/commands/sites/constants.ts"; -import { syncOptimizerConfig } from "@/commands/sites/optimizer/shared.ts"; -import { - resolveSitePullZone, - type SitePullZone, -} from "@/commands/sites/optimizer/site.ts"; -import { UserError } from "@/core/errors.ts"; -import { loadManifest, saveManifest } from "@/core/manifest.ts"; + linkedPullZoneSource, + resolvePullZoneTarget, +} from "@/commands/pz/target.ts"; +import { sitePullZoneSource } from "@/commands/sites/optimizer/shared.ts"; import { createOptimizerCommands } from "@/core/optimizer/index.ts"; -import type { OutputFormat } from "@/core/types.ts"; -import { confirm, isInteractive, prompts, withSpinner } from "@/core/ui.ts"; - -const target = (zone: PullZone): SitePullZone => ({ - name: zone.name, - pullZone: zone.id, - fromDirectory: false, -}); - -function find(zones: PullZone[], ref: string): PullZone { - const zone = /^\d+$/.test(ref) - ? zones.find((z) => z.id === Number(ref)) - : zones.find((z) => z.name === ref); - if (!zone) { - throw new UserError( - `No pull zone found for "${ref}".`, - "Pass a pull zone ID or name (see `bunny pz list`).", - ); - } - return zone; -} - -/** Explicit ref, then the linked pull zone, then the linked site, then a picker that offers to link. */ -export async function resolveOptimizerPullZone( - ctx: ToolContext, - ref: string | undefined, - output: OutputFormat, -): Promise { - const linked = ref ?? loadManifest(PULL_ZONE_MANIFEST).id; - const siteLinked = - loadManifest(SITES_MANIFEST).id || - loadSiteConfig()?.config.name; - if (linked === undefined && siteLinked) { - return resolveSitePullZone(ctx, { output }); - } - - const zones = await withSpinner("Fetching pull zones...", () => - pullZonesList.invoke(ctx, {}), - ); - if (linked !== undefined) return target(find(zones, String(linked))); - if (!isInteractive(output) || zones.length === 0) { - throw new UserError( - "No pull zone specified and none linked.", - "Pass a pull zone ID or name, or run `bunny pz link`.", - ); - } - const { selected } = await prompts({ - type: "select", - name: "selected", - message: "Select a pull zone:", - choices: zones.map((z) => ({ title: `${z.name} (${z.id})`, value: z })), - }); - if (!selected) throw new UserError("A pull zone is required."); - const zone = selected as PullZone; - if ( - await confirm(`Link this directory to ${zone.name}?`, { - initial: true, - optional: true, - }) - ) { - saveManifest(PULL_ZONE_MANIFEST, { - id: zone.id, - name: zone.name, - }); - } - return target(zone); -} +// Resources whose directory links lead to a pull zone; the first linked one wins. +const sources = [linkedPullZoneSource, sitePullZoneSource]; export const optimizerNamespace = createOptimizerCommands({ commandPath: "optimizer", @@ -95,8 +17,6 @@ export const optimizerNamespace = createOptimizerCommands({ describe: "Pull zone ID or name (uses the linked pull zone or site if omitted)", }, - label: "Pull zone", - resolve: (ref, args, ctx) => resolveOptimizerPullZone(ctx, ref, args.output), - // A linked site keeps `sites.optimizer` in step, as `sites optimizer` does. - onChanged: syncOptimizerConfig, + resolve: (ref, args, ctx) => + resolvePullZoneTarget(ctx, ref, args.output, sources), }); diff --git a/packages/cli/src/commands/pz/target.test.ts b/packages/cli/src/commands/pz/target.test.ts new file mode 100644 index 00000000..7a28d957 --- /dev/null +++ b/packages/cli/src/commands/pz/target.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { type CoreClient, createToolContext } from "@bunny.net/tools"; +import { type PullZoneSource, resolvePullZoneTarget } from "./target.ts"; + +const ctx = createToolContext({ + clients: { + core: { + GET: () => + Promise.resolve({ data: [{ Id: 9, Name: "cdn", Enabled: true }] }), + } as unknown as CoreClient, + }, +}); + +const source = (linked: boolean, name: string): PullZoneSource => ({ + linked: () => linked, + resolve: async () => ({ pullZone: 1, name, label: "Site" }), +}); + +test("an explicit pull zone beats links, and the first linked source wins", async () => { + const sources = [source(false, "a"), source(true, "b"), source(true, "c")]; + expect( + await resolvePullZoneTarget(ctx, undefined, "json", sources), + ).toMatchObject({ name: "b" }); + expect(await resolvePullZoneTarget(ctx, "cdn", "json", sources)).toEqual({ + pullZone: 9, + name: "cdn", + label: "Pull zone", + }); +}); diff --git a/packages/cli/src/commands/pz/target.ts b/packages/cli/src/commands/pz/target.ts new file mode 100644 index 00000000..272e077e --- /dev/null +++ b/packages/cli/src/commands/pz/target.ts @@ -0,0 +1,103 @@ +import type { ToolContext } from "@bunny.net/tools"; +import { type PullZone, pullZonesList } from "@bunny.net/tools/pullzones"; +import { UserError } from "@/core/errors.ts"; +import { loadManifest, saveManifest } from "@/core/manifest.ts"; +import type { OutputFormat } from "@/core/types.ts"; +import { confirm, isInteractive, prompts, withSpinner } from "@/core/ui.ts"; +import { PULL_ZONE_MANIFEST, type PullZoneManifest } from "./constants.ts"; + +/** A pull zone a command acts on, named the way its owner (a site, a script, a bare zone) is known. */ +export interface PullZoneTarget { + pullZone: number; + name: string; + /** What `name` names, for output rows, e.g. "Site" or "Pull zone". */ + label: string; +} + +/** A kind of directory link that leads to a pull zone; resources register one so pull-zone commands work from their directories. */ +export interface PullZoneSource { + /** Whether the current directory is linked to this kind of resource. */ + linked(): boolean; + resolve(ctx: ToolContext, output: OutputFormat): Promise; +} + +const toTarget = (zone: PullZone): PullZoneTarget => ({ + pullZone: zone.id, + name: zone.name, + label: "Pull zone", +}); + +async function listZones(ctx: ToolContext): Promise { + return withSpinner("Fetching pull zones...", () => + pullZonesList.invoke(ctx, {}), + ); +} + +function findZone(zones: PullZone[], ref: string): PullZone { + const zone = /^\d+$/.test(ref) + ? zones.find((z) => z.id === Number(ref)) + : zones.find((z) => z.name === ref); + if (!zone) { + throw new UserError( + `No pull zone found for "${ref}".`, + "Pass a pull zone ID or name (see `bunny pz list`).", + ); + } + return zone; +} + +/** `.bunny/pullzone.json`, written by `bunny pz link`. */ +export const linkedPullZoneSource: PullZoneSource = { + linked: () => !!loadManifest(PULL_ZONE_MANIFEST).id, + resolve: async (ctx) => { + const { id } = loadManifest(PULL_ZONE_MANIFEST); + return toTarget(findZone(await listZones(ctx), String(id))); + }, +}; + +/** An explicit ID or name, then the first linked source, then a picker that offers to link the directory. */ +export async function resolvePullZoneTarget( + ctx: ToolContext, + ref: string | undefined, + output: OutputFormat, + sources: PullZoneSource[], +): Promise { + if (ref) return toTarget(findZone(await listZones(ctx), ref)); + + const source = sources.find((s) => s.linked()); + if (source) return source.resolve(ctx, output); + + if (!isInteractive(output)) { + throw new UserError( + "No pull zone specified and none linked.", + "Pass a pull zone ID or name, or run `bunny pz link`.", + ); + } + const zones = await listZones(ctx); + if (zones.length === 0) { + throw new UserError( + "No pull zones found.", + 'Run "bunny pz create" to create one.', + ); + } + const { selected } = await prompts({ + type: "select", + name: "selected", + message: "Select a pull zone:", + choices: zones.map((z) => ({ title: `${z.name} (${z.id})`, value: z })), + }); + if (!selected) throw new UserError("A pull zone is required."); + const zone = selected as PullZone; + if ( + await confirm(`Link this directory to ${zone.name}?`, { + initial: true, + optional: true, + }) + ) { + saveManifest(PULL_ZONE_MANIFEST, { + id: zone.id, + name: zone.name, + }); + } + return toTarget(zone); +} diff --git a/packages/cli/src/commands/sites/optimizer/index.ts b/packages/cli/src/commands/sites/optimizer/index.ts index ae7228cd..ec0356ee 100644 --- a/packages/cli/src/commands/sites/optimizer/index.ts +++ b/packages/cli/src/commands/sites/optimizer/index.ts @@ -1,7 +1,6 @@ import { createOptimizerCommands } from "@/core/optimizer/index.ts"; -import { loadSiteConfig } from "../config.ts"; import { ARG_SITE_DESCRIPTION } from "../interactive.ts"; -import { syncOptimizerConfig } from "./shared.ts"; +import { siteOptimizerTarget } from "./shared.ts"; import { resolveSitePullZone } from "./site.ts"; export const sitesOptimizerNamespace = createOptimizerCommands({ @@ -9,12 +8,8 @@ export const sitesOptimizerNamespace = createOptimizerCommands({ describe: "Serve a site's images as WebP sized for each device (Bunny Optimizer).", positional: { name: "site", describe: ARG_SITE_DESCRIPTION }, - label: "Site", - resolve: (site, args, ctx) => - resolveSitePullZone(ctx, { site, output: args.output }), - onChanged: syncOptimizerConfig, - enableHint: () => - loadSiteConfig()?.config.optimizer === undefined - ? "Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc" - : undefined, + resolve: async (site, args, ctx) => + siteOptimizerTarget( + await resolveSitePullZone(ctx, { site, output: args.output }), + ), }); diff --git a/packages/cli/src/commands/sites/optimizer/shared.ts b/packages/cli/src/commands/sites/optimizer/shared.ts index f675d492..f88d4afa 100644 --- a/packages/cli/src/commands/sites/optimizer/shared.ts +++ b/packages/cli/src/commands/sites/optimizer/shared.ts @@ -1,7 +1,14 @@ +import type { PullZoneSource } from "@/commands/pz/target.ts"; import { logger } from "@/core/logger.ts"; +import { loadManifest } from "@/core/manifest.ts"; +import type { OptimizerTarget } from "@/core/optimizer/index.ts"; import type { OutputFormat } from "@/core/types.ts"; import { loadSiteConfig, saveSiteConfig } from "../config.ts"; -import type { SitePullZone } from "./site.ts"; +import { SITES_MANIFEST, type SiteManifest } from "../constants.ts"; +import { resolveSitePullZone, type SitePullZone } from "./site.ts"; + +/** Sites keep minification off: every framework already minifies its build. */ +export const SITE_OPTIMIZER_MINIFY = false; /** Keep `sites.optimizer` in bunny.jsonc from undoing a change on the next deploy; only touches a value that is already set, for the site this directory points at. */ export function syncOptimizerConfig( @@ -17,3 +24,32 @@ export function syncOptimizerConfig( logger.dim(` Updated sites.optimizer to ${enabled} in ${path}`); } } + +/** A site as an Optimizer target, carrying the sites policy to whichever command reached it. */ +export function siteOptimizerTarget(site: SitePullZone): OptimizerTarget { + return { + pullZone: site.pullZone, + name: site.name, + label: "Site", + optimizer: { + minify: SITE_OPTIMIZER_MINIFY, + onChanged: (enabled, output) => + syncOptimizerConfig(site, enabled, output), + enableHint: () => + site.fromDirectory && loadSiteConfig()?.config.optimizer === undefined + ? "Keep deploys in line with it: set sites.optimizer to true in bunny.jsonc" + : undefined, + }, + }; +} + +/** A directory linked to a site (`.bunny/site.json` or `sites.name`) resolves to the site's pull zone. */ +export const sitePullZoneSource: PullZoneSource = { + linked: () => + !!( + loadManifest(SITES_MANIFEST).id || + loadSiteConfig()?.config.name + ), + resolve: async (ctx, output) => + siteOptimizerTarget(await resolveSitePullZone(ctx, { output })), +}; diff --git a/packages/cli/src/commands/sites/optimizer/sync.ts b/packages/cli/src/commands/sites/optimizer/sync.ts index b43d99b9..c8245411 100644 --- a/packages/cli/src/commands/sites/optimizer/sync.ts +++ b/packages/cli/src/commands/sites/optimizer/sync.ts @@ -11,6 +11,7 @@ import { formatPrice } from "@/core/optimizer/index.ts"; import type { OutputFormat } from "@/core/types.ts"; import { confirm, isInteractive, withSpinner } from "@/core/ui.ts"; import { saveSiteConfig } from "../config.ts"; +import { SITE_OPTIMIZER_MINIFY } from "./shared.ts"; /** * Bring the site's pull zone in line with `sites.optimizer` before a deploy publishes (the publish purges, so no separate purge here). @@ -57,7 +58,12 @@ export async function syncOptimizer(opts: { ? "Turning on Bunny Optimizer..." : "Turning off Bunny Optimizer...", () => - pullZonesOptimizerSet.invoke(ctx, { pullZone, enabled, purge: false }), + pullZonesOptimizerSet.invoke(ctx, { + pullZone, + enabled, + minify: SITE_OPTIMIZER_MINIFY, + purge: false, + }), ); if (!quiet) { logger.success( @@ -128,6 +134,7 @@ export async function offerOptimizerOnCreate(opts: { pullZonesOptimizerSet.invoke(opts.ctx, { pullZone: opts.pullZone, enabled: true, + minify: SITE_OPTIMIZER_MINIFY, purge: false, }), ); diff --git a/packages/cli/src/core/optimizer/commands.ts b/packages/cli/src/core/optimizer/commands.ts index 141b77be..51735300 100644 --- a/packages/cli/src/core/optimizer/commands.ts +++ b/packages/cli/src/core/optimizer/commands.ts @@ -12,30 +12,37 @@ import { logger } from "@/core/logger.ts"; import type { GlobalArgs, OutputFormat } from "@/core/types.ts"; import { confirm, requireConfirmable, withSpinner } from "@/core/ui.ts"; -/** The pull zone Optimizer acts on, named for output. */ +/** How the resource that owns a pull zone wants Optimizer run; it travels with the target so every command that reaches the resource applies it. */ +export interface OptimizerPolicy { + /** Set CSS/JS minification on enable; omitted leaves the zone's setting alone. */ + minify?: boolean; + /** Runs after Optimizer is turned on or off, e.g. to keep a config file in line. */ + onChanged?: (enabled: boolean, output: OutputFormat) => void; + /** Extra line printed after a successful enable. */ + enableHint?: () => string | undefined; +} + +/** The pull zone Optimizer acts on, named the way its owner is known. */ export interface OptimizerTarget { pullZone: number; name: string; + /** What `name` names, e.g. "Site" or "Pull zone". */ + label: string; + optimizer?: OptimizerPolicy; } -export interface OptimizerMountOptions { +export interface OptimizerMountOptions { /** Command breadcrumb used in examples and hints, e.g. "sites optimizer". */ commandPath: string; describe: string; /** Trailing positional that names the target, e.g. `site` or `pull-zone`. */ positional: { name: string; describe: string }; - /** Row label for the target in `status`, e.g. "Site". */ - label: string; /** Resolve the positional (or the linked directory) to a pull zone. */ resolve: ( ref: string | undefined, args: GlobalArgs, ctx: ToolContext, - ) => Promise; - /** Runs after Optimizer is turned on or off, e.g. to keep a config file in line. */ - onChanged?: (target: T, enabled: boolean, output: OutputFormat) => void; - /** Extra line printed after a successful enable. */ - enableHint?: (target: T) => string | undefined; + ) => Promise; } export function formatPrice(monthly: number): string { @@ -75,11 +82,11 @@ export function renderOptimizerStatus( } /** The `optimizer` namespace (status, enable, disable), mounted wherever a pull zone can be resolved. */ -export function createOptimizerCommands( - opts: OptimizerMountOptions, +export function createOptimizerCommands( + opts: OptimizerMountOptions, ): CommandModule { const { commandPath, positional } = opts; - const targets = new WeakMap(); + const targets = new WeakMap(); const ref = (args: object) => (args as Record)[positional.name] as string | undefined; const resolve = async (args: GlobalArgs, ctx: ToolContext) => { @@ -111,7 +118,7 @@ export function createOptimizerCommands( }, render: (result, args) => { renderOptimizerStatus( - opts.label, + targets.get(args)?.label ?? "Pull zone", nameOf(args, result.pullZone), result, args.output, @@ -139,7 +146,7 @@ export function createOptimizerCommands( [`$0 ${commandPath} enable --yes`, "Skip the price confirmation"], ], epilogue: - "Optimizer is billed monthly per pull zone. Turning it on also adds the Dynamic Image API (?width=, ?quality=, ...) for srcset loaders, keys the cache on those parameters only, turns minification off (frameworks already minify), and purges the cache.", + "Optimizer is billed monthly per pull zone. Turning it on also adds the Dynamic Image API (?width=, ?quality=, ...) for srcset loaders, adds those parameters to the cache key (other parameters cache as before), and purges the cache.", progress: "Turning on Bunny Optimizer...", builder: (yargs) => withTarget(yargs).option("force", { @@ -165,13 +172,16 @@ export function createOptimizerCommands( } else { logger.info(`Bunny Optimizer is already on for ${target.name}.`); } - opts.onChanged?.(target, true, args.output); + target.optimizer?.onChanged?.(true, args.output); return DONE; } + const input = { + pullZone: target.pullZone, + enabled: true, + minify: target.optimizer?.minify, + }; // Already paying: re-applying the settings needs no consent. - if (current.enabled) { - return { input: { pullZone: target.pullZone, enabled: true } }; - } + if (current.enabled) return { input }; const price = formatPrice(current.monthlyPrice); requireConfirmable(args.output, { @@ -180,7 +190,7 @@ export function createOptimizerCommands( hint: "Re-run with --yes to accept the price non-interactively.", }); return { - input: { pullZone: target.pullZone, enabled: true }, + input, confirm: () => confirm( `Turn on Bunny Optimizer for ${target.name}? It costs ${price}.`, @@ -189,8 +199,7 @@ export function createOptimizerCommands( }; }, after: (_, args) => { - const target = targets.get(args); - if (target) opts.onChanged?.(target, true, args.output); + targets.get(args)?.optimizer?.onChanged?.(true, args.output); }, render: (result, args) => { const name = nameOf(args, result.pullZone); @@ -202,8 +211,7 @@ export function createOptimizerCommands( logger.dim( " Images are now served as WebP and sized for each device, with no code changes.", ); - const target = targets.get(args); - const hint = target && opts.enableHint?.(target); + const hint = targets.get(args)?.optimizer?.enableHint?.(); if (hint) logger.dim(` ${hint}`); }, }); @@ -222,8 +230,7 @@ export function createOptimizerCommands( return { input: { pullZone: target.pullZone, enabled: false } }; }, after: (_, args) => { - const target = targets.get(args); - if (target) opts.onChanged?.(target, false, args.output); + targets.get(args)?.optimizer?.onChanged?.(false, args.output); }, render: (result, args) => { const name = nameOf(args, result.pullZone); diff --git a/packages/cli/src/core/optimizer/index.ts b/packages/cli/src/core/optimizer/index.ts index 658e0e05..9a78618e 100644 --- a/packages/cli/src/core/optimizer/index.ts +++ b/packages/cli/src/core/optimizer/index.ts @@ -2,6 +2,7 @@ export { createOptimizerCommands, formatPrice, type OptimizerMountOptions, + type OptimizerPolicy, type OptimizerTarget, renderOptimizerStatus, } from "./commands.ts"; diff --git a/packages/tools/src/pullzones/model.ts b/packages/tools/src/pullzones/model.ts index 45e41b1c..365a5158 100644 --- a/packages/tools/src/pullzones/model.ts +++ b/packages/tools/src/pullzones/model.ts @@ -17,6 +17,10 @@ export const PullZoneSchema = z.object({ export type PullZone = z.infer; +function positiveOrNull(id: number | null | undefined): number | null { + return id && id > 0 ? id : null; +} + export function toPullZone(zone: PullZoneModel): PullZone { return { id: zone.Id ?? 0, @@ -26,8 +30,9 @@ export function toPullZone(zone: PullZoneModel): PullZone { hostnames: (zone.Hostnames ?? []).flatMap((h) => h.Value ? [h.Value] : [], ), - storageZoneId: zone.StorageZoneId || null, - edgeScriptId: zone.EdgeScriptId || null, + // The API reports "none" as -1. + storageZoneId: positiveOrNull(zone.StorageZoneId), + edgeScriptId: positiveOrNull(zone.EdgeScriptId), zoneSecurity: zone.ZoneSecurityEnabled ?? false, }; } diff --git a/packages/tools/src/pullzones/optimizer.test.ts b/packages/tools/src/pullzones/optimizer.test.ts index 34639536..58928a3b 100644 --- a/packages/tools/src/pullzones/optimizer.test.ts +++ b/packages/tools/src/pullzones/optimizer.test.ts @@ -31,7 +31,7 @@ const ENABLED_ZONE = { OptimizerPricing: 9.5, }; -test("pullzones.optimizer.set turns Optimizer on, keys the cache on image parameters only, and purges", async () => { +test("pullzones.optimizer.set turns Optimizer on, adds image parameters to a key that ignored query strings, and purges", async () => { const { ctx, calls } = fakeCore({ OptimizerEnabled: false, IgnoreQueryStrings: true, @@ -40,6 +40,7 @@ test("pullzones.optimizer.set turns Optimizer on, keys the cache on image parame const result = await pullZonesOptimizerSet.invoke(ctx, { pullZone: 7, enabled: true, + minify: false, }); expect(calls.map((c) => c.path)).toEqual([ "/pullzone/{id}", @@ -47,60 +48,76 @@ test("pullzones.optimizer.set turns Optimizer on, keys the cache on image parame ]); expect(calls[0]?.body).toMatchObject({ OptimizerEnabled: true, - OptimizerEnableWebP: true, - OptimizerEnableManipulationEngine: true, OptimizerMinifyCSS: false, - OptimizerMinifyJavaScript: false, IgnoreQueryStrings: false, QueryStringVaryParameters: OPTIMIZER_QUERY_PARAMETERS, }); expect(result).toMatchObject({ - enabled: true, configured: true, changed: true, purged: true, }); }); -test("pullzones.optimizer.set leaves a zone that already matches alone", async () => { - const { ctx, calls } = fakeCore({ ...ENABLED_ZONE }); - const result = await pullZonesOptimizerSet.invoke(ctx, { +test("pullzones.optimizer.set never narrows an existing cache key or touches minification unasked", async () => { + const custom = fakeCore({ + IgnoreQueryStrings: false, + QueryStringVaryParameters: ["page"], + OptimizerMinifyCSS: true, + }); + await pullZonesOptimizerSet.invoke(custom.ctx, { pullZone: 7, enabled: true, }); - expect(calls).toEqual([]); - expect(result).toMatchObject({ changed: false, purged: false }); -}); + expect(custom.calls[0]?.body).toMatchObject({ + QueryStringVaryParameters: ["page", ...OPTIMIZER_QUERY_PARAMETERS], + }); + expect(custom.calls[0]?.body).not.toHaveProperty("OptimizerMinifyCSS"); -test("pullzones.optimizer.set re-applies drifted settings when Optimizer is already on", async () => { - const { ctx, calls } = fakeCore({ - ...ENABLED_ZONE, - OptimizerMinifyCSS: true, + const everyParameter = fakeCore({ + IgnoreQueryStrings: false, + QueryStringVaryParameters: [], }); - await pullZonesOptimizerSet.invoke(ctx, { + await pullZonesOptimizerSet.invoke(everyParameter.ctx, { pullZone: 7, enabled: true, - purge: false, }); - expect(calls.map((c) => c.path)).toEqual(["/pullzone/{id}"]); + expect(everyParameter.calls[0]?.body).not.toHaveProperty( + "QueryStringVaryParameters", + ); }); -test("pullzones.optimizer.set off restores a cache key that ignores query strings", async () => { +test("pullzones.optimizer.set leaves a zone that already matches alone", async () => { const { ctx, calls } = fakeCore({ ...ENABLED_ZONE }); const result = await pullZonesOptimizerSet.invoke(ctx, { + pullZone: 7, + enabled: true, + }); + expect(calls).toEqual([]); + expect(result).toMatchObject({ changed: false, purged: false }); +}); + +test("pullzones.optimizer.set off undoes only the cache key it added", async () => { + const added = fakeCore({ ...ENABLED_ZONE }); + await pullZonesOptimizerSet.invoke(added.ctx, { pullZone: 7, enabled: false, }); - expect(calls[0]?.body).toEqual({ + expect(added.calls[0]?.body).toEqual({ OptimizerEnabled: false, IgnoreQueryStrings: true, QueryStringVaryParameters: [], }); - expect(result).toMatchObject({ + + const custom = fakeCore({ + ...ENABLED_ZONE, + QueryStringVaryParameters: ["page", ...OPTIMIZER_QUERY_PARAMETERS], + }); + await pullZonesOptimizerSet.invoke(custom.ctx, { + pullZone: 7, enabled: false, - configured: true, - cacheKeyParameters: [], }); + expect(custom.calls[0]?.body).toEqual({ OptimizerEnabled: false }); }); test("pullzones.optimizer.get falls back to the list price", async () => { diff --git a/packages/tools/src/pullzones/optimizer.ts b/packages/tools/src/pullzones/optimizer.ts index 6dc698f2..9cf3d74a 100644 --- a/packages/tools/src/pullzones/optimizer.ts +++ b/packages/tools/src/pullzones/optimizer.ts @@ -31,24 +31,78 @@ export const OPTIMIZER_QUERY_PARAMETERS = [ "output", ]; -// On: automatic WebP and per-device sizing, plus the Dynamic Image API for srcset loaders. Minification stays off because every framework already minifies. -const ENABLED_SETTINGS = { +// Optimizer's own switches; the zone's cache key and minification are handled separately so other settings survive. +const OPTIMIZER_FLAGS = { OptimizerEnabled: true, OptimizerAutomaticOptimizationEnabled: true, OptimizerEnableWebP: true, OptimizerEnableManipulationEngine: true, - OptimizerMinifyCSS: false, - OptimizerMinifyJavaScript: false, - IgnoreQueryStrings: false, - QueryStringVaryParameters: OPTIMIZER_QUERY_PARAMETERS, } satisfies PullZoneSettings; -// Off: back to a site's default cache key, which ignores query strings. -const DISABLED_SETTINGS = { - OptimizerEnabled: false, - IgnoreQueryStrings: true, - QueryStringVaryParameters: [], -} satisfies PullZoneSettings; +const sameParameter = (a: string, b: string) => + a.toLowerCase() === b.toLowerCase(); + +function hasAll(have: readonly string[], want: readonly string[]): boolean { + return want.every((w) => have.some((h) => sameParameter(h, w))); +} + +/** The Dynamic Image API needs its parameters in the cache key; add them without changing how any other parameter is cached. */ +function imageCacheKey(zone: PullZoneModel): PullZoneSettings { + const vary = zone.QueryStringVaryParameters ?? []; + // Every parameter already varies the cache. + if (!zone.IgnoreQueryStrings && vary.length === 0) return {}; + const base = zone.IgnoreQueryStrings ? [] : vary; + const missing = OPTIMIZER_QUERY_PARAMETERS.filter( + (p) => !base.some((b) => sameParameter(b, p)), + ); + return { + IgnoreQueryStrings: false, + QueryStringVaryParameters: [...base, ...missing], + }; +} + +/** Undo {@link imageCacheKey} only when the key is exactly what it adds to a zone that ignored query strings. */ +function restoredCacheKey(zone: PullZoneModel): PullZoneSettings { + const vary = zone.QueryStringVaryParameters ?? []; + const onlyImageParameters = + !zone.IgnoreQueryStrings && + vary.length === OPTIMIZER_QUERY_PARAMETERS.length && + hasAll(vary, OPTIMIZER_QUERY_PARAMETERS); + return onlyImageParameters + ? { IgnoreQueryStrings: true, QueryStringVaryParameters: [] } + : {}; +} + +function desiredSettings( + zone: PullZoneModel, + enabled: boolean, + minify: boolean | undefined, +): PullZoneSettings { + const minification = + minify === undefined + ? {} + : { OptimizerMinifyCSS: minify, OptimizerMinifyJavaScript: minify }; + return enabled + ? { ...OPTIMIZER_FLAGS, ...imageCacheKey(zone), ...minification } + : { OptimizerEnabled: false, ...restoredCacheKey(zone) }; +} + +/** The subset of `desired` that differs from the zone. */ +function changes( + zone: PullZoneModel, + desired: PullZoneSettings, +): PullZoneSettings { + return Object.fromEntries( + Object.entries(desired).filter(([key, want]) => { + const have = zone[key as keyof PullZoneModel]; + if (Array.isArray(want)) { + const list = (have as string[] | null | undefined) ?? []; + return !(list.length === want.length && hasAll(list, want as string[])); + } + return (have ?? false) !== want; + }), + ); +} export const OptimizerStatusSchema = z.object({ pullZone: z.number(), @@ -58,29 +112,12 @@ export const OptimizerStatusSchema = z.object({ minify: z.object({ css: z.boolean(), js: z.boolean() }), /** Query parameters in the cache key; empty when query strings are ignored, null when every parameter varies it. */ cacheKeyParameters: z.array(z.string()).nullable(), - /** True when every setting matches what `enabled` implies. */ + /** True when Optimizer's settings, and a cache key that admits image parameters, match what `enabled` implies. */ configured: z.boolean(), monthlyPrice: z.number(), }); export type OptimizerStatus = z.infer; -function sameMembers(a: readonly string[], b: readonly string[]): boolean { - const set = new Set(a.map((p) => p.toLowerCase())); - return a.length === b.length && b.every((p) => set.has(p.toLowerCase())); -} - -type OptimizerSettings = typeof ENABLED_SETTINGS | typeof DISABLED_SETTINGS; - -function matches(zone: PullZoneModel, desired: OptimizerSettings): boolean { - return Object.entries(desired).every(([key, want]) => { - const have = zone[key as keyof PullZoneModel]; - if (Array.isArray(want)) { - return sameMembers((have as string[] | null | undefined) ?? [], want); - } - return (have ?? false) === want; - }); -} - export function toOptimizerStatus(zone: PullZoneModel): OptimizerStatus { const enabled = zone.OptimizerEnabled ?? false; const vary = zone.QueryStringVaryParameters ?? []; @@ -98,7 +135,9 @@ export function toOptimizerStatus(zone: PullZoneModel): OptimizerStatus { : vary.length ? vary : null, - configured: matches(zone, enabled ? ENABLED_SETTINGS : DISABLED_SETTINGS), + configured: + Object.keys(changes(zone, desiredSettings(zone, enabled, undefined))) + .length === 0, monthlyPrice: zone.OptimizerPricing || OPTIMIZER_MONTHLY_PRICE, }; } @@ -149,10 +188,16 @@ export const pullZonesOptimizerSet = defineTool({ name: "pullzones.optimizer.set", title: "Turn Bunny Optimizer on or off", description: - "Turn Bunny Optimizer on (automatic WebP and per-device sizing, the Dynamic Image API, image parameters in the cache key, minification off) or off for a pull zone, then purge the cache so no unoptimized files are served. Optimizer is billed monthly per pull zone. Settings already in place are left alone.", + "Turn Bunny Optimizer on (automatic WebP and per-device sizing, the Dynamic Image API, image parameters added to the cache key) or off for a pull zone, then purge the cache so no unoptimized files are served. Other query parameters keep caching as before. Optimizer is billed monthly per pull zone. Settings already in place are left alone.", schema: z.strictObject({ pullZone: pullZoneRef, enabled: z.boolean().describe("`true` turns Optimizer on, `false` off."), + minify: z + .boolean() + .optional() + .describe( + "Set CSS and JavaScript minification when turning on; omit to leave it as is.", + ), purge: z .boolean() .default(true) @@ -164,14 +209,15 @@ export const pullZonesOptimizerSet = defineTool({ [{ pullZone: 12345, enabled: true }, "Turn Optimizer on"], [{ pullZone: 12345, enabled: false }, "Turn Optimizer off"], ], - run: async (ctx, { pullZone, enabled, purge }): Promise => { + run: async ( + ctx, + { pullZone, enabled, minify, purge }, + ): Promise => { const client = ctx.clients.core; ctx.progress("Fetching Optimizer status..."); const zone = await fetchZone(client, pullZone, ctx.signal); - const desired: OptimizerSettings = enabled - ? ENABLED_SETTINGS - : DISABLED_SETTINGS; - if (matches(zone, desired)) { + const patch = changes(zone, desiredSettings(zone, enabled, minify)); + if (Object.keys(patch).length === 0) { return { ...toOptimizerStatus(zone), changed: false, purged: false }; } @@ -182,7 +228,7 @@ export const pullZonesOptimizerSet = defineTool({ ); await client.POST("/pullzone/{id}", { params: { path: { id: pullZone } }, - body: desired, + body: patch, signal: ctx.signal, }); // Cached responses were built under the old settings, e.g. full-size PNGs.