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

Filter by extension

Filter by extension

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

fix(sites): redirect extensionless directory paths to their slash URL in the router
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions packages/cli/src/commands/sites/router/source.test.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand All @@ -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(
Expand Down
31 changes: 28 additions & 3 deletions packages/cli/src/commands/sites/router/source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = \`<!doctype html>
Expand All @@ -14,6 +15,14 @@ const NO_DEPLOYS_PAGE = \`<!doctype html>
<p>This site has no published deploys. Run <code>bunny sites deploy</code> to publish one.</p>
</body></html>\`;

// 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;
Expand Down Expand Up @@ -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/")) {
Expand All @@ -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")) {
Expand Down