diff --git a/application.ts b/application.ts index 62998009..d9e145af 100644 --- a/application.ts +++ b/application.ts @@ -4,7 +4,11 @@ import { Context } from "./context.ts"; import { KeyStack, Status, STATUS_TEXT } from "./deps.ts"; import { HttpServer } from "./http_server_native.ts"; import { NativeRequest } from "./http_server_native_request.ts"; -import { compose, Middleware } from "./middleware.ts"; +import { + compose, + isMiddlewareObject, + type MiddlewareOrMiddlewareObject, +} from "./middleware.ts"; import { cloneState } from "./structured_clone.ts"; import { Key, @@ -303,7 +307,7 @@ export class Application> >; #contextState: "clone" | "prototype" | "alias" | "empty"; #keys?: KeyStack; - #middleware: Middleware>[] = []; + #middleware: MiddlewareOrMiddlewareObject>[] = []; #serverConstructor: ServerConstructor; /** A set of keys, or an instance of `KeyStack` which will be used to sign @@ -571,6 +575,11 @@ export class Application> if (!this.#middleware.length) { throw new TypeError("There is no middleware to process requests."); } + for (const middleware of this.#middleware) { + if (isMiddlewareObject(middleware) && middleware.init) { + await middleware.init(); + } + } if (typeof options === "string") { const match = ADDR_REGEXP.exec(options); if (!match) { @@ -651,11 +660,11 @@ export class Application> * ``` */ use( - middleware: Middleware>, - ...middlewares: Middleware>[] + middleware: MiddlewareOrMiddlewareObject>, + ...middlewares: MiddlewareOrMiddlewareObject>[] ): Application; use( - ...middleware: Middleware>[] + ...middleware: MiddlewareOrMiddlewareObject>[] ): Application { this.#middleware.push(...middleware); this.#composedMiddleware = undefined; diff --git a/examples/countingServer.ts b/examples/countingServer.ts new file mode 100644 index 00000000..0d02afab --- /dev/null +++ b/examples/countingServer.ts @@ -0,0 +1,88 @@ +/* This is an example of how to use an object as a middleware. + * `MiddlewareObject` can be ideal for when a middleware needs to encapsulate + * large amounts of logic or its own state. */ + +import { + bold, + cyan, + green, + yellow, +} from "https://deno.land/std@0.183.0/fmt/colors.ts"; + +import { + Application, + composeMiddleware, + type Context, + type MiddlewareObject, + type Next, +} from "../mod.ts"; + +const app = new Application(); + +class CountingMiddleware implements MiddlewareObject { + #id = 0; + #counter = 0; + + init() { + const array = new Uint32Array(1); + crypto.getRandomValues(array); + this.#id = array[0]; + } + + handleRequest(ctx: Context, next: Next) { + ctx.response.headers.set("X-Response-Count", String(this.#counter++)); + ctx.response.headers.set("X-Response-Counter-ID", String(this.#id)); + return next(); + } +} + +class LoggerMiddleware implements MiddlewareObject { + #composedMiddleware: (context: Context, next: Next) => Promise; + + constructor() { + this.#composedMiddleware = composeMiddleware([ + this.#handleLogger, + this.#handleResponseTime, + ]); + } + + async #handleLogger(ctx: Context, next: Next) { + await next(); + const rt = ctx.response.headers.get("X-Response-Time"); + console.log( + `${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${ + bold( + String(rt), + ) + }`, + ); + } + + async #handleResponseTime(ctx: Context, next: Next) { + const start = Date.now(); + await next(); + const ms = Date.now() - start; + ctx.response.headers.set("X-Response-Time", `${ms}ms`); + } + + handleRequest(ctx: Context, next: Next) { + return this.#composedMiddleware(ctx, next); + } +} + +app.use(new CountingMiddleware()); +app.use(new LoggerMiddleware()); + +app.use((ctx) => { + ctx.response.body = "Hello World!"; +}); + +app.addEventListener("listen", ({ hostname, port, serverType }) => { + console.log( + bold("Start listening on ") + yellow(`${hostname}:${port}`), + ); + console.log(bold(" using HTTP server: " + yellow(serverType))); +}); + +await app.listen({ hostname: "127.0.0.1", port: 8000 }); +console.log(bold("Finished.")); diff --git a/middleware.ts b/middleware.ts index ba4bf0b7..53f08aee 100644 --- a/middleware.ts +++ b/middleware.ts @@ -5,12 +5,45 @@ import type { State } from "./application.ts"; import type { Context } from "./context.ts"; -/** Middleware are functions which are chained together to deal with requests. */ +/** A function for chaining middleware. */ +export type Next = () => Promise; + +/** Middleware are functions which are chained together to deal with + * requests. */ export interface Middleware< S extends State = Record, T extends Context = Context, > { - (context: T, next: () => Promise): Promise | unknown; + (context: T, next: Next): Promise | unknown; +} + +/** Middleware objects allow encapsulation of middleware along with the ability + * to initialize the middleware upon listen. */ +export interface MiddlewareObject< + S extends State = Record, + T extends Context = Context, +> { + /** Optional function for delayed initialization which will be called when + * the application starts listening. */ + init?: () => Promise | unknown; + /** The method to be called to handle the request. */ + handleRequest(context: T, next: Next): Promise | unknown; +} + +/** Type that represents {@linkcode Middleware} or + * {@linkcode MiddlewareObject}. */ +export type MiddlewareOrMiddlewareObject< + S extends State = Record, + T extends Context = Context, +> = Middleware | MiddlewareObject; + +/** A type guard that returns true if the value is + * {@linkcode MiddlewareObject}. */ +export function isMiddlewareObject< + S extends State = Record, + T extends Context = Context, +>(value: MiddlewareOrMiddlewareObject): value is MiddlewareObject { + return value && typeof value === "object" && "handleRequest" in value; } /** Compose multiple middleware functions into a single middleware function. */ @@ -18,11 +51,11 @@ export function compose< S extends State = Record, T extends Context = Context, >( - middleware: Middleware[], -): (context: T, next?: () => Promise) => Promise { + middleware: MiddlewareOrMiddlewareObject[], +): (context: T, next?: Next) => Promise { return function composedMiddleware( context: T, - next?: () => Promise, + next?: Next, ): Promise { let index = -1; @@ -31,7 +64,13 @@ export function compose< throw new Error("next() called multiple times."); } index = i; - let fn: Middleware | undefined = middleware[i]; + let m: MiddlewareOrMiddlewareObject | undefined = middleware[i]; + let fn: Middleware | undefined; + if (typeof m === "function") { + fn = m; + } else if (m && typeof m.handleRequest === "function") { + fn = (m as MiddlewareObject).handleRequest.bind(m); + } if (i === middleware.length) { fn = next; } diff --git a/middleware_test.ts b/middleware_test.ts index dc6f8b32..3d5f26ab 100644 --- a/middleware_test.ts +++ b/middleware_test.ts @@ -5,11 +5,16 @@ import { assert, assertEquals, assertStrictEquals } from "./test_deps.ts"; import { errors } from "./deps.ts"; import { createMockContext } from "./testing.ts"; -import { compose, Middleware } from "./middleware.ts"; +import { + compose, + isMiddlewareObject, + type Middleware, + type MiddlewareObject, + type Next, +} from "./middleware.ts"; +import { Context } from "./context.ts"; -const { test } = Deno; - -test({ +Deno.test({ name: "test compose()", async fn() { const callStack: number[] = []; @@ -31,7 +36,50 @@ test({ }, }); -test({ +Deno.test({ + name: "isMiddlewareObject()", + async fn() { + class MockMiddlewareObject implements MiddlewareObject { + handleRequest( + _context: Context, Record>, + _next: Next, + ): unknown { + return; + } + } + + assert(isMiddlewareObject(new MockMiddlewareObject())); + assert(isMiddlewareObject({ handleRequest() {} })); + assert(!isMiddlewareObject(function () {})); + }, +}); + +Deno.test({ + name: "middleware objects are composed correctly", + async fn() { + const callStack: number[] = []; + const mockContext = createMockContext(); + + class MockMiddlewareObject implements MiddlewareObject { + #counter = 0; + + async handleRequest(_context: any, next: Next) { + assertEquals(typeof next, "function"); + callStack.push(this.#counter++); + await next(); + } + } + + const mwo = new MockMiddlewareObject(); + const fn = compose([mwo]); + + await fn(mockContext); + await fn(mockContext); + assertEquals(callStack, [0, 1]); + }, +}); + +Deno.test({ name: "next() is catchable", async fn() { let caught: any; @@ -51,7 +99,7 @@ test({ }, }); -test({ +Deno.test({ name: "composed middleware accepts next middleware", async fn() { const callStack: number[] = []; diff --git a/mod.ts b/mod.ts index a03d3883..aa9de0a6 100644 --- a/mod.ts +++ b/mod.ts @@ -68,7 +68,12 @@ export { type NativeRequest } from "./http_server_native_request.ts"; export { proxy } from "./middleware/proxy.ts"; export type { ProxyOptions } from "./middleware/proxy.ts"; export { compose as composeMiddleware } from "./middleware.ts"; -export type { Middleware } from "./middleware.ts"; +export type { + Middleware, + MiddlewareObject, + MiddlewareOrMiddlewareObject, + Next, +} from "./middleware.ts"; export { FormDataReader } from "./multipart.ts"; export type { FormDataBody,