diff --git a/src/app.ts b/src/app.ts index 6406f9b110e..bdfad0e7b68 100644 --- a/src/app.ts +++ b/src/app.ts @@ -21,10 +21,12 @@ import { renderToString } from "preact-render-to-string"; import { FinishSetup, ForgotBuild } from "./finish_setup.tsx"; import { HttpError } from "./error.ts"; -const DEFAULT_NOT_FOUND = () => - Promise.resolve(new Response("Not found", { status: 404 })); -const DEFAULT_NOT_ALLOWED_METHOD = () => - Promise.resolve(new Response("Method not allowed", { status: 405 })); +const DEFAULT_NOT_FOUND = () => { + throw new HttpError(404); +}; +const DEFAULT_NOT_ALLOWED_METHOD = () => { + throw new HttpError(405); +}; export type ListenOptions = Partial & { remoteAddress?: string; @@ -163,39 +165,6 @@ export class App { return this; } - async #process( - req: Request, - params: Record, - handlers: MiddlewareFn[][], - next: () => Promise, - ) { - const ctx = new FreshReqContext( - req, - this.config, - next, - this.#islandRegistry, - this.#buildCache!, - ); - - ctx.params = params; - - try { - if (handlers.length === 1 && handlers[0].length === 1) { - return handlers[0][0](ctx); - } - - ctx.next = next; - return await runMiddlewares(handlers, ctx); - } catch (err) { - if (err instanceof HttpError) { - return new Response(null, { status: err.status }); - } - - console.error(err); - return new Response("Internal server error", { status: 500 }); - } - } - async handler(): Promise< (request: Request, info?: Deno.ServeHandlerInfo) => Promise > { @@ -207,7 +176,7 @@ export class App { return missingBuildHandler; } - return (req: Request) => { + return async (req: Request) => { const url = new URL(req.url); // Prevent open redirect attacks url.pathname = url.pathname.replace(/\/+/g, "/"); @@ -215,16 +184,36 @@ export class App { const method = req.method.toUpperCase() as Method; const matched = this.#router.match(method, url); - const fallback = matched.patternMatch && !matched.methodMatch + const next = matched.patternMatch && !matched.methodMatch ? DEFAULT_NOT_ALLOWED_METHOD : DEFAULT_NOT_FOUND; - return this.#process( + const { params, handlers } = matched; + const ctx = new FreshReqContext( req, - matched.params, - matched.handlers, - fallback, + this.config, + next, + this.#islandRegistry, + this.#buildCache!, ); + + ctx.params = params; + + try { + if (handlers.length === 1 && handlers[0].length === 1) { + return handlers[0][0](ctx); + } + + ctx.next = next; + return await runMiddlewares(handlers, ctx); + } catch (err) { + if (err instanceof HttpError) { + return new Response(err.message, { status: err.status }); + } + + console.error(err); + return new Response("Internal server error", { status: 500 }); + } }; } diff --git a/src/app_test.tsx b/src/app_test.tsx index 72a9561b3d2..c9d9d1566ce 100644 --- a/src/app_test.tsx +++ b/src/app_test.tsx @@ -212,7 +212,7 @@ Deno.test("FreshApp - wrong method match", async () => { let res = await server.put("/"); expect(res.status).toEqual(405); - expect(await res.text()).toEqual("Method not allowed"); + expect(await res.text()).toEqual("Method Not Allowed"); res = await server.post("/"); expect(res.status).toEqual(200); diff --git a/src/error.ts b/src/error.ts index 365a4a63da3..e6e2c149531 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,13 +1,149 @@ import { MODE } from "./runtime/server/mod.tsx"; +export function getMessage(status: number): string { + switch (status) { + case 100: + return "Continue"; + case 101: + return "Switching Protocols"; + case 102: + return "Processing (WebDAV)"; + case 103: + return "Early Hints"; + case 200: + return "OK"; + case 201: + return "Created"; + case 202: + return "Accepted"; + case 203: + return "Non-Authoritative Information"; + case 204: + return "No Content"; + case 205: + return "Reset Content"; + case 206: + return "Partial Content"; + case 207: + return "Multi-Status (WebDAV)"; + case 208: + return "Already Reported (WebDAV)"; + case 226: + return "IM Used (HTTP Delta encoding)"; + case 300: + return "Multiple Choices"; + case 301: + return "Moved Permanently"; + case 302: + return "Found"; + case 303: + return "See Other"; + case 304: + return "Not Modified"; + case 305: + return "Use Proxy Deprecated"; + case 306: + return "unused"; + case 307: + return "Temporary Redirect"; + case 308: + return "Permanent Redirect"; + case 400: + return "Bad Request"; + case 401: + return "Unauthorized"; + case 402: + return "Payment Required Experimental"; + case 403: + return "Forbidden"; + case 404: + return "Not Found"; + case 405: + return "Method Not Allowed"; + case 406: + return "Not Acceptable"; + case 407: + return "Proxy Authentication Required"; + case 408: + return "Request Timeout"; + case 409: + return "Conflict"; + case 410: + return "Gone"; + case 411: + return "Length Required"; + case 412: + return "Precondition Failed"; + case 413: + return "Payload Too Large"; + case 414: + return "URI Too Long"; + case 415: + return "Unsupported Media Type"; + case 416: + return "Range Not Satisfiable"; + case 417: + return "Expectation Failed"; + case 418: + return "I'm a teapot"; + case 421: + return "Misdirected Request"; + case 422: + return "Unprocessable Content (WebDAV)"; + case 423: + return "Locked (WebDAV)"; + case 424: + return "Failed Dependency (WebDAV)"; + case 425: + return "Too Early Experimental"; + case 426: + return "Upgrade Required"; + case 428: + return "Precondition Required"; + case 429: + return "Too Many Requests"; + case 431: + return "Request Header Fields Too Large"; + case 451: + return "Unavailable For Legal Reasons"; + case 500: + return "Internal Server Error"; + case 501: + return "Not Implemented"; + case 502: + return "Bad Gateway"; + case 503: + return "Service Unavailable"; + case 504: + return "Gateway Timeout"; + case 505: + return "HTTP Version Not Supported"; + case 506: + return "Variant Also Negotiates"; + case 507: + return "Insufficient Storage (WebDAV)"; + case 508: + return "Loop Detected (WebDAV)"; + case 510: + return "Not Extended"; + case 511: + return "Network Authentication Required"; + default: + return "Internal Server Error"; + } +} + export class HttpError { #error: Error | null = null; name = "HttpError"; + message: string; constructor( public status: number, + message: string = getMessage(status), public options?: ErrorOptions, ) { + this.message = message; if (MODE !== "production") { this.#error = new Error(); } diff --git a/src/plugins/fs_routes/mod.ts b/src/plugins/fs_routes/mod.ts index fecddef2a57..45d6fb9aafd 100644 --- a/src/plugins/fs_routes/mod.ts +++ b/src/plugins/fs_routes/mod.ts @@ -14,6 +14,7 @@ import { type Method, pathToPattern } from "../../router.ts"; import { type HandlerFn, isHandlerByMethod } from "../../handlers.ts"; import { type FsAdapter, fsAdapter } from "../../fs.ts"; import type { PageProps } from "../../runtime/server/mod.tsx"; +import { HttpError } from "../../error.ts"; const TEST_FILE_PATTERN = /[._]test\.(?:[tj]sx?|[mc][tj]s)$/; const GROUP_REG = /(^|[/\\\\])\((_[^/\\\\]+)\)[/\\\\]/; @@ -183,6 +184,12 @@ export async function fsRoutes( } else if (normalized.endsWith("/_error")) { stack.push(routeMod); continue; + } else if (normalized.endsWith("/_404")) { + stack.push(routeMod); + continue; + } else if (normalized.endsWith("/_500")) { + stack.push(routeMod); + continue; } // Remove any elements not matching our parent path anymore @@ -227,7 +234,7 @@ export async function fsRoutes( } } - if (mod.path.endsWith("/_error")) { + if (mod.path.endsWith("/_error") || mod.path.endsWith("/_500")) { const handlers = mod.handlers; const handler = handlers === null || (isHandlerByMethod(handlers) && Object.keys(handlers).length === 0) @@ -243,6 +250,22 @@ export async function fsRoutes( continue; } + if (mod.path.endsWith("/_404")) { + const handlers = mod.handlers; + const handler = handlers === null || + (isHandlerByMethod(handlers) && Object.keys(handlers).length === 0) + ? undefined + : typeof handlers === "function" + ? handlers + : undefined; // FIXME: Method handler + const notFoundComponents = components.slice(); + if (mod.component !== null) { + notFoundComponents.push(mod.component); + } + app.use(notFoundMiddleware(notFoundComponents, handler)); + continue; + } + if (mod.component !== null) { components.push(mod.component); } @@ -298,6 +321,23 @@ function errorMiddleware( }; } +function notFoundMiddleware( + components: AnyComponent>[], + handler: HandlerFn | undefined, +): MiddlewareFn { + const mid = renderMiddleware(components, handler); + return async function notFoundMiddleware(ctx) { + try { + return await ctx.next(); + } catch (err) { + if (err instanceof HttpError && err.status === 404) { + return mid(ctx); + } + throw err; + } + }; +} + async function walkDir( dir: string, callback: (entry: WalkEntry) => void, diff --git a/src/plugins/fs_routes/mod_test.tsx b/src/plugins/fs_routes/mod_test.tsx index ffe461c6aa1..22537bed8c4 100644 --- a/src/plugins/fs_routes/mod_test.tsx +++ b/src/plugins/fs_routes/mod_test.tsx @@ -542,11 +542,49 @@ Deno.test("fsRoutes - route overrides _app", async () => { expect(doc.body.firstChild?.textContent).toEqual("route"); }); +Deno.test("fsRoutes - _404", async () => { + const server = await createServer({ + "routes/_404.tsx": { + default: () => { + return
Custom 404 - Not Found
; + }, + }, + "routes/index.tsx": { + handlers: () => { + throw new Error("ok"); + }, + }, + }); + + const res = await server.get("/invalid"); + const content = await res.text(); + expect(content).toContain("Custom 404 - Not Found"); +}); + +Deno.test("fsRoutes - _500", async () => { + const server = await createServer({ + "routes/_500.tsx": { + default: () => { + return
Custom Error Page
; + }, + }, + "routes/index.tsx": { + handlers: () => { + throw new Error("ok"); + }, + }, + }); + + const res = await server.get("/"); + const content = await res.text(); + expect(content).toContain("Custom Error Page"); +}); + Deno.test("fsRoutes - _error", async () => { const server = await createServer({ "routes/_error.tsx": { - default: (ctx) => { - return
{(ctx.error as Error).message}
; + default: () => { + return
Custom Error Page
; }, }, "routes/index.tsx": { @@ -557,8 +595,8 @@ Deno.test("fsRoutes - _error", async () => { }); const res = await server.get("/"); - const doc = parseHtml(await res.text()); - expect(doc.body.firstChild?.textContent).toEqual("ok"); + const content = await res.text(); + expect(content).toContain("Custom Error Page"); }); Deno.test("fsRoutes - _error nested", async () => {