diff --git a/README.md b/README.md index e0866d9..55e5b6f 100644 --- a/README.md +++ b/README.md @@ -98,16 +98,18 @@ const handleDate = routeAllAndEverything(date); const handleVerify = routePrivate(verify); const handleSubdomain = getSubdomain(sub); -const mainHandler = compose( +const mainMiddleware = compose( handleSubdomain, handleWelcome, handleVerify, handleDate, ) as any; // TS WTF! -const catchHandler = routeAllAndEverything(fix); -const finallyHandler = routeAllAndEverything(log, setHeader); +const catchMiddleware = routeAllAndEverything(fix); +const finallyMiddleware = routeAllAndEverything(log, setHeader); -const handler = createHandler(Ctx)(mainHandler)(catchHandler)(finallyHandler); +const handler = createHandler(Ctx)(mainMiddleware)(catchMiddleware)( + finallyMiddleware, +); await listen(handler)({ port: 8080 }); ``` diff --git a/context.ts b/context.ts new file mode 100644 index 0000000..4ab5f9c --- /dev/null +++ b/context.ts @@ -0,0 +1,33 @@ +import { type ConnInfo } from "./deps.ts"; + +/** Any object can be assigned to the property `state` of the `Context` object. */ +type State = Record; +// deno-lint-ignore no-explicit-any +type DefaultState = Record; + +/** + * An instance of the extendable `Context` is passed as only argument to your + * `Middleware`s. You can optionally extend the default `Context` object or pass + * a `State` type. + * ```ts + * export class Ctx extends Context<{ start: number }> { + * pathname = this.url.pathname; + * } + * ``` + */ +export class Context { + connInfo: ConnInfo; + error: Error | null = null; + result: URLPatternResult = {} as URLPatternResult; + request: Request; + response: Response = new Response("Not Found", { status: 404 }); + state: S; + url: URL; + + constructor(request: Request, connInfo: ConnInfo, state?: S) { + this.connInfo = connInfo; + this.request = request; + this.state = state || {} as S; + this.url = new URL(request.url); + } +} diff --git a/example.ts b/example.ts index 1410ef1..bb3b4d5 100644 --- a/example.ts +++ b/example.ts @@ -69,15 +69,17 @@ const handleDate = routeAllAndEverything(date); const handleVerify = routePrivate(verify); const handleSubdomain = getSubdomain(sub); -const mainHandler = compose( +const mainMiddleware = compose( handleSubdomain, handleWelcome, handleVerify, handleDate, ) as any; // TS WTF! -const catchHandler = routeAllAndEverything(fix); -const finallyHandler = routeAllAndEverything(log, setHeader); +const catchMiddleware = routeAllAndEverything(fix); +const finallyMiddleware = routeAllAndEverything(log, setHeader); -const handler = createHandler(Ctx)(mainHandler)(catchHandler)(finallyHandler); +const handler = createHandler(Ctx)(mainMiddleware)(catchMiddleware)( + finallyMiddleware, +); await listen(handler)({ port: 8080 }); diff --git a/handler.ts b/handler.ts new file mode 100644 index 0000000..0a8335e --- /dev/null +++ b/handler.ts @@ -0,0 +1,54 @@ +import { type ConnInfo, type Handler } from "./deps.ts"; +import { compose } from "./composition.ts"; +import { Middleware } from "./route.ts"; +import { Context } from "./context.ts"; + +function setXResponseTimeHeader(ctx: C, startTime: number) { + const ms = Date.now() - startTime; + ctx.response.headers.set("X-Response-Time", `${ms}ms`); +} + +function assertError(caught: unknown): Error { + return caught instanceof Error ? caught : new Error("[non-error thrown]"); +} + +/** + * A curried function which takes a `Context` class, `tryMiddlewares`, + * `catchMiddlewares` and `finallyMiddlewares` and returns in the end a `Handler` + * function which can be passed to `listen`. It also handles the HTTP method + * `HEAD` appropriately and sets the `X-Response-Time` header. You can pass + * an initial `state` object or disable the `X-Response-Time` header optionally. + * ```ts + * createHandler(Ctx)(tryHandler)(catchHandler)(finallyHandler) + * ``` + */ +export function createHandler( + Context: new (request: Request, connInfo: ConnInfo, state?: S) => C, + { state, enableXResponseTimeHeader = true }: { + state?: S; + enableXResponseTimeHeader?: boolean; + } = {}, +) { + let startTime = NaN; + return (...tryMiddlewares: Middleware[]) => + (...catchMiddlewares: Middleware[]) => + (...finallyMiddlewares: Middleware[]): Handler => + async (request: Request, connInfo: ConnInfo): Promise => { + const ctx = new Context(request, connInfo, state); + try { + if (enableXResponseTimeHeader) startTime = Date.now(); + await (compose(...tryMiddlewares)(ctx)); + } catch (caught) { + ctx.error = assertError(caught); + await (compose(...catchMiddlewares)(ctx)); + } finally { + await (compose(...finallyMiddlewares)(ctx)); + if (enableXResponseTimeHeader) setXResponseTimeHeader(ctx, startTime); + } + return request.method === "HEAD" + ? new Response(null, ctx.response) + : ctx.response; + }; +} + +export type { Handler }; diff --git a/handler_test.ts b/handler_test.ts new file mode 100644 index 0000000..d4865b6 --- /dev/null +++ b/handler_test.ts @@ -0,0 +1,49 @@ +import { Context, createHandler } from "./mod.ts"; +import { + connInfo, + mainMiddleware, + subtract5DelayedMiddleware, +} from "./test_util.ts"; +import { assertEquals } from "./test_deps.ts"; + +type State = { result: number }; + +const request = new Request("https:example.com/books"); + +class Ctx extends Context {} + +function catchMiddleware(ctx: Ctx) { + ctx.state.result = 1; + return ctx; +} + +function finallyMiddleware(ctx: Ctx) { + ctx.response = new Response(ctx.state.result.toString()); + return ctx; +} + +function throwMiddleware(_ctx: Ctx): never { + throw new Error("uups"); +} + +Deno.test("createHandler", async function () { + assertEquals( + await (await createHandler(Ctx, { state: { result: 10 } })(mainMiddleware)( + subtract5DelayedMiddleware, + )( + finallyMiddleware, + )(request, connInfo)).text(), + "28", + ); + assertEquals( + await (await createHandler(Ctx, { state: { result: 10 } })( + mainMiddleware, + throwMiddleware, + )( + catchMiddleware, + )( + finallyMiddleware, + )(request, connInfo)).text(), + "1", + ); +}); diff --git a/mod.ts b/mod.ts index 21e2e86..e10df20 100644 --- a/mod.ts +++ b/mod.ts @@ -1,8 +1,5 @@ -export { - Context, - createHandler, - createRoute, - listen, - type Method, -} from "./server.ts"; +export { listen } from "./server.ts"; +export { Context } from "./context.ts"; +export * from "./route.ts"; +export { createHandler, type Handler } from "./handler.ts"; export { compose, composeSync } from "./composition.ts"; diff --git a/route.ts b/route.ts new file mode 100644 index 0000000..c394026 --- /dev/null +++ b/route.ts @@ -0,0 +1,54 @@ +import { compose } from "./composition.ts"; +import { type Context } from "./context.ts"; + +export type Middleware = (ctx: C) => C | Promise; +export type Method = + | "ALL" + | "CONNECT" + | "DELETE" + | "GET" + | "HEAD" + | "OPTIONS" + | "PATCH" + | "POST" + | "PUT" + | "TRACE"; + +/** + * A curried function which takes HTTP `Method`s, a `URLPatternInput` and + * `Middleware`s and returns in the end a composed route function. + * ```ts + * createRoute("GET")({ pathname: "*" })(middleware) + * ``` + */ +export function createRoute(...methods: Method[]) { + return (urlPatternInput: URLPatternInput) => { + const urlPattern = new URLPattern(urlPatternInput); + return (...middlewares: Middleware[]) => + async (ctx: C): Promise => { + if ( + methods.includes("ALL") || + methods.includes(ctx.request.method as Method) || + (ctx.request.method === "HEAD" && methods.includes("GET")) + ) { + const urlPatternResult = urlPattern.exec(ctx.url); + if (urlPatternResult) { + ctx.result = urlPatternResult; + return await (compose>(...middlewares))(ctx); + } + } + return ctx; + }; + }; +} + +export const createAllRoute = createRoute("ALL"); +export const createConnectRoute = createRoute("CONNECT"); +export const createDeleteRoute = createRoute("DELETE"); +export const createGetRoute = createRoute("GET"); +export const createHeadRoute = createRoute("HEAD"); +export const createOptionsRoute = createRoute("OPTIONS"); +export const createPatchRoute = createRoute("PATCH"); +export const createPostRoute = createRoute("POST"); +export const createPutRoute = createRoute("PUT"); +export const createTraceRoute = createRoute("TRACE"); diff --git a/route_test.ts b/route_test.ts new file mode 100644 index 0000000..a3dcc68 --- /dev/null +++ b/route_test.ts @@ -0,0 +1,64 @@ +import { Context, createRoute } from "./mod.ts"; +import { + add10Middleware, + connInfo, + divide5DelayedMiddleware, + mainMiddleware, +} from "./test_util.ts"; +import { assertEquals } from "./test_deps.ts"; + +type State = { result: number }; + +const request = new Request("https:example.com/books"); + +class Ctx extends Context {} + +Deno.test("createRoute", async function () { + assertEquals( + (await createRoute("ALL")({ pathname: "/books" })(add10Middleware)( + new Ctx(request, connInfo, { result: 10 }), + )).state.result, + 20, + ); + assertEquals( + (await createRoute("GET")({ pathname: "/books" })( + add10Middleware, + divide5DelayedMiddleware, + )( + new Ctx(request, connInfo, { result: 10 }), + )).state.result, + 12, + ); + assertEquals( + (await createRoute("POST", "GET")({ pathname: "/books" })(mainMiddleware)( + new Ctx( + request, + connInfo, + { result: 10 }, + ), + )).state.result, + 28, + ); + assertEquals( + (await createRoute("POST", "DELETE")({ pathname: "/books" })( + mainMiddleware, + )( + new Ctx( + request, + connInfo, + { result: 10 }, + ), + )).state.result, + 10, + ); + assertEquals( + (await createRoute("GET")({ pathname: "/ups" })(mainMiddleware)( + new Ctx( + request, + connInfo, + { result: 10 }, + ), + )).state.result, + 10, + ); +}); diff --git a/server.ts b/server.ts index 620e25a..897a68d 100644 --- a/server.ts +++ b/server.ts @@ -1,133 +1,11 @@ -import { compose } from "./composition.ts"; import { - ConnInfo, - Handler, + type Handler, serve, - ServeInit, + type ServeInit, serveTls, - ServeTlsInit, + type ServeTlsInit, } from "./deps.ts"; -type CtxHandler = (ctx: C) => C | Promise; -/** Any object can be assigned to the property `state` of the `Context` object. */ -type State = Record; -// deno-lint-ignore no-explicit-any -type DefaultState = Record; -export type Method = - | "ALL" - | "CONNECT" - | "DELETE" - | "GET" - | "HEAD" - | "OPTIONS" - | "PATCH" - | "POST" - | "PUT" - | "TRACE"; - -/** - * An instance of the extendable `Context` is passed as only argument to your - * `CtxHandler`s. You can optionally extend the default `Context` object or pass - * a `State` type. - * ```ts - * export class Ctx extends Context<{ start: number }> { - * pathname = this.url.pathname; - * } - * ``` - */ -export class Context { - connInfo: ConnInfo; - error: Error | null = null; - result: URLPatternResult = {} as URLPatternResult; - request: Request; - response: Response = new Response("Not Found", { status: 404 }); - state: S; - url: URL; - - constructor(request: Request, connInfo: ConnInfo, state?: S) { - this.connInfo = connInfo; - this.request = request; - this.state = state || {} as S; - this.url = new URL(request.url); - } -} - -/** - * A curried function which takes HTTP `Method`s, a `URLPatternInput` and - * `CtxHandler`s and returns in the end a composed route function. - * ```ts - * createRoute("GET")({ pathname: "*" })(ctxHandler) - * ``` - */ -export function createRoute(...methods: Method[]) { - return (urlPatternInput: URLPatternInput) => { - const urlPattern = new URLPattern(urlPatternInput); - return (...handlers: CtxHandler[]) => - async (ctx: C): Promise => { - if ( - methods.includes("ALL") || - methods.includes(ctx.request.method as Method) || - (ctx.request.method === "HEAD" && methods.includes("GET")) - ) { - const urlPatternResult = urlPattern.exec(ctx.url); - if (urlPatternResult) { - ctx.result = urlPatternResult; - return await (compose>(...handlers))(ctx); - } - } - return ctx; - }; - }; -} - -function setXResponseTimeHeader(ctx: C, startTime: number) { - const ms = Date.now() - startTime; - ctx.response.headers.set("X-Response-Time", `${ms}ms`); -} - -function assertError(caught: unknown): Error { - return caught instanceof Error ? caught : new Error("[non-error thrown]"); -} - -/** - * A curried function which takes a `Context` class, `tryHandlers`, - * `catchHandlers` and `finallyHandlers` and returns in the end a `Handler` - * function which can be passed to `listen`. It also handles the HTTP method - * `HEAD` appropriately and sets the `X-Response-Time` header. You can pass - * an initial `state` object or disable the `X-Response-Time` header optionally. - * ```ts - * createHandler(Ctx)(tryHandler)(catchHandler)(finallyHandler) - * ``` - */ -export function createHandler( - Context: new (request: Request, connInfo: ConnInfo, state?: S) => C, - { state, enableXResponseTimeHeader = true }: { - state?: S; - enableXResponseTimeHeader?: boolean; - } = {}, -) { - let startTime = NaN; - return (...tryHandlers: CtxHandler[]) => - (...catchHandlers: CtxHandler[]) => - (...finallyHandlers: CtxHandler[]) => - async (request: Request, connInfo: ConnInfo): Promise => { - const ctx = new Context(request, connInfo, state); - try { - if (enableXResponseTimeHeader) startTime = Date.now(); - await (compose(...tryHandlers)(ctx)); - } catch (caught) { - ctx.error = assertError(caught); - await (compose(...catchHandlers)(ctx)); - } finally { - await (compose(...finallyHandlers)(ctx)); - if (enableXResponseTimeHeader) setXResponseTimeHeader(ctx, startTime); - } - return request.method === "HEAD" - ? new Response(null, ctx.response) - : ctx.response; - }; -} - /** * A curried function which takes a `Handler` and `options`. It constructs a * server, creates a listener on the given address, accepts incoming connections, diff --git a/server_test.ts b/server_test.ts deleted file mode 100644 index 21b1388..0000000 --- a/server_test.ts +++ /dev/null @@ -1,123 +0,0 @@ -// deno-lint-ignore-file -import { compose, Context, createHandler, createRoute } from "./mod.ts"; -import { - add10, - divide5Delayed, - multiply10, - subtract5Delayed, -} from "./test_util.ts"; -import { assertEquals } from "./test_deps.ts"; - -type State = { result: number }; - -const request = new Request("https:example.com/books"); -const connInfo = { - localAddr: { transport: "tcp" as const, hostname: "127.0.0.1", port: 8080 }, - remoteAddr: { transport: "tcp" as const, hostname: "127.0.0.1", port: 48951 }, -}; - -const add10Handler = createCtxHandler(add10); -const multiplyHandler = createCtxHandler(multiply10); -const subtract5DelayedHandler = createCtxHandler(subtract5Delayed); -const divide5DelayedHandler = createCtxHandler(divide5Delayed); - -const mainHandler = compose( - add10Handler, - divide5DelayedHandler, - subtract5DelayedHandler, - multiplyHandler, -) as any; - -class Ctx extends Context {} - -function createCtxHandler(f: (...args: any[]) => any | Promise) { - return async (ctx: C) => { - ctx.state.result = await f(ctx.state.result); - return ctx; - }; -} - -function catchHandler(ctx: Ctx) { - ctx.state.result = 1; - return ctx; -} - -function finallyHandler(ctx: Ctx) { - ctx.response = new Response(ctx.state.result.toString()); - return ctx; -} - -function throwHandler(ctx: Ctx) { - throw new Error("uups"); - return ctx; -} - -Deno.test("createRoute", async function () { - assertEquals( - (await createRoute("ALL")({ pathname: "/books" })(add10Handler)( - new Ctx(request, connInfo, { result: 10 }), - )).state.result, - 20, - ); - assertEquals( - (await createRoute("GET")({ pathname: "/books" })( - add10Handler, - divide5DelayedHandler, - )( - new Ctx(request, connInfo, { result: 10 }), - )).state.result, - 12, - ); - assertEquals( - (await createRoute("POST", "GET")({ pathname: "/books" })(mainHandler)( - new Ctx( - request, - connInfo, - { result: 10 }, - ), - )).state.result, - 28, - ); - assertEquals( - (await createRoute("POST", "DELETE")({ pathname: "/books" })(mainHandler)( - new Ctx( - request, - connInfo, - { result: 10 }, - ), - )).state.result, - 10, - ); - assertEquals( - (await createRoute("GET")({ pathname: "/ups" })(mainHandler)( - new Ctx( - request, - connInfo, - { result: 10 }, - ), - )).state.result, - 10, - ); -}); - -Deno.test("createHandler", async function () { - assertEquals( - await (await createHandler(Ctx, { state: { result: 10 } })(mainHandler)( - subtract5DelayedHandler, - )( - finallyHandler, - )(request, connInfo)).text(), - "28", - ); - assertEquals( - await (await createHandler(Ctx, { state: { result: 10 } })( - mainHandler, - throwHandler, - )( - catchHandler, - )( - finallyHandler, - )(request, connInfo)).text(), - "1", - ); -}); diff --git a/test_util.ts b/test_util.ts index a1d7782..bfbf693 100644 --- a/test_util.ts +++ b/test_util.ts @@ -1,4 +1,18 @@ +// deno-lint-ignore-file import { delay } from "./test_deps.ts"; +import { compose, type Context } from "./mod.ts"; + +export const connInfo = { + localAddr: { transport: "tcp" as const, hostname: "127.0.0.1", port: 8080 }, + remoteAddr: { transport: "tcp" as const, hostname: "127.0.0.1", port: 48951 }, +}; + +function createMiddleware(f: (...args: any[]) => any | Promise) { + return async (ctx: C) => { + ctx.state.result = await f(ctx.state.result); + return ctx; + }; +} export function add10(n: number) { return n + 10; @@ -17,3 +31,15 @@ export async function divide5Delayed(n: number) { await delay(10); return n / 5; } + +export const add10Middleware = createMiddleware(add10); +export const multiplyMiddleware = createMiddleware(multiply10); +export const subtract5DelayedMiddleware = createMiddleware(subtract5Delayed); +export const divide5DelayedMiddleware = createMiddleware(divide5Delayed); + +export const mainMiddleware = compose( + add10Middleware, + divide5DelayedMiddleware, + subtract5DelayedMiddleware, + multiplyMiddleware, +) as any; diff --git a/url-pattern/server.ts b/url-pattern/server.ts index 984f2e9..a2d3f96 100644 --- a/url-pattern/server.ts +++ b/url-pattern/server.ts @@ -1,6 +1,6 @@ import { Context, createHandler, createRoute, listen } from "../mod.ts"; -import { serveDir } from "https://deno.land/std@0.164.0/http/file_server.ts"; -import { fromFileUrl } from "https://deno.land/std@0.164.0/path/mod.ts"; +import { serveDir } from "https://deno.land/std@0.192.0/http/file_server.ts"; +import { fromFileUrl } from "https://deno.land/std@0.192.0/path/mod.ts"; function identity(x: X) { return x; @@ -14,7 +14,7 @@ async function serveStatic(ctx: Context) { return ctx; } -const mainHandler = createRoute("GET")({ pathname: "*" })(serveStatic); -const handler = createHandler(Context)(mainHandler)(identity)(identity); +const mainMiddleware = createRoute("GET")({ pathname: "*" })(serveStatic); +const handler = createHandler(Context)(mainMiddleware)(identity)(identity); await listen(handler)({ port: 8080 });