Compare commits

...

10 Commits

Author SHA1 Message Date
fcba2014c2 file server content type headers 2025-01-20 23:45:07 -07:00
3b1a969145 hack workaround that might not even work, thanks jsr for being unusable 2025-01-20 21:16:19 -07:00
5de39d8573 resolves errors in filerouter dynamic import 2025-01-20 20:56:30 -07:00
fa73b0bdc0 Even more docs, adds response utils to module 2025-01-04 19:27:29 -07:00
5dad5bc0b1 did more docs 2025-01-04 18:38:54 -07:00
0c8d1865ff prep for publish 2025-01-04 18:05:31 -07:00
b73af68989 Implements basic file based router 2025-01-02 16:06:55 -07:00
defd40293f mod docs 2024-12-01 05:31:21 -07:00
3e68ca285b exports types 2024-12-01 03:57:56 -07:00
67ddb71fdd fixes handlers not being compatible with deno handlers 2024-11-11 12:41:56 -07:00
14 changed files with 750 additions and 127 deletions

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

0
LICENSE Normal file → Executable file
View File

68
README.md Normal file → Executable file
View File

@@ -7,24 +7,26 @@ A simple router for Deno.
## Usage
### Basics
```ts
import Router from '@bearmetal/router';
import Router from "@bearmetal/router";
const router = new Router();
router
.route('/users')
.get((ctx) => {
return new Response('GET /users');
.route("/users")
.get((req, ctx) => {
return new Response("GET /users");
})
.post((ctx) => {
return new Response('POST /users');
.post((req, ctx) => {
return new Response("POST /users");
});
Deno.serve(router.handle)
Deno.serve(router.handle);
```
### Middleware
```ts
...
@@ -37,6 +39,7 @@ router.use('/users', async (ctx, next) => {
```
### Nested Routers
```ts
...
@@ -44,10 +47,10 @@ const nestedRouter = new Router();
nestedRouter
.route('/users')
.get((ctx) => {
.get((req, ctx) => {
return new Response('GET /users');
})
.post((ctx) => {
.post((req, ctx) => {
return new Response('POST /users');
});
@@ -56,3 +59,50 @@ router.use('/users', nestedRouter);
...
```
### Static Files
```ts
...
router.serveDirectory('dirname', '/url-root') // files from 'dirname' directory will be available at '/url-root/filename'
// To automatically locate index.html pages:
router.serveDirectory('dirWithIndexHtml', '/indexes', {showIndex: true});
// Will also generate an index if there is no index.html present in the directory
...
```
### File-based Routing
**Note:** _This is an experimental feature and may change in the future.
Currently, JSR does not support dynamic imports for external modules in Deno. In
order to use this feature, you will need to install as an HTTP module (available
soon)._
```ts
import { FileRouter } from "@bearmetal/router";
const router = new FileRouter("dirname");
Deno.listen(router.handle);
// dirname/index.ts - will be accessible at '/'
export default function (req, ctx) {
return new Response("Hello, world!");
}
// dirname/methods.ts - will be accessible at '/methods'
export const handlers = {
get() {
return new Response("Hello, world");
},
post(req, ctx) {
const data = doDataOp(req.body);
return new Response(data);
},
};
// dirname/nestedRouter.ts - will be accessible at '/nestedRouter'
import { router } from "@bearmetal/router";
const router = new Router();
export default router;
```

12
deno.json Normal file → Executable file
View File

@@ -1,15 +1,21 @@
{
"name": "@bearmetal/router",
"description": "A simple router for Deno",
"version": "0.1.0",
"version": "0.2.4",
"stable": true,
"repository": "https://github.com/emmaos/bearmetal",
"files": [
"mod.ts",
"README.md",
"LICENSE"
],
"exports": "./mod.ts",
"exports": {
".": "./mod.ts",
"./types": "./types.ts",
"./util/response": "./util/response.ts"
},
"exclude": [
".vscode/"
],
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.7",
"@std/testing": "jsr:@std/testing@^1.0.4"

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

7
fileRouterTest/test.ts Executable file
View File

@@ -0,0 +1,7 @@
const handlers = {
get: () => {
return new Response("Hello World");
},
};
export default handlers;

85
file_router.ts Executable file
View File

@@ -0,0 +1,85 @@
import Router from "./router.ts";
import type { Handler, RouteConfigurator } from "./types.ts";
import { isRelativePath } from "./util/isRelativePath.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);
}
}
}
/**
* @example
* ```ts
* import {FileRouter} from "@bearmetal/router"
*
* const router = new FileRouter("dirName");
* Deno.listen(router.handle);
* ```
*/
export class FileRouter extends Router {
constructor(root: string, _import?: (path: string) => Promise<unknown>) {
super();
crawl(root, async (path) => {
let relativePath = path.replace(root, "").replace(/index\/?/, "");
if (path.endsWith(".ts") || path.endsWith(".js")) {
if (isRelativePath(path)) {
path = Deno.build.os === "windows"
? `${Deno.cwd()}\\${path.replace(/^.?.?\\/, "")}`
: `${Deno.cwd()}/${path.replace(/^.?.?\//, "")}`;
}
relativePath = relativePath.replace(/\.[tj]s/, "");
const handlers = _import
? await _import(path)
: await import("file://" + path);
if (handlers.default) {
handlers.default instanceof Router
? this.use(relativePath, handlers.default)
: this.route(relativePath).get(handlers.default);
}
if (handlers.handlers) {
for (const [method, handler] of Object.entries(handlers.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 });
});
}
});
if (Deno.env.get("BEARMETAL_ROUTER_DEBUG") === "true") {
this.route("/debug/dump").get((_ctx) => {
console.log("Dumping routes:");
return new Response(
this.routes.map((r) => r.pattern.pathname).join("\n"),
);
});
}
}
}
if (import.meta.main) {
const router = new FileRouter("fileRouterTest");
router.serveDirectory("docs", "/", { showIndex: true });
Deno.serve({
port: 8000,
handler: router.handle,
});
}

8
mod.ts Normal file → Executable file
View File

@@ -1 +1,7 @@
export { Router } from "./router.ts";
/**
* @module
* BearMetal Router, for routing HTTP requests with Deno.serve
*/
export { Router } from "./router.ts";
export { FileRouter } from "./file_router.ts";

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

