feature: add ability to drop a .spa.static into the tree

This commit is contained in:
Andy Burke 2026-01-15 20:33:26 -08:00
parent 75439172c9
commit d07991bc60
6 changed files with 175 additions and 70 deletions

View file

@ -78,10 +78,15 @@ If you place a `.spa` file under the root, that directory will try to return an
app/ app/
.spa .spa
index.html index.html
static/
.spa.static
some_static_file.txt
``` ```
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.) 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.)
If you have a subdirectory you still want to allow 404s to happen in, you can place a `.spa.static` file in your tree to skip the typical SPA handler.
### Typescript Handling ### Typescript Handling
These types and interface define the default serverus Typescript handler's expected These types and interface define the default serverus Typescript handler's expected
@ -136,8 +141,3 @@ TODO: write me
- [ ] write examples above - [ ] write examples above
- [ ] reload typescript if it is modified on disk - [ ] reload typescript if it is modified on disk
- [X] wrap markdown converted to html in a div with a class for styling - [X] wrap markdown converted to html in a div with a class for styling
## CHANGELOG
- 0.13.0
- wrap converted markdown in an `html-from-markdown` class

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.14.2", "version": "0.15.0",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
".": "./serverus.ts", ".": "./serverus.ts",
@ -21,14 +21,10 @@
"serverus": "deno --allow-env --allow-read --allow-write --allow-net ./serverus.ts" "serverus": "deno --allow-env --allow-read --allow-write --allow-net ./serverus.ts"
}, },
"test": { "test": {
"exclude": [ "exclude": ["tests/data/"]
"tests/data/"
]
}, },
"fmt": { "fmt": {
"include": [ "include": ["**/*.ts"],
"**/*.ts"
],
"options": { "options": {
"useTabs": true, "useTabs": true,
"lineWidth": 140, "lineWidth": 140,
@ -39,16 +35,10 @@
} }
}, },
"lint": { "lint": {
"include": [ "include": ["**/*.ts"],
"**/*.ts"
],
"rules": { "rules": {
"tags": [ "tags": ["recommended"],
"recommended" "exclude": ["no-explicit-any"]
],
"exclude": [
"no-explicit-any"
]
} }
}, },
"imports": { "imports": {

View file

@ -3,19 +3,34 @@
* @module * @module
*/ */
import * as path from '@std/path'; import * as path from "@std/path";
import { PRECHECK, SERVER } from '../server.ts'; import { PRECHECK, SERVER } from "../server.ts";
import { getCookies } from '@std/http/cookie'; import { getCookies } from "@std/http/cookie";
import { load_html_with_ssi } from './html.ts'; import { load_html_with_ssi } from "./html.ts";
async function find_spa_file_root(request_path: string): Promise<string | undefined> { async function find_spa_file_root(request_path: string): Promise<string | undefined> {
let current_path = Deno.cwd(); const cwd = Deno.cwd();
const relative_path = path.relative(current_path, request_path);
const path_elements = relative_path.split('/').slice(0, -1); const relative_path = path.relative(cwd, request_path);
const path_elements = relative_path.split("/").slice(0, -1);
do { do {
const current_path = path.join(cwd, ...path_elements);
try { try {
const spa_file_stat = await Deno.stat(path.join(current_path, '.spa')); const spa_static_file_stat = await Deno.stat(path.join(current_path, ".spa.static"));
if (spa_static_file_stat.isFile) {
return; // return nothing, we want files under this path to be handled as if static
}
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
}
try {
const spa_file_stat = await Deno.stat(path.join(current_path, ".spa"));
if (spa_file_stat.isFile) { if (spa_file_stat.isFile) {
return current_path; return current_path;
@ -25,39 +40,41 @@ async function find_spa_file_root(request_path: string): Promise<string | undefi
throw error; throw error;
} }
} }
} while (path_elements.pop());
current_path = path.join(current_path, path_elements.shift() ?? '');
} while (path_elements.length);
return undefined; return undefined;
} }
export type HTTP_METHOD = 'GET' | 'PUT' | 'POST' | 'DELETE' | 'HEAD' | 'OPTIONS'; export type HTTP_METHOD = "GET" | "PUT" | "POST" | "DELETE" | "HEAD" | "OPTIONS";
export type HANDLER_METHOD = ( export type HANDLER_METHOD = (
request: Request, request: Request,
normalized_path: string, normalized_path: string,
server: SERVER server: SERVER,
) => Promise<Response | undefined> | Response | undefined; ) => Promise<Response | undefined> | Response | undefined;
export const PRECHECKS: Partial<Record<HTTP_METHOD, PRECHECK[]>> = {}; export const PRECHECKS: Partial<Record<HTTP_METHOD, PRECHECK[]>> = {};
export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = { export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise<Response | undefined> => { HEAD: async (
_request: Request,
normalized_path: string,
_server: SERVER,
): Promise<Response | undefined> => {
const spa_root = await find_spa_file_root(normalized_path); const spa_root = await find_spa_file_root(normalized_path);
if (!spa_root) { if (!spa_root) {
return; return;
} }
for await (const index_filename of ['index.html', 'index.htm']) { for await (const index_filename of ["index.html", "index.htm"]) {
try { try {
const index_file_path = path.join(spa_root, index_filename); const index_file_path = path.join(spa_root, index_filename);
const index_file_stat = await Deno.stat(index_file_path); const index_file_stat = await Deno.stat(index_file_path);
if (index_file_stat.isFile) { if (index_file_stat.isFile) {
return new Response('', { return new Response("", {
headers: { headers: {
'Content-Type': 'text/html', "Content-Type": "text/html",
'Content-Length': `${index_file_stat.size}`, "Content-Length": `${index_file_stat.size}`,
'Last-Modified': `${index_file_stat.mtime ?? index_file_stat.ctime}` "Last-Modified": `${index_file_stat.mtime ?? index_file_stat.ctime}`,
} },
}); });
} }
} catch (error) { } catch (error) {
@ -70,13 +87,17 @@ export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
} }
}, },
GET: async (request: Request, normalized_path: string, _server: SERVER): Promise<Response | undefined> => { GET: async (
request: Request,
normalized_path: string,
_server: SERVER,
): Promise<Response | undefined> => {
const spa_root = await find_spa_file_root(normalized_path); const spa_root = await find_spa_file_root(normalized_path);
if (!spa_root) { if (!spa_root) {
return; return;
} }
for await (const index_filename of ['index.html', 'index.htm']) { for await (const index_filename of ["index.html", "index.htm"]) {
try { try {
const index_file_path = path.join(spa_root, index_filename); const index_file_path = path.join(spa_root, index_filename);
const index_file_stat = await Deno.stat(index_file_path); const index_file_stat = await Deno.stat(index_file_path);
@ -85,8 +106,11 @@ export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
const processed: string = await load_html_with_ssi(index_file_path); const processed: string = await load_html_with_ssi(index_file_path);
return new Response(processed, { return new Response(processed, {
headers: { headers: {
'Content-Type': request.headers.get('accept')?.indexOf('text/plain') !== -1 ? 'text/plain' : 'text/html' "Content-Type":
} request.headers.get("accept")?.indexOf("text/plain") !== -1
? "text/plain"
: "text/html",
},
}); });
} }
} catch (error) { } catch (error) {
@ -105,19 +129,20 @@ export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
return; return;
} }
for await (const index_filename of ['index.html', 'index.htm']) { for await (const index_filename of ["index.html", "index.htm"]) {
try { try {
const index_file_path = path.join(spa_root, index_filename); const index_file_path = path.join(spa_root, index_filename);
const index_file_stat = await Deno.stat(index_file_path); const index_file_stat = await Deno.stat(index_file_path);
if (index_file_stat.isFile) { if (index_file_stat.isFile) {
const allowed = ['GET', 'HEAD', 'OPTIONS']; const allowed = ["GET", "HEAD", "OPTIONS"];
return new Response('', { return new Response("", {
headers: { headers: {
'Allow': allowed.sort().join(','), Allow: allowed.sort().join(","),
'Access-Control-Allow-Origin': Deno.env.get('SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN') ?? '*' "Access-Control-Allow-Origin":
} Deno.env.get("SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN") ?? "*",
},
}); });
} }
} catch (error) { } catch (error) {
@ -128,7 +153,7 @@ export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
throw error; throw error;
} }
} }
} },
}; };
/** /**
@ -137,7 +162,10 @@ export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
* @param request The incoming HTTP request * @param request The incoming HTTP request
* @returns Either a response (a static file was requested and returned properly) or undefined if unhandled. * @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> { 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 method: HTTP_METHOD = request.method.toUpperCase() as HTTP_METHOD;
const handler: HANDLER_METHOD | undefined = HANDLERS[method]; const handler: HANDLER_METHOD | undefined = HANDLERS[method];
@ -146,7 +174,9 @@ export default async function handle_spa_files_in_path(request: Request, server:
} }
const url = new URL(request.url); const url = new URL(request.url);
const normalized_path = path.resolve(path.normalize(decodeURIComponent(url.pathname)).replace(/^\/+/, '')); const normalized_path = path.resolve(
path.normalize(decodeURIComponent(url.pathname)).replace(/^\/+/, ""),
);
// if they're requesting something outside the working dir, just bail // if they're requesting something outside the working dir, just bail
if (!normalized_path.startsWith(Deno.cwd())) { if (!normalized_path.startsWith(Deno.cwd())) {
@ -160,7 +190,7 @@ export default async function handle_spa_files_in_path(request: Request, server:
const metadata = { const metadata = {
cookies, cookies,
query query,
}; };
for await (const precheck of prechecks) { for await (const precheck of prechecks) {

View file

@ -1,26 +1,29 @@
import * as asserts from '@std/assert'; import * as asserts from "@std/assert";
import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from "./helpers.ts";
Deno.test({ Deno.test({
name: 'check that .spa files work', name: "check that .spa files work",
permissions: { permissions: {
env: true, env: true,
read: true, read: true,
write: true, write: true,
net: true net: true,
}, },
fn: async () => { fn: async () => {
let test_server_info: EPHEMERAL_SERVER | null = null; let test_server_info: EPHEMERAL_SERVER | null = null;
const cwd = Deno.cwd(); const cwd = Deno.cwd();
try { try {
Deno.chdir('./tests/www/spa'); Deno.chdir("./tests/www/spa");
test_server_info = await get_ephemeral_listen_server(); test_server_info = await get_ephemeral_listen_server();
{ {
const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/foo/bar/baz`, { const response = await fetch(
method: 'GET' `http://${test_server_info.hostname}:${test_server_info.port}/foo/bar/baz`,
}); {
method: "GET",
},
);
const body = await response.text(); const body = await response.text();
@ -31,9 +34,12 @@ Deno.test({
} }
{ {
const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/testing`, { const response = await fetch(
method: 'GET' `http://${test_server_info.hostname}:${test_server_info.port}/testing`,
}); {
method: "GET",
},
);
const body = await response.text(); const body = await response.text();
@ -43,9 +49,12 @@ Deno.test({
} }
{ {
const response = await fetch(`http://${test_server_info.hostname}:${test_server_info.port}/testing/blah.html`, { const response = await fetch(
method: 'GET' `http://${test_server_info.hostname}:${test_server_info.port}/testing/blah.html`,
}); {
method: "GET",
},
);
const body = await response.text(); const body = await response.text();
@ -59,5 +68,75 @@ Deno.test({
await test_server_info?.server?.stop(); await test_server_info?.server?.stop();
} }
} }
} },
});
Deno.test({
name: "check that .spa.static 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/spa");
test_server_info = await get_ephemeral_listen_server();
{
const response = await fetch(
`http://${test_server_info.hostname}:${test_server_info.port}/foo/bar/baz`,
{
method: "GET",
},
);
const body = await response.text();
asserts.assert(response.ok);
asserts.assert(body);
asserts.assertMatch(body, /SPA PAGE/s);
asserts.assertMatch(body, /test include/s);
}
{
const response = await fetch(
`http://${test_server_info.hostname}:${test_server_info.port}/possibly_missing/here.html`,
{
method: "GET",
},
);
const body = await response.text();
asserts.assert(response.ok);
asserts.assert(body);
asserts.assertMatch(body, /here/s);
}
{
const response = await fetch(
`http://${test_server_info.hostname}:${test_server_info.port}/possibly_missing/missing.html`,
{
method: "GET",
},
);
const body = await response.text();
asserts.assert(!response.ok);
asserts.assert(body);
asserts.assertEquals(response.status, 404);
}
} finally {
Deno.chdir(cwd);
if (test_server_info) {
await test_server_info?.server?.stop();
}
}
},
}); });

View file

@ -0,0 +1,6 @@
<!doctype html>
<html>
<body>
<h1>here</h1>
</body>
</html>