├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── deps.ts ├── dev_deps.ts ├── examples ├── errorHandling.ts ├── helloWorld.ts ├── middleware.ts ├── public │ ├── index.css │ └── index.html └── serveStatic.ts ├── middleware ├── basicAuth.ts ├── decodeParams.ts ├── deps.ts ├── json.ts └── serveStatic.ts ├── mod.ts ├── src ├── Kyuko.ts ├── KyukoRequest.ts ├── KyukoResponse.ts └── RoutePathHandler.ts └── test ├── Kyuko ├── assertResponse.ts ├── helloWorld.test.ts ├── pathParams.test.ts ├── query.test.ts └── scripts │ ├── helloWorld.ts │ ├── pathParams.ts │ └── query.ts └── RoutePathHandler.test.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | # This workflow will install Deno and run tests across stable and canary builds on Windows, Ubuntu and macOS. 7 | # For more information see: https://github.com/denoland/setup-deno 8 | 9 | name: ci 10 | 11 | on: 12 | push: 13 | branches: 14 | - main 15 | pull_request: 16 | branches: 17 | - main 18 | 19 | jobs: 20 | test: 21 | runs-on: ${{ matrix.os }} 22 | 23 | strategy: 24 | matrix: 25 | deno: 26 | - "v1.x" 27 | # - "canary" 28 | os: 29 | # - macOS-latest 30 | # - windows-latest 31 | - ubuntu-latest 32 | 33 | steps: 34 | - name: Setup repo 35 | uses: actions/checkout@v2 36 | 37 | - name: Setup Deno 38 | # uses: denoland/setup-deno@v1 39 | uses: denoland/setup-deno@4a4e59637fa62bd6c086a216c7e4c5b457ea9e79 40 | with: 41 | deno-version: ${{ matrix.deno }} # tests across multiple Deno versions 42 | 43 | # Uncomment this step to verify the use of 'deno fmt' on each commit. 44 | - name: Verify formatting 45 | run: deno fmt --check 46 | 47 | - name: Run linter 48 | run: deno lint 49 | 50 | # Uncomment this step when we start using dependencies 51 | - name: Cache dependencies 52 | run: deno cache deps.ts 53 | 54 | - name: Cache dev dependencies 55 | run: deno cache dev_deps.ts 56 | 57 | - name: Run tests 58 | run: deno test --unstable --allow-net --allow-read 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test_coverage 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | "deno.suggest.imports.hosts": { 5 | "https://deno.land": true 6 | }, 7 | "[javascript]": { 8 | "editor.defaultFormatter": "denoland.vscode-deno" 9 | }, 10 | "[javascriptreact]": { 11 | "editor.defaultFormatter": "denoland.vscode-deno" 12 | }, 13 | "[typescript]": { 14 | "editor.defaultFormatter": "denoland.vscode-deno" 15 | }, 16 | "[typescriptreact]": { 17 | "editor.defaultFormatter": "denoland.vscode-deno" 18 | }, 19 | "cSpell.words": [ 20 | "Kyuko", 21 | "dectyl", 22 | "deployctl" 23 | ], 24 | "emeraldwalk.runonsave": { 25 | "commands": [ 26 | { 27 | "match": ".*", 28 | "isAsync": true, 29 | "cmd": "deno fmt" 30 | } 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Riki Singh Khorana 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![ci](https://github.com/rikilele/kyuko/actions/workflows/ci.yml/badge.svg)](https://github.com/rikilele/kyuko/actions/workflows/ci.yml) 2 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/rikilele/kyuko)](https://github.com/rikilele/kyuko/releases) 3 | [![deno doc](https://doc.deno.land/badge.svg)](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts) 4 | 5 | > Fast and easy http framework for Deno Deploy 🦕 6 | 7 | Kyuko is an ultra-light http framework for apps hosted on 8 | [Deno Deploy](https://deno.com/deploy). 9 | 10 | It aims to provide developers with a similar experience to using 11 | [Express](https://expressjs.com/), 12 | [hence its name](https://translate.google.com/?sl=ja&tl=en&text=%E6%80%A5%E8%A1%8C&op=translate&hl=en). 13 | 14 | **Table of Contents** 15 | 16 | - [Hello World](#hello-world) and [Usage](#usage) to get started quickly 17 | - [Philosophy](#philosophy) to learn more about the apps Kyuko serves well 18 | - [Guide](#guide) to read an in-depth introduction on how to use Kyuko 19 | 20 | # Hello World 21 | 22 | Deployed at https://kyuko.deno.dev 23 | 24 | ```js 25 | import { Kyuko } from "https://deno.land/x/kyuko/mod.ts"; 26 | 27 | const app = new Kyuko(); 28 | 29 | app.get("/", (req, res) => { 30 | res.send("Hello World!"); 31 | }); 32 | 33 | app.get("/:name", (req, res) => { 34 | res.send(`Hello ${req.params.name}!`); 35 | }); 36 | 37 | app.listen(); 38 | ``` 39 | 40 | # Usage 41 | 42 | To run your Kyuko app locally using `deployctl`: 43 | 44 | ```sh 45 | deployctl run --libs="" your_kyuko_app.ts 46 | ``` 47 | 48 | # Philosophy 49 | 50 | Kyuko is an http framework for Deno Deploy that aims to be **`fast`** and 51 | **`easy`**. 52 | 53 | ### Fast 54 | 55 | Kyuko provides the bare minimum functionality of an http framework: routing, 56 | application-level middleware, and error handling. By focusing on what is only 57 | absolutely necessary, Kyuko powers apps that are **`fast`** by default. 58 | 59 | ### Easy 60 | 61 | Kyuko offers a set of functionality that is light and well-documented, saving 62 | developers from having to guess what is happening from outside a black box. 63 | Predictability makes Kyuko a framework that is extremely **`easy`** to adopt. 64 | 65 | # Guide 66 | 67 | For the API reference, visit the Kyuko 68 | [Deno Doc](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts). 69 | 70 | ## Table of Contents 71 | 72 | 1. [Routing](#routing) 73 | 1. [Middleware](#middleware) 74 | 1. [Error Handling](#error-handling) 75 | 1. [Event Lifecycle](#event-lifecycle) 76 | 77 | ## Routing 78 | 79 | From [Express](https://expressjs.com/en/starter/basic-routing.html): 80 | 81 | > **_Routing_** refers to determining how an application responds to a client 82 | > request to a particular endpoint, which is [a path] and a specific HTTP 83 | > request method (GET, POST, and so on). 84 | 85 | Kyuko allows developers to register a route handler to a route path in the 86 | following manner: 87 | 88 | ```js 89 | app.METHOD(PATH, HANDLER); 90 | ``` 91 | 92 | Where: 93 | 94 | - `app` is an instance of 95 | [`Kyuko`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#Kyuko) 96 | - `METHOD` is an http request method in lowercase 97 | - `PATH` is a valid [route path](#route-paths) 98 | - `HANDLER` is the 99 | [`KyukoRouteHandler`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#KyukoRouteHandler) 100 | executed when the route is matched 101 | 102 | Only a single route handler is registered for a specific route path. When 103 | multiple handlers are registered under the same route path via `app.METHOD()`, 104 | the last route handler will be registered. 105 | 106 | ### Route Paths 107 | 108 | Route paths define endpoints at which requests can be made. They consist of 109 | segments that can either be concrete or a wildcard. In the following example, 110 | `users` is a concrete segment, while `:userId` is a wildcard segment. The 111 | example will handle GET requests that are sent to `"/users/Alice"`, but not 112 | requests that are sent to `"/"`, `"/users"`, `"/users/Alice/friends"`, etc. 113 | 114 | ```js 115 | app.get("/users/:userId", (req, res) => { 116 | const { userId } = req.params; 117 | res.send(`Hello ${userId}!`); 118 | }); 119 | ``` 120 | 121 | Response: 122 | 123 | ``` 124 | Hello Alice! 125 | ``` 126 | 127 | Kyuko only officially supports route paths that consist of 128 | [unreserved characters](https://datatracker.ietf.org/doc/html/rfc3986#section-2.3). 129 | The behavior for when a route path consisting of other characters is registered 130 | is undefined. 131 | 132 | ### Slashes in Paths 133 | 134 | - Recurring leading slashes will be merged and considered as one slash 135 | - Recurring slashes that appear mid-path will contribute to empty paths 136 | - A single trailing slash will be ignored 137 | 138 | For more detail, refer to 139 | [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986). 140 | 141 | **[↑ back to top](#guide)** 142 | 143 | ## Middleware 144 | 145 | Kyuko allows developers to register application-level middleware in the 146 | following manner: 147 | 148 | ```js 149 | app.use(MIDDLEWARE); 150 | ``` 151 | 152 | Where: 153 | 154 | - `app` is an instance of 155 | [`Kyuko`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#Kyuko) 156 | - `MIDDLEWARE` is the 157 | [`KyukoMiddleware`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#KyukoMiddleware) 158 | that is run on each request 159 | 160 | Multiple middleware can be registered on a Kyuko application. Middleware are 161 | always called in order of registration, and will all run until completion unless 162 | an error is thrown. 163 | 164 | Middleware functions can perform the following tasks: 165 | 166 | - Execute any code 167 | - Make changes to the request and response objects 168 | - Send a response 169 | - Defer logic until after route handling 170 | 171 | ### Sending Responses 172 | 173 | Take note of the following points when choosing to send responses in middleware: 174 | 175 | 1. Check `res.wasSent()` beforehand to make sure that no other middleware have 176 | sent a response already 177 | 1. The route handler that was assigned to the request **will not run** if a 178 | middleware responds early 179 | 180 | **[↑ back to top](#guide)** 181 | 182 | ## Error Handling 183 | 184 | Kyuko allows developers to register application-level error handlers in the 185 | following manner: 186 | 187 | ```js 188 | app.error(HANDLER); 189 | ``` 190 | 191 | Where: 192 | 193 | - `app` is an instance of 194 | [`Kyuko`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#Kyuko) 195 | - `HANDLER` is the 196 | [`KyukoErrorHandler`](https://doc.deno.land/https/deno.land/x/kyuko/mod.ts#KyukoErrorHandler) 197 | that is run when errors are thrown 198 | 199 | Like middleware, multiple error handlers can be registered on a Kyuko 200 | application. Error handlers are called when an error is thrown during execution 201 | of middleware or a route handler. Error handlers are called in order of 202 | registration, and will all run until completion unless an error is thrown from 203 | the error handlers. 204 | 205 | Error handlers can perform the following tasks: 206 | 207 | - Execute any code 208 | - Make changes to the error, request, and response objects 209 | - Send a response 210 | 211 | ### Sending Responses 212 | 213 | Check `res.wasSent()` before sending a response from an error handler to make 214 | sure that a response wasn't sent already. 215 | 216 | **[↑ back to top](#guide)** 217 | 218 | ## Event Lifecycle 219 | 220 | Here is a very simple Deno Deploy script: 221 | 222 | ```js 223 | addEventListener("fetch", (event) => { 224 | const response = new Response("Hello World!"); 225 | event.respondWith(response); 226 | }); 227 | ``` 228 | 229 | As shown, a simple Deno Deploy script essentially receives fetch events, and 230 | creates responses to respond with. 231 | 232 | Kyuko adds routing, middleware, and error handling to the event lifecycle. Here 233 | are the specific steps taken, when an event is first received until the event is 234 | responded to within a Kyuko app: 235 | 236 | --- 237 | 238 | 1. **`[SETUP]` Extraction of request information** 239 | 240 | The `req` object is created from the `event` received. The url path of the 241 | request is also extracted from the request information, for use in the next 242 | step. 243 | 244 | 1. **`[ROUTING]` Finding a route handler** 245 | 246 | A registered route path is matched from the request url path, and is used to 247 | determine the route handler. If no registered route paths match the request 248 | url path, a default handler that returns a 404 Not Found is selected to 249 | handle the request. A custom default handler can be registered via 250 | `app.default()`. 251 | 252 | > Note: only **one** route handler is chosen for each request. 253 | 254 | 1. **`[SETUP`] Creation of `req.params` and `req.query`** 255 | 256 | If a registered route path was found, the `req.params` object is populated to 257 | contain pairs of wildcard segments to corresponding url path segments. 258 | 259 | 1. **`[MIDDLEWARE]` Running middleware** 260 | 261 | Middleware registered via `app.use()` are run in this step. The middleware 262 | are given access to the `req` and `res` objects, and are free to modify them 263 | as needed. All middleware will run in order of registration and until 264 | completion, unless an error is thrown. 265 | 266 | Middleware also can choose to `defer()` logic until after the 267 | `[ROUTE HANDLING]` step is completed. 268 | 269 | A middleware can choose to respond early to an event by calling `res.send()`, 270 | `res.redirect()`, etc. In that case, the `[ROUTE HANDLING]` step will not be 271 | taken, and skips over to the `[DEFERRED HANDLERS]` step. 272 | 273 | 1. **`[ROUTE HANDLING]` Running the route handler** 274 | 275 | The **one** route handler that was chosen in the `[ROUTING]` step will be 276 | executed in this step. The route handler will not run however, if 277 | 278 | - A middleware chose to respond early 279 | - A middleware threw an error AND the error handler responded early 280 | 281 | 1. **`[DEFERRED HANDLERS]` Runs deferred handlers** 282 | 283 | Logic that is deferred in the `[MIDDLEWARE]` are run in this step. The logic 284 | will be handled LIFO, and will all run until completion unless an error is 285 | thrown. A deferred logic can also choose to respond (late) to the request. 286 | 287 | 1. **`[ERROR HANDLING]` Handling errors** 288 | 289 | This step is run only if an error was thrown during the `[MIDDLEWARE]`, 290 | `[ROUTE HANDLING]`, or `[DEFERRED HANDLERS]` steps. Error handlers registered 291 | via `app.error()` are run in order of registration and until completion. They 292 | are given access to the `err` thrown and the `req` and `res` objects, and are 293 | free to modify them as needed. 294 | 295 | Error handlers can choose to respond to the request by calling `res.send()`, 296 | `res.redirect()`, etc. If not, a 500 Internal Server Error is used as a 297 | default response. 298 | 299 | --- 300 | 301 | **[↑ back to top](#guide)** 302 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { brightRed } from "https://deno.land/std@0.113.0/fmt/colors.ts"; 2 | export { 3 | Status, 4 | STATUS_TEXT, 5 | } from "https://deno.land/std@0.113.0/http/http_status.ts"; 6 | -------------------------------------------------------------------------------- /dev_deps.ts: -------------------------------------------------------------------------------- 1 | // std 2 | export { assertEquals } from "https://deno.land/std@0.113.0/testing/asserts.ts"; 3 | export { 4 | dirname, 5 | fromFileUrl, 6 | join, 7 | } from "https://deno.land/std@0.113.0/path/mod.ts"; 8 | 9 | // dectyl 10 | export { createWorker } from "https://deno.land/x/dectyl@0.10.7/mod.ts"; 11 | export type { DeployWorker } from "https://deno.land/x/dectyl@0.10.7/mod.ts"; 12 | -------------------------------------------------------------------------------- /examples/errorHandling.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../mod.ts"; 4 | 5 | const app = new Kyuko(); 6 | 7 | /** 8 | * This will handle the error thrown. 9 | */ 10 | app.error((err, _req, res) => { 11 | if (!res.wasSent()) { 12 | res.send(err.message); 13 | } 14 | }); 15 | 16 | app.get("/", (_req, _res) => { 17 | throw new Error("An intentional error occurred!"); 18 | }); 19 | 20 | app.listen(); 21 | -------------------------------------------------------------------------------- /examples/helloWorld.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../mod.ts"; 4 | 5 | const app = new Kyuko(); 6 | 7 | app.get("/", (_, res) => { 8 | res.send("Hello World!"); 9 | }); 10 | 11 | app.get("/:name", (req, res) => { 12 | res.send(`Hello ${req.params.name}!`); 13 | }); 14 | 15 | app.listen(); 16 | -------------------------------------------------------------------------------- /examples/middleware.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../mod.ts"; 4 | import { decodeParams } from "../middleware/decodeParams.ts"; 5 | import { json, WithBody } from "../middleware/json.ts"; 6 | 7 | const app = new Kyuko(); 8 | 9 | /** 10 | * Logs the rough response time for each request. 11 | * For example purposes only! 12 | */ 13 | let id = 0; 14 | app.use((req, _res, defer) => { 15 | const unique = `${id++} ${req.path}`; 16 | console.time(unique); 17 | defer(() => { 18 | console.timeEnd(unique); 19 | }); 20 | }); 21 | 22 | app.use(decodeParams()); 23 | app.use(json()); 24 | 25 | /** 26 | * Try accessing encoded url paths such as "/Alice%20%26%20Bob". 27 | */ 28 | app.get("/:name", (req, res) => { 29 | res.send(`Hello ${req.params.name}!`); 30 | }); 31 | 32 | /** 33 | * Responds with a pretty version of the JSON request body. 34 | */ 35 | app.post("/", (req, res) => { 36 | const { requestBody } = req as WithBody; 37 | res.send(JSON.stringify(requestBody, null, 2)); 38 | }); 39 | 40 | app.listen(); 41 | -------------------------------------------------------------------------------- /examples/public/index.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | color: #027ab1; 3 | } 4 | -------------------------------------------------------------------------------- /examples/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

Hello World!

9 | 10 | 11 | -------------------------------------------------------------------------------- /examples/serveStatic.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../mod.ts"; 4 | import { serveStatic } from "../middleware/serveStatic.ts"; 5 | 6 | /** 7 | * Try accessing index.html! 8 | */ 9 | const app = new Kyuko(); 10 | app.use(serveStatic(import.meta.url, "public")); 11 | app.listen(); 12 | -------------------------------------------------------------------------------- /middleware/basicAuth.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Status } from "./deps.ts"; 4 | import { KyukoMiddleware, KyukoRequest, KyukoResponse } from "../mod.ts"; 5 | 6 | /** 7 | * An extension of `KyukoRequest` that can be used with the `basicAuth` middleware. 8 | * Adds authentication information onto the request object. 9 | * 10 | * ```ts 11 | * app.use(basicAuth(authenticator)); 12 | * 13 | * app.get("/secret", (req, res) => { 14 | * const { authenticated } = (req as WithBasicAuth).basicAuth; 15 | * if (authenticated) { 16 | * res.send("a secret message"); 17 | * } 18 | * 19 | * // ... 20 | * }); 21 | * ``` 22 | */ 23 | export interface WithBasicAuth extends KyukoRequest { 24 | basicAuth: { 25 | realm: string; 26 | authenticated: boolean; 27 | 28 | /** 29 | * The username of the authenticated user. 30 | */ 31 | user: string | undefined; 32 | }; 33 | } 34 | 35 | /** 36 | * A function that returns `true` if the username and password are valid. 37 | */ 38 | export type Authenticator = ( 39 | username: string, 40 | password: string, 41 | ) => Promise | boolean; 42 | 43 | /** 44 | * Returns a `KyukoMiddleware` that handles basic authentication. 45 | * The result of authentication is stored in `req.basicAuth`. 46 | * See [RFC7617](https://datatracker.ietf.org/doc/html/rfc7617) for more information. 47 | * 48 | * @param authenticator Authenticates the username and password supplied by the middleware. 49 | * @param realm Defines a "protection space" that can be informed to clients. 50 | * @param sendResponse Whether to automatically send a `401 Unauthorized` response on failed authentication. 51 | */ 52 | export function basicAuth( 53 | authenticator: Authenticator, 54 | realm = "Access to app", 55 | sendResponse = false, 56 | ): KyukoMiddleware { 57 | return async function basicAuth(req: KyukoRequest, res: KyukoResponse) { 58 | const _req = req as WithBasicAuth; 59 | _req.basicAuth = { 60 | realm, 61 | authenticated: false, 62 | user: undefined, 63 | }; 64 | 65 | const h = _req.headers.get("authorization"); 66 | if (!h?.startsWith("Basic ")) { 67 | return sendResponse && unauthenticated(_req, res); 68 | } 69 | 70 | const [username, password] = (h as string).substr(6).split(":").map(atob); 71 | if (!await authenticator(username, password)) { 72 | return sendResponse && unauthenticated(_req, res); 73 | } 74 | 75 | _req.basicAuth.authenticated = true; 76 | _req.basicAuth.user = username; 77 | }; 78 | } 79 | 80 | function unauthenticated(req: WithBasicAuth, res: KyukoResponse) { 81 | if (!res.wasSent()) { 82 | const { realm } = req.basicAuth; 83 | res.headers.append("WWW-Authenticate", `Basic: realm="${realm}"`); 84 | res.headers.append("WWW-Authenticate", 'charset="UTF-8"'); 85 | res.status(Status.Unauthorized).send(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /middleware/decodeParams.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { KyukoMiddleware, KyukoRequest } from "../mod.ts"; 4 | 5 | /** 6 | * Returns a `KyukoMiddleware` that decodes the values of `req.params`. 7 | */ 8 | export function decodeParams(): KyukoMiddleware { 9 | return function decodeParams(req: KyukoRequest) { 10 | Object.keys(req.params).forEach((param) => { 11 | const encoded = req.params[param]; 12 | req.params[param] = decodeURIComponent(encoded); 13 | }); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /middleware/deps.ts: -------------------------------------------------------------------------------- 1 | export { Status } from "https://deno.land/std@0.113.0/http/http_status.ts"; 2 | export { contentType } from "https://deno.land/x/media_types@v2.10.2/mod.ts"; 3 | -------------------------------------------------------------------------------- /middleware/json.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { KyukoMiddleware, KyukoRequest } from "../mod.ts"; 4 | 5 | /** 6 | * An extension of `KyukoRequest` that can be used with the `json` middleware. 7 | * The generic `T` can be supplied to assist with request body type checking. 8 | * 9 | * ```ts 10 | * interface UserSchema { 11 | * firstName: string; 12 | * middleName: string; 13 | * lastName: string; 14 | * age: number; 15 | * } 16 | * 17 | * app.use(json()); 18 | * 19 | * app.post("/", (req, res) => { 20 | * const { requestBody } = req as WithBody; 21 | * // use req.firstName,... 22 | * }); 23 | * ``` 24 | */ 25 | export interface WithBody extends KyukoRequest { 26 | requestBody: T; 27 | } 28 | 29 | /** 30 | * Returns a `KyukoMiddleware` that attempts to parse the request body as JSON. 31 | * The parsed body is stored into `req.requestBody`. 32 | * Note that `req.body` will be stay unused (hence `req.bodyUsed === false`). 33 | */ 34 | export function json(): KyukoMiddleware { 35 | return async function json(req: KyukoRequest) { 36 | const contentType = req.headers.get("content-type"); 37 | if (contentType?.includes("application/json")) { 38 | const requestClone = req.clone(); 39 | const json = await requestClone.json(); 40 | (req as WithBody).requestBody = json; 41 | } 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /middleware/serveStatic.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { contentType } from "./deps.ts"; 4 | import { KyukoMiddleware, KyukoRequest, KyukoResponse } from "../mod.ts"; 5 | 6 | /** 7 | * Returns a `KyukoMiddleware` that serves static assets. 8 | * The middleware will proxy files located under the `dir` at `url` when a 9 | * request is made to `path`. If you wish to serve static files that are hosted 10 | * alongside your source code (the Kyuko app), you should set `url` to 11 | * `import.meta.url`. Note that you must add a trailing slash "/" to `url` when 12 | * specifying a remote url other than `import.meta.main`. 13 | * 14 | * ```ts 15 | * // static assets placed alongside the app will be served 16 | * app.use(serveStatic(import.meta.url)); 17 | * ``` 18 | * 19 | * @param url The url that the static assets are located at. 20 | * @param dir The directory under the url that the static assets are located at. Default is ".". 21 | * @param path The root path where requests made will be served static files. Default is "/". 22 | * @returns The middleware. 23 | */ 24 | export function serveStatic( 25 | url: string, 26 | dir = ".", 27 | path = "/", 28 | ): KyukoMiddleware { 29 | return async function serveStatic(req: KyukoRequest, res: KyukoResponse) { 30 | if (req.method === "GET" && req.path.startsWith(path)) { 31 | const fileName = req.path.split("/").at(-1) as string; 32 | const contentTypeHeader = contentType(fileName); 33 | 34 | // contentTypeHeader = undefined if file can't be served statically 35 | if (contentTypeHeader) { 36 | const fileUrl = new URL(dir + req.path, url); 37 | const file = await fetch(fileUrl); 38 | if (file.ok && !res.wasSent()) { 39 | res.headers.set("content-type", contentTypeHeader); 40 | res.body = file.body; 41 | res.status(file.status).send(); 42 | } 43 | } 44 | } 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | export { Kyuko } from "./src/Kyuko.ts"; 4 | export type { 5 | KyukoDeferFunction, 6 | KyukoDeferredHandler, 7 | KyukoErrorHandler, 8 | KyukoMiddleware, 9 | KyukoRouteHandler, 10 | } from "./src/Kyuko.ts"; 11 | export type { KyukoRequest } from "./src/KyukoRequest.ts"; 12 | export type { KyukoResponse } from "./src/KyukoResponse.ts"; 13 | -------------------------------------------------------------------------------- /src/Kyuko.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | /// 4 | /// 5 | /// 6 | 7 | import { brightRed, Status } from "../deps.ts"; 8 | import { KyukoRequest, KyukoRequestImpl } from "./KyukoRequest.ts"; 9 | import { KyukoResponse, KyukoResponseImpl } from "./KyukoResponse.ts"; 10 | import { RoutePathHandler } from "./RoutePathHandler.ts"; 11 | 12 | /** 13 | * A function that is invoked in response to fetch requests. 14 | * Runs after all middleware functions have been called. 15 | */ 16 | export type KyukoRouteHandler = ( 17 | req: KyukoRequest, 18 | res: KyukoResponse, 19 | ) => Promise | unknown; 20 | 21 | /** 22 | * A function that is invoked before the route handler is called. 23 | * Hands over execution to the next middleware / route handler on return. 24 | */ 25 | export type KyukoMiddleware = ( 26 | req: KyukoRequest, 27 | res: KyukoResponse, 28 | defer: KyukoDeferFunction, 29 | ) => Promise | unknown; 30 | 31 | /** 32 | * A function that is called by a `KyukoMiddleware` when it wants to defer some 33 | * processing until after the route handler is called. 34 | * 35 | * ```ts 36 | * app.use((req, res, defer) => { 37 | * // ... before route handling 38 | * 39 | * defer((req, res) => { 40 | * // ... after route handling 41 | * }); 42 | * }); 43 | * ``` 44 | */ 45 | export type KyukoDeferFunction = (deferred: KyukoDeferredHandler) => void; 46 | 47 | /** 48 | * A function that is registered by a `KyukoMiddleware`, 49 | * to be invoked after the route handling step is done. 50 | */ 51 | export type KyukoDeferredHandler = ( 52 | req: KyukoRequest, 53 | res: KyukoResponse, 54 | ) => Promise | unknown; 55 | 56 | /** 57 | * A function that is invoked when errors are thrown within the Kyuko app. 58 | * Has access to the `err` object as well as the `req` and `res` objects. 59 | * Hands over execution to the next error handler on return. 60 | */ 61 | export type KyukoErrorHandler = ( 62 | err: Error, 63 | req: KyukoRequest, 64 | res: KyukoResponse, 65 | ) => Promise | unknown; 66 | 67 | /** 68 | * An ultra-light framework for http servers hosted on [Deno Deploy](https://deno.com/deploy). 69 | * Visit the [guide](https://github.com/rikilele/kyuko#guide) for more information. 70 | */ 71 | export class Kyuko { 72 | #routes; 73 | #middleware: KyukoMiddleware[]; 74 | #errorHandlers: KyukoErrorHandler[]; 75 | #defaultHandler: KyukoRouteHandler; 76 | #customHandlers: Map>; 77 | 78 | /** 79 | * Initializes a new Kyuko app. 80 | */ 81 | constructor() { 82 | this.#routes = new RoutePathHandler(); 83 | this.#middleware = []; 84 | this.#errorHandlers = []; 85 | this.#defaultHandler = (_, res) => res.status(Status.NotFound).send(); 86 | this.#customHandlers = new Map(); 87 | this.#customHandlers.set("GET", new Map()); 88 | this.#customHandlers.set("POST", new Map()); 89 | this.#customHandlers.set("PUT", new Map()); 90 | this.#customHandlers.set("DELETE", new Map()); 91 | this.#customHandlers.set("PATCH", new Map()); 92 | this.#customHandlers.set("HEAD", new Map()); 93 | } 94 | 95 | /** 96 | * Registers a `handler` that is invoked when 97 | * GET requests are made to url paths that match the `routePath`. 98 | * 99 | * ```ts 100 | * app.get('/', (req, res) => { 101 | * const { name } = req.query; 102 | * res.send(`Hello ${name}!`); 103 | * }); 104 | * ``` 105 | */ 106 | get(routePath: string, handler: KyukoRouteHandler) { 107 | this.#routes.addRoutePath(routePath); 108 | this.#customHandlers.get("GET")?.set(routePath, handler); 109 | } 110 | 111 | /** 112 | * Registers a `handler` that is invoked when 113 | * POST requests are made to url paths that match the `routePath`. 114 | */ 115 | post(routePath: string, handler: KyukoRouteHandler) { 116 | this.#routes.addRoutePath(routePath); 117 | this.#customHandlers.get("POST")?.set(routePath, handler); 118 | } 119 | 120 | /** 121 | * Registers a `handler` that is invoked when 122 | * PUT requests are made to url paths that match the `routePath`. 123 | * 124 | * ```ts 125 | * app.put('/users/:id', (req, res) => { 126 | * const { id } = req.params; 127 | * 128 | * // ... 129 | * 130 | * res.status(204).send(`Updated ${id}!`); 131 | * }); 132 | * ``` 133 | */ 134 | put(routePath: string, handler: KyukoRouteHandler) { 135 | this.#routes.addRoutePath(routePath); 136 | this.#customHandlers.get("PUT")?.set(routePath, handler); 137 | } 138 | 139 | /** 140 | * Registers a `handler` that is invoked when 141 | * DELETE requests are made to url paths that match the `routePath`. 142 | */ 143 | delete(routePath: string, handler: KyukoRouteHandler) { 144 | this.#routes.addRoutePath(routePath); 145 | this.#customHandlers.get("DELETE")?.set(routePath, handler); 146 | } 147 | 148 | /** 149 | * Registers a `handler` that is invoked when 150 | * PATCH requests are made to url paths that match the `routePath`. 151 | */ 152 | patch(routePath: string, handler: KyukoRouteHandler) { 153 | this.#routes.addRoutePath(routePath); 154 | this.#customHandlers.get("PATCH")?.set(routePath, handler); 155 | } 156 | 157 | /** 158 | * Registers a `handler` that is invoked when 159 | * HEAD requests are made to url paths that match the `routePath`. 160 | * 161 | * ```ts 162 | * app.head("/", (_, res) => { 163 | * res.headers.append("content-type", "text/plain;charset=UTF-8"); 164 | * res.headers.append("content-length", "12"); 165 | * res.send(); 166 | * }); 167 | * ``` 168 | */ 169 | head(routePath: string, handler: KyukoRouteHandler) { 170 | this.#routes.addRoutePath(routePath); 171 | this.#customHandlers.get("HEAD")?.set(routePath, handler); 172 | } 173 | 174 | /** 175 | * Registers a `handler` that is invoked when 176 | * any type of requests are made to url paths that match the `routePath`. 177 | */ 178 | all(routePath: string, handler: KyukoRouteHandler) { 179 | this.#routes.addRoutePath(routePath); 180 | this.#customHandlers.get("GET")?.set(routePath, handler); 181 | this.#customHandlers.get("POST")?.set(routePath, handler); 182 | this.#customHandlers.get("PUT")?.set(routePath, handler); 183 | this.#customHandlers.get("DELETE")?.set(routePath, handler); 184 | this.#customHandlers.get("PATCH")?.set(routePath, handler); 185 | this.#customHandlers.get("HEAD")?.set(routePath, handler); 186 | } 187 | 188 | /** 189 | * Registers a default `handler` that is invoked when 190 | * a request isn't caught by any other custom handlers. 191 | */ 192 | default(handler: KyukoRouteHandler) { 193 | this.#defaultHandler = handler; 194 | } 195 | 196 | /** 197 | * Adds `middleware` to a list of application-level middleware to run. 198 | * Middleware are invoked in order of addition via `use()`. 199 | */ 200 | use(middleware: KyukoMiddleware) { 201 | this.#middleware.push(middleware); 202 | } 203 | 204 | /** 205 | * Adds `errorHandler` to a list of application-level error handlers. 206 | * Error handlers are invoked in order of addition via `error()`. 207 | * 208 | * > Note that in Express, you call `use()` instead. 209 | */ 210 | error(errorHandler: KyukoErrorHandler) { 211 | this.#errorHandlers.push(errorHandler); 212 | } 213 | 214 | /** 215 | * Starts listening to 'fetch' requests. 216 | * @param callback Called when server starts listening. 217 | */ 218 | listen(callback?: VoidFunction) { 219 | addEventListener("fetch", this.handleFetchEvent.bind(this)); 220 | callback && callback(); 221 | } 222 | 223 | private handleFetchEvent(event: FetchEvent) { 224 | const req = new KyukoRequestImpl(event); 225 | const res = new KyukoResponseImpl(event); 226 | const { pathname, searchParams } = new URL(req.url); 227 | 228 | // Handle routing 229 | let routeHandler: KyukoRouteHandler = this.#defaultHandler; 230 | const routePath = this.#routes.findMatch(pathname); 231 | if (routePath !== undefined) { 232 | const customHandlers = this.#customHandlers.get(req.method); 233 | if (customHandlers?.has(routePath)) { 234 | routeHandler = customHandlers.get(routePath) as KyukoRouteHandler; 235 | } 236 | 237 | // Fill req.params 238 | req.params = RoutePathHandler.createPathParams(routePath, pathname); 239 | } 240 | 241 | // Fill req.query 242 | searchParams.forEach((value, key) => { 243 | req.query.append(key, value); 244 | }); 245 | 246 | // Fill req.path 247 | req.path = RoutePathHandler.sanitizePath(pathname); 248 | 249 | this.invokeHandlers(req, res, routeHandler); 250 | } 251 | 252 | private async invokeHandlers( 253 | req: KyukoRequest, 254 | res: KyukoResponse, 255 | routeHandler: KyukoRouteHandler, 256 | ) { 257 | // Run middleware 258 | const deferredHandlers: KyukoDeferredHandler[] = []; 259 | try { 260 | for (const middleware of this.#middleware) { 261 | await middleware(req, res, (deferred) => { 262 | deferredHandlers.push(deferred); 263 | }); 264 | } 265 | } catch (err) { 266 | console.error(brightRed("Error in KyukoMiddleware:")); 267 | console.error(err); 268 | this.handleError(err, req, res); 269 | } 270 | 271 | // Run route handler 272 | try { 273 | if (!res.wasSent()) { 274 | await routeHandler(req, res); 275 | } 276 | } catch (err) { 277 | console.error(brightRed("Error in KyukoRouteHandler:")); 278 | console.error(err); 279 | this.handleError(err, req, res); 280 | } 281 | 282 | // Run deferred handlers 283 | try { 284 | while (deferredHandlers.length > 0) { 285 | const deferred = deferredHandlers.pop() as KyukoDeferredHandler; 286 | await deferred(req, res); 287 | } 288 | } catch (err) { 289 | console.error(brightRed("Error in KyukoDeferredHandler:")); 290 | console.error(err); 291 | this.handleError(err, req, res); 292 | } 293 | } 294 | 295 | private async handleError(err: Error, req: KyukoRequest, res: KyukoResponse) { 296 | try { 297 | for (const errorHandler of this.#errorHandlers) { 298 | await errorHandler(err, req, res); 299 | } 300 | } catch (ohShit) { 301 | console.error(brightRed("Error in KyukoErrorHandler:")); 302 | console.error(ohShit); 303 | } 304 | 305 | if (!res.wasSent()) { 306 | res.status(Status.InternalServerError).send(); 307 | } 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/KyukoRequest.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | /// 4 | /// 5 | /// 6 | 7 | /** 8 | * The request object that is handled in Kyuko applications. 9 | * Can be extended further for middleware to populate the original `Request`. 10 | */ 11 | export interface KyukoRequest extends Request { 12 | /** 13 | * Stores path parameters and their values in an object. 14 | */ 15 | params: Record; 16 | 17 | /** 18 | * Stores query parameters and their values. 19 | * Note that a single key may map to multiple different values. 20 | */ 21 | query: URLSearchParams; 22 | 23 | /** 24 | * Stores the sanitized path of the request, where leading and trailing 25 | * slashes are stripped off accordingly. 26 | */ 27 | path: string; 28 | } 29 | 30 | /** 31 | * This class is instantiated when a fetch request is captured by a Kyuko application. 32 | * The instance is populated by the original request handed over from the event listener. 33 | */ 34 | export class KyukoRequestImpl extends Request implements KyukoRequest { 35 | params: Record; 36 | query: URLSearchParams; 37 | path: string; 38 | 39 | /** 40 | * Instantiates a `KyukoRequest` based on the original `fetchEvent` request. 41 | * @param fetchEvent The event that this request originated from. 42 | */ 43 | constructor(fetchEvent: FetchEvent) { 44 | super(fetchEvent.request); 45 | this.params = {}; 46 | this.query = new URLSearchParams(); 47 | this.path = "/"; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/KyukoResponse.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | /// 4 | /// 5 | /// 6 | 7 | import { Status, STATUS_TEXT } from "../deps.ts"; 8 | 9 | /** 10 | * The response object that is handled in Kyuko applications. 11 | * Responsible for storing information about the response to the request, 12 | * as well as sending the response via the `send()` and `redirect()` methods. 13 | * 14 | * Note that `send()` or `redirect()` **must** be called or else the request will hang. 15 | */ 16 | export interface KyukoResponse { 17 | body: BodyInit | null; 18 | statusCode: number | undefined; 19 | statusText: string | undefined; 20 | headers: Headers; 21 | 22 | /** 23 | * Sets the status code to `status`, and returns `this`. 24 | */ 25 | status(status: Status): KyukoResponse; 26 | 27 | /** 28 | * Redirects the request to a new `address`. 29 | * The `address` can be either a relative url path, or a full url. 30 | * The optional `status` parameter can be used to set a custom status code. 31 | * Otherwise overrides the current `res.statusCode` with 302. 32 | * 33 | * @param address The address to redirect to. 34 | * @param status The status code of the response. Defaults to 302. 35 | */ 36 | redirect(address: string, status?: number): void; 37 | 38 | /** 39 | * Sends a proper json response to the original request. 40 | * The json is `stringify`'d from the input JavaScript `object`. 41 | * 42 | * @param object The object to respond with. 43 | */ 44 | // deno-lint-ignore no-explicit-any 45 | json(object: any): void; 46 | 47 | /** 48 | * Sends a response to the original request that instantiated this object. 49 | * The response is built using the public attributes of this object, 50 | * which should've been set by the user beforehand. 51 | * 52 | * @param body A response body that would supersede `this.body` 53 | */ 54 | send(body?: BodyInit): void; 55 | 56 | /** 57 | * @returns Whether the response was sent (`send()` was called) or not. 58 | */ 59 | wasSent(): boolean; 60 | } 61 | 62 | /** 63 | * This class is instantiated when a fetch request is captured by a Kyuko application. 64 | */ 65 | export class KyukoResponseImpl implements KyukoResponse { 66 | body: BodyInit | null; 67 | statusCode: number | undefined; 68 | statusText: string | undefined; 69 | headers: Headers; 70 | #sent: boolean; 71 | #fetchEvent: FetchEvent; 72 | 73 | /** 74 | * Instantiates a `KyukoResponse` based on the original `fetchEvent` request. 75 | * @param fetchEvent The original event that this response is responsible to respond to. 76 | */ 77 | constructor(fetchEvent: FetchEvent) { 78 | this.body = null; 79 | this.statusCode = undefined; 80 | this.statusText = undefined; 81 | this.headers = new Headers(); 82 | this.#sent = false; 83 | this.#fetchEvent = fetchEvent; 84 | } 85 | 86 | status(status: Status) { 87 | this.statusCode = status; 88 | const statusText = STATUS_TEXT.get(status); 89 | if (statusText !== undefined) { 90 | this.statusText = statusText; 91 | } 92 | 93 | return this; 94 | } 95 | 96 | redirect(address: string, status = 302) { 97 | if (this.#sent) { 98 | throw new Error("Can't send multiple responses to a single request"); 99 | } 100 | 101 | this.status(status); 102 | this.headers.append("Location", encodeURI(address)); 103 | this.send(); 104 | } 105 | 106 | // deno-lint-ignore no-explicit-any 107 | json(object: any) { 108 | if (this.#sent) { 109 | throw new Error("Can't send multiple responses to a single request"); 110 | } 111 | 112 | this.headers.append("content-type", "application/json; charset=UTF-8"); 113 | this.send(JSON.stringify(object)); 114 | } 115 | 116 | send(body?: BodyInit) { 117 | if (this.#sent) { 118 | throw new Error("Can't send multiple responses to a single request"); 119 | } 120 | 121 | const response = new Response( 122 | body || this.body, 123 | { 124 | status: this.statusCode, 125 | statusText: this.statusText, 126 | headers: this.headers, 127 | }, 128 | ); 129 | 130 | this.#fetchEvent.respondWith(response); 131 | this.#sent = true; 132 | } 133 | 134 | wasSent() { 135 | return this.#sent; 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/RoutePathHandler.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | /** 4 | * A handler that stores different route paths registered by Kyuko, 5 | * and offers methods to match url paths to those route paths. 6 | * 7 | * Note on handling slashes: 8 | * - Recurring leading slashes will be merged and considered as one slash 9 | * - Recurring slashes that appear mid-path will contribute to empty paths 10 | * - A single trailing slash will be ignored 11 | * 12 | * For more details, see: https://datatracker.ietf.org/doc/html/rfc3986. 13 | */ 14 | export class RoutePathHandler { 15 | #rootNode = RoutePathNode.createRoot(); 16 | 17 | /** 18 | * Given that the `urlPath` matches the `routePath`, 19 | * compares the two strings and constructs an object that contains 20 | * the wildcards as its keys and the corresponding url path segments as its values. 21 | * The object an be used directly as `req.params`. 22 | * 23 | * @param routePath e.g.) "/users/:userId/friends/:friendId" 24 | * @param urlPath e.g.) "/users/Alice/friends/Bob" 25 | * @returns e.g.) { userId: "Alice", friendId: "Bob" } 26 | */ 27 | static createPathParams(routePath: string, urlPath: string) { 28 | const result: Record = {}; 29 | const routeSegments = RoutePathHandler.splitPathSegments(routePath); 30 | const urlSegments = RoutePathHandler.splitPathSegments(urlPath); 31 | routeSegments.forEach((routeSegment, i) => { 32 | if (routeSegment.startsWith(":")) { 33 | result[routeSegment.substring(1)] = urlSegments[i]; 34 | } 35 | }); 36 | 37 | return result; 38 | } 39 | 40 | /** 41 | * Returns the sanitized input `path`. 42 | * See "Note on handling slashes" from `RoutePathHandler` class description. 43 | * 44 | * @param path The path to sanitize. 45 | * @returns The sanitized path. 46 | */ 47 | static sanitizePath(path: string) { 48 | const split = RoutePathHandler.splitPathSegments(path); 49 | if (split.at(-1) === "") { 50 | split.push(""); 51 | } 52 | 53 | return split.join("/"); 54 | } 55 | 56 | /** 57 | * Splits the given path into an array of path segments. 58 | * Note that `splitPathSegments(path).join('/') !== path`. 59 | * 60 | * Examples: 61 | * - `'/'` => `['']` 62 | * - `'//'` => `['']` 63 | * - `'/users'` => `['', 'users']` 64 | * - `'//users'` => `['', 'users']` 65 | * - `'/users/'` => `['', 'users']` 66 | * - `'/users/:id'` => `['', 'users', ':id']` 67 | * - `'/users//:id'` => `['', 'users', '', ':id']` 68 | * 69 | * @param path The route or url path to split 70 | */ 71 | private static splitPathSegments(path: string): string[] { 72 | const result = path.split("/"); 73 | const divider = result.findIndex((seg) => seg !== ""); 74 | if (divider === -1) { 75 | return [""]; 76 | } 77 | 78 | result.splice(0, divider - 1); 79 | if (result[result.length - 1] === "") { 80 | result.pop(); 81 | } 82 | 83 | return result; 84 | } 85 | 86 | /** 87 | * Adds a route path to the handler. 88 | * Added route paths will be considered in subsequent calls to `findMatch()`. 89 | * 90 | * @param path A valid Kyuko route path such as "/", "/users", "/users/:id" 91 | */ 92 | addRoutePath(routePath: string): void { 93 | const segments = RoutePathHandler.splitPathSegments(routePath); 94 | let currNode = this.#rootNode; 95 | segments.forEach((segment) => { 96 | currNode = currNode.findOrCreateChild(segment); 97 | }); 98 | 99 | currNode.isStationaryNode = true; 100 | } 101 | 102 | /** 103 | * Returns a route path that matches the input urlPath. 104 | * Returns undefined if no such path exists. 105 | * Prioritizes route paths that have early exact matches rather than wildcards, 106 | * and route paths that were added earlier to the handler. 107 | * 108 | * @param urlPath The path to match. 109 | * @returns matched path if exists. undefined if not. 110 | */ 111 | findMatch(urlPath: string): string | undefined { 112 | const segments = RoutePathHandler.splitPathSegments(urlPath); 113 | let currNodes: RoutePathNode[] = [this.#rootNode]; 114 | segments.forEach((segment, i) => { 115 | if (currNodes.length === 0) { 116 | return undefined; 117 | } 118 | 119 | const nextNodes: RoutePathNode[] = []; 120 | currNodes.forEach((node) => { 121 | node.findMatchingChildren(segment).forEach((child) => { 122 | // Child should be taller than remaining path 123 | if (child.getHeight() >= segments.length - i - 1) { 124 | nextNodes.push(child); 125 | } 126 | }); 127 | }); 128 | 129 | currNodes = nextNodes; 130 | }); 131 | 132 | const finalists = currNodes.filter((node) => node.isStationaryNode); 133 | if (finalists.length === 0) { 134 | return undefined; 135 | } 136 | 137 | return finalists[0].routePath; 138 | } 139 | } 140 | 141 | /** 142 | * Represents a segment of a route path as a tree node. 143 | * For example, ["", "users", ":id"] are segments of the route path "/users/:id". 144 | * Each segment of the route path will be stored in a tree data structure as nodes. 145 | */ 146 | class RoutePathNode { 147 | /** 148 | * Whether the node can be considered the end of a path or not. 149 | */ 150 | isStationaryNode: boolean; 151 | 152 | /** 153 | * The full route path that the node represents. 154 | * Dependent on the specific node's parent. 155 | */ 156 | routePath: string; 157 | 158 | #value: string; 159 | #height: number; 160 | #parent: RoutePathNode | null; 161 | #concreteChildren: Map; 162 | #wildcardChildren: Map; 163 | 164 | /** 165 | * @returns A new `RoutePathNode` that acts as the root of the tree. 166 | */ 167 | static createRoot(): RoutePathNode { 168 | return new RoutePathNode("\0", null); 169 | } 170 | 171 | /** 172 | * Constructs a `RoutePathNode` object. 173 | * Private constructor to prevent clients from creating circular trees. 174 | * Use `RoutePathNode.createRoot()` to create root nodes. 175 | * 176 | * @param value The value of the segment of the route path. '\0' for root node. 177 | * @param parent The parent of the segment of the route path. null for root node. 178 | */ 179 | private constructor(value: string, parent: RoutePathNode | null) { 180 | this.isStationaryNode = false; 181 | this.#value = value; 182 | this.#height = 0; 183 | this.#parent = parent; 184 | this.#concreteChildren = new Map(); 185 | this.#wildcardChildren = new Map(); 186 | 187 | // Construct the routePath 188 | if (parent === null) { 189 | this.routePath = ""; 190 | } else if (parent.#value === "\0") { 191 | this.routePath = "/"; 192 | } else if (parent.routePath === "/") { 193 | this.routePath = `/${value}`; 194 | } else { 195 | this.routePath = `${parent.routePath}/${value}`; 196 | } 197 | } 198 | 199 | /** 200 | * @returns The height of the node (= # of nodes until furthest leaf) 201 | */ 202 | getHeight(): number { 203 | return this.#height; 204 | } 205 | 206 | /** 207 | * Finds and returns a child node that contains the **exact** `value`. 208 | * If the child doesn't exist, adds a new child node and returns that. 209 | * 210 | * @param value The value of the child node to find or create. 211 | * @returns The child node with the corresponding `value` (could be newly-created). 212 | */ 213 | findOrCreateChild(value: string): RoutePathNode { 214 | let container = this.#concreteChildren; 215 | if (value.startsWith(":")) { 216 | container = this.#wildcardChildren; 217 | } 218 | 219 | if (container.has(value)) { 220 | return container.get(value) as RoutePathNode; 221 | } 222 | 223 | const newNode = new RoutePathNode(value, this); 224 | container.set(value, newNode); 225 | newNode.updateTreeHeight(); 226 | return newNode; 227 | } 228 | 229 | /* 230 | * To be called by `findOrCreateChild()`. 231 | * Assumes that `this` refers to a newly created child node. 232 | */ 233 | private updateTreeHeight() { 234 | let currNode = this as RoutePathNode; 235 | while (currNode.#parent !== null) { 236 | const parentNode = currNode.#parent; 237 | 238 | // Parent has taller children 239 | if (parentNode.#height !== currNode.#height) { 240 | return; 241 | } 242 | 243 | parentNode.#height += 1; 244 | currNode = parentNode; 245 | } 246 | } 247 | 248 | /** 249 | * Finds and returns children that match the `value`, including wildcards. 250 | * 251 | * @param value The value of the child node to match. 252 | * @returns An array of children that matches the `value`, including wildcards 253 | */ 254 | findMatchingChildren(value: string): RoutePathNode[] { 255 | const result: RoutePathNode[] = []; 256 | if (this.#concreteChildren.has(value)) { 257 | result.push(this.#concreteChildren.get(value) as RoutePathNode); 258 | } 259 | 260 | this.#wildcardChildren.forEach((child) => { 261 | result.push(child); 262 | }); 263 | 264 | return result; 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /test/Kyuko/assertResponse.ts: -------------------------------------------------------------------------------- 1 | import { DeployWorker } from "../../dev_deps.ts"; 2 | 3 | /** 4 | * Calls the supplied `asserts` function. 5 | * Calls `script.close()` regardless of assertion result. 6 | * **MUST** `await` this function when called. 7 | */ 8 | export async function assertResponse( 9 | script: DeployWorker, 10 | asserts: () => Promise | void, 11 | ) { 12 | try { 13 | await asserts(); 14 | } catch (err) { 15 | throw err; 16 | } finally { 17 | await script.close(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/Kyuko/helloWorld.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | createWorker, 4 | dirname, 5 | fromFileUrl, 6 | join, 7 | } from "../../dev_deps.ts"; 8 | import { assertResponse } from "./assertResponse.ts"; 9 | 10 | const __dirname = dirname(fromFileUrl(import.meta.url)); 11 | 12 | Deno.test("hello world", async () => { 13 | const script = await createWorker(join(__dirname, "./scripts/helloWorld.ts")); 14 | await script.start(); 15 | const [response] = await script.fetch("/"); 16 | await assertResponse(script, async () => { 17 | assertEquals(await response.text(), "Hello World!"); 18 | }); 19 | }); 20 | 21 | Deno.test("hello world 404", async () => { 22 | const script = await createWorker(join(__dirname, "./scripts/helloWorld.ts")); 23 | await script.start(); 24 | const [response] = await script.fetch("/Alice"); 25 | await assertResponse(script, () => { 26 | assertEquals(response.ok, false); 27 | assertEquals(response.status, 404); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/Kyuko/pathParams.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | createWorker, 4 | dirname, 5 | fromFileUrl, 6 | join, 7 | } from "../../dev_deps.ts"; 8 | import { assertResponse } from "./assertResponse.ts"; 9 | 10 | const __dirname = dirname(fromFileUrl(import.meta.url)); 11 | 12 | Deno.test("single path parameter is set", async () => { 13 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 14 | await script.start(); 15 | const [response] = await script.fetch("/users/Alice"); 16 | await assertResponse(script, async () => { 17 | assertEquals(await response.text(), "Alice"); 18 | }); 19 | }); 20 | 21 | Deno.test("multiple path parameters are set", async () => { 22 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 23 | await script.start(); 24 | const [response] = await script.fetch("/users/Alice/friends/Bob"); 25 | await assertResponse(script, async () => { 26 | assertEquals(await response.text(), "Alice+Bob"); 27 | }); 28 | }); 29 | 30 | Deno.test("empty path parameter is set", async () => { 31 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 32 | await script.start(); 33 | const [response] = await script.fetch("/users//"); 34 | await assertResponse(script, async () => { 35 | assertEquals(await response.text(), ""); 36 | }); 37 | }); 38 | 39 | Deno.test("multiple empty path parameters are set", async () => { 40 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 41 | await script.start(); 42 | const [response] = await script.fetch("/users//friends//"); 43 | await assertResponse(script, async () => { 44 | assertEquals(await response.text(), "+"); 45 | }); 46 | }); 47 | 48 | Deno.test("trailing slash doesn't mess up", async () => { 49 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 50 | await script.start(); 51 | const [response] = await script.fetch("/users/Alice/"); 52 | await assertResponse(script, async () => { 53 | assertEquals(await response.text(), "Alice"); 54 | }); 55 | }); 56 | 57 | Deno.test("ambiguous 404", async () => { 58 | const script = await createWorker(join(__dirname, "./scripts/pathParams.ts")); 59 | await script.start(); 60 | const [response] = await script.fetch("/friends/"); 61 | await assertResponse(script, () => { 62 | assertEquals(response.ok, false); 63 | assertEquals(response.status, 404); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/Kyuko/query.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | assertEquals, 3 | createWorker, 4 | dirname, 5 | fromFileUrl, 6 | join, 7 | } from "../../dev_deps.ts"; 8 | import { assertResponse } from "./assertResponse.ts"; 9 | 10 | const __dirname = dirname(fromFileUrl(import.meta.url)); 11 | 12 | Deno.test("returns query string", async () => { 13 | const script = await createWorker(join(__dirname, "./scripts/query.ts")); 14 | await script.start(); 15 | const [response] = await script.fetch("/?q=Query"); 16 | await assertResponse(script, async () => { 17 | assertEquals(await response.text(), "q=Query"); 18 | }); 19 | }); 20 | 21 | Deno.test("handles multiple query strings", async () => { 22 | const script = await createWorker(join(__dirname, "./scripts/query.ts")); 23 | await script.start(); 24 | const [response] = await script.fetch("/?q=Query&qs=QueryString"); 25 | await assertResponse(script, async () => { 26 | assertEquals(await response.text(), "q=Query&qs=QueryString"); 27 | }); 28 | }); 29 | 30 | Deno.test("handles duplicate keys", async () => { 31 | const script = await createWorker(join(__dirname, "./scripts/query.ts")); 32 | await script.start(); 33 | const [response] = await script.fetch("/?q=Query&q=QueryString"); 34 | await assertResponse(script, async () => { 35 | assertEquals(await response.text(), "q=Query&q=QueryString"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /test/Kyuko/scripts/helloWorld.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../../../mod.ts"; 4 | 5 | const app = new Kyuko(); 6 | 7 | app.get("/", (_, res) => { 8 | res.send("Hello World!"); 9 | }); 10 | 11 | app.listen(); 12 | -------------------------------------------------------------------------------- /test/Kyuko/scripts/pathParams.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../../../mod.ts"; 4 | 5 | const app = new Kyuko(); 6 | 7 | app.get("/users/:userId", (req, res) => { 8 | res.send(req.params.userId); 9 | }); 10 | 11 | app.get("/users/:userId/friends/:friendId", (req, res) => { 12 | res.send(req.params.userId + "+" + req.params.friendId); 13 | }); 14 | 15 | app.listen(); 16 | -------------------------------------------------------------------------------- /test/Kyuko/scripts/query.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { Kyuko } from "../../../mod.ts"; 4 | 5 | const app = new Kyuko(); 6 | 7 | app.get("/", (req, res) => { 8 | res.send(req.query.toString()); 9 | }); 10 | 11 | app.listen(); 12 | -------------------------------------------------------------------------------- /test/RoutePathHandler.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2021 Riki Singh Khorana. All rights reserved. MIT license. 2 | 3 | import { assertEquals } from "../dev_deps.ts"; 4 | import { RoutePathHandler } from "../src/RoutePathHandler.ts"; 5 | 6 | Deno.test("empty handler", () => { 7 | const pathHandler = new RoutePathHandler(); 8 | assertEquals(pathHandler.findMatch("/"), undefined); 9 | assertEquals(pathHandler.findMatch("/users"), undefined); 10 | assertEquals(pathHandler.findMatch("/users/Alice"), undefined); 11 | assertEquals(pathHandler.findMatch("/users/Alice/friends"), undefined); 12 | assertEquals(pathHandler.findMatch("/users/Alice/friends/Bob"), undefined); 13 | }); 14 | 15 | Deno.test("handler handles /", () => { 16 | const pathHandler = new RoutePathHandler(); 17 | pathHandler.addRoutePath("/"); 18 | pathHandler.addRoutePath("/users"); 19 | pathHandler.addRoutePath("/users/:userId"); 20 | pathHandler.addRoutePath("/users/:userId/friends"); 21 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 22 | assertEquals(pathHandler.findMatch("/"), "/"); 23 | }); 24 | 25 | Deno.test("handler handles /users", () => { 26 | const pathHandler = new RoutePathHandler(); 27 | pathHandler.addRoutePath("/"); 28 | pathHandler.addRoutePath("/users"); 29 | pathHandler.addRoutePath("/users/:userId"); 30 | pathHandler.addRoutePath("/users/:userId/friends"); 31 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 32 | assertEquals(pathHandler.findMatch("/users"), "/users"); 33 | }); 34 | 35 | Deno.test("handler handles /users/:userId", () => { 36 | const pathHandler = new RoutePathHandler(); 37 | pathHandler.addRoutePath("/"); 38 | pathHandler.addRoutePath("/users"); 39 | pathHandler.addRoutePath("/users/:userId"); 40 | pathHandler.addRoutePath("/users/:userId/friends"); 41 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 42 | assertEquals(pathHandler.findMatch("/users/Alice"), "/users/:userId"); 43 | assertEquals(pathHandler.findMatch("/users/Bob"), "/users/:userId"); 44 | assertEquals(pathHandler.findMatch("/users/Charlie"), "/users/:userId"); 45 | }); 46 | 47 | Deno.test("handler handles /users/:userId/friends", () => { 48 | const pathHandler = new RoutePathHandler(); 49 | pathHandler.addRoutePath("/"); 50 | pathHandler.addRoutePath("/users"); 51 | pathHandler.addRoutePath("/users/:userId"); 52 | pathHandler.addRoutePath("/users/:userId/friends"); 53 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 54 | assertEquals( 55 | pathHandler.findMatch("/users/Alice/friends"), 56 | "/users/:userId/friends", 57 | ); 58 | assertEquals( 59 | pathHandler.findMatch("/users/Bob/friends"), 60 | "/users/:userId/friends", 61 | ); 62 | assertEquals( 63 | pathHandler.findMatch("/users/Charlie/friends"), 64 | "/users/:userId/friends", 65 | ); 66 | }); 67 | 68 | Deno.test("handler handles /users/:userId/friends/:friendId", () => { 69 | const pathHandler = new RoutePathHandler(); 70 | pathHandler.addRoutePath("/"); 71 | pathHandler.addRoutePath("/users"); 72 | pathHandler.addRoutePath("/users/:userId"); 73 | pathHandler.addRoutePath("/users/:userId/friends"); 74 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 75 | assertEquals( 76 | pathHandler.findMatch("/users/Alice/friends/Bob"), 77 | "/users/:userId/friends/:friendId", 78 | ); 79 | assertEquals( 80 | pathHandler.findMatch("/users/Alice/friends/Charlie"), 81 | "/users/:userId/friends/:friendId", 82 | ); 83 | assertEquals( 84 | pathHandler.findMatch("/users/Bob/friends/Alice"), 85 | "/users/:userId/friends/:friendId", 86 | ); 87 | assertEquals( 88 | pathHandler.findMatch("/users/Bob/friends/Charlie"), 89 | "/users/:userId/friends/:friendId", 90 | ); 91 | assertEquals( 92 | pathHandler.findMatch("/users/Charlie/friends/Alice"), 93 | "/users/:userId/friends/:friendId", 94 | ); 95 | assertEquals( 96 | pathHandler.findMatch("/users/Charlie/friends/Bob"), 97 | "/users/:userId/friends/:friendId", 98 | ); 99 | }); 100 | 101 | Deno.test("handler ignores trailing /", () => { 102 | const pathHandler = new RoutePathHandler(); 103 | pathHandler.addRoutePath("/"); 104 | pathHandler.addRoutePath("/users"); 105 | pathHandler.addRoutePath("/users/:userId"); 106 | pathHandler.addRoutePath("/users/:userId/friends"); 107 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 108 | assertEquals(pathHandler.findMatch("/users/"), "/users"); 109 | assertEquals(pathHandler.findMatch("/users/Alice/"), "/users/:userId"); 110 | assertEquals( 111 | pathHandler.findMatch("/users/Alice/friends/"), 112 | "/users/:userId/friends", 113 | ); 114 | assertEquals( 115 | pathHandler.findMatch("/users/Alice/friends/Bob/"), 116 | "/users/:userId/friends/:friendId", 117 | ); 118 | }); 119 | 120 | Deno.test("handler ignores multiple leading /", () => { 121 | const pathHandler = new RoutePathHandler(); 122 | pathHandler.addRoutePath("/"); 123 | pathHandler.addRoutePath("/users"); 124 | pathHandler.addRoutePath("/users/:userId"); 125 | pathHandler.addRoutePath("/users/:userId/friends"); 126 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 127 | assertEquals(pathHandler.findMatch("//"), "/"); 128 | assertEquals(pathHandler.findMatch("///users"), "/users"); 129 | assertEquals(pathHandler.findMatch("////users/Alice"), "/users/:userId"); 130 | assertEquals( 131 | pathHandler.findMatch("/////users/Alice/friends"), 132 | "/users/:userId/friends", 133 | ); 134 | assertEquals( 135 | pathHandler.findMatch("//////users/Alice/friends/Bob"), 136 | "/users/:userId/friends/:friendId", 137 | ); 138 | }); 139 | 140 | Deno.test("handler recognizes empty paths", () => { 141 | const pathHandler = new RoutePathHandler(); 142 | pathHandler.addRoutePath("/"); 143 | pathHandler.addRoutePath("/users"); 144 | pathHandler.addRoutePath("/users/:userId"); 145 | pathHandler.addRoutePath("/users/:userId/friends"); 146 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 147 | assertEquals( 148 | pathHandler.findMatch("/users//friends"), 149 | "/users/:userId/friends", 150 | ); 151 | assertEquals( 152 | pathHandler.findMatch("/users//friends/Bob"), 153 | "/users/:userId/friends/:friendId", 154 | ); 155 | assertEquals(pathHandler.findMatch("/users/Alice//friends"), undefined); 156 | assertEquals(pathHandler.findMatch("/users/Alice//friends/Bob"), undefined); 157 | assertEquals(pathHandler.findMatch("/users/Alice//Bob"), undefined); 158 | assertEquals(pathHandler.findMatch("/users/friends//Bob"), undefined); 159 | }); 160 | 161 | Deno.test("handler handles mix of / caveats", () => { 162 | const pathHandler = new RoutePathHandler(); 163 | pathHandler.addRoutePath("/"); 164 | pathHandler.addRoutePath("/users"); 165 | pathHandler.addRoutePath("/users/:userId"); 166 | pathHandler.addRoutePath("/users/:userId/friends"); 167 | pathHandler.addRoutePath("/users/:userId/friends/:friendId"); 168 | assertEquals(pathHandler.findMatch("//users/"), "/users"); 169 | assertEquals(pathHandler.findMatch("/users//"), "/users/:userId"); 170 | assertEquals(pathHandler.findMatch("//users//"), "/users/:userId"); 171 | assertEquals( 172 | pathHandler.findMatch("//users//friends/"), 173 | "/users/:userId/friends", 174 | ); 175 | assertEquals( 176 | pathHandler.findMatch("/users//friends//"), 177 | "/users/:userId/friends/:friendId", 178 | ); 179 | assertEquals( 180 | pathHandler.findMatch("//users//friends//"), 181 | "/users/:userId/friends/:friendId", 182 | ); 183 | assertEquals(pathHandler.findMatch("/users///"), undefined); 184 | assertEquals(pathHandler.findMatch("//users///"), undefined); 185 | assertEquals(pathHandler.findMatch("/users//friends///"), undefined); 186 | assertEquals(pathHandler.findMatch("//users//friends///"), undefined); 187 | }); 188 | 189 | /** 190 | * Custom cases 191 | */ 192 | 193 | Deno.test("handler doesn't match partial url paths", () => { 194 | const pathHandler = new RoutePathHandler(); 195 | pathHandler.addRoutePath("/users/:userId"); 196 | assertEquals(pathHandler.findMatch("/"), undefined); 197 | assertEquals(pathHandler.findMatch("//"), undefined); 198 | assertEquals(pathHandler.findMatch("/users"), undefined); 199 | assertEquals(pathHandler.findMatch("/users/"), undefined); 200 | assertEquals(pathHandler.findMatch("//users/"), undefined); 201 | }); 202 | 203 | Deno.test("handler doesn't match partial route paths", () => { 204 | const pathHandler = new RoutePathHandler(); 205 | pathHandler.addRoutePath("/users/:userId"); 206 | assertEquals(pathHandler.findMatch("/users/Alice/friends"), undefined); 207 | assertEquals(pathHandler.findMatch("/users/:userId/friends"), undefined); 208 | }); 209 | 210 | Deno.test("handler doesn't confuse root path", () => { 211 | const pathHandler = new RoutePathHandler(); 212 | pathHandler.addRoutePath("/:slug"); 213 | assertEquals(pathHandler.findMatch("/"), undefined); 214 | assertEquals(pathHandler.findMatch("//"), undefined); 215 | }); 216 | 217 | Deno.test("handler prioritizes route path registered earliest", () => { 218 | const pathHandler = new RoutePathHandler(); 219 | pathHandler.addRoutePath("/:id"); 220 | pathHandler.addRoutePath("/:id2"); 221 | for (let i = 0; i < 20; i++) { 222 | assertEquals(pathHandler.findMatch("/users"), "/:id"); 223 | } 224 | }); 225 | 226 | Deno.test("handler prioritizes route path with exact match", () => { 227 | const pathHandler = new RoutePathHandler(); 228 | pathHandler.addRoutePath("/:slug"); 229 | pathHandler.addRoutePath("/users"); 230 | for (let i = 0; i < 20; i++) { 231 | assertEquals(pathHandler.findMatch("/users"), "/users"); 232 | } 233 | }); 234 | 235 | Deno.test("handler prioritizes route path with early exact match", () => { 236 | const pathHandler = new RoutePathHandler(); 237 | pathHandler.addRoutePath("/:slug/:id/friends"); 238 | pathHandler.addRoutePath("/users/:id/friends"); 239 | for (let i = 0; i < 20; i++) { 240 | assertEquals( 241 | pathHandler.findMatch("/users/Alice/friends"), 242 | "/users/:id/friends", 243 | ); 244 | } 245 | }); 246 | 247 | Deno.test("handler doesn't confuse deceiving early match", () => { 248 | const pathHandler = new RoutePathHandler(); 249 | pathHandler.addRoutePath("/:slug/:id/friends"); 250 | pathHandler.addRoutePath("/users/:id"); 251 | for (let i = 0; i < 20; i++) { 252 | assertEquals( 253 | pathHandler.findMatch("/users/Alice/friends"), 254 | "/:slug/:id/friends", 255 | ); 256 | assertEquals(pathHandler.findMatch("/users/Alice"), "/users/:id"); 257 | } 258 | }); 259 | 260 | /** 261 | * Static 262 | */ 263 | 264 | Deno.test("creates empty req.params", () => { 265 | const { createPathParams } = RoutePathHandler; 266 | assertEquals(createPathParams("/", "/"), {}); 267 | assertEquals(createPathParams("/users", "/users"), {}); 268 | }); 269 | 270 | Deno.test("creates req.params properly", () => { 271 | const { createPathParams } = RoutePathHandler; 272 | assertEquals(createPathParams("/users/:userId", "/users/Alice"), { 273 | userId: "Alice", 274 | }); 275 | assertEquals( 276 | createPathParams( 277 | "/users/:userId/friends/:friendId", 278 | "/users/Alice/friends/Bob", 279 | ), 280 | { userId: "Alice", friendId: "Bob" }, 281 | ); 282 | }); 283 | 284 | Deno.test("sanitizes leading slashes correctly", () => { 285 | const { sanitizePath } = RoutePathHandler; 286 | assertEquals(sanitizePath("/"), "/"); 287 | assertEquals(sanitizePath("//"), "/"); 288 | assertEquals(sanitizePath("///users"), "/users"); 289 | assertEquals(sanitizePath("///users/Alice"), "/users/Alice"); 290 | }); 291 | 292 | Deno.test("sanitizes trailing slashes correctly", () => { 293 | const { sanitizePath } = RoutePathHandler; 294 | assertEquals(sanitizePath("/users/"), "/users"); 295 | assertEquals(sanitizePath("/users/Alice/"), "/users/Alice"); 296 | }); 297 | 298 | Deno.test("sanitizes mid-path slashes correctly", () => { 299 | const { sanitizePath } = RoutePathHandler; 300 | assertEquals(sanitizePath("/users//"), "/users//"); 301 | assertEquals(sanitizePath("/users///"), "/users///"); 302 | assertEquals(sanitizePath("/users//Alice/"), "/users//Alice"); 303 | assertEquals(sanitizePath("/users///Alice/"), "/users///Alice"); 304 | }); 305 | --------------------------------------------------------------------------------