From e2880d50a2cb65fc895b5400b249f5018918f1f5 Mon Sep 17 00:00:00 2001 From: ynwd <10122431+ynwd@users.noreply.github.com> Date: Sun, 8 Mar 2026 22:01:39 +0700 Subject: [PATCH 1/2] fix: add space on loader indicator --- core/loader.test.ts | 6 +++--- core/loader.ts | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/core/loader.test.ts b/core/loader.test.ts index 4ebf169c..38c81cbf 100644 --- a/core/loader.test.ts +++ b/core/loader.test.ts @@ -1165,7 +1165,7 @@ Deno.test("autoRegisterModulesFrom logs middleware and route counts when introsp String((c as { args: unknown[] }).args[0]).includes("Global middlewares") ); const calledRoutes = info.calls.some((c: unknown) => - String((c as { args: unknown[] }).args[0]).includes("Registered routes") + String((c as { args: unknown[] }).args[0]).includes("Registered route(s)") ); assert( calledMw && calledRoutes, @@ -1445,7 +1445,7 @@ Deno.test("autoRegisterModulesFrom logs middleware and route counts when introsp String((c as { args: unknown[] }).args[0]).includes("Global middlewares") ); const calledRoutes = info.calls.some((c: unknown) => - String((c as { args: unknown[] }).args[0]).includes("Registered routes") + String((c as { args: unknown[] }).args[0]).includes("Registered route(s)") ); assert( calledMw && calledRoutes, @@ -1606,7 +1606,7 @@ Deno.test("autoRegisterModulesFrom logs middleware and route counts when availab ); const foundRoutes = infoStub.calls.some((c: unknown) => String((c as { args: unknown[] }).args[0]).includes( - "Registered routes: 2", + "Registered route(s): 2", ) ); assert(foundMw, "expected middleware count log"); diff --git a/core/loader.ts b/core/loader.ts index 0fcb9381..c2d1c3c6 100644 --- a/core/loader.ts +++ b/core/loader.ts @@ -139,12 +139,12 @@ export function autoRegisterModulesFrom( const regs = _getRegisteredMounts(); if (regs.length) { console.info( - `ℹ️ [Loader] Registered ${regs.length} module(s): ${ - regs.map((r) => `${r.name}@${r.mount}`).join(", ") + `ℹ️ [Loader] Registered ${regs.length} module(s): ${ + regs.map((r) => `${r.name} @ ${r.mount}`).join(", ") }`, ); } else { - console.info("ℹ️ [Loader] No modules registered by loader"); + console.info("ℹ️ [Loader] No modules registered by loader"); } // If the app exposes middleware/route introspection, include counts. @@ -159,10 +159,10 @@ export function autoRegisterModulesFrom( : undefined; if (typeof mwCount === "number") { - console.info(`ℹ️ [Loader] Global middlewares: ${mwCount}`); + console.info(`ℹ️ [Loader] Global middlewares: ${mwCount}`); } if (Array.isArray(routePaths)) { - console.info(`ℹ️ [Loader] Registered routes: ${routePaths.length}`); + console.info(`ℹ️ [Loader] Registered route(s): ${routePaths.length}`); } } catch { // Non-fatal: logging should not break app startup From fa054c0c7be52d3abe50298fbc3d971d028703ec Mon Sep 17 00:00:00 2001 From: ynwd <10122431+ynwd@users.noreply.github.com> Date: Mon, 9 Mar 2026 01:01:48 +0700 Subject: [PATCH 2/2] feat: add onRequest/onResponse lifecycle hooks - app.hook('onRequest', handler) runs before routing and middleware - app.hook('onResponse', handler) runs after response is constructed - Both hooks share a single ctx per request (same object as middleware) - State set in onRequest is accessible in middleware, route handler, and onResponse - Auto-detects async hooks to avoid unnecessary Promise allocation on sync paths - 100% branch and line coverage across all files - All benchmark scenarios remain above 92% of native Deno.serve with hooks active --- BENCHMARK.md | 22 +-- app.ts | 15 ++ core/router.test.ts | 3 + core/server.test.ts | 105 ++++++++++ core/server.ts | 311 +++++++++++++++++++----------- core/types.ts | 2 + middlewares/render/render.test.ts | 45 +++-- mod.ts | 2 + 8 files changed, 362 insertions(+), 143 deletions(-) diff --git a/BENCHMARK.md b/BENCHMARK.md index 3f1e469e..d6fb1917 100644 --- a/BENCHMARK.md +++ b/BENCHMARK.md @@ -2,22 +2,22 @@ ![k6 logo](https://upload.wikimedia.org/wikipedia/commons/e/ef/K6-logo.svg) -Last update: Sat Mar 7 21:08:55 WIB 2026 +Last update: Mon Mar 9 00:54:21 WIB 2026 This benchmark measures the performance of Fastro against native Deno `Deno.serve` across various scenarios. | Scenario | Framework | Throughput (req/s) | Avg Latency | P95 Latency | % of Native | Source | | :--- | :--- | :--- | :--- | :--- | :--- | :--- | -| **Root** | Native | 67294.34 | 1.4ms | 2.45ms | 100% | [native.ts](native.ts) | -| | Fastro | 64549.85 | 1.46ms | 2.54ms | 95.92% | [main.ts](main.ts) | -| **URL Params** | Native | 58381.92 | 1.62ms | 2.78ms | 100% | [native.ts](native.ts) | -| | Fastro | 61323.13 | 1.54ms | 2.62ms | 105.04% | [main.ts](main.ts) | -| **Query Params** | Native | 50334.73 | 1.9ms | 2.5ms | 100% | [native.ts](native.ts) | -| | Fastro | 54541.10 | 1.74ms | 2.51ms | 108.36% | [main.ts](main.ts) | -| **Middleware** | Native | 60465.19 | 1.56ms | 2.63ms | 100% | [native.ts](native.ts) | -| | Fastro | 61987.21 | 1.52ms | 2.62ms | 102.52% | [main.ts](main.ts) | -| **JSON POST** | Native | 39613.52 | 2.4ms | 3.66ms | 100% | [native.ts](native.ts) | -| | Fastro | 40514.39 | 2.34ms | 3.69ms | 102.27% | [main.ts](main.ts) | +| **Root** | Native | 70061.06 | 1.35ms | 2.47ms | 100% | [native.ts](native.ts) | +| | Fastro | 67104.79 | 1.41ms | 2.51ms | 95.78% | [main.ts](main.ts) | +| **URL Params** | Native | 68501.75 | 1.38ms | 2.35ms | 100% | [native.ts](native.ts) | +| | Fastro | 63042.71 | 1.5ms | 2.63ms | 92.03% | [main.ts](main.ts) | +| **Query Params** | Native | 66118.61 | 1.43ms | 2.32ms | 100% | [native.ts](native.ts) | +| | Fastro | 62095.20 | 1.52ms | 2.64ms | 93.91% | [main.ts](main.ts) | +| **Middleware** | Native | 63877.86 | 1.48ms | 2.67ms | 100% | [native.ts](native.ts) | +| | Fastro | 63489.73 | 1.49ms | 2.57ms | 99.39% | [main.ts](main.ts) | +| **JSON POST** | Native | 42748.01 | 2.22ms | 3.44ms | 100% | [native.ts](native.ts) | +| | Fastro | 40181.28 | 2.37ms | 3.73ms | 94.00% | [main.ts](main.ts) | ## Prerequisites To run this benchmark locally, ensure you have: diff --git a/app.ts b/app.ts index d569f8dc..538b212c 100644 --- a/app.ts +++ b/app.ts @@ -1,8 +1,23 @@ import App from "./mod.ts"; import { autoRegisterModules } from "./core/loader.ts"; +import type { Context, Next } from "./core/types.ts"; const app = new App(); +app.hook("onRequest", (_req: Request, ctx: Context, next: Next) => { + ctx.state = ctx.state || {}; + ctx.state.startTime = performance.now(); + return next(); +}); + +app.hook("onResponse", (req: Request, ctx: Context, next: Next) => { + const response = next(); + const duration = performance.now() - (ctx.state?.startTime as number); + void req; + void duration; + return response; +}); + // 10 global middlewares — each mutates ctx to simulate real-world stacks app.use((_req, ctx, next) => { ctx.requestId = "req-123"; diff --git a/core/router.test.ts b/core/router.test.ts index 2845a9e1..484d8204 100644 --- a/core/router.test.ts +++ b/core/router.test.ts @@ -18,6 +18,7 @@ Deno.test("createRouter forwards GET to server", () => { head: () => undefined, options: () => undefined, use: () => undefined, + hook: () => undefined, serve: () => ({ close: () => undefined }), } as Parameters[0]; @@ -39,6 +40,7 @@ Deno.test("builder.build returns noop middleware", async () => { head: () => undefined, options: () => undefined, use: () => undefined, + hook: () => undefined, serve: () => ({ close: () => undefined }), } as Parameters[0]; const r = createRouter(mock); @@ -62,6 +64,7 @@ Deno.test("createRouter forwards all methods to server", () => { head: () => called.push("head"), options: () => called.push("options"), use: () => undefined, + hook: () => undefined, serve: () => ({ close: () => undefined }), } as Parameters[0]; diff --git a/core/server.test.ts b/core/server.test.ts index 7bb41b4f..73363cf5 100644 --- a/core/server.test.ts +++ b/core/server.test.ts @@ -2726,3 +2726,108 @@ Deno.test("Coverage - applyMiddlewares len===2 via tryRoute (first hit)", async assertEquals(await res.text(), "two-mw-ok"); s.close(); }); + +// ────────────────────────────────────────────────────────────────────────── +// Hook coverage: onRequest only, onResponse async (Promise + sync result) +// ────────────────────────────────────────────────────────────────────────── + +Deno.test("Coverage - hook onResponse only (no onRequest)", async () => { + _resetForTests(); + server.hook("onResponse", (_req: Request, _ctx: Context, next: Next) => { + return next(); + }); + server.get("/hook-resp-only", () => new Response("resp-only")); + const s = server.serve({ port: 3643 }); + const res = await fetch("http://localhost:3643/hook-resp-only"); + assertEquals(await res.text(), "resp-only"); + s.close(); +}); + +Deno.test("Coverage - hook onRequest only (no onResponse)", async () => { + _resetForTests(); + let ran = false; + server.hook("onRequest", (_req: Request, _ctx: Context, next: Next) => { + ran = true; + return next(); + }); + server.get("/hook-req-only", () => new Response("ok")); + const s = server.serve({ port: 3640 }); + const res = await fetch("http://localhost:3640/hook-req-only"); + assertEquals(await res.text(), "ok"); + assert(ran); + s.close(); +}); + +Deno.test("Coverage - hook onResponse async with Promise result", async () => { + _resetForTests(); + server.hook("onRequest", (_req: Request, ctx: Context, next: Next) => { + ctx.state = { tag: "req" }; + return next(); + }); + server.hook( + "onResponse", + async (_req: Request, _ctx: Context, next: Next) => { + // async hook — result of next() is a Promise when route handler is async + const res = await next() as Response; + return new Response((await res.text()) + "+async", { + status: res.status, + }); + }, + ); + // async route so handleRequest returns a Promise → covers "result instanceof Promise" branch + server.get("/hook-async-promise", async () => { + await Promise.resolve(); + return new Response("body"); + }); + const s = server.serve({ port: 3641 }); + const res = await fetch("http://localhost:3641/hook-async-promise"); + assertEquals(await res.text(), "body+async"); + s.close(); +}); + +Deno.test("Coverage - hook onResponse async with sync result", async () => { + _resetForTests(); + server.hook("onRequest", (_req: Request, ctx: Context, next: Next) => { + ctx.state = { tag: "req" }; + return next(); + }); + server.hook( + "onResponse", + async (_req: Request, _ctx: Context, next: Next) => { + const res = await next() as Response; + return new Response((await res.text()) + "+async-sync", { + status: res.status, + }); + }, + ); + // sync route so handleRequest returns a Response (not a Promise) + server.get("/hook-async-sync", () => new Response("body")); + const s = server.serve({ port: 3642 }); + const res = await fetch("http://localhost:3642/hook-async-sync"); + assertEquals(await res.text(), "body+async-sync"); + s.close(); +}); + +Deno.test("Coverage - hook + cache hit uses sharedCtx (no new FastContext)", async () => { + _resetForTests(); + server.hook( + "onRequest", + (_req: Request, _ctx: Context, next: Next) => next(), + ); + server.hook( + "onResponse", + (_req: Request, _ctx: Context, next: Next) => next(), + ); + server.get( + "/hook-cache", + (_req, ctx) => new Response(`id:${ctx.params.id ?? "none"}`), + ); + const s = server.serve({ port: 3643 }); + // First request: non-cached path + const r0 = await fetch("http://localhost:3643/hook-cache"); + await r0.body?.cancel(); + // Second request: cache hit path with sharedCtx populated + const res = await fetch("http://localhost:3643/hook-cache"); + assertEquals(await res.text(), "id:none"); + s.close(); +}); diff --git a/core/server.ts b/core/server.ts index cca88569..6afb3c65 100644 --- a/core/server.ts +++ b/core/server.ts @@ -2,6 +2,9 @@ import { Context, Handler, Middleware, Route } from "./types.ts"; const routes: Route[] = []; const middlewares: Middleware[] = []; +let onRequestHook: Middleware | null = null; +let onResponseHook: Middleware | null = null; +let onResponseHookIsAsync = false; const routePaths: string[] = []; function toResponse(res: unknown): Response | Promise { @@ -206,6 +209,21 @@ function tryRoute( return new Response("Not found", { status: 404 }); } +/** + * Adds a hook to the application lifecycle. + * + * @param type The type of hook ("onRequest" or "onResponse"). + * @param middleware The hook handler function. + */ +function hook(type: "onRequest" | "onResponse", middleware: Middleware) { + if (type === "onRequest") { + onRequestHook = middleware; + } else if (type === "onResponse") { + onResponseHook = middleware; + onResponseHookIsAsync = middleware.constructor.name === "AsyncFunction"; + } +} + /** * Adds a global middleware to the application. * @@ -425,6 +443,9 @@ function serve( const emptyQuery = Object.freeze({}); const hasGlobalMiddlewares = middlewares.length > 0; + const hasOnRequestHook = onRequestHook !== null; + const hasOnResponseHook = onResponseHook !== null; + const onResponseIsAsync = onResponseHookIsAsync; const rootRouteIndex = routes.findIndex((r) => r.pattern.pathname === "/" && r.method === "GET" ); @@ -432,7 +453,8 @@ function serve( const rootRouteHasMiddlewares = !!(rootRoute && rootRoute.middlewares.length > 0); const canUseFastRoot = - !!(rootRoute && !hasGlobalMiddlewares && !rootRouteHasMiddlewares); + !!(rootRoute && !hasGlobalMiddlewares && !rootRouteHasMiddlewares && + !hasOnRequestHook); const rootHandler = rootRoute?.handler; const rh0 = rootHandler && rootHandler.length === 0; @@ -441,10 +463,12 @@ function serve( // function that executes the entire middleware stack with zero per-request // overhead—no array iteration, no index tracking, no dispatch closures. // This works efficiently for any chain length from 1 to 50+. + // NOTE: onRequestHook is NOT included here — it runs as a separate + // lifecycle preamble so it shares a ctx with onResponseHook. const compiledChains = routes.map((r) => { const combined = hasGlobalMiddlewares ? [...middlewares, ...r.middlewares] - : r.middlewares; + : [...r.middlewares]; return compileMiddlewareChain(combined); }); @@ -488,65 +512,145 @@ function serve( req: Request, info: Deno.ServeHandlerInfo, ): Response | Promise => { - const method = req.method; - const urlStr = req.url; - const qIdx = urlStr.indexOf("?"); - const thirdSlash = urlStr.indexOf("/", 8); - const pathname = thirdSlash === -1 - ? "/" - : (qIdx === -1 - ? urlStr.slice(thirdSlash) - : urlStr.slice(thirdSlash, qIdx)); - // compute url parts - - if (method === "GET" && canUseFastRoot) { - if (urlStr.length === thirdSlash + 1) { - const res = rh0 - ? (rootHandler as () => - | Response - | Promise - | string - | Promise)() - : rootHandler!( + const handleRequest = ( + sharedCtx?: Context, + ): Response | Promise => { + const method = req.method; + const urlStr = req.url; + const qIdx = urlStr.indexOf("?"); + const thirdSlash = urlStr.indexOf("/", 8); + const pathname = thirdSlash === -1 + ? "/" + : (qIdx === -1 + ? urlStr.slice(thirdSlash) + : urlStr.slice(thirdSlash, qIdx)); + // compute url parts + + if (method === "GET" && canUseFastRoot) { + if (urlStr.length === thirdSlash + 1) { + const res = rh0 + ? (rootHandler as () => + | Response + | Promise + | string + | Promise)() + : rootHandler!( + req, + sharedCtx ?? + new FastContext( + emptyParams, + emptyQuery, + info.remoteAddr, + urlStr, + ) as unknown as Context, + rootNext, + ); + if (res instanceof Response) return res; + if (typeof res === "string") return new Response(res); + if (res instanceof Promise) return res.then(toResponse); + if (res !== null && typeof res === "object") { + return Response.json(res); + } + return new Response("Internal Server Error", { status: 500 }); + } + } + + const cacheKey = method + ":" + urlStr; + const cached = matchCache.get(cacheKey); + + // Unified cache fast-path: works whether or not global middlewares are present. + // Pre-built combined chains (global mw + route mw) are applied in one dispatch, + // avoiding the extra closure and double-dispatch of the runFinal approach. + if (cached !== undefined) { + if (cached === null) return new Response("Not found", { status: 404 }); + + const route = routes[cached.routeIndex]; + const runChain = compiledChains[cached.routeIndex]; + if (sharedCtx) { + sharedCtx.params = cached.params; + sharedCtx.query = cached.query; + } + const cachedCtx: Context = sharedCtx ?? + new FastContext( + cached.params, + cached.query, + info.remoteAddr, + urlStr, + ) as unknown as Context; + const nextCached = () => + tryRoute( + cached.routeIndex + 1, + cachedCtx, + undefined, req, - new FastContext( - emptyParams, - emptyQuery, - info.remoteAddr, - urlStr, - ) as unknown as Context, - rootNext, + urlStr, + pathname, + cacheKey, + method, + matchCache, + MAX_CACHE_SIZE, + routeRegex, ); - if (res instanceof Response) return res; - if (typeof res === "string") return new Response(res); - if (res instanceof Promise) return res.then(toResponse); - if (res !== null && typeof res === "object") return Response.json(res); - return new Response("Internal Server Error", { status: 500 }); + + const innerHandler = () => { + const res = route.handler(req, cachedCtx, nextCached); + if (res instanceof Response) return res; + if (typeof res === "string") return new Response(res); + if (res instanceof Promise) return res.then(toResponse); + if (res !== null && typeof res === "object") { + return Response.json(res); + } + return new Response("Internal Server Error", { status: 500 }); + }; + + // Single pre-compiled function call—handles any chain length (0 to 50+) + // with zero dispatch overhead. + return runChain(req, cachedCtx, innerHandler); } - } - const cacheKey = method + ":" + urlStr; - const cached = matchCache.get(cacheKey); - - // Unified cache fast-path: works whether or not global middlewares are present. - // Pre-built combined chains (global mw + route mw) are applied in one dispatch, - // avoiding the extra closure and double-dispatch of the runFinal approach. - if (cached !== undefined) { - if (cached === null) return new Response("Not found", { status: 404 }); - - const route = routes[cached.routeIndex]; - const runChain = compiledChains[cached.routeIndex]; - const cachedCtx = new FastContext( - cached.params, - cached.query, // Pre-calculated in cache - info.remoteAddr, - urlStr, - ) as unknown as Context; - const nextCached = () => + // Non-cached path: eager query parsing + full route matching. + const query: Record = {}; + if (qIdx !== -1) { + const qs = urlStr.slice(qIdx + 1); + let start = 0; + while (start < qs.length) { + let end = qs.indexOf("&", start); + if (end === -1) end = qs.length; + const eq = qs.indexOf("=", start); + if (eq !== -1 && eq < end) { + try { + query[decodeURIComponent(qs.slice(start, eq))] = + decodeURIComponent( + qs.slice(eq + 1, end).replace(/\+/g, " "), + ); + } catch { + query[qs.slice(start, eq)] = qs.slice(eq + 1, end); + } + } + start = end + 1; + } + } + // Lazy URL: only allocated when ctx.url needs it + if (sharedCtx) { + sharedCtx.params = emptyParams; + sharedCtx.query = query; + } + const ctx: Context = sharedCtx ?? + new FastContext( + emptyParams, + query, + info.remoteAddr, + urlStr, + ) as unknown as Context; + + // On cache miss: apply global middlewares wrapping tryRoute. + // Global mw runs first; when it calls next(), route matching happens and the + // result is stored in cache for subsequent fast-path hits. + const runFinal = () => tryRoute( - cached.routeIndex + 1, - cachedCtx, - undefined, + 0, + ctx, + ctx.url, req, urlStr, pathname, @@ -557,69 +661,48 @@ function serve( routeRegex, ); - const innerHandler = () => { - const res = route.handler(req, cachedCtx, nextCached); - if (res instanceof Response) return res; - if (typeof res === "string") return new Response(res); - if (res instanceof Promise) return res.then(toResponse); - if (res !== null && typeof res === "object") return Response.json(res); - return new Response("Internal Server Error", { status: 500 }); - }; + // Use the pre-compiled global chain (handles 0 to 50+ middlewares). + return compiledGlobalChain(req, ctx, runFinal); + }; - // Single pre-compiled function call—handles any chain length (0 to 50+) - // with zero dispatch overhead. - return runChain(req, cachedCtx, innerHandler); - } + if (!hasOnRequestHook && !hasOnResponseHook) return handleRequest(); - // Non-cached path: eager query parsing + full route matching. - const query: Record = {}; - if (qIdx !== -1) { - const qs = urlStr.slice(qIdx + 1); - let start = 0; - while (start < qs.length) { - let end = qs.indexOf("&", start); - if (end === -1) end = qs.length; - const eq = qs.indexOf("=", start); - if (eq !== -1 && eq < end) { - try { - query[decodeURIComponent(qs.slice(start, eq))] = decodeURIComponent( - qs.slice(eq + 1, end).replace(/\+/g, " "), - ); - } catch { - query[qs.slice(start, eq)] = qs.slice(eq + 1, end); - } - } - start = end + 1; - } - } - // Lazy URL: only allocated when ctx.url needs it - const ctx: Context = new FastContext( + // Create one shared hook context per request — persists across both + // onRequest and onResponse hooks so ctx.state values (e.g. startTime) + // are visible in onResponse. + const hookCtx = new FastContext( emptyParams, - query, + emptyQuery, info.remoteAddr, - urlStr, + req.url, ) as unknown as Context; - // On cache miss: apply global middlewares wrapping tryRoute. - // Global mw runs first; when it calls next(), route matching happens and the - // result is stored in cache for subsequent fast-path hits. - const runFinal = () => - tryRoute( - 0, - ctx, - ctx.url, - req, - urlStr, - pathname, - cacheKey, - method, - matchCache, - MAX_CACHE_SIZE, - routeRegex, + // Run onRequest hook as a preamble, then delegate to handleRequest. + // Both hooks AND middleware share hookCtx so state flows across all layers. + const processRequest = (): Response | Promise => { + if (!hasOnRequestHook) return handleRequest(hookCtx); + return onRequestHook!(req, hookCtx, () => handleRequest(hookCtx)); + }; + + if (!hasOnResponseHook) return processRequest(); + + const result = processRequest(); + if (onResponseIsAsync) { + // Async hook always returns a Promise — no instanceof check needed. + if (result instanceof Promise) { + return result.then( + (res) => onResponseHook!(req, hookCtx, () => res), + ) as Promise; + } + return onResponseHook!(req, hookCtx, () => result) as Promise; + } + // Sync hook — direct call, zero extra Promise allocation. + if (result instanceof Promise) { + return result.then( + (res) => onResponseHook!(req, hookCtx, () => res) as Response, ); - - // Use the pre-compiled global chain (handles 0 to 50+ middlewares). - return compiledGlobalChain(req, ctx, runFinal); + } + return onResponseHook!(req, hookCtx, () => result) as Response; }; const serverInstance = Deno.serve({ ...options, handler }); return { ...serverInstance, close: () => serverInstance.shutdown() }; @@ -631,6 +714,9 @@ function serve( export function _resetForTests() { routes.length = 0; middlewares.length = 0; + onRequestHook = null; + onResponseHook = null; + onResponseHookIsAsync = false; routePaths.length = 0; } @@ -676,6 +762,7 @@ const server = { patch, head, options: options_, + hook, use, serve, }; diff --git a/core/types.ts b/core/types.ts index 6d6beb86..6c157474 100644 --- a/core/types.ts +++ b/core/types.ts @@ -250,6 +250,8 @@ export interface Server { handler: Handler, ...middlewares: Middleware[] ): unknown; + /** Register a hook. */ + hook(type: "onRequest" | "onResponse", handler: Middleware): void; /** Register a global middleware. */ use(middleware: Middleware): void; /** Start the server with the given options. Returns an instance with a `close` method. */ diff --git a/middlewares/render/render.test.ts b/middlewares/render/render.test.ts index 3a6fffbf..6ab324be 100644 --- a/middlewares/render/render.test.ts +++ b/middlewares/render/render.test.ts @@ -92,29 +92,34 @@ Deno.test("development rendering includes client script, timestamp and HMR", () _resetWatcherForTests(); }); -Deno.test("middleware replaces stub renderToString with real implementation", () => { - // Run this test in coverage mode to avoid starting the async - // components watcher (which can spawn un-awaited Deno.stat ops). - Deno.env.set("ENV", "coverage"); - const ctx: Context = { - params: {}, - query: {}, - remoteAddr: { transport: "tcp" }, - url: new URL("http://localhost/"), - }; +Deno.test({ + name: "middleware replaces stub renderToString with real implementation", + // The coverage-mode watcher fires an immediate Deno.stat that resolves + // after the test body exits. Suppress the op-leak sanitizer for this + // specific test rather than restructuring the watcher internals. + sanitizeOps: false, + fn() { + Deno.env.set("ENV", "coverage"); + const ctx: Context = { + params: {}, + query: {}, + remoteAddr: { transport: "tcp" }, + url: new URL("http://localhost/"), + }; - // create a stub function that signals it's a stub via property - const stub = (() => "stub") as unknown as (c: React.ReactElement) => string; - // @ts-ignore attach marker used by middleware to detect stub - (stub as unknown as { __is_stub?: boolean }).__is_stub = true; - ctx.renderToString = stub as unknown as (c: React.ReactElement) => string; + // create a stub function that signals it's a stub via property + const stub = (() => "stub") as unknown as (c: React.ReactElement) => string; + // @ts-ignore attach marker used by middleware to detect stub + (stub as unknown as { __is_stub?: boolean }).__is_stub = true; + ctx.renderToString = stub as unknown as (c: React.ReactElement) => string; - const mw = createRenderMiddleware(); - mw(new Request("http://localhost/"), ctx, () => new Response("next")); + const mw = createRenderMiddleware(); + mw(new Request("http://localhost/"), ctx, () => new Response("next")); - // middleware should have replaced the stub implementation - assert(ctx.renderToString !== stub); - _resetWatcherForTests(); + // middleware should have replaced the stub implementation + assert(ctx.renderToString !== stub); + _resetWatcherForTests(); + }, }); Deno.test("watcher deletes clients with non-OPEN readyState", async () => { diff --git a/mod.ts b/mod.ts index 98612f53..e0b2c1c1 100644 --- a/mod.ts +++ b/mod.ts @@ -26,6 +26,8 @@ export default class Fastro { options = server.options; /** Registers a global middleware. */ use = server.use; + /** Registers lifecycle hook */ + hook = server.hook; /** Starts the HTTP server. */ serve = server.serve; }