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
144 changes: 143 additions & 1 deletion src/routing/api/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { assertEquals, assertExists } from "#veryfront/testing/assert.ts";
import { afterEach, describe, it } from "#veryfront/testing/bdd.ts";
import { createMockAdapter } from "#veryfront/platform/adapters/mock.ts";
import { HTTP_OK } from "#veryfront/utils";
import { __injectDepsForTests, APIRouteHandler } from "./handler.ts";
import type { HandlerContext } from "#veryfront/types";
import {
__injectDepsForTests,
type APIRoute,
APIRouteHandler,
sanitizeLoadErrorForResponse,
} from "./handler.ts";

const handlers: APIRouteHandler[] = [];

Expand Down Expand Up @@ -474,4 +480,140 @@ describe("APIRouteHandler", () => {
});
});
});

// A load failure belongs to the attempt that produced it. Held on the
// instance, it outlived the request and the next route to fail for its own
// reason reported someone else's error.
describe("load failure scoping", () => {
async function handlerWithTwoRoutes(
onLoad: (modulePath: string) => Promise<APIRoute | null>,
): Promise<{ handler: APIRouteHandler; localCtx: HandlerContext }> {
const adapter = createMockAdapter();
adapter.fs.files.set(
"/test/project/pages/api/broken.ts",
"export function GET() { return new Response('broken'); }",
);
adapter.fs.files.set(
"/test/project/pages/api/empty.ts",
"export const notAMethod = 1;",
);

__injectDepsForTests({ loadHandlerModule: ({ modulePath }) => onLoad(modulePath) });

return {
handler: await createInitializedHandler("/test/project", adapter),
localCtx: {
projectDir: "/test/project",
adapter,
securityConfig: null,
cspUserHeader: null,
isLocalProject: true,
},
};
}

it("does not report one route's load error on another route", async () => {
const { handler, localCtx } = await handlerWithTwoRoutes((modulePath) => {
if (modulePath.includes("broken")) throw new Error("Unexpected token in broken.ts");
// A module with no HTTP exports: no error, just nothing to call.
return Promise.resolve({});
});

const broken = await handler.handle(new Request("http://localhost/api/broken"), localCtx);
assertEquals(broken?.status, 500);
assertEquals(await broken?.text(), "Unexpected token in broken.ts");

const empty = await handler.handle(new Request("http://localhost/api/empty"), localCtx);
assertEquals(empty?.status, 500);
assertEquals(await empty?.text(), "Handler not found");
});

it("classifies the allow-list block against the current attempt only", async () => {
const { handler } = await handlerWithTwoRoutes((modulePath) => {
if (modulePath.includes("broken")) {
throw new Error("Remote import blocked by allow-list: evil.example.com");
}
return Promise.resolve({});
});

const blocked = await handler.handle(new Request("http://localhost/api/broken"));
assertEquals(blocked?.status, 502);

const empty = await handler.handle(new Request("http://localhost/api/empty"));
assertEquals(empty?.status, 500, "a later route inherited the allow-list classification");
});
});

// AGENTS.md forbids local absolute paths, home directories, temp directories
// and full stack traces in user-facing output. A dev-mode 500 body is
// user-facing, and a raw module load error carries all four.
describe("sanitizeLoadErrorForResponse", () => {
const projectDir = "/PROJECT_ROOT/app";

it("keeps the actionable first line", () => {
const result = sanitizeLoadErrorForResponse(
'Expected ";" but found "}"\n at file:///PROJECT_ROOT/app/api/users.ts:12:3',
projectDir,
);

assertEquals(result, 'Expected ";" but found "}"');
});

it("drops the stack trace", () => {
const result = sanitizeLoadErrorForResponse(
"Boom\n at load (file:///PROJECT_ROOT/app/x.ts:1:1)\n at run (x.ts:2:2)",
projectDir,
);

assertEquals(result.includes(" at "), false);
});

it("makes a path inside the project relative", () => {
const result = sanitizeLoadErrorForResponse(
"Module not found: file:///PROJECT_ROOT/app/api/users.ts",
projectDir,
);

assertEquals(result, "Module not found: api/users.ts");
});

it("redacts a temp directory the bundle was written to", () => {
const result = sanitizeLoadErrorForResponse(
"Could not resolve /var/folders/kx/T/vf-bundle-1234/route.js",
projectDir,
);

assertEquals(result.includes("/var/folders/"), false);
assertEquals(result.includes("<PATH>"), true);
});

it("redacts a home directory", () => {
for (const path of ["/Users/someone/code/x.ts", "/home/someone/code/x.ts"]) {
const result = sanitizeLoadErrorForResponse(`Cannot find module ${path}`, projectDir);

assertEquals(result.includes("someone"), false, `leaked a home directory: ${result}`);
assertEquals(result.includes("<PATH>"), true);
}
});

it("redacts a file:// URL outside the project", () => {
const result = sanitizeLoadErrorForResponse(
"Failed to load file:///tmp/vf-9f/route.js",
projectDir,
);

assertEquals(result.includes("file://"), false);
});

it("truncates a very long message", () => {
const result = sanitizeLoadErrorForResponse("x".repeat(1000), projectDir);
assertEquals(result.length <= 303, true);
assertEquals(result.endsWith("..."), true);
});

it("handles an empty message and a missing project directory", () => {
assertEquals(sanitizeLoadErrorForResponse(""), "");
assertEquals(sanitizeLoadErrorForResponse("Handler not found"), "Handler not found");
});
});
});
80 changes: 67 additions & 13 deletions src/routing/api/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,40 @@ const HANDLER_CACHE_MAX_ENTRIES = 256;

