feature: support .spa files for Single Page Apps

This commit is contained in:
Andy Burke 2025-11-20 16:44:49 -08:00
parent 604090d8b8
commit 9fc91f4cf4
11 changed files with 329 additions and 8 deletions

23
.zed/settings.json Normal file
View file

@ -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"
}

View file

@ -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

View file

@ -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": {

View file

@ -18,7 +18,7 @@ const replaceAsync = async (str: string, regex: RegExp, asyncFn: (match: any, ..
};
const SSI_REGEX: RegExp = /\<\!--\s+#include\s+(?<type>.*?)\s*=\s*["'](?<location>.*?)['"]\s+-->/mg;
async function load_html_with_ssi(html_file: string): Promise<string> {
export 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,

View file

@ -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
};

193
handlers/spa.ts Normal file
View file

@ -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<string | undefined> {
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> | Response | undefined;
export const PRECHECKS: Partial<Record<HTTP_METHOD, PRECHECK[]>> = {};
export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise<Response | undefined> => {
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<Response | undefined> => {
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<Response | undefined> => {
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<Response | undefined> {
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<string, string> = 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);
}

62
tests/11_test_spa.test.ts Normal file
View file

@ -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();
}
}
}
});

0
tests/www/spa/.spa Normal file
View file

6
tests/www/spa/index.html Normal file
View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>SPA PAGE</h1>
</body>
</html>

View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hello World - Blah</h1>
</body>
</html>

View file

@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hello World - Testing</h1>
</body>
</html>