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
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;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue