├── .gitignore ├── src ├── example │ ├── static │ │ └── sample.txt │ ├── static-files.ts │ └── hello-world.ts ├── index.ts ├── endpoint.ts ├── error.ts ├── body.ts ├── types.ts ├── accept-path.ts ├── service.ts ├── static-files.ts └── status.ts ├── .npmignore ├── tsconfig.json ├── .editorconfig ├── tests ├── index.ts ├── body.ts ├── router.ts ├── static-files.ts ├── accept-path.ts └── service.ts ├── yarn.lock ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | -------------------------------------------------------------------------------- /src/example/static/sample.txt: -------------------------------------------------------------------------------- 1 | This is a sample text file. 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | tests 2 | src 3 | .editorconfig 4 | tsconfig.json 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { endpoint, router } from "./endpoint"; 2 | export { service } from "./service"; 3 | export { Endpoint, Request, Response, ErrorHandler, Logger, HttpStatus } from "./types"; 4 | export { HttpError } from "./error"; 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "module": "CommonJS", 5 | "moduleResolution": "node", 6 | "declaration": true, 7 | "target": "ES2017", 8 | "lib": ["ESNext"], 9 | "outDir": "lib" 10 | }, 11 | "include": ["src"] 12 | } 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | -------------------------------------------------------------------------------- /tests/index.ts: -------------------------------------------------------------------------------- 1 | import { bodyTests } from "./body"; 2 | import { routerTests } from "./router"; 3 | import { serviceTests } from "./service"; 4 | import { staticFilesTests } from "./static-files"; 5 | import { acceptPathTests } from "./accept-path"; 6 | 7 | routerTests(); 8 | serviceTests(); 9 | bodyTests(); 10 | staticFilesTests(); 11 | acceptPathTests(); 12 | -------------------------------------------------------------------------------- /src/endpoint.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint } from "./types"; 2 | 3 | export const endpoint = (def: Endpoint): Endpoint => def; 4 | 5 | export const router = (...endpoints: Endpoint[]): Endpoint => { 6 | return { 7 | accept: async (message) => { 8 | for (const endpoint of endpoints) { 9 | const payload = await endpoint.accept(message); 10 | if (!payload) continue; 11 | return await endpoint.handle(payload); 12 | } 13 | }, 14 | handle: (res) => res, 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /tests/body.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from "assert"; 2 | import { Readable } from "stream"; 3 | import { readJSON } from "../src/body"; 4 | 5 | export const bodyTests = async () => { 6 | { 7 | const data = { hello: "World" }; 8 | const request = new Readable({ 9 | read() { 10 | this.push(Buffer.from(JSON.stringify(data))); 11 | this.push(null); // Signals the end of the stream 12 | }, 13 | }); 14 | 15 | const json = await readJSON(request); 16 | deepStrictEqual(json, data); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/example/static-files.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | import { service } from "../service"; 3 | import { staticFiles } from "../static-files"; 4 | 5 | const port = process.env.PORT || "3000"; 6 | 7 | const startServer = async () => { 8 | const endpoint = await staticFiles({ 9 | dir: "src/example/static", 10 | dynamic: true, 11 | }); 12 | const server = createServer(service({ endpoint })); 13 | 14 | server.listen(port, () => { 15 | console.log(`Server started on port ${port}`); 16 | }); 17 | }; 18 | 19 | startServer(); 20 | -------------------------------------------------------------------------------- /src/error.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from "./types"; 2 | 3 | type HttpErrorParams = { 4 | status: HttpStatus; 5 | options?: ErrorOptions; 6 | headers?: Record; 7 | body?: any; 8 | } 9 | 10 | export class HttpError extends Error { 11 | status: HttpStatus; 12 | body?: any; 13 | headers?: Record; 14 | 15 | constructor({ status, options, headers, body }: HttpErrorParams) { 16 | super(`Error ${status.code}`, options) 17 | this.headers = headers; 18 | this.status = status; 19 | this.body = body; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/node@^18.11.17": 6 | version "18.11.17" 7 | resolved "https://registry.npmjs.org/@types/node/-/node-18.11.17.tgz" 8 | integrity sha512-HJSUJmni4BeDHhfzn6nF0sVmd1SMezP7/4F0Lq+aXzmp2xm9O7WXrUtHW/CHlYVtZUbByEvWidHqRtcJXGF2Ng== 9 | 10 | typescript@^5.4.5: 11 | version "5.4.5" 12 | resolved "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz" 13 | integrity sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ== 14 | -------------------------------------------------------------------------------- /src/body.ts: -------------------------------------------------------------------------------- 1 | import { Readable } from "stream"; 2 | 3 | export function readRaw(req: Readable) { 4 | return new Promise((resolve, reject) => { 5 | const chunks: Buffer[] = []; 6 | req.on("data", (chunk: Buffer) => chunks.push(chunk)); 7 | req.on("end", () => { 8 | try { 9 | const data = Buffer.concat(chunks); 10 | resolve(data); 11 | } catch (error) { 12 | reject(error); 13 | } 14 | }); 15 | }) 16 | } 17 | 18 | export async function readJSON(req: Readable) { 19 | const raw = await readRaw(req); 20 | return JSON.parse(raw.toString("utf-8")) as T; 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hndl", 3 | "version": "2.1.2", 4 | "description": "A simple node framework, you've been looking for", 5 | "keywords": [ 6 | "http", 7 | "router", 8 | "backend", 9 | "server" 10 | ], 11 | "main": "lib/index.js", 12 | "types": "lib/index.d.ts", 13 | "author": "Marko", 14 | "license": "MIT", 15 | "scripts": { 16 | "test": "npx ts-node tests/index.ts", 17 | "prepare": "rm -rf lib && tsc", 18 | "prepublishOnly": "npm test" 19 | }, 20 | "files": [ 21 | "lib/**/*" 22 | ], 23 | "devDependencies": { 24 | "@types/node": "^20.12.7", 25 | "typescript": "^5.4.5" 26 | }, 27 | "dependencies": {} 28 | } 29 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from "http"; 2 | 3 | export type HttpStatus = { 4 | code: number; 5 | phrase: string; 6 | }; 7 | 8 | export type Response = { 9 | status: HttpStatus; 10 | body?: any; 11 | headers?: Record; 12 | }; 13 | 14 | export type Request = IncomingMessage & { 15 | url: string; 16 | method: string; 17 | }; 18 | 19 | export type Falsy = null | undefined | false | 0 | ""; 20 | export type AcceptResult = T | Falsy; 21 | 22 | export type Endpoint = { 23 | accept: (request: Request) => Promise> | AcceptResult; 24 | handle: (payload: Exclude) => Promise | Response; 25 | }; 26 | 27 | export type ErrorHandler = (error: any) => Response | Promise; 28 | 29 | export type Logger = (request: Request, response: Response) => void; 30 | -------------------------------------------------------------------------------- /src/example/hello-world.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "http"; 2 | import { acceptPath } from "../accept-path"; 3 | import { endpoint } from "../endpoint"; 4 | import { service } from "../service"; 5 | import { OK } from "../status"; 6 | 7 | const port = process.env.PORT || "3000"; 8 | 9 | const helloEndpoint = endpoint({ 10 | accept: (req) => 11 | acceptPath(req, { 12 | path: "/hello/:name", 13 | params: { name: (s) => s }, 14 | }), 15 | handle: (payload) => { 16 | return { 17 | status: OK, 18 | headers: { "Content-Type": "application/json" }, 19 | body: JSON.stringify({ hello: payload.name }) 20 | }; 21 | }, 22 | }); 23 | 24 | const server = createServer(service({ endpoint: helloEndpoint })); 25 | 26 | server.listen(port, () => { 27 | console.log(`Server started on port ${port}`); 28 | }); 29 | -------------------------------------------------------------------------------- /tests/router.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from "assert"; 2 | import { router } from "../src/endpoint"; 3 | import { BAD_REQUEST, MOVED_TEMPORARILY, OK } from "../src/status"; 4 | 5 | export const routerTests = async () => { 6 | { 7 | const testRouter = router( 8 | { 9 | accept: () => false, 10 | handle: () => ({ status: OK }), 11 | }, 12 | { 13 | accept: () => true, 14 | handle: () => ({ status: BAD_REQUEST }), 15 | }, 16 | { 17 | accept: () => true, 18 | handle: () => ({ status: MOVED_TEMPORARILY }), 19 | }, 20 | ); 21 | 22 | const response = await testRouter.handle( 23 | await testRouter.accept({} as any), 24 | ); 25 | deepStrictEqual( 26 | response.status, 27 | BAD_REQUEST, 28 | "Router should choose the first endpoint that accepts", 29 | ); 30 | } 31 | 32 | { 33 | const truthyPayload = "Truthy payload"; 34 | let handledPayload = ""; 35 | 36 | const testRouter = router( 37 | { 38 | accept: () => false, 39 | handle: () => ({ status: MOVED_TEMPORARILY }), 40 | }, 41 | { 42 | accept: () => truthyPayload, 43 | handle: (payload) => { 44 | handledPayload = payload; 45 | return { status: OK }; 46 | }, 47 | }, 48 | { 49 | accept: () => true, 50 | handle: () => ({ status: BAD_REQUEST }), 51 | }, 52 | ); 53 | 54 | const response = await testRouter.handle( 55 | await testRouter.accept({} as any), 56 | ); 57 | 58 | deepStrictEqual( 59 | response.status, 60 | OK, 61 | "Router should accept any truthy value", 62 | ); 63 | deepStrictEqual( 64 | handledPayload, 65 | truthyPayload, 66 | "Router should pass the correct payload to handle", 67 | ); 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /tests/static-files.ts: -------------------------------------------------------------------------------- 1 | import { staticFiles } from "../src/static-files"; 2 | import { strictEqual, deepStrictEqual } from "assert"; 3 | import { OK } from "../src/status"; 4 | 5 | const testFileContent = "Test file content"; 6 | const testContentType = "test/example"; 7 | const testHeaders = { "X-Test": "example" }; 8 | const testValidPaths = new Set(["example.txt", "index.html"]); 9 | 10 | export const staticFilesTests = async () => { 11 | const endpoint = await staticFiles({ 12 | dir: "./", 13 | route: "/route", 14 | validPathsProvider: () => Promise.resolve(testValidPaths), 15 | fileReader: () => Promise.resolve(testFileContent), 16 | contentTypeResolver: () => testContentType, 17 | headersProvider: () => testHeaders, 18 | }); 19 | 20 | { 21 | const result = await endpoint.accept({ url: "/wrong" } as any); 22 | strictEqual(!result, true, "Should not accept wrong route"); 23 | } 24 | 25 | { 26 | const result = await endpoint.accept({ url: "/route/other.txt" } as any); 27 | strictEqual(!result, true, "Should not accept invalid path"); 28 | } 29 | 30 | { 31 | const result = await endpoint.accept({ url: "/route" } as any); 32 | strictEqual(result, "index.html", "Should rewrite to index.html"); 33 | } 34 | 35 | { 36 | const request = { url: "/route/example.txt" } as any; 37 | const acceptResult = await endpoint.accept(request); 38 | strictEqual(acceptResult, "example.txt", "Should accept correct route"); 39 | 40 | const handleResult = await endpoint.handle(acceptResult); 41 | deepStrictEqual( 42 | handleResult, 43 | { 44 | status: OK, 45 | body: testFileContent, 46 | headers: { 47 | "Content-Type": testContentType, 48 | ...testHeaders, 49 | }, 50 | }, 51 | "Should handle valid request correctly", 52 | ); 53 | } 54 | }; 55 | -------------------------------------------------------------------------------- /src/accept-path.ts: -------------------------------------------------------------------------------- 1 | import { Request } from "./types"; 2 | 3 | type PathParamParser = (value: string) => any; 4 | type PathParamParsersMap = Record; 5 | 6 | type AcceptPathDef = { 7 | path: string; 8 | params: T; 9 | }; 10 | 11 | type Params = { 12 | [k in keyof T]: ReturnType; 13 | }; 14 | 15 | export function acceptPath( 16 | request: Request, 17 | def: AcceptPathDef, 18 | ) { 19 | const [path] = request.url.split("?"); 20 | const paramsData = parsePathWithParams(path, def.path); 21 | if (!paramsData) return; 22 | 23 | const params = {} as Params; 24 | for (const key in def.params) { 25 | params[key] = def.params[key](paramsData[key]); 26 | } 27 | 28 | return params; 29 | }; 30 | 31 | export function parsePathWithParams(url: string, pattern: string) { 32 | const data: Record = {}; 33 | 34 | let urlIndex = 0; 35 | let patternIndex = 0; 36 | 37 | while (true) { 38 | if (pattern[patternIndex] === ":") { 39 | // Start of param 40 | patternIndex++; 41 | let paramName = ""; 42 | let paramValue = ""; 43 | 44 | // Consume param name 45 | while (pattern[patternIndex] !== "/" && patternIndex <= pattern.length - 1) { 46 | paramName += pattern[patternIndex++]; 47 | } 48 | 49 | // Consume param value 50 | while (url[urlIndex] !== "/" && url[urlIndex] !== "?" && urlIndex <= url.length - 1) { 51 | paramValue += url[urlIndex++]; 52 | } 53 | 54 | if (paramName.length && paramValue.length) { 55 | data[paramName] = paramValue; 56 | } 57 | } 58 | 59 | const urlEnd = urlIndex === url.length || url[urlIndex] === "?"; 60 | const patternEnd = patternIndex === pattern.length; 61 | 62 | if (urlEnd && patternEnd) { 63 | return data; 64 | } else if (urlEnd !== patternEnd) { 65 | // The length of pattern and url is different 66 | return; 67 | } 68 | 69 | if (url[urlIndex] !== pattern[patternIndex]) { 70 | // We are not parsing params and pattern and url are different 71 | // means it's not a match 72 | return; 73 | } 74 | 75 | urlIndex++; 76 | patternIndex++; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /tests/accept-path.ts: -------------------------------------------------------------------------------- 1 | import { parsePathWithParams } from "../src/accept-path"; 2 | import { deepStrictEqual } from "assert"; 3 | 4 | export function acceptPathTests() 5 | { 6 | { // MATCH: single slash 7 | const params = parsePathWithParams("/", "/"); 8 | deepStrictEqual(params, {}); 9 | } 10 | 11 | { // MATCH: simple path with no params 12 | const params = parsePathWithParams("/simple/path", "/simple/path"); 13 | deepStrictEqual(params, {}); 14 | } 15 | 16 | { // MATCH: simple path with no params with query string 17 | const params = parsePathWithParams("/simple/path?a=1", "/simple/path"); 18 | deepStrictEqual(params, {}); 19 | } 20 | 21 | { // FAIL: different at the end 22 | const params = parsePathWithParams("/simple/path/fail", "/simple/path"); 23 | deepStrictEqual(params, undefined); 24 | } 25 | 26 | { // FAIL: different in the middle 27 | const params = parsePathWithParams("/simple/fail/path", "/simple/correct/path"); 28 | deepStrictEqual(params, undefined); 29 | } 30 | 31 | { // MATCH: single param 32 | const params = parsePathWithParams("/hello/world", "/hello/:greeting"); 33 | deepStrictEqual(params, { greeting: "world" }); 34 | } 35 | 36 | { // FAIL: double slash 37 | const params = parsePathWithParams("/hello//world", "/hello/:greeting"); 38 | deepStrictEqual(params, undefined); 39 | } 40 | 41 | { // MATCH: only one param 42 | const params = parsePathWithParams("hello", ":greeting"); 43 | deepStrictEqual(params, { greeting: "hello" }); 44 | } 45 | 46 | { // MATCH: single param with query string 47 | const params = parsePathWithParams("/hello/world?a=1", "/hello/:greeting"); 48 | deepStrictEqual(params, { greeting: "world" }); 49 | } 50 | 51 | { // MATCH: single param with trailing slash 52 | const params = parsePathWithParams("/hello/world/", "/hello/:greeting/"); 53 | deepStrictEqual(params, { greeting: "world" }); 54 | } 55 | 56 | { // FAIL: single param with trailing slash in URL only 57 | const params = parsePathWithParams("/hello/world/", "/hello/:greeting"); 58 | deepStrictEqual(params, undefined); 59 | } 60 | 61 | { // FAIL: single param with trailing slash in pattern only 62 | const params = parsePathWithParams("/hello/world", "/hello/:greeting/"); 63 | deepStrictEqual(params, undefined); 64 | } 65 | 66 | { // MATCH: 2 consecutive params 67 | const params = parsePathWithParams("/hello/world/hey", "/hello/:greeting/:second"); 68 | deepStrictEqual(params, { greeting: "world", second: "hey" }); 69 | } 70 | 71 | { // MATCH: 1 param containing ":" 72 | const params = parsePathWithParams("/hello/world", "/hello/:greeting:second"); 73 | deepStrictEqual(params, { "greeting:second": "world" }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage, ServerResponse } from "http"; 2 | import { Readable } from "stream"; 3 | import { endpoint, router } from "./endpoint"; 4 | import { INTERNAL_SERVER_ERROR, NOT_FOUND } from "./status"; 5 | import { Endpoint, ErrorHandler, Logger, Request, Response } from "./types"; 6 | 7 | type ServiceProps = { 8 | endpoint: Endpoint; 9 | errorHandler?: ErrorHandler; 10 | logger?: Logger; 11 | }; 12 | 13 | type Listener = (req: IncomingMessage, res: ServerResponse) => Promise; 14 | 15 | export const service = ({ 16 | endpoint: rootEndpoint, 17 | errorHandler = defaultErrorHandler, 18 | logger = defaultLogger, 19 | }: ServiceProps): Listener => { 20 | const serviceRouter = router(rootEndpoint, catchAllEndpoint); 21 | return async (request, serverResponse) => { 22 | let response: Response; 23 | try { 24 | response = await produceResponse(request as Request, serviceRouter); 25 | await writeResponse(response, serverResponse); 26 | } catch (error) { 27 | response = await errorHandler(error); 28 | await writeResponse(response, serverResponse); 29 | } 30 | logger(request as Request, response); 31 | } 32 | }; 33 | 34 | const produceResponse = async ( 35 | request: Request, 36 | endpoint: Endpoint, 37 | ) => { 38 | const payload = await endpoint.accept(request); 39 | if (payload) { 40 | return await endpoint.handle(payload); 41 | } else { 42 | return { 43 | status: INTERNAL_SERVER_ERROR, 44 | body: "Endpoint did not produce a response", 45 | headers: { "Content-Type": "text/plain" }, 46 | }; 47 | } 48 | }; 49 | 50 | const writeResponse = async ( 51 | response: Response, 52 | serverResponse: ServerResponse, 53 | ) => { 54 | const { status, headers, body } = response; 55 | serverResponse.writeHead(status.code, status.phrase, headers); 56 | if (body instanceof Readable) { 57 | await writeStreamBody(body, serverResponse); 58 | } else { 59 | serverResponse.end(body); 60 | } 61 | }; 62 | 63 | const writeStreamBody = ( 64 | body: Readable, 65 | serverResponse: ServerResponse 66 | ) => { 67 | return new Promise((resolve, reject) => { 68 | body.on("error", reject); 69 | body.on("end", resolve); 70 | body.pipe(serverResponse); 71 | }) 72 | } 73 | 74 | const catchAllEndpoint = endpoint({ 75 | accept: () => true, 76 | handle: () => ({ status: NOT_FOUND }), 77 | }); 78 | 79 | const defaultLogger = (request: Request, response: Response) => { 80 | console.log(`${response.status.code} ${request.method} ${request.url}`); 81 | }; 82 | 83 | const defaultErrorHandler = (error: any): Response => { 84 | return { 85 | status: getErrorStatus(error), 86 | headers: error?.headers, 87 | body: error.body ?? error?.message, 88 | }; 89 | }; 90 | 91 | const getErrorStatus = (error: any) => { 92 | if ( 93 | typeof error?.status?.code === "number" && 94 | typeof error?.status?.phrase === "string" 95 | ) 96 | return error.status; 97 | return INTERNAL_SERVER_ERROR; 98 | }; 99 | -------------------------------------------------------------------------------- /src/static-files.ts: -------------------------------------------------------------------------------- 1 | import { endpoint } from "./endpoint"; 2 | import { readdir, readFile } from "fs/promises"; 3 | import { OK } from "./status"; 4 | import { join, extname } from "path"; 5 | 6 | type FileReader = (path: string) => Promise; 7 | type ContentTypeResolver = (ext: string) => string | undefined; 8 | type HeadersProvider = (path: string) => Record; 9 | type ValidPathsProvider = (path: string) => Promise>; 10 | type PathRewriter = (path: string) => string; 11 | 12 | type Params = { 13 | dir: string; 14 | route?: string; 15 | dynamic?: boolean; 16 | pathRewriter?: PathRewriter; 17 | validPathsProvider?: ValidPathsProvider; 18 | fileReader?: FileReader; 19 | contentTypeResolver?: ContentTypeResolver; 20 | headersProvider?: HeadersProvider; 21 | }; 22 | 23 | export const staticFiles = async ({ 24 | dir, 25 | route = "", 26 | dynamic = false, 27 | pathRewriter = defaultPathRewriter, 28 | validPathsProvider = defaultValidPathsProvider, 29 | fileReader = defaultFileReader(dynamic), 30 | contentTypeResolver = minimalContentTypeResolver, 31 | headersProvider = defaultHeadersProvider, 32 | }: Params) => { 33 | let validPaths: Set | null = null; 34 | const prefixLength = route.endsWith("/") ? route.length : route.length + 1; 35 | 36 | return endpoint({ 37 | accept: async (request) => { 38 | if (!request.url.startsWith(route)) { 39 | return; 40 | } 41 | 42 | if (dynamic || !validPaths) { 43 | validPaths = await validPathsProvider(dir); 44 | } 45 | 46 | const path = pathRewriter(request.url.substring(prefixLength)); 47 | return validPaths.has(path) && join(dir, path); 48 | }, 49 | handle: async (path) => { 50 | const buffer = await fileReader(path); 51 | const contentType = contentTypeResolver(extname(path)); 52 | return { 53 | status: OK, 54 | headers: { 55 | "Content-Type": contentType || "", 56 | ...headersProvider(path), 57 | }, 58 | body: buffer, 59 | }; 60 | }, 61 | }); 62 | }; 63 | 64 | const defaultPathRewriter: PathRewriter = (path) => { 65 | if (!path.length) { 66 | return "index.html"; 67 | } else { 68 | return path; 69 | } 70 | }; 71 | 72 | const defaultValidPathsProvider = (dir: string) => 73 | readdir(dir).then((list) => new Set(list)); 74 | 75 | const defaultFileReader = (dynamic: boolean): FileReader => { 76 | const cache: Record = {}; 77 | return async (path) => { 78 | const buffer = (!dynamic && cache[path]) || (await readFile(path)); 79 | cache[path] = buffer; 80 | return buffer; 81 | }; 82 | }; 83 | 84 | const minimalContentTypeResolver: ContentTypeResolver = (ext) => { 85 | const mimeTypes = { 86 | jpeg: "image/jpeg", 87 | js: "application/javascript", 88 | svg: "image/svg+xml", 89 | }; 90 | 91 | const mapping: Record = { 92 | ".html": "text/html", 93 | ".jpg": mimeTypes.jpeg, 94 | ".jpeg": mimeTypes.jpeg, 95 | ".jpe": mimeTypes.jpeg, 96 | ".png": "image/png", 97 | ".svg": "image/svg", 98 | ".svgz": "image/svg", 99 | ".json": "application/json", 100 | ".js": mimeTypes.js, 101 | ".jsm": mimeTypes.js, 102 | ".css": "text/css", 103 | }; 104 | 105 | return mapping[ext]; 106 | }; 107 | 108 | const defaultHeadersProvider: HeadersProvider = (path) => { 109 | return {}; 110 | }; 111 | -------------------------------------------------------------------------------- /src/status.ts: -------------------------------------------------------------------------------- 1 | import { HttpStatus } from "./types"; 2 | 3 | export const CONTINUE: HttpStatus = { code: 100, phrase: "Continue" }; 4 | export const SWITCHING_PROTOCOLS: HttpStatus = { code: 101, phrase: "Switching Protocols" }; 5 | export const PROCESSING: HttpStatus = { code: 102, phrase: "Processin" }; 6 | export const OK: HttpStatus = { code: 200, phrase: "OK" }; 7 | export const CREATED: HttpStatus = { code: 201, phrase: "Created" }; 8 | export const ACCEPTED: HttpStatus = { code: 202, phrase: "Accepted" }; 9 | export const NON_AUTHORITATIVE_INFORMATION: HttpStatus = { code: 203, phrase: "Non Authoritative Information" }; 10 | export const NO_CONTENT: HttpStatus = { code: 204, phrase: "No Content" }; 11 | export const RESET_CONTENT: HttpStatus = { code: 205, phrase: "Reset Content" }; 12 | export const PARTIAL_CONTENT: HttpStatus = { code: 206, phrase: "Partial Content" }; 13 | export const MULTI_STATUS: HttpStatus = { code: 207, phrase: "Multi-Status" }; 14 | export const MULTIPLE_CHOICES: HttpStatus = { code: 300, phrase: "Multiple Choices" }; 15 | export const MOVED_PERMANENTLY: HttpStatus = { code: 301, phrase: "Moved Permanently" }; 16 | export const MOVED_TEMPORARILY: HttpStatus = { code: 302, phrase: "Moved Temporarily" }; 17 | export const SEE_OTHER: HttpStatus = { code: 303, phrase: "See Other" }; 18 | export const NOT_MODIFIED: HttpStatus = { code: 304, phrase: "Not Modified" }; 19 | export const USE_PROXY: HttpStatus = { code: 305, phrase: "Use Proxy" }; 20 | export const TEMPORARY_REDIRECT: HttpStatus = { code: 307, phrase: "Temporary Redirect" }; 21 | export const PERMANENT_REDIRECT: HttpStatus = { code: 308, phrase: "Permanent Redirect" }; 22 | export const BAD_REQUEST: HttpStatus = { code: 400, phrase: "Bad Request" }; 23 | export const UNAUTHORIZED: HttpStatus = { code: 401, phrase: "Unauthorized" }; 24 | export const PAYMENT_REQUIRED: HttpStatus = { code: 402, phrase: "Payment Required" }; 25 | export const FORBIDDEN: HttpStatus = { code: 403, phrase: "Forbidden" }; 26 | export const NOT_FOUND: HttpStatus = { code: 404, phrase: "Not Found" }; 27 | export const METHOD_NOT_ALLOWED: HttpStatus = { code: 405, phrase: "Method Not Allowed" }; 28 | export const NOT_ACCEPTABLE: HttpStatus = { code: 406, phrase: "Not Acceptable" }; 29 | export const PROXY_AUTHENTICATION_REQUIRED: HttpStatus = { code: 407, phrase: "Proxy Authentication Required" }; 30 | export const REQUEST_TIMEOUT: HttpStatus = { code: 408, phrase: "Request Timeout" }; 31 | export const CONFLICT: HttpStatus = { code: 409, phrase: "Conflict" }; 32 | export const GONE: HttpStatus = { code: 410, phrase: "Gone" }; 33 | export const LENGTH_REQUIRED: HttpStatus = { code: 411, phrase: "Length Required" }; 34 | export const PRECONDITION_FAILED: HttpStatus = { code: 412, phrase: "Precondition Failed" }; 35 | export const REQUEST_TOO_LONG: HttpStatus = { code: 413, phrase: "Request Entity Too Large" }; 36 | export const REQUEST_URI_TOO_LONG: HttpStatus = { code: 414, phrase: "Request-URI Too Long" }; 37 | export const UNSUPPORTED_MEDIA_TYPE: HttpStatus = { code: 415, phrase: "Unsupported Media Type" }; 38 | export const REQUESTED_RANGE_NOT_SATISFIABLE: HttpStatus = { code: 416, phrase: "Requested Range Not Satisfiable" }; 39 | export const EXPECTATION_FAILED: HttpStatus = { code: 417, phrase: "Expectation Failed" }; 40 | export const IM_A_TEAPOT: HttpStatus = { code: 418, phrase: "I'm a teapot" }; 41 | export const INSUFFICIENT_SPACE_ON_RESOURCE: HttpStatus = { code: 419, phrase: "Insufficient Space on Resource" }; 42 | export const METHOD_FAILURE: HttpStatus = { code: 420, phrase: "Method Failure" }; 43 | export const MISDIRECTED_REQUEST: HttpStatus = { code: 421, phrase: "Misdirected Request" }; 44 | export const UNPROCESSABLE_ENTITY: HttpStatus = { code: 422, phrase: "Unprocessable Entity" }; 45 | export const LOCKED: HttpStatus = { code: 423, phrase: "Locked" }; 46 | export const FAILED_DEPENDENCY: HttpStatus = { code: 424, phrase: "Failed Dependency" }; 47 | export const PRECONDITION_REQUIRED: HttpStatus = { code: 428, phrase: "Precondition Required" }; 48 | export const TOO_MANY_REQUESTS: HttpStatus = { code: 429, phrase: "Too Many Requests" }; 49 | export const REQUEST_HEADER_FIELDS_TOO_LARGE: HttpStatus = { code: 431, phrase: "Request Header Fields Too Large" }; 50 | export const UNAVAILABLE_FOR_LEGAL_REASONS: HttpStatus = { code: 451, phrase: "Unavailable For Legal Reasons" }; 51 | export const INTERNAL_SERVER_ERROR: HttpStatus = { code: 500, phrase: "Internal Server Error" }; 52 | export const NOT_IMPLEMENTED: HttpStatus = { code: 501, phrase: "Not Implemented" }; 53 | export const BAD_GATEWAY: HttpStatus = { code: 502, phrase: "Bad Gateway" }; 54 | export const SERVICE_UNAVAILABLE: HttpStatus = { code: 503, phrase: "Service Unavailable" }; 55 | export const GATEWAY_TIMEOUT: HttpStatus = { code: 504, phrase: "Gateway Timeout" }; 56 | export const HTTP_VERSION_NOT_SUPPORTED: HttpStatus = { code: 505, phrase: "HTTP Version Not Supported" }; 57 | export const INSUFFICIENT_STORAGE: HttpStatus = { code: 507, phrase: "Insufficient Storage" }; 58 | export const NETWORK_AUTHENTICATION_REQUIRED: HttpStatus = { code: 511, phrase: "Network Authentication Required" }; 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # HNDL 2 | 3 | Are you looking for a new Node framework? Well, you've come to the right place! 4 | 5 | HNDL was made to address my personal frustrations with existing Node frameworks, 6 | and here are the main design decisions behind HNDL: 7 | 8 | - No middleware 9 | - Strong types 10 | - Simple routing 11 | - Async by default 12 | - No external dependencies 13 | - Sane defaults 14 | 15 | ## No Middleware?! 16 | 17 | Yes! This is my main point of concern with existing Node frameworks. 18 | Let's take a typicial Express request handler: 19 | 20 | ```js 21 | app.get("/timestamp", (req, res) => { 22 | res.send({ 23 | timestamp: new Date().valueOf(); 24 | }) 25 | }); 26 | ``` 27 | 28 | Let's say we send a request `GET /timestamp`, what would the response be? 29 | 30 | It would be valid to say: "Well it returns a json object containing a unix millis timestamp.". 31 | However it's not correct, because you don't know that before I registered this handler, I also have: 32 | 33 | ```js 34 | app.use((req, res) => { 35 | res.sendStatus(400); 36 | }) 37 | ``` 38 | 39 | So to avoid confusion like this, there is no middleware support in HNDL. 40 | If your request handler gets invoked, it is guaranteed to be THE ONLY thing that will process this event. 41 | Isn't that cool? Why do I even have to sell this as a feature? How did we come to this? 42 | 43 | ## Endpoint Type Definition 44 | 45 | In HNDL any object that looks like this is a valid endpoint: 46 | 47 | ```ts 48 | type Endpoint = { 49 | accept: (request: Request) => Optional | Promise>; 50 | handle: (payload: T) => Response | Promise; 51 | } 52 | ``` 53 | 54 | The most important thing to notice is the relationship between `accept` and `handle`: 55 | `handle` takes as a param whatever `accept` returns. 56 | 57 | How they are related is explained in the next section about routing. 58 | 59 | ## Routing 60 | 61 | A router in HNDL is defined using the function `router`, it takes a variadic list of 62 | endpoints like this: 63 | 64 | ```ts 65 | const myRouter = router( 66 | firstEndpoint, 67 | secondEndpoint, 68 | thirdEndpoint 69 | ) 70 | ``` 71 | 72 | The main job of the router is to choose one of the passed endpoints to handle the 73 | incoming request. And for this the router uses the `accept` function. 74 | 75 | So when a new request comes in, the router will go in order, and call the `accept` 76 | function of every endpoint. The first `accept` that returns a truthy value, is chosen. 77 | 78 | Whatever this truthy value is, the router will invoke the `handle` method of this chosen 79 | endpoint with that value. 80 | 81 | The `handle` method of the chosen endpoint MUST return a `Response` object. 82 | In other words, if you accept the request, you must handle it entirely. 83 | 84 | Both `accept` and `handle` can of course be async. 85 | 86 | Finally, it's worth noting that the `router` function just returns another 87 | `Endpoint` so they can be nested, if needed, but this is discouraged. 88 | 89 | ### Examples 90 | 91 | Let's go through a few examples to illustrate how this works: 92 | 93 | This endpoint will respond to any request with a 200 OK: 94 | 95 | ```ts 96 | const everythingIsOK = { 97 | accept: () => true, 98 | handle: () => { status: OK } 99 | } 100 | ``` 101 | 102 | This router will respond with `200 OK` if the URL starts with `/ok`, 103 | otherwise with `404 Not Found`: 104 | 105 | ```ts 106 | const myRouter = router( 107 | { 108 | accept: request => request.url.startsWith("/ok"), 109 | handle: () => { status: OK } 110 | }, 111 | { 112 | accept: () => true, 113 | handle: () { status: NOT_FOUND } 114 | } 115 | ) 116 | ``` 117 | 118 | ## The Service 119 | 120 | When developing a web service it's necessary to perform additional 121 | tasks such as logging, error handling, etc... These do not fall 122 | into the area of responsibility of endpoints, and for this we use the `service`. 123 | 124 | The `service` function takes the following arguments: 125 | 126 | ```ts 127 | type ServiceProps = { 128 | endpoint: Endpoint; 129 | errorHandler?: ErrorHandler; 130 | logger?: Logger; 131 | } 132 | 133 | type ErrorHandler = (error: any) => Response | Promise; 134 | 135 | type Logger = (request: Request, response: Response) => void; 136 | ``` 137 | 138 | And returns a new `endpoint` which will log requests and handle 139 | errors correctly thrown from both the `accept` and `handle` functions 140 | of the passed endpoint. 141 | 142 | It's a convenient function that is not strictly necessary in this 143 | package, but is something that most people will like to have. 144 | 145 | ## The Listener 146 | 147 | The last piece of the puzzle is the `listener`. It is meant to conform 148 | to the request handler function passed into the `createServer` function 149 | which comes with the default node `http` module. 150 | 151 | The listener's job is to simply allow async handling of incoming 152 | requests. And can be used in the following way: 153 | 154 | ```ts 155 | const myEndpoint: Endpoint = { /* ... */}; 156 | const server = createServer(listener(myEndpoint)); 157 | ``` 158 | -------------------------------------------------------------------------------- /tests/service.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual } from "assert"; 2 | import { service } from "../src/service"; 3 | import { BAD_REQUEST, INTERNAL_SERVER_ERROR, NOT_FOUND, OK, UNAUTHORIZED } from "../src/status"; 4 | import { Request, Response } from "../src/types"; 5 | import { Readable } from "stream"; 6 | 7 | const createMockResponse = () => { 8 | const result: any = {}; 9 | return { 10 | response: () => result as Response, 11 | writeHead: ( 12 | code: number, 13 | phrase: string, 14 | headers: Record, 15 | ) => { 16 | result.status = { code, phrase }; 17 | result.headers = headers; 18 | }, 19 | end: (body: any) => { 20 | result.body = body; 21 | }, 22 | }; 23 | }; 24 | 25 | export const serviceTests = async () => { 26 | { 27 | const body = "Hello World"; 28 | 29 | const testService = service({ 30 | logger: () => {}, 31 | endpoint: { 32 | accept: () => true, 33 | handle: () => ({ status: OK, body }), 34 | }, 35 | }); 36 | 37 | const serverResponse = createMockResponse(); 38 | await testService({} as any, serverResponse as any); 39 | 40 | deepStrictEqual( 41 | serverResponse.response().body, 42 | body, 43 | "Service should write body data correctly", 44 | ); 45 | } 46 | 47 | { 48 | const response = { 49 | status: OK, 50 | body: new Readable({ 51 | read() { 52 | this.push(Buffer.from("Hello World")); 53 | this.push(null); // Signals the end of the stream 54 | }, 55 | }), 56 | }; 57 | 58 | const testService = service({ 59 | logger: () => {}, 60 | endpoint: { 61 | accept: () => true, 62 | handle: () => response, 63 | }, 64 | }); 65 | 66 | let actualPipedTo: any = null; 67 | (response.body as any).pipe = function(writable: any) { 68 | actualPipedTo = writable; 69 | this.emit("end"); 70 | }; 71 | 72 | const serverResponse = createMockResponse(); 73 | await testService({} as any, serverResponse as any); 74 | 75 | deepStrictEqual( 76 | actualPipedTo, 77 | serverResponse, 78 | "Service should pipe body stream correctly", 79 | ); 80 | } 81 | 82 | { 83 | const response = { 84 | status: OK, 85 | body: new Readable({ 86 | read() { 87 | this.emit("error"); 88 | }, 89 | }), 90 | }; 91 | 92 | const testService = service({ 93 | logger: () => {}, 94 | endpoint: { 95 | accept: () => true, 96 | handle: () => response, 97 | }, 98 | }); 99 | 100 | const serverResponse = createMockResponse(); 101 | await testService({} as any, serverResponse as any); 102 | 103 | deepStrictEqual( 104 | serverResponse.response().status, 105 | INTERNAL_SERVER_ERROR, 106 | "Service should handle stream errors correctly", 107 | ); 108 | } 109 | 110 | { 111 | const testService = service({ 112 | logger: () => {}, 113 | endpoint: { 114 | accept: () => false, 115 | handle: () => ({ status: OK }), 116 | }, 117 | }); 118 | 119 | const serverResponse = createMockResponse(); 120 | await testService({} as any, serverResponse as any); 121 | 122 | deepStrictEqual( 123 | serverResponse.response().status, 124 | NOT_FOUND, 125 | "Service should return 404 if endpoint doesn't accept", 126 | ); 127 | } 128 | 129 | { 130 | const testService = service({ 131 | logger: () => {}, 132 | endpoint: { 133 | accept: () => false, 134 | handle: () => ({ status: OK }), 135 | }, 136 | }); 137 | 138 | const serverResponse = createMockResponse(); 139 | await testService({} as any, serverResponse as any); 140 | 141 | deepStrictEqual( 142 | serverResponse.response().status, 143 | NOT_FOUND, 144 | "Service should return 404 if endpoint doesn't accept", 145 | ); 146 | } 147 | 148 | { 149 | const testService = service({ 150 | logger: () => {}, 151 | endpoint: { 152 | accept: () => { 153 | throw { status: BAD_REQUEST }; 154 | }, 155 | handle: () => ({ status: OK }), 156 | }, 157 | }); 158 | 159 | const serverResponse = createMockResponse(); 160 | await testService({} as any, serverResponse as any); 161 | 162 | deepStrictEqual( 163 | serverResponse.response().status, 164 | BAD_REQUEST, 165 | "Service should return correctly when an error is thrown in accept", 166 | ); 167 | } 168 | 169 | { 170 | const testService = service({ 171 | logger: () => {}, 172 | endpoint: { 173 | accept: () => true, 174 | handle: () => { 175 | throw { status: UNAUTHORIZED }; 176 | }, 177 | }, 178 | }); 179 | 180 | const serverResponse = createMockResponse(); 181 | await testService({} as any, serverResponse as any); 182 | 183 | deepStrictEqual( 184 | serverResponse.response().status, 185 | UNAUTHORIZED, 186 | "Service should return correctly when an error is thrown in handle", 187 | ); 188 | } 189 | 190 | { 191 | const request: Request = {} as any; 192 | const response: Response = { status: OK }; 193 | 194 | let loggedRequest: Request | undefined; 195 | let loggedResponse: Response | undefined; 196 | 197 | const testService = service({ 198 | logger: (req, res) => { 199 | loggedRequest = req; 200 | loggedResponse = res; 201 | }, 202 | endpoint: { 203 | accept: () => true, 204 | handle: () => response, 205 | }, 206 | }); 207 | 208 | const serverResponse = createMockResponse(); 209 | await testService({} as any, serverResponse as any); 210 | 211 | deepStrictEqual( 212 | loggedRequest, 213 | request, 214 | "Service should log the request correctly", 215 | ); 216 | deepStrictEqual( 217 | loggedResponse, 218 | response, 219 | "Service should log the response correctly", 220 | ); 221 | } 222 | }; 223 | --------------------------------------------------------------------------------