Implements basic file based router

This commit is contained in:
2025-01-02 16:06:55 -07:00
parent defd40293f
commit b73af68989
11 changed files with 464 additions and 72 deletions

0
.vscode/settings.json vendored Normal file → Executable file
View File

0
LICENSE Normal file → Executable file
View File

0
README.md Normal file → Executable file
View File

0
deno.json Normal file → Executable file
View File

0
deno.lock generated Normal file → Executable file
View File

363
file_router.ts Executable file
View File

@@ -0,0 +1,363 @@
import Router from "./router.ts";
import type { Handler, RouteConfigurator } from "./types.ts";
import { NotFound } from "./util/response.ts";
function crawl(dir: string, callback: (path: string) => void) {
for (const entry of Deno.readDirSync(dir)) {
const path = Deno.build.os === "windows"
? `${dir}\\${entry.name}`
: `${dir}/${entry.name}`;
if (entry.isDirectory) {
crawl(path, callback);
} else {
callback(path);
}
}
}
export class FileRouter extends Router {
constructor(root: string) {
super();
crawl(root, async (path) => {
let relativePath = path.replace(root, "");
if (path.endsWith(".ts") || path.endsWith(".js")) {
relativePath = relativePath.replace(/\.[tj]s/, "");
const asdf = await import(path);
if (asdf.default) {
asdf.default instanceof Router
? this.use(relativePath, asdf.default)
: this.route(relativePath).get(asdf.default);
}
if (asdf.handlers) {
for (const [method, handler] of Object.entries(asdf.handlers)) {
this.route(relativePath)[method as keyof RouteConfigurator](
handler as Handler,
);
}
}
}
if (path.endsWith(".htm") || path.endsWith(".html")) {
const headers = new Headers();
headers.set("Content-Type", "text/html");
this.route(relativePath).get((_ctx) => {
return new Response(Deno.readTextFileSync(path), { headers });
});
}
});
}
serveDirectory(dir: string, root: string, opts?: { showIndex: boolean }) {
this.route(root + "*").get(async (_req, ctx) => {
const { showIndex } = opts ?? { showIndex: false };
let normalizedPath = (dir + "/" +
ctx.url.pathname.replace(new RegExp("^" + root), "")).trim().replace(
"//",
"/",
);
normalizedPath = normalizedPath.replace(
/\/\s?$/,
"",
);
let fileInfo: Deno.FileInfo;
try {
fileInfo = await Deno.stat(normalizedPath);
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
return NotFound();
} else {
throw error;
}
}
if (fileInfo.isDirectory) {
if (!showIndex) return NotFound();
normalizedPath += "/index.html";
}
try {
const file = await Deno.readFile(normalizedPath);
return new Response(file);
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
return showIndex ? generateIndex(normalizedPath) : NotFound();
}
throw e;
}
});
}
}
async function generateIndex(dir: string) {
dir = dir.replace(/index\.html$/, "");
const items: Deno.DirEntry[] = [];
for await (const entry of Deno.readDir(dir)) {
items.push(entry);
}
const fileIcon = `
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg1"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
sodipodi:docname="file icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="17.318182"
inkscape:cx="10.018373"
inkscape:cy="28.207349"
inkscape:window-width="3440"
inkscape:window-height="1417"
inkscape:window-x="-8"
inkscape:window-y="1432"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
d="m 3.5196818,1.7373617 c -0.3351528,0 -0.605131,0.2694614 -0.605131,0.6046142 v 8.0160481 c 0,0.335153 0.2699782,0.604614 0.605131,0.604614 h 5.6606364 c 0.3351528,0 0.605131,-0.269461 0.605131,-0.604614 V 3.7372396 L 7.7850545,1.7373617 Z"
style="fill:#4d1f6d;stroke-width:0.264583"
id="path4" />
<path
d="M 9.6529679,3.8697209 7.6525732,1.869843 v 1.3952637 c 0,0.3351528 0.2699782,0.6046142 0.605131,0.6046142 z"
style="fill:#918b93;fill-opacity:1;stroke:#4d1f6d;stroke-width:0.265;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="path5" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.285269;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect5"
width="5.5833006"
height="0.5505569"
x="3.5583498"
y="4.0022206" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.272493;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect6"
width="5.0944114"
height="0.5505569"
x="3.5583498"
y="4.8455539" />
<rect
style="fill:#463e4b;fill-opacity:1;stroke:none;stroke-width:0.279751;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect7"
width="5.3694115"
height="0.5505569"
x="3.5583498"
y="5.682776" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.279751;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect8"
width="5.3694115"
height="0.5505569"
x="3.5583498"
y="5.6888871" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.285269;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect9"
width="5.5833006"
height="0.5505569"
x="3.5583498"
y="6.5322208" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.272493;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect10"
width="5.0944114"
height="0.5505569"
x="3.5583498"
y="7.3755541" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.279751;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect11"
width="5.3694115"
height="0.5505569"
x="3.5583498"
y="8.2188873" />
<rect
style="fill:#918b93;fill-opacity:1;stroke:none;stroke-width:0.217416;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;paint-order:markers fill stroke"
id="rect12"
width="3.2431545"
height="0.5505569"
x="5.8984962"
y="9.7313871" />
</g>
</svg>
`;
const folderIcon = `
<svg
width="48"
height="48"
viewBox="0 0 12.7 12.7"
version="1.1"
id="svg1"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
sodipodi:docname="folder icon.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#505050"
bordercolor="#eeeeee"
borderopacity="1"
inkscape:showpageshadow="0"
inkscape:pageopacity="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:document-units="mm"
inkscape:zoom="12.245804"
inkscape:cx="17.924507"
inkscape:cy="30.990207"
inkscape:window-width="3440"
inkscape:window-height="1417"
inkscape:window-x="-8"
inkscape:window-y="1432"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<rect
style="fill:#463e4b;fill-opacity:1;stroke-width:0.264583"
id="rect3"
width="10.867838"
height="7.4972959"
x="0.91608107"
y="2.1476252"
ry="1.1883322" />
<rect
style="fill:#4d1f6d;fill-opacity:1;stroke-width:0.264583"
id="rect1"
width="10.867838"
height="7.4972959"
x="0.91608107"
y="2.601352"
ry="1.1883322" />
<rect
style="fill:#4d1f6d;fill-opacity:1;stroke-width:0.264583"
id="rect2"
width="4.2779961"
height="4.0619354"
x="0.91608107"
y="1.5426561"
ry="1.1883322" />
</g>
</svg>
`;
const template = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${dir}</title>
<style>
* {
font-family: sans-serif;
}
ul {
margin: 0;
padding: 0;
}
li {
list-style: none;
display: flex;
align-items: center;
gap: 1rem;
padding: 4px 2px;
&:nth-child(odd) {
background-color: rgba(0,0,0, .2)
}
& svg {
height: 1.5rem;
}
}
a {
color: white;
}
@media (prefers-color-scheme: dark) {
:root {
background-color: #1b1220;
color: #dcdcdc
}
ul {
background-color: rgba(255,255,255,.1)
}
}
</style>
</head>
<body>
<h1>${dir}</h1>
<ul>
${
items
.sort((a, b) => {
if (a.isDirectory && b.isDirectory) return a.name > b.name ? 1 : -1;
if (a.isDirectory) return -1;
if (b.isDirectory) return 1;
return a.name > b.name ? 1 : -1;
})
.map((e) =>
`<li>${e.isFile ? fileIcon : folderIcon}<a href="${
"/" + dir.replace(/^\//, "") + e.name
}">${e.name}</a></li>`
)
.join("\n") || "<li>Directory is empty</li>"
}
</ul>
</body>
</html>
`;
return new Response(template, {
headers: {
"Content-Type": "text/html",
},
});
}
if (import.meta.main) {
const router = new FileRouter("fileRouterTest");
router.serveDirectory("things", "/things", { showIndex: true });
Deno.serve({
port: 8000,
handler: router.handle,
});
}

0
mod.ts Normal file → Executable file
View File

0
router.test.ts Normal file → Executable file
View File

129
router.ts Normal file → Executable file
View File

@@ -1,4 +1,11 @@
import type { Handler, Middleware, MiddlewareConfig, RouteConfig, RouteConfigurator, RouterContext } from "./types.ts"; import type {
Handler,
Middleware,
MiddlewareConfig,
RouteConfig,
RouteConfigurator,
RouterContext,
} from "./types.ts";
/** /**
* A simple router for Deno * A simple router for Deno
@@ -69,51 +76,54 @@ export class Router {
private middleware: MiddlewareConfig[] = []; private middleware: MiddlewareConfig[] = [];
route(path: string): RouteConfigurator { route(path: string): RouteConfigurator {
path = path.startsWith('/') ? path : `/${path}`; path = path.startsWith("/") ? path : `/${path}`;
const pattern = new URLPattern({ pathname: path }); const pattern = new URLPattern({ pathname: path });
const routeConfig: RouteConfig = { const routeConfig: RouteConfig = {
pattern, pattern,
handlers: {} handlers: {},
}; };
this.routes.push(routeConfig); this.routes.push(routeConfig);
return { return {
get(handler: Handler) { get(handler: Handler) {
routeConfig.handlers['GET'] = handler; routeConfig.handlers["GET"] = handler;
return this; return this;
}, },
post(handler: Handler) { post(handler: Handler) {
routeConfig.handlers['POST'] = handler; routeConfig.handlers["POST"] = handler;
return this; return this;
}, },
put(handler: Handler) { put(handler: Handler) {
routeConfig.handlers['PUT'] = handler; routeConfig.handlers["PUT"] = handler;
return this; return this;
}, },
delete(handler: Handler) { delete(handler: Handler) {
routeConfig.handlers['DELETE'] = handler; routeConfig.handlers["DELETE"] = handler;
return this; return this;
}, },
patch(handler: Handler) { patch(handler: Handler) {
routeConfig.handlers['PATCH'] = handler; routeConfig.handlers["PATCH"] = handler;
return this; return this;
}, },
options(handler: Handler) { options(handler: Handler) {
routeConfig.handlers['OPTIONS'] = handler; routeConfig.handlers["OPTIONS"] = handler;
return this; return this;
} },
}; };
} }
use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router): Router { use(
pathOrMiddleware: string | Middleware,
middlewareOrRouter?: Middleware | Router,
): Router {
// Handle the case where only middleware is provided // Handle the case where only middleware is provided
if (typeof pathOrMiddleware === 'function') { if (typeof pathOrMiddleware === "function") {
const pattern = new URLPattern({ pathname: '/*' }); const pattern = new URLPattern({ pathname: "/*" });
this.middleware.push({ this.middleware.push({
pattern, pattern,
handler: pathOrMiddleware, handler: pathOrMiddleware,
path: '/*' path: "/*",
}); });
return this; return this;
} }
@@ -122,27 +132,30 @@ export class Router {
const middleware = middlewareOrRouter; const middleware = middlewareOrRouter;
if (!middleware) { if (!middleware) {
throw new Error('Middleware or Router is required'); throw new Error("Middleware or Router is required");
} }
// Normalize the path to handle both exact matches and nested paths // Normalize the path to handle both exact matches and nested paths
const normalizedPath = path.startsWith('/') ? path : `/${path}`; const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const isParameterPath = normalizedPath.includes(':'); const isParameterPath = normalizedPath.includes(":");
const wildcardPath = isParameterPath ? const wildcardPath = isParameterPath
normalizedPath : ? normalizedPath
normalizedPath === '/' ? : normalizedPath === "/"
'/*' : ? "/*"
`${normalizedPath.replace(/\/+$/, '')}/*?`; : `${normalizedPath.replace(/\/+$/, "")}/*?`;
if (middleware instanceof Router) { if (middleware instanceof Router) {
// Merge the nested router's routes // Merge the nested router's routes
for (const nestedRoute of middleware.routes) { for (const nestedRoute of middleware.routes) {
const combinedPath = this.combinePaths(path, nestedRoute.pattern.pathname); const combinedPath = this.combinePaths(
path,
nestedRoute.pattern.pathname,
);
const newPattern = new URLPattern({ pathname: combinedPath }); const newPattern = new URLPattern({ pathname: combinedPath });
this.routes.push({ this.routes.push({
pattern: newPattern, pattern: newPattern,
handlers: { ...nestedRoute.handlers } handlers: { ...nestedRoute.handlers },
}); });
} }
@@ -154,7 +167,7 @@ export class Router {
this.middleware.push({ this.middleware.push({
pattern: newPattern, pattern: newPattern,
handler: nestedMiddleware.handler, handler: nestedMiddleware.handler,
path: combinedPath path: combinedPath,
}); });
} }
} else { } else {
@@ -163,7 +176,7 @@ export class Router {
this.middleware.push({ this.middleware.push({
pattern, pattern,
handler: middleware, handler: middleware,
path: wildcardPath path: wildcardPath,
}); });
} }
@@ -177,12 +190,12 @@ export class Router {
// Find the matching route // Find the matching route
const matchedRoute = this.findMatchingRoute(url); const matchedRoute = this.findMatchingRoute(url);
if (!matchedRoute) { if (!matchedRoute) {
return new Response('Not Found', { status: 404 }); return new Response("Not Found", { status: 404 });
} }
const handler = matchedRoute.config.handlers[method]; const handler = matchedRoute.config.handlers[method];
if (!handler) { if (!handler) {
return new Response('Method Not Allowed', { status: 405 }); return new Response("Method Not Allowed", { status: 405 });
} }
// Get matching middleware and sort by path specificity // Get matching middleware and sort by path specificity
@@ -194,13 +207,13 @@ export class Router {
params: {}, params: {},
state: {}, state: {},
pattern: matchedRoute.config.pattern, pattern: matchedRoute.config.pattern,
request: req request: req,
}; };
// Combine route parameters with the base context // Combine route parameters with the base context
baseCtx.params = { baseCtx.params = {
...baseCtx.params, ...baseCtx.params,
...matchedRoute.params ...matchedRoute.params,
}; };
// Execute middleware chain // Execute middleware chain
@@ -212,7 +225,7 @@ export class Router {
const middlewareCtx: RouterContext = { const middlewareCtx: RouterContext = {
...baseCtx, ...baseCtx,
params: { ...baseCtx.params, ...params }, params: { ...baseCtx.params, ...params },
pattern: middleware.pattern pattern: middleware.pattern,
}; };
return await middleware.handler(middlewareCtx, executeMiddleware); return await middleware.handler(middlewareCtx, executeMiddleware);
} }
@@ -223,18 +236,22 @@ export class Router {
try { try {
return await executeMiddleware(); return await executeMiddleware();
} catch (error) { } catch (error) {
console.error('Error handling request:', error); console.error("Error handling request:", error);
return new Response('Internal Server Error', { status: 500 }); return new Response("Internal Server Error", { status: 500 });
}
} }
};
private findMatchingRoute(url: URL): { config: RouteConfig; params: Record<string, string | undefined> } | null { private findMatchingRoute(
url: URL,
):
| { config: RouteConfig; params: Record<string, string | undefined> }
| null {
for (const route of this.routes) { for (const route of this.routes) {
const result = route.pattern.exec(url); const result = route.pattern.exec(url);
if (result) { if (result) {
return { return {
config: route, config: route,
params: result.pathname.groups params: result.pathname.groups,
}; };
} }
} }
@@ -243,23 +260,23 @@ export class Router {
private getMatchingMiddleware(url: URL): Array<{ private getMatchingMiddleware(url: URL): Array<{
middleware: MiddlewareConfig; middleware: MiddlewareConfig;
params: Record<string, string> params: Record<string, string>;
}> { }> {
const matches = this.middleware const matches = this.middleware
.map(mw => { .map((mw) => {
const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + '/'); const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + "/");
// console.log(url, mw.pattern, result); // console.log(url, mw.pattern, result);
if (result) { if (result) {
return { return {
middleware: mw, middleware: mw,
params: result.pathname.groups params: result.pathname.groups,
}; };
} }
return null; return null;
}) })
.filter((item): item is { .filter((item): item is {
middleware: MiddlewareConfig; middleware: MiddlewareConfig;
params: Record<string, string> params: Record<string, string>;
} => item !== null); } => item !== null);
// Sort middleware by path specificity // Sort middleware by path specificity
@@ -268,12 +285,12 @@ export class Router {
const pathB = b.middleware.path; const pathB = b.middleware.path;
// Global middleware comes first // Global middleware comes first
if (pathA === '/*' && pathB !== '/*') return -1; if (pathA === "/*" && pathB !== "/*") return -1;
if (pathB === '/*' && pathA !== '/*') return 1; if (pathB === "/*" && pathA !== "/*") return 1;
// More specific paths (with more segments) come later // More specific paths (with more segments) come later
const segmentsA = pathA.split('/').length; const segmentsA = pathA.split("/").length;
const segmentsB = pathB.split('/').length; const segmentsB = pathB.split("/").length;
if (segmentsA !== segmentsB) { if (segmentsA !== segmentsB) {
return segmentsA - segmentsB; return segmentsA - segmentsB;
@@ -285,21 +302,23 @@ export class Router {
} }
private combinePaths(basePath: string, routePath: string): string { private combinePaths(basePath: string, routePath: string): string {
const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/'); const normalizedBase = basePath.replace(/\/+$/, "").replace(/^\/*/, "/");
const normalizedRoute = routePath.replace(/^\/*/, ''); const normalizedRoute = routePath.replace(/^\/*/, "");
return normalizedBase === '/' ? `/${normalizedRoute}` : `${normalizedBase}/${normalizedRoute}`; return normalizedBase === "/"
? `/${normalizedRoute}`
: `${normalizedBase}/${normalizedRoute}`;
} }
} }
export default Router; export default Router;
if (import.meta.main) { if (import.meta.main) {
console.log('Starting server...'); console.log("Starting server...");
const router = new Router(); const router = new Router();
router.route('/users') router.route("/users")
.get((_ctx) => { .get((_ctx) => {
return new Response('GET /users'); return new Response("GET /users");
}) });
// .post((ctx) => { // .post((ctx) => {
// return new Response('POST /users'); // return new Response('POST /users');
// }); // });
@@ -342,14 +361,14 @@ if (import.meta.main) {
// return new Response('POST /*'); // return new Response('POST /*');
// }); // });
router.use('/users', async (_, next) => { router.use("/users", async (_, next) => {
console.log('Using middleware'); console.log("Using middleware");
return await next(); return await next();
}); });
Deno.serve({ Deno.serve({
port: 8000, port: 8000,
handler: router.handle handler: router.handle,
}); });
} }

10
types.ts Normal file → Executable file
View File

@@ -11,8 +11,14 @@ export interface RouterContext {
request: Request; request: Request;
} }
export type Handler = (req: Request, ctx: RouterContext) => Promise<Response> | Response; export type Handler = (
export type Middleware = (ctx: RouterContext, next: () => Promise<Response>) => Promise<Response>; req: Request,
ctx: RouterContext,
) => Promise<Response> | Response;
export type Middleware = (
ctx: RouterContext,
next: () => Promise<Response>,
) => Promise<Response>;
export interface RouteConfig { export interface RouteConfig {
pattern: URLPattern; pattern: URLPattern;

4
util/response.ts Executable file
View File

@@ -0,0 +1,4 @@
export const NotFound = (msg?: string) =>
new Response(msg ?? "Not Found", { status: 404 });
export const InternalError = (msg?: string) =>
new Response(msg ?? "Internal Server Error", { status: 500 });