From 9fc91f4cf45a0c70b6dd7d982698bc9e7a288ee0 Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Thu, 20 Nov 2025 16:44:49 -0800 Subject: [PATCH] feature: support `.spa` files for Single Page Apps --- .zed/settings.json | 23 ++++ README.md | 13 +++ deno.json | 22 +++- handlers/html.ts | 2 +- handlers/index.ts | 4 +- handlers/spa.ts | 193 +++++++++++++++++++++++++++++++ tests/11_test_spa.test.ts | 62 ++++++++++ tests/www/spa/.spa | 0 tests/www/spa/index.html | 6 + tests/www/spa/testing/blah.html | 6 + tests/www/spa/testing/index.html | 6 + 11 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 .zed/settings.json create mode 100644 handlers/spa.ts create mode 100644 tests/11_test_spa.test.ts create mode 100644 tests/www/spa/.spa create mode 100644 tests/www/spa/index.html create mode 100644 tests/www/spa/testing/blah.html create mode 100644 tests/www/spa/testing/index.html diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..4f9464f --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,23 @@ +{ + "lsp": { + "deno": { + "settings": { + "deno": { + "enable": true + } + } + } + }, + "languages": { + "TypeScript": { + "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint", "..."] + }, + "TSX": { + "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint", "..."] + }, + "JavaScript": { + "language_servers": ["deno", "!typescript-language-server", "!vtsls", "!eslint", "..."] + } + }, + "formatter": "language_server" +} diff --git a/README.md b/README.md index 544fb6e..cb88a8f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,19 @@ listening. - `SERVERUS_PUT_PATHS_ALLOWED`: a list of ;-separated directories for which file uploads via PUT are allowed - `SERVERUS_DELETE_PATHS_ALLOWED`: a list of ;-separated directories for which file deletions via DELETE are allowed +### Singe-Page Applications (SPA) + +If you place a `.spa` file under the root, that directory will try to return an `index.html` or `index.htm` file that lives in it for any requests that are relative to it. For example: + +``` + www/ + app/ + .spa + index.html +``` + +If you have this file structure (assuming `www` is the root), a `GET` to `/app/foo/bar` will return the `index.html` file in the `app/` directory. (Which would then presumably handle the url in `window.location` appropriately.) + ### Typescript Handling These types and interface define the default serverus Typescript handler's expected diff --git a/deno.json b/deno.json index 3be3a87..fb36792 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.13.0", + "version": "0.14.0", "license": "MIT", "exports": { ".": "./serverus.ts", @@ -21,10 +21,14 @@ "serverus": "deno --allow-env --allow-read --allow-write --allow-net ./serverus.ts" }, "test": { - "exclude": ["tests/data/"] + "exclude": [ + "tests/data/" + ] }, "fmt": { - "include": ["**/*.ts"], + "include": [ + "**/*.ts" + ], "options": { "useTabs": true, "lineWidth": 140, @@ -35,10 +39,16 @@ } }, "lint": { - "include": ["**/*.ts"], + "include": [ + "**/*.ts" + ], "rules": { - "tags": ["recommended"], - "exclude": ["no-explicit-any"] + "tags": [ + "recommended" + ], + "exclude": [ + "no-explicit-any" + ] } }, "imports": { diff --git a/handlers/html.ts b/handlers/html.ts index eead3b7..111c170 100644 --- a/handlers/html.ts +++ b/handlers/html.ts @@ -18,7 +18,7 @@ const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, .. }; const SSI_REGEX: RegExp = /\<\!--\s+#include\s+(?.*?)\s*=\s*["'](?.*?)['"]\s+-->/mg; -async function load_html_with_ssi(html_file: string): Promise { +export 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, diff --git a/handlers/index.ts b/handlers/index.ts index 459d9bb..5bf4f1c 100644 --- a/handlers/index.ts +++ b/handlers/index.ts @@ -2,10 +2,12 @@ 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'; +import * as spa from './spa.ts'; export default { html, markdown, typescript, - static: static_files + static: static_files, + spa }; diff --git a/handlers/spa.ts b/handlers/spa.ts new file mode 100644 index 0000000..75821ee --- /dev/null +++ b/handlers/spa.ts @@ -0,0 +1,193 @@ +/** + * Handles requests for route beneath a directory with a .spa file dropped in it. + * @module + */ + +import * as path from '@std/path'; +import { PRECHECK, SERVER } from '../server.ts'; +import { getCookies } from '@std/http/cookie'; +import { load_html_with_ssi } from './html.ts'; + +async function find_spa_file_root(request_path: string): Promise { + let current_path = Deno.cwd(); + const relative_path = path.relative(current_path, request_path); + const path_elements = relative_path.split('/').slice(0, -1); + + let element; + + while ((element = path_elements.shift())) { + current_path = path.join(current_path, element); + + try { + const spa_file_stat = await Deno.stat(path.join(current_path, '.spa')); + + if (spa_file_stat.isFile) { + return current_path; + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + + throw error; + } + } + + return undefined; +} + +export type HTTP_METHOD = 'GET' | 'PUT' | 'POST' | 'DELETE' | 'HEAD' | 'OPTIONS'; +export type HANDLER_METHOD = ( + request: Request, + normalized_path: string, + server: SERVER +) => Promise | Response | undefined; +export const PRECHECKS: Partial> = {}; +export const HANDLERS: Partial> = { + HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise => { + const spa_root = await find_spa_file_root(normalized_path); + if (!spa_root) { + return; + } + + for await (const index_filename of ['index.html', 'index.htm']) { + try { + const index_file_path = path.join(spa_root, index_filename); + const index_file_stat = await Deno.stat(index_file_path); + + if (index_file_stat.isFile) { + return new Response('', { + headers: { + 'Content-Type': 'text/html', + 'Content-Length': `${index_file_stat.size}`, + 'Last-Modified': `${index_file_stat.mtime ?? index_file_stat.ctime}` + } + }); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + + throw error; + } + } + }, + + GET: async (request: Request, normalized_path: string, _server: SERVER): Promise => { + const spa_root = await find_spa_file_root(normalized_path); + if (!spa_root) { + return; + } + + for await (const index_filename of ['index.html', 'index.htm']) { + try { + const index_file_path = path.join(spa_root, index_filename); + const index_file_stat = await Deno.stat(index_file_path); + + if (index_file_stat.isFile) { + const processed: string = await load_html_with_ssi(index_file_path); + + const accepts = request.headers.get('accept') ?? 'text/html'; + if (!['*/*', 'text/html', 'text/plain'].includes(accepts)) { + return new Response('unsupported accepts header for SPA: ' + accepts, { + status: 400 + }); + } + + return new Response(processed, { + headers: { + 'Content-Type': accepts === '*/*' ? 'text/html' : accepts + } + }); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + + throw error; + } + } + }, + + OPTIONS: async (request: Request, normalized_path: string): Promise => { + const spa_root = await find_spa_file_root(normalized_path); + if (!spa_root) { + return; + } + + for await (const index_filename of ['index.html', 'index.htm']) { + try { + const index_file_path = path.join(spa_root, index_filename); + const index_file_stat = await Deno.stat(index_file_path); + + if (index_file_stat.isFile) { + const accepts = request.headers.get('accept') ?? 'text/html'; + if (!['*/*', 'text/html', 'text/plain'].includes(accepts)) { + return new Response('unsupported accepts header for SPA: ' + accepts, { + status: 400 + }); + } + + const allowed = ['GET', 'HEAD', 'OPTIONS']; + + return new Response('', { + headers: { + 'Allow': allowed.sort().join(','), + 'Access-Control-Allow-Origin': Deno.env.get('SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN') ?? '*' + } + }); + } + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + + throw error; + } + } + } +}; + +/** + * 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_spa_files_in_path(request: Request, server: SERVER): Promise { + const method: HTTP_METHOD = request.method.toUpperCase() as HTTP_METHOD; + const handler: HANDLER_METHOD | undefined = HANDLERS[method]; + + if (!handler) { + return; + } + + const url = new URL(request.url); + const normalized_path = path.resolve(path.normalize(decodeURIComponent(url.pathname)).replace(/^\/+/, '')); + + // if they're requesting something outside the working dir, just bail + if (!normalized_path.startsWith(Deno.cwd())) { + return; + } + + const prechecks: PRECHECK[] = PRECHECKS[method] ?? []; + + const cookies: Record = getCookies(request.headers); + const query = Object.fromEntries(new URL(request.url).searchParams.entries()); + + const metadata = { + cookies, + query + }; + + for await (const precheck of prechecks) { + const error_response: Response | undefined = await precheck(request, metadata); + if (error_response) { + return error_response; + } + } + + return await handler(request, normalized_path, server); +} diff --git a/tests/11_test_spa.test.ts b/tests/11_test_spa.test.ts new file mode 100644 index 0000000..b57a8ff --- /dev/null +++ b/tests/11_test_spa.test.ts @@ -0,0 +1,62 @@ +import * as asserts from '@std/assert'; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; + +Deno.test({ + name: 'check that .spa files work', + 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}/spa/foo/bar/baz`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertMatch(body, /SPA PAGE/s); + } + + { + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/spa/testing`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertMatch(body, /Hello World - Testing/s); + } + + { + const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/spa/testing/blah.html`, { + method: 'GET' + }); + + const body = await response.text(); + + asserts.assert(response.ok); + asserts.assert(body); + asserts.assertMatch(body, /Hello World - Blah/s); + } + } finally { + Deno.chdir(cwd); + if (test_server_info) { + await test_server_info?.server?.stop(); + } + } + } +}); diff --git a/tests/www/spa/.spa b/tests/www/spa/.spa new file mode 100644 index 0000000..e69de29 diff --git a/tests/www/spa/index.html b/tests/www/spa/index.html new file mode 100644 index 0000000..6a697d3 --- /dev/null +++ b/tests/www/spa/index.html @@ -0,0 +1,6 @@ + + + +

SPA PAGE

+ + diff --git a/tests/www/spa/testing/blah.html b/tests/www/spa/testing/blah.html new file mode 100644 index 0000000..0c93ff6 --- /dev/null +++ b/tests/www/spa/testing/blah.html @@ -0,0 +1,6 @@ + + + +

Hello World - Blah

+ + diff --git a/tests/www/spa/testing/index.html b/tests/www/spa/testing/index.html new file mode 100644 index 0000000..a5e4505 --- /dev/null +++ b/tests/www/spa/testing/index.html @@ -0,0 +1,6 @@ + + + +

Hello World - Testing

+ +