Describe the bug
httpHeader() cleanup restores a whole write-time snapshot of the header. That is correct only when scopes dispose in reverse write order. With independently recovering sibling SSR scopes, an earlier writer can dispose while a later writer remains live; restoring the earlier snapshot then deletes the later scope's surviving contribution.
For Set-Cookie, this can silently drop authentication or session cookies from the outgoing response. Ordinary headers and httpStatus() have the same ordering problem.
At a2057304:
httpHeader() snapshots and restores the complete previous header value/cookie list:
|
export function httpHeader(name: string, value: string, options?: { append?: boolean }): void { |
|
const event = getRequestEvent() as (RequestEvent & { response?: ResponseStub }) | undefined; |
|
const response = event && event.response; |
|
if (response && !response.committed) { |
|
const headers = response.headers; |
|
// Entry-exact path for set-cookie: `get()` comma-joins multiple |
|
// entries, and restoring that join through `set()` would collapse them |
|
// into one corrupt header (commas are legal inside a single cookie's |
|
// `Expires`), so snapshot the entry list and rebuild it on retraction. |
|
const setCookie = name.toLowerCase() === "set-cookie"; |
|
const prevCookies = setCookie ? headers.getSetCookie() : undefined; |
|
const prev = setCookie ? null : headers.get(name); |
|
if (options && options.append) headers.append(name, value); |
|
else headers.set(name, value); |
|
onCleanup(() => { |
|
if (response.committed) return; |
|
if (setCookie) { |
|
headers.delete(name); |
|
for (const cookie of prevCookies!) headers.append(name, cookie); |
|
} else if (prev === null) headers.delete(name); |
|
else headers.set(name, prev); |
|
}); |
httpStatus() uses the same write-time snapshot approach:
|
export function httpStatus(code: number, text?: string): void { |
|
// `response` is an integration-augmented field (see core's ResponseStub); |
|
// read it structurally so the primitives work against the bare contract. |
|
const event = getRequestEvent() as (RequestEvent & { response?: ResponseStub }) | undefined; |
|
const response = event && event.response; |
|
if (response && !response.committed) { |
|
const prevStatus = response.status; |
|
const prevStatusText = response.statusText; |
|
response.status = code; |
|
response.statusText = text; |
|
onCleanup(() => { |
|
if (response.committed) return; |
|
response.status = prevStatus; |
|
response.statusText = prevStatusText; |
|
}); |
Reproduction
import { AsyncLocalStorage } from "node:async_hooks";
import { httpHeader } from "@solidjs/web";
import { createRoot } from "solid-js";
const RequestContext = Symbol.for("solid.RequestContext");
const storage = new AsyncLocalStorage<any>();
(globalThis as any)[RequestContext] = storage;
const event = {
request: new Request("http://localhost/"),
locals: {},
response: {
status: 200,
statusText: undefined,
headers: new Headers(),
committed: false
}
};
storage.run(event, () => {
createRoot(disposeAll => {
let disposeEarly!: () => void;
// Sibling A writes first.
createRoot(dispose => {
disposeEarly = dispose;
httpHeader("set-cookie", "early=1; Path=/", { append: true });
});
// Sibling B writes later and remains live.
createRoot(() => {
httpHeader("set-cookie", "session=abc; Path=/; HttpOnly", {
append: true
});
});
console.log(event.response.headers.getSetCookie());
// ["early=1; Path=/", "session=abc; Path=/; HttpOnly"]
// A recovers/disposes before B.
disposeEarly();
console.log(event.response.headers.getSetCookie());
// Actual: []
// Expected: ["session=abc; Path=/; HttpOnly"]
disposeAll();
});
});
The same sequence with:
httpHeader("x-shared", "early");
httpHeader("x-shared", "late", { append: true });
changes "early, late" to null when the early scope disposes, rather than preserving "late".
Expected behavior
Disposing one scope should retract only that scope's declaration. Contributions from still-live scopes must remain in the outgoing response regardless of disposal order.
This likely requires tracking individual declarations/contributions and recomputing the effective status/header on cleanup instead of restoring a stale whole-field snapshot.
Platform
- OS: macOS and Linux
- Runtime: Node.js SSR
- Version:
@solidjs/web@2.0.0-beta.32
Additional context
The current tests cover nested/LIFO restoration, where snapshot restore works. The failing shape requires sibling declarations to dispose out of write order, which occurs when an earlier asynchronous boundary recovers while a later sibling remains active.
Describe the bug
httpHeader()cleanup restores a whole write-time snapshot of the header. That is correct only when scopes dispose in reverse write order. With independently recovering sibling SSR scopes, an earlier writer can dispose while a later writer remains live; restoring the earlier snapshot then deletes the later scope's surviving contribution.For
Set-Cookie, this can silently drop authentication or session cookies from the outgoing response. Ordinary headers andhttpStatus()have the same ordering problem.At
a2057304:httpHeader()snapshots and restores the complete previous header value/cookie list:solid/packages/solid-web/server/index.ts
Lines 266 to 287 in a205730
httpStatus()uses the same write-time snapshot approach:solid/packages/solid-web/server/index.ts
Lines 224 to 238 in a205730
Reproduction
The same sequence with:
changes
"early, late"tonullwhen the early scope disposes, rather than preserving"late".Expected behavior
Disposing one scope should retract only that scope's declaration. Contributions from still-live scopes must remain in the outgoing response regardless of disposal order.
This likely requires tracking individual declarations/contributions and recomputing the effective status/header on cleanup instead of restoring a stale whole-field snapshot.
Platform
@solidjs/web@2.0.0-beta.32Additional context
The current tests cover nested/LIFO restoration, where snapshot restore works. The failing shape requires sibling declarations to dispose out of write order, which occurs when an earlier asynchronous boundary recovers while a later sibling remains active.