Compare commits

...

41 Commits

Author SHA1 Message Date
c5f842b409 just remove session cookie, if its set 2023-05-14 22:13:17 +02:00
46cf6081af remove debug log 2023-05-14 22:07:51 +02:00
997fe6dc77 change middleware done result to own type 2023-05-14 22:07:08 +02:00
b335bb61bd set crypto version 2023-05-14 20:23:17 +02:00
2e6ce88e80 add exception for sessionSecret 2023-05-14 20:22:37 +02:00
8a3683d2e5 allow undefined arg for cookie, just set cookie if session has entries 2023-05-13 06:29:01 +02:00
96a73035f8 add sessionDestroy func 2023-05-13 06:25:28 +02:00
5392b032d0 add sessionExpire option 2023-05-13 04:58:01 +02:00
d519c7bf9c add sessions, add session example 2023-05-13 04:50:15 +02:00
b2e357227c add example for multiple preprocessors 2023-05-12 15:19:02 +02:00
1376fbdeba replace return with continue 2023-05-12 15:05:06 +02:00
7f1fce92b2 revert last change 2023-05-12 14:51:36 +02:00
784a34ed4d fix memory leak, close file after serve 2023-05-12 14:49:21 +02:00
4e1ac5888a better example html 2023-05-12 14:14:06 +02:00
e6f6b1ddc5 add var to identify static resource requests 2023-05-12 14:10:41 +02:00
06695c5443 add img to html static test 2023-05-12 14:01:51 +02:00
c6cb48b4ff add static example to site html test 2023-05-12 14:00:24 +02:00
452be0cecf export HTTPMethod 2023-05-12 13:57:13 +02:00
ec0df0de60 change HTTPMethod to enum 2023-05-12 13:55:26 +02:00
ca6d23f5ef fix catch block text 2023-05-12 13:34:11 +02:00
d89310bb2e remove whitespace at start of path 2023-05-12 13:09:23 +02:00
1c085b9ec5 catch issue where the connection was closed before reply 2023-05-12 13:09:09 +02:00
5626782178 do log after request is done 2023-05-12 12:55:31 +02:00
e3e5f8c1d2 add funcs for better content handling 2023-05-12 12:43:54 +02:00
03a1f35eaa add preprocessor handlers 2023-05-12 12:28:26 +02:00
0675b26dad remove query params from path param if present 2023-05-12 12:12:35 +02:00
1c668e5892 add reply to middlewareResponse 2023-05-12 11:56:48 +02:00
301a6d2c3d adjust listenting console output 2023-05-12 10:29:45 +02:00
49d5818062 remove unused npm import 2023-05-12 10:20:06 +02:00
ee384eb4bc remove unused func 2023-05-12 10:18:35 +02:00
60b819b9a4 massive performance fixes 2023-05-12 10:16:26 +02:00
42ae41ba87 replace URL with split to gain some extra performance 2023-05-11 15:59:44 +02:00
bbc861fde3 force async func 2023-05-11 14:30:53 +02:00
874c43126d add static example 2023-05-11 14:27:22 +02:00
64d77caeb1 add prettyTime npm example usage 2023-05-11 14:20:37 +02:00
8482c4c6cc use hrtime instead of date 2023-05-11 13:29:23 +02:00
10fb61793f add done promise for middleware 2023-05-11 13:11:14 +02:00
b974abff1b add ip func to request 2023-05-11 12:33:57 +02:00
a9017c7550 add middleware handler, move notfound to own func 2023-05-11 12:16:13 +02:00
ab10ab7091 add object returner support 2023-05-11 11:53:28 +02:00
bf454f83b8 rename module 2023-05-11 10:29:54 +02:00
6 changed files with 726 additions and 386 deletions

2
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"name": "test deno-http", "name": "test deno-http",
"type": "node", "type": "node",
"program": "${workspaceFolder}/example/test.ts", "program": "${workspaceFolder}/example/test.ts",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}/example/",
"runtimeExecutable": "C:\\Users\\Admin\\.deno\\bin\\deno.EXE", "runtimeExecutable": "C:\\Users\\Admin\\.deno\\bin\\deno.EXE",
"runtimeArgs": [ "runtimeArgs": [
"run", "run",

BIN
example/static/lucoa.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

6
example/static/style.css Normal file
View File

@@ -0,0 +1,6 @@
h1 {
color: red;
}
img {
margin-bottom: 15px;
}

View File

@@ -1,22 +1,165 @@
import { Status } from "https://deno.land/std@0.186.0/http/http_status.ts"; import { Status } from "https://deno.land/std@0.186.0/http/http_status.ts";
import { HTTPServer } from "../http_server.ts"; import prettyTime from "npm:pretty-time";
import { HTTPServer, SessionExpire } from "../mod.ts";
const JOKES = [
"Why do Java developers often wear glasses? They can't C#.",
"A SQL query walks into a bar, goes up to two tables and says “can I join you?”",
"Wasn't hard to crack Forrest Gump's password. 1forrest1.",
"I love pressing the F5 key. It's refreshing.",
"Called IT support and a chap from Australia came to fix my network connection. I asked “Do you come from a LAN down under?”",
"There are 10 types of people in the world. Those who understand binary and those who don't.",
"Why are assembly programmers often wet? They work below C level.",
"My favourite computer based band is the Black IPs.",
"What programme do you use to predict the music tastes of former US presidential candidates? An Al Gore Rhythm.",
"An SEO expert walked into a bar, pub, inn, tavern, hostelry, public house.",
];
const httpServer = new HTTPServer(); const httpServer = new HTTPServer();
// Add as many preprocessors as you want
httpServer.preprocessor((req, _rep) => {
if (req.resourceRequest) {
console.log(`Requested resource ${req.path}`);
}
});
httpServer.preprocessor((_req, rep) => {
rep.header("Access-Control-Allow-Origin", "*");
});
httpServer.middleware(async (req, _rep, done) => {
const result = await done();
const hrArray: number[] = [0, Math.trunc(result.processTime * 1000000)];
if (!req.resourceRequest) {
console.log(
`${req.method} - ${req.remoteIpAddr} - ${req.path} - ${
prettyTime(hrArray)
}`,
);
}
});
httpServer.error((req, _rep) => { httpServer.error((req, _rep) => {
return JSON.stringify( return JSON.stringify(
{ {
code: Status.NotFound, code: Status.NotFound,
message: "Route not found!", message: "Route not found!",
path: req.path, path: req.path,
url: req.url url: req.url,
}, },
null, null,
2, 2,
); );
}) });
httpServer.add("GET", "/", (req, rep) => { httpServer.get("/api/joke", (_req, rep) => {
const randomIndex = Math.floor(Math.random() * JOKES.length);
const joke = JOKES[randomIndex];
rep.json({
code: 200,
joke,
});
});
httpServer.get("/site", (_req, rep) => {
const htmlTest = `
<html>
<head>
<title>HTML Test</title>
<link rel="stylesheet" type="text/css" href="/assets/style.css">
</head>
<body>
<h1>Hello World!</h1>
<img src="/assets/lucoa.gif" id="lucoa" width="150" />
<br>
<button onclick="document.getElementById('lucoa').remove(); alert('omg, you killed her you monster.')">Useless button, do not press.</button>
</body>
</html>
`;
rep.html(htmlTest);
});
httpServer.delete("/session", (req, _rep) => {
const username = req.session.user as string ?? "";
if (username.length > 0) {
req.sessionDestroy();
return {
code: 200,
message: "Logged out!",
};
} else {
return {
code: 403,
message: "Not logged in!",
};
}
});
httpServer.post("/session", (req, _rep) => {
const username = req.queryParam("username") ?? "";
if (username.length > 0) {
req.session.user = username;
return {
code: 200,
message: "Logged in!",
};
} else {
return {
code: 403,
message: "Please enter a Username",
};
}
});
httpServer.get("/session", (req, rep) => {
const headerText = req.session.user
? `Hello, ${req.session.user}!`
: `Please login!`;
const htmlTest = `
<html>
<head>
<title>Session Example</title>
</head>
<body>
<h1>${headerText}</h1>
<input type="text" placeholder="Username" id="username" style="margin-bottom: 15px;" ${req.session.user ? "value='" + req.session.user + "' disabled" : ""}/>
<br>
<button onclick="${req.session.user ? "doLogout" : "doLogin"}()">${
req.session.user ? "Logout" : "Login"
}</button>
</body>
<script type="">
async function doLogout() {
const fetchResult = await fetch("/session", { method: 'DELETE'});
const jsonResult = await fetchResult.json();
if("code" in jsonResult){
if(jsonResult.code == 200){
document.location.reload(true)
}else{
alert(jsonResult.message);
}
}
}
async function doLogin() {
const username = document.getElementById('username').value;
const fetchResult = await fetch("/session?username=" + username, { method: 'POST'});
const jsonResult = await fetchResult.json();
if("code" in jsonResult){
if(jsonResult.code == 200){
document.location.reload(true)
}else{
alert(jsonResult.message);
}
}
}
</script>
</html>
`;
rep.html(htmlTest);
});
httpServer.get("/", (req, rep) => {
rep.status(Status.Teapot) rep.status(Status.Teapot)
.header("working", "true") .header("working", "true")
.type("application/json") .type("application/json")
@@ -24,17 +167,13 @@ httpServer.add("GET", "/", (req, rep) => {
console.log(req.cookie("working")); console.log(req.cookie("working"));
return JSON.stringify( return {
{ code: Status.Teapot,
code: Status.Teapot, message: "Hello World!",
message: "Hello World!", };
},
null,
2,
);
}); });
httpServer.add("GET", "/api/user/:userId", (req, rep) => { httpServer.get("/api/user/:userId", (req, rep) => {
rep.status(Status.Teapot) rep.status(Status.Teapot)
.type("application/json"); .type("application/json");
@@ -55,4 +194,6 @@ httpServer.listen({
port: 8080, port: 8080,
staticLocalDir: "/static", staticLocalDir: "/static",
staticServePath: "/assets", staticServePath: "/assets",
sessionSecret: "SuperDuperSecret",
sessionExpire: SessionExpire.NEVER
}); });

View File

@@ -1,372 +0,0 @@
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";
type ListenOptions = {
port: number;
host?: string;
staticLocalDir?: string;
staticServePath?: string;
};
type HTTPMethod = "GET" | "POST" | "PUSH" | "DELETE";
type RouteHandler = (
req: RouteRequest,
rep: RouteReply,
) =>
| Promise<unknown>
| unknown;
export type RouteParam = {
idx: number;
paramKey: string;
};
export class HTTPServer {
private server?: Deno.Listener;
private routes = new Map<string, Route>();
private staticLocalDir?: string;
private staticServePath?: string;
private notFoundHandler?: RouteHandler;
async listen(options: ListenOptions) {
this.server = Deno.listen({
port: options.port,
hostname: options.host,
});
console.log(
`Listening on ${
options.host ? 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 handleHttp(conn: Deno.Conn) {
const httpConn = Deno.serveHttp(conn);
for await (const requestEvent of httpConn) {
const url = new URL(requestEvent.request.url);
const filepath = decodeURIComponent(url.pathname);
const routeRequest = new RouteRequest(requestEvent.request);
const routeReply: RouteReply = new RouteReply();
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 the file cannot be opened, return a "404 Not Found" response
if (this.notFoundHandler) {
routeReply.status(Status.NotFound);
routeReply.type("application/json");
const notNoundHandle = await this.notFoundHandler(
routeRequest,
routeReply,
);
await requestEvent.respondWith(
new Response(notNoundHandle as string, {
status: routeReply.statusCode,
headers: routeReply.headers,
statusText: STATUS_TEXT[routeReply.statusCode],
}),
);
continue;
} else {
await requestEvent.respondWith(
new Response(
JSON.stringify({
code: 404,
message: `File ${filepath} not found!`,
}),
{
status: Status.NotFound,
headers: {
"Content-Type": "application/json",
},
},
),
);
}
continue;
}
const readableStream = file.readable;
const response = new Response(readableStream);
await requestEvent.respondWith(response);
return;
}
const routeName = `${requestEvent.request.method}@${filepath}`;
let route = this.routes.has(routeName)
? this.routes.get(routeName)
: undefined;
if (route) {
const handler = await route.handler(
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],
};
},
{},
);
const handler = await route.handler(
routeRequest,
routeReply,
);
await requestEvent.respondWith(
new Response(handler as string, {
status: routeReply.statusCode,
headers: routeReply.headers,
statusText: STATUS_TEXT[routeReply.statusCode],
}),
);
continue;
}
if (this.notFoundHandler) {
routeReply.status(Status.NotFound);
routeReply.type("application/json");
const notNoundHandle = await this.notFoundHandler(
routeRequest,
routeReply,
);
await requestEvent.respondWith(
new Response(notNoundHandle as string, {
status: routeReply.statusCode,
headers: routeReply.headers,
statusText: STATUS_TEXT[routeReply.statusCode],
}),
);
} else {
await requestEvent.respondWith(
new Response(
JSON.stringify({
code: 404,
message: `Route ${routeName} not found!`,
}),
{
status: Status.NotFound,
headers: {
"Content-Type": "application/json",
},
},
),
);
}
}
}
close() {
if (this.server) {
this.server.close();
}
}
error(handler: RouteHandler) {
this.notFoundHandler = handler;
}
get(path: string, handler: RouteHandler) {
this.add("GET", path, handler);
}
post(path: string, handler: RouteHandler) {
this.add("POST", path, handler);
}
push(path: string, handler: RouteHandler) {
this.add("PUSH", path, handler);
}
delete(path: string, handler: RouteHandler) {
this.add("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;
}, []);
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;
method: HTTPMethod;
queryParams: { [key: string]: string };
pathParams: { [key: string]: string };
constructor(request: Request) {
this.url = request.url;
const urlObj = new URL(request.url);
this.path = decodeURIComponent(urlObj.pathname);
this.headers = request.headers;
this.method = request.method as HTTPMethod;
this.pathParams = {};
this.queryParams = this.paramsToObject(urlObj.searchParams.entries());
}
private paramsToObject(entries: IterableIterator<[string, string]>) {
const result: { [key: string]: string } = {};
for (const [key, value] of entries) {
result[key] = value;
}
return result;
}
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 allCookies = cookie.getCookies(this.headers);
const allCookieNames = Object.keys(allCookies);
return allCookieNames.includes(name) ? allCookies[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;
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, 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;
}
}

565
mod.ts Normal file
View File

@@ -0,0 +1,565 @@
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>
| unknown;
type RouteMiddlewareHandler = (
req: RouteRequest,
rep: RouteReply,
done: () => Promise<MiddlewareResult>,
) => Promise<void>;
type RoutePreprocessor = (
req: RouteRequest,
rep: RouteReply,
) => void;
type RouteParam = {
idx: number;
paramKey: string;
};
export class HTTPServer {
private server?: Deno.Listener;
private routes = new Map<string, Route>();
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<MiddlewareResult> => {
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<string, string>;
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;
}
}