Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sites-optimizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

`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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down
29 changes: 28 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1077,6 +1077,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
Expand All @@ -1087,7 +1093,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 `<img src="/hero.png">` 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 <name> --from-zone <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.

Expand All @@ -1107,13 +1115,32 @@ Every deploy publishes: the files land in an immutable `deploys/<id>/` 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) |
| `--force` | `ci init` | Overwrite an existing workflow file |
| `--print` | `open` | Print the URL instead of opening a browser |
| `--link` | `create`, `deploy`, `show`, `ci init`, `deployments list`, `deployments publish` | Link the directory to the site; `--no-link` never links |
| `--keep-storage` | `delete` | Delete the pull zone but keep the storage zone and its deploy files |
| `--force`, `-f` | `create --from-zone`, `deployments publish`/`prune`/`delete`, `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=`, ...), 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. 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
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.
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -37,6 +38,7 @@ const commands: CommandModule[] = [
sandboxNamespace,
storageNamespace,
sitesNamespace,
optimizerNamespace,
configNamespace,
skillsNamespace,
docsCommand,
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/commands/optimizer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
linkedPullZoneSource,
resolvePullZoneTarget,
} from "@/commands/pz/target.ts";
import { sitePullZoneSource } from "@/commands/sites/optimizer/shared.ts";
import { createOptimizerCommands } from "@/core/optimizer/index.ts";

// Resources whose directory links lead to a pull zone; the first linked one wins.
const sources = [linkedPullZoneSource, sitePullZoneSource];

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)",
},
resolve: (ref, args, ctx) =>
resolvePullZoneTarget(ctx, ref, args.output, sources),
});
29 changes: 29 additions & 0 deletions packages/cli/src/commands/pz/target.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
103 changes: 103 additions & 0 deletions packages/cli/src/commands/pz/target.ts
Original file line number Diff line number Diff line change
@@ -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<T extends PullZoneTarget = PullZoneTarget> {
/** Whether the current directory is linked to this kind of resource. */
linked(): boolean;
resolve(ctx: ToolContext, output: OutputFormat): Promise<T>;
}

const toTarget = (zone: PullZone): PullZoneTarget => ({
pullZone: zone.id,
name: zone.name,
label: "Pull zone",
});

async function listZones(ctx: ToolContext): Promise<PullZone[]> {
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<PullZoneManifest>(PULL_ZONE_MANIFEST).id,
resolve: async (ctx) => {
const { id } = loadManifest<PullZoneManifest>(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<T extends PullZoneTarget>(
ctx: ToolContext,
ref: string | undefined,
output: OutputFormat,
sources: PullZoneSource<T>[],
): Promise<T | PullZoneTarget> {
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<PullZoneManifest>(PULL_ZONE_MANIFEST, {
id: zone.id,
name: zone.name,
});
}
return toTarget(zone);
}
12 changes: 12 additions & 0 deletions packages/cli/src/commands/sites/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -382,6 +384,16 @@ export const sitesCreateCommand = defineCommand<CreateArgs>({
}
}

// 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());
Expand Down
Loading
Loading