diff --git a/.changeset/deploy-health-check.md b/.changeset/deploy-health-check.md new file mode 100644 index 00000000..9316c064 --- /dev/null +++ b/.changeset/deploy-health-check.md @@ -0,0 +1,22 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny sites deploy` asks the site for a page before it calls the deploy a +success. + +A published Edge Script that will not start makes the edge answer 400 with an +empty body, and the deploy said nothing: a green line, a URL, and a site that +served nothing. `withastro/astro.build` deployed exactly like that. + +The check probes the production URL up to three times, each with its own query so +the CDN cache cannot hold the answer. A redirect or a 404 counts as a working +script; only 400 and 5xx are faults, and a site that cannot be reached at all is +not called one. + +A site that answers is then asked for a path it cannot hold. The answer has to be +the deploy's own `404.html`, because a pull zone with no error page of its own +answers a miss with bunny.net's. That shipped once, on a documentation site. + +`--output json` carries `serving`, `status` when it is not, and `notFoundStatus` +when the 404 page did not answer. diff --git a/.changeset/deploy-polish.md b/.changeset/deploy-polish.md new file mode 100644 index 00000000..69cba2b6 --- /dev/null +++ b/.changeset/deploy-polish.md @@ -0,0 +1,16 @@ +--- +"@bunny.net/cli": patch +--- + +Four smaller things around `bunny sites deploy`. + +- `--name ` is honoured when the deploy creates the site. Without it an + unattended run stopped with "No site specified and no linked site found." +- `--region ` chooses the storage region for a site the deploy creates. + Only `sites create` could name one before. +- The domain prompt after a first deploy refuses a value that is not a hostname, + and says so. It used to send it, and the API's answer is `An error has + occurred.` +- The upload counts bytes as well as files. `withastro/astro.build` sends 1.4 GB + in 8828 files, and ten minutes of `4210/8828 files` says nothing about how much + is left. diff --git a/.changeset/lab-astro-deploy.md b/.changeset/lab-astro-deploy.md new file mode 100644 index 00000000..ca7393b8 --- /dev/null +++ b/.changeset/lab-astro-deploy.md @@ -0,0 +1,76 @@ +--- +"@bunny.net/cli": minor +"@bunny.net/config": minor +--- + +`bunny lab deploy astro` deploys an Astro project that renders pages per request. + +Two commands, and no more: + +```bash +bunny lab deploy astro +bunny lab undeploy astro +``` + +Astro's server becomes a standalone Edge Script. The client build goes into a +storage zone the script reads. The pull zone's origin is the script, so nothing +sits between a request and the code. The command provisions the three resources +on its first run, uploads the build, sets every variable from what it already +knows, applies the pull zone settings the adapter asks for, and publishes. No +password passes through the terminal. + +`bunny sites deploy` goes back to deploying a directory of files. It used to do +both jobs, and the share was the problem: a project that renders per request +cannot use `CURRENT_DEPLOY`, because one script serves one release, and a +directory of files cannot use a build manifest. Each flow carried checks for the +shape it was not, and a reader could not tell which command applied to which +project. Nothing under `lab/` imports from `sites/` now. + +`lab` says the interface is still being shaped. The namespace is hidden from help +and from the landing page, and a workflow built on either command should expect +to be updated. + +Server-side rendering only. A static Astro build is a directory of files, and +this command refuses one and names the command that deploys it. + +Measured against two real templates, deployed to a real account: +`withastro/astro/examples/ssr` and `render-examples/astro-ssr`. What that +changed: + +- **Astro 7 is checked before the install.** The adapter's peer range is + `^7.0.0`, and `render-examples/astro-ssr` ships Astro 5. npm answers that with + an ERESOLVE about peer ranges, which tells a developer nothing to act on. The + command stops first, and names `npx @astrojs/upgrade`. Upgrading a framework + major stays the developer's decision. +- **The adapter it replaces is uninstalled, not only unimported.** + `@astrojs/node@9` peers on `astro@^5`, so after an upgrade to Astro 7 it makes + every later install in that project fail. Replacing the adapter in the config + and leaving the package in `package.json` left the project broken in a way + nothing explained. +- **The pull zone's cache override goes off.** With the zone default in place the + edge rewrites every `Cache-Control` the adapter sets, so an HTML page would sit + a month stale in a browser that a purge cannot reach. +- **The state file belongs to the project, not to the working directory.** + `bunny lab deploy astro ./project` run from anywhere else found no state, + decided the app was new, and created a second set of resources beside the first. +- **The prefix is not added twice.** An app called `astro-ssr-demo` became + `astro-astro-ssr-demo-a1b2c3`, which reads like a mistake and spends six + characters of a 63-character DNS label on nothing. + +The deploy asks the site for its home page, and for a path it does not hold, +before it calls itself a success. Above 7.5 MB the warning says why a script +answers 400: measured in August 2026, the same code served every request at +7.44 MB and none at 7.83 MB, well under the documented 10 MB. + +Each deploy's files live at `deploys/{id}/`, and the folder's name is written into +the top of the bundle at publish time, so a release can only read the files it was +built against. There is no rollback, so every folder but the current one and the +one before it is deleted after a publish. + +`bunny lab undeploy astro` deletes the pull zone, the script, and the storage +zone. `--keep-storage` keeps the files. It lists what will go before it asks, and +`--name` finds the same resources with no state file, which is the fresh-clone and +CI case. + +`BuildManifestSchema` in `@bunny.net/config` is the contract with the adapter. The +CLI knows no framework: it reads the manifest. diff --git a/.changeset/router-static-layer.md b/.changeset/router-static-layer.md new file mode 100644 index 00000000..2de1e295 --- /dev/null +++ b/.changeset/router-static-layer.md @@ -0,0 +1,34 @@ +--- +"@bunny.net/cli": minor +--- + +Serve a static site's 404 page, redirects, and headers from the router. + +The router reads three file names out of the deploy it is serving: `404.html`, +`_redirects`, and `_headers`. Cloudflare Pages and Netlify read the same three, +so nothing in the router knows about a framework and every preset gets it. + +- **`404.html`** answers a path the deploy does not hold, at status 404. Without + it the pull zone answers with bunny.net's error page, whatever the site built. + That shipped: a documentation site went up and every wrong URL showed + bunny.net's page. +- **`_redirects`** sends a real redirect. One rule per line, `/from /to [status]`, + `#` comments, a trailing `*` captured as `:splat`, and `!` to beat a file at the + same path. 301 is the default status; 302, 303, 307 and 308 are read too. A + rewrite (`200`) is not: it would have the router fetch another path of its own + site, which can be made to loop. +- **`_headers`** carries the headers Bunny Storage cannot hold. A `/path` line + opens a block, `Name: value` lines under it belong to it, and a later block + wins the same name. + +A rule and a header match on a trailing-slash-normalised path, so `/about` and +`/about/` are one rule. The rules are read once per deploy and held in memory, +never written into the script, so a publish stays an environment variable change. + +The router now sets `Cache-Control` on every response, and a site's pull zone +stops overriding it (`CacheControlMaxAgeOverride: -1`). The zone default of 30 +days replaced every answer the script gave, so an HTML page could be a month +stale in a browser that no purge reaches. A page now gets 60 seconds, anything +else 30 days as before, and `_headers` wins where it says anything. +`bunny sites upgrade-router` applies the router and the setting together, and +`bunny sites deploy` does it for a site whose router lags. diff --git a/AGENTS.md b/AGENTS.md index 9ae491a7..ffa310ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ bunny-cli/ │ │ └── src/ │ │ ├── index.ts # Barrel export: schemas, types, conversion functions │ │ ├── schema.ts # Zod schemas + inferred types: BunnyConfigSchema (root; optional app + sites), AppConfigSchema, SiteConfigSchema, BunnyAppConfigSchema (app required) +│ ├── build-manifest.ts # BuildManifestSchema: the `.bunny/build.json` a framework adapter writes, and the whole contract between an adapter and the CLI. It names the server entry, the client directory, the pull zone settings the build needs, and whether the build renders per request (`kind`). The CLI knows no framework: it reads the manifest │ │ ├── convert.ts # API ↔ config conversion (apiToConfig, configToAddRequest, configToPatchRequest) │ │ └── parse-image-ref.ts # Docker image reference parser (parseImageRef) │ │ @@ -410,28 +411,31 @@ bunny-cli/ │ │ │ ├── upload.ts # Upload a local file ( positional, --zone, --to, --checksum streams a SHA256, --content-type) │ │ │ ├── download.ts # Download a file to disk ( positional, --zone, --out) │ │ │ └── remove.ts # Delete a file or directory (alias: rm; positional, --zone, trailing slash = recursive); `/` empties the zone and takes the same double confirmation as deleting the zone, and requireConfirmable means unattended runs need --force -│ │ ├── sites/ # Experimental (hidden from help and landing page) — static-site hosting (storage zone + pull zone + middleware router) +│ │ ├── lab/ # Experimental (hidden from help and landing page) — commands still being shaped. `lab/astro/` deploys one Astro project that renders per request: `bunny lab deploy astro` and `bunny lab undeploy astro`, and nothing else. It imports nothing from `sites/`: the two commands deploy different shapes, and sharing a state file made each of them carry checks for the shape it is not. project.ts (resolveProject: a monorepo root is not a project, so it offers the ones below it; requireSupportedAstro: the adapter's peer range is `astro@^7`, and `render-examples/astro-ssr` ships Astro 5, where `npm install` answers with an ERESOLVE about peer ranges that a developer cannot act on — so the version is checked before the install, and the message names `npx @astrojs/upgrade`), adapter.ts (ensureAdapter: install, and patchAstroConfig, which replaces a known vendor adapter and refuses any config it cannot edit safely, and never touches `output` — setting `output: "server"` on astro.build took the script from 7.83 MB to 22.30 MB. The replaced adapter is uninstalled too, not only unimported: `@astrojs/node@9` peers on `astro@^5`, so once Astro is 7 it makes every later install in that project fail), manifest.ts (loadBuildManifest + requireSsrBuild: a static build is refused and pointed at `bunny sites deploy`), naming.ts (`astro--` for the globally-unique zone names, and the prefix is not added twice), state.ts (`.bunny/astro.json`, built from the PROJECT root and never walked up from the working directory — that bug made `lab deploy astro ./project` run from elsewhere find no state, call the app new, and create a second set of resources beside the first), resources.ts (ensureResources: storage zone + standalone Edge Script + its linked pull zone, each looked up by name first so a half-finished create re-runs; deleteResources takes the pull zone down first, because it is the only public thing), publish.ts (the `globalThis.__BUNNY_DEPLOY__` preamble at publish time, purge-settle-purge because a probe cannot tell the outgoing release from the incoming one, and applyPullZoneSettings, which applies what the manifest asks for AND `CacheControlMaxAgeOverride: -1` — with the zone default in place the edge rewrites every Cache-Control the adapter sets), env.ts (the zone, its endpoint, its read-only password, the pull zone ID; a write password only when the build asks for sessions; a secret is written once, when the name is absent, so a rotated password stays), upload.ts, verify.ts (the home page, and a path the site cannot hold) + tests +│ │ ├── sites/ # Experimental (hidden from help and landing page) — site hosting for a directory of files (storage zone + pull zone + the CLI's router Edge Script) │ │ │ ├── index.ts # defineNamespace("sites", false, ...): create/list/show/deploy/deployments/domains/link/unlink/upgrade-router/delete; describe:false keeps it out of help while it stabilizes │ │ │ ├── constants.ts # SITES_MANIFEST (.bunny/site.json), REMOTE_STATE_PATH (_bunny/site.json), RemoteSiteState/DeployRecord types (state carries routerVersion), parseRemoteState (shape-checked; null = not a site), deployPrefix, deploy-ID + site-name validators (3-47 chars), suffixedResourceName/siteResourcePattern (zone names are `sites-{name}-{random 6}`: the prefix marks them in the dashboard, the suffix dodges the global zone namespace) │ │ │ ├── constants.test.ts # parseRemoteState round-trip/rejection + helper tests -│ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; current/previous follow promotedTo, so last promote wins and non-promoting writers adopt the concurrent pointers), siteContextFromZone, fetchSites (pull zone listing → middleware+storage candidates → per-zone state verification), createSite (idempotent provisioning: storage zone → router script code+publish+CURRENT_DEPLOY → pull zone + MiddlewareScriptId attach → state; both zones share a random name suffix so globally-taken names can't block the create, retrying fresh suffixes on collision; resume adopts a stateless name-pattern zone, and state.name keeps the clean site name), promoteDeploy (env var PUT + purgeCache POST), ensureRouterCurrent (republishes the router when state.routerVersion lags ROUTER_VERSION), fetchSystemHostname, deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles. fetchSites only accepts a candidate whose state names it as the site's own pullZoneId, so another zone sharing the storage origin is never mistaken for a site +│ │ │ ├── api.ts # siteFiles IO seam (connect/download/upload/remove; swap in tests instead of mock.module), remote state read/write (sha256 etag optimistic lock: concurrent deploy records merge on mismatch, ours win per id; current/previous follow promotedTo, so last promote wins and non-promoting writers adopt the concurrent pointers), siteContextFromZone, fetchSites (pull zone listing → middleware+storage candidates → per-zone state verification), createSite (idempotent provisioning: storage zone → router script code+publish+CURRENT_DEPLOY → pull zone + MiddlewareScriptId attach → state; both zones share a random name suffix so globally-taken names can't block the create, retrying fresh suffixes on collision; resume adopts a stateless name-pattern zone, and state.name keeps the clean site name), promoteDeploy (env var PUT + purgeCache POST), ensureRouterCurrent (republishes the router when state.routerVersion lags ROUTER_VERSION, then applies applySiteZoneSettings), applySiteZoneSettings (STATIC_SITE_ZONE_SETTINGS on the site's pull zone: `CacheControlMaxAgeOverride: -1`, because from router v6 the router owns Cache-Control and the zone default of 2592000 would replace every answer it gives — a month-stale page in a browser no purge reaches, and a 404 outliving the deploy that fixes it. Best-effort and idempotent, so a failure warns and the next republish retries), fetchSystemHostname, deleteSiteResources (pull zone → script → storage zone, best-effort), deleteDeployFiles. fetchSites only accepts a candidate whose state names it as the site's own pullZoneId, so another zone sharing the storage origin is never mistaken for a site │ │ │ ├── api.test.ts # In-memory siteFiles store + path-branching fake clients: state round-trip, etag conflict, createSite fresh/resume/already-exists, promote, fetchSites filtering │ │ │ ├── interactive.ts # selectSite: explicit ref (storage zone ID/name, falling back to a state.name match since zone names carry a suffix) → .bunny/site.json → bunny.jsonc sites.name → picker (offerLink like scripts); `force` errors instead of opening the picker (destructive commands pass their --force, which also skips the confirmation, so a picked site would be acted on unprompted; deploy's --force means "redeploy unchanged content" and is not passed); optional offerCreate (deploy only) adds a new-vs-existing prompt, and creates straight away when the account has no sites; siteOptionBuilder (--site) + sitePositionalBuilder ([site]) + siteLinkOption (--link, mounted only by the commands that call offerLink); an explicit --link links whatever site was resolved (ref or bunny.jsonc included) during resolution, not via offerLink, since every command returns from its `--output json` branch before offerLink runs (and the confirmation line is suppressed under json); the picker keeps prompting unless --link/--no-link already decided it │ │ │ ├── provision.ts # promptSiteName (normalize/validate, directory-name suggestion) + createSiteWithProgress (createSite under a step-tracking spinner; shared with create.ts) + createLinkedSite (create + manifest link → SiteContext, skipping create's domain/CI prompts) for the deploy picker's new-site branch │ │ │ ├── config.ts # loadSiteConfig: reads bunny.jsonc via core/bunny-config.ts and validates ONLY the `sites` block (SiteConfigSchema from @bunny.net/config), so sites-only configs work without an `app` block or `version` -│ │ │ ├── router/source.ts # routerSource + ROUTER_VERSION (recorded in site state; deploy republishes stale routers via ensureRouterCurrent): the middleware Edge Script, one per site, attached to its pull zone. CRITICAL platform gotcha: at the edge ctx.request.url is the ORIGIN-facing address (http://:9000/...), NOT the requested host; the client hostname comes from the CDN-Host/Host headers (clientHostname()), and the index-retry probe URL must be rebuilt on that host so it re-enters the CDN instead of hitting unrouted storage paths. Every request → CURRENT_DEPLOY's directory, root-served so client-side routers and root-absolute assets work as-is; /_bunny/* → 403 (the client-sent x-bunny-index-retry header is stripped; the flag is router-internal), trailing-slash → index.html, and a slashless GET/HEAD 404 probes its directory index (re-entrant HEAD of the URL + "/") and 301-redirects to the slash URL when it exists (so /blog resolves with the right relative-URL base, while exact extensionless objects and dotted directories stay reachable) +│ │ │ ├── router/source.ts # routerSource + ROUTER_VERSION (recorded in site state; deploy republishes stale routers via ensureRouterCurrent): the middleware Edge Script, one per site, attached to its pull zone. CRITICAL platform gotcha: at the edge ctx.request.url is the ORIGIN-facing address (http://:9000/...), NOT the requested host; the client hostname comes from the CDN-Host/Host headers (clientHostname()), and the index-retry probe URL must be rebuilt on that host so it re-enters the CDN instead of hitting unrouted storage paths. Every request → CURRENT_DEPLOY's directory, root-served so client-side routers and root-absolute assets work as-is; /_bunny/* → 403 (the client-sent x-bunny-index-retry header is stripped; the flag is router-internal), trailing-slash → index.html, and a slashless GET/HEAD 404 probes its directory index (re-entrant HEAD of the URL + "/") and 301-redirects to the slash URL when it exists (so /blog resolves with the right relative-URL base, while exact extensionless objects and dotted directories stay reachable). v6: the router also reads three names out of the deploy it is serving — `404.html` (answers a miss, at status 404, with `Cache-Control: no-cache` so the next deploy's fix is not outlived), `_redirects` and `_headers` — through its own reserved `/_bunny/router/` path, which is the whole permission (the allowlist is those names plus `404/index.html`, so nothing else under `_bunny/` becomes reachable; the mapped request carries `x-bunny-raw`, so the response phase adds nothing to it and cannot recurse). Cloudflare Pages and Netlify read the same names, so every one of the ~30 static presets gets this and nothing here knows a framework. The rules are parsed once per deploy and held in memory (`configs`), never inlined in the source, so a publish stays an env var change; a read that failed is forgotten rather than remembered as "no rules". `_redirects` subset: `from to [status]`, `#` comments, trailing `*` captured as `:splat`, `!` forces the rule ahead of the origin (the only kind answered before it, which is what makes a real file win), statuses 301/302/303/307/308 (a `200` rewrite is deliberately out: it would have the router fetch its own site and can be made to loop). `_headers` subset: a `/path` line opens a block, `Name: value` lines under it belong to it, a later block wins a name. Both match on a trailing-slash-normalised path (`/about/` and `/about` are one rule), and `_headers` also matches the index-expanded object path. `x-bunny-path` carries the client's path to the response phase, because by then the URL is the rewritten origin one. onOriginResponse: `_headers`, then a `Cache-Control` for every response that carries none (`public, max-age=60` for a document, `public, max-age=2592000` for anything else — the zone override is off, see `STATIC_SITE_ZONE_SETTINGS`, so this is the answer the visitor gets) │ │ │ ├── deploy-id.ts # gitIdentity (short sha + dirty check via Bun.spawn), contentHashId (sorted path+sha256 merkle → 8 hex), resolveDeployIdentity (clean git → sha, else content hash) │ │ │ ├── deploy-id.test.ts # Hash determinism + real temp git repos (clean → sha, dirty → content hash) │ │ │ ├── uploader.ts # collectFiles (recursive walk, skips dotfiles/node_modules, sorted), hashFiles (streaming sha256), uploadDeploy (8-way concurrency, per-file checksum, 3-attempt backoff retry) via siteFiles.upload │ │ │ ├── uploader.test.ts # Walk/skip/hash tests + upload paths/checksums/retry via siteFiles swap │ │ │ ├── build.ts # resolveAutoBuild (framework preset or package.json build script, via ci/frameworks detection) + runBuildCommand (Bun.spawn shell, caller env + overrides, throws on non-zero exit) │ │ │ ├── build.test.ts # Env parsing + real build spawn success/failure +│ │ │ ├── health.ts # What a fresh deploy is asked before the command calls it a success: findDeployFault (the home page, three tries, each with its own query so the CDN cannot hold the answer; a 400 or 5xx that does not go away is a script that will not start) and findMissingPageFault + readNotFoundPage (a path the deploy cannot hold has to answer with the deploy's own 404 page; the probe is a path, not a query string, because a sites zone ignores query strings) +│ │ │ ├── health.test.ts # Fault/no-fault statuses, retries, unreachable sites │ │ │ ├── create.ts # bunny sites create [name] (falls back to `sites.name` in bunny.jsonc, else prompted with a directory-name suggestion): createSite (storage + router + pull zone; forces HTTPS on the system host, best-effort) + manifest link + custom production domain via setupSiteDomain (--domain flag, offered interactively when omitted; domain failure warns, never fails the create) │ │ │ ├── list.ts # List sites (name, URL, deploy count, current) via fetchSites │ │ │ ├── show.ts # Site details + hostname table (SSL cert + Force SSL columns); a failed hostname fetch hides the table, never the site │ │ │ ├── open.ts # bunny sites open [site]: open the live URL (recorded custom domain when the zone still serves it, else system host) in the browser; --print emits it, siteLiveUrl is the pure resolver │ │ │ ├── ssl.ts # bunny sites ssl [site]: toggle Force HTTPS on the site's b-cdn.net system host via setForceSsl (no cert issued; --no-force-ssl allows HTTP); custom domains use `sites domains ssl` -│ │ │ ├── deploy.ts # bunny sites deploy [dir]: resolve site (picker offers to create a new site when none is linked) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → router upgrade check (ensureRouterCurrent; a failed republish is a warning, since the old router still resolves CURRENT_DEPLOY) → hash → no-op if unchanged AND already live → upload deploys/{id}/ → state write → promote. Every deploy publishes; rollback to any earlier deploy is `deployments publish`. A domainless site's first-ever deploy also offers a custom production domain (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint +│ │ │ ├── deploy.ts # bunny sites deploy [dir]: deploy a directory of files. A build that renders pages per request is a different shape: `bunny lab deploy astro` deploys one, and this command refuses it. Resolve site (picker offers to create a new site when none is linked; --name with --region creates one unattended) → build (--build resolves flag command → `sites.build` → detected build, failing before any site is created; no --build offers a detected/configured build interactively; without a dir arg, --build deploys the detected framework's output dir) → router upgrade check (ensureRouterCurrent; a failed republish is a warning, since the old router still resolves CURRENT_DEPLOY) → hash → no-op if unchanged AND already live → upload deploys/{id}/ → state write → promote → the published deploy is asked for a path it cannot hold, and the answer has to be the deploy's own 404 page (findMissingPageFault in sites/health.ts; a pull zone with no error page of its own answers a miss with bunny.net's, which shipped once on a documentation site). Every deploy publishes; rollback to any earlier deploy is `deployments publish`. A domainless site's first-ever deploy also offers a custom production domain (setupSiteDomain, interactive text runs only); later domainless deploys print a dim `sites domains add` hint │ │ │ ├── link.ts # Link directory to a site (.bunny/site.json) │ │ │ ├── unlink.ts # Remove .bunny/site.json │ │ │ ├── upgrade-router.ts # Republish the site's router script with the CLI's current source and record ROUTER_VERSION in state (deploy also auto-republishes stale routers) @@ -1179,6 +1183,13 @@ bunny │ ├── show [id] Show Edge Script details (uses linked script if omitted) │ └── stats [id] [--from] [--to] [--hourly] [--link] │ Show usage statistics (requests/CPU/cost totals + bar chart; defaults to last 30 days). No ID → linked script → interactive picker (offers to link; --no-link skips). JSON output skips the picker and errors. +├── lab (experimental, hidden from help and landing page) Commands still being shaped; the interface can change between releases. +│ ├── deploy +│ │ └── astro [dir] [--name] [--region] [--no-build] [--yes] [--force] +│ │ Deploy one Astro project that renders pages per request. Server-side rendering only: a static build is refused and pointed at `bunny sites deploy`. Three resources, named `astro-{app}-{suffix}` (suffixed because zone and pull zone names are global across bunny.net, and the prefix is not added twice when the app name already carries it): a storage zone for the client build, a standalone Edge Script holding Astro's server, and the pull zone whose ORIGIN is that script (OriginType 4), so nothing sits between a request and the code. In order: resolveProject (a monorepo root is not a project) → requireSupportedAstro (Astro 7; an older project stops here with `npx @astrojs/upgrade`, because npm's own ERESOLVE about peer ranges tells a developer nothing to act on) → ensureAdapter (install `@bunny.net/astro-adapter`, patch the Astro config, and UNINSTALL the adapter it replaces — `@astrojs/node@9` peers on `astro@^5` and breaks every later install once Astro is 7) → build → `.bunny/build.json` must say `kind: "ssr"` → the 10 MB refusal, before any resource exists → ensureResources (each looked up by name first, so a half-finished create re-runs) → `.bunny/astro.json`, built from the PROJECT root, never walked up from the working directory → applyPullZoneSettings (what the manifest asks for, plus `CacheControlMaxAgeOverride: -1`; with the zone default in place the edge rewrites every Cache-Control the adapter sets, so an HTML page goes a month stale in a browser a purge cannot reach) → applyScriptEnv → upload `deploys/{id}/`, THEN publish the code (a script published before its assets renders pages naming files that are not there) → purge, settle, purge (a probe cannot tell the outgoing release from the incoming one) → ask the site for its home page and for a path it does not hold → prune every deploy folder but this one and the one before it. The deploy ID is the content hash of the client build AND the server bundle together, because the bundle names the hashed asset it loads; an unchanged deploy is a no-op unless --force. `--yes` is required unattended, because the adapter changes a package.json and an astro.config. +│ └── undeploy +│ └── astro [dir] [--name] [--keep-storage] [--force] +│ Delete the app and its three resources. The state file names them, so the prompt lists what will go, and the app name has to be typed; `--name` finds the same resources by name instead, which is the fresh-clone and CI case. The pull zone goes first, because it is the only public thing. A 404 from the API counts as deleted, so a partial failure can be re-run. The local link is cleared only when everything it pointed at is gone. ├── sites (experimental, hidden from help and landing page) Manage sites. │ │ Static-site hosting: one storage zone (files) + one pull zone (CDN) + one middleware router script per site. Zone names are `sites-{name}-{random suffix}` (prefixed for dashboard grouping; suffixed because zone names are global across bunny.net); the site keeps its clean name in state. Deploys are immutable directories (`deploys/{id}/`); every deploy publishes, and rollback flips the router's CURRENT_DEPLOY env var + purges the cache with no files moving. Custom domains are vanity hostnames on the site's pull zone. Site state lives at `_bunny/site.json` in the storage zone (403-blocked by the router); `.bunny/site.json` is the local pointer. Site resolution everywhere: explicit ref → .bunny/site.json → `sites.name` in bunny.jsonc → interactive picker (offers to link). The picker is skipped, with an error, under `--output json`/no TTY and on destructive commands run with `--force`. │ ├── create [name] [--region] [--domain] [--link] @@ -1186,8 +1197,8 @@ bunny │ ├── list (alias: ls) List sites (middleware+storage pull zones with matching remote state) │ ├── show [site] [--link] Show resources, domains (with SSL + Force SSL state), current deploy; warns when a newer router is available │ ├── open [site] [--print] Open the live URL (recorded custom domain when live, else system host) in the browser; --print emits it -│ ├── deploy [dir] [--site] [--link] [--build [cmd]] [--env K=V] [--env-file] [--force] -│ │ Deploy a directory: git short-sha ID when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID skips the upload and just publishes). Every deploy is published as the live site. The target site resolves via selectSite (--site → linked → bunny.jsonc → picker); when nothing is linked, the interactive picker offers to create a new site (or, with no sites yet, goes straight to create) and links it. A [dir] arg is cwd-relative; without it the target is `sites.dir` (or the detected output dir), resolved against the bunny.jsonc directory where the build runs, else that directory (dotfiles + node_modules excluded). --build runs the command (or `sites.build`, else the detected build; resolved before any site is created so a missing command can't leave an orphan site) in the caller's environment plus --env/--env-file overrides. Without --build, an interactive run offers to run the configured `sites.build`, else a detected build (the CI framework preset's command, else a package.json `build` script); confirming builds first and, when no dir was given, deploys the framework's output dir. +│ ├── deploy [dir] [--site] [--link] [--build [cmd]] [--env K=V] [--env-file] [--name] [--region] [--force] +│ │ Deploy a directory of files. A build that renders pages per request is a different shape: `bunny lab deploy astro` deploys one, and this command refuses it. Deploy IDs: git short-sha when the tree is clean, content hash otherwise; identical IDs are no-ops (an already-uploaded ID skips the upload and just publishes). Every deploy is published as the live site. The target site resolves via selectSite (--site → linked → bunny.jsonc → picker); when nothing is linked, the interactive picker offers to create a new site (or, with no sites yet, goes straight to create) and links it, and --name (with --region) creates one unattended. A [dir] arg is cwd-relative; without it the target is `sites.dir` (or the detected output dir), resolved against the bunny.jsonc directory where the build runs, else that directory (dotfiles + node_modules excluded). --build runs the command (or `sites.build`, else the detected build; resolved before any site is created so a missing command can't leave an orphan site) in the caller's environment plus --env/--env-file overrides. Without --build, an interactive run offers to run the configured `sites.build`, else a detected build (the CI framework preset's command, else a package.json `build` script); confirming builds first and, when no dir was given, deploys the framework's output dir. │ ├── deployments │ │ ├── list [site] [--link] (alias: ls) List deploys (● Live / ○ Previous markers, created, source, files, size) │ │ ├── publish [id] [--previous] [--site] [--link] [--force] (alias: promote) @@ -1658,7 +1669,7 @@ bunny db migrations apply ## Conventions for Adding New Commands -1. Create a new directory under `packages/cli/src/commands/` for the domain (e.g., `packages/cli/src/commands/deploy/`). +1. Create a new directory under `packages/cli/src/commands/` for the domain (e.g., `packages/cli/src/commands/queues/`). 2. Create `index.ts` using `defineCommand()` for leaf commands or `defineNamespace()` for groups. 3. Use `builder` to define command-specific flags. Use positionals for required arguments (`command: "create "`). 4. **Add flag equivalents for every interactive prompt** so the command is fully scriptable (see "Agent & Scripting Compatibility"). diff --git a/README.md b/README.md index 8fd9383e..308e1249 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ bun ny storage files remove / # empty the zone; asks twice (yes/no bun ny sites create my-site # provision a static site (storage zone + pull zone + edge router; zones are named sites-my-site-, served at sites-my-site-.b-cdn.net) bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys (a site's first deploy also offers to attach a custom domain) bun ny sites deploy ./dist # deploy a directory and publish it as the live site +bun ny lab deploy astro # deploy an Astro project that renders per request (experimental) bun ny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected framework's build), then deploy `sites.dir` (or the detected output dir) bun ny sites deployments list # list deploys with the live one marked bun ny sites deployments publish --previous # instant rollback to the previous deploy @@ -85,6 +86,8 @@ bun ny sites ci init # add a GitHub Actions workflow (pus Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file). +`bunny sites deploy` deploys a directory of files. A build that renders pages per request is a different shape: `bun ny lab deploy astro` deploys an Astro project as an Edge Script, with its client build in Bunny Storage. It reads the `.bunny/build.json` that [`@bunny.net/astro-adapter`](https://github.com/BunnyWay/bunny-adapters) writes, and it has one companion command, `bun ny lab undeploy astro`, which deletes the app again. `lab` means the interface is still being shaped. + ### Available scripts ```bash diff --git a/bun.lock b/bun.lock index ef8dd815..5cb99a8c 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "bun-ny-cli", diff --git a/packages/cli/README.md b/packages/cli/README.md index 474ad0d9..65867357 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -908,7 +908,9 @@ bunny scripts docs > **Experimental**: hidden from `--help` and the landing page while it stabilizes. -Host static sites on bunny.net. Each site is three resources provisioned and wired together for you: a **storage zone** holding the files, a **pull zone** serving them over the CDN, and a **middleware router** (an Edge Script) that maps incoming requests to the deploy that should answer them. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. +Host sites on bunny.net. Each site is three resources provisioned and wired together for you: a **storage zone** holding the files, a **pull zone** serving them over the CDN, and an **Edge Script**. Zones are named `sites--` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name. + +`bunny sites` deploys a directory of files. A build that renders pages per request is a different shape, and this command does not deploy one: `bunny lab deploy astro` deploys an Astro project as an Edge Script of its own. The two flows share nothing, because a project that renders per request cannot use `CURRENT_DEPLOY` (one script serves one release), and a directory of files has no build manifest to read. Deploys are immutable: every `sites deploy` uploads to its own `deploys//` directory and then goes live. Publishing flips the router's `CURRENT_DEPLOY` variable and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op. @@ -927,6 +929,7 @@ bunny sites deploy ./dist # deploy a directory and p bunny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected build), then deploy bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content +bunny sites deploy --name my-site --region NY # create the site this deploy needs, unattended # Deploys: list, publish (roll back), prune bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size @@ -959,11 +962,24 @@ bunny sites delete my-site --keep-storage # typed-name confirmation; Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos. +The router serves the deploy's own configuration, from three file names Cloudflare Pages and Netlify read too. They belong to the build, not to bunny.net, so any framework that already writes them works here unchanged: + +| File in the deploy | What the router does with it | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `404.html` | Answers a path the deploy does not hold, at status 404. Without it a miss gets bunny.net's error page | +| `_redirects` | One rule per line: `/from /to [status]`. A trailing `*` in the path is captured as `:splat`, and `!` beats a real file | +| `_headers` | A `/path` line, then indented `Name: value` lines. This is where a build asks for a CSP, or for immutable assets | + +A rule needs no status, and 301 is the default; 302, 303, 307 and 308 are read too. A rewrite (`200`) is not: it would have the router fetch another path of its own site, which can be made to loop. A rule without `!` applies only when the deploy holds no file at that path, so a real file always wins. Both files are read once per deploy and held in memory, and `bunny sites deploy` asks the live site for a path it cannot hold, so a 404 page that never reaches a visitor is reported rather than shipped. + +The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says where it says anything. So `sites create` turns the pull zone's own cache override off, which is what lets the router's answer through. `sites upgrade-router` applies both to a site made by an earlier CLI. + Every deploy publishes: the files land in an immutable `deploys//` directory and the router is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. Content is root-served, so client-side routing and absolute asset paths work as-is. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. | Flag | Commands | Description | | -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | | `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | +| `--name`, `--region` | `deploy` | Site name and storage region, for a site this deploy creates | | `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) | | `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides | | `--force` | `deploy` | Deploy even when the content is unchanged | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0679e267..edafed81 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ import { configNamespace } from "./commands/config/index.ts"; import { dbNamespace } from "./commands/db/index.ts"; import { dnsNamespace } from "./commands/dns/index.ts"; import { docsCommand } from "./commands/docs.ts"; +import { labNamespace } from "./commands/lab/index.ts"; import { openCommand } from "./commands/open.ts"; import { registriesNamespace } from "./commands/registries/index.ts"; import { registryNamespace } from "./commands/registry/index.ts"; @@ -41,6 +42,7 @@ const commands: CommandModule[] = [ // Experimental commands — registered but hidden from help and landing page const experimentalCommands: CommandModule[] = [ appsNamespace, + labNamespace, registriesNamespace, registryNamespace, sitesNamespace, @@ -143,7 +145,7 @@ export const cli = instance ["Create an edge script", "bunny scripts init"], ["Add a domain to manage DNS", "bunny dns zones add example.com"], ["Create a dev sandbox", "bunny sandbox create my-sandbox"], - // ["Deploy a static site", "bunny sites deploy"], + // ["Deploy this project", "bunny sites deploy"], // ["Deploy an app", "bunny apps deploy"], ]; diff --git a/packages/cli/src/commands/lab/astro/adapter.test.ts b/packages/cli/src/commands/lab/astro/adapter.test.ts new file mode 100644 index 00000000..682d5cb4 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/adapter.test.ts @@ -0,0 +1,160 @@ +import { expect, test } from "bun:test"; +import { patchAstroConfig, vendorAdapterIn } from "./adapter.ts"; + +const PKG = "@bunny.net/astro-adapter"; + +test("adds the import and the adapter to a fresh config", () => { + const source = [ + "// @ts-check", + 'import { defineConfig } from "astro/config";', + "", + "export default defineConfig({});", + "", + ].join("\n"); + + const patched = patchAstroConfig(source, PKG)?.source; + expect(patched).toContain('import bunny from "@bunny.net/astro-adapter";'); + expect(patched).toContain("adapter: bunny()"); + // The import goes after the last existing one, not above the file's comment. + expect(patched?.indexOf("// @ts-check")).toBe(0); +}); + +// Since Astro 5, a project that says nothing prerenders its pages, and a page +// asks for the edge with `export const prerender = false`. Setting +// `output: "server"` here took astro.build's script from 7.83 MB to 22.30 MB. +test("never sets output", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + "export default defineConfig({});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)?.source).not.toContain("output"); +}); + +test("keeps existing options, and adds the adapter beside them", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import sitemap from "@astrojs/sitemap";', + "", + "export default defineConfig({", + " site: https://example.com,", + " integrations: [sitemap()],", + "});", + ].join("\n"); + + const patched = patchAstroConfig(source, PKG)?.source; + expect(patched).toContain("integrations: [sitemap()]"); + expect(patched).toContain("adapter: bunny()"); + // The adapter's import lands after the last one, so nothing is shadowed. + const importEnd = patched?.lastIndexOf("import ") ?? -1; + expect(patched?.slice(importEnd)).toContain("bunny"); +}); + +// Moving to bunny.net from another host is the commonest first deploy there is. +test("replaces another vendor's adapter, and says which one", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + "import cloudflare from '@astrojs/cloudflare';", + 'import sitemap from "@astrojs/sitemap";', + "export default defineConfig({", + " integrations: [sitemap()],", + " adapter: cloudflare({", + " imageService: 'cloudflare-binding',", + " }),", + "});", + ].join("\n"); + + const patch = patchAstroConfig(source, PKG); + expect(patch?.replaced).toBe("@astrojs/cloudflare"); + // The name follows the package: `cloudflare()` pointing at bunny.net would + // work, and would read like a mistake. + expect(patch?.source).toContain( + 'import bunny from "@bunny.net/astro-adapter";', + ); + expect(patch?.source).toContain("adapter: bunny(),"); + expect(patch?.source).not.toContain("cloudflare"); + expect(patch?.source).not.toContain("cloudflare-binding"); + // Nothing else moved. + expect(patch?.source).toContain("integrations: [sitemap()]"); +}); + +// A file that already has a `bunny` keeps its own name, so nothing is shadowed. +test("keeps the old name when bunny is taken", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import node from "@astrojs/node";', + "const bunny = 1;", + "export default defineConfig({", + " adapter: node(),", + "});", + ].join("\n"); + + const patch = patchAstroConfig(source, PKG); + expect(patch?.source).toContain( + 'import node from "@bunny.net/astro-adapter";', + ); + expect(patch?.source).toContain("adapter: node(),"); +}); + +test("replaces an adapter whose options span nothing at all", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import node from "@astrojs/node";', + "export default defineConfig({", + ' adapter: node({ mode: "standalone" }),', + "});", + ].join("\n"); + + const patch = patchAstroConfig(source, PKG); + expect(patch?.replaced).toBe("@astrojs/node"); + expect(patch?.source).not.toContain("standalone"); +}); + +// An adapter nobody has heard of is somebody's decision, so this refuses rather +// than fights. The CLI then names the file and quotes the lines to write. +test("refuses to replace an adapter it does not know", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import mystery from "astro-adapter-mystery";', + "export default defineConfig({", + " adapter: mystery(),", + "});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)).toBeNull(); +}); + +test("changes nothing when the adapter is already configured", () => { + const source = [ + 'import { defineConfig } from "astro/config";', + 'import bunny from "@bunny.net/astro-adapter";', + "export default defineConfig({", + " adapter: bunny(),", + "});", + ].join("\n"); + + expect(patchAstroConfig(source, PKG)?.source).toBe(source); +}); + +// Anything this cannot read safely is left alone, and the CLI prints the snippet. +test("refuses a config it cannot read", () => { + expect(patchAstroConfig("export default makeConfig();", PKG)).toBeNull(); + expect(patchAstroConfig("export default defineConfig({});", PKG)).toBeNull(); +}); + +test("names the adapter in the way, so the prompt can say it", () => { + const cloudflare = [ + "import cloudflare from '@astrojs/cloudflare';", + "export default defineConfig({", + " adapter: cloudflare(),", + "});", + ].join("\n"); + expect(vendorAdapterIn(cloudflare)).toBe("@astrojs/cloudflare"); + + const none = [ + "export default defineConfig({", + " site: 'https://example.com',", + "});", + ].join("\n"); + expect(vendorAdapterIn(none)).toBeUndefined(); +}); diff --git a/packages/cli/src/commands/lab/astro/adapter.ts b/packages/cli/src/commands/lab/astro/adapter.ts new file mode 100644 index 00000000..c7541303 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/adapter.ts @@ -0,0 +1,324 @@ +/** + * Putting the adapter into somebody's project. + * + * Editing a configuration file is only acceptable when the result is obviously + * right. So this handles two shapes and no more: the config `astro create` + * writes, and the one line another host's adapter occupies. Anything else gets + * the exact lines to paste, and the command stops. + */ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { + detectWorkspace, + installCommand, + readPackageJson, + uninstallCommand, +} from "../../../core/package-manager.ts"; +import { confirm, isInteractive } from "../../../core/ui.ts"; + +/** The adapter this command deploys with. */ +export const ADAPTER_PACKAGE = "@bunny.net/astro-adapter"; + +/** Is the adapter already a dependency of the project? */ +export async function hasAdapter(root: string, pkg: string): Promise { + const json = await readPackageJson(root); + const deps = { + ...(json?.dependencies as Record | undefined), + ...(json?.devDependencies as Record | undefined), + }; + return Boolean(deps[pkg]); +} + +/** The project's Astro config file, whichever extension it uses. */ +export function findAstroConfig(root: string): string | undefined { + for (const name of [ + "astro.config.mjs", + "astro.config.js", + "astro.config.ts", + "astro.config.mts", + ]) { + const path = join(root, name); + if (existsSync(path)) return path; + } + return undefined; +} + +/** + * Adapters people move to bunny.net from. + * + * Replacing one is a mechanical edit: the import goes, and the `adapter` value + * becomes ours. Naming them is what lets the CLI say "this project uses + * @astrojs/cloudflare" instead of "the config needs one more change". + */ +const VENDOR_ADAPTERS = [ + "@astrojs/cloudflare", + "@astrojs/vercel", + "@astrojs/netlify", + "@astrojs/node", + "@astrojs/deno", + "@deno/astro-adapter", + "astro-sst", + "@sveltejs/adapter-auto", +]; + +/** What the adapter is called in a config this writes. */ +const LOCAL_NAME = "bunny"; + +export interface ConfigPatch { + /** The new config source. */ + source: string; + /** The adapter package this took out of the config, when it replaced one. */ + replaced?: string; +} + +/** The end of the call that starts at `open`, by counting brackets. */ +function endOfCall(source: string, open: number): number | null { + let depth = 0; + for (let i = open; i < source.length; i++) { + const char = source[i]; + if (char === "(" || char === "{" || char === "[") depth++; + else if (char === ")" || char === "}" || char === "]") { + depth--; + if (depth === 0) return i + 1; + } else if (char === '"' || char === "'" || char === "`") { + // Skip the string, so a bracket inside it does not count. + for (i++; i < source.length; i++) { + if (source[i] === "\\") i++; + else if (source[i] === char) break; + } + } + } + return null; +} + +/** The import statement that brings `local` into the file. */ +function importOf( + source: string, + local: string, +): { text: string; from: string } | null { + const pattern = new RegExp( + `^import\\s+${local}\\s*(?:,\\s*\\{[^}]*\\}\\s*)?from\\s*["']([^"']+)["'];?\\s*$`, + "m", + ); + const match = pattern.exec(source); + return match ? { text: match[0], from: match[1] ?? "" } : null; +} + +/** + * Add the adapter to an Astro config, replacing another vendor's when one is + * there. + * + * Returns the new source, or null when the config is not one this can edit + * safely. Editing somebody's configuration is only acceptable when the result is + * obviously right, so this handles the shape `astro create` writes, and the one + * line another host's adapter occupies. + * + * It does not touch `output`. Since Astro 5 a project that says nothing gets + * prerendered pages, and a page asks for the edge with + * `export const prerender = false`. Setting `output: "server"` on such a project + * turns every page into one that renders per request: measured on + * `withastro/astro.build`, it took the script from 7.83 MB to 22.30 MB and + * prerendered none of its 4499 pages. + */ +export function patchAstroConfig( + source: string, + pkg: string, +): ConfigPatch | null { + if (source.includes(pkg)) return { source }; + + if (!/defineConfig\(\{/.test(source)) return null; + + const lastImport = [...source.matchAll(/^import .*?;?$/gm)].pop(); + if (lastImport?.index === undefined) return null; + + // An adapter already in the config: replace it when it is one we know, and + // leave it alone when it is not. + const existing = /(\n[ \t]*adapter\s*:\s*)([A-Za-z_$][\w$]*)\s*\(/.exec( + source, + ); + if (existing) { + const local = existing[2] ?? ""; + const found = importOf(source, local); + if (!found || !VENDOR_ADAPTERS.includes(found.from)) return null; + + const callStart = existing.index + existing[0].length - 1; + const callEnd = endOfCall(source, callStart); + if (callEnd === null) return null; + + // `import cloudflare from "@bunny.net/astro-adapter"` would work and read + // like a mistake, so the name changes with the package. It only stays when + // the file already has something called `bunny`. + const name = new RegExp(`\\b${LOCAL_NAME}\\b`).test(source) + ? local + : LOCAL_NAME; + const withAdapter = `${source.slice(0, callStart - local.length)}${name}()${source.slice(callEnd)}`; + // The import keeps its place, so the file's order is the one it had. + return { + source: withAdapter.replace(found.text, `import ${name} from "${pkg}";`), + replaced: found.from, + }; + } + + const importEnd = lastImport.index + lastImport[0].length; + const withImport = `${source.slice(0, importEnd)}\nimport ${LOCAL_NAME} from "${pkg}";${source.slice(importEnd)}`; + + // Re-find the call: the import above moved it. + const call = /defineConfig\(\{/.exec(withImport); + if (call?.index === undefined) return null; + const insertAt = call.index + call[0].length; + + return { + source: `${withImport.slice(0, insertAt)}\n adapter: ${LOCAL_NAME}(),${withImport.slice(insertAt)}`, + }; +} + +/** What to tell a developer whose config this cannot edit. */ +function manualSnippet(pkg: string): string { + return [ + `import bunny from "${pkg}";`, + "", + "export default defineConfig({", + " adapter: bunny(),", + "});", + ].join("\n"); +} + +async function run(command: string, cwd: string): Promise { + logger.info(`Running: ${command}`); + const shell = + process.platform === "win32" + ? ["cmd", "/c", command] + : ["sh", "-c", command]; + const proc = Bun.spawn(shell, { + cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + if ((await proc.exited) !== 0) { + throw new UserError(`\`${command}\` failed.`); + } +} + +/** + * Put the adapter into this project, asking first. + * + * Installing is always the developer's choice. An unattended run reports the two + * changes and stops, unless `--yes` says to go ahead: a deploy command that + * silently rewrites a config in CI is worse than one that refuses. + */ +export async function ensureAdapter(opts: { + root: string; + output: string | undefined; + /** `--yes`: make the changes without asking. */ + assumeYes: boolean; +}): Promise { + const { root, output } = opts; + const pkg = ADAPTER_PACKAGE; + + const installed = await hasAdapter(root, pkg); + const configPath = findAstroConfig(root); + const source = configPath ? await Bun.file(configPath).text() : null; + const configured = source?.includes(pkg) ?? false; + if (installed && configured) return; + + const workspace = await detectWorkspace(root); + const install = installCommand(workspace, pkg); + // What is in the way, when something is: another host's adapter. + const inTheWay = source === null ? null : vendorAdapterIn(source); + const file = configPath?.split("/").pop() ?? "the Astro config"; + + // The adapter it replaces has to leave the project, not only the config. + // `@astrojs/node@9` peers on `astro@^5`, so once Astro is 7 it makes every + // later `npm install` in that project fail with an unrelated-looking + // ERESOLVE. Taking it out is the same change as replacing it in the config. + const stale = + inTheWay && (await hasAdapter(root, inTheWay)) ? inTheWay : null; + const uninstall = stale ? uninstallCommand(workspace, stale) : null; + + if (configPath === undefined) { + throw new UserError( + `This project has no Astro config file, so the adapter cannot be set.`, + [ + "Create astro.config.mjs with:", + "", + 'import { defineConfig } from "astro/config";', + manualSnippet(pkg), + ].join("\n"), + ); + } + + const asking = !opts.assumeYes; + if (asking && !isInteractive(output)) { + logger.warn(`This project has no bunny.net adapter.`); + if (uninstall) logger.dim(` ${uninstall}`); + if (!installed) logger.dim(` ${install}`); + if (!configured) { + if (inTheWay) { + logger.dim(` In ${file}, replace ${inTheWay} with ${pkg}.`); + } + logger.dim(` ${manualSnippet(pkg)}`); + } + throw new UserError( + "The adapter is not in this project yet.", + "Make the changes above, or re-run with --yes to have this command make them.", + ); + } + + if (asking) { + const wanted = await confirm( + inTheWay + ? `Replace ${inTheWay} with ${pkg}?` + : installed + ? `Add ${pkg} to ${file}?` + : `Add ${pkg} to this project?`, + { initial: true }, + ); + if (!wanted) { + throw new UserError( + "Nothing was deployed.", + `Without an adapter, Astro cannot build a route that renders on demand.`, + ); + } + } + + // Out before in: leaving the old adapter's peer range in place is what makes + // the install fail. + if (uninstall) await run(uninstall, root); + if (!installed) await run(install, root); + + if (!configured) { + const patch = source === null ? null : patchAstroConfig(source, pkg); + if (patch === null) { + throw new UserError( + `${installed ? "This project has" : "Installed"} ${pkg}, and ${file} needs one change this cannot make safely.`, + [ + ...(inTheWay + ? [`Take out the ${inTheWay} adapter, and add this:`] + : ["Add this:"]), + "", + manualSnippet(pkg), + "", + "Then run `bunny lab deploy astro` again.", + ].join("\n"), + ); + } + await Bun.write(configPath, patch.source); + logger.success( + patch.replaced + ? `Replaced ${patch.replaced} with ${pkg} in ${file}.` + : `Added the adapter to ${file}.`, + ); + } +} + +/** The vendor adapter a config already uses, when it uses one. */ +export function vendorAdapterIn(source: string): string | undefined { + const existing = /\n[ \t]*adapter\s*:\s*([A-Za-z_$][\w$]*)\s*\(/.exec(source); + const local = existing?.[1]; + if (!local) return undefined; + const from = importOf(source, local)?.from; + return from && from !== "astro/config" ? from : undefined; +} diff --git a/packages/cli/src/commands/lab/astro/build.ts b/packages/cli/src/commands/lab/astro/build.ts new file mode 100644 index 00000000..9083539b --- /dev/null +++ b/packages/cli/src/commands/lab/astro/build.ts @@ -0,0 +1,38 @@ +/** + * Running the project's own build. + * + * The command runs `astro build` through the project's package manager, so the + * build sees the same binaries and the same lockfile the developer does. It is + * run before any resource is created, so a build that fails cannot leave three + * empty resources behind. + */ +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { detectWorkspace } from "../../../core/package-manager.ts"; + +/** The build command, per package manager. */ +export async function buildCommand(root: string): Promise { + const { pm } = await detectWorkspace(root); + return pm === "npm" ? "npm run build" : `${pm} run build`; +} + +/** Run one shell command in the project, streaming its output. */ +export async function run(command: string, cwd: string): Promise { + logger.info(`Running: ${command}`); + const shell = + process.platform === "win32" + ? ["cmd", "/c", command] + : ["sh", "-c", command]; + const proc = Bun.spawn(shell, { + cwd, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + }); + if ((await proc.exited) !== 0) { + throw new UserError( + `\`${command}\` failed.`, + "Fix the build, then run the command again.", + ); + } +} diff --git a/packages/cli/src/commands/lab/astro/deploy.ts b/packages/cli/src/commands/lab/astro/deploy.ts new file mode 100644 index 00000000..9dbb11f0 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/deploy.ts @@ -0,0 +1,425 @@ +/** + * `bunny lab deploy astro` + * + * One Astro project that renders per request, on Edge Scripting and Bunny + * Storage. Astro's server becomes a standalone Edge Script, and the client build + * goes into a storage zone the script reads. + * + * Nothing here knows about `bunny sites`. The two commands deploy different + * shapes, and sharing a state file made each of them carry checks for the shape + * it is not. + */ +import { resolve } from "node:path"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { formatBytes } from "../../../core/format.ts"; +import { logger } from "../../../core/logger.ts"; +import { ignoreManifestDir } from "../../../core/manifest.ts"; +import { readPackageJson } from "../../../core/package-manager.ts"; +import { isInteractive, withSpinner } from "../../../core/ui.ts"; +import { ADAPTER_PACKAGE, ensureAdapter } from "./adapter.ts"; +import { buildCommand, run } from "./build.ts"; +import { applyScriptEnv, resolveScriptEnv } from "./env.ts"; +import { + type LoadedBuildManifest, + loadBuildManifest, + requireAstroBuild, + requireSsrBuild, + resolveAssetsDir, + resolveScriptEntry, +} from "./manifest.ts"; +import { APP_NAME_RULES, appNameFrom, requireValidAppName } from "./naming.ts"; +import { requireSupportedAstro, resolveProject } from "./project.ts"; +import { applyPullZoneSettings, publishDeploy } from "./publish.ts"; +import { DEFAULT_REGION, ensureResources } from "./resources.ts"; +import { loadState, markCurrent, saveState } from "./state.ts"; +import { connect, pruneDeploys } from "./storage.ts"; +import { + collectFiles, + contentHash, + hashFiles, + uploadClientBuild, +} from "./upload.ts"; +import { findMissingPageFault, findServingFault } from "./verify.ts"; + +/** Edge Scripting takes one JavaScript file of up to 10 MB. */ +const SCRIPT_SIZE_LIMIT = 10 * 1024 * 1024; + +/** + * Above this, a published script often misses its 500 ms startup budget, and the + * edge answers 400 with an empty body. + * + * Measured in August 2026 on a standalone script in DE, in the units this CLI + * prints: the same code served every request at 7.44 MB (7,798,944 bytes) and + * none at 7.83 MB (8,209,699 bytes). Nothing in the API reports this, so the only + * place a developer can hear it is here. + */ +const SCRIPT_START_RISK = 7.5 * 1024 * 1024; + +interface DeployArgs { + dir?: string; + name?: string; + region?: string; + build: boolean; + yes: boolean; + force: boolean; +} + +/** The app name: `--name`, then the state, then the package's own name. */ +async function resolveAppName( + root: string, + explicit: string | undefined, +): Promise { + if (explicit) return requireValidAppName(explicit); + + const state = loadState(root); + if (state) return state.name; + + const pkg = await readPackageJson(root); + const fromPackage = + typeof pkg?.name === "string" ? appNameFrom(pkg.name) : null; + if (fromPackage) return fromPackage; + + const fromDir = appNameFrom(resolve(root).split("/").pop() ?? ""); + if (fromDir) return fromDir; + + throw new UserError( + "Couldn't work out a name for this app.", + `Pass one: bunny lab deploy astro --name my-app\n${APP_NAME_RULES}`, + ); +} + +/** Read the built bundle, and refuse a script the platform cannot take. */ +async function readBundle( + loaded: LoadedBuildManifest, +): Promise<{ code: string; bytes: number; sha256: string }> { + const code = await Bun.file(resolveScriptEntry(loaded)).text(); + const bytes = Buffer.byteLength(code); + if (bytes > SCRIPT_SIZE_LIMIT) { + throw new UserError( + `${loaded.manifest.script?.entry} is ${formatBytes(bytes)}, and Edge Scripting takes ${formatBytes(SCRIPT_SIZE_LIMIT)}.`, + "Prerender the routes that need no server, or drop a dependency the server does not need, then build again.", + ); + } + return { + code, + bytes, + sha256: new Bun.CryptoHasher("sha256").update(code).digest("hex"), + }; +} + +export const labDeployAstroCommand = defineCommand({ + command: "astro [dir]", + describe: "Deploy an Astro project that renders pages per request.", + examples: [ + ["$0 lab deploy astro", "Build this project, then deploy it"], + ["$0 lab deploy astro --name my-app", "Name the app it deploys to"], + ["$0 lab deploy astro --no-build", "Deploy the build already on disk"], + ["$0 lab deploy astro --yes", "Add the adapter without asking"], + ], + + builder: (yargs) => + yargs + .positional("dir", { + type: "string", + describe: "The project directory (default: the current one)", + }) + .option("name", { + type: "string", + describe: + "The app's name. Default: the state file, then the package's name", + }) + .option("region", { + type: "string", + describe: `Storage region for a new app (default: ${DEFAULT_REGION})`, + }) + .option("build", { + type: "boolean", + default: true, + describe: "Run the project's build first (--no-build to skip)", + }) + .option("yes", { + alias: "y", + type: "boolean", + default: false, + describe: "Install and configure the adapter without asking", + }) + .option("force", { + type: "boolean", + default: false, + describe: "Deploy even when nothing changed", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey } = args; + const json = output === "json"; + + // 1. The project, and whether its Astro can run this adapter at all. The + // version check comes before the install, because npm's own peer error is + // not something a developer can act on. + const root = await resolveProject(args.dir, output); + await requireSupportedAstro(root); + + // 2. The adapter. Astro cannot build a route that renders on demand without + // one, so this is the only change to somebody's source this command makes. + await ensureAdapter({ root, output, assumeYes: args.yes }); + + // 3. The build, before any resource exists. + if (args.build) await run(await buildCommand(root), root); + + // 4. What the build says it produced. + const loaded = await loadBuildManifest(root); + if (!loaded) { + throw new UserError( + `${ADAPTER_PACKAGE} wrote no build manifest, so there is nothing to deploy.`, + args.build + ? "The build ran and wrote no .bunny/build.json. Report it to the adapter." + : "Build the project first, or drop --no-build.", + ); + } + requireAstroBuild(loaded); + requireSsrBuild(loaded); + + const bundle = await readBundle(loaded); + const assetsDir = resolveAssetsDir(loaded); + + const appName = await resolveAppName(root, args.name); + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + // 5. The files. Hashed before anything is created, so the deploy's own name + // is known and an unchanged deploy can be recognised. + const files = await withSpinner("Hashing the client build...", () => + hashFiles(collectFiles(assetsDir)), + ); + if (files.length === 0) { + throw new UserError( + `Nothing to deploy; ${loaded.manifest.assets.dir} has no files.`, + "Dotfiles and node_modules are excluded.", + ); + } + const totalBytes = files.reduce((sum, f) => sum + f.size, 0); + const deployId = contentHash(files, bundle.sha256); + + const previous = loadState(root); + if ( + !args.force && + previous?.contentHash === deployId && + previous.current === deployId + ) { + const url = previous.hostname ? `https://${previous.hostname}` : null; + if (json) { + logger.log( + JSON.stringify( + { app: appName, id: deployId, unchanged: true, url }, + null, + 2, + ), + ); + return; + } + logger.info( + `No changes: deploy ${deployId} is already live. Use --force to deploy it again.`, + ); + if (url) logger.log(` ${url}`); + return; + } + + // 6. The resources. Each one is looked up before it is made. + const created = await withSpinner(`Preparing "${appName}"...`, (spin) => + ensureResources({ + coreClient, + computeClient, + appName, + region: args.region ?? DEFAULT_REGION, + onStep: (message) => { + spin.text = message; + }, + }), + ); + const state = { + ...created.state, + ...(previous?.name === appName + ? { current: previous.current, previous: previous.previous } + : {}), + }; + const zone = created.storageZone; + const first = previous?.current === undefined; + + saveState(root, state); + if (ignoreManifestDir(root)) { + logger.dim( + " Added .bunny/ to .gitignore; it holds the link to this app.", + ); + } + + // 7. The pull zone's settings, before a page is served through it. A site + // that answers while the zone still strips Set-Cookie looks broken in a way + // nothing explains. + const changed = await withSpinner("Checking the pull zone...", () => + applyPullZoneSettings( + coreClient, + state.pullZoneId, + loaded.manifest.requires?.pullZone, + ), + ); + if (changed.length > 0 && !json) { + logger.info(`Applied the pull zone settings: ${changed.join(", ")}.`); + } + + // 8. The variables the script reads. + const { entries, unset } = resolveScriptEnv( + loaded.manifest, + zone, + state.pullZoneId, + ); + const set = await withSpinner("Setting the script's variables...", () => + applyScriptEnv(computeClient, state.scriptId, entries), + ); + if (set.length > 0 && !json) { + logger.info(`Set ${set.length} script variable(s): ${set.join(", ")}.`); + } + + // 9. The files, then the code. In that order, always: a script published + // before its assets are up would render pages naming files that are not + // there yet. + const connection = connect(zone); + let sent = 0; + await withSpinner(`Uploading ${files.length} files...`, (spin) => + uploadClientBuild(connection, deployId, files, (done, total, file) => { + // Bytes, not only files: a large build spends minutes here, and a file + // count says nothing about how much of it is left. + sent += file.size; + spin.text = `Uploading ${done}/${total} files (${formatBytes(sent)} of ${formatBytes(totalBytes)})...`; + }), + ); + + const published = await withSpinner("Publishing...", () => + publishDeploy({ + computeClient, + coreClient, + scriptId: state.scriptId, + pullZoneId: state.pullZoneId, + code: bundle.code, + deploy: { + id: deployId, + assetPrefix: `deploys/${deployId}`, + site: appName, + }, + }), + ); + + markCurrent(state, deployId); + state.contentHash = deployId; + saveState(root, state); + + // 10. The old deploys. Nothing can publish one, because there is no + // rollback, and the one before this release keeps its files for a moment + // longer in case a node is still serving it. + const pruned = await withSpinner("Pruning old deploys...", () => + pruneDeploys(connection, [deployId, state.previous ?? ""]), + ); + + const url = state.hostname ? `https://${state.hostname}` : null; + + // 11. The check. A green line above a URL that answers 400 is the worst + // thing this command can do. + const fault = url + ? await withSpinner("Checking the site...", () => + findServingFault(url, deployId), + ) + : null; + const missing = + url && fault === null + ? await withSpinner("Checking a missing page...", () => + findMissingPageFault(url, deployId), + ) + : null; + + if (json) { + logger.log( + JSON.stringify( + { + app: appName, + id: deployId, + url, + files: files.length, + bytes: totalBytes, + scriptBytes: bundle.bytes, + release: published.release ?? null, + storageZone: state.storageZone, + scriptId: state.scriptId, + pullZoneId: state.pullZoneId, + pruned, + serving: fault === null, + ...(fault === null ? {} : { status: fault }), + ...(missing === null ? {} : { notFoundStatus: missing }), + unsetEnv: unset, + }, + null, + 2, + ), + ); + return; + } + + logger.success( + `Deployed ${deployId}: ${files.length} files (${formatBytes(totalBytes)}), script ${formatBytes(bundle.bytes)}.`, + ); + if (url) logger.info(` ${url}`); + if (first) { + logger.dim(` storage zone ${state.storageZone}`); + logger.dim(` edge script ${state.scriptId}`); + logger.dim(` pull zone ${state.pullZoneId}`); + } + + if (fault !== null) { + logger.warn(`The site answered ${fault}, so the script is not serving.`); + if (bundle.bytes > SCRIPT_START_RISK) { + logger.dim( + ` The script is ${formatBytes(bundle.bytes)}, and a script has 500 ms to start. Every byte is parsed first.`, + ); + logger.dim( + " Measured in August 2026: the same code served every request at 7.4 MB, and none at 7.8 MB.", + ); + logger.dim( + " Prerender a route, or drop a dependency the server does not need, then deploy again.", + ); + } else { + logger.dim( + " The script may be failing as it starts. Read its logs in the dashboard: Scripting > your script > Logs.", + ); + } + } else if (missing !== null) { + logger.warn( + `A path this site does not hold answered with bunny.net's error page (${missing}), not Astro's.`, + ); + logger.dim( + " Check the script's logs in the dashboard: Scripting > your script > Logs.", + ); + } + + const named = unset.filter((name) => name !== "BUNNY_API_KEY"); + if (named.length > 0) { + logger.dim( + ` ${ADAPTER_PACKAGE} also reads ${named.join(", ")}. Set them with \`bunny scripts env set\`.`, + ); + } + if (unset.includes("BUNNY_API_KEY")) { + logger.dim( + " Cache purging needs an account API key: bunny scripts env set BUNNY_API_KEY --secret", + ); + } + if (!isInteractive(output)) return; + logger.dim(" Take it down again: bunny lab undeploy astro"); + }, +}); diff --git a/packages/cli/src/commands/lab/astro/env.ts b/packages/cli/src/commands/lab/astro/env.ts new file mode 100644 index 00000000..134a70a6 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/env.ts @@ -0,0 +1,114 @@ +/** + * The variables the script reads, and where their values come from. + * + * The developer types no password. This command created the zone, so it holds + * both of them: the read-only one goes to the script, and only a build that + * writes sessions gets the one that can write. + * + * No secret enters the bundle or the config. It is set on the script, and read + * from the environment at run time. + */ +import type { BuildManifest } from "@bunny.net/config"; +import { fetchEnvEntries } from "../../scripts/api.ts"; +import type { StorageZoneModel } from "../../storage/api.ts"; +import type { ComputeClient } from "./resources.ts"; +import { readOnlyPassword, storageHostFor } from "./storage.ts"; + +export interface ScriptEnv { + name: string; + value: string; + secret?: boolean; +} + +/** + * What this command can set, and what it can only name. + * + * `unset` is every variable the manifest asks for that nothing here knows. An + * API key for cache purging is the usual one: it belongs to the account, not to + * a zone, so the developer sets it. + */ +export function resolveScriptEnv( + manifest: BuildManifest, + zone: StorageZoneModel, + pullZoneId: number, +): { entries: ScriptEnv[]; unset: string[] } { + const known: Record = { + BUNNY_STORAGE_ZONE: { name: "BUNNY_STORAGE_ZONE", value: zone.Name ?? "" }, + BUNNY_STORAGE_HOST: { + name: "BUNNY_STORAGE_HOST", + value: storageHostFor(zone.Region), + }, + BUNNY_STORAGE_KEY: { + name: "BUNNY_STORAGE_KEY", + value: readOnlyPassword(zone), + secret: true, + }, + BUNNY_PULLZONE_ID: { + name: "BUNNY_PULLZONE_ID", + value: String(pullZoneId), + }, + }; + if (manifest.requires?.storage?.write) { + known.BUNNY_SESSION_ZONE = { + name: "BUNNY_SESSION_ZONE", + value: zone.Name ?? "", + }; + known.BUNNY_SESSION_KEY = { + name: "BUNNY_SESSION_KEY", + value: zone.Password ?? "", + secret: true, + }; + } + + const entries: ScriptEnv[] = []; + const unset: string[] = []; + for (const want of manifest.requires?.env ?? []) { + const entry = known[want.name.toUpperCase()]; + if (entry?.value) entries.push(entry); + else unset.push(want.name); + } + return { entries, unset }; +} + +/** + * Set the variables, skipping the ones already correct. + * + * A secret cannot be read back, so it is written once, when the name is absent. + * That keeps a rotated password in place, and keeps a deploy from re-writing a + * secret on every run. + */ +export async function applyScriptEnv( + client: ComputeClient, + scriptId: number, + entries: ScriptEnv[], +): Promise { + const existing = await fetchEnvEntries(client, scriptId); + const variables = new Map( + existing + .filter((e) => !e.secret) + .map((e) => [e.name.toUpperCase(), e.value]), + ); + const secrets = new Set( + existing.filter((e) => e.secret).map((e) => e.name.toUpperCase()), + ); + + const set: string[] = []; + for (const entry of entries) { + const name = entry.name.toUpperCase(); + if (entry.secret) { + if (secrets.has(name)) continue; + await client.PUT("/compute/script/{id}/secrets", { + params: { path: { id: scriptId } }, + body: { Name: name, Secret: entry.value }, + }); + } else { + if (variables.get(name) === entry.value) continue; + await client.PUT("/compute/script/{id}/variables", { + params: { path: { id: scriptId } }, + body: { Name: name, DefaultValue: entry.value }, + }); + } + set.push(name); + } + return set; +} diff --git a/packages/cli/src/commands/lab/astro/manifest.test.ts b/packages/cli/src/commands/lab/astro/manifest.test.ts new file mode 100644 index 00000000..92984eb7 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/manifest.test.ts @@ -0,0 +1,141 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import { + loadBuildManifest, + minimumCliVersion, + resolveAssetsDir, + resolveScriptEntry, +} from "./manifest.ts"; + +const tempDir = useTempDir("bunny-manifest-"); + +function validManifest(overrides?: Record) { + return { + manifestVersion: 1, + adapter: { package: "@bunny.net/astro-adapter", version: "0.2.0" }, + framework: { name: "astro", version: "7.2.3" }, + kind: "ssr", + script: { entry: "dist/index.js", type: "standalone", bytes: 1234 }, + assets: { dir: "dist/client" }, + ...overrides, + }; +} + +/** Write a manifest, and optionally the build it describes. */ +function project( + root: string, + manifest: unknown, + opts?: { build?: boolean }, +): void { + mkdirSync(join(root, ".bunny"), { recursive: true }); + writeFileSync( + join(root, ".bunny/build.json"), + typeof manifest === "string" ? manifest : JSON.stringify(manifest), + ); + if (opts?.build) { + mkdirSync(join(root, "dist/client/_astro"), { recursive: true }); + writeFileSync(join(root, "dist/index.js"), "export default 1;"); + writeFileSync(join(root, "dist/client/_astro/app.css"), "body{}"); + } +} + +test("minimumCliVersion reads a >= floor and ignores anything else", () => { + expect(minimumCliVersion(">=2.6.0")).toBe("2.6.0"); + expect(minimumCliVersion(" >= 2.6.0 ")).toBe("2.6.0"); + // An unparseable range must not stop a deploy: an adapter's guess about a + // future CLI is not worth failing over. + expect(minimumCliVersion("^2.6.0")).toBeNull(); + expect(minimumCliVersion(undefined)).toBeNull(); +}); + +test("no manifest is not an error; it means the project builds no server", async () => { + expect(await loadBuildManifest(tempDir())).toBeNull(); +}); + +test("a manifest is read, and its root is the directory holding .bunny", async () => { + const root = tempDir(); + project(root, validManifest()); + const loaded = await loadBuildManifest(root); + expect(loaded?.root).toBe(root); + expect(loaded?.manifest.kind).toBe("ssr"); + expect(loaded?.manifest.script?.entry).toBe("dist/index.js"); +}); + +test("the manifest is found from a subdirectory of the project", async () => { + const root = tempDir(); + project(root, validManifest()); + mkdirSync(join(root, "src/pages"), { recursive: true }); + const loaded = await loadBuildManifest(join(root, "src/pages")); + expect(loaded?.root).toBe(root); +}); + +test("a manifest that is not JSON stops the deploy", async () => { + const root = tempDir(); + project(root, "{ not json"); + await expect(loadBuildManifest(root)).rejects.toThrow(/not valid JSON/); +}); + +test("a manifest missing a required field stops the deploy", async () => { + const root = tempDir(); + project(root, { manifestVersion: 1, kind: "ssr" }); + await expect(loadBuildManifest(root)).rejects.toThrow( + /not a build manifest this CLI understands/, + ); +}); + +// Half a deployed site is worse than none, so an unknown shape is refused. +test("a newer manifest version asks for a newer CLI", async () => { + const root = tempDir(); + project(root, validManifest({ manifestVersion: 99 })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /version 99, and this CLI reads 1/, + ); +}); + +test("an adapter that needs a newer CLI says so, naming the version", async () => { + const root = tempDir(); + project(root, validManifest({ requires: { cliVersion: ">=999.0.0" } })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /needs bunny CLI 999\.0\.0 or newer/, + ); +}); + +test("the CLI's own version satisfies a floor it is above", async () => { + const root = tempDir(); + project(root, validManifest({ requires: { cliVersion: ">=0.0.1" } })); + expect((await loadBuildManifest(root))?.manifest.kind).toBe("ssr"); +}); + +test("a server build with no script named is refused", async () => { + const root = tempDir(); + project(root, validManifest({ script: undefined })); + await expect(loadBuildManifest(root)).rejects.toThrow( + /server build with no script/, + ); +}); + +test("a static manifest needs no script", async () => { + const root = tempDir(); + project(root, validManifest({ kind: "static", script: undefined })); + expect((await loadBuildManifest(root))?.manifest.kind).toBe("static"); +}); + +test("the script entry and the assets directory resolve against the root", async () => { + const root = tempDir(); + project(root, validManifest(), { build: true }); + const loaded = await loadBuildManifest(root); + if (!loaded) throw new Error("expected a manifest"); + expect(resolveScriptEntry(loaded)).toBe(join(root, "dist/index.js")); + expect(resolveAssetsDir(loaded)).toBe(join(root, "dist/client")); +}); + +test("a manifest that points at a missing build says to build again", async () => { + const root = tempDir(); + project(root, validManifest()); + const loaded = await loadBuildManifest(root); + if (!loaded) throw new Error("expected a manifest"); + expect(() => resolveScriptEntry(loaded)).toThrow(/which is not there/); + expect(() => resolveAssetsDir(loaded)).toThrow(/not a directory/); +}); diff --git a/packages/cli/src/commands/lab/astro/manifest.ts b/packages/cli/src/commands/lab/astro/manifest.ts new file mode 100644 index 00000000..0dc72896 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/manifest.ts @@ -0,0 +1,167 @@ +import { existsSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { + BUILD_MANIFEST_PATH, + BUILD_MANIFEST_VERSION, + type BuildManifest, + BuildManifestSchema, +} from "@bunny.net/config"; +import { UserError } from "../../../core/errors.ts"; +import { VERSION } from "../../../core/version.ts"; + +export interface LoadedBuildManifest { + manifest: BuildManifest; + /** The directory holding `.bunny/build.json`; every path in the manifest resolves against it. */ + root: string; +} + +/** Walk up from `from` looking for `.bunny/build.json`. */ +function findManifest(from: string): string | null { + let dir = resolve(from); + while (true) { + const candidate = join(dir, BUILD_MANIFEST_PATH); + if (existsSync(candidate)) return candidate; + const parent = dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// Compare dotted numbers; a suffix like `-beta.1` is ignored, which is the right call for a floor check. +function isAtLeast(version: string, minimum: string): boolean { + const parts = (v: string) => + (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0); + const [a, b] = [parts(version), parts(minimum)]; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0); + if (diff !== 0) return diff > 0; + } + return true; +} + +/** + * The `>=x.y.z` floor from a `requires.cliVersion` range, or null. + * + * Only that one form is honoured. A range this CLI cannot parse must not stop a + * deploy: an adapter's opinion about a future CLI is not worth a hard failure. + */ +export function minimumCliVersion(range: string | undefined): string | null { + const match = /^\s*>=\s*(\d+\.\d+\.\d+[\w.-]*)\s*$/.exec(range ?? ""); + return match?.[1] ?? null; +} + +/** + * Read the build manifest, or null when the project has none. + * + * A manifest that exists but does not parse is an error: it means an adapter + * wrote something this CLI cannot act on, and deploying half a site is worse + * than stopping. + */ +export async function loadBuildManifest( + from: string = process.cwd(), +): Promise { + const path = findManifest(from); + if (!path) return null; + + let data: unknown; + try { + data = await Bun.file(path).json(); + } catch { + throw new UserError( + `${path} is not valid JSON.`, + "Run the project's build again to rewrite it.", + ); + } + + const parsed = BuildManifestSchema.safeParse(data); + if (!parsed.success) { + throw new UserError( + `${path} is not a build manifest this CLI understands.`, + parsed.error.issues + .map((i) => `${i.path.join(".") || "manifest"}: ${i.message}`) + .join("; "), + ); + } + const manifest = parsed.data; + + if (manifest.manifestVersion > BUILD_MANIFEST_VERSION) { + throw new UserError( + `${manifest.adapter.package} wrote a build manifest of version ${manifest.manifestVersion}, and this CLI reads ${BUILD_MANIFEST_VERSION}.`, + "Update the CLI: npm install -g @bunny.net/cli", + ); + } + + const floor = minimumCliVersion(manifest.requires?.cliVersion); + if (floor && !isAtLeast(VERSION, floor)) { + throw new UserError( + `${manifest.adapter.package} needs bunny CLI ${floor} or newer, and this is ${VERSION}.`, + "Update the CLI: npm install -g @bunny.net/cli", + ); + } + + if (manifest.kind === "ssr" && !manifest.script) { + throw new UserError( + `${manifest.adapter.package} reports a server build with no script to deploy.`, + "Run the project's build again. Report it to the adapter if it persists.", + ); + } + + return { manifest, root: dirname(dirname(path)) }; +} + +/** The built file to deploy, checked for existence. */ +export function resolveScriptEntry(loaded: LoadedBuildManifest): string { + const entry = loaded.manifest.script?.entry; + if (!entry) throw new UserError("The build manifest names no script entry."); + const path = resolve(loaded.root, entry); + if (!existsSync(path) || !statSync(path).isFile()) { + throw new UserError( + `The build manifest points at ${entry}, which is not there.`, + "Run the build again.", + ); + } + return path; +} + +/** The folder of client files to upload, checked for existence. */ +export function resolveAssetsDir(loaded: LoadedBuildManifest): string { + const path = resolve(loaded.root, loaded.manifest.assets.dir); + if (!existsSync(path) || !statSync(path).isDirectory()) { + throw new UserError( + `The build manifest points at ${loaded.manifest.assets.dir}, which is not a directory.`, + "Run the build again.", + ); + } + return path; +} + +/** + * Refuse anything but a build that renders per request. + * + * A static Astro build is a directory of files, and it has a home already. This + * command deploys one Edge Script, so a build with no script in it is not + * something it can carry halfway. + */ +export function requireSsrBuild(loaded: LoadedBuildManifest): void { + if (loaded.manifest.kind === "ssr") return; + throw new UserError( + "This Astro build prerenders every page, so it needs no server.", + [ + `Deploy the ${loaded.manifest.assets.dir} directory as files:`, + "", + ` bunny sites deploy ${loaded.manifest.assets.dir}`, + "", + "A page renders per request when it exports `prerender = false`.", + ].join("\n"), + ); +} + +/** Refuse a manifest another framework's adapter wrote. */ +export function requireAstroBuild(loaded: LoadedBuildManifest): void { + const name = loaded.manifest.framework.name.toLowerCase(); + if (name === "astro") return; + throw new UserError( + `.bunny/build.json says this build is ${loaded.manifest.framework.name}, not Astro.`, + "This command deploys Astro. Delete .bunny/build.json and build again if that is wrong.", + ); +} diff --git a/packages/cli/src/commands/lab/astro/naming.test.ts b/packages/cli/src/commands/lab/astro/naming.test.ts new file mode 100644 index 00000000..092478f2 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/naming.test.ts @@ -0,0 +1,59 @@ +import { expect, test } from "bun:test"; +import { + appNameFrom, + deployPrefix, + isValidAppName, + requireValidAppName, + resourcePattern, + scriptName, + suffixedName, +} from "./naming.ts"; + +test("a name from a scoped package drops the scope", () => { + expect(appNameFrom("@example/ssr")).toBe("ssr"); + expect(appNameFrom("@acme/My_Blog")).toBe("my-blog"); +}); + +test("a name a DNS label cannot hold is refused", () => { + expect(appNameFrom("!!")).toBeNull(); + expect(isValidAppName("-blog")).toBe(false); + expect(isValidAppName("blog-")).toBe(false); + expect(isValidAppName("ab")).toBe(false); + expect(isValidAppName("my-blog")).toBe(true); +}); + +test("an unusable name stops the command with the rules", () => { + expect(() => requireValidAppName("no")).toThrow(/not a usable app name/); +}); + +// `astro-ssr-demo` became `astro-astro-ssr-demo-a1b2c3`, which reads like a +// mistake and spends six characters of a 63-character DNS label on nothing. +test("the prefix is not added twice", () => { + const name = suffixedName("astro-ssr-demo"); + expect(name).toMatch(/^astro-ssr-demo-[a-z0-9]{6}$/); + expect(resourcePattern("astro-ssr-demo").test(name)).toBe(true); +}); + +test("a name without the prefix gets one", () => { + const name = suffixedName("blog"); + expect(name).toMatch(/^astro-blog-[a-z0-9]{6}$/); + expect(resourcePattern("blog").test(name)).toBe(true); +}); + +// The pattern is how a deploy finds the zone it made last time. A name that only +// starts the same must not match, or one app adopts another's resources. +test("the pattern matches this app's zones and no others", () => { + const pattern = resourcePattern("blog"); + expect(pattern.test("astro-blog-a1b2c3")).toBe(true); + expect(pattern.test("astro-blog-sessions")).toBe(false); + expect(pattern.test("astro-blogging-a1b2c3")).toBe(false); + expect(pattern.test("sites-blog-a1b2c3")).toBe(false); +}); + +test("the script takes the zone's own name", () => { + expect(scriptName("astro-blog-a1b2c3")).toBe("astro-blog-a1b2c3-server"); +}); + +test("a deploy's files live under its own id", () => { + expect(deployPrefix("a1b2c3d4")).toBe("deploys/a1b2c3d4"); +}); diff --git a/packages/cli/src/commands/lab/astro/naming.ts b/packages/cli/src/commands/lab/astro/naming.ts new file mode 100644 index 00000000..c8ab329a --- /dev/null +++ b/packages/cli/src/commands/lab/astro/naming.ts @@ -0,0 +1,96 @@ +/** + * What the resources are called. + * + * A storage zone name and a pull zone name are globally unique, across every + * account. So a name a developer chose cannot be used as it stands: `blog` is + * taken, and the API answers with a 409 that explains nothing. A random suffix + * makes the name available, and the prefix makes it obvious which command owns + * the resource. + */ +import { UserError } from "../../../core/errors.ts"; + +/** Every resource this command creates carries it. */ +export const RESOURCE_PREFIX = "astro-"; + +const SUFFIX_LENGTH = 6; + +/** Name rules, and the message that explains them. */ +export const APP_NAME_RULES = + "Use 3-40 lowercase letters, digits, and dashes (no leading or trailing dash)."; + +// The suffix and the prefix both spend characters of the 63-char DNS label a +// `*.b-cdn.net` hostname allows, so the name itself gets what is left. +const APP_NAME_RE = /^[a-z0-9](?:[a-z0-9-]{1,38}[a-z0-9])$/; + +export function isValidAppName(name: string): boolean { + return APP_NAME_RE.test(name); +} + +export function requireValidAppName(name: string): string { + if (!isValidAppName(name)) { + throw new UserError(`"${name}" is not a usable app name.`, APP_NAME_RULES); + } + return name; +} + +/** + * A name derived from whatever the project calls itself. + * + * A scope goes, because `@acme/blog` is not a hostname. So does every character + * a DNS label cannot hold. + */ +export function appNameFrom(raw: string): string | null { + const name = raw + .toLowerCase() + .replace(/^@[^/]+\//, "") + .replace(/[^a-z0-9-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 40) + .replace(/-$/, ""); + return isValidAppName(name) ? name : null; +} + +function randomSuffix(): string { + return Math.random() + .toString(36) + .slice(2, 2 + SUFFIX_LENGTH) + .padEnd(SUFFIX_LENGTH, "0"); +} + +/** + * The prefix, unless the name carries it already. + * + * An app called `astro-ssr-demo` became `astro-astro-ssr-demo-a1b2c3`, which + * reads like a mistake and spends six characters of a DNS label on nothing. + */ +function prefixed(appName: string): string { + return appName.startsWith(RESOURCE_PREFIX) + ? appName + : `${RESOURCE_PREFIX}${appName}`; +} + +/** A fresh globally-unique name for the storage zone and the pull zone. */ +export function suffixedName(appName: string): string { + return `${prefixed(appName)}-${randomSuffix()}`; +} + +/** Matches every name {@link suffixedName} can produce for this app. */ +export function resourcePattern(appName: string): RegExp { + return new RegExp(`^${prefixed(appName)}-[a-z0-9]{${SUFFIX_LENGTH}}$`, "i"); +} + +/** + * The Edge Script's name. + * + * A script name is unique per account, not globally, so it takes the zone's + * name and stays findable on a re-run. + */ +export function scriptName(resourceName: string): string { + return `${resourceName}-server`; +} + +/** Where a deploy's client files go in the zone. */ +export function deployPrefix(deployId: string): string { + return `deploys/${deployId}`; +} diff --git a/packages/cli/src/commands/lab/astro/project.test.ts b/packages/cli/src/commands/lab/astro/project.test.ts new file mode 100644 index 00000000..1befce7d --- /dev/null +++ b/packages/cli/src/commands/lab/astro/project.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { findAstroProjects, isAstroProject } from "./project.ts"; + +/** Build a tree from paths; a path ending in `/` is a directory. */ +function tree(paths: string[]): string { + const root = mkdtempSync(join(tmpdir(), "bunny-project-")); + for (const path of paths) { + const full = join(root, path); + if (path.endsWith("/")) { + mkdirSync(full, { recursive: true }); + continue; + } + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, ""); + } + return root; +} + +test("a directory with an Astro config is a project", () => { + expect(isAstroProject(tree(["astro.config.mjs"]))).toBe(true); + expect(isAstroProject(tree(["astro.config.ts"]))).toBe(true); +}); + +// Astro needs no config file. A project with pages is still a project. +test("a directory with pages and no config is a project", () => { + expect(isAstroProject(tree(["src/pages/index.astro"]))).toBe(true); +}); + +test("a workspace root is not a project", () => { + expect( + isAstroProject(tree(["package.json", "pnpm-workspace.yaml", "docs/"])), + ).toBe(false); +}); + +// The shape of withastro/starlight: the site is docs/, and the examples are not. +test("finds the projects below a monorepo root, likeliest first", () => { + const root = tree([ + "package.json", + "pnpm-workspace.yaml", + "docs/astro.config.mjs", + "examples/basics/astro.config.mjs", + "examples/tailwind/astro.config.mjs", + "packages/starlight/package.json", + ]); + + const found = findAstroProjects(root).map((candidate) => candidate.label); + expect(found).toEqual(["docs", "examples/basics", "examples/tailwind"]); +}); + +test("looks inside apps/, and stops at the project it finds", () => { + const root = tree([ + "package.json", + "apps/web/astro.config.mjs", + "apps/web/tests/fixtures/nested/astro.config.mjs", + "apps/api/package.json", + ]); + + expect(findAstroProjects(root).map((c) => c.label)).toEqual(["apps/web"]); +}); + +test("does not walk into node_modules", () => { + const root = tree([ + "package.json", + "node_modules/astro-thing/astro.config.mjs", + ]); + expect(findAstroProjects(root)).toEqual([]); +}); + +test("finds nothing when there is nothing", () => { + expect(findAstroProjects(tree(["package.json", "src/index.ts"]))).toEqual([]); +}); diff --git a/packages/cli/src/commands/lab/astro/project.ts b/packages/cli/src/commands/lab/astro/project.ts new file mode 100644 index 00000000..cda88dd5 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/project.ts @@ -0,0 +1,252 @@ +/** + * Which directory the deploy is about, and whether Astro there can be deployed. + * + * A monorepo root is not a project. `withastro/starlight` keeps `astro` in the + * root `package.json` for `astro check`, and its site is `docs/`. Reading only + * the root, the CLI detected Astro, offered to add an adapter to a package that + * builds nothing, and `pnpm add` refused to touch a workspace root at all. + * + * So a project has to look like one: it needs a config file, or pages. When this + * directory has neither, the workspace usually holds one that does. + */ +import { existsSync, readdirSync } from "node:fs"; +import { join, relative, resolve } from "node:path"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { readPackageJson } from "../../../core/package-manager.ts"; +import { isInteractive, prompts } from "../../../core/ui.ts"; +import { findAstroConfig } from "./adapter.ts"; + +/** Directories that hold no deployable site, however deep the search goes. */ +const SKIP = new Set([ + "node_modules", + ".git", + ".astro", + ".bunny", + ".cache", + ".github", + ".vscode", + "dist", + "build", + "out", + "public", + "src", + "test", + "tests", + "__tests__", + "e2e", + "fixtures", + "coverage", +]); + +/** How far down to look. Deeper than this is a fixture, not the site. */ +const MAX_DEPTH = 3; + +/** Names that usually hold the site a repository is about. */ +const LIKELY = [ + "docs", + "site", + "sites", + "www", + "web", + "app", + "apps", + "frontend", + "website", +]; + +/** Names that usually hold something else that happens to be a site. */ +const UNLIKELY = [ + "example", + "examples", + "demo", + "demos", + "playground", + "template", + "templates", +]; + +export interface Candidate { + dir: string; + /** The path to show, relative to where the search started. */ + label: string; +} + +/** True when this directory is itself an Astro project. */ +export function isAstroProject(dir: string): boolean { + return Boolean(findAstroConfig(dir)) || existsSync(join(dir, "src/pages")); +} + +/** Rank: a likely name first, an example last, and a shallower path before a deeper one. */ +function score(label: string): number { + const parts = label.split("/"); + const first = parts[0] ?? ""; + let value = parts.length * 10; + if (LIKELY.includes(first)) value -= 100; + if (parts.some((part) => UNLIKELY.includes(part))) value += 100; + return value; +} + +/** + * Every Astro project under `root`, nearest first. + * + * `root` itself is not a candidate: this is only called when it is not one. + */ +export function findAstroProjects(root: string): Candidate[] { + const found: Candidate[] = []; + + const walk = (dir: string, depth: number): void => { + if (depth > MAX_DEPTH) return; + let entries: string[]; + try { + entries = readdirSync(dir, { withFileTypes: true }) + .filter( + (entry) => + entry.isDirectory() && + !SKIP.has(entry.name) && + entry.name[0] !== ".", + ) + .map((entry) => entry.name); + } catch { + return; + } + for (const name of entries) { + const child = join(dir, name); + if (isAstroProject(child)) { + found.push({ + dir: child, + label: relative(root, child).split("\\").join("/"), + }); + // A project inside a project is that project's own fixture. + continue; + } + walk(child, depth + 1); + } + }; + + walk(root, 1); + return found.sort( + (a, b) => score(a.label) - score(b.label) || a.label.localeCompare(b.label), + ); +} + +/** + * The directory this deploy is about. + * + * Everything after this reads it: the config, the build, and the state file that + * links the directory to what it deploys to. + */ +export async function resolveProject( + dir: string | undefined, + output: string | undefined, +): Promise { + const root = resolve(dir ?? process.cwd()); + if (!existsSync(root)) { + throw new UserError(`${root} is not there.`); + } + if (isAstroProject(root)) return root; + + const candidates = findAstroProjects(root); + if (candidates.length === 0) { + throw new UserError( + `There is no Astro project in ${dir ?? "this directory"}.`, + "An Astro project has an astro.config file, or a src/pages directory.", + ); + } + + const list = candidates.map((candidate) => ` ${candidate.label}`).join("\n"); + if (!isInteractive(output)) { + throw new UserError( + `There is no Astro project in this directory, and ${candidates.length} below it.`, + `Name the one to deploy:\n${list}\n\n bunny lab deploy astro `, + ); + } + + logger.info( + candidates.length === 1 + ? "This directory holds no Astro project, and one below it does." + : `This directory holds no Astro project, and ${candidates.length} below it do.`, + ); + + const { value } = await prompts({ + type: "select", + name: "value", + message: "Which one should be deployed?", + choices: [ + ...candidates.map((candidate) => ({ + title: candidate.label, + value: candidate.dir, + })), + { title: "None of these", value: "" }, + ], + initial: 0, + }); + + const chosen = value as string | undefined; + if (!chosen) { + throw new UserError( + "Nothing to deploy here.", + "Run the command in the project's own directory.", + ); + } + logger.info(`Deploying ${relative(root, chosen) || "."}.`); + return chosen; +} + +/** The lowest Astro this adapter runs on. Its peer range is `^7.0.0`. */ +export const MINIMUM_ASTRO_MAJOR = 7; + +/** The declared Astro range, from the project's `package.json`. */ +export async function astroRange(root: string): Promise { + const pkg = await readPackageJson(root); + const deps = { + ...(pkg?.dependencies as Record | undefined), + ...(pkg?.devDependencies as Record | undefined), + }; + return deps.astro ?? null; +} + +/** + * The first major a range allows, or null when this cannot tell. + * + * Only the forms a `package.json` actually holds are read: `^7.2.6`, `~7.2`, + * `>=7`, `7.2.6`, and a bare `7`. A range this cannot parse must not stop a + * deploy, because the build is the real test. + */ +export function majorFrom(range: string): number | null { + const match = /(\d+)/.exec(range.replace(/^[\^~><= v]+/, "")); + const major = match ? Number.parseInt(match[1] as string, 10) : Number.NaN; + return Number.isNaN(major) ? null : major; +} + +/** + * Stop a project whose Astro is too old for the adapter. + * + * This is the one change no deploy command can make for somebody. A framework + * major moves APIs, and upgrading one is the developer's decision, taken with + * their own tests in front of them. `render-examples/astro-ssr` ships Astro 5, + * and `npm install` refuses the adapter outright, so the message has to arrive + * before the install rather than out of npm's own error. + */ +export async function requireSupportedAstro(root: string): Promise { + const range = await astroRange(root); + if (range === null) { + throw new UserError( + "This project does not depend on Astro.", + "Run the command in an Astro project, or add Astro to this one.", + ); + } + const major = majorFrom(range); + if (major === null || major >= MINIMUM_ASTRO_MAJOR) return; + + throw new UserError( + `This project uses Astro ${major}, and @bunny.net/astro-adapter needs Astro ${MINIMUM_ASTRO_MAJOR}.`, + [ + "Upgrade Astro first, and run its own migration:", + "", + " npx @astrojs/upgrade", + "", + "A framework major changes APIs, so this command will not do it for you.", + ].join("\n"), + ); +} diff --git a/packages/cli/src/commands/lab/astro/project.version.test.ts b/packages/cli/src/commands/lab/astro/project.version.test.ts new file mode 100644 index 00000000..7779a504 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/project.version.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import { majorFrom, requireSupportedAstro } from "./project.ts"; + +const tempDir = useTempDir("bunny-lab-astro-"); + +function project(astro: string | null): string { + const dir = tempDir(); + mkdirSync(join(dir, "src", "pages"), { recursive: true }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify({ + name: "app", + dependencies: astro === null ? {} : { astro }, + }), + ); + return dir; +} + +test("the major is read from the forms a package.json holds", () => { + expect(majorFrom("^7.2.6")).toBe(7); + expect(majorFrom("~7.2")).toBe(7); + expect(majorFrom(">=7")).toBe(7); + expect(majorFrom("7.2.6")).toBe(7); + expect(majorFrom("5.16.10")).toBe(5); + expect(majorFrom("11.0.0")).toBe(11); +}); + +// A range this cannot parse must not stop a deploy: the build is the real test, +// and `workspace:*` is a range every monorepo holds. +test("a range this cannot read does not stop the deploy", () => { + expect(majorFrom("workspace:*")).toBeNull(); + expect(majorFrom("*")).toBeNull(); +}); + +// `render-examples/astro-ssr` ships Astro 5, and `npm install` then refuses the +// adapter with an ERESOLVE about peer ranges. That error tells a developer +// nothing to act on, so the check happens before the install. +test("Astro 5 stops with the upgrade command", async () => { + await expect(requireSupportedAstro(project("^5.16.10"))).rejects.toThrow( + /uses Astro 5.*needs Astro 7/s, + ); +}); + +test("Astro 7 and newer pass", async () => { + await expect( + requireSupportedAstro(project("^7.2.6")), + ).resolves.toBeUndefined(); + await expect( + requireSupportedAstro(project("^8.0.0")), + ).resolves.toBeUndefined(); +}); + +test("a project without Astro says so", async () => { + await expect(requireSupportedAstro(project(null))).rejects.toThrow( + /does not depend on Astro/, + ); +}); diff --git a/packages/cli/src/commands/lab/astro/publish.ts b/packages/cli/src/commands/lab/astro/publish.ts new file mode 100644 index 00000000..e9cac93e --- /dev/null +++ b/packages/cli/src/commands/lab/astro/publish.ts @@ -0,0 +1,180 @@ +/** + * Getting one build in front of visitors. + * + * Two steps at the API, and then the cache in front of them. The cache is the + * part that has caught people out: without the purges below, the command reports + * success while the site still serves the release before it. + */ +import type { ManifestPullZone } from "@bunny.net/config"; +import { errorMessage } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import type { CoreClient } from "../../storage/api.ts"; +import type { ComputeClient } from "./resources.ts"; + +/** + * The line put at the top of the bundle, so the code carries the name of the + * folder its files are in. + * + * `var` and not `globalThis.x =` alone: a bundle is an ES module, and this has + * to be visible to code that reads `globalThis`. Assigning to `globalThis` does + * both, in every runtime the script may start in. + */ +export function deployPreamble(info: { + id: string; + assetPrefix: string; + site: string; + environment: string; +}): string { + return `globalThis.__BUNNY_DEPLOY__ = ${JSON.stringify(info)};\n`; +} + +/** + * How long to let a new release reach the edge nodes before the second purge. + * + * A probe cannot tell the outgoing release from the incoming one: both answer + * 200 with a page. So this waits rather than polls, and the second purge is what + * clears anything the first one re-cached from the old release. + */ +const SETTLE_MS = 5000; + +/** Overridden by the test, which has no five seconds to spare. */ +export const settle = { + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +export interface PublishOptions { + computeClient: ComputeClient; + coreClient: CoreClient; + scriptId: number; + pullZoneId: number; + /** The bundle as the build wrote it. The preamble is added here, never on disk. */ + code: string; + deploy: { id: string; assetPrefix: string; site: string }; +} + +/** Publish one deploy's code, and clear the cache in front of it. */ +export async function publishDeploy( + opts: PublishOptions, +): Promise<{ release?: string }> { + const { computeClient, coreClient, scriptId } = opts; + + await computeClient.POST("/compute/script/{id}/code", { + params: { path: { id: scriptId } }, + body: { + Code: + deployPreamble({ ...opts.deploy, environment: "production" }) + + opts.code, + }, + }); + await computeClient.POST("/compute/script/{id}/publish", { + params: { path: { id: scriptId, uuid: null } }, + body: {}, + }); + + const purge = () => + coreClient + .POST("/pullzone/{id}/purgeCache", { + params: { path: { id: opts.pullZoneId } }, + body: {}, + }) + .catch((err) => { + logger.warn( + `Couldn't purge the cache; the site may serve the previous release for a while: ${errorMessage(err)}`, + ); + }); + + await purge(); + await settle.wait(SETTLE_MS); + await purge(); + + return { release: await activeRelease(computeClient, scriptId) }; +} + +/** The live release's ID. Best effort: it is a label, not a lever. */ +async function activeRelease( + client: ComputeClient, + scriptId: number, +): Promise { + try { + const { data } = await client.GET("/compute/script/{id}/releases/active", { + params: { path: { id: scriptId } }, + }); + return (data as { Uuid?: string } | null)?.Uuid ?? undefined; + } catch { + return undefined; + } +} + +/** + * The pull zone settings this deploy needs. + * + * Two come from the manifest, because only the adapter knows whether the script + * sets cookies or caching headers. The third is ours: with the zone's + * `CacheControlMaxAgeOverride` at its default the edge rewrites every + * `Cache-Control` it forwards, so nothing the adapter returns reaches a visitor. + * A page would sit a month stale in a browser that a purge cannot reach. + * + * Only what differs is written, and every change is reported. A developer who + * changed one of these by hand should be able to see the CLI change it back. + */ +export async function applyPullZoneSettings( + client: CoreClient, + pullZoneId: number, + want: ManifestPullZone | undefined, +): Promise { + const changed: string[] = []; + + const { data: zone } = await client.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + if (!zone) return changed; + + const body: Record = {}; + const wanted: Array<{ + field: string; + value: boolean | number | undefined; + label: string; + }> = [ + { + field: "DisableCookies", + value: want?.disableCookies, + label: want?.disableCookies === false ? "cookies on" : "cookies off", + }, + { + field: "EnableSmartCache", + value: want?.enableSmartCache, + label: + want?.enableSmartCache === false ? "Smart Cache off" : "Smart Cache on", + }, + { + field: "EnableCacheSlice", + value: want?.enableCacheSlice, + label: + want?.enableCacheSlice === false + ? "large object delivery off" + : "large object delivery on", + }, + // The adapter owns Cache-Control, so the zone must stop overriding it. + { + field: "CacheControlMaxAgeOverride", + value: -1, + label: "cache override off", + }, + ]; + + for (const { field, value, label } of wanted) { + if (value === undefined) continue; + if ((zone as Record)[field] === value) continue; + body[field] = value; + changed.push(label); + } + + if (changed.length > 0) { + await client.POST("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + body, + }); + } + return changed; +} diff --git a/packages/cli/src/commands/lab/astro/resources.test.ts b/packages/cli/src/commands/lab/astro/resources.test.ts new file mode 100644 index 00000000..133a743e --- /dev/null +++ b/packages/cli/src/commands/lab/astro/resources.test.ts @@ -0,0 +1,263 @@ +import { expect, test } from "bun:test"; +import type { BuildManifest } from "@bunny.net/config"; +import type { CoreClient, StorageZoneModel } from "../../storage/api.ts"; +import { applyScriptEnv, resolveScriptEnv } from "./env.ts"; +import { applyPullZoneSettings, deployPreamble } from "./publish.ts"; +import type { ComputeClient } from "./resources.ts"; +import { storageHostFor } from "./storage.ts"; + +interface Call { + method: string; + path: string; + body?: unknown; +} + +test("storageHostFor maps a region to its endpoint", () => { + expect(storageHostFor("DE")).toBe("storage.bunnycdn.com"); + expect(storageHostFor("de")).toBe("storage.bunnycdn.com"); + expect(storageHostFor("NY")).toBe("ny.storage.bunnycdn.com"); + expect(storageHostFor("syd")).toBe("syd.storage.bunnycdn.com"); + // A zone with no region reported is the default one. + expect(storageHostFor(null)).toBe("storage.bunnycdn.com"); +}); + +// The preamble is what keeps a release and its files together. +test("deployPreamble writes the deploy onto globalThis", () => { + const line = deployPreamble({ + id: "a1b2c3d4", + assetPrefix: "deploys/a1b2c3d4", + site: "my-site", + environment: "production", + }); + expect(line).toBe( + 'globalThis.__BUNNY_DEPLOY__ = {"id":"a1b2c3d4","assetPrefix":"deploys/a1b2c3d4","site":"my-site","environment":"production"};\n', + ); + // It is one line, so a source map's line numbers shift by exactly one. + expect(line.split("\n")).toHaveLength(2); +}); + +function fakePullZoneClient( + calls: Call[], + zone: Record, +): CoreClient { + return { + GET: async (path: string) => { + calls.push({ method: "GET", path }); + return { data: zone }; + }, + POST: async (path: string, init?: { body?: unknown }) => { + calls.push({ method: "POST", path, body: init?.body }); + return { data: {} }; + }, + } as unknown as CoreClient; +} + +test("only the settings that differ are written, and each is reported", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { + DisableCookies: true, + EnableSmartCache: true, + CacheControlMaxAgeOverride: 2592000, + }); + + const changed = await applyPullZoneSettings(client, 30, { + disableCookies: false, + enableSmartCache: false, + }); + + expect(changed).toEqual([ + "cookies on", + "Smart Cache off", + "cache override off", + ]); + expect(calls.filter((c) => c.method === "POST")).toHaveLength(1); + expect(calls.at(-1)?.body).toEqual({ + DisableCookies: false, + EnableSmartCache: false, + CacheControlMaxAgeOverride: -1, + }); +}); + +test("a pull zone already configured is left alone", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { + DisableCookies: false, + EnableSmartCache: false, + CacheControlMaxAgeOverride: -1, + }); + + const changed = await applyPullZoneSettings(client, 30, { + disableCookies: false, + enableSmartCache: false, + }); + + expect(changed).toEqual([]); + expect(calls.some((c) => c.method === "POST")).toBe(false); +}); + +// The zone's own override would replace every Cache-Control the adapter sets, so +// turning it off is this command's business rather than the manifest's. +test("the cache override goes off even when the build asks for nothing", async () => { + const calls: Call[] = []; + const client = fakePullZoneClient(calls, { + DisableCookies: true, + CacheControlMaxAgeOverride: 2592000, + }); + + expect(await applyPullZoneSettings(client, 30, undefined)).toEqual([ + "cache override off", + ]); + expect(calls.at(-1)?.body).toEqual({ CacheControlMaxAgeOverride: -1 }); +}); + +function fakeScriptClient( + calls: Call[], + existing: { + variables?: { Name: string; DefaultValue: string }[]; + secrets?: { Name: string }[]; + }, +): ComputeClient { + return { + GET: async (path: string) => { + calls.push({ method: "GET", path }); + if (path === "/compute/script/{id}/secrets") { + return { data: { Secrets: existing.secrets ?? [] } }; + } + return { data: { EdgeScriptVariables: existing.variables ?? [] } }; + }, + PUT: async (path: string, init?: { body?: unknown }) => { + calls.push({ method: "PUT", path, body: init?.body }); + return { data: {} }; + }, + } as unknown as ComputeClient; +} + +test("a variable already holding the right value is not written again", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, { + variables: [{ Name: "BUNNY_STORAGE_ZONE", DefaultValue: "my-site" }], + }); + + const set = await applyScriptEnv(client, 20, [ + { name: "BUNNY_STORAGE_ZONE", value: "my-site" }, + { name: "BUNNY_PULLZONE_ID", value: "30" }, + ]); + + expect(set).toEqual(["BUNNY_PULLZONE_ID"]); + const writes = calls.filter((c) => c.method === "PUT"); + expect(writes).toHaveLength(1); + expect(writes[0]?.body).toEqual({ + Name: "BUNNY_PULLZONE_ID", + DefaultValue: "30", + }); +}); + +// A secret cannot be read back, so a rotated password must survive a deploy. +test("an existing secret is left in place", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, { + secrets: [{ Name: "BUNNY_STORAGE_KEY" }], + }); + + const set = await applyScriptEnv(client, 20, [ + { name: "BUNNY_STORAGE_KEY", value: "a-new-password", secret: true }, + ]); + + expect(set).toEqual([]); + expect(calls.some((c) => c.method === "PUT")).toBe(false); +}); + +test("a secret that is not there yet is written once", async () => { + const calls: Call[] = []; + const client = fakeScriptClient(calls, {}); + + const set = await applyScriptEnv(client, 20, [ + { name: "bunny_storage_key", value: "password", secret: true }, + ]); + + expect(set).toEqual(["BUNNY_STORAGE_KEY"]); + expect(calls.at(-1)).toEqual({ + method: "PUT", + path: "/compute/script/{id}/secrets", + body: { Name: "BUNNY_STORAGE_KEY", Secret: "password" }, + }); +}); + +const ZONE = { + Id: 10, + Name: "sites-my-site-k3f9wq", + Region: "NY", + Password: "write-password", + ReadOnlyPassword: "read-password", +} as StorageZoneModel; + +function manifest(requires?: BuildManifest["requires"]): BuildManifest { + return { + manifestVersion: 1, + adapter: { package: "@bunny.net/astro-adapter" }, + framework: { name: "astro" }, + kind: "ssr", + script: { entry: "dist/index.js", type: "standalone" }, + assets: { dir: "dist/client" }, + requires, + }; +} + +test("the script gets the read-only password for assets, and no typing", () => { + const { entries } = resolveScriptEnv( + manifest({ + env: [ + { name: "BUNNY_STORAGE_ZONE" }, + { name: "BUNNY_STORAGE_HOST" }, + { name: "BUNNY_STORAGE_KEY", secret: true }, + ], + }), + ZONE, + 30, + ); + + expect(entries).toEqual([ + { name: "BUNNY_STORAGE_ZONE", value: "sites-my-site-k3f9wq" }, + { name: "BUNNY_STORAGE_HOST", value: "ny.storage.bunnycdn.com" }, + { name: "BUNNY_STORAGE_KEY", value: "read-password", secret: true }, + ]); +}); + +// Sessions have to write, and only sessions do. +test("sessions get the password that can write, and only when asked for", () => { + const withSessions = resolveScriptEnv( + manifest({ + storage: { write: true }, + env: [{ name: "BUNNY_SESSION_ZONE" }, { name: "BUNNY_SESSION_KEY" }], + }), + ZONE, + 30, + ); + expect(withSessions.entries).toEqual([ + { name: "BUNNY_SESSION_ZONE", value: "sites-my-site-k3f9wq" }, + { name: "BUNNY_SESSION_KEY", value: "write-password", secret: true }, + ]); + + const without = resolveScriptEnv( + manifest({ env: [{ name: "BUNNY_SESSION_ZONE" }] }), + ZONE, + 30, + ); + expect(without.entries).toEqual([]); + expect(without.unset).toEqual(["BUNNY_SESSION_ZONE"]); +}); + +test("a variable the CLI cannot supply is reported, not invented", () => { + const { entries, unset } = resolveScriptEnv( + manifest({ + env: [ + { name: "BUNNY_PULLZONE_ID" }, + { name: "BUNNY_API_KEY", secret: true, optional: true }, + ], + }), + ZONE, + 30, + ); + expect(entries).toEqual([{ name: "BUNNY_PULLZONE_ID", value: "30" }]); + expect(unset).toEqual(["BUNNY_API_KEY"]); +}); diff --git a/packages/cli/src/commands/lab/astro/resources.ts b/packages/cli/src/commands/lab/astro/resources.ts new file mode 100644 index 00000000..bb6c21ee --- /dev/null +++ b/packages/cli/src/commands/lab/astro/resources.ts @@ -0,0 +1,329 @@ +/** + * The three resources one Astro app needs, and how to find them again. + * + * A storage zone holds the client build. A standalone Edge Script holds Astro's + * server. A pull zone puts the script on a hostname, and the script is its + * origin, so nothing sits between a request and the code. + * + * Every step looks its resource up by name first, so a half-finished create + * re-runs cleanly rather than leaving a second set behind. + */ +import type { createComputeClient } from "@bunny.net/openapi-client"; +import { ApiError, errorMessage, UserError } from "../../../core/errors.ts"; +import { + createPullZone, + setForceSsl, + systemHostname, +} from "../../../core/hostnames/index.ts"; +import { logger } from "../../../core/logger.ts"; +import { fetchScripts } from "../../scripts/api.ts"; +import { SCRIPT_TYPE_STANDALONE } from "../../scripts/constants.ts"; +import { + type CoreClient, + fetchStorageZone, + type StorageZoneModel, +} from "../../storage/api.ts"; +import { resourcePattern, scriptName, suffixedName } from "./naming.ts"; +import { type LabState, STATE_VERSION } from "./state.ts"; + +export type ComputeClient = ReturnType; + +/** The default storage region. Frankfurt, which needs no endpoint prefix. */ +export const DEFAULT_REGION = "DE"; + +// The globally-unique name is taken (often by another account, so a pre-create +// lookup missed it): a 409, or a 400 that says so. +function isNameTaken(err: unknown): boolean { + if (!(err instanceof ApiError)) return false; + if (err.status === 409) return true; + return ( + err.status === 400 && + /already (exists|taken|in use)|not available|is taken/i.test(err.message) + ); +} + +export interface FoundResources { + storageZone: StorageZoneModel; + scriptId: number; + pullZoneId: number; + hostname?: string; +} + +/** The `astro-{name}-{suffix}` storage zone, re-fetched by ID so it carries the passwords. */ +export async function findStorageZone( + client: CoreClient, + appName: string, +): Promise { + const { data } = await client.GET("/storagezone", { + params: { query: { search: appName } }, + }); + const pattern = resourcePattern(appName); + const match = (data ?? []).find( + (zone) => pattern.test(zone.Name ?? "") && zone.Id != null, + ); + return match?.Id == null + ? undefined + : fetchStorageZone(client, match.Id as number); +} + +/** The pull zone whose origin is this script. */ +async function findPullZone( + client: CoreClient, + appName: string, + scriptId: number, +) { + const { data } = await client.GET("/pullzone", { + params: { query: { search: appName, perPage: 1000 } }, + }); + // The endpoint answers with a plain array for some queries and an envelope for + // others, so read both shapes. + const raw = data as unknown; + const items = Array.isArray(raw) + ? raw + : ((raw as { Items?: unknown[] } | undefined)?.Items ?? []); + return ( + items as { + Id?: number; + EdgeScriptId?: number; + Hostnames?: Parameters[0]; + }[] + ).find((pz) => pz.EdgeScriptId === scriptId); +} + +/** + * Find what this app already has, by name. + * + * This is what makes `--name` enough: a fresh clone, or a CI runner with no + * `.bunny/astro.json`, finds the same three resources the last deploy made. + * Returns null when the app has no storage zone, which means it has nothing. + */ +export async function findResources(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + appName: string; +}): Promise { + const storageZone = await findStorageZone(opts.coreClient, opts.appName); + if (!storageZone?.Id) return null; + + const resourceName = storageZone.Name ?? opts.appName; + const script = (await fetchScripts(opts.computeClient)).find( + (s) => s.Name === scriptName(resourceName), + ); + if (script?.Id == null) return null; + + const linked = script.LinkedPullZones?.[0]?.Id; + const pullZone = + linked == null + ? await findPullZone(opts.coreClient, opts.appName, script.Id) + : { Id: linked, Hostnames: undefined }; + if (pullZone?.Id == null) return null; + + return { + storageZone, + scriptId: script.Id, + pullZoneId: pullZone.Id, + hostname: await resolveHostname( + opts.coreClient, + pullZone.Id, + pullZone.Hostnames, + ), + }; +} + +/** The zone's `*.b-cdn.net` host, fetching the zone when the caller has no list. */ +async function resolveHostname( + client: CoreClient, + pullZoneId: number, + hostnames: Parameters[0] | undefined, +): Promise { + if (hostnames) return systemHostname(hostnames) ?? undefined; + const { data } = await client.GET("/pullzone/{id}", { + params: { path: { id: pullZoneId } }, + }); + return systemHostname(data?.Hostnames ?? []) ?? undefined; +} + +export interface CreateOptions { + coreClient: CoreClient; + computeClient: ComputeClient; + appName: string; + region: string; + onStep?: (message: string) => void; +} + +/** Create what is missing, and return the state that describes all of it. */ +export async function ensureResources( + opts: CreateOptions, +): Promise<{ state: LabState; storageZone: StorageZoneModel }> { + const { coreClient, computeClient, appName } = opts; + const step = opts.onStep ?? (() => {}); + const region = (opts.region || DEFAULT_REGION).toUpperCase(); + + // 1. The storage zone. Its name carries the suffix every other resource takes. + step("Finding the storage zone..."); + let storageZone = await findStorageZone(coreClient, appName); + if (!storageZone) { + step("Creating the storage zone..."); + // Retry with fresh suffixes on the off chance a name is still taken. + for (let attempt = 0; !storageZone && attempt < 3; attempt++) { + const zoneName = suffixedName(appName); + try { + const { data } = await coreClient.POST("/storagezone", { + body: { Name: zoneName, Region: region, ReplicationRegions: null }, + }); + if (!data?.Id) { + throw new UserError(`Failed to create storage zone "${zoneName}".`); + } + // Re-fetch for the full record, which carries the zone passwords. + storageZone = await fetchStorageZone(coreClient, data.Id); + } catch (err) { + if (!isNameTaken(err)) throw err; + } + } + } + if (!storageZone?.Id) { + throw new UserError( + `Couldn't find an available storage zone name for "${appName}".`, + "Re-run the command, or choose another name with --name.", + ); + } + const resourceName = storageZone.Name ?? appName; + + // 2. The script, with the pull zone it is the origin of. A standalone script is + // its own origin, so the compute API can create both: that is one call rather + // than a public zone with no origin for a moment. + step("Finding the Edge Script..."); + const wantedScript = scriptName(resourceName); + let script = (await fetchScripts(computeClient)).find( + (s) => s.Name === wantedScript, + ); + if (script?.Id == null) { + step("Creating the Edge Script..."); + const { data } = await computeClient.POST("/compute/script", { + body: { + Name: wantedScript, + ScriptType: SCRIPT_TYPE_STANDALONE, + CreateLinkedPullZone: true, + LinkedPullZoneName: resourceName, + }, + }); + if (data?.Id == null) { + throw new UserError(`Failed to create Edge Script "${wantedScript}".`); + } + script = data; + } + const scriptId = script.Id as number; + + // 3. The pull zone. Normally the script created it; adopt an existing one on a + // resumed create, and create one when the compute API made none. + step("Finding the pull zone..."); + let pullZoneId = script.LinkedPullZones?.[0]?.Id ?? undefined; + let hostnames: Parameters[0] | undefined; + if (pullZoneId == null) { + const found = await findPullZone(coreClient, appName, scriptId); + if (found?.Id != null) { + pullZoneId = found.Id; + hostnames = found.Hostnames ?? undefined; + } + } + if (pullZoneId == null) { + step("Creating the pull zone..."); + const zone = await createPullZone(coreClient, resourceName, 0, { + edgeScriptId: scriptId, + }); + if (zone.Id == null) { + throw new UserError(`Failed to create pull zone "${resourceName}".`); + } + pullZoneId = zone.Id; + hostnames = zone.Hostnames ?? undefined; + } + + const hostname = await resolveHostname(coreClient, pullZoneId, hostnames); + + // Force HTTPS on the `*.b-cdn.net` host. It is already on bunny's wildcard + // certificate, so this only redirects HTTP. Best effort: a site that serves + // over HTTP is still a site. + if (hostname) { + try { + await setForceSsl(coreClient, pullZoneId, hostname, true); + } catch (err) { + logger.warn(`Couldn't force HTTPS on ${hostname}: ${errorMessage(err)}`); + } + } + + return { + storageZone, + state: { + version: STATE_VERSION, + name: appName, + storageZone: resourceName, + storageZoneId: storageZone.Id, + region: (storageZone.Region ?? region).toLowerCase(), + scriptId, + pullZoneId, + ...(hostname ? { hostname } : {}), + }, + }; +} + +export interface TeardownResult { + resource: "pull zone" | "edge script" | "storage zone"; + id: number; + deleted: boolean; + error?: string; +} + +/** + * Delete the three resources, in the order that leaves nothing serving. + * + * The pull zone goes first: it is the only public thing, and taking it down stops + * requests before the code behind it disappears. A 404 from the API counts as + * deleted, because the resource is gone either way, which is what lets a failed + * run be repeated. + */ +export async function deleteResources(opts: { + coreClient: CoreClient; + computeClient: ComputeClient; + state: LabState; + keepStorage: boolean; +}): Promise { + const results: TeardownResult[] = []; + + const attempt = async ( + resource: TeardownResult["resource"], + id: number, + call: () => Promise, + ) => { + try { + await call(); + results.push({ resource, id, deleted: true }); + } catch (err) { + const gone = err instanceof ApiError && err.status === 404; + results.push({ + resource, + id, + deleted: gone, + ...(gone ? {} : { error: errorMessage(err) }), + }); + } + }; + + await attempt("pull zone", opts.state.pullZoneId, () => + opts.coreClient.DELETE("/pullzone/{id}", { + params: { path: { id: opts.state.pullZoneId } }, + }), + ); + await attempt("edge script", opts.state.scriptId, () => + opts.computeClient.DELETE("/compute/script/{id}", { + params: { path: { id: opts.state.scriptId } }, + }), + ); + if (!opts.keepStorage) { + await attempt("storage zone", opts.state.storageZoneId, () => + opts.coreClient.DELETE("/storagezone/{id}", { + params: { path: { id: opts.state.storageZoneId } }, + }), + ); + } + return results; +} diff --git a/packages/cli/src/commands/lab/astro/state.test.ts b/packages/cli/src/commands/lab/astro/state.test.ts new file mode 100644 index 00000000..580e1a6b --- /dev/null +++ b/packages/cli/src/commands/lab/astro/state.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { useTempDir } from "../../../test-utils/temp-dir.ts"; +import { + clearState, + type LabState, + loadState, + markCurrent, + STATE_VERSION, + saveState, + statePath, +} from "./state.ts"; + +const tempDir = useTempDir("bunny-lab-state-"); + +function state(overrides?: Partial): LabState { + return { + version: STATE_VERSION, + name: "my-app", + storageZone: "astro-my-app-a1b2c3", + storageZoneId: 1, + region: "de", + scriptId: 2, + pullZoneId: 3, + ...overrides, + }; +} + +test("state is written and read back from the project", () => { + const root = tempDir(); + saveState(root, state({ current: "aaaa1111" })); + expect(loadState(root)?.current).toBe("aaaa1111"); + expect(statePath(root)).toBe(join(root, ".bunny", "astro.json")); +}); + +// Walking up from the working directory was the bug: `bunny lab deploy astro +// ./project` run from anywhere else found no state, called the app new, and +// created a second set of resources beside the first. +test("state belongs to the project, not to a directory above it", () => { + const above = tempDir(); + const project = join(above, "project"); + mkdirSync(project, { recursive: true }); + saveState(project, state()); + + // The state is the project's. A command run in the parent finds none, which is + // what makes the parent a different app rather than the same one. + expect(loadState(project)?.name).toBe("my-app"); + expect(loadState(above)).toBeNull(); +}); + +test("a project with no state reads as null", () => { + expect(loadState(tempDir())).toBeNull(); +}); + +// The file is a pointer. A deploy that re-finds its resources by name recovers +// from a damaged one, so it must not be an error. +test("state that does not parse reads as absent", () => { + const root = tempDir(); + mkdirSync(join(root, ".bunny"), { recursive: true }); + writeFileSync(join(root, ".bunny", "astro.json"), "{ not json"); + expect(loadState(root)).toBeNull(); + + writeFileSync(join(root, ".bunny", "astro.json"), '{"version":1}'); + expect(loadState(root)).toBeNull(); +}); + +test("clearing the state removes the file", () => { + const root = tempDir(); + saveState(root, state()); + clearState(root); + expect(loadState(root)).toBeNull(); + // Clearing what is not there is not an error. + clearState(root); +}); + +test("the outgoing deploy becomes the previous one", () => { + const s = state({ current: "aaaa1111" }); + markCurrent(s, "bbbb2222"); + expect(s.current).toBe("bbbb2222"); + expect(s.previous).toBe("aaaa1111"); + + // Re-publishing the same deploy does not make it its own predecessor. + markCurrent(s, "bbbb2222"); + expect(s.previous).toBe("aaaa1111"); +}); diff --git a/packages/cli/src/commands/lab/astro/state.ts b/packages/cli/src/commands/lab/astro/state.ts new file mode 100644 index 00000000..95af14b6 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/state.ts @@ -0,0 +1,100 @@ +/** + * The link between a project directory and what it deploys to. + * + * `bunny sites` keeps its state in the storage zone, because a site outlives + * every clone of the repository. This command keeps it locally instead, at + * `.bunny/astro.json`, for one reason: an undeploy has to name what it is about + * to delete, before it asks. + * + * Losing the file is not fatal. `--name` finds the same resources by name, which + * is what a fresh clone and a CI runner both do. + */ +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { z } from "zod"; + +/** The file, under the project's `.bunny/`. That directory is git-ignored. */ +export const STATE_FILE = "astro.json"; + +export const STATE_VERSION = 1; + +export const LabStateSchema = z.object({ + version: z.number().int().positive(), + /** The app name the developer chose. Every resource name derives from it. */ + name: z.string(), + /** The storage zone's own name, which carries the random suffix. */ + storageZone: z.string(), + storageZoneId: z.number().int().positive(), + /** The zone's region code, lowercase, for the endpoint the script reads. */ + region: z.string(), + scriptId: z.number().int().positive(), + pullZoneId: z.number().int().positive(), + /** The `*.b-cdn.net` host, for the line the deploy prints. */ + hostname: z.string().optional(), + /** The deploy now published. Its files are the ones the script reads. */ + current: z.string().optional(), + /** The deploy before it. Kept so its files survive one more deploy. */ + previous: z.string().optional(), + /** The current deploy's content hash, so an unchanged one can be skipped. */ + contentHash: z.string().optional(), +}); + +export type LabState = z.infer; + +/** + * Where the state lives, for one project. + * + * The path is always built from the project root, never from the working + * directory. Walking up from the current directory is what `bunny scripts` does, + * and here it was wrong: `bunny lab deploy astro ./some/project` run from + * anywhere else found no state, decided the app was new, and created a second set + * of resources beside the first. + */ +export function statePath(root: string): string { + return join(root, ".bunny", STATE_FILE); +} + +/** + * The state for this project, or null when there is none to read. + * + * A file this cannot parse is treated as absent, not as an error. It is a + * pointer, and a deploy that re-finds its resources by name recovers from a bad + * one without asking anybody to delete a file by hand. + */ +export function loadState(root: string): LabState | null { + const path = statePath(root); + if (!existsSync(path)) return null; + let data: unknown; + try { + data = JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } + const parsed = LabStateSchema.safeParse(data); + return parsed.success ? parsed.data : null; +} + +export function saveState(root: string, state: LabState): void { + const path = statePath(root); + mkdirSync(join(root, ".bunny"), { recursive: true }); + writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 }); +} + +export function clearState(root: string): void { + const path = statePath(root); + if (existsSync(path)) rmSync(path); +} + +/** Point the state at `deployId`, remembering the outgoing deploy. */ +export function markCurrent(state: LabState, deployId: string): void { + if (state.current && state.current !== deployId) { + state.previous = state.current; + } + state.current = deployId; +} diff --git a/packages/cli/src/commands/lab/astro/storage.ts b/packages/cli/src/commands/lab/astro/storage.ts new file mode 100644 index 00000000..a69bb4b4 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/storage.ts @@ -0,0 +1,92 @@ +/** + * The zone's files, as this command uses them. + * + * A thin layer over the storage SDK, so nothing above it knows how a connection + * is made. `bunny sites` has the same layer for the same reason; sharing one + * would tie the two commands together again. + */ +import { UserError } from "../../../core/errors.ts"; +import type { StorageZoneModel } from "../../storage/api.ts"; +import { + connectStorageZone, + deleteFile, + downloadFile, + listFiles, + type StorageZone, + uploadFile, +} from "../../storage/files-api.ts"; + +export type { StorageZone } from "../../storage/files-api.ts"; + +/** A connection that can read and write the zone. */ +export function connect(zone: StorageZoneModel): StorageZone { + return connectStorageZone(zone); +} + +export const zoneFiles = { + upload: uploadFile, + download: downloadFile, + remove: deleteFile, + list: listFiles, +}; + +/** + * Frankfurt has no prefix; every other region is `.storage.bunnycdn.com`. + * + * The script reads this at run time, and a wrong endpoint means every stored + * path answers 404 while the zone itself is perfectly healthy. + */ +export function storageHostFor(region: string | null | undefined): string { + const code = (region ?? "de").toLowerCase(); + return code === "de" || code === "" + ? "storage.bunnycdn.com" + : `${code}.storage.bunnycdn.com`; +} + +/** The deploy folders the zone holds, newest name order not implied. */ +export async function listDeployIds( + connection: StorageZone, +): Promise { + const entries = await zoneFiles.list(connection, "deploys/").catch(() => []); + return entries + .filter((entry) => entry.isDirectory) + .map((entry) => entry.objectName ?? "") + .filter((name) => name !== ""); +} + +/** + * Delete every deploy folder but the ones named. + * + * A deploy this command does not keep is dead weight: nothing can publish it, + * because there is no rollback. The one before the current release stays, so a + * deploy that has not reached every edge node yet still has its files. + */ +export async function pruneDeploys( + connection: StorageZone, + keep: string[], +): Promise { + const kept = new Set(keep.filter(Boolean)); + const removed: string[] = []; + for (const id of await listDeployIds(connection)) { + if (kept.has(id)) continue; + try { + await zoneFiles.remove(connection, `deploys/${id}/`); + removed.push(id); + } catch { + // Storage the next deploy prunes again, not a failed deploy. + } + } + return removed; +} + +/** The zone's read-only password, which is what the script gets. */ +export function readOnlyPassword(zone: StorageZoneModel): string { + const password = zone.ReadOnlyPassword ?? zone.Password; + if (!password) { + throw new UserError( + `Storage zone ${zone.Name} reports no password.`, + "Re-run the command; if it persists, check the zone in the dashboard.", + ); + } + return password; +} diff --git a/packages/cli/src/commands/lab/astro/undeploy.ts b/packages/cli/src/commands/lab/astro/undeploy.ts new file mode 100644 index 00000000..1e6c3657 --- /dev/null +++ b/packages/cli/src/commands/lab/astro/undeploy.ts @@ -0,0 +1,227 @@ +/** + * `bunny lab undeploy astro` + * + * Take the app down, and delete the three resources it was made of. This is the + * other half of the deploy: an experiment nobody can remove is not an experiment + * anybody will start. + * + * The state file names what will go, so the prompt can list it. Without one, + * `--name` finds the same resources the deploy created. + */ +import { resolve } from "node:path"; +import { + createComputeClient, + createCoreClient, +} from "@bunny.net/openapi-client"; +import { resolveConfig } from "../../../config/index.ts"; +import { clientOptions } from "../../../core/client-options.ts"; +import { defineCommand } from "../../../core/define-command.ts"; +import { UserError } from "../../../core/errors.ts"; +import { logger } from "../../../core/logger.ts"; +import { + confirm, + confirmTyped, + requireConfirmable, + withSpinner, +} from "../../../core/ui.ts"; +import { requireValidAppName } from "./naming.ts"; +import { resolveProject } from "./project.ts"; +import { deleteResources, findResources } from "./resources.ts"; +import { + clearState, + type LabState, + loadState, + STATE_VERSION, +} from "./state.ts"; + +interface UndeployArgs { + dir?: string; + name?: string; + force: boolean; + "keep-storage": boolean; +} + +export const labUndeployAstroCommand = defineCommand({ + command: "astro [dir]", + describe: "Delete an Astro app and the resources it runs on.", + examples: [ + ["$0 lab undeploy astro", "Take down the app this directory deploys to"], + ["$0 lab undeploy astro --name my-app", "Name it, with no local state"], + [ + "$0 lab undeploy astro --keep-storage", + "Delete the script and pull zone, keep the files", + ], + ], + + builder: (yargs) => + yargs + .positional("dir", { + type: "string", + describe: "The project directory (default: the current one)", + }) + .option("name", { + type: "string", + describe: "The app's name, when this directory has no state file", + }) + .option("force", { + alias: "f", + type: "boolean", + default: false, + describe: "Skip the confirmation prompts", + }) + .option("keep-storage", { + type: "boolean", + default: false, + describe: "Keep the storage zone and every file in it", + }), + + handler: async (args) => { + const { profile, output, verbose, apiKey, force } = args; + const json = output === "json"; + + const config = resolveConfig(profile, apiKey, verbose); + const options = clientOptions(config, verbose); + const coreClient = createCoreClient(options); + const computeClient = createComputeClient(options); + + const state = await resolveTarget({ + coreClient, + computeClient, + dir: args.dir, + name: args.name, + output, + }); + + const what = args["keep-storage"] + ? "its pull zone and Edge Script" + : "its pull zone, Edge Script, and ALL uploaded files"; + + if (!json) { + logger.log(); + logger.info(`"${state.name}" is made of:`); + logger.dim(` pull zone ${state.pullZoneId}`); + logger.dim(` edge script ${state.scriptId}`); + logger.dim( + ` storage zone ${state.storageZone}${args["keep-storage"] ? " (kept)" : ""}`, + ); + logger.log(); + } + + requireConfirmable(output, { + force, + message: `Deleting "${state.name}" needs a confirmation prompt.`, + hint: "Re-run with --force to delete non-interactively.", + }); + const confirmed = + (await confirm( + `Delete "${state.name}" (${what})? This cannot be undone.`, + { + force, + }, + )) && (await confirmTyped(state.name, { force })); + if (!confirmed) { + logger.log("Cancelled."); + return; + } + + const results = await withSpinner("Deleting...", () => + deleteResources({ + coreClient, + computeClient, + state, + keepStorage: args["keep-storage"], + }), + ); + + const failures = results.filter((r) => !r.deleted); + // The link goes only when everything it points at is gone. Keeping it is what + // lets a failed run be repeated. `--name` names no directory, so there is no + // link to clear. + if (failures.length === 0 && state.root) clearState(state.root); + + if (json) { + logger.log( + JSON.stringify( + { app: state.name, deleted: failures.length === 0, results }, + null, + 2, + ), + ); + if (failures.length > 0) process.exit(1); + return; + } + + for (const result of results) { + if (result.deleted) { + logger.success(`Deleted ${result.resource} ${result.id}.`); + } else { + logger.warn( + `Couldn't delete ${result.resource} ${result.id}: ${result.error}`, + ); + } + } + if (args["keep-storage"]) { + logger.info( + `Storage zone ${state.storageZone} was kept, with every file in it.`, + ); + } + if (failures.length > 0) { + logger.dim(" Re-run the command to retry the failed deletions."); + process.exit(1); + } + }, +}); + +/** + * What to delete: the state file, else the resources `--name` finds. + * + * A name and no state is the CI case, and the fresh-clone case. Looking the + * resources up rather than trusting a remembered ID is also what makes the + * command safe to re-run after a partial failure. + */ +async function resolveTarget(opts: { + coreClient: ReturnType; + computeClient: ReturnType; + dir: string | undefined; + name: string | undefined; + output: string | undefined; +}): Promise { + if (!opts.name) { + // Reading the project is only for the state file beside it, so a directory + // that is not an Astro project is not an error worth stopping for. + const root = + (await resolveProject(opts.dir, opts.output).catch(() => null)) ?? + resolve(opts.dir ?? process.cwd()); + const state = loadState(root); + if (state) return { ...state, root }; + throw new UserError( + "This directory has no .bunny/astro.json, so there is nothing to take down.", + "Name the app instead: bunny lab undeploy astro --name my-app", + ); + } + + const appName = requireValidAppName(opts.name); + const found = await withSpinner(`Finding "${appName}"...`, () => + findResources({ + coreClient: opts.coreClient, + computeClient: opts.computeClient, + appName, + }), + ); + if (!found?.storageZone.Id) { + throw new UserError( + `Found no app called "${appName}".`, + "Check the name with `bunny storage zones list`; this command's zones start with astro-.", + ); + } + return { + version: STATE_VERSION, + name: appName, + storageZone: found.storageZone.Name ?? appName, + storageZoneId: found.storageZone.Id, + region: (found.storageZone.Region ?? "de").toLowerCase(), + scriptId: found.scriptId, + pullZoneId: found.pullZoneId, + ...(found.hostname ? { hostname: found.hostname } : {}), + }; +} diff --git a/packages/cli/src/commands/lab/astro/upload.ts b/packages/cli/src/commands/lab/astro/upload.ts new file mode 100644 index 00000000..95a8bbee --- /dev/null +++ b/packages/cli/src/commands/lab/astro/upload.ts @@ -0,0 +1,135 @@ +/** + * The client build, on its way into the zone. + * + * Each deploy gets its own folder, named after what is in it. So a release can + * only read the files it was built against, and a deploy that changes nothing + * uploads nothing. + */ +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; +import { mapWithConcurrency } from "../../../core/concurrency.ts"; +import { UserError } from "../../../core/errors.ts"; +import { deployPrefix } from "./naming.ts"; +import { type StorageZone, zoneFiles } from "./storage.ts"; + +export interface LocalFile { + /** Posix-style path relative to the client build directory. */ + path: string; + absPath: string; + size: number; +} + +export interface HashedFile extends LocalFile { + sha256: string; +} + +const CONCURRENCY = 8; +const ATTEMPTS = 3; + +// Dot-directories that carry web-visible content the site must serve. +const ALLOWED_DOT_ENTRIES = new Set([".well-known"]); + +/** Dotfiles and node_modules are tooling, not content. `.well-known` is content. */ +export function shouldSkipEntry(name: string): boolean { + if (ALLOWED_DOT_ENTRIES.has(name)) return false; + return name.startsWith(".") || name === "node_modules"; +} + +/** Every file in the client build, sorted by path so the hash is stable. */ +export function collectFiles(dir: string): LocalFile[] { + const files: LocalFile[] = []; + + const walk = (abs: string, rel: string) => { + for (const entry of readdirSync(abs, { withFileTypes: true })) { + if (shouldSkipEntry(entry.name)) continue; + const entryAbs = join(abs, entry.name); + const entryRel = rel ? `${rel}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(entryAbs, entryRel); + } else if (entry.isFile()) { + files.push({ + path: entryRel, + absPath: entryAbs, + size: statSync(entryAbs).size, + }); + } + // Sockets, FIFOs, and dangling symlinks are silently skipped. + } + }; + + walk(dir, ""); + return files.sort((a, b) => a.path.localeCompare(b.path)); +} + +async function hashFile(file: LocalFile): Promise { + const hasher = new Bun.CryptoHasher("sha256"); + for await (const chunk of Bun.file(file.absPath).stream()) { + hasher.update(chunk); + } + return { ...file, sha256: hasher.digest("hex") }; +} + +/** One streaming SHA-256 per file. It feeds the deploy ID and the upload checksum. */ +export function hashFiles(files: LocalFile[]): Promise { + return mapWithConcurrency(files, CONCURRENCY, hashFile); +} + +/** + * One name for a set of files and the code that renders them. + * + * The server bundle is hashed in with the files, because the bundle names the + * hashed asset it loads. Change either half and the deploy is a different one. + */ +export function contentHash(files: HashedFile[], bundleSha: string): string { + const hasher = new Bun.CryptoHasher("sha256"); + for (const file of files) hasher.update(`${file.path}:${file.sha256}\n`); + hasher.update(`server:${bundleSha}\n`); + return hasher.digest("hex").slice(0, 12); +} + +async function withRetries(fn: () => Promise): Promise { + let lastErr: unknown; + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + try { + return await fn(); + } catch (err) { + lastErr = err; + if (attempt < ATTEMPTS - 1) { + await new Promise((r) => setTimeout(r, 250 * 2 ** attempt)); + } + } + } + throw lastErr; +} + +/** + * Upload the client build to this deploy's folder. + * + * Every file carries its SHA-256, which the storage API verifies, so a truncated + * upload fails here rather than serving half a file for a month. + */ +export async function uploadClientBuild( + connection: StorageZone, + deployId: string, + files: HashedFile[], + onUploaded?: (done: number, total: number, file: HashedFile) => void, +): Promise { + if (files.length === 0) { + throw new UserError("Nothing to upload; the client build has no files."); + } + + const prefix = deployPrefix(deployId); + let done = 0; + await mapWithConcurrency(files, CONCURRENCY, async (file) => { + await withRetries(() => + zoneFiles.upload( + connection, + `${prefix}/${file.path}`, + Bun.file(file.absPath).stream(), + { sha256Checksum: file.sha256.toUpperCase() }, + ), + ); + done++; + onUploaded?.(done, files.length, file); + }); +} diff --git a/packages/cli/src/commands/lab/astro/verify.test.ts b/packages/cli/src/commands/lab/astro/verify.test.ts new file mode 100644 index 00000000..6fc4930b --- /dev/null +++ b/packages/cli/src/commands/lab/astro/verify.test.ts @@ -0,0 +1,37 @@ +import { expect, test } from "bun:test"; +import { isBunnyErrorPage } from "./verify.ts"; + +// The check exists because a pull zone with no error page of its own answers a +// miss with bunny.net's, whatever the build produced. Astro renders its own 404, +// so bunny.net's page here means the request never reached the script. +test("bunny.net's own error page is recognised", () => { + expect( + isBunnyErrorPage( + "

