diff --git a/.changeset/html-content-type-case-insensitive.md b/.changeset/html-content-type-case-insensitive.md new file mode 100644 index 0000000..14ed4cd --- /dev/null +++ b/.changeset/html-content-type-case-insensitive.md @@ -0,0 +1,11 @@ +--- +"@dualmark/cloudflare": patch +"@dualmark/deno": patch +"@dualmark/fastly": patch +"@dualmark/netlify": patch +"@dualmark/vercel": patch +--- + +Match the HTML `Content-Type` case-insensitively when deciding to inject `Vary: Accept` and the `Link` alternate header. + +HTTP media types are case-insensitive (RFC 7231 §3.1.1.1), but the cloudflare, deno, fastly, netlify and vercel adapters gated markdown-twin advertisement on a case-sensitive `contentType.includes("text/html")`. An upstream that emitted `Content-Type: Text/HTML` (or any non-lowercase spelling) skipped the block, so the negotiable HTML response was returned without `Vary: Accept` — risking a shared cache serving HTML to a markdown client — and without the `Link rel="alternate"` twin. The check now lowercases the header first, matching the astro, nuxt and sveltekit adapters which already did this. diff --git a/packages/cloudflare/src/worker.ts b/packages/cloudflare/src/worker.ts index 63297b4..5708541 100644 --- a/packages/cloudflare/src/worker.ts +++ b/packages/cloudflare/src/worker.ts @@ -284,7 +284,7 @@ export function createAEOWorker( if ( !shouldSkip(pathname, skipPrefixes, skipExtensions) && !pathname.endsWith(".md") && - upstreamResponse.headers.get("content-type")?.includes("text/html") + upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html") ) { const newHeaders = new Headers(upstreamResponse.headers); const vary = newHeaders.get("Vary"); diff --git a/packages/cloudflare/test/worker.test.ts b/packages/cloudflare/test/worker.test.ts index e0812ca..d448ead 100644 --- a/packages/cloudflare/test/worker.test.ts +++ b/packages/cloudflare/test/worker.test.ts @@ -479,4 +479,22 @@ describe("createAEOWorker — Link header injection", () => { expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); expect(res.headers.get("link")).toBeNull(); }); + + it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => { + const env: TestEnv = { ASSETS: makeAssets({}) }; + const worker = createAEOWorker({ + upstream: makeUpstream( + () => new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } }), + ), + }); + const req = new Request("https://acme.test/page", { + headers: { + "user-agent": "Mozilla/5.0 Chrome/130", + accept: "text/html,*/*;q=0.8", + }, + }); + const res = await worker.fetch(req, env, makeCtx()); + expect(res.headers.get("link")).toContain('rel="alternate"'); + expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); + }); }); diff --git a/packages/deno/src/handler.ts b/packages/deno/src/handler.ts index 403f5d3..73fb3fa 100644 --- a/packages/deno/src/handler.ts +++ b/packages/deno/src/handler.ts @@ -275,7 +275,7 @@ export function createAEOHandler(options: CreateAEOHandlerOptions): AEODenoHandl if ( !shouldSkip(pathname, skipPrefixes, skipExtensions) && !pathname.endsWith(".md") && - upstreamResponse.headers.get("content-type")?.includes("text/html") + upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html") ) { const newHeaders = new Headers(upstreamResponse.headers); const vary = newHeaders.get("Vary"); diff --git a/packages/deno/test/handler.test.ts b/packages/deno/test/handler.test.ts index 85ddd20..4c5b93c 100644 --- a/packages/deno/test/handler.test.ts +++ b/packages/deno/test/handler.test.ts @@ -292,6 +292,15 @@ describe("createAEOHandler — Link header injection", () => { const res = await handler(new Request("https://acme.test/data"), makeInfo()); expect(res.headers.get("link")).toBeNull(); }); + + it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => { + const upstream: DenoUpstreamHandler = async () => + new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } }); + const handler = createAEOHandler({ upstream }); + const res = await handler(new Request("https://acme.test/page"), makeInfo()); + expect(res.headers.get("link")).toContain('rel="alternate"'); + expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); + }); }); describe("createAEOHandler — direct .md handling", () => { diff --git a/packages/fastly/src/handler.ts b/packages/fastly/src/handler.ts index 68b2fdc..04828e7 100644 --- a/packages/fastly/src/handler.ts +++ b/packages/fastly/src/handler.ts @@ -274,7 +274,7 @@ export function createAEORequestHandler(options: CreateAEOFastlyOptions): AEOFas if ( !shouldSkip(pathname, skipPrefixes, skipExtensions) && !pathname.endsWith(".md") && - upstreamResponse.headers.get("content-type")?.includes("text/html") + upstreamResponse.headers.get("content-type")?.toLowerCase().includes("text/html") ) { const newHeaders = new Headers(upstreamResponse.headers); const vary = newHeaders.get("Vary"); diff --git a/packages/fastly/test/handler.test.ts b/packages/fastly/test/handler.test.ts index 432d109..bc56dbf 100644 --- a/packages/fastly/test/handler.test.ts +++ b/packages/fastly/test/handler.test.ts @@ -158,6 +158,30 @@ describe("Fastly Adapter", () => { expect(fetch).toHaveBeenCalledWith(req, { backend: "default_backend" }); }); + it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + new Response("", { + status: 200, + headers: { "Content-Type": "Text/HTML; charset=UTF-8" }, + }), + ), + ); + const handler = createAEORequestHandler({ backend: "default_backend" }); + const req = new Request("https://acme.test/page", { + headers: { + "user-agent": "Mozilla/5.0 Chrome/130", + accept: "text/html,*/*;q=0.8", + }, + }); + const res = await handler(req); + expect(res.status).toBe(200); + expect(res.headers.get("link")).toContain('rel="alternate"'); + expect(res.headers.get("vary")).toContain("Accept"); + }); + it("handles missing .md (cache miss) for bot — falls to upstream HTML", async () => { const handler = createAEORequestHandler({ backend: "default_backend", diff --git a/packages/netlify/src/worker.ts b/packages/netlify/src/worker.ts index 40b6b01..2b21a82 100644 --- a/packages/netlify/src/worker.ts +++ b/packages/netlify/src/worker.ts @@ -269,7 +269,7 @@ export function createAEOWorker( if ( !shouldSkip(pathname, skipPrefixes, skipExtensions) && !pathname.endsWith(".md") && - originResponse.headers.get("content-type")?.includes("text/html") + originResponse.headers.get("content-type")?.toLowerCase().includes("text/html") ) { const newHeaders = new Headers(originResponse.headers); const vary = newHeaders.get("Vary"); diff --git a/packages/netlify/test/worker.test.ts b/packages/netlify/test/worker.test.ts index 25e6661..4124629 100644 --- a/packages/netlify/test/worker.test.ts +++ b/packages/netlify/test/worker.test.ts @@ -493,4 +493,23 @@ describe("createAEOWorker — default options", () => { expect(res.status).toBe(301); expect(res.headers.get("location")).toBe("https://acme.test/blog?ref=twitter"); }); + + it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => { + const assets = makeAssets({}); + const worker = createAEOWorker({ assets }); + const req = new Request("https://acme.test/page", { + headers: { + "user-agent": "Mozilla/5.0 Chrome/130", + accept: "text/html,*/*;q=0.8", + }, + }); + const res = await worker( + req, + makeContext( + () => new Response("", { headers: { "Content-Type": "Text/HTML; charset=UTF-8" } }), + ), + ); + expect(res.headers.get("link")).toContain('rel="alternate"'); + expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); + }); }); diff --git a/packages/vercel/src/middleware.ts b/packages/vercel/src/middleware.ts index 7888e18..07e93c9 100644 --- a/packages/vercel/src/middleware.ts +++ b/packages/vercel/src/middleware.ts @@ -295,7 +295,7 @@ export function createAEOMiddleware( const ct = upstreamResponse.headers.get("content-type"); // Passthrough responses (e.g. NextResponse.next()) have no content-type yet — // always inject. For concrete responses, only inject on text/html. - if (!ct || ct.includes("text/html")) { + if (!ct || ct.toLowerCase().includes("text/html")) { const mdPath = toMarkdownPath(pathname); try { // Fast path: mutate headers in-place (works for NextResponse.next() and diff --git a/packages/vercel/test/middleware.test.ts b/packages/vercel/test/middleware.test.ts index f9bc875..afdc359 100644 --- a/packages/vercel/test/middleware.test.ts +++ b/packages/vercel/test/middleware.test.ts @@ -446,6 +446,27 @@ describe("createAEOMiddleware — Link header injection", () => { expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); expect(res.headers.get("link")).toBeNull(); }); + + it("injects Link and Vary on HTML with a mixed-case Content-Type", async () => { + const middleware = createAEOMiddleware({ + upstream: makeUpstream( + () => + new Response("", { + headers: { "Content-Type": "Text/HTML; charset=UTF-8" }, + }), + ), + fetchAsset, + }); + const req = new Request("https://acme.test/page", { + headers: { + "user-agent": "Mozilla/5.0 Chrome/130", + accept: "text/html,*/*;q=0.8", + }, + }); + const res = await middleware(req, makeCtx()); + expect(res.headers.get("link")).toContain('rel="alternate"'); + expect((res.headers.get("vary") ?? "").toLowerCase()).toContain("accept"); + }); }); describe("createAEOMiddleware — edge cases", () => {