From 6384dd06a5143b89160f5a29f995e188bdac6f6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89verton=20Toffanetto?= Date: Mon, 12 Oct 2026 22:04:32 -0300 Subject: [PATCH] fix(api): let createAPIHandler take the request event start.middleware in @solidjs/vite-plugin now calls (event, next), and the handler read its first argument as a Request, so new URL(request.url) threw. Read event.request when given an event and hand that event to handlers. getEvent still wins, and a bare Request keeps reading the request-event scope. Update the README and JSDoc, and point the scope error at passing the event. --- .changeset/event-first-api-handler.md | 5 ++++ README.md | 6 +++-- src/api.ts | 26 +++++++++++-------- test/api.spec.ts | 36 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 .changeset/event-first-api-handler.md diff --git a/.changeset/event-first-api-handler.md b/.changeset/event-first-api-handler.md new file mode 100644 index 0000000..af9b29e --- /dev/null +++ b/.changeset/event-first-api-handler.md @@ -0,0 +1,5 @@ +--- +"filesystem-routing": patch +--- + +`createAPIHandler` takes the request event as its first argument as well as the request, so it drops into event-first chains such as `@solidjs/vite-plugin`'s `start.middleware`, where the documented `export default [createAPIHandler(routes)]` failed every request with `TypeError: Invalid URL` (the event has no `url`). Given an event, handlers receive that event with the matched `params` written onto it, and no request-event scope is needed; `getEvent` still takes precedence. `(request, next)` chains behave as before. diff --git a/README.md b/README.md index 0d66bd8..7e4948d 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,8 @@ pages get: Dispatch ships as fetch-style middleware — `filesystem-routing/api` matches requests against the manifest's handler refs and composes into any -`(request, next)` chain, an SSR handler's middleware option included: +`(request, next)` or `(event, next)` chain, an SSR handler's middleware option +(`@solidjs/vite-plugin`'s `start.middleware`) included: ```ts // src/middleware.ts @@ -175,7 +176,8 @@ import { createAPIHandler } from "filesystem-routing/api"; export default [createAPIHandler(routes)]; ``` -Handlers receive the request event of the surrounding scope (the storage +Handlers receive the event the chain passes in or, in a `(request, next)` +chain, the request event of the surrounding scope (the storage `provideRequestEvent` establishes — pass `getEvent` to dispatch outside one), with the matched `params` written onto it. Unmatched requests (no route, or no handler for the method) advance the chain; `HEAD` falls back diff --git a/src/api.ts b/src/api.ts index fc05360..9c83b39 100644 --- a/src/api.ts +++ b/src/api.ts @@ -3,9 +3,9 @@ import { createRouter } from "radix3"; /** * The API dispatch adapter: matches requests against the flat manifest's * handler refs (`$GET`, `$POST`, …) and serves them as fetch-style - * middleware, so API routes compose into any `(request, next)` chain — an - * SSR handler's middleware option, a framework's request pipeline — with no - * bundler or server involvement. + * middleware, so API routes compose into any `(request, next)` or + * `(event, next)` chain — an SSR handler's middleware option, a framework's + * request pipeline — with no bundler or server involvement. * * Ported from SolidStart's `server/routes.ts` (the radix3 matcher) and the * method dispatch in its `server/handler.ts`. @@ -134,7 +134,8 @@ export interface APIHandlerOptions { base?: string; /** * How the adapter reaches the request event it hands to handlers. Defaults - * to the core request-event scope (the storage `provideRequestEvent` + * to the event the chain passes in or, when it passes a bare `Request`, to + * the core request-event scope (the storage `provideRequestEvent` * establishes), which is where a middleware chain runs — override it when * dispatching outside one, or to hand handlers a richer event. */ @@ -147,8 +148,8 @@ function getScopedEvent(): APIEvent { const event = (globalThis as Record)[RequestContext]?.getStore(); if (!event) { throw new Error( - "API route dispatched outside a request-event scope. Compose the handler " + - "inside `provideRequestEvent` (any `ssr.middleware`-style chain already is), " + + "API route dispatched outside a request-event scope. Pass the request event " + + "instead of the request, compose the handler inside `provideRequestEvent`, " + "or pass `getEvent`." ); } @@ -171,6 +172,10 @@ function toResponse(result: unknown): Response { * export default [createAPIHandler(routes)]; * ``` * + * The first argument is the request or a request event carrying it + * (`event.request`), so the handler fits `(request, next)` and + * `(event, next)` chains alike; it calls `next()` without arguments. + * * Dispatch preserves SolidStart's semantics: unmatched requests (no route, * or no handler for the method) advance the chain; `HEAD` falls back to the * `GET` handler; matched params are written to `event.params` before the @@ -183,13 +188,13 @@ export function createAPIHandler( routes: readonly FileRouteHandlers[], options: APIHandlerOptions = {} ): ( - request: Request, + input: Request | { request: Request }, next: (request?: Request) => Response | Promise ) => Promise { const match = createAPIMatcher(routes); - const getEvent = options.getEvent ?? getScopedEvent; - return async (request, next) => { + return async (input, next) => { + const request = "request" in input ? input.request : input; const url = new URL(request.url); const matched = match(stripPathBase(url.pathname, options.base ?? "/"), request.method); if (!matched) return next(); @@ -199,7 +204,8 @@ export function createAPIHandler( const fn = request.method === "HEAD" ? mod.HEAD || mod.GET : mod[request.method]; if (typeof fn !== "function") return next(); - const event = getEvent(); + const event = + options.getEvent?.() ?? ("request" in input ? (input as APIEvent) : getScopedEvent()); event.params = matched.params || {}; const result = await fn(event); diff --git a/test/api.spec.ts b/test/api.spec.ts index 9b43a6e..c56f852 100644 --- a/test/api.spec.ts +++ b/test/api.spec.ts @@ -183,4 +183,40 @@ describe("createAPIHandler", () => { /request-event scope/ ); }); + + it("takes the request event in place of the request, as event-first chains pass it", async () => { + const routes = [ + { + path: "/api/posts/:id", + $GET: ref({ GET: event => Response.json({ id: event.params!.id, user: event.user }) }) + } + ]; + const event = { request: new Request("http://test/api/posts/42"), user: "passed" }; + + const response = await createAPIHandler(routes)(event, next); + expect(await response.json()).toEqual({ id: "42", user: "passed" }); + expect((event as APIEvent).params).toEqual({ id: "42" }); + + const richer = { request: event.request, user: "richer" }; + const viaOption = await createAPIHandler(routes, { getEvent: () => richer })(event, next); + expect(await viaOption.json()).toEqual({ id: "42", user: "richer" }); + }); + + it("advances an event-first chain with a bare next()", async () => { + const routes = [ + { path: "/about", page: true, $component: {}, $GET: ref({ GET: () => undefined }) } + ]; + const handler = createAPIHandler(routes); + + const unmatched = vi.fn(next); + await handler({ request: new Request("http://test/other") }, unmatched); + expect(unmatched).toHaveBeenCalledOnce(); + expect(unmatched).toHaveBeenCalledWith(); + + const declined = vi.fn(next); + const response = await handler({ request: new Request("http://test/about") }, declined); + expect(declined).toHaveBeenCalledOnce(); + expect(declined).toHaveBeenCalledWith(); + expect(await response.text()).toBe("rendered"); + }); });