Compare commits
44 Commits
93f5bd30b9
...
master
Author | SHA1 | Date | |
---|---|---|---|
c5f842b409 | |||
46cf6081af | |||
997fe6dc77 | |||
b335bb61bd | |||
2e6ce88e80 | |||
8a3683d2e5 | |||
96a73035f8 | |||
5392b032d0 | |||
d519c7bf9c | |||
b2e357227c | |||
1376fbdeba | |||
7f1fce92b2 | |||
784a34ed4d | |||
4e1ac5888a | |||
e6f6b1ddc5 | |||
06695c5443 | |||
c6cb48b4ff | |||
452be0cecf | |||
ec0df0de60 | |||
ca6d23f5ef | |||
d89310bb2e | |||
1c085b9ec5 | |||
5626782178 | |||
e3e5f8c1d2 | |||
03a1f35eaa | |||
0675b26dad | |||
1c668e5892 | |||
301a6d2c3d | |||
49d5818062 | |||
ee384eb4bc | |||
60b819b9a4 | |||
42ae41ba87 | |||
bbc861fde3 | |||
874c43126d | |||
64d77caeb1 | |||
8482c4c6cc | |||
10fb61793f | |||
b974abff1b | |||
a9017c7550 | |||
ab10ab7091 | |||
bf454f83b8 | |||
d7d2320103 | |||
8c2a3eb798 | |||
e0b2739408 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +0,0 @@
|
||||
.vscode
|
20
.vscode/launch.json
vendored
Normal file
20
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"request": "launch",
|
||||
"name": "test deno-http",
|
||||
"type": "node",
|
||||
"program": "${workspaceFolder}/example/test.ts",
|
||||
"cwd": "${workspaceFolder}/example/",
|
||||
"runtimeExecutable": "C:\\Users\\Admin\\.deno\\bin\\deno.EXE",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"--unstable",
|
||||
"--inspect-wait",
|
||||
"--allow-all"
|
||||
],
|
||||
"attachSimplePort": 9229
|
||||
}
|
||||
]
|
||||
}
|
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"deno.enable": true,
|
||||
"deno.unstable": true
|
||||
}
|
BIN
example/static/lucoa.gif
Normal file
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
6
example/static/style.css
Normal file
@@ -0,0 +1,6 @@
|
||||
h1 {
|
||||
color: red;
|
||||
}
|
||||
img {
|
||||
margin-bottom: 15px;
|
||||
}
|
167
example/test.ts
167
example/test.ts
@@ -1,22 +1,165 @@
|
||||
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();
|
||||
|
||||
// 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) => {
|
||||
return JSON.stringify(
|
||||
{
|
||||
code: Status.NotFound,
|
||||
message: "Route not found!",
|
||||
path: req.path,
|
||||
url: req.url
|
||||
url: req.url,
|
||||
},
|
||||
null,
|
||||
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)
|
||||
.header("working", "true")
|
||||
.type("application/json")
|
||||
@@ -24,17 +167,13 @@ httpServer.add("GET", "/", (req, rep) => {
|
||||
|
||||
console.log(req.cookie("working"));
|
||||
|
||||
return JSON.stringify(
|
||||
{
|
||||
code: Status.Teapot,
|
||||
message: "Hello World!",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
return {
|
||||
code: Status.Teapot,
|
||||
message: "Hello World!",
|
||||
};
|
||||
});
|
||||
|
||||
httpServer.add("GET", "/api/user/:userId", (req, rep) => {
|
||||
httpServer.get("/api/user/:userId", (req, rep) => {
|
||||
rep.status(Status.Teapot)
|
||||
.type("application/json");
|
||||
|
||||
@@ -55,4 +194,6 @@ httpServer.listen({
|
||||
port: 8080,
|
||||
staticLocalDir: "/static",
|
||||
staticServePath: "/assets",
|
||||
sessionSecret: "SuperDuperSecret",
|
||||
sessionExpire: SessionExpire.NEVER
|
||||
});
|
||||
|
366
http_server.ts
366
http_server.ts
@@ -1,366 +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",
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
565
mod.ts
Normal 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;
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user