diff --git a/.changeset/sites-router-extensionless-index.md b/.changeset/sites-router-extensionless-index.md new file mode 100644 index 00000000..9bd48009 --- /dev/null +++ b/.changeset/sites-router-extensionless-index.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): redirect extensionless directory paths to their slash URL in the router diff --git a/AGENTS.md b/AGENTS.md index ecb9bfdf..a8c25c40 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -387,7 +387,7 @@ bunny-cli/ │ │ │ ├── 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: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview headers are stripped; the flag is router-internal), trailing-slash → index.html. onOriginResponse: HTMLRewriter rewrites root-absolute href/src/srcset in flagged path-preview HTML → /deploys/{id}/… (so Jekyll/SSG assets render on one PZ; each deploy's assets get a unique cache key), and X-Robots-Tag: noindex on all previews. Production HTML is never rewritten (no header), so promote doesn't churn its cache +│ │ │ ├── router/source.ts # routerSource: the middleware Edge Script (one script per site; no version tracking; upgrade-router just republishes the latest). apex → CURRENT_DEPLOY, dpl-{id}.preview.{domain} → that deploy, /deploys/{id}/ passthrough (path preview) flagged with x-bunny-preview header, /_bunny/* → 403 (client-sent x-bunny-preview/x-bunny-index-retry headers are stripped; the flags are 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 in production and previews with the right relative-URL base, while exact extensionless objects and dotted directories stay reachable). onOriginResponse: HTMLRewriter rewrites root-absolute href/src/srcset in flagged path-preview HTML → /deploys/{id}/… (so Jekyll/SSG assets render on one PZ; each deploy's assets get a unique cache key), and X-Robots-Tag: noindex on all previews. Production HTML is never rewritten (no header), so promote doesn't churn its cache │ │ │ ├── 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 diff --git a/packages/cli/src/commands/sites/router/source.test.ts b/packages/cli/src/commands/sites/router/source.test.ts index 0920bb75..7e91dc0a 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -1,6 +1,25 @@ import { expect, test } from "bun:test"; import { routerSource } from "./source.ts"; +// Extracts a top-level function from the generated script and evaluates it, so tests run the shipped code rather than a mirror of it. +function extractFn(name: string): (...args: unknown[]) => unknown { + const match = routerSource.match( + new RegExp(`function ${name}\\([^]*?\\n\\}`), + ); + if (!match) throw new Error(`function ${name} not found in routerSource`); + return new Function(`return (${match[0]});`)() as ( + ...args: unknown[] + ) => unknown; +} + +const withDeploy = extractFn("withDeploy") as ( + id: string, + value: string, +) => string; +const indexRetryUrl = extractFn("indexRetryUrl") as ( + rawUrl: string, +) => string | null; + test("routerSource wires up the preview machinery", () => { const src = routerSource; expect(src).toContain("bunny sites router"); @@ -13,18 +32,36 @@ test("routerSource wires up the preview machinery", () => { ); // Flags previews so the response phase rewrites their HTML (and never production's). expect(src).toContain('const PREVIEW_HEADER = "x-bunny-preview";'); - // Client-sent preview flags must be stripped, or they'd poison cached production HTML. + // Slashless 404s probe the directory index and redirect to the slash URL, after the exact lookup misses. + expect(src).toContain('const RETRY_HEADER = "x-bunny-index-retry";'); + expect(src).toContain("if (retry && response.status === 404)"); + expect(src).toContain("{ status: 301, headers: { Location: retry } }"); + // Client-sent flags must be stripped, or they'd poison cached HTML. expect(src).toContain("headers.delete(PREVIEW_HEADER);"); + expect(src).toContain("headers.delete(RETRY_HEADER);"); expect(src).toContain("new HTMLRewriter()"); expect(src).toContain("X-Robots-Tag"); }); -// Mirrors the router's withDeploy() so a regression in the rewrite rule is caught here. -function withDeploy(id: string, value: string): string { - if (!value.startsWith("/") || value.startsWith("//")) return value; - if (value.startsWith("/deploys/")) return value; - return `/deploys/${id}${value}`; -} +test("indexRetryUrl targets the directory index for slashless paths only", () => { + expect(indexRetryUrl("https://x.b-cdn.net/blog")).toBe( + "https://x.b-cdn.net/blog/", + ); + // No dot heuristic: dotted segments retry too, so dotted directories stay reachable. + expect(indexRetryUrl("https://x.b-cdn.net/v2.1/docs")).toBe( + "https://x.b-cdn.net/v2.1/docs/", + ); + expect(indexRetryUrl("https://x.b-cdn.net/deploys/abcd/blog")).toBe( + "https://x.b-cdn.net/deploys/abcd/blog/", + ); + // Query strings survive the retry. + expect(indexRetryUrl("https://x.b-cdn.net/blog?page=2")).toBe( + "https://x.b-cdn.net/blog/?page=2", + ); + // Directory and root URLs already expand to an index; no retry. + expect(indexRetryUrl("https://x.b-cdn.net/blog/")).toBeNull(); + expect(indexRetryUrl("https://x.b-cdn.net/")).toBeNull(); +}); test("withDeploy prefixes only root-absolute, un-prefixed paths", () => { expect(withDeploy("abcd", "/assets/main.css")).toBe( diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 8436540c..3ac6e1b7 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -5,6 +5,7 @@ import * as BunnySDK from "@bunny.net/edgescript-sdk"; const PREVIEW_HOST = /^dpl-([a-z0-9]{4,40})\\.preview\\./i; const PREVIEW_HEADER = "x-bunny-preview"; +const RETRY_HEADER = "x-bunny-index-retry"; const DEPLOY_PATH = /^\\/deploys\\/([a-z0-9]{4,40})\\//i; const NO_DEPLOYS_PAGE = \` @@ -14,6 +15,14 @@ const NO_DEPLOYS_PAGE = \`

This site has no published deploys. Run bunny sites deploy to publish one.

\`; +// A slashless URL's directory-index retry target (/blog -> /blog/); null when the path already ends with a slash. +function indexRetryUrl(rawUrl) { + const u = new URL(rawUrl); + if (u.pathname.endsWith("/")) return null; + u.pathname += "/"; + return u.toString(); +} + // Prefix root-absolute, un-prefixed paths with the deploy dir; leave everything else alone. function withDeploy(id, value) { if (!value || value[0] !== "/" || value[1] === "/") return value; @@ -61,9 +70,16 @@ BunnySDK.net.http return new Response("Forbidden", { status: 403 }); } - // The preview flag is router-internal: a client-sent one is stripped, or it would poison cached production HTML with preview rewrites. + // Both flags are router-internal: client-sent copies are stripped, or they'd poison cached HTML. const headers = new Headers(ctx.request.headers); headers.delete(PREVIEW_HEADER); + 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); + if (retry) headers.set(RETRY_HEADER, retry); + } // Path preview: serve the deploy dir directly, flagging the request so the response phase rewrites its HTML. if (path === "/deploys" || path.startsWith("/deploys/")) { @@ -88,12 +104,21 @@ BunnySDK.net.http return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { + let response = ctx.response; + + // 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 && response.status === 404) { + const probe = await fetch(retry, { method: "HEAD" }); + if (probe.ok) { + return new Response(null, { status: 301, headers: { Location: retry } }); + } + } + const previewId = ctx.request.headers.get(PREVIEW_HEADER); const isCustomPreview = PREVIEW_HOST.test(new URL(ctx.request.url).hostname); if (!previewId && !isCustomPreview) return; - let response = ctx.response; - // Path previews serve under a subpath: rewrite root-absolute asset refs into the deploy. const contentType = response.headers.get("content-type") || ""; if (previewId && contentType.includes("text/html")) {