From 0f65e57539548d5e495f5b468dfe5063ddcd2226 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Mon, 30 Jun 2025 15:21:07 -0700 Subject: [PATCH] feature: html with SSI --- README.md | 13 ++-- deno.json | 2 +- handlers/html.ts | 111 ++++++++++++++++++++++++++++ handlers/index.ts | 6 +- handlers/static.ts | 18 ----- tests/08_test_html_includes.test.ts | 73 ++++++++++++++++++ tests/www/another_include.html | 1 + tests/www/index.html | 9 +++ tests/www/test_include.html | 3 + tests/www/yet_another_include.html | 1 + 10 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 handlers/html.ts create mode 100644 tests/08_test_html_includes.test.ts create mode 100644 tests/www/another_include.html create mode 100644 tests/www/index.html create mode 100644 tests/www/test_include.html create mode 100644 tests/www/yet_another_include.html diff --git a/README.md b/README.md index 9973c60..09f00a1 100644 --- a/README.md +++ b/README.md @@ -9,21 +9,20 @@ bit more like old-school CGI. Compiled: ``` - +[user@machine] ~/ serverus --root ./public ``` Container: ``` - + ``` Deno: ``` - -deno run jsr:@andyburke/serverus +deno --allow-env --allow-read --allow-write --allow-net jsr:@andyburke/serverus --root ./public ``` ## Overview @@ -33,8 +32,8 @@ 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 + - HTML with SSI support + eg: - markdown will serve markdown as HTML (or raw with an Accept header of text/markdown) - Typescript @@ -42,6 +41,8 @@ The default handlers are: 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. + - 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 listening. diff --git a/deno.json b/deno.json index 276878e..93b4af5 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "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.", - "version": "0.6.0", + "version": "0.7.0", "license": "MIT", "exports": { ".": "./serverus.ts", diff --git a/handlers/html.ts b/handlers/html.ts new file mode 100644 index 0000000..aeb545a --- /dev/null +++ b/handlers/html.ts @@ -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) => { + const promises: Promise[] = []; + 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+(?.*?)\s*=\s*["'](?.*?)['"]\s+-->/mg; +async function load_html_with_ssi(html_file: string): Promise { + 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 => { + 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 { + 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 = {}; + 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; + } +} diff --git a/handlers/index.ts b/handlers/index.ts index 410f0fb..459d9bb 100644 --- a/handlers/index.ts +++ b/handlers/index.ts @@ -1,9 +1,11 @@ +import * as html from './html.ts'; import * as markdown from './markdown.ts'; import * as static_files from './static.ts'; import * as typescript from './typescript.ts'; export default { + html, markdown, - static: static_files, - typescript + typescript, + static: static_files }; diff --git a/handlers/static.ts b/handlers/static.ts index 2769ee2..cca2169 100644 --- a/handlers/static.ts +++ b/handlers/static.ts @@ -22,24 +22,6 @@ export default async function handle_static_files(request: Request): Promise { + 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, /\.*?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, /\.*?Include #1.*?Include #2.*?\<\/html\>/is); + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/www/another_include.html b/tests/www/another_include.html new file mode 100644 index 0000000..2fbede4 --- /dev/null +++ b/tests/www/another_include.html @@ -0,0 +1 @@ +
Include #2!
diff --git a/tests/www/index.html b/tests/www/index.html new file mode 100644 index 0000000..8b20a60 --- /dev/null +++ b/tests/www/index.html @@ -0,0 +1,9 @@ + + +

Hello. An include should follow:

+ + + +

Goodbye. The includes should be above.

+ + diff --git a/tests/www/test_include.html b/tests/www/test_include.html new file mode 100644 index 0000000..1218707 --- /dev/null +++ b/tests/www/test_include.html @@ -0,0 +1,3 @@ +
Include #1
+ + diff --git a/tests/www/yet_another_include.html b/tests/www/yet_another_include.html new file mode 100644 index 0000000..0a94f28 --- /dev/null +++ b/tests/www/yet_another_include.html @@ -0,0 +1 @@ +
Include #3