Compare commits

...

4 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
fcba2014c2 file server content type headers 2025-01-20 23:45:07 -07:00
17 changed files with 639 additions and 14 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";

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.3-b",
"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;

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", () => {
});
});
});
});
});

5
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";
/**
@@ -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();

0
types.ts Executable file → Normal file
View File

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";
}
}

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