From d07991bc60942e4318abf8cef3c3778770f589bb Mon Sep 17 00:00:00 2001 From: Andy Burke Date: Thu, 15 Jan 2026 20:33:26 -0800 Subject: [PATCH] feature: add ability to drop a `.spa.static` into the tree --- README.md | 10 +- deno.json | 22 ++--- handlers/spa.ts | 98 +++++++++++------- tests/11_test_spa.test.ts | 109 ++++++++++++++++++--- tests/www/spa/possibly_missing/.spa.static | 0 tests/www/spa/possibly_missing/here.html | 6 ++ 6 files changed, 175 insertions(+), 70 deletions(-) create mode 100644 tests/www/spa/possibly_missing/.spa.static create mode 100644 tests/www/spa/possibly_missing/here.html diff --git a/README.md b/README.md index cb88a8f..6c0ea6e 100644 --- a/README.md +++ b/README.md @@ -78,10 +78,15 @@ If you place a `.spa` file under the root, that directory will try to return an app/ .spa 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 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 These types and interface define the default serverus Typescript handler's expected @@ -136,8 +141,3 @@ TODO: write me - [ ] write examples above - [ ] reload typescript if it is modified on disk - [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 diff --git a/deno.json b/deno.json index 3d68252..da044ae 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.14.2", + "version": "0.15.0", "license": "MIT", "exports": { ".": "./serverus.ts", @@ -21,14 +21,10 @@ "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, @@ -39,16 +35,10 @@ } }, "lint": { - "include": [ - "**/*.ts" - ], + "include": ["**/*.ts"], "rules": { - "tags": [ - "recommended" - ], - "exclude": [ - "no-explicit-any" - ] + "tags": ["recommended"], + "exclude": ["no-explicit-any"] } }, "imports": { diff --git a/handlers/spa.ts b/handlers/spa.ts index 2f44895..4dafc4e 100644 --- a/handlers/spa.ts +++ b/handlers/spa.ts @@ -3,19 +3,34 @@ * @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'; +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); + const cwd = Deno.cwd(); + + const relative_path = path.relative(cwd, request_path); + const path_elements = relative_path.split("/").slice(0, -1); do { + const current_path = path.join(cwd, ...path_elements); + 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) { return current_path; @@ -25,39 +40,41 @@ async function find_spa_file_root(request_path: string): Promise Promise | Response | undefined; export const PRECHECKS: Partial> = {}; export const HANDLERS: Partial> = { - HEAD: async (_request: Request, normalized_path: string, _server: SERVER): Promise => { + 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']) { + 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('', { + return new Response("", { headers: { - 'Content-Type': 'text/html', - 'Content-Length': `${index_file_stat.size}`, - 'Last-Modified': `${index_file_stat.mtime ?? index_file_stat.ctime}` - } + "Content-Type": "text/html", + "Content-Length": `${index_file_stat.size}`, + "Last-Modified": `${index_file_stat.mtime ?? index_file_stat.ctime}`, + }, }); } } catch (error) { @@ -70,13 +87,17 @@ export const HANDLERS: Partial> = { } }, - GET: async (request: Request, normalized_path: string, _server: SERVER): Promise => { + 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']) { + 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); @@ -85,8 +106,11 @@ export const HANDLERS: Partial> = { const processed: string = await load_html_with_ssi(index_file_path); return new Response(processed, { 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) { @@ -105,19 +129,20 @@ export const HANDLERS: Partial> = { return; } - for await (const index_filename of ['index.html', 'index.htm']) { + 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 allowed = ['GET', 'HEAD', 'OPTIONS']; + const allowed = ["GET", "HEAD", "OPTIONS"]; - return new Response('', { + return new Response("", { headers: { - 'Allow': allowed.sort().join(','), - 'Access-Control-Allow-Origin': Deno.env.get('SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN') ?? '*' - } + Allow: allowed.sort().join(","), + "Access-Control-Allow-Origin": + Deno.env.get("SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN") ?? "*", + }, }); } } catch (error) { @@ -128,7 +153,7 @@ export const HANDLERS: Partial> = { throw error; } } - } + }, }; /** @@ -137,7 +162,10 @@ export const HANDLERS: Partial> = { * @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 { +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]; @@ -146,7 +174,9 @@ export default async function handle_spa_files_in_path(request: Request, server: } 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 (!normalized_path.startsWith(Deno.cwd())) { @@ -160,7 +190,7 @@ export default async function handle_spa_files_in_path(request: Request, server: const metadata = { cookies, - query + query, }; for await (const precheck of prechecks) { diff --git a/tests/11_test_spa.test.ts b/tests/11_test_spa.test.ts index b8c7301..8cff2f2 100644 --- a/tests/11_test_spa.test.ts +++ b/tests/11_test_spa.test.ts @@ -1,26 +1,29 @@ -import * as asserts from '@std/assert'; -import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from './helpers.ts'; +import * as asserts from "@std/assert"; +import { EPHEMERAL_SERVER, get_ephemeral_listen_server } from "./helpers.ts"; Deno.test({ - name: 'check that .spa files work', + name: "check that .spa files work", permissions: { env: true, read: true, write: true, - net: true + net: true, }, fn: async () => { let test_server_info: EPHEMERAL_SERVER | null = null; const cwd = Deno.cwd(); try { - Deno.chdir('./tests/www/spa'); + 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 response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/foo/bar/baz`, + { + method: "GET", + }, + ); 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`, { - method: 'GET' - }); + const response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/testing`, + { + method: "GET", + }, + ); 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`, { - method: 'GET' - }); + const response = await fetch( + `http://${test_server_info.hostname}:${test_server_info.port}/testing/blah.html`, + { + method: "GET", + }, + ); const body = await response.text(); @@ -59,5 +68,75 @@ Deno.test({ 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(); + } + } + }, }); diff --git a/tests/www/spa/possibly_missing/.spa.static b/tests/www/spa/possibly_missing/.spa.static new file mode 100644 index 0000000..e69de29 diff --git a/tests/www/spa/possibly_missing/here.html b/tests/www/spa/possibly_missing/here.html new file mode 100644 index 0000000..dce945b --- /dev/null +++ b/tests/www/spa/possibly_missing/here.html @@ -0,0 +1,6 @@ + + + +

here

+ +