From 397b96673c127ba8913223fd67639fda3ae0ef5a Mon Sep 17 00:00:00 2001 From: abhay-codes07 Date: Wed, 1 Jul 2026 08:25:27 +0530 Subject: [PATCH] fix: match skip prefixes on a path boundary cloudflare, netlify and vercel tested skip prefixes with a bare startsWith, so with the default ["/admin", "/api/", "/_"] a real page like /administrator matched /admin and was silently excluded from negotiation, its markdown twin, and the Link header. Use the same boundary-aware check as the deno and fastly adapters: match the exact path or a prefix/ subpath (a prefix already ending in / still matches via startsWith). --- .changeset/skip-prefix-boundary.md | 9 +++++++++ packages/cloudflare/src/worker.ts | 7 ++++++- packages/cloudflare/test/worker.test.ts | 14 ++++++++++++++ packages/netlify/src/worker.ts | 7 ++++++- packages/netlify/test/worker.test.ts | 13 +++++++++++++ packages/vercel/src/middleware.ts | 7 ++++++- packages/vercel/test/middleware.test.ts | 16 ++++++++++++++++ 7 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 .changeset/skip-prefix-boundary.md diff --git a/.changeset/skip-prefix-boundary.md b/.changeset/skip-prefix-boundary.md new file mode 100644 index 0000000..f60b797 --- /dev/null +++ b/.changeset/skip-prefix-boundary.md @@ -0,0 +1,9 @@ +--- +"@dualmark/cloudflare": patch +"@dualmark/netlify": patch +"@dualmark/vercel": patch +--- + +Match skip prefixes on a path boundary so a page sharing a prefix is not skipped. + +The cloudflare, netlify and vercel adapters tested skip prefixes with a bare `startsWith`, so with the default `["/admin", "/api/", "/_"]` a real page like `/administrator` (or `/admins`) matched `/admin` and was silently excluded from content negotiation, its markdown twin, and the Link header. They now use the same boundary-aware check as the deno and fastly adapters: a prefix matches only the exact path or a `prefix/` subpath (a prefix that already ends in `/` still matches by `startsWith`). Asset paths under `/_` remain covered by the extension list. diff --git a/packages/cloudflare/src/worker.ts b/packages/cloudflare/src/worker.ts index b263e3e..16fece6 100644 --- a/packages/cloudflare/src/worker.ts +++ b/packages/cloudflare/src/worker.ts @@ -40,7 +40,12 @@ function shouldSkip( extensions: ReadonlyArray, ): boolean { if (extensions.some((ext) => pathname.endsWith(ext))) return true; - return prefixes.some((p) => pathname.startsWith(p)); + return prefixes.some((p) => { + if (pathname === p) return true; + if (p.endsWith("/")) return pathname.startsWith(p); + // Match on a path boundary so "/admin" does not also skip "/administrator". + return pathname.startsWith(p + "/"); + }); } function normalizePath(pathname: string): string { diff --git a/packages/cloudflare/test/worker.test.ts b/packages/cloudflare/test/worker.test.ts index 946193f..b450d5b 100644 --- a/packages/cloudflare/test/worker.test.ts +++ b/packages/cloudflare/test/worker.test.ts @@ -83,6 +83,20 @@ describe("createAEOWorker — markdown serving", () => { expect(await res.text()).toBe("# Post 1\n\nBody."); }); + it("does not skip a path that merely shares a skip prefix (/administrator)", async () => { + const worker = createAEOWorker({ + upstream: makeUpstream(() => new Response("html", { headers: { "Content-Type": "text/html" } })), + }); + const env: TestEnv = { ASSETS: makeAssets({ "/administrator.md": "# Admin Guide" }) }; + const req = new Request("https://acme.test/administrator", { + headers: { "user-agent": "GPTBot/1.0" }, + }); + const res = await worker.fetch(req, env, makeCtx()); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + expect(await res.text()).toBe("# Admin Guide"); + }); + it("serves markdown when Accept: text/markdown (no bot UA)", async () => { const worker = createAEOWorker({ upstream: makeUpstream(() => new Response("html", { headers: { "Content-Type": "text/html" } })), diff --git a/packages/netlify/src/worker.ts b/packages/netlify/src/worker.ts index 9defb27..79bed89 100644 --- a/packages/netlify/src/worker.ts +++ b/packages/netlify/src/worker.ts @@ -47,7 +47,12 @@ function shouldSkip( extensions: ReadonlyArray, ): boolean { if (extensions.some((ext) => pathname.endsWith(ext))) return true; - return prefixes.some((p) => pathname.startsWith(p)); + return prefixes.some((p) => { + if (pathname === p) return true; + if (p.endsWith("/")) return pathname.startsWith(p); + // Match on a path boundary so "/admin" does not also skip "/administrator". + return pathname.startsWith(p + "/"); + }); } function normalizePath(pathname: string): string { diff --git a/packages/netlify/test/worker.test.ts b/packages/netlify/test/worker.test.ts index 0277536..5a442ae 100644 --- a/packages/netlify/test/worker.test.ts +++ b/packages/netlify/test/worker.test.ts @@ -62,6 +62,19 @@ describe("createAEOWorker — markdown serving", () => { expect(await res.text()).toBe("# Post 1\n\nBody."); }); + it("does not skip a path that merely shares a skip prefix (/administrator)", async () => { + const worker = createAEOWorker({ + assets: makeAssets({ "/administrator.md": "# Admin Guide" }), + }); + const req = new Request("https://acme.test/administrator", { + headers: { "user-agent": "GPTBot/1.0" }, + }); + const res = await worker(req, makeContext()); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + expect(await res.text()).toBe("# Admin Guide"); + }); + it("serves markdown when Accept: text/markdown (no bot UA)", async () => { const worker = createAEOWorker({ assets }); const req = new Request("https://acme.test/blog/post-1", { diff --git a/packages/vercel/src/middleware.ts b/packages/vercel/src/middleware.ts index 2e949a9..5da0343 100644 --- a/packages/vercel/src/middleware.ts +++ b/packages/vercel/src/middleware.ts @@ -50,7 +50,12 @@ function shouldSkip( extensions: ReadonlyArray, ): boolean { if (extensions.some((ext) => pathname.endsWith(ext))) return true; - return prefixes.some((p) => pathname.startsWith(p)); + return prefixes.some((p) => { + if (pathname === p) return true; + if (p.endsWith("/")) return pathname.startsWith(p); + // Match on a path boundary so "/admin" does not also skip "/administrator". + return pathname.startsWith(p + "/"); + }); } function normalizePath(pathname: string): string { diff --git a/packages/vercel/test/middleware.test.ts b/packages/vercel/test/middleware.test.ts index f91d33d..d793d49 100644 --- a/packages/vercel/test/middleware.test.ts +++ b/packages/vercel/test/middleware.test.ts @@ -53,6 +53,22 @@ describe("createAEOMiddleware — markdown serving", () => { expect(await res.text()).toBe("# Post 1\n\nBody."); }); + it("does not skip a path that merely shares a skip prefix (/administrator)", async () => { + const middleware = createAEOMiddleware({ + upstream: makeUpstream( + () => new Response("html", { headers: { "Content-Type": "text/html" } }), + ), + fetchAsset: makeAssets({ "/administrator.md": "# Admin Guide" }), + }); + const req = new Request("https://acme.test/administrator", { + headers: { "user-agent": "GPTBot/1.0" }, + }); + const res = await middleware(req, makeCtx()); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("text/markdown; charset=utf-8"); + expect(await res.text()).toBe("# Admin Guide"); + }); + it("serves markdown when Accept: text/markdown (no bot UA)", async () => { const middleware = createAEOMiddleware({ upstream: makeUpstream(