diff --git a/_app.ts b/_app.ts index 44057c5..aa40aaa 100644 --- a/_app.ts +++ b/_app.ts @@ -1,7 +1,12 @@ -import createRequest, { ServaRequest } from "./_request.ts"; +import { RouteFactory, HooksFactory } from "./factories.ts"; +import createRequest, { ServaRequest } from "./request.ts"; import createRoute, { Route } from "./_route.ts"; import { fs, http, path, flags } from "./deps.ts"; +export interface OnRequestCallback { + (request: ServaRequest, next: () => Promise): Promise | any; +} + export interface RouteCallback { (request: ServaRequest): Promise | any; } @@ -21,7 +26,7 @@ const DEFAULT_CONFIG = Object.freeze({ methods: "get|post|put|delete|patch".split("|"), }); -type RouteEntry = [Route, RouteCallback]; +type RouteEntry = [Route, OnRequestCallback[]]; type RoutesStruct = Map; export default class App { @@ -159,7 +164,7 @@ export default class App { const configFilePath = this.path("serva.config.json"); const lstats = await Deno.lstat(configFilePath); if (!lstats.isFile) { - throw new Error(); + throw new Error("serva.config.json is not a file"); } // @ts-expect-error @@ -210,16 +215,26 @@ export default class App { } const routes: [string, RouteEntry][] = []; + const routeHooks: [string, RouteEntry][] = []; - for await (const entry of fs.walk(routesPath)) { - // ignore directories and any non-typescript files + for await (const entry of fs.walk(routesPath, { includeDirs: false })) { + // ignore any non-typescript files // todo: support for javascript - if (entry.isDirectory || !entry.name.endsWith(this.config.extension)) { + if (!entry.name.endsWith(this.config.extension)) { continue; } const relativeEntryPath = path.relative(routesPath, entry.path); - const method = routeMethod(relativeEntryPath, this.config); + let [filename, method] = nameAndMethodFromPath( + relativeEntryPath, + this.config, + ); + + // throw if dev is trying to: _hooks.get.ts + if (filename === "_hooks" && method !== "*") { + throw new Error("Hook files should not contain a methods"); + } + const urlPath = routePath(relativeEntryPath, this.config); // route information const route = createRoute( method, @@ -228,7 +243,7 @@ export default class App { ); // register route - let callback: RouteCallback; + let callback: unknown; try { let filePath = `file://${entry.path}`; if (remount) { @@ -238,7 +253,7 @@ export default class App { ({ default: callback } = await import(filePath)); if (typeof callback !== "function") { throw new TypeError( - `${route.filePath} default export is not a callback.`, + `${route.filePath} default export is not a callback`, ); } } catch (err) { @@ -246,16 +261,59 @@ export default class App { continue; } - routes.push( - [ - route.method, - [route, callback!], - ], - ); + // check if the export was a factory + // @ts-ignore https://github.com/Microsoft/TypeScript/issues/1863 + const factory = callback[Symbol.for("serva_factory")]; + let hooks: OnRequestCallback[] = []; + if (factory) { + switch (factory) { + case "hooks": + if (filename !== "_hooks") { + throw new Error("Invalid hooks filename"); + } + hooks = (callback as HooksFactory)(route); + routeHooks.push([route.method, [route, hooks]]); + continue; + + case "route": + [hooks, callback] = (callback as RouteFactory)(route); + hooks.push(routeToRequestCallback(callback as RouteCallback)); + break; + + default: + throw new Error(`Factory (${factory}) not implemented`); + } + } else { + hooks = [routeToRequestCallback(callback as RouteCallback)]; + } + + routes.push([route.method, [route, hooks]]); } // sort and set routes + routeHooks.sort(sortRoutes); routes.sort(sortRoutes).forEach(([method, entry]) => { + // merge routeHooks into route + const [route, hooks] = entry; + const fakeUrl = route.toPath( + route.paramNames.length + ? route.paramNames.reduce((previous, name) => ({ + ...previous, + [name]: "foo", + }), {}) + : undefined, + ); + + const hooksToMerge = routeHooks + .filter(([, [hookRoute]]) => hookRoute.regexp.test(fakeUrl)) + .map(([, entry]) => entry[1]) + .reduce((previous, hooks_) => [ + ...previous, + ...hooks_, + ], []); + + hooks.unshift(...hooksToMerge); + const entries = this.routes.get(method) || []; entries.push(entry); @@ -275,21 +333,22 @@ export default class App { private async handleRequest(req: http.ServerRequest): Promise { // find a matching route let route: Route; - let callback: RouteCallback; + let callbacks: OnRequestCallback[] = []; const { pathname } = new URL(req.url, "https://serva.land"); const possibleMethods = [req.method, "*"]; if (req.method === "HEAD") { - possibleMethods.splice(0, 1, "GET"); + // HEAD, GET, * + possibleMethods.splice(1, 0, "GET"); } possibleMethods.some((m) => { const entries = this.routes.get(m); if (entries) { - return entries.some(([r, cb]) => { + return entries.some(([r, cbs]) => { if (r.regexp.test(pathname)) { route = r; - callback = cb; + callbacks = cbs; // route found return true; } @@ -300,89 +359,88 @@ export default class App { // not found // @ts-ignore if (!route) { - req.respond({ - status: 404, - }); - return; + return req.respond({ status: 404 }); } const request = createRequest(req, route); - const body = await callback!(request); - - // allow return values to set the body - if (body !== undefined) { - request.response.body = body; - } + await dispatch(callbacks, request); // if nobody has responded, send the current request's response if (request.httpRequest.w.usedBufferBytes === 0) { + const { response } = request; + + // detect json response + if (!validHttpResponseBody(response.body)) { + response.body = JSON.stringify(response.body); + response.headers.set("content-type", "application/json; charset=utf-8"); + } + req.respond(request.response); } } } /** - * Gets the route path from a file path. + * Returns the filename and extracted method from a file path. * * @example - * routePath("index.ts"); - * // => "/" - * - * routePath("comments/[comment].get.ts"); - * // => "/comments/[comment]" + * nameAndMethodFromPath("index.ts") + * // => ["index", "*"] + * + * nameAndMethodFromPath("comments/[comment].get.ts"); + * // => ["[comment]", "GET"] * - * @param {string} filePath + * @param {string} filePath * @param {ServaConfig} config - * @returns {string} + * @returns [string, string] */ -function routePath(filePath: string, config: ServaConfig): string { +function nameAndMethodFromPath( + filePath: string, + config: ServaConfig, +): [string, string] { let name = path.basename(filePath, config.extension); + let method = "*"; + const matched = name.match( new RegExp(`.*(?=\.(${config.methods.join("|")})$)`, "i"), ); if (matched) { - [name] = matched; - } - - if (name === "index") { - name = ""; - } - - let base = path.dirname(filePath); - if (base === ".") { - base = ""; + [name, method] = matched; } - return "/" + (base ? path.join(base, name) : name); + return [name, method.toUpperCase()]; } /** - * Returns the route method from a give route path. + * Gets the route path from a file path. * * @example - * routeMethod("index.ts"); - * // => "*" + * routePath("index.ts"); + * // => "/" + * + * routePath("comments/[comment].get.ts"); + * // => "/comments/[comment]" * - * routeMethod("/comments/[comment].get.ts") - * // => "GET" - * * @param {string} filePath * @param {ServaConfig} config * @returns {string} */ -function routeMethod(filePath: string, config: ServaConfig): string { - const name = path.basename(filePath, config.extension); - const matched = name.match( - new RegExp(`.*(?=\.(${config.methods.join("|")})$)`, "i"), - ); +function routePath(filePath: string, config: ServaConfig): string { + let [name] = nameAndMethodFromPath(filePath, config); - let method = "*"; - if (matched) { - [, method] = matched; + if (name === "index") { + name = ""; + } else if (name === "_hooks") { + name = "*"; // single glob match for hook files } - return method.toUpperCase(); + let base = path.dirname(filePath); + if (base === ".") { + base = ""; + } + + return "/" + (base ? path.join(base, name) : name); } /** @@ -425,3 +483,66 @@ function sortRoutes(a: [string, RouteEntry], b: [string, RouteEntry]): number { return 0; } + +/** + * The hooks dispatcher. + * + * @param {OnRequestCallback[]} callbacks + * @param {ServaRequest} request + * @returns {Promise} + */ +function dispatch( + callbacks: OnRequestCallback[], + request: ServaRequest, +): Promise { + let i = -1; + const next = (current = 0): Promise => { + if (current <= i) { + throw new Error("next() already called"); + } + + const cb = callbacks[i = current]; + + return Promise.resolve( + cb ? cb(request, next.bind(undefined, i + 1)) : undefined, + ); + }; + return next(); +} + +/** + * Transforms a route callback to a request hook callback. + * + * @param {RouteCallback} callback + * @returns {OnRequestCallback} + */ +function routeToRequestCallback(callback: RouteCallback): OnRequestCallback { + return async function (request, next) { + const body = await callback(request); + if (body !== undefined) { + request.response.body = body; + } + return next(); + }; +} + +/** + * Chek if a given body is a valid Http response type. + * + * @param {any} body + * @returns {boolean} + */ +function validHttpResponseBody(body: any): boolean { + switch (typeof body) { + case "undefined": + case "string": + return true; + + case "object": + return body && + (body instanceof Uint8Array || typeof body.read === "function"); + + default: + return false; + } +} diff --git a/_route.ts b/_route.ts index 5ad210c..3e61c04 100644 --- a/_route.ts +++ b/_route.ts @@ -5,7 +5,9 @@ export interface Route { readonly method: string; readonly path: string; readonly regexp: RegExp; + readonly paramNames: Array; params: (path: string) => Map; + toPath: (params?: object) => string; } /** @@ -29,16 +31,18 @@ export default function create( }); const matcher = pathToRegexp.match(cleaned); - return { + return Object.freeze({ filePath, path, method, regexp, + paramNames: keys.map((k) => k.name), + toPath: pathToRegexp.compile(cleaned, { encode: encodeURIComponent }), params(path: string): Map { const matches = matcher(path); return new Map(matches ? Object.entries(matches.params) : []); }, - }; + }); } /** diff --git a/_route_test.ts b/_route_test.ts index 10daea0..4ad79af 100644 --- a/_route_test.ts +++ b/_route_test.ts @@ -3,23 +3,33 @@ import createRoute from "./_route.ts"; import { pathToRegexp } from "./deps.ts"; Deno.test("basicRouteObject", () => { - const { params, ...route } = createRoute("GET", "/", "/routes/index.get.ts"); + const { params, toPath, ...route } = createRoute( + "GET", + "/", + "/routes/index.get.ts", + ); assertEquals(route, { filePath: "/routes/index.get.ts", path: "/", method: "GET", + paramNames: [], regexp: pathToRegexp.pathToRegexp("/"), }); }); Deno.test("nonEndingRouteObject", () => { - const { params, ...route } = createRoute("GET", "/*", "/routes/_hook.ts"); + const { params, toPath, ...route } = createRoute( + "GET", + "/*", + "/routes/_hook.ts", + ); assertEquals(route, { filePath: "/routes/_hook.ts", path: "/*", method: "GET", + paramNames: [], regexp: pathToRegexp.pathToRegexp("/", [], { end: false, }), @@ -27,12 +37,13 @@ Deno.test("nonEndingRouteObject", () => { }); Deno.test("routeParams", () => { - const { params } = createRoute( + const { params, paramNames } = createRoute( "GET", "/[first]-[last]/comments/[comment]/view", "./routes/[first]-[last]/comments/[comment]/view.get.ts", ); + assertEquals(paramNames, ["first", "last", "comment"]); assertEquals( params("/chris-turner/comments/123/view"), new Map([["first", "chris"], ["last", "turner"], ["comment", "123"]]), diff --git a/body_reader.ts b/body_reader.ts new file mode 100644 index 0000000..74abdb5 --- /dev/null +++ b/body_reader.ts @@ -0,0 +1,64 @@ +import { http } from "./deps.ts"; + +interface JSONOptions { + reviver?: (this: any, key: string, value: any) => any; +} + +export default class BodyReader { + private request: http.ServerRequest; + + /** + * Creates a new instance of BodyParser. + * + * @param {http.ServerRequest} request + */ + constructor(request: http.ServerRequest) { + this.request = request; + } + + /** + * Reads the request body. + * + * @returns {Promise} + */ + async read() { + return await Deno.readAll(this.request.body); + } + + /** + * Reads the request body as string. + * + * @returns {Promise} + */ + async text() { + const decoder = new TextDecoder(); + return decoder.decode(await this.read()); + } + + /** + * Reads the request body as json. + * + * @param {JSONOptions} [options] + * @returns {Promise} + */ + async json(options?: JSONOptions): Promise { + return JSON.parse(await this.text(), options && options.reviver) as T; + } + + /** + * Reads the request body as form data. + * + * @returns {Promise} + */ + async form() { + const text = await this.text(); + const params = new URLSearchParams(text); + const form = new FormData(); + + for (const [key, value] of params.entries()) { + form.set(key, value); + } + + return form; + } +} diff --git a/deps.ts b/deps.ts index d523491..6d2f9b5 100644 --- a/deps.ts +++ b/deps.ts @@ -1,12 +1,12 @@ -export * as flags from "https://deno.land/std@0.57.0/flags/mod.ts"; +export * as flags from "https://deno.land/std@0.62.0/flags/mod.ts"; // fs contains unstable features just re-export under fs -import { readJson } from "https://deno.land/std@0.57.0/fs/read_json.ts"; -import { walk } from "https://deno.land/std@0.57.0/fs/walk.ts"; +import { readJson } from "https://deno.land/std@0.62.0/fs/read_json.ts"; +import { walk } from "https://deno.land/std@0.62.0/fs/walk.ts"; export const fs = { readJson, walk }; -export * as http from "https://deno.land/std@0.57.0/http/mod.ts"; +export * as http from "https://deno.land/std@0.62.0/http/mod.ts"; -export * as path from "https://deno.land/std@0.57.0/path/mod.ts"; +export * as path from "https://deno.land/std@0.62.0/path/mod.ts"; export * as pathToRegexp from "https://raw.githubusercontent.com/pillarjs/path-to-regexp/v6.1.0/src/index.ts"; diff --git a/example/routes/_hooks.ts b/example/routes/_hooks.ts new file mode 100644 index 0000000..d0f7697 --- /dev/null +++ b/example/routes/_hooks.ts @@ -0,0 +1,17 @@ +// ./example/routes/_hooks.ts +import { hooks } from "../../mod.ts"; + +export default hooks(({ onRequest }) => { + onRequest(async ({ url }, next) => { + const { pathname } = url; + + console.log(`--> ${pathname}`); + await next(); + console.log(`<-- ${pathname}`); + }); + + onRequest(async ({ response }, next) => { + await next(); + response.headers.set("X-Powered-By", "Serva"); + }); +}); diff --git a/example/routes/index.get.ts b/example/routes/index.get.ts index 44709f1..594ff01 100644 --- a/example/routes/index.get.ts +++ b/example/routes/index.get.ts @@ -1,10 +1,12 @@ // ./example/routes/index.get.ts -import { ServaRequest } from "../../mod.ts"; +import { route } from "../../mod.ts"; -export default ({ response }: ServaRequest) => { - response.headers = new Headers({ - "X-Powered-By": "Serva", +export default route(({ onRequest }) => { + onRequest(async (_, next) => { + console.log("before"); + await next(); + console.log("after"); }); - return "Hello from Serva."; -}; + return () => "Hello from Serva."; +}); diff --git a/example/routes/index.post.ts b/example/routes/index.post.ts index 92237dc..9149c7f 100644 --- a/example/routes/index.post.ts +++ b/example/routes/index.post.ts @@ -1,10 +1,8 @@ -// ./example/routes/index.post.ts -import { ServaRequest } from "../../mod.ts"; +import { ServaRequest } from "../../request.ts"; -export default ({ response }: ServaRequest) => { - response.headers = new Headers({ - "X-Powered-By": "Serva", - }); +// ./example/routes/index.post.ts +export default async ({ body }: ServaRequest) => { + const { name } = await body.json(); - return "Wait a minute, please Mr. POST-man."; + return `Hello, ${name}!`; }; diff --git a/example/routes/profile/[name].get.ts b/example/routes/profile/[name].get.ts index c186b32..a250304 100644 --- a/example/routes/profile/[name].get.ts +++ b/example/routes/profile/[name].get.ts @@ -1,10 +1,4 @@ // ./example/routes/index.get.ts import { ServaRequest } from "../../../mod.ts"; -export default ({ response, params }: ServaRequest) => { - response.headers = new Headers({ - "X-Powered-By": "Serva", - }); - - return `Welcome ${params.get("name")}.`; -}; +export default ({ params }: ServaRequest) => `Welcome ${params.get("name")}.`; diff --git a/factories.ts b/factories.ts new file mode 100644 index 0000000..c453ed0 --- /dev/null +++ b/factories.ts @@ -0,0 +1,49 @@ +import { RouteCallback, OnRequestCallback } from "./_app.ts"; +import { Route } from "./_route.ts"; + +export interface RouteFactory { + (route: Route): [OnRequestCallback[], RouteCallback]; +} + +export interface HooksFactory { + (route: Route): OnRequestCallback[]; +} + +interface HooksApi { + route: Route; + onRequest: (callback: OnRequestCallback) => void; +} + +interface RouteApi extends HooksApi {} + +export function route( + callback: (api: RouteApi) => RouteCallback, +): RouteFactory { + function routeFactory(route: Route): [OnRequestCallback[], RouteCallback] { + const hooks: OnRequestCallback[] = []; + return [hooks, callback({ onRequest: hooks.push.bind(hooks), route })]; + } + + // signal to the app this is a factory + Object.assign(routeFactory, { + [Symbol.for("serva_factory")]: "route", + }); + + return routeFactory; +} + +export function hooks(callback: (api: HooksApi) => void): HooksFactory { + function hooksFactory(route: Route): OnRequestCallback[] { + const hooks: OnRequestCallback[] = []; + callback({ onRequest: hooks.push.bind(hooks), route }); + + return hooks; + } + + // signal to the app this is a factory + Object.assign(hooksFactory, { + [Symbol.for("serva_factory")]: "hooks", + }); + + return hooksFactory; +} diff --git a/factories_test.ts b/factories_test.ts new file mode 100644 index 0000000..1210746 --- /dev/null +++ b/factories_test.ts @@ -0,0 +1,45 @@ +import { + assert, + assertEquals, +} from "https://deno.land/std@0.57.0/testing/asserts.ts"; +import { route, hooks } from "./factories.ts"; +import createRoute from "./_route.ts"; + +Deno.test("routeFactory", () => { + let inner = false; + const r = createRoute("GET", "/", "./routes/index.get.ts"); + const hook = () => {}; + const callback = () => {}; + + const factory = route(({ route, onRequest }) => { + inner = true; // spy + assertEquals(route, r); + + onRequest(hook); + + return callback; + }); + + const res = factory(r); + + assert(inner); + assertEquals(res, [[hook], callback]); +}); + +Deno.test("hooksFactory", () => { + let inner = false; + const r = createRoute("*", "/", "./routes/_hooks.ts"); + const hook = () => {}; + + const factory = hooks(({ route, onRequest }) => { + inner = true; // spy + assertEquals(route, r); + + onRequest(hook); + }); + + const res = factory(r); + + assert(inner); + assertEquals(res, [hook]); +}); diff --git a/mod.ts b/mod.ts index 3abb7a3..3eaf814 100644 --- a/mod.ts +++ b/mod.ts @@ -1,2 +1,3 @@ -export { ServaRequest } from "./_request.ts"; -export { RouteCallback, ServaConfig } from "./_app.ts"; +export { OnRequestCallback, RouteCallback, ServaConfig } from "./_app.ts"; +export { route, hooks, RouteFactory, HooksFactory } from "./factories.ts"; +export { ServaRequest, ServaResponse } from "./request.ts"; diff --git a/_request.ts b/request.ts similarity index 69% rename from _request.ts rename to request.ts index c3abdd1..029cb69 100644 --- a/_request.ts +++ b/request.ts @@ -1,5 +1,11 @@ import { Route } from "./_route.ts"; import { http } from "./deps.ts"; +import BodyReader from "./body_reader.ts"; + +export interface ServaResponse extends http.Response { + headers: Headers; + body?: any; // allow routes to return anything +} export interface ServaRequest { readonly httpRequest: http.ServerRequest; @@ -9,9 +15,10 @@ export interface ServaRequest { readonly method: string; readonly params: ReadonlyMap; readonly headers: Headers; + readonly body: BodyReader; // response - readonly response: http.Response; + readonly response: ServaResponse; } /** @@ -25,7 +32,9 @@ export default function create( req: http.ServerRequest, route: Route, ): ServaRequest { - const response: http.Response = {}; + const response: ServaResponse = { + headers: new Headers(), + }; const proto = req.proto.split("/")[0].toLowerCase(); const url = new URL(req.url, `${proto}://${req.headers.get("host")}`); @@ -35,7 +44,8 @@ export default function create( method: req.method, params: route.params(url.pathname), headers: req.headers, - get response(): http.Response { + body: new BodyReader(req), + get response(): ServaResponse { return response; }, }; diff --git a/_request_test.ts b/request_test.ts similarity index 89% rename from _request_test.ts rename to request_test.ts index 9163dbe..f2ae420 100644 --- a/_request_test.ts +++ b/request_test.ts @@ -1,6 +1,6 @@ import { assertEquals } from "https://deno.land/std/testing/asserts.ts"; import { http } from "./deps.ts"; -import createRequest from "./_request.ts"; +import createRequest from "./request.ts"; import createRoute from "./_route.ts"; Deno.test("servaRequestObject", () => { @@ -26,6 +26,8 @@ Deno.test("servaRequestObject", () => { method: mockRequest.method, params: new Map([["name", "chris"]]), headers: mockRequest.headers, - response: {}, + response: { + headers: new Headers(), + }, }); });