From 1d04b58fe1db604832583027f146f2d50bede180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 09:09:47 +0100 Subject: [PATCH 1/5] feat: compile middleware chains at build time for faster request handling Pre-compiles middleware arrays into single handler functions during app initialization instead of rebuilding the chain on every request. Router now stores compiled handlers (T | null) instead of arrays (T[]). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/app.ts | 19 ++-- packages/fresh/src/commands.ts | 72 +++++++------- packages/fresh/src/middlewares/mod.ts | 103 ++++++++++++--------- packages/fresh/src/middlewares/mod_test.ts | 72 +++++++------- packages/fresh/src/router.ts | 52 +++++------ packages/fresh/src/router_test.ts | 20 ++-- 6 files changed, 182 insertions(+), 156 deletions(-) diff --git a/packages/fresh/src/app.ts b/packages/fresh/src/app.ts index 0c0e88bd62a..b2c0a44105c 100644 --- a/packages/fresh/src/app.ts +++ b/packages/fresh/src/app.ts @@ -2,11 +2,7 @@ import { trace } from "@opentelemetry/api"; import { DENO_DEPLOYMENT_ID } from "@fresh/build-id"; import * as colors from "@std/fmt/colors"; -import { - type MaybeLazyMiddleware, - type Middleware, - runMiddlewares, -} from "./middlewares/mod.ts"; +import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts"; import { Context } from "./context.ts"; import { mergePath, type Method, UrlPatternRouter } from "./router.ts"; import type { FreshConfig, ResolvedFreshConfig } from "./config.ts"; @@ -387,12 +383,13 @@ export class App { } } - const router = new UrlPatternRouter>(); + const router = new UrlPatternRouter>(); - const { rootMiddlewares } = applyCommands( + const { rootHandler } = applyCommands( router, this.#commands, this.config.basePath, + this.#onError, ); return async ( @@ -405,7 +402,7 @@ export class App { const method = req.method.toUpperCase() as Method; const matched = router.match(method, url); - let { params, pattern, handlers, methodMatch } = matched; + let { params, pattern, item: handler, methodMatch } = matched; const span = trace.getActiveSpan(); if (span && pattern) { @@ -416,7 +413,7 @@ export class App { let next: () => Promise; if (pattern === null || !methodMatch) { - handlers = rootMiddlewares; + handler = rootHandler; } if (matched.pattern !== null && !methodMatch) { @@ -442,9 +439,7 @@ export class App { ); try { - if (handlers.length === 0) return await next(); - - const result = await runMiddlewares(handlers, ctx, this.#onError); + const result = await (handler !== null ? handler(ctx) : next()); if (!(result instanceof Response)) { throw new Error( `Expected a "Response" instance to be returned, but got: ${result}`, diff --git a/packages/fresh/src/commands.ts b/packages/fresh/src/commands.ts index 87be2279a88..f207d5bc8e5 100644 --- a/packages/fresh/src/commands.ts +++ b/packages/fresh/src/commands.ts @@ -1,7 +1,11 @@ import { setAdditionalStyles } from "./context.ts"; import { HttpError } from "./error.ts"; import { isHandlerByMethod, type PageResponse } from "./handlers.ts"; -import type { MaybeLazyMiddleware, Middleware } from "./middlewares/mod.ts"; +import { + compileMiddlewares, + type MaybeLazyMiddleware, + type Middleware, +} from "./middlewares/mod.ts"; import { mergePath, type Method, type Router, toRoutePath } from "./router.ts"; import { getOrCreateSegment, @@ -202,22 +206,25 @@ export type Command = | FsRouteCommand; export function applyCommands( - router: Router>, + router: Router>, commands: Command[], basePath: string, -): { rootMiddlewares: MaybeLazyMiddleware[] } { + onError?: (err: unknown) => void, +): { rootHandler: Middleware } { const root = newSegment("", null); - applyCommandsInner(root, router, commands, basePath); + applyCommandsInner(root, router, commands, basePath, onError); - return { rootMiddlewares: segmentToMiddlewares(root) }; + const rootMiddlewares = segmentToMiddlewares(root); + return { rootHandler: compileMiddlewares(rootMiddlewares, onError) }; } function applyCommandsInner( root: Segment, - router: Router>, + router: Router>, commands: Command[], basePath: string, + onError?: (err: unknown) => void, ) { for (let i = 0; i < commands.length; i++) { const cmd = commands[i]; @@ -290,18 +297,19 @@ function applyCommandsInner( return renderRoute(ctx, def); }); + const compiled = compileMiddlewares(fns, onError); if (config === undefined || config.methods === "ALL") { - router.add("GET", routePath, fns); - router.add("DELETE", routePath, fns); - router.add("HEAD", routePath, fns); - router.add("OPTIONS", routePath, fns); - router.add("PATCH", routePath, fns); - router.add("POST", routePath, fns); - router.add("PUT", routePath, fns); + router.add("GET", routePath, compiled); + router.add("DELETE", routePath, compiled); + router.add("HEAD", routePath, compiled); + router.add("OPTIONS", routePath, compiled); + router.add("PATCH", routePath, compiled); + router.add("POST", routePath, compiled); + router.add("PUT", routePath, compiled); } else if (Array.isArray(config.methods)) { for (let i = 0; i < config.methods.length; i++) { const method = config.methods[i]; - router.add(method, routePath, fns); + router.add(method, routePath, compiled); } } } else { @@ -313,17 +321,18 @@ function applyCommandsInner( false, )); + const compiled = compileMiddlewares(fns, onError); if (typeof route.handler === "function") { - router.add("GET", routePath, fns); - router.add("DELETE", routePath, fns); - router.add("HEAD", routePath, fns); - router.add("OPTIONS", routePath, fns); - router.add("PATCH", routePath, fns); - router.add("POST", routePath, fns); - router.add("PUT", routePath, fns); + router.add("GET", routePath, compiled); + router.add("DELETE", routePath, compiled); + router.add("HEAD", routePath, compiled); + router.add("OPTIONS", routePath, compiled); + router.add("PATCH", routePath, compiled); + router.add("POST", routePath, compiled); + router.add("PUT", routePath, compiled); } else if (isHandlerByMethod(route.handler!)) { for (const method of Object.keys(route.handler)) { - router.add(method as Method, routePath, fns); + router.add(method as Method, routePath, compiled); } } } @@ -340,17 +349,18 @@ function applyCommandsInner( result.push(...fns); + const compiled = compileMiddlewares(result, onError); const resPath = toRoutePath(mergePath(basePath, pattern, false)); if (method === "ALL") { - router.add("GET", resPath, result); - router.add("DELETE", resPath, result); - router.add("HEAD", resPath, result); - router.add("OPTIONS", resPath, result); - router.add("PATCH", resPath, result); - router.add("POST", resPath, result); - router.add("PUT", resPath, result); + router.add("GET", resPath, compiled); + router.add("DELETE", resPath, compiled); + router.add("HEAD", resPath, compiled); + router.add("OPTIONS", resPath, compiled); + router.add("PATCH", resPath, compiled); + router.add("POST", resPath, compiled); + router.add("PUT", resPath, compiled); } else { - router.add(method, resPath, result); + router.add(method, resPath, compiled); } break; @@ -358,7 +368,7 @@ function applyCommandsInner( case CommandType.FsRoute: { const items = cmd.getItems(); const base = mergePath(basePath, cmd.pattern, true); - applyCommandsInner(root, router, items, base); + applyCommandsInner(root, router, items, base, onError); break; } default: diff --git a/packages/fresh/src/middlewares/mod.ts b/packages/fresh/src/middlewares/mod.ts index 445f3bb0a55..5091497c599 100644 --- a/packages/fresh/src/middlewares/mod.ts +++ b/packages/fresh/src/middlewares/mod.ts @@ -88,56 +88,69 @@ export type MaybeLazyMiddleware = ( ctx: Context, ) => Response | Promise>; -export async function runMiddlewares( +export function compileMiddlewares( middlewares: MaybeLazyMiddleware[], - ctx: Context, onError?: (err: unknown) => void, -): Promise { - return await tracer.startActiveSpan("middlewares", { - attributes: { "fresh.middleware.count": middlewares.length }, - }, async (span) => { - try { - let fn = ctx.next; - let i = middlewares.length; - while (i--) { - const local = fn; - let next = middlewares[i]; - const idx = i; - fn = async () => { - const internals = getInternals(ctx); - const { app: prevApp, layouts: prevLayouts } = internals; +): Middleware { + if (middlewares.length === 0) return (ctx) => ctx.next(); + + // Each step is a function that takes (ctx, tail) where tail is the + // original ctx.next captured at invocation time. This avoids the + // infinite recursion bug where the compiled tail reads an already- + // overwritten ctx.next, and is safe under concurrent requests. + type ChainFn = ( + ctx: Context, + tail: () => Promise, + ) => Response | Promise; + + let chain: ChainFn = (_ctx, tail) => tail(); - ctx.next = local; - try { - const result = await next(ctx); - if (typeof result === "function") { - middlewares[idx] = result; - next = result; - return await result(ctx); - } + for (let i = middlewares.length - 1; i >= 0; i--) { + const nextChain = chain; + let middleware = middlewares[i]; + chain = async (ctx, tail) => { + const internals = getInternals(ctx); + const { app: prevApp, layouts: prevLayouts } = internals; - return result; - } catch (err) { - if (ctx.error !== err) { - ctx.error = err; + ctx.next = () => Promise.resolve(nextChain(ctx, tail)); + try { + const result = await middleware(ctx); + if (typeof result === "function") { + middleware = result; + return await result(ctx); + } - if (onError !== undefined) { - onError(err); - } - } - throw err; - } finally { - internals.app = prevApp; - internals.layouts = prevLayouts; + return result; + } catch (err) { + if (ctx.error !== err) { + ctx.error = err; + + if (onError !== undefined) { + onError(err); } - }; + } + throw err; + } finally { + internals.app = prevApp; + internals.layouts = prevLayouts; + } + }; + } + + const count = middlewares.length; + return (ctx) => { + const tail = ctx.next; + return tracer.startActiveSpan("middlewares", { + attributes: { "fresh.middleware.count": count }, + }, async (span) => { + try { + return await chain(ctx, tail); + } catch (err) { + recordSpanError(span, err); + throw err; + } finally { + span.end(); } - return await fn(); - } catch (err) { - recordSpanError(span, err); - throw err; - } finally { - span.end(); - } - }); + }); + }; } diff --git a/packages/fresh/src/middlewares/mod_test.ts b/packages/fresh/src/middlewares/mod_test.ts index 77c44f1fd88..d4f7efefca2 100644 --- a/packages/fresh/src/middlewares/mod_test.ts +++ b/packages/fresh/src/middlewares/mod_test.ts @@ -1,10 +1,10 @@ -import { runMiddlewares } from "./mod.ts"; +import { compileMiddlewares } from "./mod.ts"; import { expect } from "@std/expect"; import { serveMiddleware } from "../test_utils.ts"; import type { Middleware } from "./mod.ts"; import type { Lazy, MaybeLazy } from "../types.ts"; -Deno.test("runMiddleware", async () => { +Deno.test("compileMiddlewares", async () => { const middlewares: Middleware<{ text: string }>[] = [ (ctx) => { ctx.state.text = "A"; @@ -24,15 +24,15 @@ Deno.test("runMiddleware", async () => { }, ]; - const server = serveMiddleware<{ text: string }>((ctx) => - runMiddlewares(middlewares, ctx) + const server = serveMiddleware<{ text: string }>( + compileMiddlewares(middlewares), ); const res = await server.get("/"); expect(await res.text()).toEqual("AB"); }); -Deno.test("runMiddleware - middlewares should only be called once", async () => { +Deno.test("compileMiddlewares - middlewares should only be called once", async () => { const A: Middleware<{ count: number }> = (ctx) => { if (ctx.state.count === undefined) { ctx.state.count = 0; @@ -42,21 +42,21 @@ Deno.test("runMiddleware - middlewares should only be called once", async () => return ctx.next(); }; - const server = serveMiddleware<{ count: number }>((ctx) => - runMiddlewares( - [A, (ctx) => new Response(String(ctx.state.count))], - ctx, - ) + const final: Middleware<{ count: number }> = (ctx) => + new Response(String(ctx.state.count)); + + const server = serveMiddleware<{ count: number }>( + compileMiddlewares([A, final]), ); const res = await server.get("/"); expect(await res.text()).toEqual("0"); }); -Deno.test("runMiddleware - runs multiple stacks", async () => { +Deno.test("compileMiddlewares - runs multiple stacks", async () => { type State = { text: string }; const A: Middleware = (ctx) => { - ctx.state.text += "A"; + ctx.state.text = "A"; return ctx.next(); }; const B: Middleware = (ctx) => { @@ -72,25 +72,18 @@ Deno.test("runMiddleware - runs multiple stacks", async () => { return ctx.next(); }; - const server = serveMiddleware((ctx) => { - ctx.state.text = ""; - return runMiddlewares( - [ - A, - B, - C, - D, - (ctx) => new Response(String(ctx.state.text)), - ], - ctx, - ); - }); + const final: Middleware = (ctx) => + new Response(String(ctx.state.text)); + + const server = serveMiddleware( + compileMiddlewares([A, B, C, D, final]), + ); const res = await server.get("/"); expect(await res.text()).toEqual("ABCD"); }); -Deno.test("runMiddleware - throws errors", async () => { +Deno.test("compileMiddlewares - throws errors", async () => { let thrownA: unknown = null; let thrownB: unknown = null; let thrownC: unknown = null; @@ -125,8 +118,8 @@ Deno.test("runMiddleware - throws errors", async () => { }, ]; - const server = serveMiddleware<{ text: string }>((ctx) => - runMiddlewares(middlewares, ctx) + const server = serveMiddleware<{ text: string }>( + compileMiddlewares(middlewares), ); try { @@ -139,7 +132,7 @@ Deno.test("runMiddleware - throws errors", async () => { expect(thrownC).toBeInstanceOf(Error); }); -Deno.test("runMiddleware - lazy middlewares", async () => { +Deno.test("compileMiddlewares - lazy middlewares", async () => { type State = { text: string }; let called = 0; @@ -164,9 +157,8 @@ Deno.test("runMiddleware - lazy middlewares", async () => { }, ]; - const server = serveMiddleware<{ text: string }>((ctx) => - runMiddlewares(middlewares, ctx) - ); + const compiled = compileMiddlewares(middlewares); + const server = serveMiddleware<{ text: string }>(compiled); let res = await server.get("/"); expect(await res.text()).toEqual("A_lazy_B"); @@ -177,3 +169,19 @@ Deno.test("runMiddleware - lazy middlewares", async () => { expect(await res.text()).toEqual("A_lazy_B"); expect(called).toEqual(1); }); + +Deno.test("compileMiddlewares - calls last next", async () => { + const middlewares: Middleware<{ text: string }>[] = [ + (ctx) => ctx.next(), + ]; + + const next = () => Promise.resolve(new Response("next")); + + const server = serveMiddleware<{ text: string }>( + compileMiddlewares(middlewares), + { next }, + ); + + const res = await server.get("/"); + expect(await res.text()).toEqual("next"); +}); diff --git a/packages/fresh/src/router.ts b/packages/fresh/src/router.ts index b731f7b9ce2..47fabd3abf9 100644 --- a/packages/fresh/src/router.ts +++ b/packages/fresh/src/router.ts @@ -8,7 +8,7 @@ export type Method = | "OPTIONS"; export type RouteByMethod = { - [m in Method]: T[]; + [m in Method]: T | null; }; export interface StaticRouteDef { @@ -23,30 +23,30 @@ export interface DynamicRouteDef { function newByMethod(): RouteByMethod { return { - GET: [], - POST: [], - PATCH: [], - DELETE: [], - PUT: [], - HEAD: [], - OPTIONS: [], + GET: null, + POST: null, + PATCH: null, + DELETE: null, + PUT: null, + HEAD: null, + OPTIONS: null, }; } export interface RouteResult { params: Record; - handlers: T[]; + item: T | null; methodMatch: boolean; pattern: string | null; } export interface Router { add( - method: Method | "ALL", + method: Method, pathname: string, - handlers: T[], + item: T, ): void; - match(method: Method, url: URL, init?: T[]): RouteResult; + match(method: Method, url: URL): RouteResult; getAllowedMethods(pattern: string): string[]; } @@ -69,7 +69,7 @@ export class UrlPatternRouter implements Router { add( method: Method, pathname: string, - handlers: T[], + item: T, ) { let allowed = this.#allowed.get(pathname); if (allowed === undefined) { @@ -105,13 +105,13 @@ export class UrlPatternRouter implements Router { byMethod = def.byMethod; } - byMethod[method].push(...handlers); + byMethod[method] = item; } - match(method: Method, url: URL, init: T[] = []): RouteResult { + match(method: Method, url: URL): RouteResult { const result: RouteResult = { params: Object.create(null), - handlers: init, + item: null, methodMatch: false, pattern: null, }; @@ -120,13 +120,13 @@ export class UrlPatternRouter implements Router { if (staticMatch !== undefined) { result.pattern = url.pathname; - let handlers = staticMatch.byMethod[method]; - if (method === "HEAD" && handlers.length === 0) { - handlers = staticMatch.byMethod.GET; + let item = staticMatch.byMethod[method]; + if (method === "HEAD" && item === null) { + item = staticMatch.byMethod.GET; } - if (handlers.length > 0) { + if (item !== null) { result.methodMatch = true; - result.handlers.push(...handlers); + result.item = item; } return result; @@ -140,14 +140,14 @@ export class UrlPatternRouter implements Router { result.pattern = route.pattern.pathname; - let handlers = route.byMethod[method]; - if (method === "HEAD" && handlers.length === 0) { - handlers = route.byMethod.GET; + let item = route.byMethod[method]; + if (method === "HEAD" && item === null) { + item = route.byMethod.GET; } - if (handlers.length > 0) { + if (item !== null) { result.methodMatch = true; - result.handlers.push(...handlers); + result.item = item; // Decode matched params for (const [key, value] of Object.entries(match.pathname.groups)) { diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index ae082a43b6a..29b80e09da1 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -20,12 +20,12 @@ Deno.test("IS_PATTERN", () => { Deno.test("UrlPatternRouter - GET extract params", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/:foo/:bar/c", [A]); + router.add("GET", "/:foo/:bar/c", A); let res = router.match("GET", new URL("/a/b/c", "http://localhost")); expect(res).toEqual({ params: { foo: "a", bar: "b" }, - handlers: [A], + item: A, methodMatch: true, pattern: "/:foo/:bar/c", }); @@ -34,7 +34,7 @@ Deno.test("UrlPatternRouter - GET extract params", () => { res = router.match("GET", new URL("/a%20a/b/c", "http://localhost")); expect(res).toEqual({ params: { foo: "a a", bar: "b" }, - handlers: [A], + item: A, methodMatch: true, pattern: "/:foo/:bar/c", }); @@ -43,12 +43,12 @@ Deno.test("UrlPatternRouter - GET extract params", () => { Deno.test("UrlPatternRouter - Wrong method match", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/foo", [A]); + router.add("GET", "/foo", A); const res = router.match("POST", new URL("/foo", "http://localhost")); expect(res).toEqual({ params: Object.create(null), - handlers: [], + item: null, methodMatch: false, pattern: "/foo", }); @@ -58,13 +58,13 @@ Deno.test("UrlPatternRouter - wrong + correct method", () => { const router = new UrlPatternRouter(); const A = () => {}; const B = () => {}; - router.add("GET", "/foo", [A]); - router.add("POST", "/foo", [B]); + router.add("GET", "/foo", A); + router.add("POST", "/foo", B); const res = router.match("POST", new URL("/foo", "http://localhost")); expect(res).toEqual({ params: Object.create(null), - handlers: [B], + item: B, methodMatch: true, pattern: "/foo", }); @@ -73,14 +73,14 @@ Deno.test("UrlPatternRouter - wrong + correct method", () => { Deno.test("UrlPatternRouter - convert patterns automatically", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/books/:id", [A]); + router.add("GET", "/books/:id", A); const res = router.match("GET", new URL("/books/foo", "http://localhost")); expect(res).toEqual({ params: { id: "foo", }, - handlers: [A], + item: A, methodMatch: true, pattern: "/books/:id", }); From bf357414f1601933150541e6026958256c1c691e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 09:31:06 +0100 Subject: [PATCH 2/5] fix: preserve first-registered route in router MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the router changed from arrays to single items, router.add() started overwriting existing routes. This broke cases where a handler and an fsRoute target the same path — the handler registered first should win. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/router.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/fresh/src/router.ts b/packages/fresh/src/router.ts index 47fabd3abf9..7cc6c67989f 100644 --- a/packages/fresh/src/router.ts +++ b/packages/fresh/src/router.ts @@ -105,7 +105,9 @@ export class UrlPatternRouter implements Router { byMethod = def.byMethod; } - byMethod[method] = item; + if (byMethod[method] === null) { + byMethod[method] = item; + } } match(method: Method, url: URL): RouteResult { From ba3a22bc4eb41289b6d35936c344a6393be2ab17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Thu, 26 Mar 2026 09:35:50 +0100 Subject: [PATCH 3/5] test: add more compileMiddlewares tests - Single middleware - Post-processing after next() (response wrapping) - Concurrent requests sharing compiled chain (verifies no state leakage) - onError callback invocation - Empty array falls through to ctx.next Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/middlewares/mod_test.ts | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/packages/fresh/src/middlewares/mod_test.ts b/packages/fresh/src/middlewares/mod_test.ts index d4f7efefca2..53aa0f5cb9c 100644 --- a/packages/fresh/src/middlewares/mod_test.ts +++ b/packages/fresh/src/middlewares/mod_test.ts @@ -170,6 +170,99 @@ Deno.test("compileMiddlewares - lazy middlewares", async () => { expect(called).toEqual(1); }); +Deno.test("compileMiddlewares - single middleware", async () => { + const server = serveMiddleware<{ text: string }>( + compileMiddlewares([() => new Response("single")]), + ); + + const res = await server.get("/"); + expect(await res.text()).toEqual("single"); +}); + +Deno.test("compileMiddlewares - post-processing after next()", async () => { + type State = { text: string }; + + const middlewares: Middleware[] = [ + async (ctx) => { + const res = await ctx.next(); + const text = await res.text(); + return new Response(`wrapped(${text})`); + }, + () => new Response("inner"), + ]; + + const server = serveMiddleware( + compileMiddlewares(middlewares), + ); + + const res = await server.get("/"); + expect(await res.text()).toEqual("wrapped(inner)"); +}); + +Deno.test("compileMiddlewares - concurrent requests share compiled chain", async () => { + type State = { text: string }; + let concurrency = 0; + let maxConcurrency = 0; + + const middlewares: Middleware[] = [ + async (ctx) => { + concurrency++; + if (concurrency > maxConcurrency) maxConcurrency = concurrency; + // Yield to let other requests enter the middleware + await new Promise((r) => setTimeout(r, 10)); + const res = await ctx.next(); + concurrency--; + return res; + }, + (ctx) => new Response(ctx.url.pathname), + ]; + + const compiled = compileMiddlewares(middlewares); + const server = serveMiddleware(compiled); + + // Fire concurrent requests + const results = await Promise.all([ + server.get("/a").then((r) => r.text()), + server.get("/b").then((r) => r.text()), + server.get("/c").then((r) => r.text()), + ]); + + expect(results).toEqual(["/a", "/b", "/c"]); + expect(maxConcurrency).toBeGreaterThan(1); +}); + +Deno.test("compileMiddlewares - onError callback", async () => { + const errors: unknown[] = []; + const middlewares: Middleware<{ text: string }>[] = [ + () => { + throw new Error("test error"); + }, + ]; + + const server = serveMiddleware<{ text: string }>( + compileMiddlewares(middlewares, (err) => errors.push(err)), + ); + + try { + await server.get("/"); + } catch { + // ignore + } + + expect(errors.length).toEqual(1); + expect(errors[0]).toBeInstanceOf(Error); +}); + +Deno.test("compileMiddlewares - empty array falls through to next", async () => { + const server = serveMiddleware<{ text: string }>( + compileMiddlewares([]), + { next: () => Promise.resolve(new Response("fallthrough")) }, + ); + + const res = await server.get("/"); + expect(await res.text()).toEqual("fallthrough"); +}); + Deno.test("compileMiddlewares - calls last next", async () => { const middlewares: Middleware<{ text: string }>[] = [ (ctx) => ctx.next(), From 9e211ef8968eb5d44b1635f5d794b6778d425160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 21:20:50 +0200 Subject: [PATCH 4/5] test: verify first-registered-wins behavior on duplicate routes The router changed from accumulating handlers (T[]) to storing a single handler (T | null) with first-registration priority. Add tests to document this behavior: duplicate registrations for the same method + pattern preserve the first handler, while different methods on the same pattern work independently. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/router_test.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index 29b80e09da1..79f6842f569 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -174,3 +174,30 @@ Deno.test("mergePath", () => { expect(mergePath("/foo", "*", true)).toEqual("/foo"); expect(mergePath("/foo", "/*", true)).toEqual("/foo/*"); }); + +Deno.test("UrlPatternRouter - first registered route wins on duplicate", () => { + const router = new UrlPatternRouter<() => string>(); + const first = () => "first"; + const second = () => "second"; + + router.add("GET", "/foo", first); + router.add("GET", "/foo", second); + + const res = router.match("GET", new URL("/foo", "http://localhost")); + expect(res.item).toBe(first); +}); + +Deno.test("UrlPatternRouter - duplicate registration different methods", () => { + const router = new UrlPatternRouter<() => string>(); + const getHandler = () => "get"; + const postHandler = () => "post"; + + router.add("GET", "/foo", getHandler); + router.add("POST", "/foo", postHandler); + + const getRes = router.match("GET", new URL("/foo", "http://localhost")); + expect(getRes.item).toBe(getHandler); + + const postRes = router.match("POST", new URL("/foo", "http://localhost")); + expect(postRes.item).toBe(postHandler); +}); From dd4c614226faa9574324e1cdf77651d1f9ac0af7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartek=20Iwa=C5=84czuk?= Date: Sun, 29 Mar 2026 21:27:10 +0200 Subject: [PATCH 5/5] fix: update router tests from main to use new single-item API Tests added on main used the old array-based router API (handlers: [], router.add(..., [A])). Updated to match the new single-item API (item:, router.add(..., A)) introduced by this PR. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/fresh/src/router_test.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/fresh/src/router_test.ts b/packages/fresh/src/router_test.ts index 54384cb1f31..a64aee214a6 100644 --- a/packages/fresh/src/router_test.ts +++ b/packages/fresh/src/router_test.ts @@ -73,12 +73,12 @@ Deno.test("UrlPatternRouter - wrong + correct method", () => { Deno.test("UrlPatternRouter - trailing slash matches route without slash", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/wissen", [A]); + router.add("GET", "/wissen", A); const res = router.match("GET", new URL("/wissen/", "http://localhost")); expect(res).toEqual({ params: Object.create(null), - handlers: [A], + item: A, methodMatch: true, pattern: "/wissen", }); @@ -87,12 +87,12 @@ Deno.test("UrlPatternRouter - trailing slash matches route without slash", () => Deno.test("UrlPatternRouter - no trailing slash matches route with slash", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/wissen/", [A]); + router.add("GET", "/wissen/", A); const res = router.match("GET", new URL("/wissen", "http://localhost")); expect(res).toEqual({ params: Object.create(null), - handlers: [A], + item: A, methodMatch: true, pattern: "/wissen/", }); @@ -102,8 +102,8 @@ Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fal const router = new UrlPatternRouter(); const A = () => {}; const B = () => {}; - router.add("GET", "/wissen", [A]); - router.add("GET", "/wissen/", [B]); + router.add("GET", "/wissen", A); + router.add("GET", "/wissen/", B); const withSlash = router.match( "GET", @@ -111,7 +111,7 @@ Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fal ); expect(withSlash).toEqual({ params: Object.create(null), - handlers: [B], + item: B, methodMatch: true, pattern: "/wissen/", }); @@ -122,7 +122,7 @@ Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fal ); expect(withoutSlash).toEqual({ params: Object.create(null), - handlers: [A], + item: A, methodMatch: true, pattern: "/wissen", }); @@ -131,12 +131,12 @@ Deno.test("UrlPatternRouter - exact match takes priority over trailing slash fal Deno.test("UrlPatternRouter - root trailing slash does not double-match", () => { const router = new UrlPatternRouter(); const A = () => {}; - router.add("GET", "/", [A]); + router.add("GET", "/", A); const res = router.match("GET", new URL("/", "http://localhost")); expect(res).toEqual({ params: Object.create(null), - handlers: [A], + item: A, methodMatch: true, pattern: "/", });