diff --git a/src/server/dev-server/middleware.test.ts b/src/server/dev-server/middleware.test.ts index ddf6e653d0..1634122b77 100644 --- a/src/server/dev-server/middleware.test.ts +++ b/src/server/dev-server/middleware.test.ts @@ -1,5 +1,10 @@ import "#veryfront/schemas/_test-setup.ts"; -import { assertEquals, assertRejects } from "#veryfront/testing/assert.ts"; +import { + assertEquals, + assertInstanceOf, + assertRejects, + assertStringIncludes, +} from "#veryfront/testing/assert.ts"; import { afterAll, describe, it } from "#veryfront/testing/bdd.ts"; import type { RuntimeAdapter } from "#veryfront/platform/adapters/base.ts"; import { loadMiddlewareFile } from "./middleware.ts"; @@ -74,3 +79,63 @@ describe("loadMiddlewareFile", () => { assertEquals(await loadMiddlewareFile("/app", adapter), []); }); }); + +describe("dev-server/middleware: actionable rejection", () => { + afterAll(async () => { + const { stop } = await import("veryfront/extensions/bundler"); + await stop(); + }); + + it("names the Next.js convention when a named middleware export is found", async () => { + // A root middleware.ts written for Next.js takes down every route, so the + // error has to be enough to fix the file without reading framework source. + const adapter = createVirtualAdapter( + "export function middleware(request) { return new Response('ok'); }", + ); + + const error = await assertRejects( + () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + TypeError, + ); + + // assertRejects hands back an unknown; narrow it before reading the copy. + assertInstanceOf(error, TypeError); + assertStringIncludes(error.message, "middleware.ts"); + assertStringIncludes(error.message, "Next.js convention"); + assertStringIncludes(error.message, "(c, next)"); + assertStringIncludes(error.message, "export default"); + assertStringIncludes(error.message, "docs/guides/middleware.md"); + }); + + it("lists the offending exports when the shape is merely wrong", async () => { + const adapter = createVirtualAdapter("export const handler = 1; export const other = 2;"); + + const error = await assertRejects( + () => loadMiddlewareFile("/app", adapter, { throwOnError: true }), + TypeError, + ); + + assertInstanceOf(error, TypeError); + assertStringIncludes(error.message, "Found export(s):"); + assertStringIncludes(error.message, "handler"); + assertStringIncludes(error.message, "other"); + }); + + it("still accepts a valid default export", async () => { + const adapter = createVirtualAdapter( + "export default async function (c, next) { return await next(); }", + ); + + const middleware = await loadMiddlewareFile("/app", adapter, { throwOnError: true }); + assertEquals(middleware.length, 1); + }); + + it("still accepts an array of functions", async () => { + const adapter = createVirtualAdapter( + "export default [async (c, next) => await next(), async (c, next) => await next()];", + ); + + const middleware = await loadMiddlewareFile("/app", adapter, { throwOnError: true }); + assertEquals(middleware.length, 2); + }); +}); diff --git a/src/server/dev-server/middleware.ts b/src/server/dev-server/middleware.ts index d4f3b348d1..3769d7f67d 100644 --- a/src/server/dev-server/middleware.ts +++ b/src/server/dev-server/middleware.ts @@ -116,12 +116,17 @@ export async function loadMiddlewareFile( middlewarePath, adapter, options.throwOnError === true, + middlewareFile, ); } const middlewareUrl = `file://${middlewarePath}?t=${Date.now()}-${crypto.randomUUID()}`; const middlewareModule = await import(middlewareUrl); - return normalizeMiddlewareExport(middlewareModule, options.throwOnError === true); + return normalizeMiddlewareExport( + middlewareModule, + options.throwOnError === true, + middlewareFile, + ); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.warn(`Failed to load ${middlewareFile}: ${errorMessage}`); @@ -136,6 +141,7 @@ async function loadMiddlewareFromVirtualFS( middlewarePath: string, adapter: RuntimeAdapter, strictExport: boolean, + sourceFile = "middleware.ts", ): Promise { const fs = createFileSystem(); @@ -171,15 +177,53 @@ async function loadMiddlewareFromVirtualFS( try { await fs.writeTextFile(tempFile, js); const middlewareModule = await import(`file://${tempFile}?v=${Date.now()}`); - return normalizeMiddlewareExport(middlewareModule, strictExport); + return normalizeMiddlewareExport(middlewareModule, strictExport, sourceFile); } finally { await fs.remove(tempDir, { recursive: true }); } } +/** + * Explain what a root middleware file must export. When the module looks like it + * was written for Next.js, say so, because that is overwhelmingly why this + * fails. A bad root middleware takes down every route, so the message has + * to be enough to fix the file without reading the source. + */ +function invalidMiddlewareExport( + middlewareModule: unknown, + sourceFile: string, +): TypeError { + const named = middlewareModule && typeof middlewareModule === "object" + ? Object.keys(middlewareModule as Record) + : []; + + const looksLikeNext = named.includes("middleware") && + typeof (middlewareModule as { middleware?: unknown }).middleware === "function"; + + const detail = looksLikeNext + ? `Found a named "middleware" export, which is the Next.js convention. ` + + `Veryfront expects a default export, and its middleware receives ` + + `(c, next), where c is a context carrying c.req, not the Request itself.` + : named.length > 0 + ? `Found export(s): ${named.join(", ")}.` + : `The module has no usable export.`; + + return new TypeError( + `Invalid middleware export in ${sourceFile}. ${detail}\n` + + `Expected a default export that is a middleware function, or a non-empty ` + + `array of them:\n\n` + + ` export default async function middleware(c, next) {\n` + + ` const response = await next();\n` + + ` return response;\n` + + ` }\n\n` + + `See docs/guides/middleware.md.`, + ); +} + function normalizeMiddlewareExport( middlewareModule: unknown, strict = false, + sourceFile = "middleware.ts", ): MiddlewareFunction[] { const exported = middlewareModule && typeof middlewareModule === "object" && "default" in middlewareModule @@ -190,9 +234,7 @@ function normalizeMiddlewareExport( if ( strict && (exported.length === 0 || exported.some((value) => typeof value !== "function")) ) { - throw new TypeError( - "Invalid middleware export: expected a function or non-empty array of functions", - ); + throw invalidMiddlewareExport(middlewareModule, sourceFile); } return exported.filter((middleware): middleware is MiddlewareFunction => typeof middleware === "function" @@ -204,9 +246,7 @@ function normalizeMiddlewareExport( } if (strict) { - throw new TypeError( - "Invalid middleware export: expected a function or non-empty array of functions", - ); + throw invalidMiddlewareExport(middlewareModule, sourceFile); } return [];