From c8863e3053b3fd8740d28646ddd8d902e6937f03 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 15:23:58 +0100 Subject: [PATCH 1/4] fix(sites): serve /dir/index.html for extensionless paths in the router --- AGENTS.md | 2 +- .../src/commands/sites/router/source.test.ts | 28 +++++++++++++++++-- .../cli/src/commands/sites/router/source.ts | 8 ++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ecb9bfd..7ca3fc0 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 headers are stripped; the flag is router-internal), trailing-slash and extensionless paths → index.html (so /blog serves /blog/index.html in production and previews alike). 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 0920bb7..b865503 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -8,9 +8,9 @@ test("routerSource wires up the preview machinery", () => { expect(src).toContain("process.env.CURRENT_DEPLOY"); expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); // Directory URLs expand to index.html before any branching, so path previews get it too. - expect(src).toContain( - 'if (url.pathname.endsWith("/")) url.pathname += "index.html";', - ); + expect(src).toContain('url.pathname += "index.html";'); + // Extensionless URLs (/blog) expand too; SSG directory output has no /blog object, only /blog/index.html. + expect(src).toContain('url.pathname += "/index.html";'); // 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. @@ -26,6 +26,28 @@ function withDeploy(id: string, value: string): string { return `/deploys/${id}${value}`; } +// Mirrors the router's index expansion so a regression in the rule is caught here. +function expandIndex(pathname: string): string { + if (pathname.endsWith("/")) return pathname + "index.html"; + const last = pathname.slice(pathname.lastIndexOf("/") + 1); + return last.includes(".") ? pathname : pathname + "/index.html"; +} + +test("expandIndex resolves directory and extensionless paths to index.html", () => { + expect(expandIndex("/")).toBe("/index.html"); + expect(expandIndex("/blog/")).toBe("/blog/index.html"); + expect(expandIndex("/blog")).toBe("/blog/index.html"); + // Only the last segment decides: a dotted parent dir still expands. + expect(expandIndex("/v2.1/docs")).toBe("/v2.1/docs/index.html"); + // Files with extensions pass through untouched. + expect(expandIndex("/assets/main.css")).toBe("/assets/main.css"); + expect(expandIndex("/blog/post.html")).toBe("/blog/post.html"); + // Rewritten preview links expand into the deploy dir. + expect(expandIndex("/deploys/abcd/blog")).toBe( + "/deploys/abcd/blog/index.html", + ); +}); + test("withDeploy prefixes only root-absolute, un-prefixed paths", () => { expect(withDeploy("abcd", "/assets/main.css")).toBe( "/deploys/abcd/assets/main.css", diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index 8436540..cee9790 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -52,8 +52,12 @@ BunnySDK.net.http .servePullZone() .onOriginRequest(async (ctx) => { const url = new URL(ctx.request.url); - // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route, path previews included. - if (url.pathname.endsWith("/")) url.pathname += "index.html"; + // Storage serves no directory indexes: expand \`/dir/\` and extensionless \`/dir\` to \`/dir/index.html\` on every route, path previews included. + if (url.pathname.endsWith("/")) { + url.pathname += "index.html"; + } else if (!url.pathname.slice(url.pathname.lastIndexOf("/") + 1).includes(".")) { + url.pathname += "/index.html"; + } const path = url.pathname; // Internal site metadata (state, env) is never served. From 0c73fb57e5b57d5b92cf731d09db8585bbd75cfd Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 15:24:50 +0100 Subject: [PATCH 2/4] changeset --- .changeset/sites-router-extensionless-index.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sites-router-extensionless-index.md diff --git a/.changeset/sites-router-extensionless-index.md b/.changeset/sites-router-extensionless-index.md new file mode 100644 index 0000000..8a5118f --- /dev/null +++ b/.changeset/sites-router-extensionless-index.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +fix(sites): serve /dir/index.html for extensionless paths in the router From 61648f008908176a67f55b65f0418759f8bc88d2 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 15:51:06 +0100 Subject: [PATCH 3/4] tweak router for indexes on previews --- AGENTS.md | 2 +- .../src/commands/sites/router/source.test.ts | 74 +++++++++++-------- .../cli/src/commands/sites/router/source.ts | 37 +++++++--- 3 files changed, 73 insertions(+), 40 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7ca3fc0..8c39086 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 and extensionless paths → index.html (so /blog serves /blog/index.html in production and previews alike). 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 retries once as its directory index (re-entrant fetch of the URL + "/", so /blog serves /blog/index.html in production and previews 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 b865503..79e9c6e 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"); @@ -8,44 +27,39 @@ test("routerSource wires up the preview machinery", () => { expect(src).toContain("process.env.CURRENT_DEPLOY"); expect(src).toContain('url.pathname = "/deploys/" + deploy + path;'); // Directory URLs expand to index.html before any branching, so path previews get it too. - expect(src).toContain('url.pathname += "index.html";'); - // Extensionless URLs (/blog) expand too; SSG directory output has no /blog object, only /blog/index.html. - expect(src).toContain('url.pathname += "/index.html";'); + expect(src).toContain( + 'if (url.pathname.endsWith("/")) url.pathname += "index.html";', + ); // 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 retry once as a directory index, after the exact lookup misses. + expect(src).toContain('const RETRY_HEADER = "x-bunny-index-retry";'); + expect(src).toContain("if (retry && response.status === 404)"); + // 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}`; -} - -// Mirrors the router's index expansion so a regression in the rule is caught here. -function expandIndex(pathname: string): string { - if (pathname.endsWith("/")) return pathname + "index.html"; - const last = pathname.slice(pathname.lastIndexOf("/") + 1); - return last.includes(".") ? pathname : pathname + "/index.html"; -} - -test("expandIndex resolves directory and extensionless paths to index.html", () => { - expect(expandIndex("/")).toBe("/index.html"); - expect(expandIndex("/blog/")).toBe("/blog/index.html"); - expect(expandIndex("/blog")).toBe("/blog/index.html"); - // Only the last segment decides: a dotted parent dir still expands. - expect(expandIndex("/v2.1/docs")).toBe("/v2.1/docs/index.html"); - // Files with extensions pass through untouched. - expect(expandIndex("/assets/main.css")).toBe("/assets/main.css"); - expect(expandIndex("/blog/post.html")).toBe("/blog/post.html"); - // Rewritten preview links expand into the deploy dir. - expect(expandIndex("/deploys/abcd/blog")).toBe( - "/deploys/abcd/blog/index.html", +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", () => { diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index cee9790..c32b24c 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; @@ -52,12 +61,8 @@ BunnySDK.net.http .servePullZone() .onOriginRequest(async (ctx) => { const url = new URL(ctx.request.url); - // Storage serves no directory indexes: expand \`/dir/\` and extensionless \`/dir\` to \`/dir/index.html\` on every route, path previews included. - if (url.pathname.endsWith("/")) { - url.pathname += "index.html"; - } else if (!url.pathname.slice(url.pathname.lastIndexOf("/") + 1).includes(".")) { - url.pathname += "/index.html"; - } + // Storage serves no directory indexes: expand \`/dir/\` to \`/dir/index.html\` on every route, path previews included. + if (url.pathname.endsWith("/")) url.pathname += "index.html"; const path = url.pathname; // Internal site metadata (state, env) is never served. @@ -65,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/")) { @@ -92,12 +104,19 @@ BunnySDK.net.http return new Request(new Request(url.toString(), ctx.request), { headers }); }) .onOriginResponse(async (ctx) => { + let response = ctx.response; + + // A flagged 404 retries once as its directory index so extensionless routes (/blog) resolve: the re-entrant fetch runs this router again with production/preview semantics intact, and its trailing slash means it can never retry further. + const retry = ctx.request.headers.get(RETRY_HEADER); + if (retry && response.status === 404) { + const fallback = await fetch(retry, { method: ctx.request.method }); + if (fallback.ok) return fallback; + } + 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")) { From 3179860b343792c390a1905cf68a5f0a7293cd69 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Fri, 31 Jul 2026 16:01:30 +0100 Subject: [PATCH 4/4] fix directory paths --- .changeset/sites-router-extensionless-index.md | 2 +- AGENTS.md | 2 +- packages/cli/src/commands/sites/router/source.test.ts | 3 ++- packages/cli/src/commands/sites/router/source.ts | 8 +++++--- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.changeset/sites-router-extensionless-index.md b/.changeset/sites-router-extensionless-index.md index 8a5118f..9bd4800 100644 --- a/.changeset/sites-router-extensionless-index.md +++ b/.changeset/sites-router-extensionless-index.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -fix(sites): serve /dir/index.html for extensionless paths in the router +fix(sites): redirect extensionless directory paths to their slash URL in the router diff --git a/AGENTS.md b/AGENTS.md index 8c39086..a8c25c4 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/x-bunny-index-retry headers are stripped; the flags are router-internal), trailing-slash → index.html, and a slashless GET/HEAD 404 retries once as its directory index (re-entrant fetch of the URL + "/", so /blog serves /blog/index.html in production and previews 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 +│ │ │ ├── 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 79e9c6e..7e91dc0 100644 --- a/packages/cli/src/commands/sites/router/source.test.ts +++ b/packages/cli/src/commands/sites/router/source.test.ts @@ -32,9 +32,10 @@ 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";'); - // Slashless 404s retry once as a directory index, after the exact lookup misses. + // 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);"); diff --git a/packages/cli/src/commands/sites/router/source.ts b/packages/cli/src/commands/sites/router/source.ts index c32b24c..3ac6e1b 100644 --- a/packages/cli/src/commands/sites/router/source.ts +++ b/packages/cli/src/commands/sites/router/source.ts @@ -106,11 +106,13 @@ BunnySDK.net.http .onOriginResponse(async (ctx) => { let response = ctx.response; - // A flagged 404 retries once as its directory index so extensionless routes (/blog) resolve: the re-entrant fetch runs this router again with production/preview semantics intact, and its trailing slash means it can never retry further. + // 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 fallback = await fetch(retry, { method: ctx.request.method }); - if (fallback.ok) return fallback; + 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);