From 58139b078d0a26221dd69d0268dc730a907a3349 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Thu, 19 Jun 2025 15:43:01 -0700 Subject: [PATCH] feature: serverus modularly serves up a directory as an HTTP server --- .gitignore | 2 + README.md | 90 +++++ deno.json | 52 +++ deno.lock | 128 +++++++ handlers/markdown.ts | 401 ++++++++++++++++++++ handlers/static.ts | 64 ++++ handlers/typescript.ts | 111 ++++++ server.ts | 190 ++++++++++ serverus.ts | 48 +++ tests/01_get_static_file.test.ts | 35 ++ tests/02_get_markdown_file.test.ts | 73 ++++ tests/03_get_typescript_route.test.ts | 36 ++ tests/04_test_overriding_handlers.test.ts | 48 +++ tests/05_check_typescript_prechecks.test.ts | 48 +++ tests/handlers/foo.ts | 22 ++ tests/helpers.ts | 67 ++++ tests/www/echo/___input/index.ts | 8 + tests/www/permissions/test.ts | 22 ++ tests/www/test.md | 3 + tests/www/test.txt | 1 + 20 files changed, 1449 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 handlers/markdown.ts create mode 100644 handlers/static.ts create mode 100644 handlers/typescript.ts create mode 100644 server.ts create mode 100644 serverus.ts create mode 100644 tests/01_get_static_file.test.ts create mode 100644 tests/02_get_markdown_file.test.ts create mode 100644 tests/03_get_typescript_route.test.ts create mode 100644 tests/04_test_overriding_handlers.test.ts create mode 100644 tests/05_check_typescript_prechecks.test.ts create mode 100644 tests/handlers/foo.ts create mode 100644 tests/helpers.ts create mode 100644 tests/www/echo/___input/index.ts create mode 100644 tests/www/permissions/test.ts create mode 100644 tests/www/test.md create mode 100644 tests/www/test.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5f49cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +data/ +tests/data diff --git a/README.md b/README.md new file mode 100644 index 0000000..b425243 --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# SERVERUS + +A flexible HTTP server for mixed content. Throw static files, markdown, Typescript +and (hopefully, eventually) more into a directory and serverus can serve it up a +bit more like old-school CGI. + +## Usage + +Compiled: + +``` + +``` + +Container: + +``` + + +``` + +Deno: + +``` + +deno run jsr:@andyburke/serverus +``` + +## Overview + +SERVERUS is a Deno-based webserver that allows for various handlers to serve up +different types of content with a great deal of control. + +The default handlers are: + + - static files + will serve up static files within the root folder + - markdown + will serve markdown as HTML (or raw with an Accept header of text/markdown) + - Typescript + you can put .ts files in your root folder (including in 'parameter' directories, + eg: ./book/:book_id/index.ts) and if they export methods like `GET` and `POST`, + they will be called for those requests. there's some additional stuff you can + export to ease typical use cases, covered below. + +You just start serverus in a directory (or specify a root) and it tells you where it's +listening. + +### Typescript Handling + +These types and interface define the default serverus Typescript handler's expected +structure: + +```typescript +export type PRECHECK = ( + request: Request, + meta: Record +) => undefined | Response | Promise; +export type PRECHECKS_TABLE = Record<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', PRECHECK>; +export type ROUTE_HANDLER_METHOD = (request: Request, meta: Record) => Promise | Response; + +export interface ROUTE_HANDLER { + PRECHECKS?: PRECHECKS_TABLE; + GET?: ROUTE_HANDLER_METHOD; + POST?: ROUTE_HANDLER_METHOD; + PUT?: ROUTE_HANDLER_METHOD; + DELETE?: ROUTE_HANDLER_METHOD; + PATCH?: ROUTE_HANDLER_METHOD; + default?: ROUTE_HANDLER_METHOD; +} +``` + +A default exported method will be called for any unspecified methods and can +decide what to do with the request itself. + +`PRECHECKS` can defined a precheck per-method, eg: `PRECHECKS.GET = ( request, meta ) => ...` + +A precheck method should return a `Response` if there's an error that should stop +the request from proceeding. For example, if you require a session for a given route, +you could add a `PRECHECK` that checks for headers/cookies and tries to retrieve a +session, perhaps adding it to the `meta` data that will be passed to the `GET` +handler itself. If there is no session, however, it should return an HTTP `Response` +object indicating permission is denied or similar. + +#### NOTE ON WINDOWS + +Because Windows has more restrictions on filenames, you can use `___` in place of `:` in +parameter directories. + + diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..1e59a05 --- /dev/null +++ b/deno.json @@ -0,0 +1,52 @@ +{ + "name": "@andyburke/serverus", + "version": "0.0.1", + "license": "MIT", + "exports": { + ".": "./serverus.ts", + "./cli": "./serverus.ts", + "./server": "./server.ts", + "./handlers/markdown": "./handlers/markdown.ts", + "./handlers/static": "./handlers/static.ts", + "./handlers/typescript": "./handlers/typescript.ts" + }, + "tasks": { + "lint": "deno lint", + "fmt": "deno fmt", + "test": "DENO_ENV=test DATA_STORAGE_ROOT=./tests/data/$(date --iso-8601=seconds) deno test --allow-env --allow-read --allow-write --allow-net --trace-leaks --fail-fast ./tests/", + "build": "deno compile --allow-env --allow-read --allow-write --allow-net ./serverus.ts", + "serverus": "deno --allow-env --allow-read --allow-write --allow-net ./serverus.ts" + }, + "test": { + "exclude": ["tests/data/"] + }, + "fmt": { + "include": ["**/*.ts"], + "options": { + "useTabs": true, + "lineWidth": 140, + "indentWidth": 4, + "singleQuote": true, + "proseWrap": "preserve", + "trailingCommas": "never" + } + }, + "lint": { + "include": ["**/*.ts"], + "rules": { + "tags": ["recommended"], + "exclude": ["no-explicit-any"] + } + }, + "imports": { + "@std/assert": "jsr:@std/assert@^1.0.11", + "@std/async": "jsr:@std/async@^1.0.13", + "@std/cli": "jsr:@std/cli@^1.0.19", + "@std/fmt": "jsr:@std/fmt@^1.0.6", + "@std/fs": "jsr:@std/fs@^1.0.14", + "@std/http": "jsr:@std/http@^1.0.13", + "@std/media-types": "jsr:@std/media-types@^1.1.0", + "@std/path": "jsr:@std/path@^1.0.8", + "@std/testing": "jsr:@std/testing@^1.0.9" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..c3e0878 --- /dev/null +++ b/deno.lock @@ -0,0 +1,128 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@^1.0.11": "1.0.13", + "jsr:@std/assert@^1.0.13": "1.0.13", + "jsr:@std/async@*": "1.0.11", + "jsr:@std/async@^1.0.13": "1.0.13", + "jsr:@std/cli@^1.0.18": "1.0.19", + "jsr:@std/cli@^1.0.19": "1.0.19", + "jsr:@std/data-structures@^1.0.8": "1.0.8", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/encoding@^1.0.7": "1.0.10", + "jsr:@std/fmt@^1.0.6": "1.0.8", + "jsr:@std/fmt@^1.0.8": "1.0.8", + "jsr:@std/fs@^1.0.14": "1.0.18", + "jsr:@std/fs@^1.0.17": "1.0.18", + "jsr:@std/html@^1.0.4": "1.0.4", + "jsr:@std/http@^1.0.13": "1.0.17", + "jsr:@std/internal@^1.0.6": "1.0.8", + "jsr:@std/internal@^1.0.8": "1.0.8", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.4": "1.0.4", + "jsr:@std/path@^1.0.8": "1.1.0", + "jsr:@std/path@^1.1.0": "1.1.0", + "jsr:@std/streams@^1.0.9": "1.0.9", + "jsr:@std/testing@^1.0.9": "1.0.13" + }, + "jsr": { + "@std/assert@1.0.13": { + "integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29", + "dependencies": [ + "jsr:@std/internal@^1.0.6" + ] + }, + "@std/async@1.0.11": { + "integrity": "eee0d3405275506638a9c8efaa849cf0d35873120c69b7caa1309c9a9e5b6f85" + }, + "@std/async@1.0.13": { + "integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96" + }, + "@std/cli@1.0.19": { + "integrity": "b3601a54891f89f3f738023af11960c4e6f7a45dc76cde39a6861124cba79e88" + }, + "@std/data-structures@1.0.8": { + "integrity": "2fb7219247e044c8fcd51341788547575653c82ae2c759ff209e0263ba7d9b66" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.8": { + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" + }, + "@std/fs@1.0.18": { + "integrity": "24bcad99eab1af4fde75e05da6e9ed0e0dce5edb71b7e34baacf86ffe3969f3a", + "dependencies": [ + "jsr:@std/path@^1.1.0" + ] + }, + "@std/html@1.0.4": { + "integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e" + }, + "@std/http@1.0.17": { + "integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f", + "dependencies": [ + "jsr:@std/cli@^1.0.18", + "jsr:@std/encoding@^1.0.10", + "jsr:@std/fmt@^1.0.8", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path@^1.1.0", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.8": { + "integrity": "fc66e846d8d38a47cffd274d80d2ca3f0de71040f855783724bb6b87f60891f5" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.4": { + "integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852" + }, + "@std/path@1.1.0": { + "integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886" + }, + "@std/streams@1.0.9": { + "integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035" + }, + "@std/testing@1.0.13": { + "integrity": "74418be16f627dfe996937ab0ffbdbda9c1f35534b78724658d981492f121e71", + "dependencies": [ + "jsr:@std/assert@^1.0.13", + "jsr:@std/data-structures", + "jsr:@std/fs@^1.0.17", + "jsr:@std/internal@^1.0.8", + "jsr:@std/path@^1.1.0" + ] + } + }, + "remote": { + "https://deno.land/std@0.184.0/_util/asserts.ts": "178dfc49a464aee693a7e285567b3d0b555dc805ff490505a8aae34f9cfb1462", + "https://deno.land/std@0.184.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.184.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.184.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.184.0/path/_util.ts": "d7abb1e0dea065f427b89156e28cdeb32b045870acdf865833ba808a73b576d0", + "https://deno.land/std@0.184.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.184.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.184.0/path/mod.ts": "bf718f19a4fdd545aee1b06409ca0805bd1b68ecf876605ce632e932fe54510c", + "https://deno.land/std@0.184.0/path/posix.ts": "8b7c67ac338714b30c816079303d0285dd24af6b284f7ad63da5b27372a2c94d", + "https://deno.land/std@0.184.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.184.0/path/win32.ts": "d186344e5583bcbf8b18af416d13d82b35a317116e6460a5a3953508c3de5bba" + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@^1.0.11", + "jsr:@std/async@^1.0.13", + "jsr:@std/cli@^1.0.19", + "jsr:@std/fmt@^1.0.6", + "jsr:@std/fs@^1.0.14", + "jsr:@std/http@^1.0.13", + "jsr:@std/media-types@^1.1.0", + "jsr:@std/path@^1.0.8", + "jsr:@std/testing@^1.0.9" + ] + } +} diff --git a/handlers/markdown.ts b/handlers/markdown.ts new file mode 100644 index 0000000..e59e0eb --- /dev/null +++ b/handlers/markdown.ts @@ -0,0 +1,401 @@ +import * as path from '@std/path'; + +/** + * Handles requests for markdown files, converting them to html by default + * but allowing for getting the raw file with an accept header of text/markdown. + * + * @param request The incoming HTTP request + * @returns Either a response (a markdown file was requested and returned properly) or undefined if unhandled. + */ +export default async function handle_markdown(request: Request): Promise { + const url = new URL(request.url); + const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, '')); + if (!normalized_path.startsWith(Deno.cwd())) { + return; + } + + try { + const stat = await Deno.stat(normalized_path); + let markdown: string | null = null; + + if (stat.isDirectory) { + const index_path = path.join(normalized_path, 'index.md'); + const index_stat = await Deno.stat(index_path); + if (!index_stat.isFile) { + return; + } + + markdown = await Deno.readTextFile(index_path); + } + + if (stat.isFile) { + const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; + + if (extension !== 'md') { + return; + } + + markdown = await Deno.readTextFile(normalized_path); + } + + const accepts: string = request.headers.get('accept') ?? 'text/html'; + switch (accepts) { + case 'text/markdown': { + return new Response(markdown, { + headers: { + 'Content-Type': 'text/markdown' + } + }); + } + + case 'text/html': + default: { + const html = md_to_html(markdown ?? ''); + const css = Deno.env.get('SERVERUS_MARKDOWN_CSS') ?? DEFAULT_CSS; + + return new Response( + ` + + + + ${css} + + + +
+ ${html} +
+ +`, + { + headers: { + 'Content-Type': 'text/html' + } + } + ); + } + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return; + } + + if (error instanceof Deno.errors.PermissionDenied) { + return new Response('Permission Denied', { + status: 400, + headers: { + 'Content-Type': 'text/plain' + } + }); + } + + throw error; + } +} + +/* DEFAULT CSS */ +const DEFAULT_CSS = ` + +`; + +/* MARKDOWN TO HTML */ + +type TRANSFORMER = [RegExp, string]; +/* order of these transforms matters, so we list them here in an array and build type from it after. */ +const TRANSFORM_NAMES = [ + 'characters', + 'headings', + 'horizontal_rules', + 'list_items', + 'bold', + 'italic', + 'strikethrough', + 'code', + 'images', + 'links', + 'breaks' +] as const; +type TRANSFORM_NAME = typeof TRANSFORM_NAMES[number]; +const TRANSFORMS: Record = { + characters: [ + [/&/g, '&'], + [//g, '>'], + [/"/g, '"'], + [/'/g, '''] + ], + + headings: [ + [/^#\s(.+)$/gm, '

$1

\n'], + [/^##\s(.+)$/gm, '

$1

\n'], + [/^###\s(.+)$/gm, '

$1

\n'], + [/^####\s(.+)$/gm, '

$1

\n'], + [/^#####\s(.+)$/gm, '
$1
\n'] + ], + + horizontal_rules: [ + [/^----*$/gm, '
\n'] + ], + + list_items: [ + [/\n\n([ \t]*)([-\*\.].*?)\n\n/gs, '\n\n
    \n$1$2\n
\n\n\n'], + [/^([ \t]*)[-\*\.](\s+.*)$/gm, '
  • $1$2
  • \n'] + ], + + bold: [ + [/\*([^\*]+)\*/gm, '$1'] + ], + + italic: [ + [/_([^_]+)_/gm, '$1'] + ], + + strikethrough: [ + [/~([^~]+)~/gm, '$1'] + ], + + code: [ + [/```\n([^`]+)\n```/gm, '
    $1
    '], + [/```([^`]+)```/gm, '$1'] + ], + + images: [ + [/!\[([^\]]+)\]\(([^\)]+)\)/g, '$1'] + ], + + links: [ + [/\[([^\]]+)\]\(([^\)]+)\)/g, '$1'] + ], + + breaks: [ + [/\s\s\n/g, '\n
    \n'], + [/\n\n/g, '\n
    \n'] + ] +}; + +/** + * Convert markdown to HTML. + * @param markdown The markdown string. + * @param options _(Optional)_ A record of transforms to disable. + * @returns The generated HTML string. + */ +export function md_to_html( + markdown: string, + transform_config?: Record +): string { + let html = markdown; + for (const transform_name of TRANSFORM_NAMES) { + const enabled: boolean = typeof transform_config === 'undefined' || transform_config[transform_name] !== false; + if (!enabled) { + continue; + } + + const transforms: TRANSFORMER[] = TRANSFORMS[transform_name] ?? []; + for (const markdown_transformer of transforms) { + html = html.replace(...markdown_transformer); + } + } + + return html; +} diff --git a/handlers/static.ts b/handlers/static.ts new file mode 100644 index 0000000..192aa4c --- /dev/null +++ b/handlers/static.ts @@ -0,0 +1,64 @@ +import * as path from '@std/path'; +import * as media_types from '@std/media-types'; + +/** + * Handles requests for static files. + * + * @param request The incoming HTTP request + * @returns Either a response (a static file was requested and returned properly) or undefined if unhandled. + */ +export default async function handle_static_files(request: Request): Promise { + const url = new URL(request.url); + const normalized_path = path.resolve(path.normalize(url.pathname).replace(/^\/+/, '')); + if (!normalized_path.startsWith(Deno.cwd())) { + return; + } + + try { + const stat = await Deno.stat(normalized_path); + + if (stat.isDirectory) { + const extensions: string[] = media_types.allExtensions('text/html') ?? ['html', 'htm']; + + for (const extension of extensions) { + const index_path = path.join(normalized_path, `index.${extension}`); + const index_stat = await Deno.stat(index_path); + if (index_stat.isFile) { + return new Response(await Deno.readFile(index_path), { + headers: { + 'Content-Type': 'text/html' + } + }); + } + } + + return; + } + + if (stat.isFile) { + const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; + + const content_type = media_types.contentType(extension) ?? 'application/octet-stream'; + return new Response(await Deno.readFile(normalized_path), { + headers: { + 'Content-Type': content_type + } + }); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + return; + } + + if (error instanceof Deno.errors.PermissionDenied) { + return new Response('Permission Denied', { + status: 400, + headers: { + 'Content-Type': 'text/plain' + } + }); + } + + throw error; + } +} diff --git a/handlers/typescript.ts b/handlers/typescript.ts new file mode 100644 index 0000000..9fe35cb --- /dev/null +++ b/handlers/typescript.ts @@ -0,0 +1,111 @@ +import { walk } from '@std/fs'; +import { delay } from '@std/async/delay'; +import * as path from '@std/path'; +import { getCookies } from '@std/http/cookie'; + +export type PRECHECK = ( + request: Request, + meta: Record +) => undefined | Response | Promise; +export type PRECHECKS_TABLE = Record<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', PRECHECK>; +export type ROUTE_HANDLER_METHOD = (request: Request, meta: Record) => Promise | Response; + +export interface ROUTE_HANDLER { + PRECHECKS?: PRECHECKS_TABLE; + GET?: ROUTE_HANDLER_METHOD; + POST?: ROUTE_HANDLER_METHOD; + PUT?: ROUTE_HANDLER_METHOD; + DELETE?: ROUTE_HANDLER_METHOD; + PATCH?: ROUTE_HANDLER_METHOD; + default?: ROUTE_HANDLER_METHOD; +} + +const routes: Map = new Map(); +let loading: boolean = false; +let all_routes_loaded: boolean = false; + +/** + * Handles requests for which there are typescript files in the root tree. + * + * NOTE: On initial request the tree will be scanned and handlers loaded, + * concurrent requests should wait for the load, but until the tree has been + * scanned, requests may take longer while the load completes. + * + * @param request The incoming HTTP request + * @returns Either a response (a handler for the request path and method was found) or undefined if unhandled. + */ +export default async function handle_typescript(request: Request): Promise { + if (!all_routes_loaded) { + if (!loading) { + loading = true; + + const root_directory = path.resolve(Deno.cwd()); + + for await ( + const entry of walk(root_directory, { + exts: ['.ts'], + skip: [/\.test\.ts$/] + }) + ) { + if (entry.isFile) { + const relative_path = entry.path.substring(root_directory.length); + const route_path = relative_path + .replace(/\.ts$/, '') + //.replace(/\[(\w+)\]/g, ':$1') + .replace(/\/index$/, '') + .replace(/___/g, ':') || // required for windows, uncivilized OS that it is + '/'; + + const import_path = new URL('file://' + entry.path, import.meta.url).toString(); + const module: ROUTE_HANDLER = await import(import_path) as ROUTE_HANDLER; + + const pattern = new URLPattern({ pathname: route_path }); + routes.set(pattern, module); + } + } + + all_routes_loaded = true; + loading = false; + } + + do { + await delay(10); + } while (!all_routes_loaded); + } + + for (const [pattern, handler_module] of routes) { + const match = pattern.exec(request.url); + if (match) { + const method = request.method as keyof ROUTE_HANDLER; + const method_handler: ROUTE_HANDLER_METHOD = (handler_module[method] ?? handler_module.default) as ROUTE_HANDLER_METHOD; + if (!method_handler) { + return; + } + + const cookies: Record = getCookies(request.headers); + const query = Object.fromEntries(new URL(request.url).searchParams.entries()); + + const metadata = { + cookies, + params: match.pathname.groups, + query + }; + + const precheck: PRECHECK | undefined = handler_module.PRECHECKS?.[request.method as keyof PRECHECKS_TABLE]; + if (precheck) { + const result = await precheck(request, metadata); + if (result) { + return result; + } + } + + return await method_handler(request, metadata); + } + } +} + +export function unload(): void { + loading = false; + routes.clear(); + all_routes_loaded = false; +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..78d7cb8 --- /dev/null +++ b/server.ts @@ -0,0 +1,190 @@ +import * as colors from '@std/fmt/colors'; +import * as fs from '@std/fs'; +import * as path from '@std/path'; + +const DEFAULT_HANDLER_DIRECTORIES = [path.resolve(path.join(path.dirname(path.fromFileUrl(import.meta.url)), 'handlers'))]; + +type HANDLER = (request: Request) => Promise | Response | null | undefined; +type LOGGER = (request: Request, response: Response, processing_time: number) => void | Promise; + +interface HANDLER_MODULE { + default: HANDLER; + unload?: () => void; +} + +/** + * Interface defining the configuration for a serverus server + * + * @property {string} [hostname='localhost'] - hostname to bind to + * @property {number} [port=8000] - port to bind to + * @property {logging} [logging=true] - true/false or a LOGGER instance, default: true (uses default logging) + */ +export type SERVER_OPTIONS = { + hostname?: string; + port?: number; + logging?: boolean | LOGGER; +}; + +/** + * Default options for a serverus server. + * + * @property {string} hostname - localhost + * @property {number} port - 8000 + * @property {boolean} logging - true (use default logger) + */ +export const DEFAULT_SERVER_OPTIONS: SERVER_OPTIONS = { + hostname: 'localhost', + port: 8000, + logging: true +}; + +/** + * Default logger + * + * @param {Request} request - the incoming request + * @param {Response} response - the outgoing response + * @param {number} time - the elapsed time in ms since the request was received + */ +function LOG_REQUEST(request: Request, response: Response, time: number) { + const c = response.status >= 500 ? colors.red : response.status >= 400 ? colors.yellow : colors.green; + const u = new URL(request.url); + const qs = u.searchParams.toString(); + console.log( + `${c(request.method)} ${colors.gray(`(${response.status})`)} - ${ + colors.cyan( + `${u.pathname}${qs ? '?' + qs : ''}` + ) + } - ${colors.bold(String(time) + 'ms')}` + ); +} + +/** + * serverus SERVER + * + * Loads all handlers found in the [semi-]colon separated list of directories in + */ +export class SERVER { + private options: SERVER_OPTIONS; + private server: Deno.HttpServer | undefined; + private controller: AbortController | undefined; + private shutdown_binding: (() => void) | undefined; + private handlers: HANDLER_MODULE[]; + + /** + * @param {SERVER_OPTIONS} (optional) options to configure the server + */ + constructor(options?: SERVER_OPTIONS) { + this.options = { + ...DEFAULT_SERVER_OPTIONS, + ...(options ?? {}) + }; + this.handlers = []; + } + + private shutdown() { + this.controller?.abort(); + } + + /** + * Start the server. + * + * @returns {Promise} + */ + public async start(): Promise { + this.controller = new AbortController(); + const { signal } = this.controller; + + const HANDLERS_DIRECTORIES: string[] = + Deno.env.get('SERVERUS_HANDLERS')?.split(/[;:]/g)?.filter((dir) => dir.length > 0)?.map((dir) => path.resolve(dir)) ?? + DEFAULT_HANDLER_DIRECTORIES; + + for (const handler_directory of HANDLERS_DIRECTORIES) { + const resolved_handler_directory_glob = path.resolve(path.join(handler_directory, '*.ts')); + for await (const globbed_record of fs.expandGlob(resolved_handler_directory_glob)) { + if (!globbed_record.isFile) { + continue; + } + + const import_path = new URL('file://' + globbed_record.path, import.meta.url).toString(); + const handler_module: HANDLER_MODULE = await import(import_path); + if (typeof handler_module.default !== 'function') { + console.warn(`Could not load handler, no default exported function: ${globbed_record.path}`); + continue; + } + + this.handlers.push(handler_module); + } + } + + if (this.handlers.length === 0) { + throw new Error(`Could not load any handlers from: ${HANDLERS_DIRECTORIES.join('; ')}`, { + cause: 'no_handlers_loaded' + }); + } + + this.server = Deno.serve( + { + port: this.options.port ?? DEFAULT_SERVER_OPTIONS.port, + hostname: this.options.hostname ?? DEFAULT_SERVER_OPTIONS.hostname, + onError: (error: unknown) => { + return Response.json({ error: { message: (error as Error).message } }, { status: 500 }); + }, + signal + }, + async (request: Request): Promise => { + const request_time = Date.now(); + const logger: LOGGER | undefined = typeof this.options.logging === 'function' + ? this.options.logging + : (this.options.logging ? LOG_REQUEST : undefined); + + for (const handler_module of this.handlers) { + const response = await handler_module.default(request); + if (response) { + logger?.(request, response, Date.now() - request_time); + return response; + } + } + + const not_found = Response.json( + { error: { message: 'Not found', cause: 'not_found' } }, + { status: 404 } + ); + logger?.(request, not_found, Date.now() - request_time); + return not_found; + } + ); + + this.shutdown_binding = this.shutdown.bind(this); + Deno.addSignalListener('SIGTERM', this.shutdown_binding); + Deno.addSignalListener('SIGINT', this.shutdown_binding); + + console.log('listening'); + return this; + } + + /** + * Stop the server + */ + public async stop(): Promise { + if (this.server) { + this.controller?.abort(); + await this.server.shutdown(); + + if (this.shutdown_binding) { + Deno.removeSignalListener('SIGTERM', this.shutdown_binding); + Deno.removeSignalListener('SIGINT', this.shutdown_binding); + } + } + + this.controller = undefined; + this.server = undefined; + this.shutdown_binding = undefined; + + for (const handler_module of this.handlers) { + if (typeof handler_module.unload === 'function') { + handler_module.unload(); + } + } + this.handlers = []; + } +} diff --git a/serverus.ts b/serverus.ts new file mode 100644 index 0000000..20e9527 --- /dev/null +++ b/serverus.ts @@ -0,0 +1,48 @@ +import { parseArgs } from '@std/cli/parse-args'; +import { SERVER } from './server.ts'; +import * as path from '@std/path'; + +const settings = parseArgs(Deno.args, { + boolean: ['help', 'logs', 'version'], + string: ['hostname', 'port', 'root'], + negatable: ['logs'], + alias: { + help: 'h', + port: 'p', + root: 'r', + version: 'v' + }, + default: { + hostname: 'localhost', + logs: true, + port: '8000', + root: Deno.env.get('SERVERUS_ROOT') ?? './' + } +}); + +if (settings.help) { + console.log( + `Usage: serverus [--h(elp)] [--v(ersion)] [--no-logs] [--r(oot) ./www] + +Options: + -h, --help Show this help message + --hostname Set the hostname/ip to bind to, default: localhost + --no-logs Disable logging + -p, --port Set the port to bind to, default: 8000 + -r, --root Set the root directory to serve, default: './' + -v, --version Show the version number +` + ); + Deno.exit(0); +} + +const resolved_root: string = path.resolve(settings.root); +Deno.chdir(resolved_root); + +const server = new SERVER({ + hostname: settings.hostname, + port: typeof settings.port == 'string' ? parseInt(settings.port) : undefined, + logging: settings.logs +}); + +await server.start(); diff --git a/tests/01_get_static_file.test.ts b/tests/01_get_static_file.test.ts new file mode 100644 index 0000000..c1babef --- /dev/null +++ b/tests/01_get_static_file.test.ts @@ -0,0 +1,35 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'get static file', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.txt`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/02_get_markdown_file.test.ts b/tests/02_get_markdown_file.test.ts new file mode 100644 index 0000000..a08190f --- /dev/null +++ b/tests/02_get_markdown_file.test.ts @@ -0,0 +1,73 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'get markdown file (html)', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.md`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertMatch(body, /\.*?\<\/html\>/is); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); + +Deno.test({ + name: 'get markdown file (markdown)', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/test.md`, { + method: 'GET', + headers: { + 'Accept': 'text/markdown' + } + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertNotMatch(body, /\.*?\<\/html\>/is); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/03_get_typescript_route.test.ts b/tests/03_get_typescript_route.test.ts new file mode 100644 index 0000000..d9365c0 --- /dev/null +++ b/tests/03_get_typescript_route.test.ts @@ -0,0 +1,36 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'get a typescript route', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/echo/hello_world`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertEquals(body, 'hello_world'); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/04_test_overriding_handlers.test.ts b/tests/04_test_overriding_handlers.test.ts new file mode 100644 index 0000000..90b7a04 --- /dev/null +++ b/tests/04_test_overriding_handlers.test.ts @@ -0,0 +1,48 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; +import * as path from '@std/path'; + +Deno.test({ + name: 'override the default handlers', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + const old_handlers: string | undefined = Deno.env.get('SERVERUS_HANDLERS'); + + try { + Deno.chdir('./tests/www'); + Deno.env.set( + 'SERVERUS_HANDLERS', + `${path.join(path.dirname(path.resolve(path.fromFileUrl(import.meta.url))), 'handlers')}${ + old_handlers ? (';' + old_handlers) : '' + }` + ); + test_server_info = await get_ephemeral_listen_server(); + + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/echo/hello_world.foo`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertEquals(body, 'foo'); + } finally { + Deno.chdir(cwd); + Deno.env.delete('SERVERUS_HANDLERS'); + if (old_handlers) { + Deno.env.set('SERVERUS_HANDLERS', old_handlers); + } + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/05_check_typescript_prechecks.test.ts b/tests/05_check_typescript_prechecks.test.ts new file mode 100644 index 0000000..bea6b0f --- /dev/null +++ b/tests/05_check_typescript_prechecks.test.ts @@ -0,0 +1,48 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'get a typescript route with permissions', + permissions: { + env: true, + read: true, + write: true, + net: true + }, + fn: async () => { + let test_server_info: EPHEMERAL_SERVER | null = null; + const cwd = Deno.cwd(); + + try { + Deno.chdir('./tests/www'); + test_server_info = await get_ephemeral_listen_server(); + + const invalid_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/permissions/test`, { + method: 'GET' + }); + + const invalid_response_body = await invalid_response.text(); + + asserts.assert(!invalid_response.ok); + asserts.assert(invalid_response_body); + asserts.assertEquals(invalid_response_body, 'Permission Denied'); + + const valid_response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/permissions/test`, { + method: 'GET', + headers: { + 'x-secret': 'very secret' + } + }); + + const valid_response_body = await valid_response.text(); + + asserts.assert(valid_response.ok); + asserts.assertEquals(valid_response_body, 'this is secret'); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/handlers/foo.ts b/tests/handlers/foo.ts new file mode 100644 index 0000000..e4da156 --- /dev/null +++ b/tests/handlers/foo.ts @@ -0,0 +1,22 @@ +import * as path from '@std/path'; +/** + * Any request that is for something with the extension 'foo' should return 'foo' + * + * @param request The incoming HTTP request + * @returns Either a response (a foo) or undefined if unhandled. + */ +export default function handle_static_files(request: Request): Response | undefined { + const url: URL = new URL(request.url); + const extension: string = path.extname(url.pathname)?.slice(1)?.toLowerCase() ?? ''; + + if (extension !== 'foo') { + return; + } + + return new Response('foo', { + status: 200, + headers: { + 'Content-Type': 'text/plain' + } + }); +} diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..8058b3c --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,67 @@ +import { SERVER, SERVER_OPTIONS } from '../server.ts'; + +const BASE_PORT: number = 50_000; +const MAX_PORT_OFFSET: number = 10_000; +let current_port_offset = 0; +function get_next_free_port(): number { + let free_port: number | undefined = undefined; + let attempts = 0; + do { + const port_to_try: number = BASE_PORT + (current_port_offset++ % MAX_PORT_OFFSET); + + try { + Deno.listen({ port: port_to_try }).close(); + free_port = port_to_try; + } catch (error) { + if (!(error instanceof Deno.errors.AddrInUse)) { + throw error; + } + } + + ++attempts; + + if (attempts % MAX_PORT_OFFSET === 0) { + console.warn(`Tried all ports at least once while trying to locate a free one, something wrong?`); + } + } while (!free_port); + + return free_port; +} + +/** + * Interface defining the configuration for an ephemeral server + * @property {string} hostname - hostname bound to, default: 'localhost' + * @property {number} port - port bound to, default: next free port + * @property {SERVER} server - server instance + */ +export interface EPHEMERAL_SERVER { + hostname: string; + port: number; + server: SERVER; +} + +/** + * Gets an ephemeral Serverus SERVER on an unused port. + * + * @param options Optional SERVER_OPTIONS + * @returns A LISTEN_SERVER_SETUP object with information and a reference to the server + */ +export async function get_ephemeral_listen_server(options?: SERVER_OPTIONS): Promise { + const server_options = { + ...{ + hostname: 'localhost', + port: get_next_free_port() + }, + ...(options ?? {}) + }; + + const server = new SERVER(server_options); + + const ephemeral_server: EPHEMERAL_SERVER = { + hostname: server_options.hostname, + port: server_options.port, + server: await server.start() + }; + + return ephemeral_server; +} diff --git a/tests/www/echo/___input/index.ts b/tests/www/echo/___input/index.ts new file mode 100644 index 0000000..bdfe527 --- /dev/null +++ b/tests/www/echo/___input/index.ts @@ -0,0 +1,8 @@ +export function GET(_req: Request, meta: Record): Response { + return new Response(meta.params.input ?? '', { + status: 200, + headers: { + 'Content-Type': 'text/plain' + } + }); +} diff --git a/tests/www/permissions/test.ts b/tests/www/permissions/test.ts new file mode 100644 index 0000000..813afc2 --- /dev/null +++ b/tests/www/permissions/test.ts @@ -0,0 +1,22 @@ +export const PRECHECKS: Record) => Promise | Response | undefined> = + {}; + +PRECHECKS.GET = (request: Request, _meta: Record): Response | undefined => { + const secret = request.headers.get('x-secret'); + if (secret !== 'very secret') { + return new Response('Permission Denied', { + status: 400, + headers: { + 'Content-Type': 'text/plain' + } + }); + } +}; +export function GET(_req: Request, _meta: Record): Response { + return new Response('this is secret', { + status: 200, + headers: { + 'Content-Type': 'text/plain' + } + }); +} diff --git a/tests/www/test.md b/tests/www/test.md new file mode 100644 index 0000000..36446e8 --- /dev/null +++ b/tests/www/test.md @@ -0,0 +1,3 @@ +# test + +## this is the test diff --git a/tests/www/test.txt b/tests/www/test.txt new file mode 100644 index 0000000..90bfcb5 --- /dev/null +++ b/tests/www/test.txt @@ -0,0 +1 @@ +this is a test