From 8acdcb238ff07418806304de2a42a8f397d2b29b Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Wed, 15 Apr 2026 17:49:40 -0400 Subject: [PATCH 1/4] wrapped pages return 500 status code when proxied-body fails to render --- bifrost-fastify/index.ts | 22 +++++---- bifrost/lib/type.ts | 2 + bifrost/package.json | 8 ++-- bifrost/renderer/config.ts | 4 +- bifrost/renderer/wrapped/Page.tsx | 5 ++ bifrost/renderer/wrapped/onAfterRenderHtml.ts | 17 +++++++ .../renderer/wrapped/onBeforeRenderClient.ts | 1 + package-lock.json | 47 +++++++++---------- tests/e2e/specs/http.spec.ts | 17 +++++++ tests/vite/package.json | 4 +- 10 files changed, 84 insertions(+), 43 deletions(-) create mode 100644 bifrost/renderer/wrapped/onAfterRenderHtml.ts diff --git a/bifrost-fastify/index.ts b/bifrost-fastify/index.ts index 5340d0d..3ba2acf 100644 --- a/bifrost-fastify/index.ts +++ b/bifrost-fastify/index.ts @@ -79,26 +79,28 @@ export const viteProxyPlugin: FastifyPluginAsync< async function replyWithPage( reply: FastifyReply, pageContext: RenderedPageContext, - statusCodeOverride?: number + statusCodeFromProxy?: number ): Promise { const { httpResponse } = pageContext; - if ( - onError && - httpResponse?.statusCode === 500 && - pageContext.errorWhileRendering - ) { - onError(pageContext.errorWhileRendering, pageContext); - } - if (!httpResponse) { return reply.code(404).type("text/html").send("Not Found"); } const { statusCode, headers, getBody } = httpResponse; + + if (onError && statusCode === 500 && pageContext.errorWhileRendering) { + onError(pageContext.errorWhileRendering, pageContext); + } + return ( reply - .status(statusCodeOverride ?? statusCode) + // If there is an error on the Bifrost side, we use the bifrost statusCode, otherwise prefer the proxy's code + .status( + pageContext.errorWhileRendering + ? statusCode + : (statusCodeFromProxy ?? statusCode) + ) .headers(Object.fromEntries(headers)) // This disables any possibility of real streaming. To re-enable streaming we should adopt vike-photon and rewrite wrapped proxy as a Vike middleware. // Why not pipe? Because Vike gives us `pipe` which sends data into a Writable, but Fastify's reply.send only accepts a ReadableStream. Passthrough can convert but causes race conditions diff --git a/bifrost/lib/type.ts b/bifrost/lib/type.ts index 271d2bb..9b81c73 100644 --- a/bifrost/lib/type.ts +++ b/bifrost/lib/type.ts @@ -10,6 +10,8 @@ export interface WrappedServerOnly { // https://vike.dev/pageContext.json#avoid-pagecontext-json-requests // Instead, we nest them inside wrappedServerOnly and move them to top-level pageContext in onBeforeRenderHtml proxyLayoutInfo: Vike.ProxyLayoutInfo; + // Marker to verify that render succeeded + renderedBody: boolean; } declare global { diff --git a/bifrost/package.json b/bifrost/package.json index df677d9..63777d6 100644 --- a/bifrost/package.json +++ b/bifrost/package.json @@ -45,8 +45,8 @@ "peerDependencies": { "react": ">=18", "typescript": ">=4.7", - "vike": ">=0.4.253", - "vike-react": ">=0.6.18" + "vike": ">=0.4.255", + "vike-react": ">=0.6.21" }, "devDependencies": { "@types/jsdom": "^21.1.2", @@ -58,9 +58,9 @@ "tsup": "^7.1.0", "turbolinks": "5.3.0-beta.1", "typescript": "^5.0.4", - "vike-react": "0.6.18", + "vike-react": "0.6.21", "vite": "^6.3.5", - "vike": "0.4.253", + "vike": "0.4.255", "cross-env": "^7.0.3" } } diff --git a/bifrost/renderer/config.ts b/bifrost/renderer/config.ts index 316c6fd..85131f9 100644 --- a/bifrost/renderer/config.ts +++ b/bifrost/renderer/config.ts @@ -27,7 +27,7 @@ export default { getLayout: { env: { server: true, client: true } }, layoutHeaders: { env: { server: true, client: false } }, proxyHeaders: { env: { server: true, client: true } }, - onWrappedReactRenderTimeout: { env: { server: false, client: true} }, + onWrappedReactRenderTimeout: { env: { server: false, client: true } }, proxyMode: { env: { server: true, client: true, config: true }, effect({ configDefinedAt, configValue }) { @@ -46,6 +46,8 @@ export default { "import:@alignable/bifrost/__internal/renderer/wrapped/onRenderHtml:default", onBeforeRenderHtml: "import:@alignable/bifrost/__internal/renderer/wrapped/onBeforeRenderHtml:default", + onAfterRenderHtml: + "import:@alignable/bifrost/__internal/renderer/wrapped/onAfterRenderHtml:default", onBeforeRender: "import:@alignable/bifrost/__internal/renderer/wrapped/onBeforeRender.client:default", onBeforeRenderClient: diff --git a/bifrost/renderer/wrapped/Page.tsx b/bifrost/renderer/wrapped/Page.tsx index 80bcf0b..75737db 100644 --- a/bifrost/renderer/wrapped/Page.tsx +++ b/bifrost/renderer/wrapped/Page.tsx @@ -10,6 +10,11 @@ export default function Page() { ? pageContext._turbolinksProxy?.body?.innerHTML : pageContext._wrappedServerOnly?.bodyInnerHtml; + // Set marker so we can render error if not + if (!pageContext.isClientSide && pageContext._wrappedServerOnly) { + pageContext._wrappedServerOnly.renderedBody = true; + } + if (bodyHtml) { return (
=18", "typescript": ">=4.7", - "vike": ">=0.4.253", - "vike-react": ">=0.6.18" + "vike": ">=0.4.255", + "vike-react": ">=0.6.21" } }, "bifrost-fastify": { @@ -1813,9 +1813,9 @@ "license": "MIT" }, "node_modules/@brillout/json-serializer": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/@brillout/json-serializer/-/json-serializer-0.5.21.tgz", - "integrity": "sha512-pzzT4U4A9rk7eZpFjloRoMrGG2jnptwNGAhPIH7ZVjCMHd6TaJ29hrERPaY6Bp3Xdzu8JWlHI1o3x7PysxkaHQ==", + "version": "0.5.22", + "resolved": "https://registry.npmjs.org/@brillout/json-serializer/-/json-serializer-0.5.22.tgz", + "integrity": "sha512-jzcOqcbysKICCeZNlwa755tffF+HfMj8G+ZSuvHe06aCpp4xQiU2D91RHFUeJpF0/RNyfa5uWZ0pRuvFoeoqow==", "license": "MIT" }, "node_modules/@brillout/picocolors": { @@ -1824,14 +1824,10 @@ "integrity": "sha512-xJjdgyN1H0qh2nB2xlzazIipiDixuUd9oD5msh/Qv5bXJG9j8MSD/m4lREt6Z10ej6FF31b8vB4tdT7lDUbiyA==", "license": "ISC" }, - "node_modules/@brillout/require-shim": { - "version": "0.1.2", - "license": "MIT" - }, "node_modules/@brillout/vite-plugin-server-entry": { - "version": "0.7.17", - "resolved": "https://registry.npmjs.org/@brillout/vite-plugin-server-entry/-/vite-plugin-server-entry-0.7.17.tgz", - "integrity": "sha512-MfvSytYl51J2B+RrHvRXMdRNc1U2lHG/K9Gw05/jdPY2iYU2YQKdEzsPswfEWnt3fd1TrXF27h/fx5DIRn19jw==", + "version": "0.7.18", + "resolved": "https://registry.npmjs.org/@brillout/vite-plugin-server-entry/-/vite-plugin-server-entry-0.7.18.tgz", + "integrity": "sha512-j3neG+vaIZ2AbP2/vGgaIyJwrFIxlK3xd3Ey2EGBswCvAGeI4QSSfXGbb7R3b3H8223PgTTsWOZuZH0Y8Ope2w==", "license": "MIT", "dependencies": { "@brillout/import": "^0.2.6", @@ -7947,18 +7943,17 @@ "license": "MIT" }, "node_modules/vike": { - "version": "0.4.253", - "resolved": "https://registry.npmjs.org/vike/-/vike-0.4.253.tgz", - "integrity": "sha512-glS8FidhqnK6I/wLs48BYtqsAMXs8QtOB0uxeZ0G93G2a0ZkymuZ5rjbcZHUCBLoUYS/8i0h4d4NhK2mAsUhTA==", + "version": "0.4.255", + "resolved": "https://registry.npmjs.org/vike/-/vike-0.4.255.tgz", + "integrity": "sha512-pVRovPzIcxPiSg1nkkHg0+PGDc+qMupHw/xgWCvmJHhHO0qfuQ6UgiMq+OdEKB9q64NJJ6uuDXDtpVgvmYUu3w==", "license": "MIT", "dependencies": { "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@brillout/import": "^0.2.6", - "@brillout/json-serializer": "^0.5.21", + "@brillout/json-serializer": "^0.5.22", "@brillout/picocolors": "^1.0.30", - "@brillout/require-shim": "^0.1.2", - "@brillout/vite-plugin-server-entry": "^0.7.17", + "@brillout/vite-plugin-server-entry": "0.7.18", "cac": "^6.0.0", "es-module-lexer": "^1.0.0", "esbuild": ">=0.19.0", @@ -7991,12 +7986,12 @@ } }, "node_modules/vike-react": { - "version": "0.6.18", - "resolved": "https://registry.npmjs.org/vike-react/-/vike-react-0.6.18.tgz", - "integrity": "sha512-kMoRGhdP7nSE59iWMQ3SkC9O4ISdHhnxJlwwH03VUFuUpZp2OgLUCPmlgBeIj7TU0z+P2KHhschmojvcZanL4w==", + "version": "0.6.21", + "resolved": "https://registry.npmjs.org/vike-react/-/vike-react-0.6.21.tgz", + "integrity": "sha512-mZyskc8xXJbUbK8/Lsztgd0MhrCxnQjHhCpruMVokA+jJC1uuZEAgwoO5Fptoiyqm2pjDfqtAGDw+1pZ97d9OA==", "license": "MIT", "dependencies": { - "react-streaming": "^0.4.15" + "react-streaming": "^0.4.16" }, "peerDependencies": { "react": ">=19", @@ -8457,8 +8452,8 @@ "@vitejs/plugin-react": "^4.7.0", "fastify": "^5.6.2", "uuid": "^9.0.0", - "vike": "0.4.253", - "vike-react": "^0.6.18", + "vike": "0.4.255", + "vike-react": "^0.6.21", "vite": "^6.3.5" }, "devDependencies": { diff --git a/tests/e2e/specs/http.spec.ts b/tests/e2e/specs/http.spec.ts index 260a259..181248d 100644 --- a/tests/e2e/specs/http.spec.ts +++ b/tests/e2e/specs/http.spec.ts @@ -99,6 +99,23 @@ test.describe("requests", () => { }); }); + test("wrapped with error in layout", async ({ request }) => { + const req = await request.get(toPath( + { + title: "SSR Error", + layout: "ssr_error", + content: "proxied content here", + } + )); + expect(diagnostics(req)).toEqual({ + status: 500, + pageId: "/pages/_error", + layout: ["ssr_error"], + proxyMode: "wrapped", + sentProxyHeaders: true, + }); + }); + test("HEAD request on vite-page", async ({ request }) => { const req = await request.head("./vite-page"); expect(diagnostics(req)).toEqual({ diff --git a/tests/vite/package.json b/tests/vite/package.json index 6a0e6bd..3c202d7 100644 --- a/tests/vite/package.json +++ b/tests/vite/package.json @@ -21,8 +21,8 @@ "@vitejs/plugin-react": "^4.7.0", "fastify": "^5.6.2", "uuid": "^9.0.0", - "vike": "0.4.253", - "vike-react": "^0.6.18", + "vike": "0.4.255", + "vike-react": "^0.6.21", "vite": "^6.3.5" }, "devDependencies": { From 27e1b403e16613d8a26be24cf6db72fd08ecc472 Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Wed, 15 Apr 2026 17:53:29 -0400 Subject: [PATCH 2/4] fix tsc --- bifrost/lib/type.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bifrost/lib/type.ts b/bifrost/lib/type.ts index 9b81c73..6306519 100644 --- a/bifrost/lib/type.ts +++ b/bifrost/lib/type.ts @@ -11,7 +11,7 @@ export interface WrappedServerOnly { // Instead, we nest them inside wrappedServerOnly and move them to top-level pageContext in onBeforeRenderHtml proxyLayoutInfo: Vike.ProxyLayoutInfo; // Marker to verify that render succeeded - renderedBody: boolean; + renderedBody?: boolean; } declare global { From fd2949e879498ca606ca4bbd7826a16988053f7f Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Thu, 16 Apr 2026 09:41:53 -0400 Subject: [PATCH 3/4] fixes --- bifrost/renderer/wrapped/Page.tsx | 4 ++-- bifrost/renderer/wrapped/onBeforeRender.client.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/bifrost/renderer/wrapped/Page.tsx b/bifrost/renderer/wrapped/Page.tsx index 75737db..9d5314c 100644 --- a/bifrost/renderer/wrapped/Page.tsx +++ b/bifrost/renderer/wrapped/Page.tsx @@ -10,8 +10,8 @@ export default function Page() { ? pageContext._turbolinksProxy?.body?.innerHTML : pageContext._wrappedServerOnly?.bodyInnerHtml; - // Set marker so we can render error if not - if (!pageContext.isClientSide && pageContext._wrappedServerOnly) { + // Set marker so we can render error if failed to render body due to error in surrounding Layout + if (bodyHtml && !pageContext.isClientSide && pageContext._wrappedServerOnly) { pageContext._wrappedServerOnly.renderedBody = true; } diff --git a/bifrost/renderer/wrapped/onBeforeRender.client.ts b/bifrost/renderer/wrapped/onBeforeRender.client.ts index be958f9..6afcd73 100644 --- a/bifrost/renderer/wrapped/onBeforeRender.client.ts +++ b/bifrost/renderer/wrapped/onBeforeRender.client.ts @@ -108,6 +108,10 @@ export default async function wrappedOnBeforeRender( parsed.innerHTML = html; const bodyEl = parsed.querySelector("body")!; const headEl = parsed.querySelector("head")!; + if(!bodyEl || !headEl) { + await hardNavigate(resp.url); + return; + } pageContext.proxyLayoutInfo = layoutInfo; pageContext._turbolinksProxy = { body: bodyEl, From 2cb3c726aca2e707a6bc33e90d72d06e078442e2 Mon Sep 17 00:00:00 2001 From: Da-Jin Chu Date: Thu, 16 Apr 2026 10:07:13 -0400 Subject: [PATCH 4/4] check mime type --- .../renderer/wrapped/onBeforeRender.client.ts | 6 +-- tests/e2e/specs/e2e.spec.ts | 52 +++++++++++++++---- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/bifrost/renderer/wrapped/onBeforeRender.client.ts b/bifrost/renderer/wrapped/onBeforeRender.client.ts index 6afcd73..98378c4 100644 --- a/bifrost/renderer/wrapped/onBeforeRender.client.ts +++ b/bifrost/renderer/wrapped/onBeforeRender.client.ts @@ -90,7 +90,7 @@ export default async function wrappedOnBeforeRender( throw redirect(resp.url); } } - if (!resp.ok) { + if (!resp.ok || !resp.headers.get("content-type")?.includes("text/html")) { await hardNavigate(resp.url); } } @@ -108,10 +108,6 @@ export default async function wrappedOnBeforeRender( parsed.innerHTML = html; const bodyEl = parsed.querySelector("body")!; const headEl = parsed.querySelector("head")!; - if(!bodyEl || !headEl) { - await hardNavigate(resp.url); - return; - } pageContext.proxyLayoutInfo = layoutInfo; pageContext._turbolinksProxy = { body: bodyEl, diff --git a/tests/e2e/specs/e2e.spec.ts b/tests/e2e/specs/e2e.spec.ts index 02c91c0..9cdb76a 100644 --- a/tests/e2e/specs/e2e.spec.ts +++ b/tests/e2e/specs/e2e.spec.ts @@ -278,7 +278,11 @@ test("wrapped proxy shows error page when layout has SSR error", async ({ page, }) => { await page.goto( - toPath({ title: "SSR Error", layout: "ssr_error", content: "proxied content here" }), + toPath({ + title: "SSR Error", + layout: "ssr_error", + content: "proxied content here", + }), { waitUntil: "networkidle" } ); @@ -286,7 +290,6 @@ test("wrapped proxy shows error page when layout has SSR error", async ({ await expect(page.getByText("500 Internal Server Error")).toHaveCount(1); }); - // If passToClient is misconfigured we will end up sending proxy content in HTML and the JSON hydration blob, doubling page size. // Being able to configure this is why we chose VPS over next.js or remix which always serialize all props test("on SSR of proxied page, proxy content is only sent once", async ({ @@ -431,6 +434,19 @@ test.describe("client navigation", () => { expect(page.url().endsWith("vite-page")).toBeTruthy(); }); }); + + test("to route with header but no body", async ({ page, baseURL }) => { + const customProxy = new CustomProxyPage(page, { + title: "a", + content: "json wrapped", + }); + await customProxy.goto(); + + await ensureBrowserNavigation(page, async () => { + await page.getByText("json wrapped").click(); + await expect(page.locator("body")).toContainText(`{"data":true}`); + }); + }); }); test.describe("redirects", () => { @@ -1788,11 +1804,23 @@ test.describe("diagnostic events", () => { await customProxy.clickLink("second page"); const events = await getDiagnosticEvents(page); - expect(events).toContainEqual({ type: "start", fnName: "_vikeBeforeRender" }); + expect(events).toContainEqual({ + type: "start", + fnName: "_vikeBeforeRender", + }); expect(events).toContainEqual({ type: "end", fnName: "_vikeBeforeRender" }); - expect(events).toContainEqual({ type: "start", fnName: "_waitForHeadScripts" }); - expect(events).toContainEqual({ type: "end", fnName: "_waitForHeadScripts" }); - expect(events).toContainEqual({ type: "start", fnName: "_vikeAfterRender" }); + expect(events).toContainEqual({ + type: "start", + fnName: "_waitForHeadScripts", + }); + expect(events).toContainEqual({ + type: "end", + fnName: "_waitForHeadScripts", + }); + expect(events).toContainEqual({ + type: "start", + fnName: "_vikeAfterRender", + }); expect(events).toContainEqual({ type: "end", fnName: "_vikeAfterRender" }); }); @@ -1838,8 +1866,14 @@ test.describe("diagnostic events", () => { events.findIndex((e) => e.type === type && e.fnName === fnName); // beforeRender starts and ends before afterRender - expect(idx("start", "_vikeBeforeRender")).toBeLessThan(idx("end", "_vikeBeforeRender")); - expect(idx("end", "_vikeBeforeRender")).toBeLessThan(idx("start", "_vikeAfterRender")); - expect(idx("start", "_vikeAfterRender")).toBeLessThan(idx("end", "_vikeAfterRender")); + expect(idx("start", "_vikeBeforeRender")).toBeLessThan( + idx("end", "_vikeBeforeRender") + ); + expect(idx("end", "_vikeBeforeRender")).toBeLessThan( + idx("start", "_vikeAfterRender") + ); + expect(idx("start", "_vikeAfterRender")).toBeLessThan( + idx("end", "_vikeAfterRender") + ); }); });