A cacheable raw Response is shaped by a request header nothing keys on #3094

Description

@frenzzy

Describe the bug

A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

// raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

Steps to reproduce

mkdir sf-vary &&cd sf-vary && npm init -y
npm i @solidjs/web@2.0.0-rc.4
node repro.mjs

repro.mjs:

import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
}=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

Output on 2.0.0-rc.4:

scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"

No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

Expected behavior

One url, one answer — or an answer that cannot be cached.

Options

  1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

    Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

    It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

  2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

  3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

  4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

  5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

(2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

Why Vary is the weak option

Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

A neighbour, not the same bug

throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

Environment

@solidjs/web2.0.0-rc.4 (published), and next
Nodev24.19.0
OSmacOS (darwin 25.6.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
       blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
      }
      } catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
      })();
      (function(){
      try {
      var __m = "github.com";
      var __re = new RegExp('^' + "github\\.com" + '
      
      Skip to content

      A cacheable raw Response is shaped by a request header nothing keys on #3094

      Description

      @frenzzy

      Describe the bug

      A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

      The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

      // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

      With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

      What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

      This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

      Steps to reproduce

      mkdir sf-vary &&cd sf-vary && npm init -y
      npm i @solidjs/web@2.0.0-rc.4
      node repro.mjs

      repro.mjs:

      import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
      }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
      ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

      Output on 2.0.0-rc.4:

      scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
      unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
      

      No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

      Expected behavior

      One url, one answer — or an answer that cannot be cached.

      Options

      1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

        Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

        It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

      2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

      3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

      4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

      5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

      (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

      Why Vary is the weak option

      Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

      Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

      There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

      Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

      A neighbour, not the same bug

      throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

      Environment

      @solidjs/web2.0.0-rc.4 (published), and next
      Nodev24.19.0
      OSmacOS (darwin 25.6.0)

      Metadata

      Metadata

      Assignees

      No one assigned

        Labels

        No labels
        No labels

        Type

        No type

        Projects

        No projects

          Milestone

          No milestone

          Relationships

          None yet

          Development

          No branches or pull requests

          Issue actions

          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
          Skip to content

          A cacheable raw Response is shaped by a request header nothing keys on #3094

          Description

          @frenzzy

          Describe the bug

          A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

          The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

          // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

          With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

          What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

          This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

          Steps to reproduce

          mkdir sf-vary &&cd sf-vary && npm init -y
          npm i @solidjs/web@2.0.0-rc.4
          node repro.mjs

          repro.mjs:

          import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
          }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
          ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

          Output on 2.0.0-rc.4:

          scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
          unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
          

          No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

          Expected behavior

          One url, one answer — or an answer that cannot be cached.

          Options

          1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

            Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

            It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

          2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

          3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

          4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

          5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

          (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

          Why Vary is the weak option

          Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

          Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

          There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

          Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

          A neighbour, not the same bug

          throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

          Environment

          @solidjs/web2.0.0-rc.4 (published), and next
          Nodev24.19.0
          OSmacOS (darwin 25.6.0)

          Metadata

          Metadata

          Assignees

          No one assigned

            Labels

            No labels
            No labels

            Type

            No type

            Projects

            No projects

              Milestone

              No milestone

              Relationships

              None yet

              Development

              No branches or pull requests

              Issue actions

              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
              Skip to content

              A cacheable raw Response is shaped by a request header nothing keys on #3094

              Description

              @frenzzy

              Describe the bug

              A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

              The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

              // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

              With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

              What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

              This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

              Steps to reproduce

              mkdir sf-vary &&cd sf-vary && npm init -y
              npm i @solidjs/web@2.0.0-rc.4
              node repro.mjs

              repro.mjs:

              import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
              }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
              ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

              Output on 2.0.0-rc.4:

              scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
              unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
              

              No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

              Expected behavior

              One url, one answer — or an answer that cannot be cached.

              Options

              1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

                Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

                It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

              2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

              3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

              4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

              5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

              (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

              Why Vary is the weak option

              Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

              Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

              There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

              Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

              A neighbour, not the same bug

              throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

              Environment

              @solidjs/web2.0.0-rc.4 (published), and next
              Nodev24.19.0
              OSmacOS (darwin 25.6.0)

              Metadata

              Metadata

              Assignees

              No one assigned

                Labels

                No labels
                No labels

                Type

                No type

                Projects

                No projects

                  Milestone

                  No milestone

                  Relationships

                  None yet

                  Development

                  No branches or pull requests

                  Issue actions

                  , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
                  Skip to content

                  A cacheable raw Response is shaped by a request header nothing keys on #3094

                  Description

                  @frenzzy

                  Describe the bug

                  A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

                  The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

                  // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

                  With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

                  What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

                  This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

                  Steps to reproduce

                  mkdir sf-vary &&cd sf-vary && npm init -y
                  npm i @solidjs/web@2.0.0-rc.4
                  node repro.mjs

                  repro.mjs:

                  import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
                  }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
                  ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

                  Output on 2.0.0-rc.4:

                  scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
                  unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
                  

                  No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

                  Expected behavior

                  One url, one answer — or an answer that cannot be cached.

                  Options

                  1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

                    Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

                    It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

                  2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

                  3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

                  4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

                  5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

                  (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

                  Why Vary is the weak option

                  Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

                  Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

                  There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

                  Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

                  A neighbour, not the same bug

                  throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

                  Environment

                  @solidjs/web2.0.0-rc.4 (published), and next
                  Nodev24.19.0
                  OSmacOS (darwin 25.6.0)

                  Metadata

                  Metadata

                  Assignees

                  No one assigned

                    Labels

                    No labels
                    No labels

                    Type

                    No type

                    Projects

                    No projects

                      Milestone

                      No milestone

                      Relationships

                      None yet

                      Development

                      No branches or pull requests

                      Issue actions

                      , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                      Skip to content

                      A cacheable raw Response is shaped by a request header nothing keys on #3094

                      Description

                      @frenzzy

                      Describe the bug

                      A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

                      The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

                      // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

                      With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

                      What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

                      This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

                      Steps to reproduce

                      mkdir sf-vary &&cd sf-vary && npm init -y
                      npm i @solidjs/web@2.0.0-rc.4
                      node repro.mjs

                      repro.mjs:

                      import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
                      }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
                      ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

                      Output on 2.0.0-rc.4:

                      scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
                      unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
                      

                      No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

                      Expected behavior

                      One url, one answer — or an answer that cannot be cached.

                      Options

                      1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

                        Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

                        It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

                      2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

                      3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

                      4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

                      5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

                      (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

                      Why Vary is the weak option

                      Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

                      Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

                      There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

                      Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

                      A neighbour, not the same bug

                      throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

                      Environment

                      @solidjs/web2.0.0-rc.4 (published), and next
                      Nodev24.19.0
                      OSmacOS (darwin 25.6.0)

                      Metadata

                      Metadata

                      Assignees

                      No one assigned

                        Labels

                        No labels
                        No labels

                        Type

                        No type

                        Projects

                        No projects

                          Milestone

                          No milestone

                          Relationships

                          None yet

                          Development

                          No branches or pull requests

                          Issue actions

                          , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
                          Skip to content

                          A cacheable raw Response is shaped by a request header nothing keys on #3094

                          Description

                          @frenzzy

                          Describe the bug

                          A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

                          The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

                          // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

                          With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

                          What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

                          This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

                          Steps to reproduce

                          mkdir sf-vary &&cd sf-vary && npm init -y
                          npm i @solidjs/web@2.0.0-rc.4
                          node repro.mjs

                          repro.mjs:

                          import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
                          }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
                          ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

                          Output on 2.0.0-rc.4:

                          scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
                          unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
                          

                          No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

                          Expected behavior

                          One url, one answer — or an answer that cannot be cached.

                          Options

                          1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

                            Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

                            It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

                          2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

                          3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

                          4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

                          5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

                          (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

                          Why Vary is the weak option

                          Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

                          Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

                          There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

                          Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

                          A neighbour, not the same bug

                          throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

                          Environment

                          @solidjs/web2.0.0-rc.4 (published), and next
                          Nodev24.19.0
                          OSmacOS (darwin 25.6.0)

                          Metadata

                          Metadata

                          Assignees

                          No one assigned

                            Labels

                            No labels
                            No labels

                            Type

                            No type

                            Projects

                            No projects

                              Milestone

                              No milestone

                              Relationships

                              None yet

                              Development

                              No branches or pull requests

                              Issue actions

                              , 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
                              Skip to content

                              A cacheable raw Response is shaped by a request header nothing keys on #3094

                              Description

                              @frenzzy

                              Describe the bug

                              A GET-declared function that answers with a raw Response and a public cache policy sends two different bodies at the same url, chosen by a request header no cache keys on and no Vary names.

                              The runtime shapes that response by whether the caller is scripted (packages/web/server-functions/src/server.ts):

                              // raw responses pass through untouchedif(result.headers&&result.headers.has("X-Content-Raw"))returnresult;if(instance){// forward headers, forward non-redirect statuses, decode the body}

                              With an instance header the body is re-encoded for the transport; without one the Response is handed back verbatim. Both keep the cache-control the function set. A shared cache stores whichever arrives first and serves it to the other caller — and GET-declared reads skip the origin gate, so the unscripted variant is reachable from a prefetch, a crawler, or a plain navigation.

                              What the wrong entry does downstream: a scripted caller receiving the raw HTML sees no body format on a 2xx, decodes nothing, and resolves the call to undefined (#3087's rule deliberately does not judge a 2xx). A navigation receiving the encoded variant renders codec frames.

                              This is the shape #3070 was about — a request header deciding a cacheable response — in a place that fix did not reach.

                              Steps to reproduce

                              mkdir sf-vary &&cd sf-vary && npm init -y
                              npm i @solidjs/web@2.0.0-rc.4
                              node repro.mjs

                              repro.mjs:

                              import{AsyncLocalStorage}from"node:async_hooks";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();const{GET, createServerReference, handleServerFunctionRequest, registerServerReference
                              }=awaitimport("@solidjs/web/server-functions/server");GET(createServerReference(registerServerReference("feed",async()=>newResponse("<p>hi</p>",{headers: {"content-type": "text/html","cache-control": "public, max-age=60"}}))));constcall=scripted=>handleServerFunctionRequest(newRequest("http://localhost/_server/feed",{headers: {"Sec-Fetch-Site": "same-origin",
                              ...(scripted ? {"X-Server-Function-Instance": "server-function:1"} : {})}}));for(constscriptedof[true,false]){constresponse=awaitcall(scripted);console.log((scripted ? "scripted " : "unscripted"),response.status,"| cache-control:",response.headers.get("cache-control"),"| vary:",response.headers.get("vary")??"(none)","| body:",JSON.stringify((awaitresponse.text()).slice(0,24)));}

                              Output on 2.0.0-rc.4:

                              scripted 200 | cache-control: public, max-age=60 | vary: (none) | body: ";0x00000271;{\"t\":25,\"i\":"
                              unscripted 200 | cache-control: public, max-age=60 | vary: (none) | body: "<p>hi</p>"
                              

                              No Vary at all: a declared read skips the origin gate, so nothing keys the difference. On rc.3 the responses did carry Vary: Sec-Fetch-Site, Origin, Referer, which names the CSRF trio and still not the header that decides the body.

                              Expected behavior

                              One url, one answer — or an answer that cannot be cached.

                              Options

                              1. Give the two answers two urls. The scripted transport dispatches to its own address: <endpoint>/<id> stays the plain-HTTP answer a form post, a crawler or curl gets, and a scripted call goes somewhere adjacent (<endpoint>/<id>.data, <endpoint>/data/<id> — pick a spelling). The instance header goes back to being what it is for, correlating a call, and stops deciding what the body is.

                                Every cache keys on the url, so the two variants can no longer collide: no Vary, no header forwarding to configure, no cooperation from the CDN at all. The discriminator is one bit, so it costs one more cache entry per function instead of fragmenting the key. Addressing is already path-based since feat(web): address server function calls by path #3076, so this is serverFunctionAddress() on the client and one more thing for parseServerFunctionAddress to strip on the server.

                                It is a wire change — client, server, and anything writing an address by hand. Forms keep posting to the bare address, so progressive enhancement is untouched, and arguably clearer: the address a human writes by hand is the one that answers in JSON. One thing worth knowing before picking a spelling — React Router serves data requests from .data urls, and iOS content blockers block those for looking like tracking endpoints (iOS content blockers block .data single fetch URLs, breaking client-side navigation remix-run/react-router#14834). The suffix wants to be boring.

                              2. Refuse to cache what the caller shaped. The runtime knows which branch it took, so the raw-Response branch could force no-store and override the function's policy. Surgical, keeps both behaviours, and the thing it takes away — caching a raw Response at the edge — is the thing that is unsafe today. Overriding an explicit policy is the part worth arguing about.

                              3. Converge the two callers: hand a returned Response back verbatim to a scripted caller too, the way X-Content-Raw already does for both. One url, one answer, cacheable. But it changes what a scripted call receives today (a decoded body, with headers and status forwarded), so it is a protocol change rather than a fix.

                              4. Vary: X-Server-Function-Instance on the divergent branch. Correct per HTTP and the weakest of these in practice — below.

                              5. Document it: a raw Response must not carry a public cache policy. Cheapest, and the failure stays silent for anyone who does not read that line.

                              (2) is the small fix that could land now; (1) is the one that makes the bug unreachable rather than unreached. They compose — no-store today does not block splitting the addresses later.

                              Why Vary is the weak option

                              Not theory — this exact shape has been paid for elsewhere. Next.js shapes a response by the rsc request header and drew CVE-2025-49005 for omitting the header that keys it: "a cache poisoning issue in Next.js App Router >=15.3.0 < 15.3.3 may have allowed RSC payloads to be cached and served in place of HTML" (advisory).

                              Their own CDN guide then says why the header was never enough on its own — "Many CDNs don't support Vary without additional configuration" — which is why they added the _rsc search parameter as a cache-key discriminator that works "even on CDNs that ignore Vary". And the direction they document now is url-shaped: move the cache-affecting inputs into the pathname (/my/page.rsc), "eliminating the need for Vary on custom headers", so that "No Vary support needed" from the CDN.

                              There is a second reason it does not apply here: X-Server-Function-Instance is unique per call, so varying on it is a guaranteed miss — option (2) with extra steps and a worse name.

                              Worth noting the seams do not let userland solve this: prepareRequest and fetch (#3080) can move the request's url, but the branch is server-side on the header, so both answers still meet at whichever url the client picked.

                              A neighbour, not the same bug

                              throw respond(x, { status: 400, headers: { "cache-control": "public, max-age=60" } }) caches an error response. Both callers get the same bytes there, so nothing is poisonable — but the no-store default stepping aside for a thrown envelope is worth a line in the docs.

                              Environment

                              @solidjs/web2.0.0-rc.4 (published), and next
                              Nodev24.19.0
                              OSmacOS (darwin 25.6.0)

                              Metadata

                              Metadata

                              Assignees

                              No one assigned

                                Labels

                                No labels
                                No labels

                                Type

                                No type

                                Projects

                                No projects

                                  Milestone

                                  No milestone

                                  Relationships

                                  None yet

                                  Development

                                  No branches or pull requests

                                  Issue actions