2025-11-20 16:44:49 -08:00
|
|
|
/**
|
|
|
|
|
* Handles requests for route beneath a directory with a .spa file dropped in it.
|
|
|
|
|
* @module
|
|
|
|
|
*/
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
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";
|
2025-11-20 16:44:49 -08:00
|
|
|
|
|
|
|
|
async function find_spa_file_root(request_path: string): Promise<string | undefined> {
|
2026-01-15 20:33:26 -08:00
|
|
|
const cwd = Deno.cwd();
|
|
|
|
|
|
|
|
|
|
const relative_path = path.relative(cwd, request_path);
|
|
|
|
|
const path_elements = relative_path.split("/").slice(0, -1);
|
2025-11-20 16:44:49 -08:00
|
|
|
|
2025-11-20 16:56:40 -08:00
|
|
|
do {
|
2026-01-15 20:33:26 -08:00
|
|
|
const current_path = path.join(cwd, ...path_elements);
|
|
|
|
|
|
2025-11-20 16:44:49 -08:00
|
|
|
try {
|
2026-01-15 20:33:26 -08:00
|
|
|
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"));
|
2025-11-20 16:44:49 -08:00
|
|
|
|
|
|
|
|
if (spa_file_stat.isFile) {
|
|
|
|
|
return current_path;
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
2025-11-20 16:56:40 -08:00
|
|
|
if (!(error instanceof Deno.errors.NotFound)) {
|
|
|
|
|
throw error;
|
2025-11-20 16:44:49 -08:00
|
|
|
}
|
|
|
|
|
}
|
2026-01-15 20:33:26 -08:00
|
|
|
} while (path_elements.pop());
|
2025-11-20 16:44:49 -08:00
|
|
|
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
export type HTTP_METHOD = "GET" | "PUT" | "POST" | "DELETE" | "HEAD" | "OPTIONS";
|
2025-11-20 16:44:49 -08:00
|
|
|
export type HANDLER_METHOD = (
|
|
|
|
|
request: Request,
|
|
|
|
|
normalized_path: string,
|
2026-01-15 20:33:26 -08:00
|
|
|
server: SERVER,
|
2025-11-20 16:44:49 -08:00
|
|
|
) => Promise<Response | undefined> | Response | undefined;
|
|
|
|
|
export const PRECHECKS: Partial<Record<HTTP_METHOD, PRECHECK[]>> = {};
|
|
|
|
|
export const HANDLERS: Partial<Record<HTTP_METHOD, HANDLER_METHOD>> = {
|
2026-01-15 20:33:26 -08:00
|
|
|
HEAD: async (
|
|
|
|
|
_request: Request,
|
|
|
|
|
normalized_path: string,
|
|
|
|
|
_server: SERVER,
|
|
|
|
|
): Promise<Response | undefined> => {
|
2025-11-20 16:44:49 -08:00
|
|
|
const spa_root = await find_spa_file_root(normalized_path);
|
|
|
|
|
if (!spa_root) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
for await (const index_filename of ["index.html", "index.htm"]) {
|
2025-11-20 16:44:49 -08:00
|
|
|
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) {
|
2026-01-15 20:33:26 -08:00
|
|
|
return new Response("", {
|
2025-11-20 16:44:49 -08:00
|
|
|
headers: {
|
2026-01-15 20:33:26 -08:00
|
|
|
"Content-Type": "text/html",
|
|
|
|
|
"Content-Length": `${index_file_stat.size}`,
|
|
|
|
|
"Last-Modified": `${index_file_stat.mtime ?? index_file_stat.ctime}`,
|
|
|
|
|
},
|
2025-11-20 16:44:49 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof Deno.errors.NotFound) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
GET: async (
|
|
|
|
|
request: Request,
|
|
|
|
|
normalized_path: string,
|
|
|
|
|
_server: SERVER,
|
|
|
|
|
): Promise<Response | undefined> => {
|
2025-11-20 16:44:49 -08:00
|
|
|
const spa_root = await find_spa_file_root(normalized_path);
|
|
|
|
|
if (!spa_root) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
for await (const index_filename of ["index.html", "index.htm"]) {
|
2025-11-20 16:44:49 -08:00
|
|
|
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);
|
|
|
|
|
return new Response(processed, {
|
|
|
|
|
headers: {
|
2026-01-15 20:33:26 -08:00
|
|
|
"Content-Type":
|
|
|
|
|
request.headers.get("accept")?.indexOf("text/plain") !== -1
|
|
|
|
|
? "text/plain"
|
|
|
|
|
: "text/html",
|
|
|
|
|
},
|
2025-11-20 16:44:49 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof Deno.errors.NotFound) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
|
2025-11-20 16:50:36 -08:00
|
|
|
OPTIONS: async (_request: Request, normalized_path: string): Promise<Response | undefined> => {
|
2025-11-20 16:44:49 -08:00
|
|
|
const spa_root = await find_spa_file_root(normalized_path);
|
|
|
|
|
if (!spa_root) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
for await (const index_filename of ["index.html", "index.htm"]) {
|
2025-11-20 16:44:49 -08:00
|
|
|
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) {
|
2026-01-15 20:33:26 -08:00
|
|
|
const allowed = ["GET", "HEAD", "OPTIONS"];
|
2025-11-20 16:44:49 -08:00
|
|
|
|
2026-01-15 20:33:26 -08:00
|
|
|
return new Response("", {
|
2025-11-20 16:44:49 -08:00
|
|
|
headers: {
|
2026-01-15 20:33:26 -08:00
|
|
|
Allow: allowed.sort().join(","),
|
|
|
|
|
"Access-Control-Allow-Origin":
|
|
|
|
|
Deno.env.get("SERVERUS_ACCESS_CONTROL_ALLOW_ORIGIN") ?? "*",
|
|
|
|
|
},
|
2025-11-20 16:44:49 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof Deno.errors.NotFound) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-15 20:33:26 -08:00
|
|
|
},
|
2025-11-20 16:44:49 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
*/
|
2026-01-15 20:33:26 -08:00
|
|
|
export default async function handle_spa_files_in_path(
|
|
|
|
|
request: Request,
|
|
|
|
|
server: SERVER,
|
|
|
|
|
): Promise<Response | undefined> {
|
2025-11-20 16:44:49 -08:00
|
|
|
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);
|
2026-01-15 20:33:26 -08:00
|
|
|
const normalized_path = path.resolve(
|
|
|
|
|
path.normalize(decodeURIComponent(url.pathname)).replace(/^\/+/, ""),
|
|
|
|
|
);
|
2025-11-20 16:44:49 -08:00
|
|
|
|
|
|
|
|
// 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,
|
2026-01-15 20:33:26 -08:00
|
|
|
query,
|
2025-11-20 16:44:49 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|