export type { APIContext, APIRoute };

/** Longest sanitised load error a response body carries. */
const MAX_LOAD_ERROR_LENGTH = 300;

/**
* Roots that only ever identify the machine running the server: home
* directories and the temp directories bundling writes to.
*/
const MACHINE_PATH_PATTERN =
/(?:file:\/\/)?\/(?:private\/)?(?:Users|home|root|var\/folders|var\/tmp|tmp)\/\S*/g;

/**
* Reduce a module load error to something safe to put in a response body.
*
* The message is worth returning, since it usually names a syntax or import
* error the developer can act on. The rest of it is not: a raw load error
* carries the temp directory the bundle was written to, absolute paths into the
* framework install, and a full stack trace. A path inside the project survives
* as a project-relative one, because that is the part that identifies the file
* to fix.
*/
export function sanitizeLoadErrorForResponse(message: string, projectDir?: string): string {
// A stack trace is never the actionable part.
let text = (message.split("\n")[0] ?? "").trim();

if (projectDir) {
const root = projectDir.replace(/\/+$/, "");
text = text.replaceAll(`file://${root}/`, "").replaceAll(`${root}/`, "");
}

text = text.replace(MACHINE_PATH_PATTERN, "<PATH>");

return text.length > MAX_LOAD_ERROR_LENGTH ? `${text.slice(0, MAX_LOAD_ERROR_LENGTH)}...` : text;
}

/**
* Injection interface for testing APIRouteHandler dependencies
*/
Expand Down Expand Up @@ -59,10 +93,18 @@ export interface APIResponse {
/** Function signature for API route handlers. */
export type APIHandler = (ctx: APIContext) => Promise<Response> | Response;

/**
* Outcome of one load attempt. The failure travels with the attempt that
* produced it, so a later route can never report an earlier route's error.
*/
interface LoadAttempt {
handler: APIRoute | null;
errorMessage: string | null;
}

export class APIRouteHandler {
private router = new ApiRouteMatcher();
private routeCache = new LRUCache<string, APIRoute>({ maxEntries: HANDLER_CACHE_MAX_ENTRIES });
private lastErrorMessage: string | null = null;
private activeRequests = 0;
private destroyRequested = false;
private destroyed = false;
Expand Down Expand Up @@ -176,17 +218,30 @@ export class APIRouteHandler {
params: match.params,
});

const handler = await this.loadHandler(match);
const { handler, errorMessage } = await this.loadHandler(match);
if (!handler) {
const msg = errorMessage ?? "Handler not found";

try {
logger.error(`handler module failed to load: ${match.route.page}`);
// The full detail, paths and all, belongs in the log.
logger.error(`handler module failed to load: ${match.route.page}`, { reason: msg });
} catch (e) {
logger.warn("API error log failed", e);
}

const msg = this.lastErrorMessage ?? "Handler not found";
if (msg.includes("Remote import blocked by allow-list")) return badGateway(msg);
return internalServerError("Handler not found");

// The reason the module failed to load is the only useful thing here.
// Reporting a flat "Handler not found" for what is usually a syntax or
// import error sends people hunting for a routing problem that does
// not exist. Local development only, and sanitised: a raw load error
// carries temp directories, absolute paths and a stack trace, none of
// which belong in a response body.
return internalServerError(
ctx?.isLocalProject
? sanitizeLoadErrorForResponse(msg, this.projectDir)
: "Handler not found",
);
}

// App Router routes are always named route.ts/js/tsx/jsx
Expand Down Expand Up @@ -224,7 +279,7 @@ export class APIRouteHandler {
).finally(() => this.completeRequest());
}

private loadHandler(match: RouteMatch): Promise<APIRoute | null> {
private loadHandler(match: RouteMatch): Promise<LoadAttempt> {
const modulePath = match.route.page;

return withSpan(
Expand All @@ -234,7 +289,7 @@ export class APIRouteHandler {
await this.ensureConfig(adapter);

const cached = this.routeCache.get(modulePath);
if (cached) return cached;
if (cached) return { handler: cached, errorMessage: null };

try {
const deps = getDeps();
Expand All @@ -248,15 +303,14 @@ export class APIRouteHandler {
// Only cache handlers that export at least one HTTP method.
// Empty objects ({}) from failed imports are truthy but useless —
// caching them would prevent retry after the user fixes the import.
if (handler && Object.keys(handler).length > 0) {
this.routeCache.set(modulePath, handler);
}
return handler && Object.keys(handler).length > 0 ? handler : null;
const usable = handler && Object.keys(handler).length > 0 ? handler : null;
if (usable) this.routeCache.set(modulePath, usable);

return { handler: usable, errorMessage: null };
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
this.lastErrorMessage = msg;
logger.error(`[API] Failed to load handler for ${modulePath}: ${msg}`);
return null;
return { handler: null, errorMessage: msg };
}
},
{ "api.modulePath": modulePath },
Expand Down
Loading