Compare commits

...

7 Commits

17 changed files with 719 additions and 24 deletions

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

0
LICENSE Executable file → Normal file
View File

5
README.md Executable file → Normal file
View File

@@ -73,6 +73,11 @@ router.serveDirectory('dirWithIndexHtml', '/indexes', {showIndex: true});
### 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";

7
deno.json Executable file → Normal file
View File

@@ -1,7 +1,7 @@
{
"name": "@bearmetal/router",
"description": "A simple router for Deno",
"version": "0.2.1",
"version": "0.2.5",
"stable": true,
"files": [
"mod.ts",
@@ -10,7 +10,8 @@
],
"exports": {
".": "./mod.ts",
"./types": "./types.ts"
"./types": "./types.ts",
"./util/response": "./util/response.ts"
},
"exclude": [
".vscode/"
@@ -19,4 +20,4 @@
"@std/assert": "jsr:@std/assert@^1.0.7",
"@std/testing": "jsr:@std/testing@^1.0.4"
}
}
}

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

@@ -1,7 +1,8 @@
{
"version": "4",
"version": "5",
"specifiers": {
"jsr:@std/assert@^1.0.7": "1.0.7",
"jsr:@std/async@^1.0.8": "1.0.13",
"jsr:@std/data-structures@^1.0.4": "1.0.4",
"jsr:@std/fs@^1.0.5": "1.0.5",
"jsr:@std/internal@^1.0.5": "1.0.5",
@@ -16,6 +17,9 @@
"jsr:@std/internal"
]
},
"@std/async@1.0.13": {
"integrity": "1d76ca5d324aef249908f7f7fe0d39aaf53198e5420604a59ab5c035adc97c96"
},
"@std/data-structures@1.0.4": {
"integrity": "fa0e20c11eb9ba673417450915c750a0001405a784e2a4e0c3725031681684a0"
},
@@ -35,6 +39,7 @@
"integrity": "ca1368d720b183f572d40c469bb9faf09643ddd77b54f8b44d36ae6b94940576",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/async",
"jsr:@std/data-structures",
"jsr:@std/fs",
"jsr:@std/internal",

7
fileRouterTest/test.ts Normal file
View File

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

23
file_router.ts Executable file → Normal file
View File

