Compare commits

...

3 Commits

Author SHA1 Message Date
0aecd354c7 tests passing 2025-07-03 01:48:25 -06:00
0b4f504ba2 first iteration of v.3 router 2025-07-02 23:40:16 -06:00
1d3be78917 improved content types 2025-01-21 01:23:41 -07:00
17 changed files with 606 additions and 15 deletions

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

0
LICENSE Executable file → Normal file
View File

0
README.md Executable file → Normal file
View File

2
deno.json Executable file → Normal file
View File

@@ -1,7 +1,7 @@
{
"name": "@bearmetal/router",
"description": "A simple router for Deno",
"version": "0.2.4",
"version": "0.2.5",
"stable": true,
"files": [
"mod.ts",

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",

0
fileRouterTest/test.ts Executable file → Normal file
View File

0
file_router.ts Executable file → Normal file
View File

0
mod.ts Executable file → Normal file
View File

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;

20
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", {

2
router.ts Executable file → Normal file
View File

@@ -393,7 +393,7 @@ export class Router {
try {
const file = await Deno.readFile(normalizedPath);
const filetype = normalizedPath.split(".")[1];
const filetype = normalizedPath.split(".").at(-1);
const contentType = getContentTypeByExtension(filetype);
return new Response(file, { headers: { "Content-Type": contentType } });
} catch (e) {

0
types.ts Executable file → Normal file
View File

2
util/contentType.ts Executable file → Normal file
View File

@@ -1,4 +1,4 @@
export function getContentTypeByExtension(extension: string) {
export function getContentTypeByExtension(extension?: string) {
switch (extension) {
case "html":
case "htm":

0
util/isRelativePath.ts Executable file → Normal file
View File

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("");

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