bunny.net

An error has occurred.

", + ), + ).toBe(true); + // The order of the two markers is not the signal, so either way round counts. + expect( + isBunnyErrorPage( + "

An error has occurred.

bunny.net
", + ), + ).toBe(true); +}); + +// A page the site built that happens to name its host is the site's page. +test("a site's own page that mentions its host is not an error page", () => { + expect(isBunnyErrorPage("

Hosted on bunny.net

Welcome.

")).toBe( + false, + ); +}); + +// A site about rabbits is not a broken site. +test("a page that merely mentions bunnies is not an error page", () => { + expect(isBunnyErrorPage("

Bunny facts

They hop.

")).toBe(false); +}); + +test("Astro's own 404 page is not mistaken for bunny.net's", () => { + const astro = `404: Not Found +

404: Not Found

This page does not exist.

`; + expect(isBunnyErrorPage(astro)).toBe(false); +}); diff --git a/packages/cli/src/commands/lab/astro/verify.ts b/packages/cli/src/commands/lab/astro/verify.ts new file mode 100644 index 00000000..c843dc4d --- /dev/null +++ b/packages/cli/src/commands/lab/astro/verify.ts @@ -0,0 +1,97 @@ +/** + * What a fresh deploy is asked before the command calls it a success. + * + * A green line printed above a URL that does not serve is the worst thing a + * deploy can do. The script has 500 ms to start and 10 MB to be parsed in, and + * when it misses that the edge answers 400 with an empty body. Nothing in the + * API reports it, so the only place a developer can hear it is here. + */ + +/** How many times to ask a fresh deploy before believing the answer. */ +const ATTEMPTS = 3; +const INTERVAL_MS = 3000; + +/** Overridden by the test, which has no nine seconds to spare. */ +export const probe = { + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Ask the site for its home page, and answer with a status that means it is down. + * + * Returns null when the site answered anything a working script can answer, a + * redirect and a 404 included, and when it could not be reached at all: DNS and + * TLS take their own time on a new hostname, and "unreachable" is not a verdict. + */ +export async function findServingFault( + url: string, + deployId: string, +): Promise { + let fault: number | null = null; + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + if (attempt > 0) await probe.wait(INTERVAL_MS); + try { + // A unique query per attempt keeps the probe out of the CDN cache, so a + // cached failure cannot outlive the release that caused it. + const response = await fetch( + `${url}/?__bunny_check=${deployId}-${attempt}`, + { redirect: "manual", signal: AbortSignal.timeout(10_000) }, + ); + if (response.status !== 400 && response.status < 500) return null; + fault = response.status; + } catch { + return fault; + } + } + return fault; +} + +/** + * Ask for a path the site cannot hold, and check Astro answered it. + * + * A pull zone with no error page of its own answers a miss with bunny.net's, + * whatever the build produced. Astro renders its own 404 route, so a bunny.net + * page here means the request never reached the script. Returns the status that + * answered when the body came from bunny.net, and null otherwise. + */ +export async function findMissingPageFault( + url: string, + deployId: string, +): Promise { + let fault: number | null = null; + for (let attempt = 0; attempt < ATTEMPTS; attempt++) { + if (attempt > 0) await probe.wait(INTERVAL_MS); + try { + const response = await fetch( + `${url}/_bunny_check/${deployId}/${attempt}`, + { redirect: "manual", signal: AbortSignal.timeout(10_000) }, + ); + const body = await response.text(); + if (!isBunnyErrorPage(body)) return null; + fault = response.status; + } catch { + return null; + } + } + return fault; +} + +/** + * True when this body is bunny.net's own error page rather than the site's. + * + * Two markers, and both are needed: the page names bunny.net, and it says + * something went wrong. Matching the whole page would break the moment the page + * is restyled. Matching "bunny" alone would call a site about rabbits broken, + * and matching "error" alone would do the same to Astro's own 404. + * + * This only decides whether the deploy prints a warning, so the cost of being + * wrong is one line of output either way. + */ +export function isBunnyErrorPage(body: string): boolean { + const head = body.slice(0, 4000); + return ( + /bunny\.net/i.test(head) && + /an error has occurred|error has occurred|request could not be/i.test(head) + ); +} diff --git a/packages/cli/src/commands/lab/index.ts b/packages/cli/src/commands/lab/index.ts new file mode 100644 index 00000000..76c636eb --- /dev/null +++ b/packages/cli/src/commands/lab/index.ts @@ -0,0 +1,30 @@ +/** + * `bunny lab` + * + * Commands we are still shaping. The name is the warning: the interface can + * change between releases, and a workflow built on one should expect to be + * updated. + * + * Astro is the first. It has two commands and no more: deploy the project, or + * take it down again. + */ +import { defineNamespace } from "../../core/define-namespace.ts"; +import { labDeployAstroCommand } from "./astro/deploy.ts"; +import { labUndeployAstroCommand } from "./astro/undeploy.ts"; + +const deployNamespace = defineNamespace( + "deploy ", + "Deploy a framework project that renders pages per request.", + [labDeployAstroCommand], +); + +const undeployNamespace = defineNamespace( + "undeploy ", + "Delete a framework project and the resources it runs on.", + [labUndeployAstroCommand], +); + +export const labNamespace = defineNamespace("lab", false, [ + deployNamespace, + undeployNamespace, +]); diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index cba05863..dfc7dd75 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -4,6 +4,7 @@ import type { CoreClient, StorageZoneModel } from "../storage/api.ts"; import { type ComputeClient, createSite, + deleteDeployFiles, deleteSiteResources, ensureRouterCurrent, fetchSites, @@ -456,7 +457,12 @@ test("createSite provisions storage zone → router → pull zone → state", as const attach = coreCalls.find( (c) => c.method === "POST" && c.path === "/pullzone/{id}", ); - expect(attach?.body).toEqual({ MiddlewareScriptId: 20 }); + // The router and the cache override go on together: the router decides what a + // response may be cached for, and the override would replace its answer. + expect(attach?.body).toEqual({ + MiddlewareScriptId: 20, + CacheControlMaxAgeOverride: -1, + }); // The system host redirects HTTP → HTTPS out of the box. const forceSsl = coreCalls.find( @@ -772,19 +778,53 @@ test("fetchSites ignores another pull zone pointed at the site's storage zone", test("ensureRouterCurrent republishes an outdated router and stamps the version", async () => { const calls: Call[] = []; const computeClient = fakeComputeClient({ calls }); - const state = fakeState(); + const coreCalls: Call[] = []; + const coreClient = fakeCoreClient({ calls: coreCalls }); + const state = fakeState({ + deploys: [ + { + id: "a1b2c3d4", + createdAt: "2026-01-01T00:00:00Z", + source: "git", + contentHash: "hash1", + files: 1, + bytes: 1, + }, + ], + }); - expect(await ensureRouterCurrent({ computeClient, state })).toBe(true); + expect(await ensureRouterCurrent({ coreClient, computeClient, state })).toBe( + true, + ); expect(state.routerVersion).toBe(ROUTER_VERSION); expect(calls.map((c) => c.path)).toEqual([ "/compute/script/{id}/code", "/compute/script/{id}/publish", ]); + // The router owns Cache-Control from v6 on, so the zone override goes off on + // the site's zone. Leaving it on would have the edge replace every answer the + // router gives, including a 404 that must not outlive the deploy which fixes + // it. + expect( + coreCalls.map((c) => ({ + path: c.path, + id: (c.params as { path: { id: number } }).path.id, + body: c.body, + })), + ).toEqual([ + { + path: "/pullzone/{id}", + id: 30, + body: { CacheControlMaxAgeOverride: -1 }, + }, + ]); + // Already current: no calls at all. const noCalls: Call[] = []; expect( await ensureRouterCurrent({ + coreClient: fakeCoreClient({ calls: [] }), computeClient: fakeComputeClient({ calls: noCalls }), state, }), @@ -886,3 +926,25 @@ test("fetchSites pages through the /pullzone envelope", async () => { expect(sites).toHaveLength(1); expect(sites[0]?.state.name).toBe("my-site"); }); + +test("fetchSites ignores a middleware pull zone with no storage zone", async () => { + store.set(REMOTE_STATE_PATH, JSON.stringify(fakeState())); + const coreClient = fakeCoreClient({ + calls: [], + storageZones: [ZONE], + pullZones: [ + { Id: 30, Name: "my-site", MiddlewareScriptId: 20, StorageZoneId: -1 }, + ], + }); + + expect(await fetchSites(coreClient)).toHaveLength(0); +}); + +test("deleting a deploy removes its files", async () => { + store.set("deploys/a1b2c3d4/index.html", "

live

"); + store.set("deploys/e5f6a7b8/index.html", "

other

"); + + await deleteDeployFiles(fakeConnection(), "a1b2c3d4"); + + expect([...store.keys()].sort()).toEqual(["deploys/e5f6a7b8/index.html"]); +}); diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 032db73c..3bf2935f 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -30,6 +30,7 @@ import { type RemoteSiteState, routerScriptName, STATE_VERSION, + STATIC_SITE_ZONE_SETTINGS, siteResourcePattern, suffixedResourceName, } from "./constants.ts"; @@ -412,7 +413,7 @@ export async function createSite( } await coreClient.POST("/pullzone/{id}", { params: { path: { id: pullZone.Id } }, - body: { MiddlewareScriptId: scriptId }, + body: { MiddlewareScriptId: scriptId, ...STATIC_SITE_ZONE_SETTINGS }, }); // Force HTTPS on the .b-cdn.net system host (already on bunny's wildcard cert, so this just redirects HTTP); best-effort. @@ -464,8 +465,36 @@ export async function fetchSystemHostname( } } +/** + * Apply {@link STATIC_SITE_ZONE_SETTINGS} to the site's pull zone. + * + * The router and these settings are one change: the router decides what a + * response may be cached for, and the zone's override would replace its answer. + * Best-effort, and idempotent, so a failure here is a warning rather than a + * failed deploy, and the next republish tries again. + */ +export async function applySiteZoneSettings(opts: { + coreClient: CoreClient; + state: RemoteSiteState; +}): Promise { + try { + await opts.coreClient.POST("/pullzone/{id}", { + params: { path: { id: opts.state.pullZoneId } }, + body: { ...STATIC_SITE_ZONE_SETTINGS }, + }); + } catch (err) { + logger.warn( + `Couldn't turn the cache override off on pull zone ${opts.state.pullZoneId}: ${errorMessage(err)}`, + ); + logger.dim( + " Until it is off, the zone replaces the Cache-Control the router sends.", + ); + } +} + // Republish the site's router when its recorded source generation lags the CLI's. Mutates state.routerVersion; the caller's next state write persists it, and a missed write just re-runs this next time. export async function ensureRouterCurrent(opts: { + coreClient: CoreClient; computeClient: ComputeClient; state: RemoteSiteState; }): Promise { @@ -480,6 +509,8 @@ export async function ensureRouterCurrent(opts: { params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); + // The new router owns Cache-Control, so the zone must stop overriding it. + await applySiteZoneSettings(opts); state.routerVersion = ROUTER_VERSION; return true; } diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 2ce3dc31..df9f24da 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -82,7 +82,7 @@ export async function runBuildCommand( if (code !== 0) { throw new UserError( `Build command failed with exit code ${code}.`, - "Fix the build and re-run `bunny sites deploy --build`.", + "Fix the build and run `bunny sites deploy --build` again.", ); } } diff --git a/packages/cli/src/commands/sites/ci/frameworks.test.ts b/packages/cli/src/commands/sites/ci/frameworks.test.ts index 2d9c5549..4b49516d 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.test.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { detectFramework, detectPackageManager, + detectWorkspace, findPreset, presetBuildCommand, } from "./frameworks.ts"; @@ -135,3 +136,69 @@ test("detectPackageManager reads the lockfile", async () => { ); expect(await detectPackageManager(tempRepo({}))).toBe("npm"); }); + +// The lockfile is at the root of a monorepo, not beside each package. Reading +// only the package made `starlight/docs` look like an npm project, and `npm +// install` then met `"@astrojs/starlight": "workspace:*"` and stopped. +test("detectWorkspace finds the package manager up the tree", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "packages:\n - 'docs'\n", + "package.json": JSON.stringify({ name: "root", private: true }), + }); + const docs = join(root, "docs"); + mkdirSync(docs); + writeFileSync(join(docs, "package.json"), pkg({ astro: "^7.0.0" })); + + const workspace = await detectWorkspace(docs); + expect(workspace.pm).toBe("pnpm"); + expect(workspace.root).toBe(root); + // The package is not the workspace root, so `pnpm add` needs no `-w`. + expect(workspace.isRoot).toBe(false); +}); + +test("detectWorkspace knows when the project is the workspace root", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "packages:\n - 'packages/**'\n", + "package.json": JSON.stringify({ name: "root", private: true }), + }); + expect((await detectWorkspace(root)).isRoot).toBe(true); +}); + +// astro.build has a pnpm-workspace.yaml holding only settings, and `pnpm add` +// works there without `-w`. +test("detectWorkspace does not call a settings-only pnpm-workspace.yaml a root", async () => { + const root = tempRepo({ + "pnpm-lock.yaml": "", + "pnpm-workspace.yaml": "minimumReleaseAge: 4320\n", + "package.json": pkg({ astro: "^7.0.0" }), + }); + expect((await detectWorkspace(root)).isRoot).toBe(false); +}); + +test("detectWorkspace reads npm and yarn workspaces too", async () => { + const npmRoot = tempRepo({ + "package-lock.json": "{}", + "package.json": JSON.stringify({ name: "root", workspaces: ["apps/*"] }), + }); + const npm = await detectWorkspace(npmRoot); + expect(npm.pm).toBe("npm"); + expect(npm.isRoot).toBe(true); + + const yarnRoot = tempRepo({ + "yarn.lock": "", + "package.json": JSON.stringify({ + name: "root", + workspaces: { packages: ["apps/*"] }, + }), + }); + const yarn = await detectWorkspace(yarnRoot); + expect(yarn.pm).toBe("yarn"); + expect(yarn.isRoot).toBe(true); +}); + +test("detectPackageManager still answers npm when nothing says otherwise", async () => { + const dir = tempRepo({ "package.json": pkg({ astro: "^7.0.0" }) }); + expect(await detectPackageManager(dir)).toBe("npm"); +}); diff --git a/packages/cli/src/commands/sites/ci/frameworks.ts b/packages/cli/src/commands/sites/ci/frameworks.ts index ea509e44..ae21f07e 100644 --- a/packages/cli/src/commands/sites/ci/frameworks.ts +++ b/packages/cli/src/commands/sites/ci/frameworks.ts @@ -1,7 +1,16 @@ import { access } from "node:fs/promises"; import { join } from "node:path"; +import { + detectWorkspace, + type PackageManager, + readPackageJson, +} from "../../../core/package-manager.ts"; -export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; +export type { + PackageManager, + Workspace, +} from "../../../core/package-manager.ts"; +export { detectWorkspace } from "../../../core/package-manager.ts"; export interface FrameworkPreset { id: string; @@ -190,18 +199,7 @@ const JS_DETECTORS: Array<[dependency: string, presetId: string]> = [ ["vite", "vite"], ]; -export async function readPackageJson( - root: string, -): Promise | null> { - try { - return (await Bun.file(join(root, "package.json")).json()) as Record< - string, - unknown - >; - } catch { - return null; - } -} +export { readPackageJson } from "../../../core/package-manager.ts"; async function readText(path: string): Promise { try { @@ -270,13 +268,5 @@ export async function detectFramework( export async function detectPackageManager( root: string, ): Promise { - if ( - (await exists(join(root, "bun.lock"))) || - (await exists(join(root, "bun.lockb"))) - ) { - return "bun"; - } - if (await exists(join(root, "pnpm-lock.yaml"))) return "pnpm"; - if (await exists(join(root, "yarn.lock"))) return "yarn"; - return "npm"; + return (await detectWorkspace(root)).pm; } diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 0728e13e..b76fb9bf 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -10,6 +10,21 @@ export const DEPLOYS_DIR = "deploys"; // Router env var selecting the production deploy; updating it is the promote/rollback lever (no republish). export const CURRENT_DEPLOY_VAR = "CURRENT_DEPLOY"; +/** + * Pull zone settings the router depends on. Applied to a site's zone at create + * and whenever the router is republished. + * + * `-1` turns the cache override off. With the zone default of 2592000 in place + * the edge rewrites every `Cache-Control` it forwards, so nothing the router + * returns reaches the visitor: an HTML page is a month stale in a browser that + * a purge cannot reach, and a 404 the next deploy fixes outlives it by weeks. + * With it off the edge follows the origin, and Bunny Storage sends no + * `Cache-Control` for HTML, so the router sets one on every response. + */ +export const STATIC_SITE_ZONE_SETTINGS = { + CacheControlMaxAgeOverride: -1, +} as const; + export const STATE_VERSION = 1; export const DEFAULT_KEEP_DEPLOYS = 5; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 2609516e..095c3e9b 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -10,9 +10,8 @@ import { defineCommand } from "../../core/define-command.ts"; import { collectEnv } from "../../core/env.ts"; import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; -import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; +import { confirm, isInteractive, withSpinner } from "../../core/ui.ts"; import { ensureRouterCurrent, fetchSystemHostname, @@ -32,7 +31,12 @@ import { type RemoteSiteState, } from "./constants.ts"; import { resolveDeployIdentity } from "./deploy-id.ts"; -import { setupSiteDomain } from "./domains/index.ts"; +import { DOMAIN_HINT, offerFirstDomain } from "./domains/index.ts"; +import { + findDeployFault, + findMissingPageFault, + readNotFoundPage, +} from "./health.ts"; import { type SiteSelectorArgs, selectSite, @@ -48,11 +52,12 @@ interface DeployArgs extends SiteSelectorArgs { env?: string[]; "env-file"?: string; force?: boolean; + /** Site name, for the first deploy from this directory. */ + name?: string; + /** Storage region for a site this deploy creates. */ + region?: string; } -const DOMAIN_HINT = - " Add a custom production domain: bunny sites domains add "; - // A site's live URL: the custom domain when it has one, else its b-cdn.net host. Always https (b-cdn.net hosts carry bunny's certificate). export function productionUrl( state: RemoteSiteState, @@ -73,7 +78,18 @@ export function resolveDeployDir( return resolve(root, configDir ?? autoDir ?? "."); } -// Deploy a directory: hash, skip if unchanged, upload to `deploys/{id}/`, record state, then publish it as the live site. Deploys are immutable under their own id, so `sites deployments publish` rolls back to any of them without re-uploading. `--build` runs the build first with `--env`/`--env-file` overrides. +/** + * Deploy a directory of files to a site. + * + * Hash it, skip an unchanged deploy, upload to `deploys/{id}/`, record the + * state, then publish it as the live site. Deploys are immutable under their + * own id, so `sites deployments publish` rolls back to any of them without + * re-uploading. `--build` runs the build first with `--env`/`--env-file` + * overrides. + * + * A build that renders pages per request is a different shape, and this command + * does not deploy one. `bunny lab deploy astro` does. + */ export const sitesDeployCommand = defineCommand({ command: "deploy [dir]", describe: "Deploy a directory to a site.", @@ -114,6 +130,14 @@ export const sitesDeployCommand = defineCommand({ type: "boolean", default: false, describe: "Deploy even when the content is unchanged", + }) + .option("name", { + type: "string", + describe: "Site name, for the first deploy from this directory", + }) + .option("region", { + type: "string", + describe: "Storage region for a new site (default: DE)", }), ), @@ -121,7 +145,6 @@ export const sitesDeployCommand = defineCommand({ const { profile, output, verbose, apiKey } = args; const siteConfig = loadSiteConfig(); const root = siteConfig?.root ?? process.cwd(); - const explicitDir = args.dir ?? siteConfig?.config.dir; if (args.build === undefined && (args.env?.length || args["env-file"])) { throw new UserError( @@ -130,15 +153,39 @@ export const sitesDeployCommand = defineCommand({ ); } - let requestedBuild: RequestedBuild | undefined; + const explicitDir = args.dir ?? siteConfig?.config.dir; + + // The build runs before any resource is created, so a failing build cannot + // leave an empty site behind. + let autoDir: string | undefined; if (args.build !== undefined) { - requestedBuild = await resolveRequestedBuild( + const requested: RequestedBuild = await resolveRequestedBuild( args.build, siteConfig?.config.build, root, ); + if (requested.label) logger.info(`Detected ${requested.label}.`); + // No dir given: target the detected framework's output dir, not the repo root the build ran in. + if (explicitDir === undefined) autoDir = requested.dir; + const overrides = await collectEnv(args.env, args["env-file"]); + await runBuildCommand(requested.command, root, overrides); + } else if (isInteractive(output)) { + // No --build: offer to run the configured build, else a detected one. + const configured = siteConfig?.config.build; + const auto = configured + ? { command: configured, label: "the configured build" } + : await resolveAutoBuild(root); + if (auto) { + // Target the framework's output dir unless one was given (whether or not the build runs). + if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; + const prompt = configured + ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` + : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; + if (await confirm(prompt, { initial: true })) { + await runBuildCommand(auto.command, root, {}); + } + } } - const config = resolveConfig(profile, apiKey, verbose); const options = clientOptions(config, verbose); const coreClient = createCoreClient(options); @@ -149,9 +196,15 @@ export const sitesDeployCommand = defineCommand({ site: args.site, link: args.link, output, + name: args.name, offerCreate: async () => { - const name = await promptSiteName(undefined, true); - return createLinkedSite({ coreClient, computeClient, name }); + const name = await promptSiteName(args.name, isInteractive(output)); + return createLinkedSite({ + coreClient, + computeClient, + name, + region: args.region, + }); }, }); const { state, connection } = site; @@ -164,7 +217,11 @@ export const sitesDeployCommand = defineCommand({ // Republish an outdated router before deploying, so this deploy is served by the current source (state.routerVersion persists with this deploy's writes, including no-op runs, so it doesn't republish every time). A failure isn't fatal: the old router still resolves CURRENT_DEPLOY. let routerUpgraded = false; try { - routerUpgraded = await ensureRouterCurrent({ computeClient, state }); + routerUpgraded = await ensureRouterCurrent({ + coreClient, + computeClient, + state, + }); if (routerUpgraded && output !== "json") { logger.info("Republished the site's router."); } @@ -173,32 +230,6 @@ export const sitesDeployCommand = defineCommand({ logger.dim(" Retry with `bunny sites upgrade-router`."); } - let autoDir: string | undefined; - if (requestedBuild) { - if (requestedBuild.label) - logger.info(`Detected ${requestedBuild.label}.`); - // No dir given: target the detected framework's output dir, not the repo root the build ran in. - if (explicitDir === undefined) autoDir = requestedBuild.dir; - const overrides = await collectEnv(args.env, args["env-file"]); - await runBuildCommand(requestedBuild.command, root, overrides); - } else if (isInteractive(output)) { - // No --build: offer to run the configured build, else a detected one. - const configured = siteConfig?.config.build; - const auto = configured - ? { command: configured, label: "the configured build" } - : await resolveAutoBuild(root); - if (auto) { - // Target the framework's output dir unless one was given (whether or not the build runs). - if (explicitDir === undefined && "dir" in auto) autoDir = auto.dir; - const prompt = configured - ? `Run ${auto.label} (\`${auto.command}\`) before deploying?` - : `Detected ${auto.label}. Run \`${auto.command}\` before deploying?`; - if (await confirm(prompt, { initial: true, optional: true })) { - await runBuildCommand(auto.command, root, {}); - } - } - } - const dir = resolveDeployDir( args.dir, siteConfig?.config.dir, @@ -271,10 +302,14 @@ export const sitesDeployCommand = defineCommand({ } if (!skipUpload) { + let sent = 0; await withSpinner(`Uploading ${files.length} files...`, (spin) => uploadDeploy(connection, deployId, files, { - onFileUploaded: (done, total) => { - spin.text = `Uploading ${done}/${total} files (${formatBytes(totalBytes)} total)...`; + onFileUploaded: (done, total, file) => { + // Bytes, not only files: a big deploy spends minutes here, and a file + // count says nothing about how much of it is left. + sent += file.size; + spin.text = `Uploading ${done}/${total} files (${formatBytes(sent)} of ${formatBytes(totalBytes)})...`; }, }), ); @@ -310,6 +345,28 @@ export const sitesDeployCommand = defineCommand({ }); }); + // A green line above a URL that does not serve is the worst thing a deploy + // can do, so the site is asked before it is called a success. Two faults: + // a router that will not start, and a site answering a miss with + // bunny.net's page rather than its own. Both have shipped for real. + const { servingFault, notFoundFault } = production + ? await withSpinner("Checking the site...", async () => { + const serving = await findDeployFault(production, deployId); + // A site that answers nothing cannot be asked about its 404 page. + if (serving !== null) { + return { servingFault: serving, notFoundFault: null }; + } + return { + servingFault: null, + notFoundFault: await findMissingPageFault({ + url: production, + deployId, + page: await readNotFoundPage(dir, files), + }), + }; + }) + : { servingFault: null, notFoundFault: null }; + if (output === "json") { logger.log( JSON.stringify( @@ -322,6 +379,11 @@ export const sitesDeployCommand = defineCommand({ unchanged: skipUpload, live: true, production: production ?? null, + serving: servingFault === null, + ...(servingFault === null ? {} : { status: servingFault }), + ...(notFoundFault === null + ? {} + : { notFoundStatus: notFoundFault }), }, null, 2, @@ -339,43 +401,31 @@ export const sitesDeployCommand = defineCommand({ } if (production) logger.info(`Production: ${production}`); - // Domainless sites: the first deploy offers a custom production domain, later ones just hint. - if (!state.domain) { - logger.log(); - let handled = false; - if (firstDeploy && isInteractive(output)) { - const { value } = await prompts({ - type: "text", - name: "value", - message: - "Custom domain for this site's production URL (leave blank to skip):", - }); - const domain = normalizeHostname(value ?? "") || undefined; - if (domain) { - handled = true; - // The domain flow writes state, so it needs the etag from this deploy's writes, not the stale read. - site.etag = etag; - try { - await setupSiteDomain({ - coreClient, - site, - domain, - interactive: true, - verbose, - }); - } catch (err) { - logger.warn( - `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, - ); - logger.dim( - ` Retry later: bunny sites domains add ${domain} ${state.name}`, - ); - } - } - } - if (!handled) logger.dim(DOMAIN_HINT); + if (servingFault !== null) { + logger.warn(`The site answered ${servingFault}, so it is not serving.`); + logger.dim( + " Its router may be older than this CLI: bunny sites upgrade-router", + ); + } else if (notFoundFault !== null) { + logger.warn( + `A path this site does not hold answered ${notFoundFault}, and not with your 404 page.`, + ); + logger.dim( + " Its router may be older than this CLI: bunny sites upgrade-router", + ); } + // The domain flow writes state, so it needs the etag from this deploy's + // writes, not the stale read. + site.etag = etag; + await offerFirstDomain({ + coreClient, + site, + firstDeploy, + interactive: isInteractive(output), + verbose, + }); + await offerLink(); }, }); diff --git a/packages/cli/src/commands/sites/domains/index.ts b/packages/cli/src/commands/sites/domains/index.ts index b9d574b6..3ec7a7b0 100644 --- a/packages/cli/src/commands/sites/domains/index.ts +++ b/packages/cli/src/commands/sites/domains/index.ts @@ -7,11 +7,14 @@ import { type CoreClient, createHostnamesCommands, fetchPullZoneHostnames, + looksLikeHostname, + normalizeHostname, type ResolvedPullZone, setupHostname, } from "../../../core/hostnames/index.ts"; import { logger } from "../../../core/logger.ts"; import type { GlobalArgs } from "../../../core/types.ts"; +import { prompts } from "../../../core/ui.ts"; import { type SiteContext, writeRemoteState } from "../api.ts"; import { selectSite } from "../interactive.ts"; @@ -125,3 +128,65 @@ export const sitesDomainsCommands = createHostnamesCommands({ } }, }); + +/** The dim line a domainless site's later deploys print. */ +export const DOMAIN_HINT = + " Add a custom production domain: bunny sites domains add "; + +/** + * Offer a custom domain after a deploy, and hint at one otherwise. + * + * The site's first-ever deploy is the one moment worth asking: the list is never + * empty again, so a later offer would only be noise. Both deploy paths end here, + * because a site that renders per request needs a domain for the same reason a + * static one does. + */ +export async function offerFirstDomain(opts: { + coreClient: CoreClient; + site: SiteContext; + /** True when this deploy is the site's first, which is what makes the offer. */ + firstDeploy: boolean; + interactive: boolean; + verbose: boolean; +}): Promise { + const { coreClient, site } = opts; + if (site.state.domain) return; + + logger.log(); + if (opts.firstDeploy && opts.interactive) { + const { value } = await prompts({ + type: "text", + name: "value", + message: + "Custom domain for this site's production URL (leave blank to skip):", + }); + const typed = normalizeHostname(value ?? ""); + // A one-word answer here used to reach the API, which calls it "An error has + // occurred." and leaves the developer with nothing to fix. + if (typed && !looksLikeHostname(typed)) { + logger.warn(`"${typed}" is not a domain name. Skipping it.`); + logger.dim(" Add one later: bunny sites domains add www.example.com"); + } + const domain = looksLikeHostname(typed) ? typed : undefined; + if (domain) { + try { + await setupSiteDomain({ + coreClient, + site, + domain, + interactive: true, + verbose: opts.verbose, + }); + } catch (err) { + logger.warn( + `Couldn't finish setting up ${domain}: ${errorMessage(err)}`, + ); + logger.dim( + ` Retry later: bunny sites domains add ${domain} ${site.state.name}`, + ); + } + return; + } + } + logger.dim(DOMAIN_HINT); +} diff --git a/packages/cli/src/commands/sites/health.test.ts b/packages/cli/src/commands/sites/health.test.ts new file mode 100644 index 00000000..38c34309 --- /dev/null +++ b/packages/cli/src/commands/sites/health.test.ts @@ -0,0 +1,150 @@ +import { afterEach, expect, test } from "bun:test"; +import { findDeployFault, findMissingPageFault, health } from "./health.ts"; + +const realFetch = globalThis.fetch; +const realWait = health.wait; + +afterEach(() => { + globalThis.fetch = realFetch; + health.wait = realWait; +}); + +/** Answer each call with the next status, and record what was asked for. */ +function answerWith(statuses: Array): string[] { + const asked: string[] = []; + let call = 0; + health.wait = () => Promise.resolve(); + globalThis.fetch = ((url: string) => { + asked.push(url); + const status = statuses[Math.min(call++, statuses.length - 1)]; + if (status === "throw") return Promise.reject(new Error("unreachable")); + return Promise.resolve(new Response(null, { status })); + }) as typeof fetch; + return asked; +} + +test("a page is a working site", async () => { + answerWith([200]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +// A script that answers 404 or redirects is a script that ran. +test("a 404 and a redirect are answers, not faults", async () => { + answerWith([404]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); + answerWith([301]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +// The 400 a script that will not start answers, on every attempt. +test("reports a 400 that does not go away", async () => { + const asked = answerWith([400]); + expect(await findDeployFault("https://site.test", "abc")).toBe(400); + expect(asked.length).toBe(3); + // Each probe carries its own query, so no answer can come from the CDN cache. + expect(new Set(asked).size).toBe(3); +}); + +test("gives a cold start the chance to warm up", async () => { + answerWith([400, 200]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +test("reports a 5xx", async () => { + answerWith([503]); + expect(await findDeployFault("https://site.test", "abc")).toBe(503); +}); + +// DNS or TLS not being ready is not the deploy's verdict. +test("says nothing when the site cannot be reached", async () => { + answerWith(["throw"]); + expect(await findDeployFault("https://site.test", "abc")).toBeNull(); +}); + +/** Answer each call with the next body, and record what was asked for. */ +function answerBodies( + bodies: Array<{ status: number; body: string }>, +): string[] { + const asked: string[] = []; + let call = 0; + health.wait = () => Promise.resolve(); + globalThis.fetch = ((url: string) => { + asked.push(url); + const next = bodies[Math.min(call++, bodies.length - 1)] as { + status: number; + body: string; + }; + return Promise.resolve(new Response(next.body, { status: next.status })); + }) as typeof fetch; + return asked; +} + +const PAGE = "nothing here"; + +// The fault that shipped: the zone has no error page of its own, so bunny.net's +// answers every miss and the site's own page is never seen. +test("reports a miss answered by anything but the deploy's own page", async () => { + const asked = answerBodies([{ status: 404, body: "bunny.net" }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBe(404); + // A path, not a query: a sites zone ignores query strings, so a cache-buster + // in the query is the same URL to the cache. + expect(asked[0]).toBe("https://site.test/_bunny_check/abc/0"); + expect(new Set(asked).size).toBe(3); +}); + +test("says nothing when the deploy's own page answers", async () => { + answerBodies([{ status: 404, body: `\n${PAGE}\n` }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBeNull(); +}); + +// A rewrite to 200 is a choice a site may make, and it is not this check's to +// overrule; a 200 is still not a 404, so it is reported and the deploy decides. +test("reports a miss that answered 200", async () => { + answerBodies([{ status: 200, body: PAGE }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBe(200); +}); + +// A deploy with no 404 page of its own has nothing to be wrong about. +test("asks nothing when the deploy has no page of its own", async () => { + const asked = answerBodies([{ status: 404, body: "" }]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: null, + }), + ).toBeNull(); + expect(asked).toEqual([]); +}); + +test("gives a fresh deploy the chance to propagate", async () => { + answerBodies([ + { status: 404, body: "bunny.net" }, + { status: 404, body: PAGE }, + ]); + expect( + await findMissingPageFault({ + url: "https://site.test", + deployId: "abc", + page: PAGE, + }), + ).toBeNull(); +}); diff --git a/packages/cli/src/commands/sites/health.ts b/packages/cli/src/commands/sites/health.ts new file mode 100644 index 00000000..0609cbae --- /dev/null +++ b/packages/cli/src/commands/sites/health.ts @@ -0,0 +1,122 @@ +/** + * What a fresh deploy is asked before the command calls it a success. + * + * A green line printed above a URL that does not serve is the worst thing a + * deploy can do. Two faults have happened for real, so two things are checked: + * a script that will not start, and a site answering a miss with bunny.net's + * error page rather than its own. + */ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/** How many times to ask a fresh deploy before believing the answer. */ +const HEALTH_ATTEMPTS = 3; +const HEALTH_INTERVAL_MS = 3000; + +/** Overridden by the test, which has no nine seconds to spare. */ +export const health = { + wait: (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)), +}; + +/** + * Ask the site for its home page, and answer with a status that means it is down. + * + * Returns null when the site answered anything a working script can answer, a + * redirect and a 404 included, and when it could not be reached at all. A deploy + * that prints a green line above a URL answering 400 is the worst thing this + * command can do, and the script's own size is the usual reason. + */ +export async function findDeployFault( + url: string, + deployId: string, +): Promise { + let fault: number | null = null; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { + if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); + try { + // A unique query per attempt keeps the probe out of the CDN cache, so a + // cached failure cannot outlive the release that caused it. + const response = await fetch( + `${url}/?__bunny_check=${deployId}-${attempt}`, + { + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }, + ); + if (response.status !== 400 && response.status < 500) return null; + fault = response.status; + } catch { + // Unreachable is not a verdict: DNS and TLS take their own time. + return fault; + } + } + return fault; +} + +/** The names a deploy's own error page is written under. Both hosts read the first. */ +const NOT_FOUND_FILES = ["404.html", "404/index.html"]; + +/** + * The deploy's own 404 page, or null when it has none. + * + * `files` is what the deploy uploaded, so this asks the build what it produced + * rather than guessing from a framework. + */ +export async function readNotFoundPage( + dir: string, + files: Array<{ path: string }>, +): Promise { + const name = NOT_FOUND_FILES.find((candidate) => + files.some((file) => file.path === candidate), + ); + if (!name) return null; + try { + // A deploy path is POSIX, whatever the machine that built it. + return await readFile(join(dir, ...name.split("/")), "utf8"); + } catch { + return null; + } +} + +/** + * Ask for a path the deploy cannot hold, and check the deploy's own page + * answers it. + * + * A pull zone with no error page of its own answers a miss with bunny.net's, + * whatever the build produced. That shipped: a documentation site went up and + * every wrong URL showed bunny.net's page instead of the site's. Nothing in the + * API reports it, and nobody reads a 404 on the happy path, so it is asked for + * here. + * + * The probe is a path, not a query string: a sites pull zone ignores query + * strings, so `?x=1` is the same URL to the cache. Returns the status that + * answered when the page was not the deploy's, and null when it was, when the + * deploy has no page of its own, or when the site could not be reached. + */ +export async function findMissingPageFault(opts: { + url: string; + deployId: string; + /** The deploy's own 404 page, from {@link readNotFoundPage}. */ + page: string | null; +}): Promise { + if (opts.page === null) return null; + const wanted = opts.page.trim(); + let fault: number | null = null; + for (let attempt = 0; attempt < HEALTH_ATTEMPTS; attempt++) { + if (attempt > 0) await health.wait(HEALTH_INTERVAL_MS); + try { + const response = await fetch( + `${opts.url}/_bunny_check/${opts.deployId}/${attempt}`, + { redirect: "manual", signal: AbortSignal.timeout(10_000) }, + ); + const body = await response.text(); + if (response.status === 404 && body.trim() === wanted) return null; + fault = response.status; + } catch { + // Unreachable is not a verdict: DNS and TLS take their own time. + return null; + } + } + return fault; +} diff --git a/packages/cli/src/commands/sites/interactive.ts b/packages/cli/src/commands/sites/interactive.ts index 0a6265b0..c79aae9d 100644 --- a/packages/cli/src/commands/sites/interactive.ts +++ b/packages/cli/src/commands/sites/interactive.ts @@ -118,6 +118,8 @@ export async function selectSite( args: SiteSelectorArgs & { output: OutputFormat; force?: boolean; + /** A name for a site to create, from `--name`. Enough to run unattended. */ + name?: string; offerCreate?: () => Promise; }, ): Promise { @@ -160,6 +162,10 @@ export async function selectSite( return linked(site); } + // A name is an instruction, so an unattended run with one needs nothing else. + // Without it there is nothing to create and nobody to ask. + if (args.name && args.offerCreate) return linked(await args.offerCreate()); + // `--force` skips the confirmation too, so picking a site from a list would act on it unprompted. if (args.force || !isInteractive(args.output)) { throw new UserError( diff --git a/packages/cli/src/commands/sites/provision.ts b/packages/cli/src/commands/sites/provision.ts index 26e390ff..76084016 100644 --- a/packages/cli/src/commands/sites/provision.ts +++ b/packages/cli/src/commands/sites/provision.ts @@ -1,7 +1,7 @@ import { basename } from "node:path"; import { UserError } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; -import { saveManifest } from "../../core/manifest.ts"; +import { ignoreManifestDir, saveManifest } from "../../core/manifest.ts"; import { prompts, withSpinner } from "../../core/ui.ts"; import type { CoreClient } from "../storage/api.ts"; import { @@ -92,6 +92,11 @@ export async function createLinkedSite(opts: { id: result.state.storageZoneId, name: opts.name, }); + if (ignoreManifestDir()) { + logger.dim( + " Added .bunny/ to .gitignore; it holds the link to this site.", + ); + } const context = await siteContextFromZone(result.storageZone); if (!context) { diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 066b342f..cf9ef499 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -1,27 +1,79 @@ import { expect, test } from "bun:test"; import { routerSource } from "./source.ts"; -// Extracts a top-level function from the generated script and evaluates it, so tests run the shipped code rather than a mirror of it. -function extractFn(name: string): (...args: unknown[]) => unknown { +// Extracts a top-level function from the generated script, so the tests run the shipped code rather than a mirror of it. +function fnSource(name: string): string { const match = routerSource.match( new RegExp(`function ${name}\\([^]*?\\n\\}`), ); if (!match) throw new Error(`function ${name} not found in routerSource`); - return new Function(`return (${match[0]});`)() as ( - ...args: unknown[] - ) => unknown; + return match[0]; } -const indexRetryUrl = extractFn("indexRetryUrl") as ( +// A top-level `const NAME = ...;` declaration, for the ones the functions close over. +function constSource(name: string): string { + const match = routerSource.match(new RegExp(`^const ${name} = .*;$`, "m")); + if (!match) throw new Error(`const ${name} not found in routerSource`); + return match[0]; +} + +// Evaluate the named functions together with the declarations they close over, and hand back the last one. +function load( + names: string[], + consts: string[] = [], +): (...args: never[]) => unknown { + const parts = [ + ...consts.map(constSource), + ...names.slice(0, -1).map(fnSource), + `return (${fnSource(names[names.length - 1] as string)});`, + ]; + return new Function(parts.join("\n"))() as (...args: never[]) => unknown; +} + +const indexRetryUrl = load(["indexRetryUrl"]) as ( rawUrl: string, host: string, ) => string | null; -const clientHostname = extractFn("clientHostname") as (request: { +const clientHostname = load(["clientHostname"]) as (request: { url: string; headers: Map; }) => string; +const matchPath = load(["matchPath"]) as (pathname: string) => string; + +interface RedirectRule { + from: string; + to: string; + status: number; + force: boolean; +} + +const parseRedirects = load( + ["matchPath", "parseRedirects"], + ["REDIRECT_STATUS"], +) as (text: string) => RedirectRule[]; + +const parseHeaders = load(["matchPath", "parseHeaders"]) as ( + text: string, +) => Array<{ from: string; entries: Array<[string, string]> }>; + +const matchRedirect = load(["ruleSplat", "matchRedirect"]) as ( + rules: RedirectRule[], + path: string, + forcedOnly: boolean, +) => RedirectRule | null; + +const matchHeaders = load(["ruleSplat", "matchHeaders"]) as ( + rules: Array<{ from: string; entries: Array<[string, string]> }>, + paths: string[], +) => Map; + +const defaultCacheControl = load( + ["defaultCacheControl"], + ["PAGE_CACHE", "ASSET_CACHE", "PAGE_EXT"], +) as (path: string) => string; + // A minimal Headers-alike; the router only calls headers.get(). function req(url: string, headers: Record) { return { url, headers: new Map(Object.entries(headers)) }; @@ -39,12 +91,34 @@ test("routerSource wires up the deploy routing", () => { ); // Slashless 404s probe the directory index and redirect to the slash URL, after the exact lookup misses. expect(src).toContain('const RETRY_HEADER = "x-bunny-index-retry";'); - expect(src).toContain("if (retry && ctx.response.status === 404)"); - expect(src).toContain("{ status: 301, headers: { Location: retry } }"); - // The client-sent flag must be stripped, or it'd poison cached HTML. + expect(src).toContain("if (retry) {"); + expect(src).toContain( + "status: 301,\n headers: { Location: retry }", + ); + // The client-sent flags must be stripped, or they'd poison cached HTML. expect(src).toContain("headers.delete(RETRY_HEADER);"); - // Internal state is never served. - expect(src).toContain('path.startsWith("/_bunny/")'); + expect(src).toContain("headers.delete(PATH_HEADER);"); + expect(src).toContain("headers.delete(RAW_HEADER);"); + // Only a request this router sent to the origin is answered in the response + // phase; a response the request phase produced itself is already final. + expect(src).toContain("if (requested === null) return;"); +}); + +// The three names a deploy configures the router with. A framework writes them; nothing here knows which framework. +test("routerSource reads the deploy's own configuration, and nothing else", () => { + expect(routerSource).toContain('const CONFIG_PATH = "/_bunny/router/";'); + expect(routerSource).toContain( + 'const CONFIG_FILES = ["_redirects", "_headers", "404.html", "404/index.html"];', + ); + // The reserved path is the whole permission: anything else under `_bunny/` is still forbidden. + expect(routerSource).toContain( + 'if (wanted === null && (path === "/_bunny" || path.startsWith("/_bunny/")))', + ); + expect(routerSource).toContain( + 'return new Response("Forbidden", { status: 403 });', + ); + // The configuration is read per deploy and held, never written into the source. + expect(routerSource).toContain("const configs = new Map();"); }); // The raw URL at the edge is an internal origin address; the retry target must be rebuilt on the client host so the probe re-enters the CDN and this router. @@ -84,3 +158,120 @@ test("clientHostname prefers CDN-Host, then Host, then the URL", () => { "fallback.example", ); }); + +// `/about` and `/about/` are one page to every static host, so a rule written either way matches both. +test("matchPath drops the trailing slash, and keeps the root", () => { + expect(matchPath("/about/")).toBe("/about"); + expect(matchPath("/about")).toBe("/about"); + expect(matchPath("/")).toBe("/"); + expect(matchPath("/a/b//")).toBe("/a/b"); +}); + +test("parseRedirects reads the subset both hosts agree on", () => { + const rules = parseRedirects( + [ + "# a comment", + "", + "/old /about", + "/gone /about 302", + "/forced /about 301!", + "/blog/* /news/:splat 308", + " /indented /about ", + ].join("\n"), + ); + expect(rules).toEqual([ + { from: "/old", to: "/about", status: 301, force: false }, + { from: "/gone", to: "/about", status: 302, force: false }, + { from: "/forced", to: "/about", status: 301, force: true }, + { from: "/blog/*", to: "/news/:splat", status: 308, force: false }, + { from: "/indented", to: "/about", status: 301, force: false }, + ]); +}); + +// A line this router cannot act on is dropped, not guessed at. A rewrite (200) is deliberately outside the subset. +test("parseRedirects drops what it cannot send", () => { + expect( + parseRedirects( + [ + "/nowhere", + "relative /about", + "/spa/* /index.html 200", + "/x /y 999", + ].join("\n"), + ), + ).toEqual([]); +}); + +test("parseHeaders reads a path and the lines under it", () => { + expect( + parseHeaders( + [ + "# a comment", + "/_astro/*", + " Cache-Control: public, max-age=31536000, immutable", + "/about/", + " X-Frame-Options: DENY", + " Content-Security-Policy: default-src 'self'; img-src *", + "/empty", + ].join("\n"), + ), + ).toEqual([ + { + from: "/_astro/*", + entries: [["Cache-Control", "public, max-age=31536000, immutable"]], + }, + { + from: "/about", + entries: [ + ["X-Frame-Options", "DENY"], + ["Content-Security-Policy", "default-src 'self'; img-src *"], + ], + }, + ]); +}); + +test("matchRedirect takes the first rule, and fills in the splat", () => { + const rules = parseRedirects( + ["/blog/* /news/:splat 301", "/old /about 302!"].join("\n"), + ); + expect(matchRedirect(rules, "/blog/2026/hello", false)).toMatchObject({ + to: "/news/2026/hello", + status: 301, + }); + expect(matchRedirect(rules, "/nothing", false)).toBeNull(); + // A forced rule is the only kind answered before the origin is asked, because it is the only kind that beats a real file. + expect(matchRedirect(rules, "/blog/x", true)).toBeNull(); + expect(matchRedirect(rules, "/old", true)).toMatchObject({ to: "/about" }); +}); + +test("matchHeaders collects every matching block, and a later one wins", () => { + const rules = parseHeaders( + [ + "/*", + " X-Frame-Options: SAMEORIGIN", + " X-Content-Type-Options: nosniff", + "/about", + " X-Frame-Options: DENY", + ].join("\n"), + ); + expect(Object.fromEntries(matchHeaders(rules, ["/about"]))).toEqual({ + "x-frame-options": "DENY", + "x-content-type-options": "nosniff", + }); + expect(Object.fromEntries(matchHeaders(rules, ["/other"]))).toEqual({ + "x-frame-options": "SAMEORIGIN", + "x-content-type-options": "nosniff", + }); +}); + +// A page is rewritten in place by the next deploy, and a promote purges the edge; a browser may only keep it briefly. The zone's own override is off, so this answer is the one the visitor gets. +test("defaultCacheControl separates a document from everything else", () => { + expect(defaultCacheControl("/about/")).toBe("public, max-age=60"); + expect(defaultCacheControl("/index.html")).toBe("public, max-age=60"); + expect(defaultCacheControl("/feed.xml")).toBe("public, max-age=60"); + expect(defaultCacheControl("/data.json")).toBe("public, max-age=60"); + expect(defaultCacheControl("/_astro/app.a1b2.js")).toBe( + "public, max-age=2592000", + ); + expect(defaultCacheControl("/logo.png")).toBe("public, max-age=2592000"); +}); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 43f2f1c0..1cfd2840 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -1,10 +1,34 @@ -export const ROUTER_VERSION = 5; +// The published router source's generation; recorded in site state so deploy republishes routers that predate the current source. +export const ROUTER_VERSION = 6; +// The site's middleware Edge Script. It maps the site's hosts to the published deploy dir, and it serves the deploy's own `404.html`, `_redirects` and `_headers`. Those three names are the whole contract with a framework: Cloudflare Pages and Netlify read the same ones, so nothing here knows about any framework, and every preset gets the same behaviour. +// +// The edge hands the script an origin-facing URL (an internal `ip:9000` address), so the client hostname MUST come from the CDN-Host/Host headers; matching on `url.hostname` silently routes every request wrong. The deploy's configuration is read at run time and held in memory, never inlined below, so a publish stays an environment variable change. +// +// The zone's `CacheControlMaxAgeOverride` is off (see `STATIC_SITE_ZONE_SETTINGS`), which means whatever this script returns is what the visitor gets. So every response leaves here with a `Cache-Control`, because Bunny Storage sends none for HTML. +// +// (See AGENTS.md and the SOURCE comments below; BunnySDK hook and header names are the platform contract.) export const routerSource = `// bunny sites router v${ROUTER_VERSION}, generated by the bunny CLI. Do not edit: // \`bunny sites upgrade-router\` overwrites this script. import * as BunnySDK from "@bunny.net/edgescript-sdk"; const RETRY_HEADER = "x-bunny-index-retry"; +// The path the client asked for, carried to the response phase: by then the URL is the rewritten origin one, and \`_redirects\` and \`_headers\` are written against what the visitor typed. +const PATH_HEADER = "x-bunny-path"; +// Marks a request this router made for itself, so the response phase adds nothing to it and cannot recurse through it. +const RAW_HEADER = "x-bunny-raw"; + +// The files a deploy configures the router with. Host-standard names: Cloudflare Pages and Netlify read the same ones, so a framework that already writes them needs nothing new. The router reads them through its own reserved path, which is the whole permission: nothing else under \`_bunny/\` becomes reachable. +const CONFIG_PATH = "/_bunny/router/"; +const CONFIG_FILES = ["_redirects", "_headers", "404.html", "404/index.html"]; + +// A page is rewritten in place by the next deploy, so a browser may only keep it briefly; a publish purges the edge. Everything else is content a new deploy renames, and \`_headers\` is where a build says which directory is hashed. +const PAGE_CACHE = "public, max-age=60"; +const ASSET_CACHE = "public, max-age=2592000"; +// Extensions whose object is a document: entered by URL, and replaced in place. +const PAGE_EXT = /\\.(?:html?|json|xml|txt|rss|atom|webmanifest|map)$/i; +// A body these statuses may not carry. Constructing one with a body throws. +const BODYLESS = [101, 204, 205, 304]; // ctx.request.url carries the ORIGIN address at the edge, not the requested host; the platform passes the client hostname in CDN-Host (Host covers local harnesses). function clientHostname(request) { @@ -26,29 +50,156 @@ function indexRetryUrl(rawUrl, host) { return "https://" + host + u.pathname + "/" + u.search; } +// The path a rule is matched against: no trailing slash, and never empty. \`/about/\` and \`/about\` are one page to every static host, so they are one rule here. +function matchPath(pathname) { + const trimmed = pathname.replace(/\\/+$/, ""); + return trimmed === "" ? "/" : trimmed; +} + +// Statuses a rule may ask for. A rewrite (\`200\`) is deliberately not one: it would have this router fetch another path of its own site, which can be made to loop, and no deploy needs it yet. +const REDIRECT_STATUS = [301, 302, 303, 307, 308]; + +// \`_redirects\`, in the subset Cloudflare Pages and Netlify agree on: \`from to [status]\` per line, \`#\` comments, a trailing \`*\` in \`from\` captured as \`:splat\`, and \`!\` after the status to beat a file at the same path. +function parseRedirects(text) { + const rules = []; + for (const raw of text.split("\\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + const parts = line.split(/\\s+/); + if (parts.length < 2) continue; + const from = parts[0]; + const to = parts[1]; + if (!from.startsWith("/")) continue; + const asked = parts[2] ?? "301"; + const status = Number.parseInt(asked.replace("!", ""), 10); + if (!REDIRECT_STATUS.includes(status)) continue; + rules.push({ from: matchPath(from), to, status, force: asked.endsWith("!") }); + } + return rules; +} + +// \`_headers\`: a line starting with \`/\` opens a block, and each \`Name: value\` line below it belongs to that block. +function parseHeaders(text) { + const rules = []; + let current = null; + for (const raw of text.split("\\n")) { + const line = raw.trim(); + if (line === "" || line.startsWith("#")) continue; + if (line.startsWith("/")) { + current = { from: matchPath(line), entries: [] }; + rules.push(current); + continue; + } + if (!current) continue; + const colon = line.indexOf(":"); + if (colon < 1) continue; + const name = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (name !== "" && value !== "") current.entries.push([name, value]); + } + return rules.filter((rule) => rule.entries.length > 0); +} + +// What a trailing \`*\` in the rule's path captured, "" for an exact match, or null when the rule does not apply. +function ruleSplat(from, path) { + if (!from.endsWith("*")) return from === path ? "" : null; + const prefix = from.slice(0, -1); + return path.startsWith(prefix) ? path.slice(prefix.length) : null; +} + +// The first rule that matches, with \`:splat\` filled in. +function matchRedirect(rules, path, forcedOnly) { + for (const rule of rules) { + if (forcedOnly && !rule.force) continue; + const splat = ruleSplat(rule.from, path); + if (splat === null) continue; + return { ...rule, to: rule.to.replaceAll(":splat", splat) }; + } + return null; +} + +// Every header the matching blocks ask for, in file order, so a later block overrides an earlier one on the same name. +function matchHeaders(rules, paths) { + const found = new Map(); + for (const rule of rules) { + if (!paths.some((path) => ruleSplat(rule.from, path) !== null)) continue; + for (const [name, value] of rule.entries) { + found.set(name.toLowerCase(), value); + } + } + return found; +} + +// What a response may be cached for when \`_headers\` says nothing. The pull zone's own override is off on a sites zone, so this is the answer the visitor gets. +function defaultCacheControl(path) { + return PAGE_EXT.test(path) || !path.includes(".") ? PAGE_CACHE : ASSET_CACHE; +} + +// Read one of the deploy's configuration files. "" means the deploy does not hold it, and null means it could not be read at all, which must not be remembered as "no rules". +async function readFile(host, name) { + try { + const response = await fetch("https://" + host + CONFIG_PATH + name, { + headers: { [RAW_HEADER]: "1" }, + }); + if (response.status === 404) return ""; + return response.ok ? await response.text() : null; + } catch { + return null; + } +} + +// The deploy's rules, read once and held for the life of the isolate. They are never inlined in this script, so a publish stays an environment variable change. +const configs = new Map(); + +function readConfig(host, deploy) { + const held = configs.get(deploy); + if (held) return held; + const loading = (async () => { + const [redirects, headers, page, nested] = await Promise.all([ + readFile(host, "_redirects"), + readFile(host, "_headers"), + readFile(host, "404.html"), + readFile(host, "404/index.html"), + ]); + // A read that failed is not an answer. Forget the lot and try again on the + // next request, rather than serving a deploy without its rules for hours. + if (redirects === null || headers === null) configs.delete(deploy); + return { + redirects: parseRedirects(redirects ?? ""), + headers: parseHeaders(headers ?? ""), + notFound: page || nested || null, + }; + })(); + configs.set(deploy, loading); + return loading; +} + BunnySDK.net.http .servePullZone() .onOriginRequest(async (ctx) => { const url = new URL(ctx.request.url); const host = clientHostname(ctx.request); + const requested = url.pathname; + + // The router's own reads, which skip every rule below. + const wanted = requested.startsWith(CONFIG_PATH) + ? requested.slice(CONFIG_PATH.length) + : null; + // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route. if (url.pathname.endsWith("/")) url.pathname += "index.html"; const path = url.pathname; // Internal site metadata (state, env) is never served. - if (path === "/_bunny" || path.startsWith("/_bunny/")) { + if (wanted === null && (path === "/_bunny" || path.startsWith("/_bunny/"))) { return new Response("Forbidden", { status: 403 }); } - // The flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. + // Every flag is router-internal: client-sent copies are stripped, or they'd poison cached HTML. const headers = new Headers(ctx.request.headers); headers.delete(RETRY_HEADER); - - // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. - if (ctx.request.method === "GET" || ctx.request.method === "HEAD") { - const retry = indexRetryUrl(ctx.request.url, host); - if (retry) headers.set(RETRY_HEADER, retry); - } + headers.delete(PATH_HEADER); + headers.delete(RAW_HEADER); const deploy = process.env.CURRENT_DEPLOY || ""; @@ -59,17 +210,103 @@ BunnySDK.net.http }); } + if (wanted !== null) { + if (!CONFIG_FILES.includes(wanted)) { + return new Response("Not Found", { status: 404 }); + } + headers.set(RAW_HEADER, "1"); + url.pathname = "/deploys/" + deploy + "/" + wanted; + return new Request(new Request(url.toString(), ctx.request), { headers }); + } + + headers.set(PATH_HEADER, requested); + + if (ctx.request.method === "GET" || ctx.request.method === "HEAD") { + // A forced rule beats a file at the same path, so it is the only kind that can be answered before the origin is asked. An unforced one waits for the 404, which is what makes a real file win. + const rules = await readConfig(host, deploy); + const forced = matchRedirect(rules.redirects, matchPath(requested), true); + if (forced) { + return new Response(null, { + status: forced.status, + headers: { Location: forced.to, "Cache-Control": PAGE_CACHE }, + }); + } + // Exact objects win: a slashless GET/HEAD miss retries as its directory index in the response phase. + const retry = indexRetryUrl(ctx.request.url, host); + if (retry) headers.set(RETRY_HEADER, retry); + } + url.pathname = "/deploys/" + deploy + path; return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { - // A flagged 404 probes its directory index and redirects to the slash URL when it exists (/blog -> /blog/), so relative references resolve against the right base; the probe re-enters this router and, slash-terminated, can never retry further. - const retry = ctx.request.headers.get(RETRY_HEADER); - if (retry && ctx.response.status === 404) { - const probe = await fetch(retry, { method: "HEAD" }); - if (probe.ok) { - return new Response(null, { status: 301, headers: { Location: retry } }); + // The router's own read. Nothing is applied to it, and nothing recurses through it. + if (ctx.request.headers.get(RAW_HEADER)) return; + + // Only a request this router sent to the origin carries the path, so only + // one of those is answered here. A response the request phase produced by + // itself (a forced redirect, a 403, an unpublished site) is already final. + const requested = ctx.request.headers.get(PATH_HEADER); + if (requested === null) return; + + const host = clientHostname(ctx.request); + const deploy = process.env.CURRENT_DEPLOY || ""; + const rules = await readConfig(host, deploy); + const response = ctx.response; + + if (response.status === 404) { + // A flagged 404 probes its directory index and redirects to the slash URL when it exists (/blog -> /blog/), so relative references resolve against the right base; the probe re-enters this router and, slash-terminated, can never retry further. + const retry = ctx.request.headers.get(RETRY_HEADER); + if (retry) { + const probe = await fetch(retry, { method: "HEAD" }); + if (probe.ok) { + return new Response(null, { + status: 301, + headers: { Location: retry }, + }); + } + } + + const rule = matchRedirect(rules.redirects, matchPath(requested), false); + if (rule) { + return new Response(null, { + status: rule.status, + headers: { Location: rule.to, "Cache-Control": PAGE_CACHE }, + }); + } + + // The deploy's own 404 page. Without this the CDN answers a miss with bunny.net's page, whatever the site built. + if (rules.notFound !== null) { + return new Response(rules.notFound, { + status: 404, + headers: { + "Content-Type": "text/html; charset=utf-8", + // A miss the next deploy fixes must not outlive it. + "Cache-Control": "no-cache", + }, + }); } } + + // Bunny Storage holds no headers, so \`_headers\` is where the deploy keeps them. The requested path and the object it resolved to are both matched, so a rule may be written either way. + const headers = new Headers(response.headers); + const paths = [matchPath(requested)]; + if (requested.endsWith("/")) { + paths.push(matchPath(requested + "index.html")); + } + for (const [name, value] of matchHeaders(rules.headers, paths)) { + headers.set(name, value); + } + + // The pull zone applies no expiry of its own to a sites zone, so a response carrying no directive would reach the visitor with none. + if (!headers.has("Cache-Control")) { + headers.set("Cache-Control", defaultCacheControl(requested)); + } + + return new Response(BODYLESS.includes(response.status) ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); }); `; diff --git a/packages/cli/src/commands/sites/upgrade-router.ts b/packages/cli/src/commands/sites/upgrade-router.ts index 36c050a8..bc0b57b5 100644 --- a/packages/cli/src/commands/sites/upgrade-router.ts +++ b/packages/cli/src/commands/sites/upgrade-router.ts @@ -8,7 +8,7 @@ import { defineCommand } from "../../core/define-command.ts"; import { errorMessage } from "../../core/errors.ts"; import { logger } from "../../core/logger.ts"; import { withSpinner } from "../../core/ui.ts"; -import { writeRemoteState } from "./api.ts"; +import { applySiteZoneSettings, writeRemoteState } from "./api.ts"; import { type SiteSelectorArgs, selectSite, @@ -53,6 +53,9 @@ export const sitesUpgradeRouterCommand = defineCommand({ params: { path: { id: state.scriptId, uuid: null } }, body: {}, }); + // The router decides what a response may be cached for, so the zone must + // stop overriding its answer. One change, applied together. + await applySiteZoneSettings({ coreClient, state }); }); // Record the published generation so deploy stops re-upgrading; best-effort (a missed write just republishes next deploy). diff --git a/packages/cli/src/commands/skills/content.ts b/packages/cli/src/commands/skills/content.ts index facf9b97..28aa1439 100644 --- a/packages/cli/src/commands/skills/content.ts +++ b/packages/cli/src/commands/skills/content.ts @@ -10,6 +10,9 @@ import databaseMd from "../../../../../skills/bunny-cli/references/database.md" import dnsMd from "../../../../../skills/bunny-cli/references/dns.md" with { type: "text", }; +import labMd from "../../../../../skills/bunny-cli/references/lab.md" with { + type: "text", +}; import sandboxMd from "../../../../../skills/bunny-cli/references/sandbox.md" with { type: "text", }; @@ -45,6 +48,7 @@ export const BUNNY_CLI_SKILL: ProjectSkill = { "references/auth.md": authMd, "references/database.md": databaseMd, "references/dns.md": dnsMd, + "references/lab.md": labMd, "references/sandbox.md": sandboxMd, "references/scripts.md": scriptsMd, "references/sites.md": sitesMd, diff --git a/packages/cli/src/core/hostnames/client.ts b/packages/cli/src/core/hostnames/client.ts index 1df2d1c7..cdf37ea7 100644 --- a/packages/cli/src/core/hostnames/client.ts +++ b/packages/cli/src/core/hostnames/client.ts @@ -46,6 +46,20 @@ export function normalizeHostname(value: string): string { .replace(/\/+$/, ""); } +/** + * True when this looks like a hostname somebody could own. + * + * The API answers "An error has occurred." for anything it does not like, which + * tells a developer who typed one word into the domain prompt nothing at all. + * Two labels and no illegal character is the whole test: the API still owns the + * question of whether the name is available. + */ +export function looksLikeHostname(value: string): boolean { + return /^(?=.{1,253}$)(?!-)[a-z0-9-]{1,63}(? { + const script = opts?.edgeScriptId; const { data } = await client.POST("/pullzone", { body: { Name: name, - StorageZoneId: storageZoneId, - OriginType: ORIGIN_TYPE_STORAGE_ZONE, + // A script is its own origin, so a script-backed zone has no storage zone. + ...(script != null + ? { OriginType: ORIGIN_TYPE_EDGE_SCRIPT, EdgeScriptId: script } + : { + OriginType: ORIGIN_TYPE_STORAGE_ZONE, + StorageZoneId: storageZoneId, + }), ...(opts?.middlewareScriptId != null ? { MiddlewareScriptId: opts.middlewareScriptId } : {}), diff --git a/packages/cli/src/core/hostnames/index.ts b/packages/cli/src/core/hostnames/index.ts index b089fcad..92dfff72 100644 --- a/packages/cli/src/core/hostnames/index.ts +++ b/packages/cli/src/core/hostnames/index.ts @@ -15,6 +15,7 @@ export { hostnameHasCertificate, hostnameUrl, liveHostnames, + looksLikeHostname, normalizeHostname, probeTlsCertificate, type ResolvedPullZone, diff --git a/packages/cli/src/core/manifest.test.ts b/packages/cli/src/core/manifest.test.ts new file mode 100644 index 00000000..f0ff33f9 --- /dev/null +++ b/packages/cli/src/core/manifest.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ignoreManifestDir } from "./manifest.ts"; + +function repo(files: Record = {}, git = true): string { + const dir = mkdtempSync(join(tmpdir(), "bunny-manifest-")); + if (git) mkdirSync(join(dir, ".git")); + for (const [name, content] of Object.entries(files)) { + writeFileSync(join(dir, name), content); + } + return dir; +} + +test("adds .bunny/ to a repository with no .gitignore", () => { + const dir = repo(); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe(".bunny/\n"); +}); + +test("keeps what the .gitignore already had, and ends the file with a newline", () => { + const dir = repo({ ".gitignore": "dist\nnode_modules" }); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe( + "dist\nnode_modules\n.bunny/\n", + ); +}); + +test("does nothing when a rule for .bunny is already there", () => { + for (const line of [".bunny/", ".bunny", "/.bunny/", " .bunny/ "]) { + const dir = repo({ ".gitignore": `dist\n${line}\n` }); + expect(ignoreManifestDir(dir)).toBe(false); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toBe( + `dist\n${line}\n`, + ); + } +}); + +// A directory that is not a repository gets no file it did not ask for. +test("writes nothing outside a git repository", () => { + const dir = repo({}, false); + expect(ignoreManifestDir(dir)).toBe(false); +}); + +// `.bunnyrc` is not `.bunny`, and a comment is not a rule. +test("is not fooled by a similar line", () => { + const dir = repo({ ".gitignore": "# .bunny/\n.bunnyrc\n" }); + expect(ignoreManifestDir(dir)).toBe(true); + expect(readFileSync(join(dir, ".gitignore"), "utf8")).toContain( + "\n.bunny/\n", + ); +}); diff --git a/packages/cli/src/core/manifest.ts b/packages/cli/src/core/manifest.ts index 97dc2536..58175ed5 100644 --- a/packages/cli/src/core/manifest.ts +++ b/packages/cli/src/core/manifest.ts @@ -38,6 +38,26 @@ function manifestPath(filename: string): string { return join(findRoot(filename), MANIFEST_DIR, filename); } +/** + * Add `.bunny/` to the repository's `.gitignore`, when it is not there already. + * + * `.bunny/` holds a build output and a link to a site, and neither belongs in a + * commit. Only a directory that is a git repository is touched, and an existing + * rule for `.bunny` is left alone. Returns true when the line was added, so the + * caller can say so. + */ +export function ignoreManifestDir(root: string = process.cwd()): boolean { + if (!existsSync(join(root, ".git"))) return false; + + const path = join(root, ".gitignore"); + const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (/^\s*\/?\.bunny\/?\s*$/m.test(existing)) return false; + + const separator = existing === "" || existing.endsWith("\n") ? "" : "\n"; + writeFileSync(path, `${existing}${separator}.bunny/\n`); + return true; +} + export function manifestDir(filename: string): string { return join(findRoot(filename), MANIFEST_DIR); } diff --git a/packages/cli/src/core/package-manager.ts b/packages/cli/src/core/package-manager.ts index 784316d0..d3af244d 100644 --- a/packages/cli/src/core/package-manager.ts +++ b/packages/cli/src/core/package-manager.ts @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join, resolve } from "node:path"; export type PackageManager = "bun" | "pnpm" | "yarn" | "npm"; @@ -77,3 +77,99 @@ export async function pickPackageManager( } return null; } + +export interface Workspace { + pm: PackageManager; + /** Where the lockfile is. The same as the project, unless the project is in a monorepo. */ + root: string; + /** True when the project is the root of a workspace that holds other packages. */ + isRoot: boolean; +} + +/** `package.json` as an object, or null when there is none to read. */ +export async function readPackageJson( + dir: string, +): Promise | null> { + try { + return (await Bun.file(join(dir, "package.json")).json()) as Record< + string, + unknown + >; + } catch { + return null; + } +} + +/** True when this directory is a workspace root with packages under it. */ +async function holdsPackages(dir: string): Promise { + const workspaceFile = await Bun.file(join(dir, "pnpm-workspace.yaml")) + .text() + .catch(() => null); + // A pnpm-workspace.yaml holding only settings is not a workspace root; the + // `packages:` key is what makes one. + if (workspaceFile !== null && /^packages:/m.test(workspaceFile)) return true; + const pkg = await readPackageJson(dir); + const workspaces = pkg?.workspaces; + return Array.isArray(workspaces) + ? workspaces.length > 0 + : Boolean( + (workspaces as { packages?: unknown[] } | undefined)?.packages?.length, + ); +} + +/** + * The package manager for a project, and where its workspace root is. + * + * The lockfile lives at the root of a monorepo, not beside each package. Looking + * only beside the project made `starlight/docs` look like an npm project, and + * `npm install` then met `"@astrojs/starlight": "workspace:*"` and stopped. So + * this walks up, the way every package manager does. + */ +export async function detectWorkspace(project: string): Promise { + const start = resolve(project); + let dir = start; + while (true) { + const pm = detectFromLockfile(dir); + if (pm) { + return { + pm, + root: dir, + isRoot: dir === start && (await holdsPackages(dir)), + }; + } + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + return { pm: "npm", root: start, isRoot: await holdsPackages(start) }; +} + +/** The install command for one package, per package manager. A workspace root needs to be told the root is meant: pnpm refuses without `-w`, and Yarn classic wants `-W`. */ +export function installCommand(workspace: Workspace, pkg: string): string { + const root = workspace.isRoot; + switch (workspace.pm) { + case "npm": + return `npm install ${pkg}`; + case "pnpm": + return root ? `pnpm add -w ${pkg}` : `pnpm add ${pkg}`; + case "yarn": + return root ? `yarn add -W ${pkg}` : `yarn add ${pkg}`; + case "bun": + return `bun add ${pkg}`; + } +} + +/** The uninstall command for one package, per package manager. */ +export function uninstallCommand(workspace: Workspace, pkg: string): string { + const root = workspace.isRoot; + switch (workspace.pm) { + case "npm": + return `npm uninstall ${pkg}`; + case "pnpm": + return root ? `pnpm remove -w ${pkg}` : `pnpm remove ${pkg}`; + case "yarn": + return root ? `yarn remove -W ${pkg}` : `yarn remove ${pkg}`; + case "bun": + return `bun remove ${pkg}`; + } +} diff --git a/packages/config/src/build-manifest.ts b/packages/config/src/build-manifest.ts new file mode 100644 index 00000000..92e0cc5b --- /dev/null +++ b/packages/config/src/build-manifest.ts @@ -0,0 +1,86 @@ +import { z } from "zod"; + +/** + * The build manifest: `.bunny/build.json`, written by a framework adapter and + * read by the CLI command that deploys that framework. + * + * This file is the whole contract between the CLI and an adapter. The CLI knows + * no framework: it reads the manifest, so a new adapter needs no new CLI. The + * specification lives beside the adapters, at + * https://github.com/BunnyWay/bunny-adapters/blob/main/docs/writing-an-adapter.md + */ + +/** Where an adapter writes the manifest, relative to the project root. */ +export const BUILD_MANIFEST_PATH = ".bunny/build.json"; + +/** + * The manifest shape this CLI understands. + * + * Bump it only for a change an older CLI cannot read. A new optional field is + * not one: the CLI ignores what it does not know, so adapters can add fields + * without waiting for a release. + */ +export const BUILD_MANIFEST_VERSION = 1; + +/** A variable the script reads. The CLI sets what it can, and names the rest. */ +export const ManifestEnvSchema = z.object({ + name: z.string(), + reason: z.string().optional(), + secret: z.boolean().optional(), + optional: z.boolean().optional(), +}); + +/** + * Pull zone settings the build needs. + * + * A framework that renders per request usually needs both of these. The CLI + * applies them, reports every change, and never changes one back in silence. + */ +export const ManifestPullZoneSchema = z.object({ + /** `false` lets `Set-Cookie` through. A script-backed zone strips it by default. */ + disableCookies: z.boolean().optional(), + /** `false` lets the pull zone cache HTML, so the adapter's cache headers count. */ + enableSmartCache: z.boolean().optional(), + /** `true` fetches a large object in chunks, so the first request is seekable. */ + enableCacheSlice: z.boolean().optional(), +}); + +export const ManifestRequiresSchema = z.object({ + /** The lowest CLI version that understands this build, as a semver range. */ + cliVersion: z.string().optional(), + pullZone: ManifestPullZoneSchema.optional(), + /** The script writes to the storage zone, so it needs a password that can write. */ + storage: z + .object({ write: z.boolean().optional(), reason: z.string().optional() }) + .optional(), + env: z.array(ManifestEnvSchema).optional(), +}); + +export const BuildManifestSchema = z.object({ + manifestVersion: z.number().int().positive(), + adapter: z.object({ package: z.string(), version: z.string().optional() }), + framework: z.object({ name: z.string(), version: z.string().optional() }), + /** `ssr` needs an Edge Script. `static` is files only, and deploys like any other static site. */ + kind: z.enum(["ssr", "static"]), + /** The one file to deploy. Required for `ssr`. */ + script: z + .object({ + /** Path to the built file, relative to the project root. */ + entry: z.string(), + type: z.enum(["standalone", "middleware"]), + bytes: z.number().int().nonnegative().optional(), + }) + .optional(), + assets: z.object({ + /** The folder to upload, relative to the project root. */ + dir: z.string(), + }), + requires: ManifestRequiresSchema.optional(), + dev: z + .object({ command: z.string().optional(), preview: z.string().optional() }) + .optional(), +}); + +export type BuildManifest = z.infer; +export type ManifestEnv = z.infer; +export type ManifestPullZone = z.infer; diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 8e9f321f..cef73b94 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,5 +1,17 @@ // Schemas +// The build manifest an adapter writes and `bunny sites deploy` reads. +export { + BUILD_MANIFEST_PATH, + BUILD_MANIFEST_VERSION, + type BuildManifest, + BuildManifestSchema, + type ManifestEnv, + ManifestEnvSchema, + type ManifestPullZone, + ManifestPullZoneSchema, + ManifestRequiresSchema, +} from "./build-manifest.ts"; // API conversion export { apiToConfig, diff --git a/skills/bunny-cli/SKILL.md b/skills/bunny-cli/SKILL.md index f63d9977..d8f1fd61 100644 --- a/skills/bunny-cli/SKILL.md +++ b/skills/bunny-cli/SKILL.md @@ -57,9 +57,10 @@ bunny dns records add example.com api A 198.51.100.1 bunny dns records preset google-workspace example.com # apply a preset record set bunny dns records list example.com -# host a static site -bunny sites create my-site # provision (served at sites-my-site-.b-cdn.net) -bunny sites deploy ./dist # deploy a directory and publish it as the live site +# host a site +bunny sites create my-site # provision a static site (served at sites-my-site-.b-cdn.net) +bunny sites deploy ./dist # deploy a directory of files and publish it as the live site +bunny lab deploy astro # deploy an Astro project that renders per request (experimental) bunny sites domains add example.com --wait # custom production domain bunny sites deployments publish --previous --force # instant rollback ``` @@ -72,7 +73,8 @@ Use this to route to the correct reference file: - **Database management (create, list, show, link, delete, shell, studio, migrations, regions, tokens)** -> `references/database.md` - **DNS (zones, delegation checks, records, presets, BIND import/export, DNSSEC, logging, Scriptable DNS scripts)** -> `references/dns.md` - **Edge Scripts (init, create, deploy, link, stats, deployments/rollback, env vars, custom domains)** -> `references/scripts.md` -- **Static sites (create, deploy, rollback, custom domains, GitHub Actions)** -> `references/sites.md` +- **Sites (create, deploy a directory, rollback, custom domains, GitHub Actions)** -> `references/sites.md` +- **Lab (experimental: `bunny lab deploy astro` for an Astro project that renders per request)** -> `references/lab.md` - **Sandboxes (create, exec, ssh, files list/cp, public URLs, persistent env vars, Claude Code auth)** -> `references/sandbox.md` - **Make raw API requests** -> `references/api.md` - **CLI doesn't have a command for it** -> use `bunny api` as a fallback (see `references/api.md`) diff --git a/skills/bunny-cli/references/lab.md b/skills/bunny-cli/references/lab.md new file mode 100644 index 00000000..fbb85350 --- /dev/null +++ b/skills/bunny-cli/references/lab.md @@ -0,0 +1,106 @@ +# Lab Commands + +`bunny lab` holds commands we are still shaping. The name is the warning: the +interface can change between releases, so a workflow built on one should expect +to be updated. The namespace is hidden from help and from the landing page. + +## Astro + +Two commands, and no more: + +```bash +bunny lab deploy astro [dir] # build this project, then deploy it +bunny lab undeploy astro [dir] # delete the app and the resources it runs on +``` + +Server-side rendering only. A static Astro build is a directory of files, and +`bunny sites deploy` deploys one; this command refuses it and says so. + +Nothing here touches `bunny sites`. The two commands deploy different shapes, and +an app made by one is invisible to the other. + +### What it makes + +Three resources, named after the app: + +| Resource | Name | Holds | +| ------------ | ----------------------------- | ------------------------------ | +| Storage zone | `astro--` | The client build, per deploy | +| Edge Script | `astro---server` | Astro's server, standalone | +| Pull zone | `astro--` | The hostname, script as origin | + +The script is the pull zone's origin, so nothing sits between a request and the +code. The client build lives at `deploys//` in the storage zone, and the +deploy's own folder name is written into the top of the bundle, so a release can +only read the files it was built against. + +`.bunny/astro.json` in the project links the directory to those three resources. +It is a pointer, not a source of truth: `--name` finds the same resources by +name, which is what a fresh clone or a CI runner does. + +### The deploy, in order + +1. Find the project. A monorepo root is not one; it offers the projects below it. +2. Check the Astro version. The adapter needs Astro 7, and an older project stops + here with the upgrade command. +3. Put `@bunny.net/astro-adapter` in. Another host's adapter is removed, from the + config and from `package.json`. +4. Build, unless `--no-build`. +5. Read `.bunny/build.json`. It has to say `kind: "ssr"`. +6. Read the bundle. Over 10 MB the platform refuses it, so this refuses first. +7. Find or create the three resources. Each one is looked up before it is made, + so a half-finished create re-runs cleanly. +8. Apply the pull zone settings the build asks for, and turn the zone's + `CacheControlMaxAgeOverride` off. Without the last one the edge rewrites every + `Cache-Control` the adapter sets. +9. Set the script's variables: the zone, its endpoint, its read-only password, + and the pull zone ID. A build that uses `Astro.session` also gets a password + that can write. +10. Upload the client build, then publish the code. In that order, always. +11. Purge, wait, purge. Without the second purge the command reports success + while the site still serves the release before it. +12. Ask the site for its home page, and for a page it does not hold. +13. Delete every deploy folder but this one and the one before it. + +### Flags + +| Command | Flag | Does | +| ---------- | ---------------- | -------------------------------------------------------------- | +| `deploy` | `--name` | The app name. Default: the state file, then the package's name | +| `deploy` | `--region` | Storage region for a new app (default: DE) | +| `deploy` | `--no-build` | Deploy the build already on disk | +| `deploy` | `--yes` / `-y` | Add the adapter without asking | +| `deploy` | `--force` | Deploy again when nothing changed | +| `undeploy` | `--name` | The app to delete, with no state file | +| `undeploy` | `--keep-storage` | Keep the storage zone and every file in it | +| `undeploy` | `--force` / `-f` | Skip the prompts | + +`--output json` prints the deploy's ID, URL, sizes, and the variables it could +not set. An unchanged deploy prints `"unchanged": true` and changes nothing. + +### What a user must change by hand + +The CLI cannot do these: + +- **Astro 7.** The adapter's peer range is `^7.0.0`. Upgrading a framework major + is the developer's decision: run `npx @astrojs/upgrade`. +- **State in memory does not hold.** Each request may reach a different edge node + and a different isolate, so a module-level `Map` or `let` is empty again on the + next request. `Astro.session` replaces it, and writes to the storage zone. +- **A page must not fetch its own site to reach its own API route.** It works, but + every page render becomes a second trip out through the CDN and back. Import the + data instead. + +Astro's own `security.checkOrigin` is on by default for `output: "server"`, so a +form POST with no `Origin` header answers 403. That is Astro, not the platform: a +browser sends the header, and `curl` has to be told to. + +### Unattended runs + +```bash +bunny lab deploy astro --name my-app --yes --output json +bunny lab undeploy astro --name my-app --force +``` + +`--yes` is needed because the adapter changes a `package.json` and an +`astro.config`. Without it, an unattended run prints the two changes and stops. diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 222b431d..62cb2e58 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -1,6 +1,10 @@ -# Static Sites Commands +# Sites Commands -All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one middleware router script, provisioned together by `sites create`. Deploys are immutable directories; promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. +All site commands live under `bunny sites`. A site is one storage zone (files) + one pull zone (CDN) + one Edge Script, provisioned together by `sites create` or by the first `sites deploy`. Deploys are immutable directories. + +The Edge Script is the router this CLI generates. Promoting or rolling back flips a router env var and purges the cache; no files move, so it's instant. + +`bunny sites` deploys a directory of files. A build that renders pages per request is a different shape, and this command does not deploy one: see `references/lab.md` for `bunny lab deploy astro`. Most commands accept an optional site (a trailing `[site]` positional, or the `--site` flag on commands whose positionals are taken, like `deploy`). When omitted, the site resolves in this order: @@ -14,7 +18,7 @@ Commands that can link the directory (`deploy`, `show`, `deployments list/publis ## Typical workflows ```bash -# New site: provision, deploy, iterate +# New static site: provision, deploy, iterate bunny sites create my-site # served at https://sites-my-site-.b-cdn.net bunny sites deploy ./dist # deploy and publish as the live site @@ -41,6 +45,22 @@ This is the rule that shapes every other command here: Content is root-served, so client-side routers (TanStack Router, React Router, Vue Router in history mode) and root-absolute assets work as-is. Deploys are not individually addressable: `/deploys//` URLs are internal to the storage layout and are not publicly served. To review a change before it goes live, build and serve it locally, or deploy it to a separate site. +## What the deploy configures + +The router reads three file names out of the deploy it serves. Cloudflare Pages and Netlify read the same three, so a build that already writes them needs nothing bunny-specific. + +| File in the deploy | What it does | +| ------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `404.html` | Answers a path the deploy does not hold, at status 404. Without it a miss gets bunny.net's error page | +| `_redirects` | `/from /to [status]` per line. `#` comments, a trailing `*` captured as `:splat`, `!` to beat a file at that path | +| `_headers` | A `/path` line, then indented `Name: value` lines | + +301 is the default status; 302, 303, 307 and 308 are read too. A rewrite (`200`) is not supported. A rule without `!` applies only where the deploy holds no file, so a real file always wins. A path matches with or without its trailing slash. + +The router also sets `Cache-Control` on every response, because Bunny Storage sends none for HTML: 60 seconds for a page, 30 days for anything else, and whatever `_headers` says. `sites create` turns the pull zone's own cache override off so that answer reaches the visitor, and `sites upgrade-router` does it for a site made by an earlier CLI. + +A published `deploy` asks the live site for a path it cannot hold, and reports it when the answer is not the deploy's own 404 page. + ## Deploy IDs - The deploy ID is the **git short-sha** when the working tree is clean, otherwise an 8-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides).