feature: serverus modularly serves up a directory as an HTTP server
This commit is contained in:
commit
58139b078d
20 changed files with 1449 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
data/
|
||||
tests/data
|
90
README.md
Normal file
90
README.md
Normal 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
52
deno.json
Normal 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
128
deno.lock
generated
Normal 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
401
handlers/markdown.ts
Normal 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, '&'],
|
||||
[/</g, '<'],
|
||||
[/>/g, '>'],
|
||||
[/"/g, '"'],
|
||||
[/'/g, ''']
|
||||
],
|
||||
|
||||
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
64
handlers/static.ts
Normal 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
111
handlers/typescript.ts
Normal 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
190
server.ts
Normal 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
48
serverus.ts
Normal 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();
|
35
tests/01_get_static_file.test.ts
Normal file
35
tests/01_get_static_file.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
73
tests/02_get_markdown_file.test.ts
Normal file
73
tests/02_get_markdown_file.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
36
tests/03_get_typescript_route.test.ts
Normal file
36
tests/03_get_typescript_route.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
48
tests/04_test_overriding_handlers.test.ts
Normal file
48
tests/04_test_overriding_handlers.test.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
48
tests/05_check_typescript_prechecks.test.ts
Normal file
48
tests/05_check_typescript_prechecks.test.ts
Normal 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
22
tests/handlers/foo.ts
Normal 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
67
tests/helpers.ts
Normal 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;
|
||||
}
|
8
tests/www/echo/___input/index.ts
Normal file
8
tests/www/echo/___input/index.ts
Normal 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'
|
||||
}
|
||||
});
|
||||
}
|
22
tests/www/permissions/test.ts
Normal file
22
tests/www/permissions/test.ts
Normal 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
3
tests/www/test.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# test
|
||||
|
||||
## this is the test
|
1
tests/www/test.txt
Normal file
1
tests/www/test.txt
Normal file
|
@ -0,0 +1 @@
|
|||
this is a test
|
Loading…
Add table
Add a link
Reference in a new issue