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..6306519 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..9d5314c 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 failed to render body due to error in surrounding Layout + if (bodyHtml && !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/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") + ); }); }); 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": {