import { Status, STATUS_TEXT, } from "https://deno.land/std@0.186.0/http/http_status.ts"; import * as path from "https://deno.land/std@0.185.0/path/mod.ts"; import * as cookie from "https://deno.land/std@0.185.0/http/cookie.ts"; import { Aes } from "https://deno.land/x/crypto@v0.10.0/aes.ts"; import { Cbc, Padding, } from "https://deno.land/x/crypto@v0.10.0/block-modes.ts"; import { cryptoRandomString } from "https://deno.land/x/crypto_random_string@1.0.0/mod.ts"; type HTTPServerOptions = { port: number; host?: string; staticLocalDir?: string; staticServePath?: string; sessionSecret?: string; sessionExpire?: SessionExpire | number; }; type MiddlewareResult = { processTime: number; }; export enum SessionExpire { NEVER = 2147483647, } export enum HTTPMethod { GET = "GET", POST = "POST", PUSH = "PUSH", DELETE = "DELETE", } type RouteHandler = ( req: RouteRequest, rep: RouteReply, ) => | Promise | unknown; type RouteMiddlewareHandler = ( req: RouteRequest, rep: RouteReply, done: () => Promise, ) => Promise; type RoutePreprocessor = ( req: RouteRequest, rep: RouteReply, ) => void; type RouteParam = { idx: number; paramKey: string; }; export class HTTPServer { private server?: Deno.Listener; private routes = new Map(); private staticLocalDir?: string; private staticServePath?: string; private notFoundHandler?: RouteHandler; private preprocessors: RoutePreprocessor[] = []; private middlewareHandler?: RouteMiddlewareHandler; settings?: HTTPServerOptions; async listen(options: HTTPServerOptions) { if (options.sessionSecret) { if (![16, 24, 32].includes(options.sessionSecret.length)) { const randomString = cryptoRandomString({ length: 32 }); throw new Error( "\nInvalid key size (must be either 16, 24 or 32 bytes)\nHere is a pregenerated key: " + randomString, ); } } this.settings = options; this.server = Deno.listen({ port: options.port, hostname: options.host, }); console.log( `Listening on ${options.host ?? "http://localhost"}:${options.port} !`, ); if (options.staticLocalDir && options.staticServePath) { this.staticLocalDir = options.staticLocalDir; this.staticServePath = options.staticServePath; } for await (const conn of this.server) { this.handleHttp(conn); } } private async handleNotFound( request: RouteRequest, reply: RouteReply, requestEvent: Deno.RequestEvent, ) { if (this.notFoundHandler) { reply.status(Status.NotFound); reply.type("application/json"); const notNoundHandle = await this.notFoundHandler( request, reply, ); await requestEvent.respondWith( new Response(notNoundHandle as string, { status: reply.statusCode, headers: reply.headers, statusText: STATUS_TEXT[reply.statusCode], }), ); } else { await requestEvent.respondWith( new Response( JSON.stringify({ code: 404, message: `File ${request.path} not found!`, }), { status: Status.NotFound, headers: { "Content-Type": "application/json", }, }, ), ); } } private async handleHttp(conn: Deno.Conn) { try { const httpConn = Deno.serveHttp(conn); for await (const requestEvent of httpConn) { const filepath = decodeURIComponent( "/" + requestEvent.request.url.split("/").slice(3).join("/"), ); const request = requestEvent.request; const url = request.url; const routeRequest = new RouteRequest( this, request, conn, filepath, url, ); const routeReply: RouteReply = new RouteReply(); if (filepath.startsWith("/_static") || filepath.endsWith(".ico")) { this.handleNotFound(routeRequest, routeReply, requestEvent); continue; } this.preprocessors.forEach((preProcessor) => preProcessor(routeRequest, routeReply) ); let resolveAction: (value: MiddlewareResult) => void = () => {}; let middlewarePromise; const perStart = performance.now(); if (this.middlewareHandler) { middlewarePromise = (): Promise => { return new Promise((resolve) => { resolveAction = resolve; }); }; this.middlewareHandler(routeRequest, routeReply, middlewarePromise); } if (this.staticServePath && filepath.startsWith(this.staticServePath)) { const fileDir = filepath.split("/").slice(2).join("/"); const pathLoc = path.join( Deno.cwd(), this.staticLocalDir as string, fileDir, ); let file; try { file = await Deno.open(pathLoc, { read: true }); } catch { if (middlewarePromise) { const pt = performance.now() - perStart; resolveAction({ processTime: pt, }); } this.processSession(routeRequest, routeReply); this.handleNotFound(routeRequest, routeReply, requestEvent); continue; } const readableStream = file.readable; const response = new Response(readableStream); if (middlewarePromise) { const pt = performance.now() - perStart; resolveAction({ processTime: pt, }); } this.processSession(routeRequest, routeReply); await requestEvent.respondWith(response); continue; } const routeName = `${requestEvent.request.method}@${ filepath.replace(/(?!\/)\W\D.*/gm, "") }`; let route = this.routes.get(routeName); if (route) { let handler = await route.handler( routeRequest, routeReply, ) ?? routeReply.body; if (typeof (handler) == "object") { handler = JSON.stringify(handler, null, 2); } if (middlewarePromise) { const pt = performance.now() - perStart; resolveAction({ processTime: pt, }); } this.processSession(routeRequest, routeReply); await requestEvent.respondWith( new Response(handler as string, { status: routeReply.statusCode, headers: routeReply.headers, statusText: STATUS_TEXT[routeReply.statusCode], }), ); continue; } route = Array.from(this.routes.values()).find((route) => routeWithParamsRouteMatcher(routeRequest, route) ); if (route) { const routeParamsMap: RouteParam[] = extractRouteParams(route.path); const routeSegments: string[] = filepath.split("/"); routeRequest.pathParams = routeParamsMap.reduce( (accum: { [key: string]: string }, curr: RouteParam) => { return { ...accum, [curr.paramKey]: routeSegments[curr.idx].replace( /(?!\/)\W\D.*/gm, "", ), }; }, {}, ); const handler = await route.handler( routeRequest, routeReply, ); if (middlewarePromise) { const pt = performance.now() - perStart; resolveAction({ processTime: pt, }); } this.processSession(routeRequest, routeReply); await requestEvent.respondWith( new Response(handler as string, { status: routeReply.statusCode, headers: routeReply.headers, statusText: STATUS_TEXT[routeReply.statusCode], }), ); continue; } if (middlewarePromise) { const pt = performance.now() - perStart; resolveAction({ processTime: pt, }); } this.processSession(routeRequest, routeReply); this.handleNotFound(routeRequest, routeReply, requestEvent); } } catch (_err) { // Ignore http connections that where closed before reply was sent } } private processSession(routeRequest: RouteRequest, routeReply: RouteReply) { if (this.settings?.sessionSecret) { const sessionObject = JSON.stringify(routeRequest.session); if (Object.keys(routeRequest.session).length > 0) { const encodedSession = encryptData( sessionObject, this.settings?.sessionSecret, ); routeReply.cookie("session", encodedSession, { maxAge: this.settings.sessionExpire ?? undefined, }); } else { if (routeRequest.cookie("session")) { routeReply.cookie("session", undefined); } } } } close() { if (this.server) { this.server.close(); } } preprocessor(handler: RoutePreprocessor) { this.preprocessors.push(handler); } middleware(handler: RouteMiddlewareHandler) { this.middlewareHandler = handler; } error(handler: RouteHandler) { this.notFoundHandler = handler; } get(path: string, handler: RouteHandler) { this.add(HTTPMethod.GET, path, handler); } post(path: string, handler: RouteHandler) { this.add(HTTPMethod.POST, path, handler); } push(path: string, handler: RouteHandler) { this.add(HTTPMethod.PUSH, path, handler); } delete(path: string, handler: RouteHandler) { this.add(HTTPMethod.DELETE, path, handler); } add(method: HTTPMethod, path: string, handler: RouteHandler) { const route = new Route(path, method, handler); if (this.routes.has(route.routeName)) { console.log(`${route.routeName} already registered!`); return; } this.routes.set(route.routeName, route); console.log(`${route.routeName} added`); } } export const routeWithParamsRouteMatcher = ( req: RouteRequest, route: Route, ): boolean => { const routeMatcherRegEx = new RegExp(`^${routeParamPattern(route.path)}$`); return ( req.method as HTTPMethod === route.method && route.path.includes("/:") && routeMatcherRegEx.test(req.path) ); }; export const routeParamPattern: (route: string) => string = (route) => route.replace(/\/\:[^/]{1,}/gi, "/[^/]{1,}").replace(/\//g, "\\/"); export const extractRouteParams: (route: string) => RouteParam[] = (route) => route.split("/").reduce((accum: RouteParam[], curr: string, idx: number) => { if (/:[A-Za-z1-9]{1,}/.test(curr)) { const paramKey: string = curr.replace(":", ""); const param: RouteParam = { idx, paramKey }; return [...accum, param]; } return accum; }, []); function encryptData(data: string, key: string) { const te = new TextEncoder(); const aeskey = te.encode(key); const encodeddata = te.encode(data); const iv = new Uint8Array(16); const cipher = new Cbc(Aes, aeskey, iv, Padding.PKCS7); const encrypted = cipher.encrypt(encodeddata); const hexed = Array.from(encrypted).map((b) => b.toString(16).padStart(2, "0") ).join(""); return hexed; } function decryptHex(data: string, key: string) { const te = new TextEncoder(); const td = new TextDecoder(); const byteArray = new Uint8Array(data.length / 2); for (let i = 0; i < data.length; i += 2) { const byte = parseInt(data.substring(i, i + 2), 16); byteArray[Math.floor(i / 2)] = byte; } const aeskey = te.encode(key); const iv = new Uint8Array(16); const decipher = new Cbc(Aes, aeskey, iv, Padding.PKCS7); const decrypted = decipher.decrypt(byteArray); return td.decode(decrypted); } export class Route { routeName: string; path: string; method: HTTPMethod; handler: RouteHandler; constructor(path: string, method: HTTPMethod, handler: RouteHandler) { this.path = path; this.method = method; this.routeName = `${method}@${path}`; this.handler = handler; } } export class RouteRequest { url: string; path: string; headers: Headers; cookies: Record; method: HTTPMethod; queryParams: { [key: string]: string }; pathParams: { [key: string]: string }; remoteIpAddr: string; resourceRequest: boolean; session: { [key: string]: unknown } = {}; constructor( httpServer: HTTPServer, request: Request, conn: Deno.Conn, path: string, url: string, ) { this.url = url; this.path = decodeURIComponent(path); this.headers = request.headers; this.method = request.method as HTTPMethod; this.pathParams = {}; this.resourceRequest = httpServer.settings?.staticServePath && httpServer.settings?.staticServePath.length > 0 ? path.startsWith(httpServer.settings?.staticServePath) : false; this.queryParams = Object.fromEntries(new URL(url).searchParams.entries()); this.cookies = cookie.getCookies(this.headers); this.remoteIpAddr = "hostname" in conn.remoteAddr ? conn.remoteAddr["hostname"] : "127.0.0.1"; const sessionCookie = this.cookie("session") as string; if (sessionCookie && httpServer.settings?.sessionSecret) { const decodedSessionCookie = decryptHex( sessionCookie, httpServer.settings.sessionSecret, ); try { this.session = JSON.parse(decodedSessionCookie); } catch (_err) { console.log(_err); // Ignore if sessionCookie is not in JSON format } } } sessionDestroy(): void { this.session = {}; } header(name: string): unknown { const matchingHeader = Array.from(this.headers.keys()).find((headerName) => headerName === name ); return matchingHeader ? this.headers.get(matchingHeader) : undefined; } cookie(name: string): unknown { const allCookieNames = Object.keys(this.cookies); return allCookieNames.includes(name) ? this.cookies[name] : undefined; } pathParam(name: string): string { return this.pathParams[name]; } queryParam(name: string): string { return this.queryParams[name]; } } export class RouteReply { headers: Headers = new Headers(); statusCode: Status = Status.OK; body: unknown; json(json: JSON | { [key: string]: unknown } | []) { this.type("application/json"); this.body = JSON.stringify(json, null, 2); } html(html: string) { this.type("text/html"); this.body = html; } header(name: string, value: string): RouteReply { this.headers.set(name, value); return this; } status(code: Status): RouteReply { this.statusCode = code; return this; } type(type: string): RouteReply { this.header("Content-Type", type); return this; } cookie(name: string, value: string | undefined, attributes?: { expires?: Date | number; maxAge?: number; domain?: string; path?: string; secure?: boolean; httpOnly?: boolean; sameSite?: "Strict" | "Lax" | "None"; unparsed?: string[]; }): RouteReply { if (!value) { cookie.deleteCookie(this.headers, name, { domain: attributes?.domain, path: attributes?.path, }); } else { cookie.setCookie(this.headers, { name: name, value: value, expires: attributes?.expires, maxAge: attributes?.maxAge, domain: attributes?.domain, path: attributes?.path, secure: attributes?.secure, httpOnly: attributes?.httpOnly, sameSite: attributes?.sameSite, unparsed: attributes?.unparsed, }); } return this; } }