diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index ce477fd..21f6ea5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from "vitepress"; import { transformerTwoslash } from "@shikijs/vitepress-twoslash"; import type { ModuleResolutionKind } from "typescript"; +import { defineConfig } from "vitepress"; // https://vitepress.dev/reference/site-config export default defineConfig({ @@ -28,7 +28,7 @@ export default defineConfig({ // https://vitepress.dev/reference/default-theme-config nav: [ { text: "Guide", link: "/guide/introduction" }, - { text: "Examples", link: "/examples/context-middleware" }, + { text: "Recipes", link: "/recipes/context-middleware" }, ], sidebar: [ @@ -50,19 +50,23 @@ export default defineConfig({ ], }, { - text: "Examples", + text: "Recipes", items: [ { text: "Updating the context", - link: "/examples/context-middleware", + link: "/recipes/context-middleware", }, { text: "Updating the headers", - link: "/examples/headers-middleware", + link: "/recipes/headers-middleware", }, { text: "Return an early response", - link: "/examples/guard-middleware", + link: "/recipes/guard-middleware", + }, + { + text: "Using route parameters", + link: "/recipes/params-handler", }, ], }, diff --git a/docs/definitions.md b/docs/definitions.md index 11a7bb8..8317ded 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -6,7 +6,7 @@ A function that returns a [Response](https://developer.mozilla.org/en-US/docs/We It will usually be associated with a _route_. ```ts twoslash -export interface UniversalHandler { +interface UniversalHandler { (request: Request, context: Context): Response | Promise; } ``` @@ -20,15 +20,19 @@ A function that alters the [Context](#context) or Response. Can also return an e It will usually _NOT_ be associated with a _route_. -Check the [examples](/examples/context-middleware) for details. +Check the [recipes](/recipes/context-middleware) for details. ```ts twoslash -export interface UniversalMiddleware { +type Awaitable = T | Promise; + +interface UniversalMiddleware { // [!code focus:9] (request: Request, context: InContext): - | Response | Promise // Can return an early Response - | void | Promise // Can return nothing - | OutContext | Promise // Can return a new context. Ensures type-safe context representation - | ((response: Response) => Response | Promise); // Can return a function that manipulates the Response + Awaitable< + | Response // Can return an early Response + | OutContext // Can return a new context. Ensures type-safe context representation + | ((response: Response) => Awaitable) // Can return a function that manipulates the Response + | void | undefined // Can return nothing + >; } ``` diff --git a/docs/guide/introduction.md b/docs/guide/introduction.md index c8e1eca..08a1fdf 100644 --- a/docs/guide/introduction.md +++ b/docs/guide/introduction.md @@ -10,7 +10,7 @@ This suite allows you to create and bundle server-related code that works with a ensuring consistency and reducing duplication. Efficiently develop a wide range of middleware and handlers, including but not limited to: -- [HTTP Header Middleware](/examples/headers-middleware): Easily modify or add headers to outgoing responses. -- [Context-Altering Middleware](/examples/context-middleware): Enhance request [context](/definitions#context), such as adding user authentication properties for logged-in users. -- [Guard Middleware](/examples/guard-middleware): Enforce request validation, like checking for an Authentication header before allowing further processing. +- [HTTP Header Middleware](/recipes/headers-middleware): Easily modify or add headers to outgoing responses. +- [Context-Altering Middleware](/recipes/context-middleware): Enhance request [context](/definitions#context), such as adding user authentication properties for logged-in users. +- [Guard Middleware](/recipes/guard-middleware): Enforce request validation, like checking for an Authentication header before allowing further processing. - Web Standard Handlers: Implement handlers that adhere to the Request/Response model defined by web standards. diff --git a/docs/guide/quick-start.md b/docs/guide/quick-start.md index b784e71..172baac 100644 --- a/docs/guide/quick-start.md +++ b/docs/guide/quick-start.md @@ -34,7 +34,7 @@ In this step, we will create a simple middleware that returns an early response ```ts twoslash // src/middlewares/demo.middleware.ts -import type { Get, UniversalMiddleware } from "universal-middleware"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; interface Config { header: string; diff --git a/docs/index.md b/docs/index.md index eccc5c8..c19b6a7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,8 +11,8 @@ hero: text: Guide link: /guide/introduction - theme: alt - text: Examples - link: /examples/context-middleware + text: Recipes + link: /recipes/context-middleware features: - title: One middleware, multiple adapters diff --git a/docs/package.json b/docs/package.json index 1357a90..4b503bf 100644 --- a/docs/package.json +++ b/docs/package.json @@ -30,6 +30,6 @@ "hono": "catalog:", "typescript": "^5.5.4", "universal-middleware": "workspace:*", - "vitepress": "^1.3.3" + "vitepress": "^1.3.4" } } diff --git a/docs/examples/context-middleware.md b/docs/recipes/context-middleware.md similarity index 100% rename from docs/examples/context-middleware.md rename to docs/recipes/context-middleware.md diff --git a/docs/examples/guard-middleware.md b/docs/recipes/guard-middleware.md similarity index 100% rename from docs/examples/guard-middleware.md rename to docs/recipes/guard-middleware.md diff --git a/docs/examples/headers-middleware.md b/docs/recipes/headers-middleware.md similarity index 100% rename from docs/examples/headers-middleware.md rename to docs/recipes/headers-middleware.md diff --git a/docs/recipes/params-handler.md b/docs/recipes/params-handler.md new file mode 100644 index 0000000..a4373af --- /dev/null +++ b/docs/recipes/params-handler.md @@ -0,0 +1,119 @@ +# Using route parameters + +Most adapters natively support route parameters (also called _parametric path_ or _path parameters_) such as `/hello/:name`. +`@universal-middleware/core` provides the `params` helper to universally retrieve those. + +We recommend to follow this next example when using route parameters: + +<<< @/../examples/tool/src/handlers/params.handler.ts + +> [!NOTE] +> For servers supporting route parameters (`app.get("/user/:name", myHandler())`), the parameters are available under `runtime.params`. +> +> For other adapters (`app.get("/user/*", myHandler({ route: "/user/:name" }))`), the 3rd argument of `params` helper must be present and not _undefined_. +> Then parameters are extracted with [regexparam](https://github.com/lukeed/regexparam). + +After bundling and publishing this middleware, one can then use this middleware as follows: + +::: code-group + +```ts twoslash [hono.ts] +import { Hono } from "hono"; +import paramHandler from "@universal-middleware-examples/tool/params-handler-hono"; + +const app = new Hono(); + +app.get("/user/:name", paramHandler()); + +export default app; +``` + +```ts twoslash [h3.ts] +import { createApp, createRouter } from "h3"; +import paramHandler from "@universal-middleware-examples/tool/params-handler-h3"; +import { universalOnBeforeResponse } from "@universal-middleware/h3"; + +const app = createApp({ + // /!\ This is required for universal-middleware to operate properly + onBeforeResponse: universalOnBeforeResponse, +}); + +const router = createRouter(); + +router.get("/user/:name", paramHandler()); + +app.use(router); + +export default app; +``` + +```ts twoslash [hattip.ts] +import { createRouter } from "@hattip/router"; +import paramHandler from "@universal-middleware-examples/tool/params-handler-hattip"; + +const app = createRouter(); + +app.get("/user/:name", paramHandler()); + +const hattipHandler = app.buildHandler(); + +export default hattipHandler; +``` + +```ts twoslash [cloudflare-worker.ts] +import paramsHandler from "@universal-middleware-examples/tool/params-handler"; +import { createHandler } from "@universal-middleware/cloudflare"; +import { pipe } from "@universal-middleware/core"; + +const paramsHandlerInstance = paramsHandler({ + // Mandatory when targeting Cloudflare Worker + route: "/user/:name", +}); + +// Cloudflare Workers have no native routing support. +// We recommend using Hono as it fully supports Cloudflare Worker. +const wrapped = pipe( + (request, ctx, runtime) => { + const url = new URL(request.url); + // intercept `/user/*` routes with this handler + if (url.pathname.startsWith("/user/")) { + return paramsHandlerInstance(request, ctx, runtime); + } + }, + // Other handlers +); + +export default createHandler(() => wrapped)(); +``` + +```ts twoslash [cloudflare-pages] +// functions/user/[name].ts + +import paramHandler from "@universal-middleware-examples/tool/params-handler-cloudflare-pages"; + +export const onRequest = paramHandler(); +``` + +```ts twoslash [express.ts] +import paramHandler from "@universal-middleware-examples/tool/params-handler-express"; +import express from "express"; + +const app = express(); + +app.get("/user/:name", paramHandler()); + +export default app; +``` + +```ts twoslash [fastify.ts] +import paramHandler from "@universal-middleware-examples/tool/params-handler-fastify"; +import fastify from "fastify"; + +const app = fastify(); + +app.get("/user/:name", paramHandler()); + +export default app; +``` + +::: diff --git a/examples/tool/package.json b/examples/tool/package.json index 216838e..fca2bdb 100644 --- a/examples/tool/package.json +++ b/examples/tool/package.json @@ -206,6 +206,51 @@ "types": "./dist/middlewares/universal-cloudflare-pages-middleware-guard.middleware.d.ts", "import": "./dist/middlewares/universal-cloudflare-pages-middleware-guard.middleware.js", "default": "./dist/middlewares/universal-cloudflare-pages-middleware-guard.middleware.js" + }, + "./params-handler": { + "types": "./dist/params.d.ts", + "import": "./dist/params.js", + "default": "./dist/params.js" + }, + "./params-handler-hono": { + "types": "./dist/handlers/universal-hono-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-hono-handler-params.handler.js", + "default": "./dist/handlers/universal-hono-handler-params.handler.js" + }, + "./params-handler-express": { + "types": "./dist/handlers/universal-express-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-express-handler-params.handler.js", + "default": "./dist/handlers/universal-express-handler-params.handler.js" + }, + "./params-handler-hattip": { + "types": "./dist/handlers/universal-hattip-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-hattip-handler-params.handler.js", + "default": "./dist/handlers/universal-hattip-handler-params.handler.js" + }, + "./params-handler-webroute": { + "types": "./dist/handlers/universal-webroute-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-webroute-handler-params.handler.js", + "default": "./dist/handlers/universal-webroute-handler-params.handler.js" + }, + "./params-handler-fastify": { + "types": "./dist/handlers/universal-fastify-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-fastify-handler-params.handler.js", + "default": "./dist/handlers/universal-fastify-handler-params.handler.js" + }, + "./params-handler-h3": { + "types": "./dist/handlers/universal-h3-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-h3-handler-params.handler.js", + "default": "./dist/handlers/universal-h3-handler-params.handler.js" + }, + "./params-handler-cloudflare-worker": { + "types": "./dist/handlers/universal-cloudflare-worker-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-cloudflare-worker-handler-params.handler.js", + "default": "./dist/handlers/universal-cloudflare-worker-handler-params.handler.js" + }, + "./params-handler-cloudflare-pages": { + "types": "./dist/handlers/universal-cloudflare-pages-handler-params.handler.d.ts", + "import": "./dist/handlers/universal-cloudflare-pages-handler-params.handler.js", + "default": "./dist/handlers/universal-cloudflare-pages-handler-params.handler.js" } }, "author": "Joël Charles ", @@ -220,6 +265,7 @@ "@hono/node-server": "^1.12.0", "@swc/core": "^1.7.11", "@types/node": "catalog:", + "@universal-middleware/core": "workspace:*", "rimraf": "^6.0.0", "tsup": "^8.2.4", "typescript": "^5.5.4", diff --git a/examples/tool/src/handlers/handler.ts b/examples/tool/src/handlers/handler.ts index dae8dde..3e2e016 100644 --- a/examples/tool/src/handlers/handler.ts +++ b/examples/tool/src/handlers/handler.ts @@ -1,4 +1,4 @@ -import type { Get, UniversalHandler } from "universal-middleware"; +import type { Get, UniversalHandler } from "@universal-middleware/core"; const handler: Get<[], UniversalHandler> = () => (_request, ctx) => { return new Response(`context: ${JSON.stringify(ctx)}`, { diff --git a/examples/tool/src/handlers/params.handler.ts b/examples/tool/src/handlers/params.handler.ts new file mode 100644 index 0000000..ca235da --- /dev/null +++ b/examples/tool/src/handlers/params.handler.ts @@ -0,0 +1,23 @@ +import type { UniversalHandler } from "@universal-middleware/core"; +import { params } from "@universal-middleware/core"; + +interface RouteParamOption { + route?: string; +} + +const handler = ((options?) => (request, _context, runtime) => { + const myParams = params(request, runtime, options?.route); + + if (myParams === null || !myParams.name) { + // Provide a useful error message to the user + throw new Error( + "A route parameter named `:name` is required. " + + "You can set your server route as `/user/:name`, or use the `route` option of this middleware " + + "to achieve the same purpose.", + ); + } + + return new Response(`User name is: ${myParams.name}`); +}) satisfies (options?: RouteParamOption) => UniversalHandler; + +export default handler; diff --git a/examples/tool/src/middlewares/context.middleware.ts b/examples/tool/src/middlewares/context.middleware.ts index 03c8e35..27a0a0b 100644 --- a/examples/tool/src/middlewares/context.middleware.ts +++ b/examples/tool/src/middlewares/context.middleware.ts @@ -1,6 +1,6 @@ // src/middlewares/context.middleware.ts -import type { Get, UniversalMiddleware } from "universal-middleware"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; const contextMiddleware = ((value) => (request, ctx) => { // Return the new universal context, thus keeping complete type safety diff --git a/examples/tool/src/middlewares/guard.middleware.ts b/examples/tool/src/middlewares/guard.middleware.ts index 1e64581..c73c9ab 100644 --- a/examples/tool/src/middlewares/guard.middleware.ts +++ b/examples/tool/src/middlewares/guard.middleware.ts @@ -1,6 +1,6 @@ // src/middlewares/guard.middleware.ts -import type { Get, UniversalMiddleware } from "universal-middleware"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; interface User { id: string; diff --git a/examples/tool/src/middlewares/headers.middleware.ts b/examples/tool/src/middlewares/headers.middleware.ts index 96193eb..7f0f232 100644 --- a/examples/tool/src/middlewares/headers.middleware.ts +++ b/examples/tool/src/middlewares/headers.middleware.ts @@ -1,6 +1,6 @@ // src/middlewares/headers.middleware.ts -import type { Get, UniversalMiddleware } from "universal-middleware"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; // This middleware will add a `X-Universal-Hello` header to all responses const headersMiddleware = (() => (request, ctx) => { diff --git a/examples/tool/tsup.config.ts b/examples/tool/tsup.config.ts index dda4361..0f50939 100644 --- a/examples/tool/tsup.config.ts +++ b/examples/tool/tsup.config.ts @@ -5,6 +5,7 @@ export default defineConfig([ { entry: { dummy: "./src/handlers/handler.ts", + params: "./src/handlers/params.handler.ts", "middlewares/context": "./src/middlewares/context.middleware.ts", "middlewares/headers": "./src/middlewares/headers.middleware.ts", "middlewares/guard": "./src/middlewares/guard.middleware.ts", diff --git a/packages/adapter-cloudflare/functions/user/[name].ts b/packages/adapter-cloudflare/functions/user/[name].ts new file mode 100644 index 0000000..4550c7d --- /dev/null +++ b/packages/adapter-cloudflare/functions/user/[name].ts @@ -0,0 +1,4 @@ +import { routeParamHandler } from "@universal-middleware/tests/utils"; +import { createPagesFunction } from "../../src/index.js"; + +export const onRequest = createPagesFunction(routeParamHandler)(); diff --git a/packages/adapter-cloudflare/src/common.ts b/packages/adapter-cloudflare/src/common.ts index 60519ec..bf9e36c 100644 --- a/packages/adapter-cloudflare/src/common.ts +++ b/packages/adapter-cloudflare/src/common.ts @@ -1,12 +1,12 @@ -import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; -import { getAdapterRuntime } from "@universal-middleware/core"; import type { + Response as CloudflareResponse, EventContext, ExecutionContext, ExportedHandlerFetchHandler, PagesFunction, - Response as CloudflareResponse, } from "@cloudflare/workers-types"; +import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import { getAdapterRuntime } from "@universal-middleware/core"; export const contextSymbol = Symbol("unContext"); @@ -21,7 +21,7 @@ export type CloudflarePagesFunction = PagesFunction }>; /** - * Creates a request handler for Cloudflare Pages. Should be used as dist/_worker.js + * Creates a request handler for Cloudflare Worker. Should be used as dist/_worker.js */ export function createHandler( handlerFactory: Get, @@ -111,8 +111,10 @@ export function getRuntime( const isContext = args.length === 1; return getAdapterRuntime( - "other", - {}, + isContext ? "cloudflare-pages" : "cloudflare-worker", + { + params: isContext ? (args[0].params as Record) ?? undefined : undefined, + }, { env: isContext ? args[0].env : args[0], ctx: { diff --git a/packages/adapter-cloudflare/tests/cloudflare.spec.ts b/packages/adapter-cloudflare/tests/cloudflare.spec.ts index b10b617..4812fd8 100644 --- a/packages/adapter-cloudflare/tests/cloudflare.spec.ts +++ b/packages/adapter-cloudflare/tests/cloudflare.spec.ts @@ -9,12 +9,14 @@ const runs: Run[] = [ port: port, command: `pnpm run test:run-cloudflare:pages --inspector-port ${port + 10000}`, waitUntilType: "function", + delay: 1000, }, { name: "adapter-cloudflare: worker", port: port + 1, command: `pnpm run test:run-cloudflare:worker --inspector-port ${port + 10000 + 1}`, waitUntilType: "function", + delay: 1000, }, ]; diff --git a/packages/adapter-cloudflare/tests/worker-entry.ts b/packages/adapter-cloudflare/tests/worker-entry.ts index 192d5df..c991cfd 100644 --- a/packages/adapter-cloudflare/tests/worker-entry.ts +++ b/packages/adapter-cloudflare/tests/worker-entry.ts @@ -1,7 +1,22 @@ -import { handler, middlewares } from "@universal-middleware/tests/utils"; -import { createHandler } from "../src/index.js"; import { pipe } from "@universal-middleware/core"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; +import { createHandler } from "../src/index.js"; + +const routeParamHandlerInstance = routeParamHandler({ + route: "/user/:name", +}); -const cloudflareHandler = pipe(middlewares[0](), middlewares[1](), middlewares[2](), handler()); +const cloudflareHandler = pipe( + middlewares[0](), + middlewares[1](), + middlewares[2](), + (request, ctx, runtime) => { + const url = new URL(request.url); + if (url.pathname.startsWith("/user/")) { + return routeParamHandlerInstance(request, ctx, runtime); + } + }, + handler(), +); export default createHandler(() => cloudflareHandler)(); diff --git a/packages/adapter-express/src/common.ts b/packages/adapter-express/src/common.ts index aa92c3f..73a212c 100644 --- a/packages/adapter-express/src/common.ts +++ b/packages/adapter-express/src/common.ts @@ -1,9 +1,9 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import type { Socket } from "node:net"; -import { createRequestAdapter, type NodeRequestAdapterOptions } from "./request.js"; -import { sendResponse, wrapResponse } from "./response.js"; -import type { Awaitable, Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import type { Awaitable, Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime } from "@universal-middleware/core"; +import { type NodeRequestAdapterOptions, createRequestAdapter } from "./request.js"; +import { sendResponse, wrapResponse } from "./response.js"; export const contextSymbol = Symbol("unContext"); export const requestSymbol = Symbol("unRequest"); @@ -32,6 +32,7 @@ export interface DecoratedRequest; [contextSymbol]?: C; [requestSymbol]?: Request; } @@ -71,14 +72,7 @@ export function createHandler( try { req[contextSymbol] ??= {}; const request = requestAdapter(req); - const response = await handler( - request, - req[contextSymbol], - getAdapterRuntime("node", { - req: req as IncomingMessage, - res, - }), - ); + const response = await handler(request, req[contextSymbol], getRuntime(req, res)); await sendResponse(response, res); } catch (error) { @@ -120,14 +114,7 @@ export function createMiddleware< try { req[contextSymbol] ??= {} as OutContext; const request = requestAdapter(req); - const response = await middleware( - request, - getContext(req), - getAdapterRuntime("node", { - req: req as IncomingMessage, - res, - }), - ); + const response = await middleware(request, getContext(req), getRuntime(req, res)); if (!response) { return next?.(); @@ -172,3 +159,11 @@ export function createMiddleware< export function getContext(req: DecoratedRequest): InContext { return req[contextSymbol] as InContext; } + +export function getRuntime(request: DecoratedRequest, response: DecoratedServerResponse): RuntimeAdapter { + return getAdapterRuntime("express", { + params: request.params, + req: request as IncomingMessage, + res: response, + }); +} diff --git a/packages/adapter-express/tests/entry-express.ts b/packages/adapter-express/tests/entry-express.ts index 552e847..5cd73a3 100644 --- a/packages/adapter-express/tests/entry-express.ts +++ b/packages/adapter-express/tests/entry-express.ts @@ -1,9 +1,9 @@ -import { createHandler, createMiddleware } from "../src/index.js"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { args } from "@universal-middleware/tests"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; import express from "express"; import helmet from "helmet"; -import { args } from "@universal-middleware/tests"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; -import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { createHandler, createMiddleware } from "../src/index.js"; const app = express(); @@ -13,6 +13,9 @@ for (const middleware of middlewares) { app.use(createMiddleware(middleware as Get<[], UniversalMiddleware>)()); } +// route params handler +app.get("/user/:name", createHandler(routeParamHandler)()); + // universal handler app.get("/", createHandler(handler)()); diff --git a/packages/adapter-fastify/src/common.ts b/packages/adapter-fastify/src/common.ts index bf41a91..8e54bb0 100644 --- a/packages/adapter-fastify/src/common.ts +++ b/packages/adapter-fastify/src/common.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from "node:http"; -import type { Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime, isBodyInit, mergeHeadersInto } from "@universal-middleware/core"; -import { createRequestAdapter, type DecoratedRequest } from "@universal-middleware/express"; +import { type DecoratedRequest, createRequestAdapter } from "@universal-middleware/express"; import type { FastifyPluginAsync, FastifyReply, FastifyRequest, RouteHandlerMethod } from "fastify"; import fp from "fastify-plugin"; @@ -111,14 +111,7 @@ export function createHandler { const ctx = initContext(request); - const response = await handler( - requestAdapter(getRawRequest(request)), - ctx, - getAdapterRuntime("node", { - req: request.raw as IncomingMessage, - res: reply.raw, - }), - ); + const response = await handler(requestAdapter(getRawRequest(request)), ctx, getRuntime(request, reply)); if (response) { if (!response.body) { @@ -144,14 +137,7 @@ export function createMiddleware< return fp(async (instance) => { instance.addHook("preHandler", async (request, reply) => { const ctx = initContext(request); - const response = await middleware( - requestAdapter(getRawRequest(request)), - ctx, - getAdapterRuntime("node", { - req: request.raw as IncomingMessage, - res: reply.raw, - }), - ); + const response = await middleware(requestAdapter(getRawRequest(request)), ctx, getRuntime(request, reply)); if (!response) { return; @@ -228,3 +214,11 @@ export function setContext | undefined, + req: request.raw as IncomingMessage, + res: reply.raw, + }); +} diff --git a/packages/adapter-fastify/tests/entry-fastify.ts b/packages/adapter-fastify/tests/entry-fastify.ts index 7ccf10b..4439f49 100644 --- a/packages/adapter-fastify/tests/entry-fastify.ts +++ b/packages/adapter-fastify/tests/entry-fastify.ts @@ -1,10 +1,10 @@ -import { createHandler, createMiddleware } from "../src/index.js"; -import fastify from "fastify"; -import { args } from "@universal-middleware/tests"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; -import rawBody from "fastify-raw-body"; import helmet from "@fastify/helmet"; import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { args } from "@universal-middleware/tests"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; +import fastify from "fastify"; +import rawBody from "fastify-raw-body"; +import { createHandler, createMiddleware } from "../src/index.js"; const app = fastify(); @@ -31,6 +31,9 @@ app.post( })(), ); +// route params handler +app.get("/user/:name", createHandler(routeParamHandler)()); + // universal handler app.get("/", createHandler(handler)()); diff --git a/packages/adapter-h3/src/common.ts b/packages/adapter-h3/src/common.ts index 8f70a77..04116ed 100644 --- a/packages/adapter-h3/src/common.ts +++ b/packages/adapter-h3/src/common.ts @@ -1,13 +1,13 @@ -import type { Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime, isBodyInit, mergeHeadersInto, nodeHeadersToWeb } from "@universal-middleware/core"; import { + type EventHandler, + type H3Event, defineResponseMiddleware, eventHandler, - type EventHandler, getResponseHeaders, getResponseStatus, getResponseStatusText, - type H3Event, sendWebResponse, toWebRequest, } from "h3"; @@ -38,7 +38,7 @@ export function createHandler(handlerFactory: Get { const ctx = initContext(event); - return handler(toWebRequest(event), ctx, getAdapterRuntime("other", {})); + return handler(toWebRequest(event), ctx, getRuntime(event)); }); }; } @@ -96,7 +96,7 @@ export function createMiddleware< return eventHandler(async (event) => { const ctx = initContext(event); - const response = await middleware(toWebRequest(event), ctx, getAdapterRuntime("other", {})); + const response = await middleware(toWebRequest(event), ctx, getRuntime(event)); if (typeof response === "function") { event.context[pendingMiddlewaresSymbol] ??= []; @@ -122,3 +122,9 @@ export function initContext(event: H3Event): export function getContext(event: H3Event): Context { return event.context[contextSymbol] as Context; } + +export function getRuntime(event: H3Event): RuntimeAdapter { + return getAdapterRuntime("h3", { + params: event.context.params, + }); +} diff --git a/packages/adapter-h3/tests/entry-h3.ts b/packages/adapter-h3/tests/entry-h3.ts index 7335c03..266c1e9 100644 --- a/packages/adapter-h3/tests/entry-h3.ts +++ b/packages/adapter-h3/tests/entry-h3.ts @@ -1,8 +1,8 @@ -import { createHandler, createMiddleware, universalOnBeforeResponse } from "../src/index.js"; -import { createApp, createRouter, toNodeListener, toWebHandler } from "h3"; -import { args, bun, deno } from "@universal-middleware/tests"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { args, bun, deno } from "@universal-middleware/tests"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; +import { createApp, createRouter, toNodeListener, toWebHandler } from "h3"; +import { createHandler, createMiddleware, universalOnBeforeResponse } from "../src/index.js"; const app = createApp({ onBeforeResponse: universalOnBeforeResponse, @@ -14,6 +14,8 @@ for (const middleware of middlewares) { const router = createRouter(); +router.get("/user/:name", createHandler(routeParamHandler)()); + router.get("/", createHandler(handler)()); app.use(router); diff --git a/packages/adapter-hattip/src/common.ts b/packages/adapter-hattip/src/common.ts index 841e9a5..9a8a1bc 100644 --- a/packages/adapter-hattip/src/common.ts +++ b/packages/adapter-hattip/src/common.ts @@ -1,5 +1,5 @@ -import type { AdapterRequestContext, HattipHandler } from "@hattip/core"; import type { RequestHandler } from "@hattip/compose"; +import type { AdapterRequestContext, HattipHandler } from "@hattip/core"; import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime } from "@universal-middleware/core"; @@ -75,8 +75,10 @@ export function getContext) : undefined, + }, { // biome-ignore lint/suspicious/noExplicitAny: env: (context.platform as any)?.env, diff --git a/packages/adapter-hattip/tests/hattip.spec.ts b/packages/adapter-hattip/tests/hattip.spec.ts index 8644958..b286457 100644 --- a/packages/adapter-hattip/tests/hattip.spec.ts +++ b/packages/adapter-hattip/tests/hattip.spec.ts @@ -24,6 +24,7 @@ const runs: Run[] = [ command: `pnpm run test:run-hattip:worker --inspector-port ${port + 10000 + 3}`, port: port + 3, waitUntilType: "function", + delay: 1000, }, ]; diff --git a/packages/adapter-hattip/tests/hattip.ts b/packages/adapter-hattip/tests/hattip.ts index 6778141..c3de8ae 100644 --- a/packages/adapter-hattip/tests/hattip.ts +++ b/packages/adapter-hattip/tests/hattip.ts @@ -1,8 +1,8 @@ -import { createHandler, createMiddleware } from "../src/index.js"; import { cors } from "@hattip/cors"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; import { createRouter } from "@hattip/router"; import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; +import { createHandler, createMiddleware } from "../src/index.js"; const app = createRouter(); @@ -13,6 +13,9 @@ for (const middleware of middlewares) { app.use(createMiddleware(middleware as Get<[], UniversalMiddleware>)()); } +// route params handler +app.get("/user/:name", createHandler(routeParamHandler)()); + // universal handler app.get("/", createHandler(handler)()); diff --git a/packages/adapter-hono/functions/user/[name].ts b/packages/adapter-hono/functions/user/[name].ts new file mode 100644 index 0000000..b62f8cb --- /dev/null +++ b/packages/adapter-hono/functions/user/[name].ts @@ -0,0 +1,5 @@ +import { routeParamHandler } from "@universal-middleware/tests/utils"; +import { handleMiddleware } from "hono/cloudflare-pages"; +import { createMiddleware } from "../../src/index.js"; + +export const onRequest = handleMiddleware(createMiddleware(routeParamHandler)()); diff --git a/packages/adapter-hono/src/common.ts b/packages/adapter-hono/src/common.ts index 6f50764..cf260a1 100644 --- a/packages/adapter-hono/src/common.ts +++ b/packages/adapter-hono/src/common.ts @@ -1,6 +1,6 @@ -import type { Context as HonoContext, Env, Handler, MiddlewareHandler } from "hono"; import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime } from "@universal-middleware/core"; +import type { Env, Handler, Context as HonoContext, MiddlewareHandler } from "hono"; export const contextSymbol = Symbol("unContext"); @@ -100,12 +100,24 @@ export function getContext | undefined = undefined; + const ctx = getExecutionCtx(honoContext); + try { + params = honoContext.req.param(); + } catch { + // Retrieve Cloudflare Pages potential params + if (ctx) { + params = (ctx as { params?: Record }).params ?? undefined; + } + } return getAdapterRuntime( - "other", - {}, + "hono", + { + params, + }, { env: honoContext.env, - ctx: getExecutionCtx(honoContext), + ctx, }, ); } diff --git a/packages/adapter-hono/tests/entry-hono.ts b/packages/adapter-hono/tests/entry-hono.ts index 920af88..453e546 100644 --- a/packages/adapter-hono/tests/entry-hono.ts +++ b/packages/adapter-hono/tests/entry-hono.ts @@ -1,9 +1,9 @@ -import { createHandler, createMiddleware } from "../src/index.js"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { args, bun, deno } from "@universal-middleware/tests"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; import { Hono } from "hono"; import { secureHeaders } from "hono/secure-headers"; -import { args, bun, deno } from "@universal-middleware/tests"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; -import type { Get, UniversalMiddleware } from "@universal-middleware/core"; +import { createHandler, createMiddleware } from "../src/index.js"; const app = new Hono(); @@ -14,6 +14,9 @@ for (const middleware of middlewares) { app.use(createMiddleware(middleware as Get<[], UniversalMiddleware>)()); } +// route params handler +app.get("/user/:name", createHandler(routeParamHandler)()); + // universal handler app.get("/", createHandler(handler)()); diff --git a/packages/adapter-hono/tests/hono.spec.ts b/packages/adapter-hono/tests/hono.spec.ts index 0b1ec80..788a4bb 100644 --- a/packages/adapter-hono/tests/hono.spec.ts +++ b/packages/adapter-hono/tests/hono.spec.ts @@ -24,6 +24,7 @@ const runs: Run[] = [ command: `pnpm run test:run-hono:wrangler --inspector-port ${port + 10000 + 3}`, port: port + 3, waitUntilType: "function", + delay: 1000, }, ]; diff --git a/packages/adapter-webroute/package.json b/packages/adapter-webroute/package.json index 735f33b..a376ce3 100644 --- a/packages/adapter-webroute/package.json +++ b/packages/adapter-webroute/package.json @@ -37,6 +37,7 @@ "@universal-middleware/tests": "workspace:*", "@webroute/middleware": "^0.10.0", "@webroute/route": "^0.8.0", + "@webroute/router": "^0.4.0", "hono": "catalog:", "rimraf": "^6.0.0", "tsup": "^8.2.4", diff --git a/packages/adapter-webroute/src/common.ts b/packages/adapter-webroute/src/common.ts index 5ce3775..09a5e5b 100644 --- a/packages/adapter-webroute/src/common.ts +++ b/packages/adapter-webroute/src/common.ts @@ -1,7 +1,7 @@ -import type { RequestCtx } from "@webroute/route"; -import type { Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import type { Get, RuntimeAdapter, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; import { getAdapterRuntime } from "@universal-middleware/core"; import type { DataResult, MiddlewareFn } from "@webroute/middleware"; +import type { RequestCtx } from "@webroute/route"; export type WebrouteMiddleware< // biome-ignore lint/complexity/noBannedTypes: @@ -38,9 +38,9 @@ export function createHandler { const handler = handlerFactory(...args); - return (request, ctx) => { + return async (request, ctx) => { const context = initContext(ctx); - return handler(request, context, getAdapterRuntime("other", {})); + return handler(request, context, await getRuntime(ctx)); }; }; } @@ -72,9 +72,9 @@ export function createMiddleware< return (...args) => { const middleware = middlewareFactory(...args); - return ((request, ctx) => { + return (async (request, ctx) => { const context = initContext(ctx); - return middleware(request, context, getAdapterRuntime("other", {})); + return middleware(request, context, await getRuntime(ctx)); }) as WebrouteMiddleware>; }; } @@ -85,3 +85,19 @@ function initContext( ctx.state ??= {} as Context; return ctx.state; } + +export function getContext( + ctx: RequestCtx, +): Context { + return ctx.state; +} + +export async function getRuntime(ctx: RequestCtx | undefined): Promise { + const parsed = await ctx?.parse(); + + const params = (parsed?.params as Record) ?? undefined; + + return getAdapterRuntime("webroute", { + params, + }); +} diff --git a/packages/adapter-webroute/src/index.ts b/packages/adapter-webroute/src/index.ts index 12886f0..9db365c 100644 --- a/packages/adapter-webroute/src/index.ts +++ b/packages/adapter-webroute/src/index.ts @@ -1,6 +1,8 @@ export { createHandler, createMiddleware, + getContext, + getRuntime, type MiddlewareFactoryDataResult, type WebrouteHandler, type WebrouteMiddleware, diff --git a/packages/adapter-webroute/tests/entry-webroute.ts b/packages/adapter-webroute/tests/entry-webroute.ts index 92161f7..7dc0aaa 100644 --- a/packages/adapter-webroute/tests/entry-webroute.ts +++ b/packages/adapter-webroute/tests/entry-webroute.ts @@ -1,10 +1,11 @@ -import { createHandler, createMiddleware } from "../src/index.js"; -import { Hono, type MiddlewareHandler } from "hono"; -import { secureHeaders } from "hono/secure-headers"; import { args, bun, deno } from "@universal-middleware/tests"; -import { handler, middlewares } from "@universal-middleware/tests/utils"; -import { route } from "@webroute/route"; +import { handler, middlewares, routeParamHandler } from "@universal-middleware/tests/utils"; import { createAdapter } from "@webroute/middleware"; +import { Route, route } from "@webroute/route"; +import { createRadixRouter } from "@webroute/router"; +import { Hono, type MiddlewareHandler } from "hono"; +import { secureHeaders } from "hono/secure-headers"; +import { createHandler, createMiddleware } from "../src/index.js"; const app = new Hono(); @@ -12,20 +13,20 @@ const m1 = middlewares[0]; const m2 = middlewares[1]; const m3 = middlewares[2]; -const router = route() - // `createMiddleware(m1)()` or `m1()` are roughly equivalant (if not using the second parameter). - // The former allows better extraction of typings. - .use(m1()) - .use((_request, ctx) => { - console.log("something BEFORE", ctx.state.something); - }) - .use(createMiddleware(m2)()) - .use(createMiddleware(m3)()) - .use((_request, ctx) => { - console.log("something", ctx.state.something); - console.log("somethingElse", ctx.state.somethingElse); - }) - .handle(createHandler(handler)()); +const router = createRadixRouter([ + Route.normalise(route("/user/:name").method("get").handle(createHandler(routeParamHandler)())), + // @ts-ignore + Route.normalise( + route("/") + .method("get") + // `createMiddleware(m1)()` or `m1()` are roughly equivalant is some cases (if not using the context or runtime). + // Usually prefer wrapping in `createMiddleware` for better compatibility + .use(m1()) + .use(createMiddleware(m2)()) + .use(createMiddleware(m3)()) + .handle(createHandler(handler)()), + ), +]); // standard Hono middleware app.use(secureHeaders()); @@ -34,10 +35,10 @@ const toHono = createAdapter((c, next) => { return { async onData(data) { c.set("state", { ...c.get("state"), ...data }); - next(); + await next(); }, async onEmpty() { - next(); + await next(); }, async onResponse(response) { return response; @@ -53,7 +54,11 @@ const toHono = createAdapter((c, next) => { app.use( "*", toHono((c) => { - return router(c.req.raw); + const handler = router.match(c.req.raw); + if (handler) { + return handler(c.req.raw); + } + return new Response("NOT FOUND", { status: 404 }); }), ); diff --git a/packages/adapter-webroute/tests/webroute.spec.ts b/packages/adapter-webroute/tests/webroute.spec.ts index 1a801f4..52ae9e5 100644 --- a/packages/adapter-webroute/tests/webroute.spec.ts +++ b/packages/adapter-webroute/tests/webroute.spec.ts @@ -1,24 +1,23 @@ import { type Run, runTests } from "@universal-middleware/tests"; import * as vitest from "vitest"; -let port = 3300; +const port = 3300; const runs: Run[] = [ - // Waiting for fix https://github.com/sinclairnick/webroute/pull/40 - // { - // name: "adapter-webroute: node", - // command: "pnpm run test:run-webroute:node", - // port: port++, - // }, - // { - // name: "adapter-webroute: bun", - // command: "pnpm run test:run-webroute:bun", - // port: port++, - // }, + { + name: "adapter-webroute: node", + command: "pnpm run test:run-webroute:node", + port: port, + }, + { + name: "adapter-webroute: bun", + command: "pnpm run test:run-webroute:bun", + port: port + 1, + }, { name: "adapter-webroute: deno", command: "pnpm run test:run-webroute:deno", - port: port++, + port: port + 2, }, ]; diff --git a/packages/core/package.json b/packages/core/package.json index 35e3470..592e4c0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,5 +32,8 @@ "vite": "^5.4.3", "vitest": "^2.0.5" }, - "sideEffects": false + "sideEffects": false, + "dependencies": { + "regexparam": "^3.0.0" + } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5d8ed6b..e8ee6ce 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ export type * from "./types.js"; -export * from "./runtime.js"; -export * from "./adapter.js"; -export * from "./utils.js"; -export * from "./pipe.js"; +export { getAdapterRuntime } from "./adapter.js"; +export { mergeHeadersInto, nodeHeadersToWeb, isBodyInit } from "./utils.js"; +export { pipe } from "./pipe.js"; +export { params } from "./route.js"; diff --git a/packages/core/src/route.ts b/packages/core/src/route.ts new file mode 100644 index 0000000..a231ff6 --- /dev/null +++ b/packages/core/src/route.ts @@ -0,0 +1,68 @@ +import { parse } from "regexparam"; +import type { RuntimeAdapter } from "./types"; + +function exec( + path: string, + result: { + keys: string[]; + pattern: RegExp; + }, +) { + const out: Record = {}; + const matches = result.pattern.exec(path); + if (!matches) return null; + + for (let i = 0; i < result.keys.length; i++) { + if (matches[i + 1]) { + out[result.keys[i]] = matches[i + 1]; + } + } + + return out; +} + +function paramsFromRequest(request: Request, path: string): null | Record { + const url = new URL(request.url); + return exec(url.pathname, parse(path)); +} + +/** + * Retrieve path parameters from URL patterns. + * For servers supporting URL patterns like '/user/:name', the parameters will be available under runtime.params. + * For other adapters, the `path` argument must be present. Then parameters are extracted thanks to `regexparam`. + * + * If you are writing a Universal Handler or Middleware and need access to path parameters, we suggest to follow + * this next example. + * + * @example + * import { params, type Get, type UniversalHandler } from "@universal-middleware/core"; + * + * interface Options { + * route?: string; + * } + * + * const myMiddleware = ((options?: Options) => (request, ctx, runtime) => { + * const myParams = params(request, runtime, options?.route); + * + * if (myParams === null) { + * // Provide a useful Error message to the user + * throw new Error("A path parameter named `:name` is required. " + + * "You can set your server route as `/user/:name`, or use the `route` option of this middleware " + + * "to achieve the same purpose."); + * } + * + * // ... + * }) satisfies Get<[Options | undefined], UniversalHandler>; + * + * export default myMiddleware; + */ +export function params( + request: Request, + runtime: RuntimeAdapter, + path: string | undefined, +): null | Record { + if (typeof path === "string") { + return paramsFromRequest(request, path); + } + return runtime.params ?? null; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 37cdb07..12de692 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -70,16 +70,74 @@ export type Runtime = export interface NodeAdapter { adapter: "node"; + params: undefined; req: IncomingMessage; res: ServerResponse; } +export interface ExpressAdapter { + adapter: "express"; + params: Record | undefined; + + req: IncomingMessage; + res: ServerResponse; +} + +export interface FastifyAdapter { + adapter: "fastify"; + params: Record | undefined; + + req: IncomingMessage; + res: ServerResponse; +} + +export interface HonoAdapter { + adapter: "hono"; + params: Record | undefined; +} + +export interface HattipAdapter { + adapter: "hattip"; + params: Record | undefined; +} + +export interface H3Adapter { + adapter: "h3"; + params: Record | undefined; +} + +export interface CloudflarePagesAdapter { + adapter: "cloudflare-pages"; + params: Record | undefined; +} + +export interface CloudflareWorkerAdapter { + adapter: "cloudflare-worker"; + params: undefined; +} + +export interface WebrouteAdapter { + adapter: "webroute"; + params: Record | undefined; +} + export interface OtherAdapter { adapter: "other"; + params: undefined; } -export type Adapter = NodeAdapter | OtherAdapter; +export type Adapter = + | NodeAdapter + | ExpressAdapter + | FastifyAdapter + | HonoAdapter + | HattipAdapter + | H3Adapter + | CloudflarePagesAdapter + | CloudflareWorkerAdapter + | WebrouteAdapter + | OtherAdapter; export type RuntimeAdapter = Runtime & Adapter; export type UniversalMiddleware< diff --git a/packages/core/test/pipe.test.ts b/packages/core/test/pipe.test.ts index ebf693c..b5c76fc 100644 --- a/packages/core/test/pipe.test.ts +++ b/packages/core/test/pipe.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { pipe } from "../src/pipe"; import type { RuntimeAdapter, UniversalMiddleware } from "../src/index"; +import { pipe } from "../src/pipe"; describe("pipe", () => { const request = new Request("http://localhost"); @@ -8,6 +8,7 @@ describe("pipe", () => { const runtime: RuntimeAdapter = { runtime: "other", adapter: "other", + params: undefined, }; test("handler", async () => { diff --git a/packages/tests/src/index.ts b/packages/tests/src/index.ts index e245aa9..a83e23c 100644 --- a/packages/tests/src/index.ts +++ b/packages/tests/src/index.ts @@ -8,6 +8,7 @@ export interface Run { command: string; port: number; waitUntilType?: "undefined" | "function"; + delay?: number; } export interface Options { @@ -28,7 +29,7 @@ declare global { export function runTests(runs: Run[], options: Options) { options.vitest.describe.concurrent.each(runs)("$name", (run) => { let server: ChildProcess | undefined = undefined; - const { command, port } = run; + const { command, port, delay } = run; let host = `http://localhost:${port}`; options.vitest.beforeAll(async () => { @@ -63,6 +64,10 @@ export function runTests(runs: Run[], options: Options) { }) .catch(reject); }); + + if (delay) { + await new Promise((r) => setTimeout(r, delay)); + } }, 30_000); options.vitest.afterAll(async () => { @@ -93,6 +98,13 @@ export function runTests(runs: Run[], options: Options) { await options?.test?.(response, body, run); }); + options.vitest.test("route param handler", { retry: 3, timeout: 30_000 }, async () => { + const response = await fetch(`${host}/user/magne4000`); + const body = await response.text(); + options.vitest.expect(response.status).toBe(200); + options.vitest.expect(body).toBe("User name is: magne4000"); + }); + if (options?.testPost) { options.vitest.test("post", { retry: 3, timeout: 30_000 }, async () => { const response = await fetch(`${host}/post`, { diff --git a/packages/tests/src/utils.ts b/packages/tests/src/utils.ts index 2e574de..bf25886 100644 --- a/packages/tests/src/utils.ts +++ b/packages/tests/src/utils.ts @@ -1,4 +1,10 @@ -import type { CloudflareWorkerdRuntime, Get, UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import { + type CloudflareWorkerdRuntime, + type Get, + type UniversalHandler, + type UniversalMiddleware, + params, +} from "@universal-middleware/core"; export const middlewares = [ // universal middleware that updates the context synchronously @@ -45,3 +51,23 @@ export const handler: Get<[], UniversalHandler> = () => (_request, context) => { }, }); }; + +interface RouteParamOption { + route?: string; +} + +export const routeParamHandler = ((options?) => (request, context, runtime) => { + const myParams = params(request, runtime, options?.route); + + if (myParams === null || !myParams.name) { + // Provide a useful error message to the user + throw new Error( + "A route parameter named `:name` is required. " + + "You can set your server route as `/user/:name`, or use the `route` option of this middleware " + + "to achieve the same purpose.", + ); + } + + // ... + return new Response(`User name is: ${myParams.name}`); +}) satisfies (options?: RouteParamOption) => UniversalHandler; diff --git a/packages/universal-middleware/README.md b/packages/universal-middleware/README.md index 9dade7e..e36fae0 100644 --- a/packages/universal-middleware/README.md +++ b/packages/universal-middleware/README.md @@ -20,7 +20,7 @@ A middleware that returns an early response if some header is missing. ```ts // src/middlewares/demo.middleware.ts -import type { Get, UniversalMiddleware } from "universal-middleware"; +import type { Get, UniversalMiddleware } from "@universal-middleware/core"; interface Config { header: string; diff --git a/packages/universal-middleware/src/index.ts b/packages/universal-middleware/src/index.ts index 0526636..f50831b 100644 --- a/packages/universal-middleware/src/index.ts +++ b/packages/universal-middleware/src/index.ts @@ -1,8 +1,6 @@ -import rollup from "./rollup.js"; import esbuild from "./esbuild.js"; +import rollup from "./rollup.js"; export { readAndEditPackageJson } from "./plugin.js"; -export type * from "@universal-middleware/core"; - export { rollup, esbuild }; diff --git a/packages/universal-middleware/src/plugin.ts b/packages/universal-middleware/src/plugin.ts index e90e327..1f7e461 100644 --- a/packages/universal-middleware/src/plugin.ts +++ b/packages/universal-middleware/src/plugin.ts @@ -1,7 +1,7 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join, parse, posix, resolve } from "node:path"; -import type { UnpluginFactory } from "unplugin"; import { packageUp } from "package-up"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import type { UnpluginFactory } from "unplugin"; export interface Options { servers?: (typeof defaultWrappers)[number][]; @@ -241,7 +241,7 @@ function loadDts(id: string, resolve?: (handler: string, type: string) => string import { ${selfImports.join(", ")} } from "@universal-middleware/${info.target ?? target}"; import ${type} from "${resolve ? resolve(handler, type) : handler}"; type ExtractT = T extends (...args: infer X) => any ? X : never; -type ExtractInContext = T extends (...args: any[]) => UniversalMiddleware ? X : {}; +type ExtractInContext = T extends (...args: any[]) => UniversalMiddleware ? unknown extends X ? Universal.Context : X : {}; export type InContext = ExtractInContext; export type OutContext = ${info.outContext?.(type) ?? "unknown"}; export default ${fn}(${type}) as (...args: ExtractT) => ${t}${info.genericParameters ?? ""}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c73b538..f137b46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: specifier: link:../packages/universal-middleware version: link:../packages/universal-middleware vitepress: - specifier: ^1.3.3 + specifier: ^1.3.4 version: 1.3.4(@algolia/client-search@4.24.0)(@types/node@20.16.5)(postcss@8.4.45)(typescript@5.5.4) examples/tool: @@ -154,6 +154,9 @@ importers: '@types/node': specifier: 'catalog:' version: 20.16.5 + '@universal-middleware/core': + specifier: link:../../packages/core + version: link:../../packages/core rimraf: specifier: ^6.0.0 version: 6.0.1 @@ -488,6 +491,9 @@ importers: '@webroute/route': specifier: ^0.8.0 version: 0.8.0 + '@webroute/router': + specifier: ^0.4.0 + version: 0.4.0 hono: specifier: 'catalog:' version: 4.5.11 @@ -511,6 +517,10 @@ importers: version: 2.0.5(@types/node@20.16.5) packages/core: + dependencies: + regexparam: + specifier: ^3.0.0 + version: 3.0.0 devDependencies: '@brillout/release-me': specifier: 'catalog:' @@ -1834,6 +1844,9 @@ packages: resolution: {integrity: sha512-FG9JQisT86E70I0ggyUKzb/fyKpYYd5XEBWfqZmCtHSHGd1uOzgEr4XmpmXfiZxpsg+GCbGiL/KTodyxDjloMQ==} engines: {node: ^20.0.0} + '@webroute/router@0.4.0': + resolution: {integrity: sha512-A//kx4i1NQPYSNj6vY79fAU2LtFYWcQCsYl/9q1ewZGnqqVETTBJ3STC54zwhCD8vfTX/kLRDxVbqq/tgOQw4g==} + '@webroute/schema@0.5.0': resolution: {integrity: sha512-35ye3JQEZLpXpCbeMOjpOzJtITdkVGJqe/7ziWb7ZLCHtXYATV11TfL8mcYWO+9r/UT0njt2ROKC1csL2PHFYQ==} @@ -3238,6 +3251,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + regexparam@3.0.0: + resolution: {integrity: sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==} + engines: {node: '>=8'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -5006,6 +5023,11 @@ snapshots: transitivePeerDependencies: - supports-color + '@webroute/router@0.4.0': + dependencies: + radix3: 1.1.2 + urlpattern-polyfill: 10.0.0 + '@webroute/schema@0.5.0': optionalDependencies: '@sinclair/typebox': 0.32.35 @@ -6580,6 +6602,8 @@ snapshots: real-require@0.2.0: {} + regexparam@3.0.0: {} + require-from-string@2.0.2: {} resolve-from@5.0.0: {} diff --git a/tests-examples/tests-tool/.testRun.ts b/tests-examples/tests-tool/.testRun.ts index 5c774a7..254afbb 100644 --- a/tests-examples/tests-tool/.testRun.ts +++ b/tests-examples/tests-tool/.testRun.ts @@ -19,4 +19,12 @@ export function testRun( expect(content).toContain('"World!!!"'); expect(response.headers.has("x-universal-hello")).toBe(true); }); + + test("/user/:name", async () => { + const response = await fetch(`${getServerUrl()}/user/magne4000`); + + const content = await response.text(); + + expect(content).toBe("User name is: magne4000"); + }); } diff --git a/tests-examples/tests-tool/functions/user/[name].ts b/tests-examples/tests-tool/functions/user/[name].ts new file mode 100644 index 0000000..77ad168 --- /dev/null +++ b/tests-examples/tests-tool/functions/user/[name].ts @@ -0,0 +1,3 @@ +import handler from "@universal-middleware-examples/tool/params-handler-cloudflare-pages"; + +export const onRequest = handler(); diff --git a/tests-examples/tests-tool/src/cloudflare-worker-entry.ts b/tests-examples/tests-tool/src/cloudflare-worker-entry.ts index 5873171..35bf638 100644 --- a/tests-examples/tests-tool/src/cloudflare-worker-entry.ts +++ b/tests-examples/tests-tool/src/cloudflare-worker-entry.ts @@ -1,11 +1,26 @@ +import handler from "@universal-middleware-examples/tool/dummy-handler"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware"; -import handler from "@universal-middleware-examples/tool/dummy-handler"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler"; import { createHandler } from "@universal-middleware/cloudflare"; import { pipe } from "@universal-middleware/core"; +const paramsHandlerInstance = paramsHandler({ + route: "/user/:name", +}); + // Cloudflare Workers have no internal way of representing a middleware // Instead, we use the universal `pipe` operator -const wrapped = pipe(contextMiddleware("World!!!"), headersMiddleware(), handler()); +const wrapped = pipe( + contextMiddleware("World!!!"), + headersMiddleware(), + (request, ctx, runtime) => { + const url = new URL(request.url); + if (url.pathname.startsWith("/user/")) { + return paramsHandlerInstance(request, ctx, runtime); + } + }, + handler(), +); export default createHandler(() => wrapped)(); diff --git a/tests-examples/tests-tool/src/express-entry.ts b/tests-examples/tests-tool/src/express-entry.ts index 3233d55..cdb46aa 100644 --- a/tests-examples/tests-tool/src/express-entry.ts +++ b/tests-examples/tests-tool/src/express-entry.ts @@ -1,6 +1,7 @@ +import handler from "@universal-middleware-examples/tool/dummy-handler-express"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-express"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-express"; -import handler from "@universal-middleware-examples/tool/dummy-handler-express"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler-express"; import express from "express"; import { args } from "./utils"; @@ -14,6 +15,8 @@ app.use(contextMiddleware("World!!!")); // the `{ "X-Universal-Hello": "World!!!" }` header is appended to it app.use(headersMiddleware()); +app.get("/user/:name", paramsHandler()); + app.get("/", handler()); const port = args.port ? Number.parseInt(args.port) : 3000; diff --git a/tests-examples/tests-tool/src/fastify-entry.ts b/tests-examples/tests-tool/src/fastify-entry.ts index 5782959..2c03e40 100644 --- a/tests-examples/tests-tool/src/fastify-entry.ts +++ b/tests-examples/tests-tool/src/fastify-entry.ts @@ -1,6 +1,7 @@ +import handler from "@universal-middleware-examples/tool/dummy-handler-fastify"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-fastify"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-fastify"; -import handler from "@universal-middleware-examples/tool/dummy-handler-fastify"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler-fastify"; import fastify from "fastify"; import rawBody from "fastify-raw-body"; import { args } from "./utils"; @@ -18,6 +19,8 @@ app.register(contextMiddleware("World!!!")); // the `{ "X-Universal-Hello": "World!!!" }` header is appended to it app.register(headersMiddleware()); +app.get("/user/:name", paramsHandler()); + app.get("/", handler()); const port = args.port ? Number.parseInt(args.port) : 3000; diff --git a/tests-examples/tests-tool/src/h3-entry.ts b/tests-examples/tests-tool/src/h3-entry.ts index 0002c1e..fc38a73 100644 --- a/tests-examples/tests-tool/src/h3-entry.ts +++ b/tests-examples/tests-tool/src/h3-entry.ts @@ -1,8 +1,9 @@ -import { createApp, createRouter, toNodeListener } from "h3"; +import handler from "@universal-middleware-examples/tool/dummy-handler-h3"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-h3"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-h3"; -import handler from "@universal-middleware-examples/tool/dummy-handler-h3"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler-h3"; import { universalOnBeforeResponse } from "@universal-middleware/h3"; +import { createApp, createRouter, toNodeListener } from "h3"; import { args } from "./utils"; const app = createApp({ @@ -20,6 +21,8 @@ app.use(headersMiddleware()); const router = createRouter(); +router.get("/user/:name", paramsHandler()); + router.get("/", handler()); app.use(router); diff --git a/tests-examples/tests-tool/src/hattip-entry.ts b/tests-examples/tests-tool/src/hattip-entry.ts index a656135..3405caf 100644 --- a/tests-examples/tests-tool/src/hattip-entry.ts +++ b/tests-examples/tests-tool/src/hattip-entry.ts @@ -1,8 +1,9 @@ +import { createServer } from "@hattip/adapter-node"; import { createRouter } from "@hattip/router"; +import handler from "@universal-middleware-examples/tool/dummy-handler-hattip"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hattip"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hattip"; -import handler from "@universal-middleware-examples/tool/dummy-handler-hattip"; -import { createServer } from "@hattip/adapter-node"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler-hattip"; import { args } from "./utils"; const app = createRouter(); @@ -15,6 +16,8 @@ app.use(contextMiddleware("World!!!")); // the `{ "X-Universal-Hello": "World!!!" }` header is appended to it app.use(headersMiddleware()); +app.get("/user/:name", paramsHandler()); + app.get("/", handler()); const hattipHandler = app.buildHandler(); diff --git a/tests-examples/tests-tool/src/hono-entry.ts b/tests-examples/tests-tool/src/hono-entry.ts index 44cac7e..04dfeb2 100644 --- a/tests-examples/tests-tool/src/hono-entry.ts +++ b/tests-examples/tests-tool/src/hono-entry.ts @@ -1,7 +1,8 @@ -import { Hono } from "hono"; +import handler from "@universal-middleware-examples/tool/dummy-handler-hono"; import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hono"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hono"; -import handler from "@universal-middleware-examples/tool/dummy-handler-hono"; +import paramsHandler from "@universal-middleware-examples/tool/params-handler-hono"; +import { Hono } from "hono"; const app = new Hono(); @@ -13,6 +14,8 @@ app.use(contextMiddleware("World!!!")); // the `{ "X-Universal-Hello": "World!!!" }` header is appended to it app.use(headersMiddleware()); +app.get("/user/:name", paramsHandler()); + app.get("/", handler()); export default app; diff --git a/tests-examples/tests-tool/test-type.test-d.ts b/tests-examples/tests-tool/test-type.test-d.ts index dd1f58e..1936147 100644 --- a/tests-examples/tests-tool/test-type.test-d.ts +++ b/tests-examples/tests-tool/test-type.test-d.ts @@ -1,32 +1,32 @@ -import { expectTypeOf, test } from "vitest"; -import honoContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hono"; -import honoHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hono"; -import honoHandler from "@universal-middleware-examples/tool/dummy-handler-hono"; -import expressContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-express"; -import expressHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-express"; +import handler from "@universal-middleware-examples/tool/dummy-handler"; import expressHandler from "@universal-middleware-examples/tool/dummy-handler-express"; -import hattipContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hattip"; -import hattipHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hattip"; +import fastifyHandler from "@universal-middleware-examples/tool/dummy-handler-fastify"; +import h3Handler from "@universal-middleware-examples/tool/dummy-handler-h3"; import hattipHandler from "@universal-middleware-examples/tool/dummy-handler-hattip"; -import webrouteContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-webroute"; -import webrouteHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-webroute"; +import honoHandler from "@universal-middleware-examples/tool/dummy-handler-hono"; import webrouteHandler from "@universal-middleware-examples/tool/dummy-handler-webroute"; +import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware"; +import expressContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-express"; import fastifyContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-fastify"; -import fastifyHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-fastify"; -import fastifyHandler from "@universal-middleware-examples/tool/dummy-handler-fastify"; import h3ContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-h3"; -import h3HeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-h3"; -import h3Handler from "@universal-middleware-examples/tool/dummy-handler-h3"; -import contextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware"; +import hattipContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hattip"; +import honoContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-hono"; +import webrouteContextMiddleware from "@universal-middleware-examples/tool/middlewares/context-middleware-webroute"; import headersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware"; -import handler from "@universal-middleware-examples/tool/dummy-handler"; -import type { HonoHandler, HonoMiddleware } from "@universal-middleware/hono"; -import type { HattipHandler, HattipMiddleware } from "@universal-middleware/hattip"; -import type { NodeHandler, NodeMiddleware } from "@universal-middleware/express"; -import type { WebrouteHandler, WebrouteMiddleware } from "@universal-middleware/webroute"; +import expressHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-express"; +import fastifyHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-fastify"; +import h3HeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-h3"; +import hattipHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hattip"; +import honoHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-hono"; +import webrouteHeadersMiddleware from "@universal-middleware-examples/tool/middlewares/headers-middleware-webroute"; import type { UniversalHandler, UniversalMiddleware } from "@universal-middleware/core"; +import type { NodeHandler, NodeMiddleware } from "@universal-middleware/express"; import type { FastifyHandler, FastifyMiddleware } from "@universal-middleware/fastify"; import type { H3Handler, H3Middleware } from "@universal-middleware/h3"; +import type { HattipHandler, HattipMiddleware } from "@universal-middleware/hattip"; +import type { HonoHandler, HonoMiddleware } from "@universal-middleware/hono"; +import type { WebrouteHandler, WebrouteMiddleware } from "@universal-middleware/webroute"; +import { expectTypeOf, test } from "vitest"; test("hono", () => { expectTypeOf(honoContextMiddleware).returns.toEqualTypeOf(); @@ -51,7 +51,7 @@ test("webroute", () => { WebrouteMiddleware >(); expectTypeOf(webrouteHeadersMiddleware).returns.toEqualTypeOf< - WebrouteMiddleware<{ hello?: string }, { hello?: string }> + WebrouteMiddleware >(); expectTypeOf(webrouteHandler).returns.toEqualTypeOf>(); });