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
5 changes: 5 additions & 0 deletions .changeset/event-first-api-handler.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
26 changes: 16 additions & 10 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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.
*/
Expand All @@ -147,8 +148,8 @@ function getScopedEvent(): APIEvent {
const event = (globalThis as Record<symbol, any>)[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`."
);
}
Expand All @@ -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
Expand All @@ -183,13 +188,13 @@ export function createAPIHandler(
routes: readonly FileRouteHandlers[],
options: APIHandlerOptions = {}
): (
request: Request,
input: Request | { request: Request },
next: (request?: Request) => Response | Promise<Response>
) => Promise<Response> {
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();
Expand All @@ -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);

Expand Down
36 changes: 36 additions & 0 deletions test/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});