Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 66 additions & 1 deletion src/server/dev-server/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
56 changes: 48 additions & 8 deletions src/server/dev-server/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -136,6 +141,7 @@ async function loadMiddlewareFromVirtualFS(
middlewarePath: string,
adapter: RuntimeAdapter,
strictExport: boolean,
sourceFile = "middleware.ts",
): Promise<MiddlewareFunction[]> {
const fs = createFileSystem();

Expand Down Expand Up @@ -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<string, unknown>)
: [];

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
Expand All @@ -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"
Expand All @@ -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 [];
Expand Down