feature: html with SSI

This commit is contained in:
Andy Burke 2025-06-30 15:21:07 -07:00
parent d917b69753
commit 0f65e57539
10 changed files with 210 additions and 27 deletions

View file

@ -9,21 +9,20 @@ bit more like old-school CGI.
Compiled: Compiled:
``` ```
<insert instructions for using compiled command line> [user@machine] ~/ serverus --root ./public
``` ```
Container: Container:
``` ```
<insert instructions for using a 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> <our container can and should be very simple: mount a /www into the container and we run serverus on it>
``` ```
Deno: Deno:
``` ```
<something like this? can you just run the command directly like this?> deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public
deno run jsr:@andyburke/serverus
``` ```
## Overview ## Overview
@ -33,8 +32,8 @@ different types of content with a great deal of control.
The default handlers are: The default handlers are:
- static files - HTML with SSI support
will serve up static files within the root folder eg: <html><body><!-- #include file="./body.html" --></body></html>
- markdown - markdown
will serve markdown as HTML (or raw with an Accept header of text/markdown) will serve markdown as HTML (or raw with an Accept header of text/markdown)
- Typescript - Typescript
@ -42,6 +41,8 @@ The default handlers are:
eg: ./book/:book_id/index.ts) and if they export methods like `GET` and `POST`, 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 they will be called for those requests. there's some additional stuff you can
export to ease typical use cases, covered below. export to ease typical use cases, covered below.
- static files
will serve up static files within the root folder
You just start serverus in a directory (or specify a root) and it tells you where it's You just start serverus in a directory (or specify a root) and it tells you where it's
listening. listening.

View file

@ -1,7 +1,7 @@
{ {
"name": "@andyburke/serverus", "name": "@andyburke/serverus",
"description": "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.", "description": "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.",
"version": "0.6.0", "version": "0.7.0",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./serverus.ts", ".": "./serverus.ts",

111
handlers/html.ts Normal file
View file

@ -0,0 +1,111 @@
/**
* Default handler for returning HTML with SSI support
* @module
*/
import * as path from '@std/path';
// https://stackoverflow.com/a/75205316
const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, ...args: any) => Promise<any>) => {
const promises: Promise<any>[] = [];
str.replace(regex, (match, ...args) => {
promises.push(asyncFn(match, ...args));
return match;
});
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift());
};
const SSI_REGEX: RegExp = /\<\!--\s+#include\s+(?<type>.*?)\s*=\s*["'](?<location>.*?)['"]\s+-->/mg;
async function load_html_with_ssi(html_file: string): Promise<string> {
const html_file_content: string = await Deno.readTextFile(html_file);
const processed: string = await replaceAsync(
html_file_content,
SSI_REGEX,
async (_match, type, location, index): Promise<string | undefined> => {
switch (type) {
case 'file': {
const resolved = path.resolve(location);
if (!resolved.startsWith(Deno.cwd())) {
console.error(
`Cannot include files above the working directory (${Deno.cwd()}): ${location} ${html_file}:${index}`
);
break;
}
return await load_html_with_ssi(resolved);
}
default: {
console.error(`Unknown include type: ${type} ${html_file}:${index}`);
break;
}
}
}
);
return processed;
}
/**
* Handles requests for HTML files, processing any server-side includes
*
* @param request The incoming HTTP request
* @returns Either a response (an HTML file was requested and returned properly) or undefined if unhandled.
*/
export default async function handle_html(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;
}
const initial_extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? '';
const target_path: string = initial_extension.length === 0 ? path.join(normalized_path, 'index.html') : normalized_path;
const extension = path.extname(target_path).slice(1).toLowerCase();
if (extension !== 'html') {
return;
}
try {
const stat = await Deno.stat(target_path);
if (!stat.isFile) {
return;
}
const processed: string = await load_html_with_ssi(target_path);
const accepts: string = request.headers.get('accept') ?? 'text/html';
const headers: Record<string, string> = {};
switch (accepts) {
case 'text/plain':
headers['Content-Type'] = 'text/plain';
break;
case 'text/html':
default:
headers['Content-Type'] = 'text/html';
break;
}
return new Response(processed, {
headers
});
} 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;
}
}

View file

@ -1,9 +1,11 @@
import * as html from './html.ts';
import * as markdown from './markdown.ts'; import * as markdown from './markdown.ts';
import * as static_files from './static.ts'; import * as static_files from './static.ts';
import * as typescript from './typescript.ts'; import * as typescript from './typescript.ts';
export default { export default {
html,
markdown, markdown,
static: static_files, typescript,
typescript static: static_files
}; };

View file

@ -22,24 +22,6 @@ export default async function handle_static_files(request: Request): Promise<Res
try { try {
const stat = await Deno.stat(normalized_path); 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) { if (stat.isFile) {
const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? ''; const extension = path.extname(normalized_path)?.slice(1)?.toLowerCase() ?? '';

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 html 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}/`, {
method: 'GET'
});
const body = await response.text();
asserts.assert(response.ok);
asserts.assert(body);
asserts.assertMatch(body, /\<html\>.*?Include #1.*?Include #2.*?\<\/html\>/is);
} finally {
Deno.chdir(cwd);
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
}
});
Deno.test({
name: 'get html file (text/plain)',
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}/index.html`, {
method: 'GET',
headers: {
'Accept': 'text/plain'
}
});
const body = await response.text();
asserts.assert(response.ok);
asserts.assert(body);
asserts.assertMatch(body, /\<html\>.*?Include #1.*?Include #2.*?\<\/html\>/is);
} finally {
Deno.chdir(cwd);
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
}
});

View file

@ -0,0 +1 @@
<div>Include #2!</div>

9
tests/www/index.html Normal file
View file

@ -0,0 +1,9 @@
<html>
<body>
<p>Hello. An include should follow:</p>
<!-- #include file="./test_include.html" -->
<!-- #include file="./yet_another_include.html" -->
<p>Goodbye. The includes should be above.</p>
</body>
</html>

View file

@ -0,0 +1,3 @@
<div>Include #1</div>
<!-- #include file="./another_include.html" -->

View file

@ -0,0 +1 @@
<div>Include #3</div>