diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..28a51d5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "compile-hero.disable-compile-files-on-did-save-code": false +} \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..8ead281 --- /dev/null +++ b/index.ts @@ -0,0 +1,12 @@ +import { rootRouter } from "./src/router"; + +export { handler } from "./src/handler"; +export { route } from "./src/route"; +export { router } from "./src/router"; +export { method } from "./src/interface/method"; +export { response } from "./src/interface/response"; +export { request } from "./src/interface/request"; +export { ChainInterrupted } from "./src/interface"; + +export { rootRouter }; +export default rootRouter; diff --git a/package.json b/package.json new file mode 100644 index 0000000..365b3dc --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "dependencies": { + "path-to-regexp": "^6.2.1" + }, + "name": "handlers.js", + "version": "0.0.1", + "main": "index.ts", + "author": "186526 ", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/186526/handlers.js" + }, + "keywords": [ + "web framework", + "lightweight", + "cross-platform", + "unified" + ], + "devDependencies": { + "@types/node": "^18.0.0" + } +} diff --git a/src/handler.ts b/src/handler.ts new file mode 100644 index 0000000..c46934e --- /dev/null +++ b/src/handler.ts @@ -0,0 +1,42 @@ +import { method, request, responder, response } from "./interface"; + +export class handler { + public responders: responder[]; + public method: method; + + constructor( + method: method, + responders: responder[] + ) { + this.responders = responders; + this.method = method; + } + + add(responder: responder) { + this.responders.push(responder); + } + + async respond( + request: request, + responseMessage: response = new response( + "" + ) + ): Promise | void> { + switch (this.responders.length) { + case 0: + Promise.reject("No responders found in this handler."); + break; + case 1: + return this.responders[0](request, responseMessage); + default: + for (let responder of this.responders) { + let thisResponse = await responder(request, responseMessage); + if (thisResponse instanceof response) { + responseMessage = thisResponse; + } + } + } + } +} + +export default handler; diff --git a/src/interface/headers.ts b/src/interface/headers.ts new file mode 100644 index 0000000..924d6cd --- /dev/null +++ b/src/interface/headers.ts @@ -0,0 +1,26 @@ +import * as lib from "../lib"; +export class headers { + public headers: { [key: string]: string } = {}; + constructor(headers: { [key: string]: string }) { + this.headers = {}; + Object.keys(this.headers).forEach((key) => { + this.headers[lib.firstUpperCase(key)] = headers[key]; + }); + } + delete(key: string) { + delete this.headers[lib.firstUpperCase(key)]; + } + get(key: string): string | undefined { + return this.headers[lib.firstUpperCase(key)]; + } + has(key: string): boolean { + return this.headers.hasOwnProperty(lib.firstUpperCase(key)); + } + set(key: string, value: string) { + this.headers[lib.firstUpperCase(key)] = value; + } + toObject() { + return this.headers; + } +} +export default headers; \ No newline at end of file diff --git a/src/interface/index.ts b/src/interface/index.ts new file mode 100644 index 0000000..41955d0 --- /dev/null +++ b/src/interface/index.ts @@ -0,0 +1,7 @@ +export { request } from "./request"; +export { response } from "./response"; +export { method } from "./method"; +export { headers } from "./headers"; +export { responder } from "./responder"; +export const ChainInterrupted = new Error("ChainInterrupted"); +export type path = string | RegExp; diff --git a/src/interface/method.ts b/src/interface/method.ts new file mode 100644 index 0000000..173cec2 --- /dev/null +++ b/src/interface/method.ts @@ -0,0 +1,58 @@ +export enum method { + /** + * The `CONNECT` method establishes a tunnel to the server identified by the + * target resource. + */ + CONNECT = "CONNECT", + + /** + * The `DELETE` method deletes the specified resource. + */ + DELETE = "DELETE", + + /** + * The `GET` method requests a representation of the specified resource. + * Requests using GET should only retrieve data. + */ + GET = "GET", + + /** + * The `HEAD` method asks for a response identical to that of a GET request, + * but without the response body. + */ + HEAD = "HEAD", + + /** + * The `OPTIONS` method is used to describe the communication options for the + * target resource. + */ + OPTIONS = "OPTIONS", + + /** + * The PATCH method is used to apply partial modifications to a resource. + */ + PATCH = "PATCH", + + /** + * The `POST` method is used to submit an entity to the specified resource, + * often causing a change in state or side effects on the server. + */ + POST = "POST", + + /** + * The `PUT` method replaces all current representations of the target + * resource with the request payload. + */ + PUT = "PUT", + + /** + * The `TRACE` method performs a message loop-back test along the path to the + * target resource. + */ + TRACE = "TRACE", + /** + * The `ANY` method will match any method. + */ + ANY = "ANY", +} +export default method; \ No newline at end of file diff --git a/src/interface/request.ts b/src/interface/request.ts new file mode 100644 index 0000000..a504ade --- /dev/null +++ b/src/interface/request.ts @@ -0,0 +1,31 @@ +import method from "./method"; +import headers from "./headers"; +export class request { + public readonly method: method; + public readonly url: URL; + public originURL?: URL; + public readonly headers: headers; + public readonly body: any; + public readonly query: URLSearchParams; + public params: { [key: string]: string | undefined }; + public custom: RequestCustomType; + public constructor( + method: method, + url: URL, + headers: headers, + body: any, + params: { [key: string]: string } + ) { + this.method = method; + this.url = url; + this.headers = headers; + this.body = body; + this.query = new URLSearchParams(url.search); + this.params = params; + } + public extends(custom: RequestCustomType): request { + this.custom = custom; + return this; + } +} +export default request; diff --git a/src/interface/responder.ts b/src/interface/responder.ts new file mode 100644 index 0000000..bfa296a --- /dev/null +++ b/src/interface/responder.ts @@ -0,0 +1,8 @@ +import { request, response } from "./index"; +export interface responder { + ( + request: request, + reponse?: response + ): Promise> | Promise | void; +} +export default responder; diff --git a/src/interface/response.ts b/src/interface/response.ts new file mode 100644 index 0000000..376380a --- /dev/null +++ b/src/interface/response.ts @@ -0,0 +1,35 @@ +import headers from "./headers"; +import packageJSON from "../../package.json"; +import { platform, version } from "../lib"; + +export class defaultHeaders extends headers { + constructor(headers: { [key: string]: string } = {}) { + super(headers); + if (!this.has("Content-Type")) + this.set("Content-Type", "plain/text; charset=utf-8"); + this.set( + "Server", + `Handler.JS/${packageJSON.version} ${platform}/${version}` + ); + } +} +export class response { + public status: number; + public headers: headers; + public body: any; + public custom: ResponseCustomType; + public constructor( + body: any, + status: number = 200, + headers: headers = new defaultHeaders() + ) { + this.status = status; + this.headers = headers; + this.body = body; + } + public extends(custom: ResponseCustomType): response { + this.custom = custom; + return this; + } +} +export default response; diff --git a/src/lib.ts b/src/lib.ts new file mode 100644 index 0000000..44e6f12 --- /dev/null +++ b/src/lib.ts @@ -0,0 +1,16 @@ +export const firstUpperCase = ([first, ...rest]: string) => + first?.toUpperCase() + rest.join(""); +export const platform = (() => { + if (typeof process != "undefined") { + return "Node.js"; + } + return "UNKNOWN"; +})(); +export const version = (() => { + switch (platform) { + case "Node.js": + return process.version; + default: + return "UNKNOWN"; + } +})(); diff --git a/src/platform/index.ts b/src/platform/index.ts new file mode 100644 index 0000000..d87528f --- /dev/null +++ b/src/platform/index.ts @@ -0,0 +1,7 @@ +import { request, response } from "../interface"; + +export interface PlatformAdapater { + listen(port: number): void; + handleRequest(request: any): request; + handleResponse(response: response, NativeResponse?: any): any; +} diff --git a/src/platform/node.ts b/src/platform/node.ts new file mode 100644 index 0000000..d218401 --- /dev/null +++ b/src/platform/node.ts @@ -0,0 +1,27 @@ +import { PlatformAdapater } from "."; +import { request, response } from "../interface"; +import router from "../router"; +import http from "http"; + +export class NodePlatformAdapter implements PlatformAdapater { + constructor(Router: ) + listen(port: number): void { + const server = http.createServer(); + server.on( + "request", + (req: http.IncomingMessage, res: http.ServerResponse) => { + const request = this.handleRequest(req); + + } + ); + server.listen(port); + } + + handleRequest(request: http.IncomingMessage): request { + throw new Error("Method not implemented."); + } + + handleResponse(response: response, NativeResponse: http.ServerResponse) { + throw new Error("Method not implemented."); + } +} diff --git a/src/route.ts b/src/route.ts new file mode 100644 index 0000000..57510ea --- /dev/null +++ b/src/route.ts @@ -0,0 +1,64 @@ +import { path } from "./interface"; +import handler from "./handler"; +import { pathToRegexp } from "path-to-regexp"; + +interface matchedStatus { + matched: boolean; + attributes: { + name: string; + value: string | undefined; + }[]; +} + +export class route { + public paths: path[]; + public handler: handler; + + constructor(paths: path[], handler: handler) { + this.paths = paths; + this.handler = handler; + } + async exec(path: string): Promise { + let Answer = await Promise.all>( + this.paths.map(async (it) => { + const keys: { + name: string; + prefix: string; + suffix: string; + pattern: string; + modifier: string; + }[] = []; + const regExp = pathToRegexp(it, keys); + const answer = regExp.exec(path); + if (answer === null) + return { + matched: false, + attributes: [], + }; + + let attributes: matchedStatus["attributes"] = []; + + keys.forEach((key, index) => { + attributes.push({ + name: key.name, + value: answer[index + 1], + }); + }); + + return { + matched: true, + attributes: attributes, + }; + }) + ); + Answer = Answer.filter((it) => it.matched); + if (Answer.length === 0) + return { + matched: false, + attributes: [], + }; + else return Answer[0]; + } +} + +export default route; diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..61812a7 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,131 @@ +import handler from "./handler"; +import { + path, + method, + response, + request, + ChainInterrupted, +} from "./interface/index"; +import { defaultHeaders } from "./interface/response"; +import route from "./route"; + +export class router { + public routes: route[]; + + constructor(routes: route[] = []) { + this.routes = routes; + } + + add(route: route) { + this.routes.push(route); + return this; + } + + binding(path: path, handler: handler) { + this.add(new route([path], handler)); + return this; + } + + create( + method: method, + responder: ( + request: request + ) => + | Promise> + | Promise + | Promise + | Promise + | Promise + ) { + return new handler(method, [ + async (request: request) => { + const answer = await responder(request); + if (answer instanceof response) { + return answer; + } else if (answer instanceof String) { + return new response(answer); + } else if (answer instanceof Number) { + return new response(answer.toString()); + } else if (answer instanceof Object) { + return new response( + JSON.stringify(answer), + 200, + new defaultHeaders({ + "Content-Type": "application/json; charset=utf-8", + }) + ); + } else { + return new response("", 204); + } + }, + ]); + } + + use(routers: router[], path: path): void { + routers.forEach((router) => { + this.binding(path, router.toHandler(path)); + }); + } + + route(path: path): router { + const Router = new router([]); + this.use([Router], path); + return Router; + } + + async respond(request: request, basePath: path): Promise> { + request.originURL = request.url; + request.url.pathname = request.url.pathname.replace(basePath, ""); + + let responseMessage: response = new response(""); + + for (let route of this.routes) { + if ( + route.handler.method != request.method || + route.handler.method != method.ANY + ) { + continue; + } + + const isMatched = await route.exec(request.url.pathname); + + if (!isMatched.matched) { + continue; + } + + isMatched.attributes.forEach((e) => { + request.params[e.name] = e.value; + }); + + let thisResponse = await route.handler.respond(request, responseMessage); + if (thisResponse instanceof response) { + responseMessage = thisResponse; + } + } + + return responseMessage; + } + + toHandler(basePath: path): handler { + return this.create(method.ANY, (request: request) => { + return this.respond(request, basePath); + }); + } +} + +export default router; + +export class rootRouter extends router { + private readonly originRespond = this.respond; + async respond(request: request, basePath: path): Promise> { + try { + return this.originRespond(request, basePath); + } catch (e) { + if (e === ChainInterrupted) { + return e.response; + } else { + throw e; + } + } + } +} diff --git a/test/index.ts b/test/index.ts new file mode 100644 index 0000000..de4f332 --- /dev/null +++ b/test/index.ts @@ -0,0 +1,43 @@ +import * as handlerJS from "../"; + +interface requestType { + hood: boolean; + id: number; +} + +const App = new handlerJS.rootRouter(); + +App.binding( + "/", + App.create(handlerJS.method["ANY"], async (request) => { + Promise.resolve("Hello World!"); + throw handlerJS.ChainInterrupted; + }) +); + +App.binding( + "/*", + App.create(handlerJS.method["ANY"], async (request) => "Fuck World!") +); + +App.route("/v1") + .add( + new handlerJS.route( + ["/echo", "/echo/*"], + new handlerJS.handler(handlerJS.method["GET"], [ + async (request, response) => { + response = response ?? new handlerJS.response(""); + response?.headers.set("Hello", "World"); + response.body = "echo"; + return response; + }, + ]) + ) + ) + .binding( + "/:a/echo", + App.create( + handlerJS.method["GET"], + async (request) => `echo with ${request.params.a}` + ) + ); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7331073 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,38 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "lib": [ + "es6", + "es2017", + "esnext", + "webworker" + ], + "skipLibCheck": true, + "sourceMap": true, + "outDir": "./dist", + "moduleResolution": "node", + "removeComments": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "resolveJsonModule": true, + "baseUrl": "." + }, + "exclude": [ + "node_modules" + ], + "include": [ + "index.ts", + "src/**/*.ts" + ] +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..baaddb7 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@types/node@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.0.tgz#67c7b724e1bcdd7a8821ce0d5ee184d3b4dd525a" + integrity sha512-cHlGmko4gWLVI27cGJntjs/Sj8th9aYwplmZFwmmgYQQvL5NUsgVJG7OddLvNfLqYS31KFN0s3qlaD9qCaxACA== + +path-to-regexp@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw==