a minimal web framework for deno
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});