This repository has been archived on 2023-06-09. You can view files and clone it, but cannot push or open issues or pull requests.
deno-http/mod.ts

542 lines
15 KiB
TypeScript
Raw Normal View History

2023-05-10 12:21:20 +00:00
import {
Status,
STATUS_TEXT,
} from "https://deno.land/std@0.186.0/http/http_status.ts";
2023-05-10 12:57:29 +00:00
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";
2023-05-13 02:50:15 +00:00
import { Aes } from "https://deno.land/x/crypto/aes.ts";
import { Cbc, Padding } from "https://deno.land/x/crypto/block-modes.ts";
2023-05-13 02:50:15 +00:00
type HTTPServerOptions = {
2023-05-10 12:21:20 +00:00
port: number;
host?: string;
2023-05-10 12:57:29 +00:00
staticLocalDir?: string;
staticServePath?: string;
2023-05-13 02:50:15 +00:00
sessionSecret?: string;
2023-05-13 02:58:01 +00:00
sessionExpire?: SessionExpire | number;
2023-05-10 12:21:20 +00:00
};
2023-05-12 11:55:26 +00:00
2023-05-13 02:58:01 +00:00
export enum SessionExpire {
NEVER = 2147483647,
}
2023-05-12 11:57:13 +00:00
export enum HTTPMethod {
2023-05-12 11:55:26 +00:00
GET = "GET",
POST = "POST",
PUSH = "PUSH",
DELETE = "DELETE",
}
2023-05-10 12:21:20 +00:00
type RouteHandler = (
req: RouteRequest,
2023-05-10 12:21:20 +00:00
rep: RouteReply,
) =>
| Promise<unknown>
| unknown;
2023-05-11 11:11:14 +00:00
type RouteMiddlewareHandler = (
req: RouteRequest,
2023-05-12 09:56:48 +00:00
rep: RouteReply,
2023-05-12 08:16:26 +00:00
done: () => Promise<number[]>,
) => Promise<void>;
2023-05-11 11:11:14 +00:00
2023-05-12 10:28:26 +00:00
type RoutePreprocessor = (
req: RouteRequest,
rep: RouteReply,
) => void;
2023-05-11 11:11:14 +00:00
type RouteParam = {
2023-05-11 04:46:41 +00:00
idx: number;
paramKey: string;
};
2023-05-10 12:21:20 +00:00
export class HTTPServer {
private server?: Deno.Listener;
private routes = new Map<string, Route>();
2023-05-10 12:57:29 +00:00
private staticLocalDir?: string;
private staticServePath?: string;
2023-05-11 07:32:18 +00:00
private notFoundHandler?: RouteHandler;
2023-05-12 10:28:26 +00:00
private preprocessors: RoutePreprocessor[] = [];
private middlewareHandler?: RouteMiddlewareHandler;
2023-05-13 02:50:15 +00:00
settings?: HTTPServerOptions;
2023-05-10 12:21:20 +00:00
2023-05-13 02:50:15 +00:00
async listen(options: HTTPServerOptions) {
this.settings = options;
2023-05-10 12:21:20 +00:00
this.server = Deno.listen({
port: options.port,
hostname: options.host,
});
2023-05-10 13:00:17 +00:00
console.log(
2023-05-12 08:29:45 +00:00
`Listening on ${options.host ?? "http://localhost"}:${options.port} !`,
2023-05-10 13:00:17 +00:00
);
2023-05-10 12:57:29 +00:00
if (options.staticLocalDir && options.staticServePath) {
this.staticLocalDir = options.staticLocalDir;
this.staticServePath = options.staticServePath;
}
2023-05-10 12:21:20 +00:00
for await (const conn of this.server) {
2023-05-10 17:03:24 +00:00
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",
},
},
),
);
}
}
2023-05-11 04:46:41 +00:00
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(
2023-05-13 02:50:15 +00:00
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)
);
2023-05-12 10:28:26 +00:00
let resolveAction: (value: number[]) => void = () => {};
let middlewarePromise;
const perStart = performance.now();
if (this.middlewareHandler) {
middlewarePromise = (): Promise<number[]> => {
return new Promise((resolve) => {
resolveAction = resolve;
});
};
this.middlewareHandler(routeRequest, routeReply, middlewarePromise);
}
2023-05-11 04:46:41 +00:00
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;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
}
2023-05-13 02:50:15 +00:00
this.processSession(routeRequest, routeReply);
this.handleNotFound(routeRequest, routeReply, requestEvent);
continue;
}
const readableStream = file.readable;
const response = new Response(readableStream);
2023-05-12 08:16:26 +00:00
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
}
2023-05-13 02:50:15 +00:00
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(response);
2023-05-12 13:05:06 +00:00
continue;
2023-05-10 12:21:20 +00:00
}
2023-05-11 04:46:41 +00:00
2023-05-13 02:50:15 +00:00
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;
2023-05-11 04:46:41 +00:00
if (typeof (handler) == "object") {
handler = JSON.stringify(handler, null, 2);
}
2023-05-11 09:53:28 +00:00
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
}
2023-05-13 02:50:15 +00:00
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(
new Response(handler as string, {
status: routeReply.statusCode,
headers: routeReply.headers,
statusText: STATUS_TEXT[routeReply.statusCode],
}),
);
continue;
2023-05-11 09:53:28 +00:00
}
route = Array.from(this.routes.values()).find((route) =>
routeWithParamsRouteMatcher(routeRequest, route)
2023-05-11 04:46:41 +00:00
);
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,
"",
),
};
},
{},
);
2023-05-11 04:46:41 +00:00
const handler = await route.handler(
routeRequest,
routeReply,
);
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
}
2023-05-13 02:50:15 +00:00
this.processSession(routeRequest, routeReply);
await requestEvent.respondWith(
new Response(handler as string, {
status: routeReply.statusCode,
headers: routeReply.headers,
statusText: STATUS_TEXT[routeReply.statusCode],
}),
);
continue;
}
2023-05-12 08:16:26 +00:00
if (middlewarePromise) {
const pt = performance.now() - perStart;
const hrArray: number[] = [0, Math.trunc(pt * 1000000)];
resolveAction(hrArray);
}
2023-05-13 02:50:15 +00:00
this.processSession(routeRequest, routeReply);
this.handleNotFound(routeRequest, routeReply, requestEvent);
2023-05-12 08:16:26 +00:00
}
} catch (_err) {
2023-05-12 13:05:06 +00:00
console.log(_err);
2023-05-12 11:34:11 +00:00
// Ignore http connections that where closed before reply was sent
2023-05-11 04:46:41 +00:00
}
2023-05-10 12:21:20 +00:00
}
2023-05-13 02:50:15 +00:00
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{
routeReply.cookie("session", undefined);
}
2023-05-13 02:50:15 +00:00
}
}
2023-05-11 08:29:21 +00:00
close() {
if (this.server) {
this.server.close();
}
}
2023-05-12 10:28:26 +00:00
preprocessor(handler: RoutePreprocessor) {
this.preprocessors.push(handler);
}
middleware(handler: RouteMiddlewareHandler) {
this.middlewareHandler = handler;
}
2023-05-11 07:32:18 +00:00
error(handler: RouteHandler) {
this.notFoundHandler = handler;
}
2023-05-10 12:21:20 +00:00
get(path: string, handler: RouteHandler) {
2023-05-12 11:55:26 +00:00
this.add(HTTPMethod.GET, path, handler);
2023-05-10 12:21:20 +00:00
}
post(path: string, handler: RouteHandler) {
2023-05-12 11:55:26 +00:00
this.add(HTTPMethod.POST, path, handler);
2023-05-10 12:21:20 +00:00
}
push(path: string, handler: RouteHandler) {
2023-05-12 11:55:26 +00:00
this.add(HTTPMethod.PUSH, path, handler);
2023-05-10 12:21:20 +00:00
}
delete(path: string, handler: RouteHandler) {
2023-05-12 11:55:26 +00:00
this.add(HTTPMethod.DELETE, path, handler);
2023-05-10 12:21:20 +00:00
}
2023-05-12 11:57:13 +00:00
add(method: HTTPMethod, path: string, handler: RouteHandler) {
2023-05-10 12:21:20 +00:00
const route = new Route(path, method, handler);
2023-05-10 13:28:00 +00:00
if (this.routes.has(route.routeName)) {
console.log(`${route.routeName} already registered!`);
return;
}
2023-05-10 12:21:20 +00:00
this.routes.set(route.routeName, route);
console.log(`${route.routeName} added`);
}
}
2023-05-11 04:46:41 +00:00
export const routeWithParamsRouteMatcher = (
req: RouteRequest,
route: Route,
): boolean => {
const routeMatcherRegEx = new RegExp(`^${routeParamPattern(route.path)}$`);
return (
2023-05-11 06:11:11 +00:00
req.method as HTTPMethod === route.method &&
2023-05-11 04:46:41 +00:00
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;
}, []);
2023-05-13 02:50:15 +00:00
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);
}
2023-05-10 12:21:20 +00:00
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 {
2023-05-11 04:46:41 +00:00
url: string;
path: string;
headers: Headers;
2023-05-13 02:50:15 +00:00
cookies: Record<string, string>;
2023-05-11 06:11:11 +00:00
method: HTTPMethod;
2023-05-11 05:03:10 +00:00
queryParams: { [key: string]: string };
pathParams: { [key: string]: string };
2023-05-12 08:16:26 +00:00
remoteIpAddr: string;
resourceRequest: boolean;
2023-05-13 02:50:15 +00:00
session: { [key: string]: unknown } = {};
constructor(
2023-05-13 02:50:15 +00:00
httpServer: HTTPServer,
request: Request,
conn: Deno.Conn,
path: string,
url: string,
) {
this.url = url;
this.path = decodeURIComponent(path);
this.headers = request.headers;
2023-05-11 06:11:11 +00:00
this.method = request.method as HTTPMethod;
2023-05-11 04:46:41 +00:00
this.pathParams = {};
2023-05-13 02:50:15 +00:00
this.resourceRequest = httpServer.settings?.staticServePath &&
httpServer.settings?.staticServePath.length > 0
? path.startsWith(httpServer.settings?.staticServePath)
: false;
2023-05-12 08:18:35 +00:00
this.queryParams = Object.fromEntries(new URL(url).searchParams.entries());
2023-05-13 02:50:15 +00:00
this.cookies = cookie.getCookies(this.headers);
2023-05-11 10:33:57 +00:00
this.remoteIpAddr = "hostname" in conn.remoteAddr
? conn.remoteAddr["hostname"]
: "127.0.0.1";
2023-05-13 02:50:15 +00:00
const sessionCookie = this.cookie("session") as string;
if (sessionCookie && httpServer.settings?.sessionSecret) {
const decodedSessionCookie = decryptHex(
sessionCookie,
httpServer.settings.sessionSecret,
);
2023-05-13 02:58:01 +00:00
try {
2023-05-13 02:50:15 +00:00
this.session = JSON.parse(decodedSessionCookie);
2023-05-13 02:58:01 +00:00
} catch (_err) {
2023-05-13 02:50:15 +00:00
// Ignore if sessionCookie is not in JSON format
}
}
}
2023-05-13 04:25:28 +00:00
sessionDestroy(): void {
this.session = {};
}
2023-05-11 05:03:10 +00:00
header(name: string): unknown {
const matchingHeader = Array.from(this.headers.keys()).find((headerName) =>
headerName === name
);
return matchingHeader ? this.headers.get(matchingHeader) : undefined;
}
2023-05-11 05:03:10 +00:00
cookie(name: string): unknown {
2023-05-13 02:50:15 +00:00
const allCookieNames = Object.keys(this.cookies);
return allCookieNames.includes(name) ? this.cookies[name] : undefined;
}
2023-05-11 05:03:10 +00:00
pathParam(name: string): string {
return this.pathParams[name];
}
queryParam(name: string): string {
return this.queryParams[name];
}
}
2023-05-10 12:21:20 +00:00
export class RouteReply {
headers: Headers = new Headers();
statusCode: Status = Status.OK;
2023-05-12 10:43:54 +00:00
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;
}
2023-05-10 12:21:20 +00:00
header(name: string, value: string): RouteReply {
this.headers.set(name, value);
return this;
2023-05-10 12:21:20 +00:00
}
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[];
2023-05-11 04:46:41 +00:00
}): 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,
});
}
2023-05-11 04:46:41 +00:00
return this;
}
2023-05-11 04:46:41 +00:00
}