a minimal web framework for deno
at main 606 lines 21 kB view raw
1/** 2 * Copyright (c) 2025 adoravel 3 * SPDX-License-Identifier: LGPL-3.0-or-later 4 */ 5 6import { assertEquals, assertExists } from "jsr:@std/assert@1.0.17"; 7import { CookieJar, serializeCookie } from "./cookie.ts"; 8import { createRouter } from "./router.ts"; 9import { formParser, jsonParser, rateLimit, staticFiles } from "./middleware.ts"; 10import { NotFoundError, TooManyRequestsError } from "./utils.ts"; 11 12const mockInfo = { remoteAddr: { hostname: "127.0.0.1" } } as Deno.ServeHandlerInfo<Deno.NetAddr>; 13 14Deno.test("cookies", async (t) => { 15 await t.step("parses cookie header correctly", () => { 16 const jar = new CookieJar("session=abc123; user=meow"); 17 assertEquals(jar.get("session"), "abc123"); 18 assertEquals(jar.get("user"), "meow"); 19 }); 20 21 await t.step("properly handles URL encoded values", () => { 22 const jar = new CookieJar("token=abc%20123"); 23 assertEquals(jar.get("token"), "abc 123"); 24 }); 25 26 await t.step("deduplicates Set-Cookie headers", () => { 27 const jar = new CookieJar(null); 28 jar.set("session", "first", { path: "/" }); 29 jar.set("session", "bleh", { path: "/" }); 30 31 const { headers } = jar; 32 assertEquals(headers.length, 1); 33 assertEquals(headers[0].includes("session=bleh"), true); 34 }); 35}); 36 37Deno.test("serializeCookie", () => { 38 const result = serializeCookie("session", "mraow", { 39 httpOnly: true, 40 maxAge: 3600, 41 }); 42 assertEquals(result, "session=mraow; Max-Age=3600; Secure; HttpOnly; SameSite=Lax"); 43}); 44 45Deno.test("trie routing: static routes", async (t) => { 46 await t.step("matches exact static paths", async () => { 47 const router = createRouter(); 48 router.get("/cats", (ctx) => ctx.json({ page: "meow" })); 49 router.get("/posts", (ctx) => ctx.json({ page: "posts" })); 50 51 const res1 = await router.fetch(new Request("http://localhost/cats"), mockInfo); 52 assertEquals(res1.status, 200); 53 assertEquals(await res1.json(), { page: "meow" }); 54 55 const res2 = await router.fetch(new Request("http://localhost/posts"), mockInfo); 56 assertEquals(res2.status, 200); 57 assertEquals(await res2.json(), { page: "posts" }); 58 }); 59 60 await t.step("distinguishes between similar paths", async () => { 61 const router = createRouter(); 62 router.get("/user", (ctx) => ctx.json({ type: "single" })); 63 router.get("/users", (ctx) => ctx.json({ type: "plural" })); 64 router.get("/user/profile", (ctx) => ctx.json({ type: "profile" })); 65 66 const res1 = await router.fetch(new Request("http://localhost/user"), mockInfo); 67 assertEquals(await res1.json(), { type: "single" }); 68 69 const res2 = await router.fetch(new Request("http://localhost/users"), mockInfo); 70 assertEquals(await res2.json(), { type: "plural" }); 71 72 const res3 = await router.fetch(new Request("http://localhost/user/profile"), mockInfo); 73 assertEquals(await res3.json(), { type: "profile" }); 74 }); 75 76 await t.step("handles trailing slashes correctly", async () => { 77 const router = createRouter(); 78 router.get("/api/users", (ctx) => ctx.json({ ok: true })); 79 80 const res1 = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 81 assertEquals(res1.status, 200); 82 83 const res2 = await router.fetch(new Request("http://localhost/api/users/"), mockInfo); 84 assertEquals(res2.status, 200); 85 }); 86 87 await t.step("handles root path", async () => { 88 const router = createRouter(); 89 router.get("/", (ctx) => ctx.json({ root: true })); 90 91 const res = await router.fetch(new Request("http://localhost/"), mockInfo); 92 assertEquals(res.status, 200); 93 assertEquals(await res.json(), { root: true }); 94 }); 95}); 96 97Deno.test("trie routing: dynamic parameters", async (t) => { 98 await t.step("extracts single parameter", async () => { 99 const router = createRouter(); 100 router.get("/users/:id", (ctx) => ctx.json({ id: ctx.params.id })); 101 102 const res = await router.fetch(new Request("http://localhost/users/42"), mockInfo); 103 assertEquals(res.status, 200); 104 assertEquals(await res.json(), { id: "42" }); 105 }); 106 107 await t.step("extracts multiple parameters", async () => { 108 const router = createRouter(); 109 router.get( 110 "/cats/:feline/meow/:mrrp", 111 (ctx) => ctx.json({ feline: ctx.params.feline, mrrp: ctx.params.mrrp }), 112 ); 113 114 const res = await router.fetch(new Request("http://localhost/cats/123/meow/456"), mockInfo); 115 assertEquals(await res.json(), { feline: "123", mrrp: "456" }); 116 }); 117 118 await t.step("decodes URL-encoded parameters", async () => { 119 const router = createRouter(); 120 router.get("/search/:query", (ctx) => ctx.json({ query: ctx.params.query })); 121 122 const res = await router.fetch( 123 new Request("http://localhost/search/hello%20world"), 124 mockInfo, 125 ); 126 assertEquals(await res.json(), { query: "hello world" }); 127 }); 128 129 await t.step("handles special characters in params", async () => { 130 const router = createRouter(); 131 router.get("/file/:name", (ctx) => ctx.json({ name: ctx.params.name })); 132 133 const res = await router.fetch( 134 new Request("http://localhost/file/test%26file.txt"), 135 mockInfo, 136 ); 137 assertEquals(await res.json(), { name: "test&file.txt" }); 138 }); 139}); 140 141Deno.test("trie routing: optional parameters", async (t) => { 142 await t.step("matches with optional param present", async () => { 143 const router = createRouter(); 144 router.get("/posts/:id?", (ctx) => ctx.json({ id: ctx.params.id || null })); 145 146 const res = await router.fetch(new Request("http://localhost/posts/123"), mockInfo); 147 assertEquals(await res.json(), { id: "123" }); 148 }); 149 150 await t.step("matches with optional param absent", async () => { 151 const router = createRouter(); 152 router.get("/posts/:id?", (ctx) => ctx.json({ id: ctx.params.id || null })); 153 154 const res = await router.fetch(new Request("http://localhost/posts"), mockInfo); 155 assertEquals(await res.json(), { id: null }); 156 }); 157}); 158 159Deno.test("trie routing: wildcard routes", async (t) => { 160 await t.step("captures remaining path segments", async () => { 161 const router = createRouter(); 162 router.get("/files/*", (ctx) => ctx.json({ path: ctx.params["*"] })); 163 164 const res = await router.fetch( 165 new Request("http://localhost/files/docs/api/readme.md"), 166 mockInfo, 167 ); 168 assertEquals(await res.json(), { path: "docs/api/readme.md" }); 169 }); 170 171 await t.step("wildcard with empty path", async () => { 172 const router = createRouter(); 173 router.get("/static/*", (ctx) => ctx.json({ path: ctx.params["*"] || "" })); 174 175 const res = await router.fetch(new Request("http://localhost/static/"), mockInfo); 176 assertEquals(await res.json(), { path: "" }); 177 }); 178}); 179 180Deno.test("trie routing: priority and precedence", async (t) => { 181 await t.step("static routes take precedence over params", async () => { 182 const router = createRouter(); 183 router.get("/users/new", (ctx) => ctx.json({ type: "new" })); 184 router.get("/users/:id", (ctx) => ctx.json({ type: "id", id: ctx.params.id })); 185 186 const res1 = await router.fetch(new Request("http://localhost/users/new"), mockInfo); 187 assertEquals(await res1.json(), { type: "new" }); 188 189 const res2 = await router.fetch(new Request("http://localhost/users/123"), mockInfo); 190 assertEquals(await res2.json(), { type: "id", id: "123" }); 191 }); 192 193 await t.step("params take precedence over wildcards", async () => { 194 const router = createRouter(); 195 router.get("/api/:resource", (ctx) => ctx.json({ type: "param", resource: ctx.params.resource })); 196 router.get("/api/*", (ctx) => ctx.json({ type: "wildcard", path: ctx.params["*"] })); 197 198 const res = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 199 assertEquals(await res.json(), { type: "param", resource: "users" }); 200 }); 201 202 await t.step("registration order doesn't affect precedence", async () => { 203 const router = createRouter(); 204 205 router.get("/users/:id", (ctx) => ctx.json({ type: "param" })); 206 router.get("/users/admin", (ctx) => ctx.json({ type: "static" })); 207 208 const res = await router.fetch(new Request("http://localhost/users/admin"), mockInfo); 209 assertEquals(await res.json(), { type: "static" }); 210 }); 211}); 212 213Deno.test("trie routing: edge cases", async (t) => { 214 await t.step("handles empty path segments", async () => { 215 const router = createRouter(); 216 router.get("/api//users", (ctx) => ctx.json({ ok: true })); 217 218 const res = await router.fetch(new Request("http://localhost/api//users"), mockInfo); 219 assertEquals(res.status, 200); 220 }); 221 222 await t.step("handles deeply nested routes", async () => { 223 const router = createRouter(); 224 router.get("/a/b/c/d/e/f/g", (ctx) => ctx.json({ depth: 7 })); 225 226 const res = await router.fetch(new Request("http://localhost/a/b/c/d/e/f/g"), mockInfo); 227 assertEquals(await res.json(), { depth: 7 }); 228 }); 229 230 await t.step("handles routes with many parameters", async () => { 231 const router = createRouter(); 232 router.get("/:a/:b/:c/:d/:e", (ctx) => 233 ctx.json({ 234 a: ctx.params.a, 235 b: ctx.params.b, 236 c: ctx.params.c, 237 d: ctx.params.d, 238 e: ctx.params.e, 239 })); 240 241 const res = await router.fetch(new Request("http://localhost/1/2/3/4/5"), mockInfo); 242 assertEquals(await res.json(), { a: "1", b: "2", c: "3", d: "4", e: "5" }); 243 }); 244 245 await t.step("404 for non-existent routes", async () => { 246 const router = createRouter(); 247 router.get("/users", (ctx) => ctx.json({ ok: true })); 248 249 const res = await router.fetch(new Request("http://localhost/posts"), mockInfo); 250 assertEquals(res.status, 404); 251 }); 252 253 await t.step("handles malformed URL encoding gracefully", async () => { 254 const router = createRouter(); 255 router.get("/search/:query", (ctx) => ctx.json({ query: ctx.params.query })); 256 257 // Invalid percent encoding - should not crash 258 const res = await router.fetch(new Request("http://localhost/search/%ZZ"), mockInfo); 259 assertEquals(res.status, 200); 260 const body = await res.json(); 261 assertEquals(body.query, "%ZZ"); // Should keep original if decode fails 262 }); 263}); 264 265Deno.test("trie routing: method isolation", async (t) => { 266 await t.step("different methods on same path", async () => { 267 const router = createRouter(); 268 router.get("/resource", (ctx) => ctx.json({ method: "GET" })); 269 router.post("/resource", (ctx) => ctx.json({ method: "POST" })); 270 router.delete("/resource", (ctx) => ctx.json({ method: "DELETE" })); 271 272 const get = await router.fetch(new Request("http://localhost/resource"), mockInfo); 273 assertEquals(await get.json(), { method: "GET" }); 274 275 const post = await router.fetch( 276 new Request("http://localhost/resource", { method: "POST" }), 277 mockInfo, 278 ); 279 assertEquals(await post.json(), { method: "POST" }); 280 281 const del = await router.fetch( 282 new Request("http://localhost/resource", { method: "DELETE" }), 283 mockInfo, 284 ); 285 assertEquals(await del.json(), { method: "DELETE" }); 286 }); 287 288 await t.step("405 not implemented for method", async () => { 289 const router = createRouter(); 290 router.get("/users", (ctx) => ctx.json({ ok: true })); 291 292 const res = await router.fetch( 293 new Request("http://localhost/users", { method: "POST" }), 294 mockInfo, 295 ); 296 assertEquals(res.status, 404); // No POST handler = 404 297 }); 298}); 299 300Deno.test("trie routing: route groups", async (t) => { 301 await t.step("applies prefix to grouped routes", async () => { 302 const router = createRouter(); 303 router.group("/api", (api) => { 304 api.get("/users", (ctx) => ctx.json({ group: "api" })); 305 api.get("/posts", (ctx) => ctx.json({ group: "api" })); 306 }); 307 308 const res1 = await router.fetch(new Request("http://localhost/api/users"), mockInfo); 309 assertEquals(await res1.json(), { group: "api" }); 310 311 const res2 = await router.fetch(new Request("http://localhost/api/posts"), mockInfo); 312 assertEquals(await res2.json(), { group: "api" }); 313 }); 314 315 await t.step("nested groups", async () => { 316 const router = createRouter(); 317 router.group("/api", (api) => { 318 api.group("/v1", (v1) => { 319 v1.get("/users", (ctx) => ctx.json({ version: "v1" })); 320 }); 321 }); 322 323 const res = await router.fetch(new Request("http://localhost/api/v1/users"), mockInfo); 324 assertEquals(await res.json(), { version: "v1" }); 325 }); 326}); 327 328Deno.test("trie routing: performance characteristics", async (t) => { 329 await t.step("scales with many routes", async () => { 330 const router = createRouter(); 331 332 for (let i = 0; i < 1000; i++) { 333 router.get(`/route${i}`, (ctx) => ctx.json({ route: i })); 334 } 335 336 const start = performance.now(); 337 const res = await router.fetch(new Request("http://localhost/route999"), mockInfo); 338 const elapsed = performance.now() - start; 339 340 assertEquals(res.status, 200); 341 assertEquals(await res.json(), { route: 999 }); 342 assertEquals(elapsed < 25, true, `Routing took ${elapsed}ms, expected < 25ms`); 343 }); 344 345 await t.step("efficient param extraction", async () => { 346 const router = createRouter(); 347 router.get("/a/:p1/b/:p2/c/:p3/d/:p4/e/:p5", (ctx) => 348 ctx.json({ 349 p1: ctx.params.p1, 350 p2: ctx.params.p2, 351 p3: ctx.params.p3, 352 p4: ctx.params.p4, 353 p5: ctx.params.p5, 354 })); 355 356 const start = performance.now(); 357 const res = await router.fetch( 358 new Request("http://localhost/a/1/b/2/c/3/d/4/e/5"), 359 mockInfo, 360 ); 361 const elapsed = performance.now() - start; 362 363 assertEquals(await res.json(), { p1: "1", p2: "2", p3: "3", p4: "4", p5: "5" }); 364 assertEquals(elapsed < 5, true, `Param extraction took ${elapsed}ms, expected < 5ms`); 365 }); 366}); 367 368Deno.test("context", async (t) => { 369 await t.step("ctx.query returns URLSearchParams", async () => { 370 const router = createRouter(); 371 router.get("/search", (ctx) => ctx.json({ q: ctx.query.get("q") })); 372 373 const res = await router.fetch(new Request("http://localhost/search?q=snarl"), mockInfo); 374 const body = await res.json(); 375 assertEquals(body.q, "snarl"); 376 }); 377 378 await t.step("ctx.set and ctx.get manage headers", async () => { 379 const router = createRouter(); 380 router.get("/", (ctx) => { 381 ctx.set("X-Custom", "heyyyy"); 382 return ctx.text("ok"); 383 }); 384 385 const res = await router.fetch(new Request("http://localhost/"), mockInfo); 386 assertEquals(res.headers.get("X-Custom"), "heyyyy"); 387 }); 388 389 await t.step("ctx.json/jsonParser caching", async () => { 390 const router = createRouter(); 391 router.use(jsonParser()); 392 router.post("/", async (ctx) => { 393 const body1 = await ctx.body.json<{ msg: string }>(); 394 const body2 = await ctx.body.json<{ msg: string }>(); 395 return ctx.json({ body1, body2 }); 396 }); 397 398 const req = new Request("http://localhost/", { 399 method: "POST", 400 headers: { "Content-Type": "application/json" }, 401 body: JSON.stringify({ msg: "hello" }), 402 }); 403 404 const res = await router.fetch(req, mockInfo); 405 const body = await res.json(); 406 assertEquals(body.body1, { msg: "hello" }); 407 assertEquals(body.body2, { msg: "hello" }); 408 }); 409}); 410 411Deno.test("error handling", async (t) => { 412 await t.step("http errors are caught and converted to JSON", async () => { 413 const router = createRouter(); 414 router.get("/", () => { 415 throw new NotFoundError("My custom message"); 416 }); 417 418 const res = await router.fetch(new Request("http://localhost/"), mockInfo); 419 assertEquals(res.status, 404); 420 const body = await res.json(); 421 assertEquals(body.error, "My custom message"); 422 }); 423 424 await t.step("too many requests includes Retry-After header", async () => { 425 const router = createRouter(); 426 router.get("/", () => { 427 throw new TooManyRequestsError("slown down mate", "5"); 428 }); 429 430 const res = await router.fetch(new Request("http://localhost/"), mockInfo); 431 assertEquals(res.status, 429); 432 assertEquals(res.headers.get("Retry-After"), "5"); 433 }); 434}); 435 436Deno.test("rate limiting", async (t) => { 437 await t.step("limits requests correctly", async () => { 438 const limiter = rateLimit({ windowMs: 3000, max: 2 }); 439 const router = createRouter(); 440 router.use(limiter); 441 router.get("/hai", (ctx) => ctx.text("ok")); 442 443 const res1 = await router.fetch(new Request("http://localhost/hai"), mockInfo); 444 assertEquals(res1.status, 200); 445 446 const res2 = await router.fetch(new Request("http://localhost/hai"), mockInfo); 447 assertEquals(res2.status, 200); 448 449 const res3 = await router.fetch(new Request("http://localhost/hai"), mockInfo); 450 assertEquals(res3.status, 429); 451 452 // test-suite constraint 453 (limiter as any).cleanup(); 454 }); 455}); 456 457Deno.test("static files", async (t) => { 458 const root = await Deno.makeTempDir({ prefix: "snarl-test-" }); 459 const testFile = "test.txt"; 460 461 await Deno.writeTextFile(`${root}/${testFile}`, "bugger off mate"); 462 463 await t.step("serves file correctly", async () => { 464 const router = createRouter(); 465 router.use(staticFiles(root)); 466 467 const res = await router.fetch(new Request(`http://localhost/${testFile}`), mockInfo); 468 assertEquals(res.status, 200); 469 assertEquals(await res.text(), "bugger off mate"); 470 }); 471 472 await t.step("prevents directory traversal", async () => { 473 const router = createRouter(); 474 router.use(staticFiles(root)); 475 476 const res = await router.fetch(new Request(`http://localhost/../../etc/passwd`), mockInfo); 477 assertEquals(res.status, 404); 478 }); 479 480 await t.step("returns 304 for ETag match", async () => { 481 const router = createRouter(); 482 router.use(staticFiles(root)); 483 484 const res1 = await router.fetch(new Request(`http://localhost/${testFile}`), mockInfo); 485 const etag = res1.headers.get("ETag"); 486 assertExists(etag); 487 488 const res2 = await router.fetch( 489 new Request(`http://localhost/${testFile}`, { headers: { "If-None-Match": etag } }), 490 mockInfo, 491 ); 492 assertEquals(res2.status, 304); 493 }); 494 495 await t.step("ctx.send helper works", async () => { 496 const router = createRouter(); 497 router.get("/download", (ctx) => ctx.send(`${root}/${testFile}`)); 498 499 const res = await router.fetch(new Request("http://localhost/download"), mockInfo); 500 assertEquals(res.status, 200); 501 assertEquals(await res.text(), "bugger off mate"); 502 assertEquals(res.headers.get("Content-Type"), "text/plain; charset=utf-8"); 503 }); 504 505 await Deno.remove(root, { recursive: true }); 506}); 507 508Deno.test("formParser", async (t) => { 509 await t.step("parses form data and caches it", async () => { 510 const router = createRouter(); 511 router.use(formParser()); 512 513 router.post("/submit", async (ctx) => { 514 const body = await ctx.body.json(); 515 516 if (typeof body === "object") { 517 return ctx.json({ received: body }); 518 } else { 519 return ctx.json({ status: "invalid bruh" }); 520 } 521 }); 522 523 const req = new Request("http://localhost/submit", { 524 method: "POST", 525 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 526 body: "meow=mrrp&myage=13", 527 }); 528 529 const res = await router.fetch(req, mockInfo); 530 assertEquals(res.status, 200); 531 532 const json = await res.json(); 533 assertEquals(json.received, { meow: "mrrp", myage: "13" }); 534 }); 535}); 536 537Deno.test("static files dotfiles security", async (t) => { 538 const root = await Deno.makeTempDir({ prefix: "snarl-dotfiles-or-smth-" }); 539 540 await Deno.writeTextFile(`${root}/visible.txt`, "I am very visible"); 541 await Deno.writeTextFile(`${root}/.env`, "SECRET_KEY=123"); 542 543 await Deno.mkdir(`${root}/.git`); 544 await Deno.writeTextFile(`${root}/.git/config`, "[core]\nrepositoryformatversion = 0"); 545 546 await t.step("default mode (ignore) returns 404 for hidden files", async () => { 547 const router = createRouter(); 548 router.use(staticFiles(root)); 549 550 const res = await router.fetch(new Request("http://localhost/.env"), mockInfo); 551 assertEquals(res.status, 404); 552 }); 553 554 await t.step("default mode (ignore) returns 404 for files inside hidden directories", async () => { 555 const router = createRouter(); 556 router.use(staticFiles(root)); 557 558 const res = await router.fetch(new Request("http://localhost/.git/config"), mockInfo); 559 assertEquals(res.status, 404); 560 }); 561 562 await t.step("default mode (ignore) serves visible files normally", async () => { 563 const router = createRouter(); 564 router.use(staticFiles(root)); 565 566 const res = await router.fetch(new Request("http://localhost/visible.txt"), mockInfo); 567 assertEquals(res.status, 200); 568 assertEquals(await res.text(), "I am very visible"); 569 }); 570 571 await t.step("deny mode returns 403 for hidden files", async () => { 572 const router = createRouter(); 573 router.use(staticFiles(root, { dotfiles: "deny" })); 574 575 const res = await router.fetch(new Request("http://localhost/.env"), mockInfo); 576 assertEquals(res.status, 403); 577 }); 578 579 await t.step("deny mode returns 403 for files inside hidden directories", async () => { 580 const router = createRouter(); 581 router.use(staticFiles(root, { dotfiles: "deny" })); 582 583 const res = await router.fetch(new Request("http://localhost/.git/config"), mockInfo); 584 assertEquals(res.status, 403); 585 }); 586 587 await t.step("allow mode serves hidden files successfully", async () => { 588 const router = createRouter(); 589 router.use(staticFiles(root, { dotfiles: "allow" })); 590 591 const res = await router.fetch(new Request("http://localhost/.env"), mockInfo); 592 assertEquals(res.status, 200); 593 assertEquals(await res.text(), "SECRET_KEY=123"); 594 }); 595 596 await t.step("allow mode serves files inside hidden directories", async () => { 597 const router = createRouter(); 598 router.use(staticFiles(root, { dotfiles: "allow" })); 599 600 const res = await router.fetch(new Request("http://localhost/.git/config"), mockInfo); 601 assertEquals(res.status, 200); 602 assertEquals(await res.text(), "[core]\nrepositoryformatversion = 0"); 603 }); 604 605 await Deno.remove(root, { recursive: true }); 606});