@@ -49,7 +49,7 @@ describe("Router", () => {
describe("Route Parameters", () => {
it("should handle route parameters", async () => {
router.route("/users/:id")
.get(async (ctx) => new Response(ctx.params.id));
.get(async (_, ctx) => new Response(ctx.params.id));
const req = new Request("http://localhost/users/123", {
method: "GET",
@@ -61,7 +61,7 @@ describe("Router", () => {
it("should handle multiple route parameters", async () => {
router.route("/users/:userId/posts/:postId")
.get(async (ctx) => {
.get(async (_, ctx) => {
return new Response(JSON.stringify(ctx.params));
});
@@ -182,7 +182,7 @@ describe("Router", () => {
it("should handle nested routes with parameters", async () => {
const apiRouter = new Router();
apiRouter.route("/users/:id")
.get(async (ctx) => new Response(ctx.params.id));
.get(async (_, ctx) => new Response(ctx.params.id));
router.use("/api/:version", apiRouter);
@@ -225,7 +225,7 @@ describe("Router", () => {
});
router.route("/test")
.get(async (ctx) => {
.get(async (_, ctx) => {
return new Response(ctx.state.test as string);
});

564
router.ts Normal file → Executable file
View File

@@ -1,40 +1,19 @@
interface RouterContext {
url: URL;
params: Record<string, string | undefined>;
state: Record<string, unknown>;
pattern: URLPattern;
request: Request;
}
type Handler = (ctx: RouterContext) => Promise<Response> | Response;
type Middleware = (ctx: RouterContext, next: () => Promise<Response>) => Promise<Response>;
interface RouteConfig {
pattern: URLPattern;
handlers: { [method: string]: Handler };
}
interface MiddlewareConfig {
pattern: URLPattern;
handler: Middleware;
// Add path for sorting
path: string;
}
interface RouteConfigurator {
get(handler: Handler): RouteConfigurator;
post(handler: Handler): RouteConfigurator;
put(handler: Handler): RouteConfigurator;
delete(handler: Handler): RouteConfigurator;
patch(handler: Handler): RouteConfigurator;
options(handler: Handler): RouteConfigurator;
}
import type {
Handler,
Middleware,
MiddlewareConfig,
RouteConfig,
RouteConfigurator,
RouterContext,
} from "./types.ts";
import { getContentTypeByExtension } from "./util/contentType.ts";
import { NotFound } from "./util/response.ts";
/**
* A simple router for Deno
*
*
* @author Emmaline Autumn
*
*
* @example
* ```ts
* const router = new Router();
@@ -95,55 +74,88 @@ interface RouteConfigurator {
* });
*/
export class Router {
private routes: RouteConfig[] = [];
protected routes: RouteConfig[] = [];
private middleware: MiddlewareConfig[] = [];
/**
* @description defines a new route
* @param path the path to match, uses the same syntax as the URLPattern constructor
* @returns a RouteConfigurator object
*
* @example
* ```ts
* router.route('/users')
* .get((req, ctx) => {
* return new Response('GET /users');
* })
* .post((req, ctx) => {
* return new Response('POST /users');
* });
* ```
*/
route(path: string): RouteConfigurator {
path = path.startsWith('/') ? path : `/${path}`;
path = path.startsWith("/") ? path : `/${path}`;
const pattern = new URLPattern({ pathname: path });
const routeConfig: RouteConfig = {
pattern,
handlers: {}
handlers: {},
};
this.routes.push(routeConfig);
return {
get(handler: Handler) {
routeConfig.handlers['GET'] = handler;
routeConfig.handlers["GET"] = handler;
return this;
},
post(handler: Handler) {
routeConfig.handlers['POST'] = handler;
routeConfig.handlers["POST"] = handler;
return this;
},
put(handler: Handler) {
routeConfig.handlers['PUT'] = handler;
routeConfig.handlers["PUT"] = handler;
return this;
},
delete(handler: Handler) {
routeConfig.handlers['DELETE'] = handler;
routeConfig.handlers["DELETE"] = handler;
return this;
},
patch(handler: Handler) {
routeConfig.handlers['PATCH'] = handler;
routeConfig.handlers["PATCH"] = handler;
return this;
},
options(handler: Handler) {
routeConfig.handlers['OPTIONS'] = handler;
routeConfig.handlers["OPTIONS"] = handler;
return this;
}
},
};
}
use(pathOrMiddleware: string | Middleware, middlewareOrRouter?: Middleware | Router): Router {
/**
* @description adds a middleware to the router
* @param pathOrMiddleware the path to match, uses the same syntax as the URLPattern constructor
* @param middlewareOrRouter the middleware to add, or a Router object to nest routes
* @returns the Router object
*
* @example
* ```ts
* router.use('/users', async (req, ctx, next) => {
* console.log('Using middleware');
* return await next();
* });
* ```
*/
use(
pathOrMiddleware: string | Middleware,
middlewareOrRouter?: Middleware | Router,
): Router {
// Handle the case where only middleware is provided
if (typeof pathOrMiddleware === 'function') {
const pattern = new URLPattern({ pathname: '/*' });
if (typeof pathOrMiddleware === "function") {
const pattern = new URLPattern({ pathname: "/*" });
this.middleware.push({
pattern,
handler: pathOrMiddleware,
path: '/*'
path: "/*",
});
return this;
}
@@ -152,27 +164,30 @@ export class Router {
const middleware = middlewareOrRouter;
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
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const isParameterPath = normalizedPath.includes(':');
const wildcardPath = isParameterPath ?
normalizedPath :
normalizedPath === '/' ?
'/*' :
`${normalizedPath.replace(/\/+$/, '')}/*?`;
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const isParameterPath = normalizedPath.includes(":");
const wildcardPath = isParameterPath
? normalizedPath
: normalizedPath === "/"
? "/*"
: `${normalizedPath.replace(/\/+$/, "")}/*?`;
if (middleware instanceof Router) {
// Merge the nested router's 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 });
this.routes.push({
pattern: newPattern,
handlers: { ...nestedRoute.handlers }
handlers: { ...nestedRoute.handlers },
});
}
@@ -180,11 +195,11 @@ export class Router {
for (const nestedMiddleware of middleware.middleware) {
const combinedPath = this.combinePaths(path, nestedMiddleware.path);
const newPattern = new URLPattern({ pathname: combinedPath });
this.middleware.push({
pattern: newPattern,
handler: nestedMiddleware.handler,
path: combinedPath
path: combinedPath,
});
}
} else {
@@ -193,103 +208,113 @@ export class Router {
this.middleware.push({
pattern,
handler: middleware,
path: wildcardPath
path: wildcardPath,
});
}
return this;
}
/**
* @description handles incoming requests
* @param req the incoming request
* @returns a Response object
*
* @example
* ```ts
* Deno.serve({
* port: 8000,
* handler: router.handle
* });
* ```
*/
handle = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const method = req.method;
// Find the matching route
const matchedRoute = this.findMatchingRoute(url);
if (!matchedRoute) {
return new Response('Not Found', { status: 404 });
return new Response("Not Found", { status: 404 });
}
const handler = matchedRoute.config.handlers[method];
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
const matchingMiddleware = this.getMatchingMiddleware(url);
// Create the base context object
const baseCtx: RouterContext = {
url,
params: {},
state: {},
pattern: matchedRoute.config.pattern,
request: req
request: req,
};
// Combine route parameters with the base context
baseCtx.params = {
...baseCtx.params,
...matchedRoute.params
...matchedRoute.params,
};
// Execute middleware chain
let index = 0;
const executeMiddleware = async (): Promise<Response> => {
if (index < matchingMiddleware.length) {
const { middleware, params } = matchingMiddleware[index++];
// Create a new context for each middleware with its specific parameters
const middlewareCtx: RouterContext = {
...baseCtx,
params: { ...baseCtx.params, ...params },
pattern: middleware.pattern
pattern: middleware.pattern,
};
return await middleware.handler(middlewareCtx, executeMiddleware);
return await middleware.handler(req, middlewareCtx, executeMiddleware);
}
// Final handler gets the accumulated parameters
return await handler(baseCtx);
return await handler(req, baseCtx);
};
try {
return await executeMiddleware();
} catch (error) {
console.error('Error handling request:', error);
return new Response('Internal Server Error', { status: 500 });
console.error("Error handling request:", error);
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) {
const result = route.pattern.exec(url);
if (result) {
return {
config: route,
params: result.pathname.groups
params: result.pathname.groups,
};
}
}
return null;
}
private getMatchingMiddleware(url: URL): Array<{
middleware: MiddlewareConfig;
params: Record<string, string>
private getMatchingMiddleware(url: URL): Array<{
middleware: MiddlewareConfig;
params: Record<string, string>;
}> {
const matches = this.middleware
.map(mw => {
const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + '/');
.map((mw) => {
const result = mw.pattern.exec(url) ?? mw.pattern.exec(url.href + "/");
// console.log(url, mw.pattern, result);
if (result) {
return {
middleware: mw,
params: result.pathname.groups
params: result.pathname.groups,
};
}
return null;
})
.filter((item): item is {
middleware: MiddlewareConfig;
params: Record<string, string>
.filter((item): item is {
middleware: MiddlewareConfig;
params: Record<string, string>;
} => item !== null);
// Sort middleware by path specificity
@@ -298,13 +323,13 @@ export class Router {
const pathB = b.middleware.path;
// Global middleware comes first
if (pathA === '/*' && pathB !== '/*') return -1;
if (pathB === '/*' && pathA !== '/*') return 1;
if (pathA === "/*" && pathB !== "/*") return -1;
if (pathB === "/*" && pathA !== "/*") return 1;
// More specific paths (with more segments) come later
const segmentsA = pathA.split('/').length;
const segmentsB = pathB.split('/').length;
const segmentsA = pathA.split("/").length;
const segmentsB = pathB.split("/").length;
if (segmentsA !== segmentsB) {
return segmentsA - segmentsB;
}
@@ -315,24 +340,343 @@ export class Router {
}
private combinePaths(basePath: string, routePath: string): string {
const normalizedBase = basePath.replace(/\/+$/, '').replace(/^\/*/, '/');
const normalizedRoute = routePath.replace(/^\/*/, '');
return normalizedBase === '/' ? `/${normalizedRoute}` : `${normalizedBase}/${normalizedRoute}`;
const normalizedBase = basePath.replace(/\/+$/, "").replace(/^\/*/, "/");
const normalizedRoute = routePath.replace(/^\/*/, "");
return normalizedBase === "/"
? `/${normalizedRoute}`
: `${normalizedBase}/${normalizedRoute}`;
}
/**
* @description serves a directory as a static website
* @param dir the directory to serve
* @param root the root path to serve the directory from
* @param opts optional options
* @returns the Router object
*
* @example
* ```ts
* router.serveDirectory('/public', './public');
* ```
*/
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);
const filetype = normalizedPath.split(".")[1];
const contentType = getContentTypeByExtension(filetype);
return new Response(file, { headers: { "Content-Type": contentType } });
} 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",
},
});
}
export default Router;
if (import.meta.main) {
console.log('Starting server...');
console.log("Starting server...");
const router = new Router();
router.route('/users')
.get((ctx) => {
return new Response('GET /users');
})
// .post((ctx) => {
// return new Response('POST /users');
// });
router.route("/users")
.get((_ctx) => {
return new Response("GET /users");
});
// .post((ctx) => {
// return new Response('POST /users');
// });
// router.route('/users/:id')
// .get((ctx) => {
@@ -372,14 +716,14 @@ if (import.meta.main) {
// return new Response('POST /*');
// });
router.use('/users', async (_, next) => {
console.log('Using middleware');
router.use("/users", async (_req, _ctx, next) => {
console.log("Using middleware");
return await next();
});
Deno.serve({
port: 8000,
handler: router.handle
handler: router.handle,
});
}

60
types.ts Executable file
View File

@@ -0,0 +1,60 @@
/**
* @module
* BearMetal Router types
*/
/**
* @description a context object for a request
*/
export interface RouterContext {
url: URL;
params: Record<string, string | undefined>;
state: Record<string, unknown>;
pattern: URLPattern;
request: Request;
}
/**
* @description a function that handles incoming requests
*/
export type Handler = (
req: Request,
ctx: RouterContext,
) => Promise<Response> | Response;
/**
* @description a middleware function
*/
export type Middleware = (
req: Request,
ctx: RouterContext,
next: () => Promise<Response>,
) => Promise<Response>;
/**
* @description a route configuration
*/
export interface RouteConfig {
pattern: URLPattern;
handlers: { [method: string]: Handler };
}
/**
* @description a middleware configuration
*/
export interface MiddlewareConfig {
pattern: URLPattern;
handler: Middleware;
path: string;
}
/**
* @description a route configurator
*/
export interface RouteConfigurator {
get(handler: Handler): RouteConfigurator;
post(handler: Handler): RouteConfigurator;
put(handler: Handler): RouteConfigurator;
delete(handler: Handler): RouteConfigurator;
patch(handler: Handler): RouteConfigurator;
options(handler: Handler): RouteConfigurator;
}

26
util/contentType.ts Executable file
View File

@@ -0,0 +1,26 @@
export function getContentTypeByExtension(extension: string) {
switch (extension) {
case "html":
case "htm":
return "text/html";
case "css":
return "text/css";
case "js":
return "text/javascript";
case "json":
return "application/json";
case "png":
return "image/png";
case "jpg":
case "jpeg":
return "image/jpeg";
case "gif":
return "image/gif";
case "svg":
return "image/svg+xml";
case "txt":
return "text/plain";
default:
return "application/octet-stream";
}
}

4
util/isRelativePath.ts Executable file
View File

@@ -0,0 +1,4 @@
export function isRelativePath(path: string) {
return !path.startsWith("/") &&
(path.startsWith("./") || path.startsWith("../"));
}

35
util/response.ts Executable file
View File

@@ -0,0 +1,35 @@
/**
* @module
* BearMetal Router response utilities
*/
/**
* @description a response with a status of 404
*/
export const NotFound = (msg?: string): Response =>
new Response(msg ?? "Not Found", { status: 404 });
/**
* @description a response with a status of 500
*/
export const InternalError = (msg?: string): Response =>
new Response(msg ?? "Internal Server Error", { status: 500 });
/**
* @description a response with a status of 400
*/
export const BadRequest = (msg?: string): Response =>
new Response(msg ?? "Bad Request", { status: 400 });
/**
* @description a response with a status of 401
*/
export const Unauthorized = (msg?: string): Response =>
new Response(msg ?? "Unauthorized", { status: 401 });
/**
* @description a response with a status of 403
*/
export const Forbidden = (msg?: string): Response =>
new Response(msg ?? "Forbidden", { status: 403 });
/**
* @description a response with a status of 200
*/
export const Ok = (msg?: string): Response =>
new Response(msg ?? "OK", { status: 200 });