From 0511b8c15f71547805bbf9c89090055296b645bd Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 10 Jun 2026 16:38:49 +0000 Subject: [PATCH 1/2] fix(vite): propagate service fetch errors in dev to match production In dev, the vite env runner caught errors from service entries and rendered them in the worker, so `HTTPError` status/headers never reached the nitro h3 error handler like they do in production. Errors now propagate to the caller; the dev error page is only rendered at the env-runner fetch boundary. --- src/runtime/internal/vite/dev-worker.mjs | 28 ++++++++++++------------ test/vite/app.test.ts | 11 ++++++++++ 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/runtime/internal/vite/dev-worker.mjs b/src/runtime/internal/vite/dev-worker.mjs index 0cf82a1736..bfc182bce1 100644 --- a/src/runtime/internal/vite/dev-worker.mjs +++ b/src/runtime/internal/vite/dev-worker.mjs @@ -63,28 +63,24 @@ class ViteEnvRunner { } } + // Errors are intentionally not caught here: like production services, + // they propagate to the caller (the nitro app's error handler or the + // env-runner fetch boundary below). async fetch(req, init) { - if (this.entryError) { - return renderError(req, this.entryError); - } for (let i = 0; i < 5 && !(this.entry || this.entryError); i++) { await new Promise((r) => setTimeout(r, 100 * Math.pow(2, i))); } if (this.entryError) { - return renderError(req, this.entryError); + throw this.entryError; } if (!this.entry) { throw httpError(503, `Vite environment "${this.name}" is unavailable`); } - try { - const entryFetch = this.entry.fetch || this.entry.default?.fetch; - if (!entryFetch) { - throw httpError(500, `No fetch handler exported from ${this.entryPath}`); - } - return await entryFetch(req, init); - } catch (error) { - return renderError(req, error); + const entryFetch = this.entry.fetch || this.entry.default?.fetch; + if (!entryFetch) { + throw httpError(500, `No fetch handler exported from ${this.entryPath}`); } + return entryFetch(req, init); } } @@ -148,13 +144,17 @@ globalThis.__transform_html__ = async function (html) { // ----- Exports (env-runner AppEntry) ----- -export function fetch(req) { +export async function fetch(req) { const viteEnv = req?.headers.get("x-vite-env") || "nitro"; const env = envs[viteEnv]; if (!env) { return renderError(req, httpError(500, `Unknown vite environment "${viteEnv}"`)); } - return env.fetch(req); + try { + return await env.fetch(req); + } catch (error) { + return renderError(req, error); + } } export function upgrade(context) { diff --git a/test/vite/app.test.ts b/test/vite/app.test.ts index 89e15491bd..9d6a9ffe34 100644 --- a/test/vite/app.test.ts +++ b/test/vite/app.test.ts @@ -82,6 +82,17 @@ describe("vite:app", () => { expect(res.status).not.toBe(200); }); + // HTTPError thrown from the SSR entry must propagate to the nitro app so the h3 + // error handler preserves its status and headers (consistent with production). + test("propagates HTTPError status and headers from the SSR entry", async () => { + const res = await fetch(`${serverURL}/?error`, { + headers: { "sec-fetch-dest": "document", accept: "text/html" }, + redirect: "manual", + }); + expect(res.status).toBe(418); + expect(res.headers.get("x-test")).toBe("123"); + }); + // A page navigation matching only the SSR `/**` catch-all must reach the renderer. test("routes page navigations to the SSR catch-all renderer", async () => { const res = await fetch(`${serverURL}/some/nested/page`, { From b0f61950f178761d8a5c46248dbef2d3cdf55866 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Wed, 10 Jun 2026 17:06:09 +0000 Subject: [PATCH 2/2] test(vite): add ssr entry error fixture for regression test --- test/vite/app-fixture/app/entry-server.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/vite/app-fixture/app/entry-server.ts b/test/vite/app-fixture/app/entry-server.ts index 021e49f90c..21a7e0d549 100644 --- a/test/vite/app-fixture/app/entry-server.ts +++ b/test/vite/app-fixture/app/entry-server.ts @@ -1,8 +1,12 @@ +import { HTTPError } from "h3"; import { useStorage } from "nitro/storage"; import { useRuntimeConfig } from "nitro/runtime-config"; export default { - async fetch() { + async fetch(req: Request) { + if (req.url.includes("?error")) { + throw new HTTPError({ status: 418, headers: { "x-test": "123" } }); + } const storage = useStorage(); const config = useRuntimeConfig(); await storage.set("test:key", "value-from-ssr");