From 819871e863f3dcca3fb71724ad2eb2a77cd735e9 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Jul 2026 02:29:43 +0200 Subject: [PATCH 1/2] fix: recover from SSR errors in dev without a manual refresh. --- .changeset/cuddly-pots-repeat.md | 5 + packages/start/src/config/dev-server.spec.ts | 224 +++++++++++++++++- packages/start/src/config/dev-server.ts | 232 +++++++++++++++++++ 3 files changed, 459 insertions(+), 2 deletions(-) create mode 100644 .changeset/cuddly-pots-repeat.md diff --git a/.changeset/cuddly-pots-repeat.md b/.changeset/cuddly-pots-repeat.md new file mode 100644 index 000000000..0d9a0f17d --- /dev/null +++ b/.changeset/cuddly-pots-repeat.md @@ -0,0 +1,5 @@ +--- +"@solidjs/start": patch +--- + +Recover from SSR errors in dev without a manual refresh. A syntax error in `app.tsx` left the 500 page on screen for good, because the error page has no modules registered for HMR and so ignored the update that fixed the file. The dev server now adds a small script to SSR error pages that reloads them once the server renders again. diff --git a/packages/start/src/config/dev-server.spec.ts b/packages/start/src/config/dev-server.spec.ts index 33aa05af5..ec0d74744 100644 --- a/packages/start/src/config/dev-server.spec.ts +++ b/packages/start/src/config/dev-server.spec.ts @@ -1,9 +1,20 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; -import { isHtmlResponse, resolvePreviewServerEntry } from "./dev-server.ts"; +import type { ServerResponse } from "node:http"; +import type { Connect } from "vite"; + +import { + headerFromWriteHead, + hmrRecoveryMiddleware, + injectHmrRecovery, + isHtmlErrorResponse, + isHtmlResponse, + resolvePreviewServerEntry, + withoutContentLength, +} from "./dev-server.ts"; const temporaryDirectories: string[] = []; @@ -64,3 +75,212 @@ describe("isHtmlResponse", () => { expect(isHtmlResponse(new Response())).toBe(false); }); }); + +describe("injectHmrRecovery", () => { + it.each(["", "", ""])("injects the script before %s", closingTag => { + const html = `Error${closingTag}`; + const injected = injectHmrRecovery(html); + + expect(injected).toContain("/@vite/client"); + expect(injected.indexOf("createHotContext")).toBeLessThan(injected.lastIndexOf(closingTag)); + }); + + it("injects into the last closing tag so the script is not left inside a nested document", () => { + const html = "
</head>
"; + + expect(injectHmrRecovery(html)).toMatch(/createHotContext[\s\S]*<\/script><\/head>/); + }); + + it("appends the script when there is nothing to inject before", () => { + expect(injectHmrRecovery("boom")).toMatch(/^boom`; + + expect(injectHmrRecovery(html)).toBe(html); + }); +}); + +describe("isHtmlErrorResponse", () => { + it("recognizes an HTML server error", () => { + expect(isHtmlErrorResponse(500, "text/html; charset=utf-8")).toBe(true); + }); + + it.each([ + [200, "text/html"], + [404, "text/html"], + [500, "application/json"], + [500, undefined], + ])("ignores %i %s", (status, contentType) => { + expect(isHtmlErrorResponse(status, contentType)).toBe(false); + }); +}); + +describe("headerFromWriteHead", () => { + it("reads a header given as an object", () => { + const args = [500, "Internal Server Error", { "Content-Type": "text/html" }]; + + expect(headerFromWriteHead(args, "content-type")).toBe("text/html"); + }); + + it("reads a header given as a flat array", () => { + const args = [500, ["content-length", "12", "content-type", "text/html"]]; + + expect(headerFromWriteHead(args, "content-type")).toBe("text/html"); + }); + + it("returns undefined when the header is absent", () => { + expect(headerFromWriteHead([500, {}], "content-type")).toBeUndefined(); + }); + + it("does not mistake the status message for headers", () => { + expect(headerFromWriteHead([500, "content-type"], "content-type")).toBeUndefined(); + }); +}); + +describe("withoutContentLength", () => { + it("removes the header from an object without touching the rest", () => { + const args = [ + 500, + "Internal Server Error", + { "Content-Length": "12", "Content-Type": "text/html" }, + ]; + + expect(withoutContentLength(args)).toEqual([ + 500, + "Internal Server Error", + { "Content-Type": "text/html" }, + ]); + }); + + it("removes the name and its value from a flat array", () => { + const args = [500, ["content-length", "12", "content-type", "text/html"]]; + + expect(withoutContentLength(args)).toEqual([500, ["content-type", "text/html"]]); + }); + + it("leaves arguments that carry no headers alone", () => { + expect(withoutContentLength([500])).toEqual([500]); + }); +}); + +describe("hmrRecoveryMiddleware", () => { + function collect(chunk: string | ArrayBufferView) { + return typeof chunk === "string" + ? Buffer.from(chunk, "utf8") + : Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength); + } + + function createResponse() { + const headers = new Map(); + const chunks: Array = []; + const response = { + statusCode: 200, + writeHeadArgs: undefined as Array | undefined, + getHeader: (name: string) => headers.get(name.toLowerCase()), + setHeader(name: string, value: unknown) { + headers.set(name.toLowerCase(), value); + return response; + }, + writeHead(...args: Array) { + response.writeHeadArgs = args; + return response; + }, + write(chunk: string | ArrayBufferView) { + chunks.push(collect(chunk)); + return true; + }, + end(chunk?: unknown) { + // Deliberately does not go through `write`: the real `end` writes to the socket directly, + // and delegating would feed the chunk straight back into the middleware's wrapper. + if (typeof chunk === "string" || ArrayBuffer.isView(chunk)) chunks.push(collect(chunk)); + return response; + }, + get body() { + return Buffer.concat(chunks).toString("utf8"); + }, + }; + + return response; + } + + function run(response: ReturnType, accept = "text/html") { + const next = vi.fn(); + hmrRecoveryMiddleware( + { headers: { accept } } as Connect.IncomingMessage, + response as unknown as ServerResponse, + next as unknown as Connect.NextFunction, + ); + + return next; + } + + it("injects the recovery script into an HTML server error and corrects the length", () => { + const response = createResponse(); + run(response); + + const page = "boom"; + response.writeHead(500, "Internal Server Error", [ + "content-length", + String(page.length), + "content-type", + "text/html; charset=utf-8", + ]); + response.end(new TextEncoder().encode(page)); + + expect(response.body).toContain("createHotContext"); + expect(response.getHeader("content-length")).toBe(Buffer.byteLength(response.body)); + expect(response.writeHeadArgs).toEqual([ + 500, + "Internal Server Error", + ["content-type", "text/html; charset=utf-8"], + ]); + }); + + it("buffers a body streamed across several chunks", () => { + const response = createResponse(); + run(response); + + response.writeHead(500, { "content-type": "text/html" }); + response.write(""); + response.write("boom"); + response.end(""); + + expect(response.body).toContain("createHotContext"); + expect(response.body.startsWith("")).toBe(true); + }); + + it("leaves a successful page untouched", () => { + const response = createResponse(); + run(response); + + response.writeHead(200, { "content-type": "text/html" }); + response.end("fine"); + + expect(response.body).toBe("fine"); + expect(response.getHeader("content-length")).toBeUndefined(); + }); + + it("leaves a non-HTML server error untouched", () => { + const response = createResponse(); + run(response); + + response.writeHead(500, { "content-type": "application/json" }); + response.end(`{"error":true}`); + + expect(response.body).toBe(`{"error":true}`); + }); + + it("skips requests that do not accept HTML", () => { + const response = createResponse(); + const next = run(response, "application/json"); + + expect(next).toHaveBeenCalled(); + + response.writeHead(500, { "content-type": "text/html" }); + response.end(""); + + expect(response.body).toBe(""); + }); +}); diff --git a/packages/start/src/config/dev-server.ts b/packages/start/src/config/dev-server.ts index 3a5ac418a..8a1fe75b4 100644 --- a/packages/start/src/config/dev-server.ts +++ b/packages/start/src/config/dev-server.ts @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import type { ServerResponse } from "node:http"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { NodeRequest, sendNodeResponse } from "srvx/node"; @@ -33,6 +34,12 @@ export function devServer(serverEntryPath: string): Array { }, configureServer(viteDevServer) { (globalThis as any).VITE_DEV_SERVER = viteDevServer; + + // Runs before the middleware that actually renders the document (ours below, or the one + // installed by whichever plugin owns the server environment) so that it can rewrite the + // response it produces. + viteDevServer.middlewares.use(hmrRecoveryMiddleware); + return async () => { if (viteDevServer.config.server.middlewareMode) return; @@ -165,6 +172,231 @@ function removeHtmlMiddlewares(server: ViteDevServer) { } } +/** + * Reloads the page once an HMR update makes the server render it again. + * + * Vite's client only reloads by itself when the page either has a `vite-error-overlay` mounted or + * receives an explicit `full-reload`. An SSR error page rendered by another plugin (Nitro's, for + * instance) has neither, and none of its modules are registered for HMR, so a plain `update` for + * the fixed file would be dropped and the error page would stay on screen forever. + * + * The update is broadcast to the browser before the server environment has necessarily picked the + * change up, so reloading straight away tends to render the same error again. Retrying briefly is + * what makes the recovery reliable; failing quietly leaves the next update to try again. + */ +const HMR_RECOVERY_SCRIPT = ``; + +/** + * Appends {@link HMR_RECOVERY_SCRIPT} to an SSR error page so that it recovers on the next update. + * + * @param html the error page rendered by the server + * @returns the error page, unchanged when it already talks to the HMR client + */ +export function injectHmrRecovery(html: string): string { + if (html.includes("/@vite/client")) return html; + + for (const closingTag of ["", "", ""]) { + const index = html.lastIndexOf(closingTag); + if (index !== -1) { + return html.slice(0, index) + HMR_RECOVERY_SCRIPT + html.slice(index); + } + } + + return html + HMR_RECOVERY_SCRIPT; +} + +/** + * Reads a header out of the arguments given to `writeHead`, which may carry them as an object or as + * a flat `[name, value, ...]` array. They are only applied to the response once the headers flush, + * so they have to be read from here while the flush is still deferred. + */ +export function headerFromWriteHead(args: Array, name: string): string | undefined { + for (const argument of args.slice(1)) { + if (Array.isArray(argument)) { + for (let i = 0; i < argument.length - 1; i += 2) { + if (String(argument[i]).toLowerCase() === name) return String(argument[i + 1]); + } + } else if (argument && typeof argument === "object") { + for (const [key, value] of Object.entries(argument)) { + if (key.toLowerCase() === name) return String(value); + } + } + } + + return undefined; +} + +/** + * Strips `content-length` from `writeHead` arguments so the rewritten body can set its own. + * + * @param args the arguments `writeHead` was called with + * @returns a copy of them, with the header removed from whichever form it was given in + */ +export function withoutContentLength(args: Array): Array { + return args.map((argument, index) => { + if (index === 0) return argument; + + if (Array.isArray(argument)) { + const kept: Array = []; + for (let i = 0; i < argument.length - 1; i += 2) { + if (String(argument[i]).toLowerCase() !== "content-length") { + kept.push(argument[i], argument[i + 1]); + } + } + return kept; + } + + if (argument && typeof argument === "object") { + return Object.fromEntries( + Object.entries(argument).filter(([key]) => key.toLowerCase() !== "content-length"), + ); + } + + return argument; + }); +} + +export function isHtmlErrorResponse(status: number, contentType: unknown): boolean { + return ( + status >= 500 && + typeof contentType === "string" && + contentType.toLowerCase().startsWith("text/html") + ); +} + +/** + * Buffers HTML error pages so {@link injectHmrRecovery} can rewrite them before they are flushed. + * + * Anything that is not an HTML 5xx passes straight through untouched: the decision is made from the + * status and content type, which are known by the time the body is first written. + */ +export function hmrRecoveryMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, +) { + // Only documents can host the recovery script, and this keeps assets out of the buffer. + if (!req.headers.accept?.includes("text/html")) return next(); + + const originalWriteHead = res.writeHead.bind(res); + const originalWrite = res.write.bind(res); + const originalEnd = res.end.bind(res); + + let intercepting: boolean | undefined; + let pendingWriteHead: Array | undefined; + const chunks: Array = []; + + /** Hands the response back untouched, flushing whatever was buffered before giving up. */ + function stopIntercepting() { + intercepting = false; + res.writeHead = originalWriteHead; + res.write = originalWrite; + res.end = originalEnd; + + if (pendingWriteHead) originalWriteHead(...(pendingWriteHead as [number])); + for (const chunk of chunks) originalWrite(chunk); + pendingWriteHead = undefined; + chunks.length = 0; + } + + /** + * The status and content type are known by the time the headers are sent, so the first call + * decides for the whole response. A body we cannot concatenate ends interception rather than + * corrupt it. + * + * @param body the chunk about to be written, when there is one + * @param writeHeadArgs headers still pending a flush, which win over the ones set on `res` + */ + function shouldIntercept(body?: unknown, writeHeadArgs?: Array): boolean { + intercepting ??= isHtmlErrorResponse( + typeof writeHeadArgs?.[0] === "number" ? writeHeadArgs[0] : res.statusCode, + (writeHeadArgs && headerFromWriteHead(writeHeadArgs, "content-type")) ?? + res.getHeader("content-type"), + ); + if (!intercepting || (body !== undefined && !isWritableBody(body))) stopIntercepting(); + + return intercepting; + } + + // `writeHead` flushes the headers, which has to wait until the rewritten length is known. + res.writeHead = function writeHead(...args: Array) { + if (!shouldIntercept(undefined, args)) return originalWriteHead(...(args as [number])); + + pendingWriteHead = args; + return res; + } as ServerResponse["writeHead"]; + + res.write = function write(chunk: any, ...rest: Array) { + if (!shouldIntercept(chunk)) return originalWrite(chunk, ...rest); + + chunks.push(toBuffer(chunk, rest)); + callbackOf(rest)?.(); + return true; + } as ServerResponse["write"]; + + res.end = function end(chunk: any, ...rest: Array) { + const body = isWritableBody(chunk) ? chunk : undefined; + if (!shouldIntercept(body)) return originalEnd(chunk, ...rest); + + if (body !== undefined) chunks.push(toBuffer(body, rest)); + + const html = injectHmrRecovery(Buffer.concat(chunks).toString("utf8")); + const rewritten = Buffer.from(html, "utf8"); + + res.setHeader("content-length", rewritten.byteLength); + if (pendingWriteHead) { + // Headers passed to `writeHead` win over `setHeader`, so the stale length goes out with them + // unless it is removed here first. + originalWriteHead(...(withoutContentLength(pendingWriteHead) as [number])); + } + + return originalEnd(rewritten, callbackOf([chunk, ...rest])); + } as ServerResponse["end"]; + + next(); +} + +/** Web stream bodies reach `write` as `Uint8Array`s rather than as Node buffers. */ +function isWritableBody(body: unknown): body is string | ArrayBufferView { + return typeof body === "string" || ArrayBuffer.isView(body); +} + +function toBuffer(body: string | ArrayBufferView, rest: Array): Buffer { + if (typeof body !== "string") { + return Buffer.from(body.buffer, body.byteOffset, body.byteLength); + } + + const encoding = rest.find(argument => typeof argument === "string") as + | BufferEncoding + | undefined; + return Buffer.from(body, encoding ?? "utf8"); +} + +function callbackOf(args: Array): (() => void) | undefined { + return args.find(argument => typeof argument === "function") as (() => void) | undefined; +} + /** * Formats error for SSR message in error overlay * @param req From cea031406eb882b5484d34c069f6b081b9ee8ef9 Mon Sep 17 00:00:00 2001 From: Birk Skyum Date: Mon, 27 Jul 2026 02:40:21 +0200 Subject: [PATCH 2/2] simplify --- packages/start/src/config/dev-server.spec.ts | 114 ++++++----- packages/start/src/config/dev-server.ts | 193 +++++-------------- 2 files changed, 103 insertions(+), 204 deletions(-) diff --git a/packages/start/src/config/dev-server.spec.ts b/packages/start/src/config/dev-server.spec.ts index ec0d74744..190e262fe 100644 --- a/packages/start/src/config/dev-server.spec.ts +++ b/packages/start/src/config/dev-server.spec.ts @@ -7,13 +7,12 @@ import type { ServerResponse } from "node:http"; import type { Connect } from "vite"; import { - headerFromWriteHead, + HMR_RECOVERY_SCRIPT, + inspectWriteHead, hmrRecoveryMiddleware, - injectHmrRecovery, isHtmlErrorResponse, isHtmlResponse, resolvePreviewServerEntry, - withoutContentLength, } from "./dev-server.ts"; const temporaryDirectories: string[] = []; @@ -76,32 +75,6 @@ describe("isHtmlResponse", () => { }); }); -describe("injectHmrRecovery", () => { - it.each(["", "", ""])("injects the script before %s", closingTag => { - const html = `Error${closingTag}`; - const injected = injectHmrRecovery(html); - - expect(injected).toContain("/@vite/client"); - expect(injected.indexOf("createHotContext")).toBeLessThan(injected.lastIndexOf(closingTag)); - }); - - it("injects into the last closing tag so the script is not left inside a nested document", () => { - const html = "
</head>
"; - - expect(injectHmrRecovery(html)).toMatch(/createHotContext[\s\S]*<\/script><\/head>/); - }); - - it("appends the script when there is nothing to inject before", () => { - expect(injectHmrRecovery("boom")).toMatch(/^boom`; - - expect(injectHmrRecovery(html)).toBe(html); - }); -}); - describe("isHtmlErrorResponse", () => { it("recognizes an HTML server error", () => { expect(isHtmlErrorResponse(500, "text/html; charset=utf-8")).toBe(true); @@ -117,51 +90,44 @@ describe("isHtmlErrorResponse", () => { }); }); -describe("headerFromWriteHead", () => { - it("reads a header given as an object", () => { +describe("inspectWriteHead", () => { + it("reads the content type from headers given as an object", () => { const args = [500, "Internal Server Error", { "Content-Type": "text/html" }]; - expect(headerFromWriteHead(args, "content-type")).toBe("text/html"); + expect(inspectWriteHead(args).contentType).toBe("text/html"); }); - it("reads a header given as a flat array", () => { + it("reads the content type from headers given as a flat array", () => { const args = [500, ["content-length", "12", "content-type", "text/html"]]; - expect(headerFromWriteHead(args, "content-type")).toBe("text/html"); - }); - - it("returns undefined when the header is absent", () => { - expect(headerFromWriteHead([500, {}], "content-type")).toBeUndefined(); + expect(inspectWriteHead(args).contentType).toBe("text/html"); }); it("does not mistake the status message for headers", () => { - expect(headerFromWriteHead([500, "content-type"], "content-type")).toBeUndefined(); + expect(inspectWriteHead([500, "content-type"]).contentType).toBeUndefined(); }); -}); -describe("withoutContentLength", () => { - it("removes the header from an object without touching the rest", () => { - const args = [ - 500, - "Internal Server Error", - { "Content-Length": "12", "Content-Type": "text/html" }, - ]; + it("removes the length from an object without touching the rest", () => { + const args = [500, "OK", { "Content-Length": "12", "Content-Type": "text/html" }]; - expect(withoutContentLength(args)).toEqual([ + expect(inspectWriteHead(args).withoutContentLength).toEqual([ 500, - "Internal Server Error", + "OK", { "Content-Type": "text/html" }, ]); }); - it("removes the name and its value from a flat array", () => { + it("removes the length and its value from a flat array", () => { const args = [500, ["content-length", "12", "content-type", "text/html"]]; - expect(withoutContentLength(args)).toEqual([500, ["content-type", "text/html"]]); + expect(inspectWriteHead(args).withoutContentLength).toEqual([ + 500, + ["content-type", "text/html"], + ]); }); it("leaves arguments that carry no headers alone", () => { - expect(withoutContentLength([500])).toEqual([500]); + expect(inspectWriteHead([500]).withoutContentLength).toEqual([500]); }); }); @@ -183,18 +149,25 @@ describe("hmrRecoveryMiddleware", () => { headers.set(name.toLowerCase(), value); return response; }, + headersSent: false, + removeHeader(name: string) { + headers.delete(name.toLowerCase()); + }, writeHead(...args: Array) { response.writeHeadArgs = args; + response.headersSent = true; return response; }, write(chunk: string | ArrayBufferView) { chunks.push(collect(chunk)); + response.headersSent = true; return true; }, end(chunk?: unknown) { // Deliberately does not go through `write`: the real `end` writes to the socket directly, // and delegating would feed the chunk straight back into the middleware's wrapper. if (typeof chunk === "string" || ArrayBuffer.isView(chunk)) chunks.push(collect(chunk)); + response.headersSent = true; return response; }, get body() { @@ -216,7 +189,7 @@ describe("hmrRecoveryMiddleware", () => { return next; } - it("injects the recovery script into an HTML server error and corrects the length", () => { + it("appends the recovery script to an HTML server error", () => { const response = createResponse(); run(response); @@ -229,8 +202,21 @@ describe("hmrRecoveryMiddleware", () => { ]); response.end(new TextEncoder().encode(page)); - expect(response.body).toContain("createHotContext"); - expect(response.getHeader("content-length")).toBe(Buffer.byteLength(response.body)); + expect(response.body).toBe(page + HMR_RECOVERY_SCRIPT); + }); + + it("drops the announced length so the longer body can go out chunked", () => { + const response = createResponse(); + run(response); + + response.writeHead(500, "Internal Server Error", [ + "content-length", + "42", + "content-type", + "text/html; charset=utf-8", + ]); + response.end(""); + expect(response.writeHeadArgs).toEqual([ 500, "Internal Server Error", @@ -238,7 +224,20 @@ describe("hmrRecoveryMiddleware", () => { ]); }); - it("buffers a body streamed across several chunks", () => { + it("removes a length set without writeHead before the headers go out", () => { + const response = createResponse(); + run(response); + + response.statusCode = 500; + response.setHeader("content-type", "text/html"); + response.setHeader("content-length", "13"); + response.end(""); + + expect(response.getHeader("content-length")).toBeUndefined(); + expect(response.body).toBe("" + HMR_RECOVERY_SCRIPT); + }); + + it("streams a body written across several chunks straight through", () => { const response = createResponse(); run(response); @@ -247,8 +246,7 @@ describe("hmrRecoveryMiddleware", () => { response.write("boom"); response.end(""); - expect(response.body).toContain("createHotContext"); - expect(response.body.startsWith("")).toBe(true); + expect(response.body).toBe("boom" + HMR_RECOVERY_SCRIPT); }); it("leaves a successful page untouched", () => { diff --git a/packages/start/src/config/dev-server.ts b/packages/start/src/config/dev-server.ts index 8a1fe75b4..cfea70688 100644 --- a/packages/start/src/config/dev-server.ts +++ b/packages/start/src/config/dev-server.ts @@ -184,7 +184,7 @@ function removeHtmlMiddlewares(server: ViteDevServer) { * change up, so reloading straight away tends to render the same error again. Retrying briefly is * what makes the recovery reliable; failing quietly leaves the next update to try again. */ -const HMR_RECOVERY_SCRIPT = ``; /** - * Appends {@link HMR_RECOVERY_SCRIPT} to an SSR error page so that it recovers on the next update. + * Reads the content type out of `writeHead` arguments and drops `content-length` from them. * - * @param html the error page rendered by the server - * @returns the error page, unchanged when it already talks to the HMR client + * `writeHead` sends the headers immediately, and it may carry them as an object or as a flat + * `[name, value, ...]` array that never reaches `getHeader`, so this is the only chance to see or + * change them. Dropping the length rather than recomputing it lets Node fall back to chunked + * encoding, so the body can stream through untouched instead of being buffered up to measure it. + * + * @param args the arguments `writeHead` was called with + * @returns the content type it announced, and a copy of the arguments without the length */ -export function injectHmrRecovery(html: string): string { - if (html.includes("/@vite/client")) return html; +export function inspectWriteHead(args: Array) { + let contentType: string | undefined; - for (const closingTag of ["", "", ""]) { - const index = html.lastIndexOf(closingTag); - if (index !== -1) { - return html.slice(0, index) + HMR_RECOVERY_SCRIPT + html.slice(index); - } - } + const withoutContentLength = args.map((argument, index) => { + const entries = index === 0 ? undefined : headerEntries(argument); + if (!entries) return argument; - return html + HMR_RECOVERY_SCRIPT; -} + const kept = entries.filter(([name, value]) => { + const header = String(name).toLowerCase(); + if (header === "content-type") contentType ??= String(value); + return header !== "content-length"; + }); -/** - * Reads a header out of the arguments given to `writeHead`, which may carry them as an object or as - * a flat `[name, value, ...]` array. They are only applied to the response once the headers flush, - * so they have to be read from here while the flush is still deferred. - */ -export function headerFromWriteHead(args: Array, name: string): string | undefined { - for (const argument of args.slice(1)) { - if (Array.isArray(argument)) { - for (let i = 0; i < argument.length - 1; i += 2) { - if (String(argument[i]).toLowerCase() === name) return String(argument[i + 1]); - } - } else if (argument && typeof argument === "object") { - for (const [key, value] of Object.entries(argument)) { - if (key.toLowerCase() === name) return String(value); - } - } - } + return Array.isArray(argument) ? kept.flat() : Object.fromEntries(kept); + }); - return undefined; + return { contentType, withoutContentLength }; } -/** - * Strips `content-length` from `writeHead` arguments so the rewritten body can set its own. - * - * @param args the arguments `writeHead` was called with - * @returns a copy of them, with the header removed from whichever form it was given in - */ -export function withoutContentLength(args: Array): Array { - return args.map((argument, index) => { - if (index === 0) return argument; - - if (Array.isArray(argument)) { - const kept: Array = []; - for (let i = 0; i < argument.length - 1; i += 2) { - if (String(argument[i]).toLowerCase() !== "content-length") { - kept.push(argument[i], argument[i + 1]); - } - } - return kept; - } - - if (argument && typeof argument === "object") { - return Object.fromEntries( - Object.entries(argument).filter(([key]) => key.toLowerCase() !== "content-length"), - ); - } +/** Both header forms `writeHead` accepts, as pairs. */ +function headerEntries(argument: unknown): Array<[unknown, unknown]> | undefined { + if (Array.isArray(argument)) { + const pairs: Array<[unknown, unknown]> = []; + for (let i = 0; i < argument.length - 1; i += 2) pairs.push([argument[i], argument[i + 1]]); + return pairs; + } - return argument; - }); + return argument && typeof argument === "object" ? Object.entries(argument) : undefined; } export function isHtmlErrorResponse(status: number, contentType: unknown): boolean { @@ -286,117 +257,47 @@ export function isHtmlErrorResponse(status: number, contentType: unknown): boole } /** - * Buffers HTML error pages so {@link injectHmrRecovery} can rewrite them before they are flushed. - * - * Anything that is not an HTML 5xx passes straight through untouched: the decision is made from the - * status and content type, which are known by the time the body is first written. + * Appends {@link HMR_RECOVERY_SCRIPT} to SSR error pages. Every other response is left alone. */ export function hmrRecoveryMiddleware( req: Connect.IncomingMessage, res: ServerResponse, next: Connect.NextFunction, ) { - // Only documents can host the recovery script, and this keeps assets out of the buffer. + // Only documents can host the recovery script. if (!req.headers.accept?.includes("text/html")) return next(); const originalWriteHead = res.writeHead.bind(res); - const originalWrite = res.write.bind(res); const originalEnd = res.end.bind(res); + let injecting: boolean | undefined; - let intercepting: boolean | undefined; - let pendingWriteHead: Array | undefined; - const chunks: Array = []; - - /** Hands the response back untouched, flushing whatever was buffered before giving up. */ - function stopIntercepting() { - intercepting = false; - res.writeHead = originalWriteHead; - res.write = originalWrite; - res.end = originalEnd; - - if (pendingWriteHead) originalWriteHead(...(pendingWriteHead as [number])); - for (const chunk of chunks) originalWrite(chunk); - pendingWriteHead = undefined; - chunks.length = 0; - } - - /** - * The status and content type are known by the time the headers are sent, so the first call - * decides for the whole response. A body we cannot concatenate ends interception rather than - * corrupt it. - * - * @param body the chunk about to be written, when there is one - * @param writeHeadArgs headers still pending a flush, which win over the ones set on `res` - */ - function shouldIntercept(body?: unknown, writeHeadArgs?: Array): boolean { - intercepting ??= isHtmlErrorResponse( - typeof writeHeadArgs?.[0] === "number" ? writeHeadArgs[0] : res.statusCode, - (writeHeadArgs && headerFromWriteHead(writeHeadArgs, "content-type")) ?? - res.getHeader("content-type"), - ); - if (!intercepting || (body !== undefined && !isWritableBody(body))) stopIntercepting(); - - return intercepting; - } - - // `writeHead` flushes the headers, which has to wait until the rewritten length is known. res.writeHead = function writeHead(...args: Array) { - if (!shouldIntercept(undefined, args)) return originalWriteHead(...(args as [number])); + const { contentType, withoutContentLength } = inspectWriteHead(args); + injecting = isHtmlErrorResponse( + typeof args[0] === "number" ? args[0] : res.statusCode, + contentType ?? res.getHeader("content-type"), + ); - pendingWriteHead = args; - return res; + return originalWriteHead(...((injecting ? withoutContentLength : args) as [number])); } as ServerResponse["writeHead"]; - res.write = function write(chunk: any, ...rest: Array) { - if (!shouldIntercept(chunk)) return originalWrite(chunk, ...rest); - - chunks.push(toBuffer(chunk, rest)); - callbackOf(rest)?.(); - return true; - } as ServerResponse["write"]; - res.end = function end(chunk: any, ...rest: Array) { - const body = isWritableBody(chunk) ? chunk : undefined; - if (!shouldIntercept(body)) return originalEnd(chunk, ...rest); - - if (body !== undefined) chunks.push(toBuffer(body, rest)); + // A response that never called `writeHead` still has its status and headers on `res`. + injecting ??= isHtmlErrorResponse(res.statusCode, res.getHeader("content-type")); + if (!injecting) return originalEnd(chunk, ...rest); - const html = injectHmrRecovery(Buffer.concat(chunks).toString("utf8")); - const rewritten = Buffer.from(html, "utf8"); - - res.setHeader("content-length", rewritten.byteLength); - if (pendingWriteHead) { - // Headers passed to `writeHead` win over `setHeader`, so the stale length goes out with them - // unless it is removed here first. - originalWriteHead(...(withoutContentLength(pendingWriteHead) as [number])); + if (!res.headersSent) res.removeHeader("content-length"); + if (typeof chunk === "string" || ArrayBuffer.isView(chunk)) { + res.write(chunk, ...(rest.filter(argument => typeof argument === "string") as [])); } - return originalEnd(rewritten, callbackOf([chunk, ...rest])); + const callback = [chunk, ...rest].find(argument => typeof argument === "function"); + return originalEnd(HMR_RECOVERY_SCRIPT, callback as () => void); } as ServerResponse["end"]; next(); } -/** Web stream bodies reach `write` as `Uint8Array`s rather than as Node buffers. */ -function isWritableBody(body: unknown): body is string | ArrayBufferView { - return typeof body === "string" || ArrayBuffer.isView(body); -} - -function toBuffer(body: string | ArrayBufferView, rest: Array): Buffer { - if (typeof body !== "string") { - return Buffer.from(body.buffer, body.byteOffset, body.byteLength); - } - - const encoding = rest.find(argument => typeof argument === "string") as - | BufferEncoding - | undefined; - return Buffer.from(body, encoding ?? "utf8"); -} - -function callbackOf(args: Array): (() => void) | undefined { - return args.find(argument => typeof argument === "function") as (() => void) | undefined; -} - /** * Formats error for SSR message in error overlay * @param req