├── .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 | [](https://github.com/rikilele/kyuko/actions/workflows/ci.yml)
2 | [](https://github.com/rikilele/kyuko/releases)
3 | [](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 |
--------------------------------------------------------------------------------