@@ -1,5 +1,6 @@
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)) {
@@ -24,13 +25,20 @@ function crawl(dir: string, callback: (path: string) => void) {
* ```
*/
export class FileRouter extends Router {
constructor(root: string) {
constructor(root: string, _import?: (path: string) => Promise<unknown>) {
super();
crawl(root, async (path) => {
let relativePath = path.replace(root, "");
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 = await import(path);
const handlers = _import
? await _import(path)
: await import("file://" + path);
if (handlers.default) {
handlers.default instanceof Router
@@ -55,6 +63,15 @@ export class FileRouter extends Router {
});
}
});
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"),
);
});
}
}
}

2
mod.ts Executable file → Normal file
View File

@@ -5,5 +5,3 @@
export { Router } from "./router.ts";
export { FileRouter } from "./file_router.ts";
export type { Handler, Middleware } from "./types.ts";

302
router.new.test.ts Normal file
View File

@@ -0,0 +1,302 @@
// deno-lint-ignore-file require-await
import { assertEquals } from "@std/assert";
import { beforeEach, describe, it } from "@std/testing/bdd";
import Router from "./router.new.ts";
describe("Router", () => {
let router: Router;
beforeEach(() => {
router = new Router();
});
describe("Basic Routing", () => {
it("should handle basic GET request", async () => {
router.route("/test")
.get(async () => new Response("test response"));
const req = new Request("http://localhost/test", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(res.status, 200);
assertEquals(await res.text(), "test response");
});
it("should return 404 for non-existent route", async () => {
const req = new Request("http://localhost/nonexistent", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(res.status, 404);
});
it("should return 405 for unsupported method", async () => {
router.route("/test")
.get(async () => new Response("test"));
const req = new Request("http://localhost/test", {
method: "POST",
});
const res = await router.handle(req);
assertEquals(res.status, 405);
});
});
describe("Route Parameters", () => {
it("should handle route parameters", async () => {
router.route("/users/:id")
.get(async (_, ctx) => new Response(ctx.params.id));
const req = new Request("http://localhost/users/123", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(await res.text(), "123");
});
it("should handle multiple route parameters", async () => {
router.route("/users/:userId/posts/:postId")
.get(async (_, ctx) => {
return new Response(JSON.stringify(ctx.params));
});
const req = new Request("http://localhost/users/123/posts/456", {
method: "GET",
});
const res = await router.handle(req);
const params = JSON.parse(await res.text());
assertEquals(params.userId, "123");
assertEquals(params.postId, "456");
});
});
describe("Middleware", () => {
it("should execute global middleware", async () => {
let middlewareExecuted = false;
router.use(async (_, _ctx, next) => {
middlewareExecuted = true;
return await next();
});
router.route("/test")
.get(async () => new Response("test"));
const req = new Request("http://localhost/test", {
method: "GET",
});
await router.handle(req);
assertEquals(middlewareExecuted, true);
});
it("should execute path-specific middleware", async () => {
let middlewareExecuted = false;
router.use("/test", async (_, _ctx, next) => {
middlewareExecuted = true;
return await next();
});
router.route("/test")
.get(async () => new Response("test"));
const req = new Request("http://localhost/test", {
method: "GET",
});
await router.handle(req);
assertEquals(middlewareExecuted, true);
});
it("should handle middleware parameters", async () => {
const capturedParams: Record<string, string | undefined> = {};
router.use("/:version/.*", async (_, ctx, next) => {
capturedParams.version = ctx.params.version;
return await next();
});
router.route("/:version/test")
.get(async () => new Response("test"));
const req = new Request("http://localhost/v1/test", {
method: "GET",
});
await router.handle(req);
assertEquals(capturedParams.version, "v1");
});
it("should execute middleware in correct order", async () => {
const order: number[] = [];
router.use(async (_, _ctx, next) => {
order.push(1);
return await next();
});
router.use("/test", async (_, _ctx, next) => {
order.push(2);
return await next();
});
router.route("/test")
.get(async () => {
order.push(3);
return new Response("test");
});
const req = new Request("http://localhost/test", {
method: "GET",
});
await router.handle(req);
assertEquals(order, [1, 2, 3]);
});
});
describe("Nested Routers", () => {
it("should handle nested routes", async () => {
const apiRouter = new Router();
apiRouter.route("/test")
.get(async () => new Response("api test"));
router.use("/api", apiRouter);
const req = new Request("http://localhost/api/test", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(await res.text(), "api test");
});
it("should handle nested routes with parameters", async () => {
const apiRouter = new Router();
apiRouter.route("/users/:id")
.get(async (_, ctx) => new Response(ctx.params.id));
router.use("/api/:version", apiRouter);
const req = new Request("http://localhost/api/v1/users/123", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(await res.text(), "123");
});
it("should handle nested middleware", async () => {
const apiRouter = new Router();
let middlewareExecuted = false;
apiRouter.use(async (_, _ctx, next) => {
middlewareExecuted = true;
return await next();
});
apiRouter.route("/test")
.get(async () => new Response("test"));
router.use("/api", apiRouter);
const req = new Request("http://localhost/api/test", {
method: "GET",
});
await router.handle(req);
assertEquals(middlewareExecuted, true);
});
});
describe("Context State", () => {
it("should maintain state across middleware chain", async () => {
router.use(async (_, ctx, next) => {
ctx.state.test = "value";
return await next();
});
router.route("/test")
.get(async (_, ctx) => {
return new Response(ctx.state.test as string);
});
const req = new Request("http://localhost/test", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(await res.text(), "value");
});
});
describe("Error Handling", () => {
it("should handle errors in handlers", async () => {
router.route("/error")
.get(async () => {
throw new Error("Test error");
});
const req = new Request("http://localhost/error", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(res.status, 500);
assertEquals(await res.text(), "Internal Server Error");
});
it("should handle errors in middleware", async () => {
router.use(async () => {
throw new Error("Middleware error");
});
router.route("/test")
.get(async () => new Response("test"));
const req = new Request("http://localhost/test", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(res.status, 500);
});
it("should handle no handlers returning a response", async () => {
router.get("/test", async () => undefined as unknown as Response);
const req = new Request("http://localhost/test", {
method: "GET",
});
const res = await router.handle(req);
assertEquals(res.status, 501);
});
});
describe("HTTP Methods", () => {
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
methods.forEach((method) => {
it(`should handle ${method} requests`, async () => {
const route = router.route("/test");
route[method.toLowerCase() as keyof typeof route](
async () => new Response(method),
);
const req = new Request("http://localhost/test", {
method: method,
});
const res = await router.handle(req);
assertEquals(res.status, 200);
assertEquals(await res.text(), method);
});
});
});
});

281
router.new.ts Normal file
View File

@@ -0,0 +1,281 @@
import type { RouterContext as oldContext } from "@bearmetal/router/types";
import { joinPaths } from "./util/join.ts";
import { InternalError, NotFound } from "@bearmetal/router/util/response";
type RouterContext = Omit<oldContext, "pattern">;
type Handler = (
req: Request,
ctx: RouterContext,
next: () => Promise<Response>,
) => Promise<Response>;
type 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;
use(handler: Handler | Router): RouteConfigurator;
};
type RouteConfig = {
handlers: { [method: string]: Handler[] };
pattern: URLPattern;
};
const GET = "GET";
const POST = "POST";
const PUT = "PUT";
const PATCH = "PATCH";
const DELETE = "DELETE";
const OPTIONS = "OPTIONS";
const _use = "_use";
const methods = [GET, POST, PUT, PATCH, DELETE, OPTIONS, _use];
export class Router {
protected routes: Map<string, RouteConfig> = new Map();
/**
* @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 = fixPath(path);
const routeConfig = this.getOrCreateConfig(path);
const configurator = {
get: (handler: Handler) => {
this.getOrCreateConfigHandlers(GET, routeConfig).push(handler);
return configurator;
},
post: (handler: Handler) => {
this.getOrCreateConfigHandlers(POST, routeConfig).push(handler);
return configurator;
},
put: (handler: Handler) => {
this.getOrCreateConfigHandlers(PUT, routeConfig).push(handler);
return configurator;
},
patch: (handler: Handler) => {
this.getOrCreateConfigHandlers(PATCH, routeConfig).push(handler);
return configurator;
},
delete: (handler: Handler) => {
this.getOrCreateConfigHandlers(DELETE, routeConfig).push(handler);
return configurator;
},
options: (handler: Handler) => {
this.getOrCreateConfigHandlers(OPTIONS, routeConfig).push(handler);
return configurator;
},
use: (handler: Handler | Router) => {
if (handler instanceof Router) {
this.resolveRouterHandlerStack(path, handler);
return configurator;
}
this.getOrCreateConfigHandlers(_use, routeConfig).push(handler);
return configurator;
},
};
return configurator;
}
get(handler: Handler): void;
get(path: string, handler: Handler): void;
get(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(GET, config).push(handler!);
}
post(handler: Handler): void;
post(path: string, handler: Handler): void;
post(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(POST, config).push(handler!);
}
put(handler: Handler): void;
put(path: string, handler: Handler): void;
put(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(PUT, config).push(handler!);
}
patch(handler: Handler): void;
patch(path: string, handler: Handler): void;
patch(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(PATCH, config).push(handler!);
}
delete(handler: Handler): void;
delete(path: string, handler: Handler): void;
delete(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(DELETE, config).push(handler!);
}
options(handler: Handler): void;
options(path: string, handler: Handler): void;
options(path: string | Handler, handler?: Handler): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(OPTIONS, config).push(handler!);
}
use(handler: Handler | Router): void;
use(path: string, handler: Handler | Router): void;
use(path: string | Handler | Router, handler?: Handler | Router): void {
if (typeof path !== "string") {
handler = path;
path = "/.*";
} else {
path = fixPath(path);
}
if (handler instanceof Router) {
return this.resolveRouterHandlerStack(path, handler);
}
const config = this.getOrCreateConfig(path);
this.getOrCreateConfigHandlers(_use, config).push(handler!);
}
private getOrCreateConfig(path: string): RouteConfig {
let config = this.routes.get(path);
if (!config) {
config = {
handlers: {},
pattern: new URLPattern({ pathname: path }),
};
this.routes.set(path, config);
}
return config;
}
private getOrCreateConfigHandlers(method: string, config: RouteConfig) {
config.handlers[method] = config.handlers[method] ?? [];
return config.handlers[method];
}
resolveRouterHandlerStack(path: string, router: Router): void {
for (const route of router.rawRoutes) {
const p = joinPaths(path, route[0]);
const thisConfig = this.getOrCreateConfig(p);
const thatConfig = route[1];
for (const method of methods) {
if (!thatConfig.handlers[method]) continue;
thisConfig.handlers[method] = (thisConfig.handlers[method] ?? [])
.concat(thatConfig.handlers[method]);
}
}
}
get rawRoutes() {
return this.routes.entries();
}
get handle() {
return this.handler;
}
async handler(req: Request) {
const url = new URL(req.url);
const method = req.method;
const matchingRoutes = this.findMatchingRoutes(url);
if (!matchingRoutes.length) return NotFound();
const matchingMethods = matchingRoutes.some((r) =>
Object.hasOwn(r.config.handlers, method)
);
if (!matchingMethods) {
return new Response("Method Not Allowed", { status: 405 });
}
const middlewareStack = matchingRoutes.flatMap((r) =>
(r.config.handlers[_use] ?? []).concat(r.config.handlers[method] ?? [])
);
const ctx: RouterContext = {
url,
params: matchingRoutes.reduce((a, b) => ({ ...a, ...b.params }), {}),
state: {},
request: req,
};
let index = 0;
const executeMiddleware = async (): Promise<Response> => {
if (index < middlewareStack.length) {
const res = await middlewareStack[index++]?.(
req,
ctx,
executeMiddleware,
);
if (res instanceof Response) return res;
}
return new Response("End of stack", { status: 501 });
};
try {
return await executeMiddleware();
} catch {
return InternalError();
}
}
private findMatchingRoutes(url: URL) {
return this.routes.values().map((route) => {
const result = route.pattern.exec(url);
if (result) {
return {
config: route,
params: result.pathname.groups,
};
}
}).filter((r) => !!r).toArray();
}
}
function fixPath(path: string) {
path = path.startsWith("/") ? path : `/${path}`;
return path;
}
export default Router;

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

@@ -80,7 +80,7 @@ describe("Router", () => {
it("should execute global middleware", async () => {
let middlewareExecuted = false;
router.use(async (_ctx, next) => {
router.use(async (_, _ctx, next) => {
middlewareExecuted = true;
return await next();
});
@@ -99,9 +99,9 @@ describe("Router", () => {
it("should execute path-specific middleware", async () => {
let middlewareExecuted = false;
router.use("/test", async (_ctx, next) => {
router.use("/test", async (_, _ctx, next) => {
middlewareExecuted = true;
console.log('middlware happened')
console.log("middlware happened");
return await next();
});
@@ -119,7 +119,7 @@ describe("Router", () => {
it("should handle middleware parameters", async () => {
const capturedParams: Record<string, string | undefined> = {};
router.use("/:version/.*", async (ctx, next) => {
router.use("/:version/.*", async (_, ctx, next) => {
capturedParams.version = ctx.params.version;
return await next();
});
@@ -138,12 +138,12 @@ describe("Router", () => {
it("should execute middleware in correct order", async () => {
const order: number[] = [];
router.use(async (_ctx, next) => {
router.use(async (_, _ctx, next) => {
order.push(1);
return await next();
});
router.use("/test", async (_ctx, next) => {
router.use("/test", async (_, _ctx, next) => {
order.push(2);
return await next();
});
@@ -198,7 +198,7 @@ describe("Router", () => {
const apiRouter = new Router();
let middlewareExecuted = false;
apiRouter.use(async (_ctx, next) => {
apiRouter.use(async (_, _ctx, next) => {
middlewareExecuted = true;
return await next();
});
@@ -219,7 +219,7 @@ describe("Router", () => {
describe("Context State", () => {
it("should maintain state across middleware chain", async () => {
router.use(async (ctx, next) => {
router.use(async (_, ctx, next) => {
ctx.state.test = "value";
return await next();
});
@@ -274,11 +274,11 @@ describe("Router", () => {
describe("HTTP Methods", () => {
const methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
methods.forEach(method => {
methods.forEach((method) => {
it(`should handle ${method} requests`, async () => {
const route = router.route("/test");
route[method.toLowerCase() as keyof typeof route](
async () => new Response(method)
async () => new Response(method),
);
const req = new Request("http://localhost/test", {
@@ -291,4 +291,4 @@ describe("Router", () => {
});
});
});
});
});

7
router.ts Executable file → Normal file
View File

@@ -6,6 +6,7 @@ import type {
RouteConfigurator,
RouterContext,
} from "./types.ts";
import { getContentTypeByExtension } from "./util/contentType.ts";
import { NotFound } from "./util/response.ts";
/**
@@ -73,7 +74,7 @@ import { NotFound } from "./util/response.ts";
* });
*/
export class Router {
private routes: RouteConfig[] = [];
protected routes: RouteConfig[] = [];
private middleware: MiddlewareConfig[] = [];
/**
@@ -392,7 +393,9 @@ export class Router {
try {
const file = await Deno.readFile(normalizedPath);
return new Response(file);
const filetype = normalizedPath.split(".").at(-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();

12
types.ts Executable file → Normal file
View File

@@ -3,6 +3,9 @@
* BearMetal Router types
*/
/**
* @description a context object for a request
*/
export interface RouterContext {
url: URL;
params: Record<string, string | undefined>;
@@ -27,17 +30,26 @@ export type Middleware = (
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;

26
util/contentType.ts Normal 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 Normal file
View File

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

3
util/join.ts Normal file
View File

@@ -0,0 +1,3 @@
export const joinPaths = (...args: string[]) =>
args.map((a, i, l) => i === l.length - 1 ? a : a.replace(/\/?\.?\*?$/, ""))
.join("");

35
util/response.ts Executable file → Normal file
View File

@@ -1,4 +1,35 @@
export const NotFound = (msg?: string) =>
/**
* @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 });
export const InternalError = (msg?: string) =>
/**
* @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 });