feature: add ability to drop a .spa.static into the tree
This commit is contained in:
parent
75439172c9
commit
d07991bc60
6 changed files with 175 additions and 70 deletions
10
README.md
10
README.md
|
|
@ -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
|
|
||||||
|
|
|
||||||
22
deno.json
22
deno.json
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
0
tests/www/spa/possibly_missing/.spa.static
Normal file
0
tests/www/spa/possibly_missing/.spa.static
Normal file
6
tests/www/spa/possibly_missing/here.html
Normal file
6
tests/www/spa/possibly_missing/here.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<h1>here</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue