feature: serverus modularly serves up a directory as an HTTP server

This commit is contained in:
Andy Burke 2025-06-19 15:43:01 -07:00
commit 58139b078d
20 changed files with 1449 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
data/
tests/data

90
README.md Normal file
View file

@ -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:
```
<insert instructions for using compiled command line>
```
Container:
```
<insert instructions for using a container>
<our container can and should be very simple: mount a /www into the container and we run the compiled command line to serve it>
```
Deno:
```
<something like this? can you just run the command directly like this?>
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<string, any>
) => undefined | Response | Promise<undefined | Response>;
export type PRECHECKS_TABLE = Record<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', PRECHECK>;
export type ROUTE_HANDLER_METHOD = (request: Request, meta: Record<string, any>) => Promise<Response> | 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.
<TODO: examples>

52
deno.json Normal file
View file

@ -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"
}
}

128
deno.lock generated Normal file
View file

@ -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"
]
}
}

401
handlers/markdown.ts Normal file
View file

@ -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<Response | undefined> {
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(
`
<html>
<head>
<!-- CSS -->
${css}
<!-- END CSS -->
</head>
<body>
<div class="markdown">
${html}
</div>
</body>
</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 = `
<style type="text/css">
pre,
code {
font-family: Menlo, Monaco, "Courier New", monospace;
}
pre {
padding: 0.5rem;
line-height: 1.25;
overflow-x: scroll;
}
@media print {
*,
*:before,
*:after {
background: transparent !important;
color: #000 !important;
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]:after {
content: " (" attr(href) ")";
}
abbr[title]:after {
content: " (" attr(title) ")";
}
a[href^="#"]:after,
a[href^="javascript:"]:after {
content: "";
}
pre,
blockquote {
border: 1px solid #999;
page-break-inside: avoid;
}
thead {
display: table-header-group;
}
tr,
img {
page-break-inside: avoid;
}
img {
max-width: 100% !important;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}
a,
a:visited {
color: #01ff70;
}
a:hover,
a:focus,
a:active {
color: #2ecc40;
}
.retro-no-decoration {
text-decoration: none;
}
html {
font-size: 12px;
}
@media screen and (min-width: 32rem) and (max-width: 48rem) {
html {
font-size: 15px;
}
}
@media screen and (min-width: 48rem) {
html {
font-size: 16px;
}
}
body {
line-height: 1.85;
}
p,
.retro-p {
font-size: 1rem;
margin-bottom: 1.3rem;
}
h1,
.retro-h1,
h2,
.retro-h2,
h3,
.retro-h3,
h4,
.retro-h4 {
margin: 1.414rem 0 0.5rem;
font-weight: inherit;
line-height: 1.42;
}
h1,
.retro-h1 {
margin-top: 0;
font-size: 3.998rem;
}
h2,
.retro-h2 {
font-size: 2.827rem;
}
h3,
.retro-h3 {
font-size: 1.999rem;
}
h4,
.retro-h4 {
font-size: 1.414rem;
}
h5,
.retro-h5 {
font-size: 1.121rem;
}
h6,
.retro-h6 {
font-size: 0.88rem;
}
small,
.retro-small {
font-size: 0.707em;
}
/* https://github.com/mrmrs/fluidity */
img,
canvas,
iframe,
video,
svg,
select,
textarea {
max-width: 100%;
}
html,
body {
background-color: #222;
min-height: 100%;
}
html {
font-size: 18px;
}
body {
color: #fafafa;
font-family: "Courier New";
line-height: 1.45;
margin: 6rem auto 1rem;
max-width: 48rem;
padding: 0.25rem;
}
pre {
background-color: #333;
}
blockquote {
border-left: 3px solid #01ff70;
padding-left: 1rem;
}
</style>
`;
/* 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<TRANSFORM_NAME, TRANSFORMER[]> = {
characters: [
[/&/g, '&amp;'],
[/</g, '&lt;'],
[/>/g, '&gt;'],
[/"/g, '&quot;'],
[/'/g, '&#39;']
],
headings: [
[/^#\s(.+)$/gm, '<h1>$1</h1>\n'],
[/^##\s(.+)$/gm, '<h2>$1</h2>\n'],
[/^###\s(.+)$/gm, '<h3>$1</h3>\n'],
[/^####\s(.+)$/gm, '<h4>$1</h4>\n'],
[/^#####\s(.+)$/gm, '<h5>$1</h5>\n']
],
horizontal_rules: [
[/^----*$/gm, '<hr>\n']
],
list_items: [
[/\n\n([ \t]*)([-\*\.].*?)\n\n/gs, '\n\n<ul>\n$1$2\n</ul>\n\n\n'],
[/^([ \t]*)[-\*\.](\s+.*)$/gm, '<li>$1$2</li>\n']
],
bold: [
[/\*([^\*]+)\*/gm, '<strong>$1</strong>']
],
italic: [
[/_([^_]+)_/gm, '<i>$1</i>']
],
strikethrough: [
[/~([^~]+)~/gm, '<s>$1</s>']
],
code: [
[/```\n([^`]+)\n```/gm, '<pre><code>$1</code></pre>'],
[/```([^`]+)```/gm, '<code>$1</code>']
],
images: [
[/!\[([^\]]+)\]\(([^\)]+)\)/g, '<img src="$2" alt="$1">']
],
links: [
[/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2">$1</a>']
],
breaks: [
[/\s\s\n/g, '\n<br>\n'],
[/\n\n/g, '\n<br>\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<TRANSFORM_NAME, boolean>
): 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;
}

64
handlers/static.ts Normal file
View file

@ -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<Response | undefined> {
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;
}
}

111
handlers/typescript.ts Normal file
View file

@ -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<string, any>
) => undefined | Response | Promise<undefined | Response>;
export type PRECHECKS_TABLE = Record<'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', PRECHECK>;
export type ROUTE_HANDLER_METHOD = (request: Request, meta: Record<string, any>) => Promise<Response> | 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<URLPattern, ROUTE_HANDLER> = new Map<URLPattern, ROUTE_HANDLER>();
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<Response | undefined> {
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<string, string> = 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;
}

190
server.ts Normal file
View file

@ -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> | Response | null | undefined;
type LOGGER = (request: Request, response: Response, processing_time: number) => void | Promise<void>;
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<SERVER>}
*/
public async start(): Promise<SERVER> {
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<Response> => {
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<void> {
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 = [];
}
}

48
serverus.ts Normal file
View file

@ -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();

View file

@ -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();
}
}
}
});

View file

@ -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\>.*?\<\/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\>.*?\<\/html\>/is);
} finally {
Deno.chdir(cwd);
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
}
});

View file

@ -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();
}
}
}
});

View file

@ -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();
}
}
}
});

View file

@ -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();
}
}
}
});

22
tests/handlers/foo.ts Normal file
View file

@ -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'
}
});
}

67
tests/helpers.ts Normal file
View file

@ -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<EPHEMERAL_SERVER> {
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;
}

View file

@ -0,0 +1,8 @@
export function GET(_req: Request, meta: Record<string, any>): Response {
return new Response(meta.params.input ?? '', {
status: 200,
headers: {
'Content-Type': 'text/plain'
}
});
}

View file

@ -0,0 +1,22 @@
export const PRECHECKS: Record<string, (req: Request, meta: Record<string, any>) => Promise<Response | undefined> | Response | undefined> =
{};
PRECHECKS.GET = (request: Request, _meta: Record<string, any>): 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<string, any>): Response {
return new Response('this is secret', {
status: 200,
headers: {
'Content-Type': 'text/plain'
}
});
}

3
tests/www/test.md Normal file
View file

@ -0,0 +1,3 @@
# test
## this is the test

1
tests/www/test.txt Normal file
View file

@ -0,0 +1 @@
this is a test