├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── _examples └── server.ts ├── constants.ts ├── context.test.ts ├── context.ts ├── deno.json ├── logger.ts ├── mod.ts ├── request_event_cfw.ts ├── request_server_bun.ts ├── request_server_deno.test.ts ├── request_server_deno.ts ├── request_server_node.ts ├── route.ts ├── router.test.ts ├── router.ts ├── routing.bench.ts ├── schema.test.ts ├── schema.ts ├── status_route.ts ├── testing_utils.ts ├── types.ts ├── utils.ts └── uuid.bench.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | version: [1.x, canary] 11 | steps: 12 | - name: clone repository 13 | uses: actions/checkout@v4 14 | 15 | - name: install deno 16 | uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: ${{ matrix.version }} 19 | 20 | - name: check format 21 | run: deno fmt --check 22 | 23 | - name: check linting 24 | run: deno lint 25 | 26 | - name: run tests 27 | run: deno task test 28 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | permissions: 12 | contents: read 13 | id-token: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | - run: npx jsr publish 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "linkcode", 4 | "Removeable", 5 | "respondable", 6 | "valibot" 7 | ], 8 | "deno.unstable": ["kv"] 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # acorn change log 2 | 3 | ## Version 1.1.1 4 | 5 | - chore: update std and commons (b4fb1e4) 6 | - chore: update deps and changes for Deno 2 (d1fd693) 7 | - chore: add canary to ci (7debfc0) 8 | 9 | ## Version 1.1.0 10 | 11 | - docs: export schema types to allow docgen (5b295bc) 12 | - feat: add automatic options method handling (a273916) 13 | 14 | ## Version 1.0.0 15 | 16 | A fundamental rewrite of acorn to provide a cleaner and more cohesive way to 17 | create RESTful services. 18 | 19 | Notable features of acorn 1.0: 20 | 21 | - Integrated request body, response body and request query parameter validation 22 | via integrated [valibot](https://valibot.dev/). 23 | - Integrated logger supporting logging to the console, rotating files or 24 | streams. 25 | - A more rich `Context` which provides short-cuts for common RESTful situations 26 | like redirection, object creation and not found handling. In addition 27 | providing runtime agnostic access to environment variables. 28 | - Default handling of exceptions, not found resources and method not allowed. 29 | - Status routes to allow generalized handling of response statuses. 30 | - Hooks to provide insight into the inner workings of acorn. 31 | - More robust support for Node.js, Bun, and Cloudflare Workers. 32 | 33 | ## Version 0.7.1 34 | 35 | - docs: improve examples (7a20b7e) 36 | 37 | ## Version 0.7.0 38 | 39 | - feat: support Cloudflare Workers (2eecd21) 40 | - feat: include duration in handled event (b6cbe17) 41 | 42 | **BREAKING CHANGE** Previously the `HandledEvent` contained the performance 43 | measurement of the handling of the route. It now only contains a `duration` 44 | property which represents in milliseconds how long it took to handle the 45 | request. 46 | 47 | - fix: use `createPromiseWithResolvers()` (b6023f8) 48 | - fix: add dynamic npm imports (acc5756) 49 | 50 | ## Version 0.6.0 51 | 52 | - chore: add publish workflow (ee5b869) 53 | - feat: support Node.js (3088469) 54 | - feat: add ErrorEvent polyfill for Node.js (c5b1819) 55 | - fix: don't await respond (ac90976) 56 | 57 | ## Version 0.5.1 58 | 59 | - fix: polyfill URLPattern under Bun (8e21f32) 60 | - fix: move loading of URLPattern polyfill (828fed3) 61 | - fix: add urlpattern-polyfill to deno.json (25edc9) 62 | - fix: move npm dep to package.json (8a615f1) 63 | - fix: fix npm dependencies (f09b303) 64 | - docs: update inline docs and readme around Bun (297a38f) 65 | 66 | ## Version 0.5.0 67 | 68 | - feat: use `Deno.serve()` instead of `Deno.serveHttp()` (014019b) 69 | 70 | **BREAKING CHANGE** acorn now uses the `Deno.serve()` API instead of 71 | `Deno.serveHttp()` which is deprecated. This requires some breaking changes in 72 | the way options are supplied on `.listen()` for SSL/TLS connections. 73 | 74 | - feat: web socket upgrade auto-responds (46e9e07) 75 | 76 | **BREAKING CHANGE** acorn now sends the response when performing a websocket 77 | upgrade. Previously the `Response` to initiate the connection would be 78 | returned from the function and it was the responsibility of the user to send 79 | that back to the client. 80 | 81 | - feat: support Bun's http server (7f08edc) 82 | 83 | acorn will detect if it is running in the Bun runtime and utilize Bun's built 84 | in HTTP server. 85 | 86 | Currently web socket upgrade are not supported when running under Bun. 87 | 88 | - fix: make handling requests more robust (3df07a4) 89 | - fix: memory leak in server wrapper (8920f73) 90 | - tests: add benchmark test (9f2fb34) 91 | - chore: update to std 0.212.0, commons 0.5.0 (8e3ffa7) 92 | - chore: updates to prep for JSR publish (cca89b7) 93 | - chore: update ci (14b51d5) 94 | - chore: add parallel flag to tests (1011b87) 95 | - docs: add a couple module docs (acdca92) 96 | 97 | ## Version 0.4.0 98 | 99 | - feat: add userAgent property to context (d680246) 100 | 101 | The context now includes a property called `.userAgent` which includes 102 | information about the request's user agent in available. 103 | 104 | - feat: expose RouterRequestEvent in mod.ts (c9ed546) 105 | 106 | - feat: add web socket upgrade method to context (4030953) 107 | 108 | A convenience method was added to be able to attempt to upgrade a connection 109 | related to a request to a web socket. 110 | 111 | - fix: abort signal on router listen (3e8a21d) 112 | 113 | Previously, while an abort signal could be passed on listening, it did not 114 | actually close the server. Now it does. 115 | 116 | - refactor: improve performance of context (47b66a4) 117 | - refactor: rename test files (6b14801) 118 | - refactor: improve `router.on()`` overload (ba96525) 119 | - refactor: use deferred from std (9e9df97) 120 | - refactor: router internal state (01dc63f) 121 | - refactor: rename types.d.ts to types.ts (b67e2bb) 122 | - chore: update copyright header dates (4705f92) 123 | - chore: update to std 0.194.0 (29ca68e) 124 | - docs: add missing inline docs (57e9d1d) 125 | - docs: make docs reflect code (07794a6) 126 | - docs: add philosophy to readme (c49fdfc) 127 | - docs: add example of SSE (e1ed289) 128 | 129 | ## Version 0.3.0 130 | 131 | - feat: add request address to context (#1) 132 | - chore: update to std 0.190.0 (d6abbb8) 133 | - docs: update README badges (652598e) 134 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2024 the oak authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | 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, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # acorn 2 | 3 | [![jsr.io/@oak/acorn](https://jsr.io/badges/@oak/acorn)](https://jsr.io/@oak/acorn) 4 | [![jsr.io/@oak/acorn score](https://jsr.io/badges/@oak/acorn/score)](https://jsr.io/@oak/acorn) 5 | 6 | A focused framework for creating RESTful JSON services across various JavaScript 7 | and TypeScript runtime environments including [Deno runtime](https://deno.com/), 8 | [Deno Deploy](https://deno.com/deploy), [Node.js](https://nodejs.org/), 9 | [Bun](https://bun.sh/) and 10 | [Cloudflare Workers](https://workers.cloudflare.com/). 11 | 12 | It focuses on providing a router which handles inbound requests and makes it 13 | trivial to respond to those requests with JSON. It also provides several other 14 | features which make creating API servers with acorn production ready. acorn is a 15 | focused framework for creating RESTful JSON services across 16 | 17 | ## Basic usage 18 | 19 | acorn is designed to work on many different JavaScript and TypeScript runtimes, 20 | including Deno, Node.js, Bun, and Cloudflare Workers. Basic usage requires 21 | installing acorn to your project and then creating a router to handle requests. 22 | 23 | ### Installing for Deno 24 | 25 | To install acorn for Deno, you can install it via the Deno runtime CLI: 26 | 27 | ``` 28 | deno add jsr:@oak/acorn 29 | ``` 30 | 31 | ### Installing for Node.js or Cloudflare Workers 32 | 33 | To install acorn for Node.js or Cloudflare Workers, you can install it via your 34 | preferred package manager. 35 | 36 | #### npm 37 | 38 | ``` 39 | npx jsr add @oak/acorn 40 | ``` 41 | 42 | #### yarn 43 | 44 | ``` 45 | yarn dlx jsr add @oak/acorn 46 | ``` 47 | 48 | #### pnpm 49 | 50 | ``` 51 | pnpm dlx jsr add @oak/acorn 52 | ``` 53 | 54 | ### Installing for Bun 55 | 56 | To install acorn for Bun, you can install it via the Bun runtime CLI: 57 | 58 | ``` 59 | bunx jsr add @oak/acorn 60 | ``` 61 | 62 | ### Usage with Deno, Node.js, and Bun 63 | 64 | Basic usage of acorn for Deno, Node.js, and Bun is the same. You import the 65 | `Router`, create an instance of it, register routes on the router, and then 66 | called the `.listen()` method on the router to start listening for requests: 67 | 68 | ```ts 69 | import { Router } from "@oak/acorn"; 70 | 71 | const router = new Router(); 72 | router.get("/", () => ({ hello: "world" })); 73 | router.listen({ port: 3000 }); 74 | ``` 75 | 76 | ### Usage with Cloudflare Workers 77 | 78 | Basic usage for Cloudflare Workers requires exporting a fetch handler which is 79 | integrated into the router, and therefore you export the router as the default 80 | export of the module: 81 | 82 | ```ts 83 | import { Router } from "@oak/acorn"; 84 | 85 | const router = new Router(); 86 | router.get("/", () => ({ hello: "world" })); 87 | export default router; 88 | ``` 89 | 90 | ## Router 91 | 92 | The `Router` is the core of acorn and is responsible for handling inbound 93 | requests and routing them to the appropriate handler. The router provides 94 | methods for registering routes for different HTTP methods and handling requests 95 | for those routes. 96 | 97 | ### Default behaviors 98 | 99 | The router provides several automatic behaviors which are designed to make 100 | creating RESTful JSON services easier. These behaviors include handling 101 | `404 Not Found` responses, `405 Method Not Allowed` responses, and providing a 102 | default response for `OPTIONS` requests. 103 | 104 | #### Not Found 105 | 106 | When a request is received by the router and no route is matched, the router 107 | will send a `404 Not Found` response to the client. This is the default behavior 108 | of the router and can be overridden by providing a `onNotFound` hook to the 109 | router. 110 | 111 | #### Method Not Allowed 112 | 113 | When a request is received by the router and a route is matched but there is no 114 | handler for the method of the request, the router will send a 115 | `405 Method Not Allowed` response to the client which will provide the allowed 116 | methods. This is the default behavior of the router and can be overridden by 117 | providing a status handler. 118 | 119 | #### Options 120 | 121 | When a request is received by the router and the method of the request is 122 | `OPTIONS`, the router will send a response to the client with the allowed 123 | methods for the route. This is the default behavior of the router and can be 124 | overridden by providing an `options()` route. 125 | 126 | ## Context 127 | 128 | The `Context` is the object passed to route handlers and provides information 129 | about the request and runtime environment. The context object provides access to 130 | the `Request` object as well as other useful properties and methods for handling 131 | requests. 132 | 133 | ### `addr` 134 | 135 | The network address of the originator of the request as presented to the runtime 136 | environment. 137 | 138 | ### `cookies` 139 | 140 | The cookies object which can be used to get and set cookies for the request. If 141 | encryptions keys are provided to the router, the cookies will be 142 | cryptographically verified and signed to ensure their integrity. 143 | 144 | ### `env` 145 | 146 | The environment variables available to the runtime environment. This assists in 147 | providing access to the environment variables for the runtime environment 148 | without having to code specifically for each runtime environment. 149 | 150 | ### `id` 151 | 152 | A unique identifier for the request event. This can be useful for logging and 153 | tracking requests. 154 | 155 | ### `params` 156 | 157 | The parameters extracted from the URL path by the router. 158 | 159 | ### `request` 160 | 161 | The Fetch API standard `Request` object which should be handled. 162 | 163 | ### `responseHeaders` 164 | 165 | The headers that will be sent with the response. This will be merged with other 166 | headers to finalize the reponse. 167 | 168 | ### `url` 169 | 170 | The URL object representing the URL of the request. 171 | 172 | ### `userAgent` 173 | 174 | A parsed version of the `User-Agent` header from the request. This can be used 175 | to determine the type of client making the request. 176 | 177 | ### `body()` 178 | 179 | A method which returns a promise that resolves with the body of the request 180 | assumed to be JSON. If the body is not JSON, an error will be thrown. If a body 181 | schema is provided to the route, the body will be validated against that schema 182 | before being returned. 183 | 184 | ### `conflict()` 185 | 186 | A method which throws a `409 Conflict` error and takes an optional message and 187 | optional cause. 188 | 189 | ### `created()` 190 | 191 | A method which returns a `Response` with a `201 Created` status code. The method 192 | takes the body of the response and an optional object with options for the 193 | response. If a `location` property is provided in the options, the response will 194 | include a `Location` header with the value of the location. 195 | 196 | If `locationParams` is provided in the options, the location will be 197 | interpolated with the parameters provided. 198 | 199 | ### `notFound()` 200 | 201 | A method which throws a `404 Not Found` error and takes an optional message and 202 | optional cause. 203 | 204 | ### `queryParams()` 205 | 206 | A method which returns a promise that resolves with the query parameters of the 207 | request. If a query parameter schema is provided to the route, the query 208 | parameters will be validated against that schema before being returned. 209 | 210 | ### `redirect()` 211 | 212 | A method which sends a redirect response to the client. The method takes a 213 | location and an optional init object with options for the response. If the 214 | location is a path with parameters, the `params` object can be provided to 215 | interpolate the parameters into the URL. 216 | 217 | ### `throw()` 218 | 219 | A method which can be used to throw an HTTP error which will be caught by the 220 | router and handled appropriately. The method takes a status code and an optional 221 | message which will be sent to the client. 222 | 223 | ### `created()` 224 | 225 | A method which returns a `Response` with a `201 Created` status code. The method 226 | takes the body of the response and an optional object with options for the 227 | response. 228 | 229 | This is an appropriate response when a `POST` request is made to a resource 230 | collection and the resource is created successfully. The options should be 231 | included with a `location` property set to the URL of the created resource. The 232 | `params` property can be used to provide parameters to the URL. For example if 233 | `location` is `/books/:id` and `params` is `{ id: 1 }` the URL will be 234 | `/books/1`. 235 | 236 | ### `conflict()` 237 | 238 | A method which throws a `409 Conflict` error and takes an optional message and 239 | optional cause. 240 | 241 | This is an appropriate response when a `PUT` request is made to a resource that 242 | cannot be updated because it is in a state that conflicts with the request. 243 | 244 | ### `sendEvents()` 245 | 246 | A method which starts sending server-sent events to the client. This method 247 | returns a `ServerSentEventTarget` which can be used to dispatch events to the 248 | client. 249 | 250 | ### `upgrade()` 251 | 252 | A method which can be used to upgrade the request to a `WebSocket` connection. 253 | When the request is upgraded, the request will be handled as a web socket 254 | connection and the method will return a `WebSocket` which can be used to 255 | communicate with the client. 256 | 257 | **Note:** This method is only available in the Deno runtime and Deno Deploy 258 | currently. If you call this method in a different runtime, an error will be 259 | thrown. 260 | 261 | ## Router Handlers 262 | 263 | The `RouteHandler` is the function which is called when a route is matched by 264 | the router. The handler is passed the `Context` object and is expected to return 265 | a response. The response can be a plain object which will be serialized to JSON, 266 | a `Response` object. The handler can also return `undefined` if the handler 267 | wishes to return a no content response. The handler can also return a promise 268 | which resolves with any of the above. 269 | 270 | ### Registering Routes 271 | 272 | Routes can be registered on the router using the various methods provided by the 273 | router. The most common methods are `get()`, `post()`, `put()`, `patch()`, and 274 | `delete()`. In addition `options()` and `head()` are provided. 275 | 276 | The methods take a path pattern and a handler function, and optionally an object 277 | with options for the route (`RouteInit`). The path pattern is a string which can 278 | include parameters and pattern matching syntax. The handler function is called 279 | when the route is matched and is passed the context object. 280 | 281 | For example, to register a route which responds to a `GET` request: 282 | 283 | ```ts 284 | router.get("/", () => ({ hello: "world" })); 285 | ``` 286 | 287 | The methods also accept a `RouteDescriptor` object, or a path along with a set 288 | of options (`RouteInitWithHandler`) which includes the handler function. 289 | 290 | For example, to register a route which responds to a `POST` request: 291 | 292 | ```ts 293 | router.post("/", { 294 | handler: () => ({ hello: "world" }), 295 | }); 296 | ``` 297 | 298 | And for a route which responds to a `PUT` request with the full descriptor: 299 | 300 | ```ts 301 | router.put({ 302 | path: "/", 303 | handler: () => ({ hello: "world" }), 304 | }); 305 | ``` 306 | 307 | ### Hooks 308 | 309 | The router provides hooks which can be used to get information about the routing 310 | process and to potentially modify the response. The hooks are provided when 311 | creating the router and are called at various points in the routing process. 312 | 313 | #### `onRequest()` 314 | 315 | The `onRequest` hook is called when a request is received by the router. The 316 | `RequestEvent` object is provided to the hook and can be used to inspect the 317 | request. 318 | 319 | The `onRequest` could invoke the `.respond()` method on the `RequestEvent` but 320 | this should be avoided. 321 | 322 | #### `onNotFound()` 323 | 324 | As a request is being handled by the router, if no route is matched or the route 325 | handler returns a `404 Not Found` response the `onNotFound` hook is called. 326 | There is a details object which provides the `RequestEvent`being handled, any 327 | `Response` that has been provided (but not yet sent to the client) and the 328 | `Route` that was matched, if any. 329 | 330 | The `onNotFound` hook can return a response to be sent to the client. If the 331 | hook returns `undefined`, the router will continue processing the request. 332 | 333 | #### `onHandled()` 334 | 335 | After a request has been processed by the router and a response has been sent to 336 | the client, the `onHandled` hook is called. The hook is provided with a set of 337 | details which include the `RequestEvent`, the `Response`, the `Route` that was 338 | matched, and the time in milliseconds that the request took to process. 339 | 340 | #### `onError()` 341 | 342 | If an unhandled error occurs in a handler, the `onError` hook is called. The 343 | hook is provided with a set of details which include the `RequestEvent`, the 344 | `Response` that was provided, the error that occurred, and the `Route` that was 345 | matched, if any. 346 | 347 | ## Route Parameters 348 | 349 | The router can extract parameters from the URL path and provide them to the 350 | route handler. The parameters are extracted from the URL path based on the 351 | pattern matching syntax provided by the 352 | [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) library. The 353 | parameters are provided to the handler as an object with the parameter names as 354 | the keys and the values as the values. 355 | 356 | For example, to register a route which extracts a parameter from the URL path: 357 | 358 | ```ts 359 | router.get("/:name", (ctx) => { 360 | return { hello: ctx.params.name }; 361 | }); 362 | ``` 363 | 364 | ## Status Handlers 365 | 366 | acorn provides a mechanism for observing or modifying the response to a request 367 | based on the status of the response. This is done using status handlers which 368 | are registered on the router. The status handlers are called when a response is 369 | being sent to the client and the status of the response matches the status or 370 | status range provided to the handler. 371 | 372 | This is intended to be able to provide consistent and customized responses to 373 | status codes across all routes in the router. For example, you could provide a 374 | status handler to handle all `404 Not Found` responses and provide a consistent 375 | response to the client: 376 | 377 | ```ts 378 | import { Router } from "@oak/acorn"; 379 | import { Status, STATUS_TEXT } from "@oak/commons/status"; 380 | 381 | const router = new Router(); 382 | 383 | router.on(Status.NotFound, () => { 384 | return Response.json( 385 | { error: "Not Found" }, 386 | { status: Status.NotFound, statusText: STATUS_TEXT[Status.NotFound] }, 387 | ); 388 | }); 389 | ``` 390 | 391 | ## Schema Validation 392 | 393 | acorn integrates the [Valibot](https://valibot.dev/) library to provide schema 394 | validation for query strings, request bodies, and responses. This allows you to 395 | define the shape of the data you expect to receive and send and have it 396 | validated automatically. 397 | 398 | You can provide a schema to the route when registering it on the router. The 399 | schema is an object which describes the shape of the data you expect to receive 400 | or send. The schema is defined using the Valibot schema definition language. 401 | 402 | For example, to define a schema for a request body: 403 | 404 | ```ts 405 | import { Router, v } from "@oak/acorn"; 406 | 407 | const router = new Router(); 408 | 409 | router.post("/", () => ({ hello: "world" }), { 410 | schema: { 411 | body: v.object({ 412 | name: v.string(), 413 | }), 414 | }, 415 | }); 416 | ``` 417 | 418 | This ensures that the request body is an object with a `name` property which is 419 | a string. If the request body does not match this schema, an error will be 420 | thrown and the request will not be processed and a `Bad Request` response will 421 | be sent to the client. 422 | 423 | You can provide an optional invalid handler to the schema which will be called 424 | when the schema validation fails. This allows you to provide a custom response 425 | to the client when the request does not match the schema. 426 | 427 | ## RESTful JSON Services 428 | 429 | acorn is designed to make it easy to create RESTful JSON services. The router 430 | provides a simple and expressive way to define routes and has several features 431 | which make it easy to create production ready services. 432 | 433 | ### HTTP Errors 434 | 435 | acorn provides a mechanism for throwing HTTP errors from route handlers. The 436 | `throw()` method on the context object can be used to throw an HTTP error. HTTP 437 | errors are caught by the router and handled appropriately. The router will send 438 | a response to the client with the status code and message provided to the 439 | `throw()` method with the body of the response respecting the content 440 | negotiation headers provided by the client. 441 | 442 | ### No Content Responses 443 | 444 | If a handler returns `undefined`, the router will send a `204 No Content` 445 | response to the client. This is useful when a request is successful but there is 446 | no content to return to the client. 447 | 448 | No content responses are appropriate for `PUT` or `PATCH` requests that are 449 | successful but you do not want to return the updated resource to the client. 450 | 451 | ### Created Responses 452 | 453 | The `created()` method on the context object can be used to send a `201 Created` 454 | response to the client. This is appropriate when a `POST` request is made to a 455 | resource collection and the resource is created successfully. The method takes 456 | the body of the response and an optional object with options for the response. 457 | 458 | The options should be included with a `location` property set to the URL of the 459 | created resource. The `params` property can be used to provide parameters to the 460 | URL. For example if `location` is `/books/:id` and `params` is `{ id: 1 }` the 461 | URL will be `/books/1`. 462 | 463 | ### Conflict Responses 464 | 465 | The `conflict()` method on the context object can be used to throw a 466 | `409 Conflict` error. This is appropriate when a `PUT` request is made to a 467 | resource that cannot be updated because it is in a state that conflicts with the 468 | request. 469 | 470 | ### Redirect Responses 471 | 472 | If you need to redirect the client to a different URL, you can use the 473 | `redirect()` method on the context object. This method takes a URL and an 474 | optional status code and will send a redirect response to the client. 475 | 476 | In addition, if the `location` is a path with parameters, you can provide the 477 | `params` object to the `redirect()` method which will be used to populate the 478 | parameters in the URL. 479 | 480 | ## Logging 481 | 482 | acorn integrates the [LogTape](https://jsr.io/@logtape/logtape) library to 483 | provide logging capabilities for the router and routes. 484 | 485 | To enable logging, you can provide a `LoggerOptions` object on the property 486 | `logger` to the router when creating it: 487 | 488 | ```ts 489 | const router = new Router({ 490 | logger: { 491 | console: { level: "debug" }, 492 | }, 493 | }); 494 | ``` 495 | 496 | Alternatively, you can simply set the `logger` property to `true` to log events 497 | at the `"WARN"` level to the console: 498 | 499 | ```ts 500 | const router = new Router({ 501 | logger: true, 502 | }); 503 | ``` 504 | 505 | --- 506 | 507 | Copyright 2018-2024 the oak authors. All rights reserved. MIT License. 508 | -------------------------------------------------------------------------------- /_examples/server.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | /** 4 | * This is a simple example of a REST API using acorn and Deno KV storage. 5 | * 6 | * It contains the following routes: 7 | * 8 | * - `GET /` - A simple route that increments a count in a cookie and returns 9 | * the count. 10 | * - `GET /redirect` - A route that redirects to `/book/1` using route 11 | * parameters. 12 | * - `GET /book` - A route that returns a list of all books. 13 | * - `GET /book/:id` - A route that returns a single book by its id. 14 | * - `POST /book` - A route that creates a new book. 15 | * - `PUT /book/:id` - A route that updates a book by its id, you must put a 16 | * full book object in the body. 17 | * - `PATCH /book/:id` - A route that updates a book by its id. 18 | * - `DELETE /book/:id` - A route that deletes a book by its id. 19 | * 20 | * @module 21 | */ 22 | 23 | import { Router, Status, v } from "../mod.ts"; 24 | import { assert } from "@oak/commons/assert"; 25 | 26 | const router = new Router({ logger: { console: true } }); 27 | 28 | const db = await Deno.openKv(); 29 | 30 | const book = v.object({ 31 | author: v.string(), 32 | title: v.string(), 33 | }); 34 | 35 | const bookList = v.array(book); 36 | 37 | type Book = v.InferOutput; 38 | 39 | const bookPatch = v.object({ 40 | author: v.optional(v.string()), 41 | title: v.optional(v.string()), 42 | }); 43 | 44 | router.get("/", async (ctx) => { 45 | const count = parseInt(await ctx.cookies.get("count") ?? "0", 10) + 1; 46 | await ctx.cookies.set("count", String(count)); 47 | return { hello: "world", count }; 48 | }); 49 | 50 | router.get("/redirect", (ctx) => { 51 | return ctx.redirect("/book/:id", { 52 | params: { id: "1" }, 53 | status: Status.TemporaryRedirect, 54 | }); 55 | }); 56 | 57 | router.get("/book", async () => { 58 | const books: Book[] = []; 59 | const bookEntries = db.list({ prefix: ["books"] }); 60 | for await (const { key, value } of bookEntries) { 61 | if (key[1] === "id") { 62 | continue; 63 | } 64 | console.log(key, value); 65 | books.push(value); 66 | } 67 | return books; 68 | }, { schema: { response: bookList } }); 69 | 70 | router.get("/book/:id", async (ctx) => { 71 | const id = parseInt(ctx.params.id, 10); 72 | const maybeBook = await db 73 | .get(["books", id]); 74 | if (!maybeBook.value) { 75 | ctx.throw(Status.NotFound, "Book not found"); 76 | } 77 | return maybeBook.value; 78 | }, { schema: { response: book } }); 79 | 80 | router.post("/book", async (ctx) => { 81 | const body = await ctx.body(); 82 | assert(body, "Body required."); 83 | const idEntry = await db.get(["books", "id"]); 84 | const id = (idEntry.value ?? 0) + 1; 85 | const result = await db.atomic() 86 | .check({ key: ["books", "id"], versionstamp: idEntry.versionstamp }) 87 | .set(["books", "id"], id) 88 | .set(["books", id], body) 89 | .commit(); 90 | if (!result.ok) { 91 | ctx.throw(Status.InternalServerError, "Conflict updating the book id"); 92 | } 93 | return ctx.created(body, { 94 | location: `/book/:id`, 95 | params: { id: String(id) }, 96 | }); 97 | }, { schema: { body: book, response: book } }); 98 | 99 | router.put("/book/:id", async (ctx) => { 100 | const body = await ctx.body(); 101 | const id = parseInt(ctx.params.id, 10); 102 | const bookEntry = await db.get(["books", id]); 103 | if (!bookEntry.value) { 104 | ctx.throw(Status.NotFound, "Book not found"); 105 | } 106 | const result = await db.atomic() 107 | .check({ key: ["books", id], versionstamp: bookEntry.versionstamp }) 108 | .set(["books", id], body) 109 | .commit(); 110 | if (!result.ok) { 111 | ctx.throw(Status.InternalServerError, "Conflict updating the book"); 112 | } 113 | return book; 114 | }, { schema: { body: book, response: book } }); 115 | 116 | router.patch("/book/:id", async (ctx) => { 117 | const body = await ctx.body(); 118 | const id = parseInt(ctx.params.id, 10); 119 | const bookEntry = await db.get(["books", id]); 120 | if (!bookEntry.value) { 121 | ctx.throw(Status.NotFound, "Book not found"); 122 | } 123 | const book = { ...bookEntry.value, ...body }; 124 | const result = await db.atomic() 125 | .check({ key: ["books", id], versionstamp: bookEntry.versionstamp }) 126 | .set(["books", id], book) 127 | .commit(); 128 | if (!result.ok) { 129 | ctx.throw(Status.InternalServerError, "Conflict updating the book"); 130 | } 131 | return book; 132 | }, { schema: { body: bookPatch, response: book } }); 133 | 134 | router.delete("/book/:id", async (ctx) => { 135 | const id = parseInt(ctx.params.id, 10); 136 | await db.delete(["books", id]); 137 | }); 138 | 139 | router.listen({ port: 3000 }); 140 | -------------------------------------------------------------------------------- /constants.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | export const BODYLESS_METHODS = ["GET", "HEAD"]; 4 | export const NOT_ALLOWED = Symbol.for("acorn.NotAllowed"); 5 | -------------------------------------------------------------------------------- /context.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { assertEquals } from "@std/assert/equals"; 4 | import { Schema } from "./schema.ts"; 5 | import { MockRequestEvent } from "./testing_utils.ts"; 6 | 7 | import { Context } from "./context.ts"; 8 | 9 | Deno.test({ 10 | name: "Context - should be able to create a new context", 11 | async fn() { 12 | const requestEvent = new MockRequestEvent( 13 | "http://localhost/item/123?a=1&b=2", 14 | { 15 | method: "POST", 16 | body: JSON.stringify({ c: 3 }), 17 | headers: { "content-type": "application/json" }, 18 | }, 19 | ); 20 | const responseHeaders = new Headers(); 21 | const schema = new Schema(undefined, false); 22 | const context = new Context( 23 | requestEvent, 24 | responseHeaders, 25 | true, 26 | { item: "123" }, 27 | schema, 28 | undefined, 29 | false, 30 | ); 31 | assertEquals(context.addr, { 32 | hostname: "localhost", 33 | port: 80, 34 | transport: "tcp", 35 | }); 36 | assertEquals(await context.cookies.size, 0); 37 | assertEquals(context.env, {}); 38 | assertEquals(context.params, { item: "123" }); 39 | assertEquals(context.url, new URL("http://localhost/item/123?a=1&b=2")); 40 | assertEquals(context.userAgent.toString(), ""); 41 | assertEquals(context.request, requestEvent.request); 42 | assertEquals(await context.body(), { c: 3 }); 43 | assertEquals(await context.queryParams(), { a: "1", b: "2" }); 44 | assertEquals(requestEvent.responded, false); 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /context.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | /** 4 | * The module which contains the {@linkcode Context} class which provides an 5 | * interface for working with requests. 6 | * 7 | * As the router handles requests, it will create a context populated with 8 | * information about the request. 9 | * 10 | * @module 11 | */ 12 | 13 | import { type KeyRing, SecureCookieMap } from "@oak/commons/cookie_map"; 14 | import { 15 | createHttpError, 16 | type HttpErrorOptions, 17 | } from "@oak/commons/http_errors"; 18 | import { 19 | ServerSentEventStreamTarget, 20 | type ServerSentEventTarget, 21 | type ServerSentEventTargetOptions, 22 | } from "@oak/commons/server_sent_event"; 23 | import { 24 | type ErrorStatus, 25 | type RedirectStatus, 26 | Status, 27 | STATUS_TEXT, 28 | } from "@oak/commons/status"; 29 | import { UserAgent } from "@std/http/user-agent"; 30 | import type { InferOutput } from "@valibot/valibot"; 31 | 32 | import { getLogger, type Logger } from "./logger.ts"; 33 | import type { BodySchema, QueryStringSchema, Schema } from "./schema.ts"; 34 | import type { 35 | Addr, 36 | ParamsDictionary, 37 | RequestEvent, 38 | RouteParameters, 39 | UpgradeWebSocketOptions, 40 | } from "./types.ts"; 41 | import { appendHeaders } from "./utils.ts"; 42 | import { compile } from "path-to-regexp"; 43 | 44 | export interface RedirectInit { 45 | /** 46 | * The parameters to interpolate into the `location` path. 47 | */ 48 | params?: LocationParams; 49 | /** 50 | * The status to use for the redirect. Defaults to `302 Found`. 51 | */ 52 | status?: RedirectStatus; 53 | } 54 | 55 | /** 56 | * Initiation options when responding to a request with a `201 Created` status. 57 | */ 58 | export interface RespondInit< 59 | Location extends string, 60 | LocationParams extends ParamsDictionary, 61 | > { 62 | /** 63 | * Additional headers to include in the response. 64 | */ 65 | headers?: HeadersInit; 66 | /** 67 | * The location to include in the `Location` header of the response. 68 | * 69 | * If the path includes parameters, the `params` should be provided to 70 | * interpolate the values into the path. 71 | */ 72 | location?: Location; 73 | /** 74 | * The parameters to interpolate into the `location` path. 75 | */ 76 | params?: LocationParams; 77 | } 78 | 79 | /** 80 | * Provides an API for understanding information about the request being 81 | * processed by the router. 82 | * 83 | * @template Env a type which allows strongly typing the environment variables 84 | * that are made available on the context used within handlers. 85 | * @template Params a type which is typically inferred from the route path 86 | * selector which represents the shape of route parameters that are parsed 87 | * from the route 88 | * @template QueryParams a type which represents the shape of query parameters 89 | * that are parsed from the search parameters of the request 90 | * @template Schema the validation schema which is used to infer the shape of 91 | * the request's parsed and validated body 92 | * @template RequestBody the shape of the parsed (and potentially validated) 93 | * body 94 | */ 95 | export class Context< 96 | Env extends Record, 97 | Params extends ParamsDictionary | undefined, 98 | QSSchema extends QueryStringSchema, 99 | QueryParams extends InferOutput, 100 | BSchema extends BodySchema, 101 | RequestBody extends InferOutput | undefined, 102 | ResSchema extends BodySchema, 103 | > { 104 | #body?: RequestBody; 105 | #bodySet = false; 106 | #cookies: SecureCookieMap; 107 | #expose: boolean; 108 | #logger: Logger; 109 | #params: Params; 110 | #queryParams?: QueryParams; 111 | #requestEvent: RequestEvent; 112 | #responseHeaders: Headers; 113 | #schema: Schema; 114 | #url: URL; 115 | #userAgent?: UserAgent; 116 | 117 | /** 118 | * The address information of the remote connection making the request as 119 | * presented to the server. 120 | */ 121 | get addr(): Addr { 122 | return this.#requestEvent.addr; 123 | } 124 | 125 | /** 126 | * Provides a unified API to get and set cookies related to a request and 127 | * response. If the `keys` property has been set when the router was created, 128 | * these cookies will be cryptographically signed and verified to prevent 129 | * tampering with their value. 130 | */ 131 | get cookies(): SecureCookieMap { 132 | return this.#cookies; 133 | } 134 | 135 | /** 136 | * Access to the environment variables in a runtime independent way. 137 | * 138 | * In some runtimes, like Cloudflare Workers, the environment variables are 139 | * supplied on each request, where in some cases they are available from the 140 | * runtime environment via specific APIs. This always conforms the variables 141 | * into a `Record` which can be strongly typed when creating 142 | * the router instance if desired. 143 | */ 144 | get env(): Env { 145 | return this.#requestEvent.env ?? Object.create(null); 146 | } 147 | 148 | /** 149 | * A globally unique identifier for the request event. 150 | * 151 | * This can be used for logging and debugging purposes. 152 | * 153 | * For automatically generated error responses, this identifier will be added 154 | * to the response as the `X-Request-ID` header. 155 | */ 156 | get id(): string { 157 | return this.#requestEvent.id; 158 | } 159 | 160 | /** 161 | * The parameters that have been parsed from the path following the syntax 162 | * of [path-to-regexp](https://github.com/pillarjs/path-to-regexp). 163 | * 164 | * @example 165 | * 166 | * Given the following route path pattern: 167 | * 168 | * ``` 169 | * /:foo/:bar 170 | * ``` 171 | * 172 | * And the following request path: 173 | * 174 | * ``` 175 | * /item/123 176 | * ``` 177 | * 178 | * The value of `.params` would be set to: 179 | * 180 | * ```ts 181 | * { 182 | * foo: "item", 183 | * bar: "123", 184 | * } 185 | * ``` 186 | */ 187 | get params(): Params { 188 | return this.#params; 189 | } 190 | 191 | /** 192 | * The {@linkcode Request} object associated with the request. 193 | */ 194 | get request(): Request { 195 | return this.#requestEvent.request; 196 | } 197 | 198 | /** 199 | * The {@linkcode Headers} object which will be used to set headers on the 200 | * response. 201 | */ 202 | get responseHeaders(): Headers { 203 | return this.#responseHeaders; 204 | } 205 | 206 | /** 207 | * The parsed form of the {@linkcode Request}'s URL. 208 | */ 209 | get url(): URL { 210 | return this.#url; 211 | } 212 | 213 | /** 214 | * A representation of the parsed value of the 215 | * [`User-Agent`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/User-Agent) 216 | * header if associated with the request. This can provide information about 217 | * the browser and device making the request. 218 | */ 219 | get userAgent(): UserAgent { 220 | if (!this.#userAgent) { 221 | this.#userAgent = new UserAgent( 222 | this.#requestEvent.request.headers.get("user-agent"), 223 | ); 224 | } 225 | return this.#userAgent; 226 | } 227 | 228 | constructor( 229 | requestEvent: RequestEvent, 230 | responseHeaders: Headers, 231 | secure: boolean, 232 | params: Params, 233 | schema: Schema, 234 | keys: KeyRing | undefined, 235 | expose: boolean, 236 | ) { 237 | this.#requestEvent = requestEvent; 238 | this.#responseHeaders = responseHeaders; 239 | this.#params = params; 240 | this.#schema = schema; 241 | this.#url = new URL(this.#requestEvent.request.url); 242 | this.#cookies = new SecureCookieMap(requestEvent.request, { 243 | keys, 244 | response: responseHeaders, 245 | secure, 246 | }); 247 | this.#logger = getLogger("acorn.context"); 248 | this.#expose = expose; 249 | } 250 | 251 | /** 252 | * Attempts to read the body as JSON. If a schema was associated with the 253 | * route, the schema will be used to validate the body. If the body is invalid 254 | * and a invalid handler was specified, that will be called. If the body is 255 | * invalid and no invalid handler was specified, the method will throw as a 256 | * `BadRequest` HTTP error, with the validation error as the `cause`. 257 | * 258 | * If the body is valid, it will be resolved. If there was no body, or the 259 | * body was already consumed, `undefined` will be resolved. Requests which 260 | * have a method of `GET` or `HEAD` will always resolved with `undefined`. 261 | * 262 | * If more direct control of the body is required, use the methods directly 263 | * on the {@linkcode Request} on the `.request` property of the context. 264 | */ 265 | async body(): Promise { 266 | if (!this.#bodySet) { 267 | this.#bodySet = true; 268 | this.#logger.debug(`${this.#requestEvent.id} validating body`); 269 | const result = await this.#schema.validateBody(this.#requestEvent); 270 | if (result.invalidResponse) { 271 | this.#requestEvent.respond(result.invalidResponse); 272 | return undefined; 273 | } 274 | this.#body = result.output as RequestBody; 275 | } 276 | return this.#body; 277 | } 278 | 279 | /** 280 | * Will throw an HTTP error with the status of `409 Conflict` and the message 281 | * provided. If a `cause` is provided, it will be included in the error as the 282 | * `cause` property. 283 | * 284 | * This is an appropriate response when a `PUT` request is made to a resource 285 | * that cannot be updated because it is in a state that conflicts with the 286 | * request. 287 | */ 288 | conflict(message = "Resource conflict", cause?: unknown): never { 289 | throw createHttpError(Status.Conflict, message, { 290 | cause, 291 | expose: this.#expose, 292 | }); 293 | } 294 | 295 | /** 296 | * Returns a {@linkcode Response} with the status of `201 Created` and the 297 | * body provided. If a `location` is provided in the respond init, the 298 | * response will include a `Location` header with the value of the `location`. 299 | * 300 | * If `locationParams` is provided, the `location` will be compiled with the 301 | * `params` and the resulting value will be used as the value of the 302 | * `Location` header. For example, if the `location` is `/book/:id` and the 303 | * `params` is `{ id: "123" }`, the `Location` header will be set to 304 | * `/book/123`. 305 | * 306 | * This is an appropriate response when a `POST` request is made to create a 307 | * new resource. 308 | */ 309 | created< 310 | Location extends string, 311 | LocationParams extends ParamsDictionary = RouteParameters, 312 | >( 313 | body: InferOutput, 314 | init: RespondInit = {}, 315 | ): Response { 316 | const { headers, location, params } = init; 317 | const response = Response.json(body, { 318 | status: Status.Created, 319 | statusText: STATUS_TEXT[Status.Created], 320 | headers, 321 | }); 322 | if (location) { 323 | if (params) { 324 | const toPath = compile(location); 325 | response.headers.set("location", toPath(params)); 326 | } else { 327 | response.headers.set("location", location); 328 | } 329 | } 330 | return response; 331 | } 332 | 333 | /** 334 | * Will throw an HTTP error with the status of `404 Not Found` and the message 335 | * provided. If a `cause` is provided, it will be included in the error as the 336 | * `cause` property. 337 | * 338 | * This is an appropriate response when a resource is requested that does not 339 | * exist. 340 | */ 341 | notFound(message = "Resource not found", cause?: unknown): never { 342 | throw createHttpError(Status.NotFound, message, { 343 | cause, 344 | expose: this.#expose, 345 | }); 346 | } 347 | 348 | /** 349 | * In addition to the value of `.url.searchParams`, acorn can parse and 350 | * validate the search part of the requesting URL with the 351 | * [qs](https://github.com/ljharb/qs) library and any supplied query string 352 | * schema, which provides a more advanced way of parsing the search part of a 353 | * URL. 354 | */ 355 | async queryParams(): Promise { 356 | if (!this.#queryParams) { 357 | this.#logger.debug( 358 | `${this.#requestEvent.id} validating query parameters`, 359 | ); 360 | const result = await this.#schema.validateQueryString(this.#requestEvent); 361 | if (result.invalidResponse) { 362 | this.#requestEvent.respond(result.invalidResponse); 363 | return undefined; 364 | } 365 | this.#queryParams = result.output as QueryParams; 366 | } 367 | return this.#queryParams; 368 | } 369 | 370 | /** 371 | * Redirect the client to a new location. The `location` can be a relative 372 | * path or an absolute URL. If the `location` string includes parameters, the 373 | * `params` should be provided in the init to interpolate the values into the 374 | * path. 375 | * 376 | * For example if the `location` is `/book/:id` and the `params` is `{ id: 377 | * "123" }`, the resulting URL will be `/book/123`. 378 | * 379 | * The status defaults to `302 Found`, but can be set to any of the redirect 380 | * statuses via passing it in the `init`. 381 | */ 382 | redirect< 383 | Location extends string, 384 | LocationParams extends ParamsDictionary = RouteParameters, 385 | >( 386 | location: Location, 387 | init: RedirectInit = {}, 388 | // status: RedirectStatus = Status.Found, 389 | // params?: LocationParams, 390 | ): Response { 391 | const { status, params } = init; 392 | if (params) { 393 | const toPath = compile(location); 394 | location = toPath(params) as Location; 395 | } 396 | return Response.redirect(location, status); 397 | } 398 | 399 | /** 400 | * Initiate server sent events, returning a {@linkcode ServerSentEventTarget} 401 | * which can be used to dispatch events to the client. 402 | * 403 | * This will immediately finalize the response and send it to the client, 404 | * which means that any value returned from the handler will be ignored. Any 405 | * additional information to initialize the response should be passed as 406 | * options to the method. 407 | */ 408 | sendEvents( 409 | options?: ServerSentEventTargetOptions & ResponseInit, 410 | ): ServerSentEventTarget { 411 | if (this.#requestEvent.responded) { 412 | throw new Error("Cannot send the correct response, already responded."); 413 | } 414 | this.#logger.debug(`${this.#requestEvent.id} starting server sent events`); 415 | const sse = new ServerSentEventStreamTarget(options); 416 | const response = sse.asResponse(options); 417 | this.#requestEvent.respond(appendHeaders(response, this.#responseHeaders)); 418 | return sse; 419 | } 420 | 421 | /** 422 | * Throw an HTTP error with the specified status and message, along with any 423 | * options. If the status is not provided, it will default to `500 Internal 424 | * Server Error`. 425 | */ 426 | throw( 427 | status?: ErrorStatus, 428 | message?: string, 429 | options?: HttpErrorOptions, 430 | ): never { 431 | throw createHttpError(status, message, options); 432 | } 433 | 434 | /** 435 | * Upgrade the current connection to a web socket and return the 436 | * {@linkcode WebSocket} object to be able to communicate with the remote 437 | * client. 438 | * 439 | * This is not supported in all runtimes and will throw if not supported. 440 | * 441 | * This will immediately respond to the client to initiate the web socket 442 | * connection meaning any value returned from the handler will be ignored. 443 | */ 444 | upgrade(options?: UpgradeWebSocketOptions): WebSocket { 445 | if (!this.#requestEvent.upgrade) { 446 | throw createHttpError( 447 | Status.ServiceUnavailable, 448 | "Web sockets not currently supported.", 449 | { expose: this.#expose }, 450 | ); 451 | } 452 | if (this.#requestEvent.responded) { 453 | throw new Error("Cannot upgrade, already responded."); 454 | } 455 | this.#logger.debug(`${this.#requestEvent.id} upgrading to web socket`); 456 | return this.#requestEvent.upgrade(options); 457 | } 458 | 459 | /** Custom inspect method under Deno. */ 460 | [Symbol.for("Deno.customInspect")]( 461 | inspect: (value: unknown) => string, 462 | ): string { 463 | return `${this.constructor.name} ${ 464 | inspect({ 465 | addr: this.#requestEvent.addr, 466 | env: this.env, 467 | id: this.#requestEvent.id, 468 | params: this.#params, 469 | cookies: this.#cookies, 470 | request: this.#requestEvent.request, 471 | responseHeaders: this.#responseHeaders, 472 | userAgent: this.userAgent, 473 | url: this.#url, 474 | }) 475 | }`; 476 | } 477 | 478 | /** Custom inspect method under Node.js. */ 479 | [Symbol.for("nodejs.util.inspect.custom")]( 480 | depth: number, 481 | // deno-lint-ignore no-explicit-any 482 | options: any, 483 | inspect: (value: unknown, options?: unknown) => string, 484 | // deno-lint-ignore no-explicit-any 485 | ): any { 486 | if (depth < 0) { 487 | return options.stylize(`[${this.constructor.name}]`, "special"); 488 | } 489 | 490 | const newOptions = Object.assign({}, options, { 491 | depth: options.depth === null ? null : options.depth - 1, 492 | }); 493 | return `${options.stylize(this.constructor.name, "special")} ${ 494 | inspect({ 495 | addr: this.#requestEvent.addr, 496 | env: this.env, 497 | id: this.#requestEvent.id, 498 | params: this.#params, 499 | cookies: this.#cookies, 500 | request: this.#requestEvent.request, 501 | responseHeaders: this.#responseHeaders, 502 | userAgent: this.userAgent, 503 | url: this.#url, 504 | }, newOptions) 505 | }`; 506 | } 507 | } 508 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oak/acorn", 3 | "version": "1.1.1", 4 | "exports": { ".": "./mod.ts" }, 5 | "publish": { 6 | "exclude": [ 7 | "_examples", 8 | ".github", 9 | ".vscode", 10 | "testing_utils.ts", 11 | "**/*.test.ts", 12 | "**/*.bench.ts" 13 | ] 14 | }, 15 | "tasks": { 16 | "bench": "deno bench --allow-write --allow-read", 17 | "check": "deno check mod.ts", 18 | "example": "deno run --allow-net --allow-env --unstable-kv _examples/server.ts", 19 | "test": "deno test --allow-net --allow-env" 20 | }, 21 | "imports": { 22 | "@oak/commons": "jsr:@oak/commons@^1.0", 23 | "@std/assert": "jsr:@std/assert@^1.0", 24 | "@std/http": "jsr:@std/http@^1.0", 25 | "@std/log": "jsr:@std/log@^0.224", 26 | "@std/media-types": "jsr:@std/media-types@^1.0", 27 | "@valibot/valibot": "jsr:@valibot/valibot@^0.42", 28 | "hyperid": "npm:hyperid@^3.3", 29 | "path-to-regexp": "npm:path-to-regexp@^8.2", 30 | "qs": "npm:qs@^6.13" 31 | }, 32 | "lock": false 33 | } 34 | -------------------------------------------------------------------------------- /logger.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { 4 | BaseHandler, 5 | type BaseHandlerOptions, 6 | ConsoleHandler, 7 | type FormatterFunction, 8 | getLogger as gl, 9 | type LevelName, 10 | type Logger, 11 | type LoggerConfig, 12 | RotatingFileHandler, 13 | setup, 14 | } from "@std/log"; 15 | import { isBun, isNode } from "./utils.ts"; 16 | 17 | export { type Logger } from "@std/log"; 18 | 19 | /** 20 | * Options which can be set when configuring file logging on the logger. 21 | */ 22 | export interface FileLoggerOptions { 23 | /** 24 | * The log level to log at. The default is `"INFO"`. 25 | */ 26 | level?: LevelName; 27 | /** 28 | * The maximum number of log files to keep. The default is `5`. 29 | */ 30 | maxBackupCount?: number; 31 | /** 32 | * The maximum size of a log file before it is rotated in bytes. The default 33 | * is 10MB. 34 | */ 35 | maxBytes?: number; 36 | /** 37 | * The path to the log file. 38 | */ 39 | filename: string; 40 | } 41 | 42 | /** 43 | * Options which can be set when configuring the logger when creating a new 44 | * router. 45 | */ 46 | export interface LoggerOptions { 47 | /** 48 | * Log events to the console. If `true`, log at the "INFO" level. If an 49 | * object, the `level` can be specified. 50 | */ 51 | console?: boolean | { level: LevelName }; 52 | /** 53 | * Log events to a rotating log file. The value should be an object with the 54 | * `path` to the log file and optionally the `level` to log at. If `level` is 55 | * not specified, the default is `"INFO"`. 56 | */ 57 | file?: FileLoggerOptions; 58 | /** 59 | * Log events to a stream. The value should be an object with the `stream` to 60 | * pipe the log events to and optionally the `level` to log at. If `level` is 61 | * not specified, the default is `"info"`. 62 | */ 63 | stream?: { level?: LevelName; stream: WritableStream }; 64 | } 65 | 66 | let inspect: (value: unknown) => string; 67 | 68 | if (typeof globalThis?.Deno?.inspect === "function") { 69 | inspect = Deno.inspect; 70 | } else { 71 | inspect = (value) => JSON.stringify(value); 72 | if (isNode() || isBun()) { 73 | import("node:util").then(({ inspect: nodeInspect }) => { 74 | inspect = nodeInspect; 75 | }); 76 | } 77 | } 78 | 79 | const formatter: FormatterFunction = ( 80 | { datetime, levelName, loggerName, msg, args }, 81 | ) => 82 | `${datetime.toISOString()} [${levelName}] ${loggerName}: ${msg} ${ 83 | args.map((arg) => inspect(arg)).join(" ") 84 | }`; 85 | 86 | const encoder = new TextEncoder(); 87 | 88 | class StreamHandler extends BaseHandler { 89 | #writer: WritableStreamDefaultWriter; 90 | 91 | constructor( 92 | levelName: LevelName, 93 | stream: WritableStream, 94 | options?: BaseHandlerOptions, 95 | ) { 96 | super(levelName, options); 97 | this.#writer = stream.getWriter(); 98 | } 99 | 100 | log(msg: string): void { 101 | this.#writer.write(encoder.encode(msg + "\n")); 102 | } 103 | 104 | override destroy(): void { 105 | this.#writer.close(); 106 | } 107 | } 108 | 109 | const mods = [ 110 | "acorn.context", 111 | "acorn.request_event_cfw", 112 | "acorn.request_server_bun", 113 | "acorn.request_server_deno", 114 | "acorn.request_server_node", 115 | "acorn.route", 116 | "acorn.router", 117 | "acorn.schema", 118 | "acorn.status_route", 119 | ] as const; 120 | 121 | type Loggers = typeof mods[number]; 122 | 123 | export function getLogger(mod: Loggers): Logger { 124 | return gl(mod); 125 | } 126 | 127 | export function configure(options?: LoggerOptions): void { 128 | const config = { 129 | handlers: {} as { [key: string]: BaseHandler }, 130 | loggers: {} as { [key: string]: LoggerConfig }, 131 | }; 132 | const handlers: string[] = []; 133 | if (options) { 134 | if (options.console) { 135 | config.handlers.console = new ConsoleHandler( 136 | typeof options.console === "object" ? options.console.level : "INFO", 137 | { formatter }, 138 | ); 139 | handlers.push("console"); 140 | } 141 | if (options.file) { 142 | const { 143 | filename, 144 | maxBackupCount = 5, 145 | maxBytes = 1024 * 1024 * 10, 146 | level = "INFO", 147 | } = options.file; 148 | config.handlers.file = new RotatingFileHandler(level, { 149 | filename, 150 | maxBackupCount, 151 | maxBytes, 152 | formatter, 153 | }); 154 | handlers.push("file"); 155 | } 156 | if (options.stream) { 157 | config.handlers.stream = new StreamHandler( 158 | options.stream.level ?? "INFO", 159 | options.stream.stream, 160 | { formatter }, 161 | ); 162 | handlers.push("stream"); 163 | } 164 | } else { 165 | config.handlers.console = new ConsoleHandler("WARN", { formatter }); 166 | handlers.push("console"); 167 | } 168 | for (const mod of mods) { 169 | config.loggers[mod] = { level: "DEBUG", handlers }; 170 | } 171 | setup(config); 172 | } 173 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | /** 4 | * acorn is a focused framework for creating RESTful JSON services across 5 | * various JavaScript and TypeScript runtime environments including Deno 6 | * runtime, Deno Deploy, Node.js, Bun and Cloudflare Workers. 7 | * 8 | * It focuses on providing a router which handles inbound requests and makes it 9 | * trivial to respond to those requests with JSON. It also provides several 10 | * other features which make creating API servers with acorn production ready. 11 | * 12 | * ## Basic usage 13 | * 14 | * acorn is designed to work on many different JavaScript and TypeScript 15 | * runtimes, including Deno, Node.js, Bun, and Cloudflare Workers. Basic usage 16 | * requires installing acorn to your project and then creating a router to 17 | * handle requests. 18 | * 19 | * ### Installing for Deno 20 | * 21 | * To install acorn for Deno, you can install it via the Deno runtime CLI: 22 | * 23 | * ``` 24 | * deno add @oak/acorn 25 | * ``` 26 | * 27 | * ### Installing for Node.js or Cloudflare Workers 28 | * 29 | * To install acorn for Node.js or Cloudflare Workers, you can install it via 30 | * your preferred package manager. 31 | * 32 | * #### npm 33 | * 34 | * ``` 35 | * npx jsr add @oak/acorn 36 | * ``` 37 | * 38 | * #### yarn 39 | * 40 | * ``` 41 | * yarn dlx jsr add @oak/acorn 42 | * ``` 43 | * 44 | * #### pnpm 45 | * 46 | * ``` 47 | * pnpm dlx jsr add @oak/acorn 48 | * ``` 49 | * 50 | * ### Installing for Bun 51 | * 52 | * To install acorn for Bun, you can install it via the Bun runtime CLI: 53 | * 54 | * ``` 55 | * bunx jsr add @oak/acorn 56 | * ``` 57 | * 58 | * ### Usage with Deno, Node.js, and Bun 59 | * 60 | * Basic usage of acorn for Deno, Node.js, and Bun is the same. You import the 61 | * {@linkcode Router}, create an instance of it, register routes on the router, 62 | * and then called the `.listen()` method on the router to start listening for 63 | * requests: 64 | * 65 | * ```ts 66 | * import { Router } from "@oak/acorn"; 67 | * 68 | * const router = new Router(); 69 | * router.get("/", () => ({ hello: "world" })); 70 | * router.listen({ port: 3000 }); 71 | * ``` 72 | * 73 | * ### Usage with Cloudflare Workers 74 | * 75 | * Basic usage for Cloudflare Workers requires exporting a fetch handler which 76 | * is integrated into the router, and therefore you export the router as the 77 | * default export of the module: 78 | * 79 | * ```ts 80 | * import { Router } from "@oak/acorn"; 81 | * 82 | * const router = new Router(); 83 | * router.get("/", () => ({ hello: "world" })); 84 | * export default router; 85 | * ``` 86 | * 87 | * ## Router 88 | * 89 | * The {@linkcode Router} is the core of acorn and is responsible for handling 90 | * inbound requests and routing them to the appropriate handler. The router 91 | * provides methods for registering routes for different HTTP methods and 92 | * handling requests for those routes. 93 | * 94 | * ### Default behaviors 95 | * 96 | * The router provides several automatic behaviors which are designed to make 97 | * creating RESTful JSON services easier. These behaviors include handling 98 | * `404 Not Found` responses, `405 Method Not Allowed` responses, and providing 99 | * a default response for `OPTIONS` requests. 100 | * 101 | * #### Not Found 102 | * 103 | * When a request is received by the router and no route is matched, the router 104 | * will send a `404 Not Found` response to the client. This is the default 105 | * behavior of the router and can be overridden by providing a `onNotFound` 106 | * hook to the router. 107 | * 108 | * #### Method Not Allowed 109 | * 110 | * When a request is received by the router and a route is matched but there is 111 | * no handler for the method of the request, the router will send a `405 Method 112 | * Not Allowed` response to the client which will provide the allowed methods. 113 | * This is the default behavior of the router and can be overridden by providing 114 | * a status handler. 115 | * 116 | * #### Options 117 | * 118 | * When a request is received by the router and the method of the request is 119 | * `OPTIONS`, the router will send a response to the client with the allowed 120 | * methods for the route. This is the default behavior of the router and can be 121 | * overridden by providing an `options()` route. 122 | * 123 | * ## Context 124 | * 125 | * The {@linkcode Context} is the object passed to route handlers and provides 126 | * information about the request and runtime environment. The context object 127 | * provides access to the {@linkcode Request} object as well as other useful 128 | * properties and methods for handling requests. 129 | * 130 | * ### `addr` 131 | * 132 | * The network address of the originator of the request as presented to the 133 | * runtime environment. 134 | * 135 | * ### `cookies` 136 | * 137 | * The cookies object which can be used to get and set cookies for the request. 138 | * If encryptions keys are provided to the router, the cookies will be 139 | * cryptographically verified and signed to ensure their integrity. 140 | * 141 | * ### `env` 142 | * 143 | * The environment variables available to the runtime environment. This assists 144 | * in providing access to the environment variables for the runtime environment 145 | * without having to code specifically for each runtime environment. 146 | * 147 | * ### `id` 148 | * 149 | * A unique identifier for the request event. This can be useful for logging 150 | * and tracking requests. 151 | * 152 | * ### `params` 153 | * 154 | * The parameters extracted from the URL path by the router. 155 | * 156 | * ### `request` 157 | * 158 | * The Fetch API standard {@linkcode Request} object which should be handled. 159 | * 160 | * ### `responseHeaders` 161 | * 162 | * The headers that will be sent with the response. This will be merged with 163 | * other headers to finalize the reponse. 164 | * 165 | * ### `url` 166 | * 167 | * The URL object representing the URL of the request. 168 | * 169 | * ### `userAgent` 170 | * 171 | * A parsed version of the `User-Agent` header from the request. This can be 172 | * used to determine the type of client making the request. 173 | * 174 | * ### `body()` 175 | * 176 | * A method which returns a promise that resolves with the body of the request 177 | * assumed to be JSON. If the body is not JSON, an error will be thrown. If a 178 | * body schema is provided to the route, the body will be validated against that 179 | * schema before being returned. 180 | * 181 | * ### `conflict()` 182 | * 183 | * A method which throws a `409 Conflict` error and takes an optional message 184 | * and optional cause. 185 | * 186 | * ### `created()` 187 | * 188 | * A method which returns a {@linkcode Response} with a `201 Created` status 189 | * code. The method takes the body of the response and an optional object with 190 | * options for the response. If a `location` property is provided in the 191 | * options, the response will include a `Location` header with the value of the 192 | * location. 193 | * 194 | * If `locationParams` is provided in the options, the location will be 195 | * interpolated with the parameters provided. 196 | * 197 | * ### `notFound()` 198 | * 199 | * A method which throws a `404 Not Found` error and takes an optional message 200 | * and optional cause. 201 | * 202 | * ### `queryParams()` 203 | * 204 | * A method which returns a promise that resolves with the query parameters of 205 | * the request. If a query parameter schema is provided to the route, the query 206 | * parameters will be validated against that schema before being returned. 207 | * 208 | * ### `redirect()` 209 | * 210 | * A method which sends a redirect response to the client. The method takes a 211 | * location and an optional init object with options for the response. If the 212 | * location is a path with parameters, the `params` object can be provided to 213 | * interpolate the parameters into the URL. 214 | * 215 | * ### `throw()` 216 | * 217 | * A method which can be used to throw an HTTP error which will be caught by the 218 | * router and handled appropriately. The method takes a status code and an 219 | * optional message which will be sent to the client. 220 | * 221 | * ### `created()` 222 | * 223 | * A method which returns a {@linkcode Response} with a `201 Created` status 224 | * code. The method takes the body of the response and an optional object with 225 | * options for the response. 226 | * 227 | * This is an appropriate response when a `POST` request is made to a resource 228 | * collection and the resource is created successfully. The options should be 229 | * included with a `location` property set to the URL of the created resource. 230 | * The `params` property can be used to provide parameters to the URL. 231 | * For example if `location` is `/books/:id` and `params` is `{ id: 1 }` 232 | * the URL will be `/books/1`. 233 | * 234 | * ### `conflict()` 235 | * 236 | * A method which throws a `409 Conflict` error and takes an optional message 237 | * and optional cause. 238 | * 239 | * This is an appropriate response when a `PUT` request is made to a resource 240 | * that cannot be updated because it is in a state that conflicts with the 241 | * request. 242 | * 243 | * ### `sendEvents()` 244 | * 245 | * A method which starts sending server-sent events to the client. This method 246 | * returns a {@linkcode ServerSentEventTarget} which can be used to dispatch 247 | * events to the client. 248 | * 249 | * ### `upgrade()` 250 | * 251 | * A method which can be used to upgrade the request to a {@linkcode WebSocket} 252 | * connection. When the request is upgraded, the request will be handled as a 253 | * web socket connection and the method will return a {@linkcode WebSocket} 254 | * which can be used to communicate with the client. 255 | * 256 | * **Note:** This method is only available in the Deno runtime and Deno Deploy 257 | * currently. If you call this method in a different runtime, an error will be 258 | * thrown. 259 | * 260 | * ## Router Handlers 261 | * 262 | * The {@linkcode RouteHandler} is the function which is called when a route is 263 | * matched by the router. The handler is passed the {@linkcode Context} object 264 | * and is expected to return a response. The response can be a plain object 265 | * which will be serialized to JSON, a {@linkcode Response} object. The handler 266 | * can also return `undefined` if the handler wishes to return a no content 267 | * response. The handler can also return a promise which resolves with any of 268 | * the above. 269 | * 270 | * ### Registering Routes 271 | * 272 | * Routes can be registered on the router using the various methods provided by 273 | * the router. The most common methods are `get()`, `post()`, `put()`, 274 | * `patch()`, and `delete()`. In addition `options()` and `head()` are provided. 275 | * 276 | * The methods take a path pattern and a handler function, and optionally an 277 | * object with options for the route ({@linkcode RouteInit}). The path pattern 278 | * is a string which can include parameters and pattern matching syntax. The 279 | * handler function is called when the route is matched and is passed the 280 | * context object. 281 | * 282 | * For example, to register a route which responds to a `GET` request: 283 | * 284 | * ```ts 285 | * router.get("/", () => ({ hello: "world" })); 286 | * ``` 287 | * 288 | * The methods also accept a {@linkcode RouteDescriptor} object, or a path along 289 | * with a set of options ({@linkcode RouteInitWithHandler}) which includes the 290 | * handler function. 291 | * 292 | * For example, to register a route which responds to a `POST` request: 293 | * 294 | * ```ts 295 | * router.post("/", { 296 | * handler: () => ({ hello: "world" }), 297 | * }); 298 | * ``` 299 | * 300 | * And for a route which responds to a `PUT` request with the full descriptor: 301 | * 302 | * ```ts 303 | * router.put({ 304 | * path: "/", 305 | * handler: () => ({ hello: "world" }), 306 | * }); 307 | * ``` 308 | * 309 | * ### Hooks 310 | * 311 | * The router provides hooks which can be used to get information about the 312 | * routing process and to potentially modify the response. The hooks are 313 | * provided when creating the router and are called at various points in the 314 | * routing process. 315 | * 316 | * #### `onRequest()` 317 | * 318 | * The `onRequest` hook is called when a request is received by the router. The 319 | * {@linkcode RequestEvent} object is provided to the hook and can be used to 320 | * inspect the request. 321 | * 322 | * The `onRequest` could invoke the `.respond()` method on the `RequestEvent` 323 | * but this should be avoided. 324 | * 325 | * #### `onNotFound()` 326 | * 327 | * As a request is being handled by the router, if no route is matched or the 328 | * route handler returns a `404 Not Found` response the `onNotFound` hook is 329 | * called. There is a details object which provides the {@linkcode RequestEvent} 330 | * being handled, any {@linkcode Response} that has been provided (but not yet 331 | * sent to the client) and the {@linkcode Route} that was matched, if any. 332 | * 333 | * The `onNotFound` hook can return a response to be sent to the client. If the 334 | * hook returns `undefined`, the router will continue processing the request. 335 | * 336 | * #### `onHandled()` 337 | * 338 | * After a request has been processed by the router and a response has been 339 | * sent to the client, the `onHandled` hook is called. The hook is provided with 340 | * a set of details which include the {@linkcode RequestEvent}, the 341 | * {@linkcode Response}, the {@linkcode Route} that was matched, and the time in 342 | * milliseconds that the request took to process. 343 | * 344 | * #### `onError()` 345 | * 346 | * If an unhandled error occurs in a handler, the `onError` hook is called. The 347 | * hook is provided with a set of details which include the 348 | * {@linkcode RequestEvent}, the {@linkcode Response} that was provided, the 349 | * error that occurred, and the {@linkcode Route} that was matched, if any. 350 | * 351 | * ## Route Parameters 352 | * 353 | * The router can extract parameters from the URL path and provide them to the 354 | * route handler. The parameters are extracted from the URL path based on the 355 | * pattern matching syntax provided by the 356 | * [`path-to-regexp`](https://github.com/pillarjs/path-to-regexp) library. The 357 | * parameters are provided to the handler as an object with the parameter names 358 | * as the keys and the values as the values. 359 | * 360 | * For example, to register a route which extracts a parameter from the URL 361 | * path: 362 | * 363 | * ```ts 364 | * router.get("/:name", (ctx) => { 365 | * return { hello: ctx.params.name }; 366 | * }); 367 | * ``` 368 | * 369 | * ## Status Handlers 370 | * 371 | * acorn provides a mechanism for observing or modifying the response to a 372 | * request based on the status of the response. This is done using status 373 | * handlers which are registered on the router. The status handlers are called 374 | * when a response is being sent to the client and the status of the response 375 | * matches the status or status range provided to the handler. 376 | * 377 | * This is intended to be able to provide consistent and customized responses to 378 | * status codes across all routes in the router. For example, you could provide 379 | * a status handler to handle all `404 Not Found` responses and provide a 380 | * consistent response to the client: 381 | * 382 | * ```ts 383 | * import { Router } from "@oak/acorn"; 384 | * import { Status, STATUS_TEXT } from "@oak/commons/status"; 385 | * 386 | * const router = new Router(); 387 | * 388 | * router.on(Status.NotFound, () => { 389 | * return Response.json( 390 | * { error: "Not Found" }, 391 | * { status: Status.NotFound, statusText: STATUS_TEXT[Status.NotFound], 392 | * }); 393 | * }); 394 | * ``` 395 | * 396 | * ## Schema Validation 397 | * 398 | * acorn integrates the [Valibot](https://valibot.dev/) library to provide 399 | * schema validation for query strings, request bodies, and responses. This 400 | * allows you to define the shape of the data you expect to receive and send 401 | * and have it validated automatically. 402 | * 403 | * You can provide a schema to the route when registering it on the router. The 404 | * schema is an object which describes the shape of the data you expect to 405 | * receive or send. The schema is defined using the Valibot schema definition 406 | * language. 407 | * 408 | * For example, to define a schema for a request body: 409 | * 410 | * ```ts 411 | * import { Router, v } from "@oak/acorn"; 412 | * 413 | * const router = new Router(); 414 | * 415 | * router.post("/", () => ({ hello: "world" }), { 416 | * schema: { 417 | * body: v.object({ 418 | * name: v.string(), 419 | * }), 420 | * }, 421 | * }); 422 | * ``` 423 | * 424 | * This ensures that the request body is an object with a `name` property which 425 | * is a string. If the request body does not match this schema, an error will be 426 | * thrown and the request will not be processed and a `Bad Request` response 427 | * will be sent to the client. 428 | * 429 | * You can provide an optional invalid handler to the schema which will be 430 | * called when the schema validation fails. This allows you to provide a custom 431 | * response to the client when the request does not match the schema. 432 | * 433 | * ## RESTful JSON Services 434 | * 435 | * acorn is designed to make it easy to create RESTful JSON services. The router 436 | * provides a simple and expressive way to define routes and has several 437 | * features which make it easy to create production ready services. 438 | * 439 | * ### HTTP Errors 440 | * 441 | * acorn provides a mechanism for throwing HTTP errors from route handlers. The 442 | * `throw()` method on the context object can be used to throw an HTTP error. 443 | * HTTP errors are caught by the router and handled appropriately. The router 444 | * will send a response to the client with the status code and message provided 445 | * to the `throw()` method with the body of the response respecting the content 446 | * negotiation headers provided by the client. 447 | * 448 | * ### No Content Responses 449 | * 450 | * If a handler returns `undefined`, the router will send a `204 No Content` 451 | * response to the client. This is useful when a request is successful but there 452 | * is no content to return to the client. 453 | * 454 | * No content responses are appropriate for `PUT` or `PATCH` requests that are 455 | * successful but you do not want to return the updated resource to the client. 456 | * 457 | * ### Created Responses 458 | * 459 | * The `created()` method on the context object can be used to send a `201 460 | * Created` response to the client. This is appropriate when a `POST` request is 461 | * made to a resource collection and the resource is created successfully. The 462 | * method takes the body of the response and an optional object with options for 463 | * the response. 464 | * 465 | * The options should be included with a `location` property set to the URL of 466 | * the created resource. The `params` property can be used to provide parameters 467 | * to the URL. For example if `location` is `/books/:id` and `params` is 468 | * `{ id: 1 }` the URL will be `/books/1`. 469 | * 470 | * ### Conflict Responses 471 | * 472 | * The `conflict()` method on the context object can be used to throw a `409 473 | * Conflict` error. This is appropriate when a `PUT` request is made to a 474 | * resource that cannot be updated because it is in a state that conflicts with 475 | * the request. 476 | * 477 | * ### Redirect Responses 478 | * 479 | * If you need to redirect the client to a different URL, you can use the 480 | * `redirect()` method on the context object. This method takes a URL and an 481 | * optional status code and will send a redirect response to the client. 482 | * 483 | * In addition, if the `location` is a path with parameters, you can provide the 484 | * `params` object to the `redirect()` method which will be used to populate the 485 | * parameters in the URL. 486 | * 487 | * ## Logging 488 | * 489 | * acorn integrates the [LogTape](https://jsr.io/@logtape/logtape) library to 490 | * provide logging capabilities for the router and routes. 491 | * 492 | * To enable logging, you can provide a {@linkcode LoggerOptions} object on the 493 | * property `logger` to the router when creating it: 494 | * 495 | * ```ts 496 | * const router = new Router({ 497 | * logger: { 498 | * console: { level: "debug" }, 499 | * }, 500 | * }); 501 | * ``` 502 | * 503 | * Alternatively, you can simply set the `logger` property to `true` to log 504 | * events at the `"WARN"` level to the console: 505 | * 506 | * ```ts 507 | * const router = new Router({ 508 | * logger: true, 509 | * }); 510 | * ``` 511 | * 512 | * @module 513 | */ 514 | 515 | /** 516 | * The re-export of the [valibot](https://valibot.dev/guides/introduction/) 517 | * library which is used for schema validation in acorn. 518 | */ 519 | export * as v from "@valibot/valibot"; 520 | export type { ServerSentEventTarget } from "@oak/commons/server_sent_event"; 521 | export { 522 | type ClientErrorStatus, 523 | type ErrorStatus, 524 | type InformationalStatus, 525 | type RedirectStatus, 526 | type ServerErrorStatus, 527 | Status, 528 | type SuccessfulStatus, 529 | } from "@oak/commons/status"; 530 | 531 | export type { Context } from "./context.ts"; 532 | export type { LoggerOptions } from "./logger.ts"; 533 | export type { PathRoute, RouteHandler, RouteOptions } from "./route.ts"; 534 | export { 535 | type ErrorDetails, 536 | type HandledDetails, 537 | type NotFoundDetails, 538 | type RouteDescriptor, 539 | type RouteDescriptorWithMethod, 540 | type RouteInit, 541 | type RouteInitWithHandler, 542 | Router, 543 | type RouterOptions, 544 | } from "./router.ts"; 545 | export type { 546 | InvalidHandler, 547 | SchemaDescriptor, 548 | ValidationOptions, 549 | } from "./schema.ts"; 550 | export type { 551 | StatusHandler, 552 | StatusRange, 553 | StatusRoute, 554 | StatusRouteDescriptor, 555 | } from "./status_route.ts"; 556 | export type { 557 | CloudflareExecutionContext, 558 | CloudflareFetchHandler, 559 | RequestEvent, 560 | Route, 561 | RouteParameters, 562 | } from "./types.ts"; 563 | -------------------------------------------------------------------------------- /request_event_cfw.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import hyperid from "hyperid"; 4 | 5 | import type { 6 | Addr, 7 | CloudflareExecutionContext, 8 | RequestEvent, 9 | } from "./types.ts"; 10 | import { createPromiseWithResolvers } from "./utils.ts"; 11 | 12 | const instance = hyperid({ urlSafe: true }); 13 | 14 | /** 15 | * The implementation of the {@linkcode RequestEvent} interface for Cloudflare 16 | * Workers. 17 | */ 18 | export class CloudflareWorkerRequestEvent< 19 | Env extends Record = Record, 20 | > implements RequestEvent { 21 | #addr?: Addr; 22 | #env: Env; 23 | #id = instance(); 24 | //deno-lint-ignore no-explicit-any 25 | #reject: (reason?: any) => void; 26 | #request: Request; 27 | #resolve: (value: Response | PromiseLike) => void; 28 | #responded = false; 29 | #response: Promise; 30 | #url: URL; 31 | 32 | get addr(): Addr { 33 | if (!this.#addr) { 34 | const hostname = this.#request.headers.get("CF-Connecting-IP") ?? 35 | "localhost"; 36 | this.#addr = { hostname, port: 80, transport: "tcp" }; 37 | } 38 | return this.#addr; 39 | } 40 | 41 | get env(): Env { 42 | return this.#env; 43 | } 44 | 45 | get id(): string { 46 | return this.#id; 47 | } 48 | 49 | get request(): Request { 50 | return this.#request; 51 | } 52 | 53 | get responded(): boolean { 54 | return this.#responded; 55 | } 56 | 57 | get response(): Promise { 58 | return this.#response; 59 | } 60 | 61 | get url(): URL { 62 | return this.#url; 63 | } 64 | 65 | constructor(request: Request, env: Env, _ctx: CloudflareExecutionContext) { 66 | this.#request = request; 67 | this.#env = env; 68 | const { resolve, reject, promise } = createPromiseWithResolvers(); 69 | this.#resolve = resolve; 70 | this.#reject = reject; 71 | this.#response = promise; 72 | this.#url = URL.parse(request.url, "http://localhost/") ?? 73 | new URL("http://localhost/"); 74 | } 75 | 76 | //deno-lint-ignore no-explicit-any 77 | error(reason?: any): void { 78 | if (this.#responded) { 79 | throw new Error("Request already responded to."); 80 | } 81 | this.#responded = true; 82 | this.#reject(reason); 83 | } 84 | 85 | respond(response: Response): void { 86 | if (this.#responded) { 87 | throw new Error("Request already responded to."); 88 | } 89 | this.#responded = true; 90 | this.#resolve(response); 91 | } 92 | 93 | [Symbol.for("nodejs.util.inspect.custom")]( 94 | depth: number, 95 | // deno-lint-ignore no-explicit-any 96 | options: any, 97 | inspect: (value: unknown, options?: unknown) => string, 98 | // deno-lint-ignore no-explicit-any 99 | ): any { 100 | if (depth < 0) { 101 | return options.stylize(`[${this.constructor.name}]`, "special"); 102 | } 103 | 104 | const newOptions = Object.assign({}, options, { 105 | depth: options.depth === null ? null : options.depth - 1, 106 | }); 107 | return `${options.stylize(this.constructor.name, "special")} ${ 108 | inspect({ 109 | addr: this.#addr, 110 | env: this.#env, 111 | id: this.#id, 112 | request: this.#request, 113 | responded: this.#responded, 114 | response: this.#response, 115 | url: this.#url, 116 | }, newOptions) 117 | }`; 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /request_server_bun.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { createHttpError } from "@oak/commons/http_errors"; 4 | import { Status } from "@oak/commons/status"; 5 | import hyperid from "hyperid"; 6 | import process from "node:process"; 7 | 8 | import type { 9 | Addr, 10 | RequestEvent, 11 | RequestServer, 12 | RequestServerOptions, 13 | } from "./types.ts"; 14 | import { createPromiseWithResolvers } from "./utils.ts"; 15 | 16 | type TypedArray = 17 | | Uint8Array 18 | | Uint16Array 19 | | Uint32Array 20 | | Int8Array 21 | | Int16Array 22 | | Int32Array 23 | | Float32Array 24 | | Float64Array 25 | | BigInt64Array 26 | | BigUint64Array 27 | | Uint8ClampedArray; 28 | type BunFile = File; 29 | 30 | interface Bun { 31 | serve(options: { 32 | fetch: (req: Request, server: BunServer) => Response | Promise; 33 | hostname?: string; 34 | port?: number; 35 | development?: boolean; 36 | error?: (error: Error) => Response | Promise; 37 | tls?: { 38 | key?: 39 | | string 40 | | TypedArray 41 | | BunFile 42 | | Array; 43 | cert?: 44 | | string 45 | | TypedArray 46 | | BunFile 47 | | Array; 48 | ca?: string | TypedArray | BunFile | Array; 49 | passphrase?: string; 50 | dhParamsFile?: string; 51 | }; 52 | maxRequestBodySize?: number; 53 | lowMemoryMode?: boolean; 54 | }): BunServer; 55 | } 56 | 57 | interface BunServer { 58 | development: boolean; 59 | hostname: string; 60 | port: number; 61 | pendingRequests: number; 62 | requestIP(req: Request): SocketAddress | null; 63 | stop(): void; 64 | upgrade(req: Request, options?: { 65 | headers?: HeadersInit; 66 | //deno-lint-ignore no-explicit-any 67 | data?: any; 68 | }): boolean; 69 | } 70 | 71 | interface SocketAddress { 72 | address: string; 73 | port: number; 74 | family: "IPv4" | "IPv6"; 75 | } 76 | 77 | declare const Bun: Bun; 78 | 79 | const instance = hyperid({ urlSafe: true }); 80 | 81 | /** 82 | * The implementation of the {@linkcode RequestEvent} interface for Bun. 83 | */ 84 | class BunRequestEvent< 85 | Env extends Record = Record, 86 | > implements RequestEvent { 87 | #addr: Addr; 88 | #id = instance(); 89 | // deno-lint-ignore no-explicit-any 90 | #reject: (reason?: any) => void; 91 | #request: Request; 92 | #resolve: (value: Response | PromiseLike) => void; 93 | #responded = false; 94 | #response: Promise; 95 | #url: URL; 96 | 97 | get addr(): Addr { 98 | return this.#addr; 99 | } 100 | 101 | get id(): string { 102 | return this.#id; 103 | } 104 | 105 | get env(): Env { 106 | return process.env as Env; 107 | } 108 | 109 | get request(): Request { 110 | return this.#request; 111 | } 112 | 113 | get responded(): boolean { 114 | return this.#responded; 115 | } 116 | 117 | get response(): Promise { 118 | return this.#response; 119 | } 120 | 121 | get url(): URL { 122 | return this.#url; 123 | } 124 | 125 | constructor(request: Request, server: BunServer) { 126 | this.#request = request; 127 | const socketAddr = server.requestIP(request); 128 | this.#addr = { 129 | hostname: socketAddr?.address ?? "", 130 | port: socketAddr?.port ?? 0, 131 | transport: "tcp", 132 | }; 133 | const { resolve, reject, promise } = createPromiseWithResolvers(); 134 | this.#resolve = resolve; 135 | this.#reject = reject; 136 | this.#response = promise; 137 | this.#url = URL.parse(request.url, "http://localhost/") ?? 138 | new URL("http://localhost/"); 139 | } 140 | 141 | //deno-lint-ignore no-explicit-any 142 | error(reason?: any): void { 143 | if (this.#responded) { 144 | throw createHttpError( 145 | Status.InternalServerError, 146 | "Request already responded to.", 147 | ); 148 | } 149 | this.#responded = true; 150 | this.#reject(reason); 151 | } 152 | 153 | respond(response: Response): void { 154 | if (this.#responded) { 155 | throw createHttpError( 156 | Status.InternalServerError, 157 | "Request already responded to.", 158 | ); 159 | } 160 | this.#responded = true; 161 | this.#resolve(response); 162 | } 163 | 164 | [Symbol.for("nodejs.util.inspect.custom")]( 165 | depth: number, 166 | // deno-lint-ignore no-explicit-any 167 | options: any, 168 | inspect: (value: unknown, options?: unknown) => string, 169 | // deno-lint-ignore no-explicit-any 170 | ): any { 171 | if (depth < 0) { 172 | return options.stylize(`[${this.constructor.name}]`, "special"); 173 | } 174 | 175 | const newOptions = Object.assign({}, options, { 176 | depth: options.depth === null ? null : options.depth - 1, 177 | }); 178 | return `${options.stylize(this.constructor.name, "special")} ${ 179 | inspect({ 180 | addr: this.#addr, 181 | env: this.env, 182 | id: this.#id, 183 | request: this.#request, 184 | responded: this.#responded, 185 | response: this.#response, 186 | url: this.#url, 187 | }, newOptions) 188 | }`; 189 | } 190 | } 191 | 192 | /** 193 | * A request server that uses the Bun HTTP server to handle requests. 194 | */ 195 | export default class BunRequestServer< 196 | Env extends Record = Record, 197 | > implements RequestServer { 198 | #closed = true; 199 | #controller?: ReadableStreamDefaultController>; 200 | #options: RequestServerOptions; 201 | #server?: BunServer; 202 | #stream?: ReadableStream>; 203 | 204 | get closed(): boolean { 205 | return this.#closed; 206 | } 207 | 208 | constructor(options: RequestServerOptions) { 209 | this.#options = options; 210 | this.#options.signal.addEventListener("abort", () => { 211 | this.#closed = true; 212 | this.#server?.stop(); 213 | try { 214 | this.#controller?.close(); 215 | } catch { 216 | // just swallow here 217 | } 218 | this.#server = undefined; 219 | this.#controller = undefined; 220 | this.#stream = undefined; 221 | }, { once: true }); 222 | } 223 | 224 | listen(): Promise { 225 | if (!this.#closed) { 226 | throw new Error("Server already listening."); 227 | } 228 | const { promise, resolve } = createPromiseWithResolvers(); 229 | this.#stream = new ReadableStream>({ 230 | start: (controller) => { 231 | if (!Bun) { 232 | return controller.error( 233 | createHttpError( 234 | Status.InternalServerError, 235 | "Unable to start server, cannot find Bun.", 236 | ), 237 | ); 238 | } 239 | this.#controller = controller; 240 | const { hostname, port } = this.#server = Bun.serve({ 241 | fetch(req, server) { 242 | const requestEvent = new BunRequestEvent(req, server); 243 | controller.enqueue(requestEvent); 244 | return requestEvent.response; 245 | }, 246 | hostname: this.#options.hostname, 247 | port: this.#options.port, 248 | tls: this.#options.tls, 249 | }); 250 | resolve({ hostname, port, transport: "tcp" }); 251 | }, 252 | }); 253 | return promise; 254 | } 255 | 256 | [Symbol.asyncIterator](): AsyncIterableIterator> { 257 | if (!this.#stream) { 258 | throw createHttpError( 259 | Status.InternalServerError, 260 | "Server hasn't started listening.", 261 | ); 262 | } 263 | return this.#stream[Symbol.asyncIterator](); 264 | } 265 | 266 | [Symbol.for("nodejs.util.inspect.custom")]( 267 | depth: number, 268 | // deno-lint-ignore no-explicit-any 269 | options: any, 270 | inspect: (value: unknown, options?: unknown) => string, 271 | // deno-lint-ignore no-explicit-any 272 | ): any { 273 | if (depth < 0) { 274 | return options.stylize(`[${this.constructor.name}]`, "special"); 275 | } 276 | 277 | const newOptions = Object.assign({}, options, { 278 | depth: options.depth === null ? null : options.depth - 1, 279 | }); 280 | return `${options.stylize(this.constructor.name, "special")} ${ 281 | inspect({ closed: this.#closed }, newOptions) 282 | }`; 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /request_server_deno.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { assertEquals } from "@std/assert/equals"; 4 | import DenoServer from "./request_server_deno.ts"; 5 | 6 | Deno.test({ 7 | name: "DenoServer - should be closed initially", 8 | fn() { 9 | const { signal } = new AbortController(); 10 | const server = new DenoServer({ signal }); 11 | assertEquals(server.closed, true); 12 | }, 13 | }); 14 | 15 | // Add more tests here... 16 | -------------------------------------------------------------------------------- /request_server_deno.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { createHttpError } from "@oak/commons/http_errors"; 4 | import { Status } from "@oak/commons/status"; 5 | import hyperid from "hyperid"; 6 | 7 | import type { 8 | Addr, 9 | RequestEvent, 10 | RequestServer, 11 | RequestServerOptions, 12 | UpgradeWebSocketOptions, 13 | } from "./types.ts"; 14 | import { createPromiseWithResolvers } from "./utils.ts"; 15 | 16 | interface ServeHandlerInfo { 17 | remoteAddr: Deno.NetAddr; 18 | } 19 | 20 | type ServeHandler = ( 21 | request: Request, 22 | info: ServeHandlerInfo, 23 | ) => Response | Promise; 24 | 25 | interface HttpServer extends AsyncDisposable { 26 | finished: Promise; 27 | ref(): void; 28 | unref(): void; 29 | shutdown(): Promise; 30 | } 31 | 32 | interface ServeInit { 33 | handler: ServeHandler; 34 | } 35 | 36 | interface ServeOptions { 37 | port?: number; 38 | hostname?: string; 39 | signal?: AbortSignal; 40 | reusePort?: boolean; 41 | onError?: (error: unknown) => Response | Promise; 42 | onListen?: (params: { hostname: string; port: number }) => void; 43 | } 44 | 45 | interface ServeTlsOptions extends ServeOptions { 46 | cert: string; 47 | key: string; 48 | } 49 | 50 | const serve: 51 | | ((options: ServeInit & (ServeOptions | ServeTlsOptions)) => HttpServer) 52 | | undefined = "Deno" in globalThis && "serve" in globalThis.Deno 53 | ? globalThis.Deno.serve.bind(globalThis.Deno) 54 | : undefined; 55 | 56 | const instance = hyperid({ urlSafe: true }); 57 | 58 | /** 59 | * The implementation of the {@linkcode RequestEvent} interface for Deno. 60 | */ 61 | class DenoRequestEvent< 62 | Env extends Record = Record, 63 | > implements RequestEvent { 64 | #addr: Addr; 65 | #env: Env; 66 | #id = instance(); 67 | #promise: Promise; 68 | //deno-lint-ignore no-explicit-any 69 | #reject: (reason?: any) => void; 70 | #request: Request; 71 | #resolve: (value: Response | PromiseLike) => void; 72 | #responded = false; 73 | #url: URL; 74 | 75 | get addr(): Addr { 76 | return this.#addr; 77 | } 78 | 79 | get env(): Env { 80 | return this.#env; 81 | } 82 | 83 | get id(): string { 84 | return this.#id; 85 | } 86 | 87 | get request(): Request { 88 | return this.#request; 89 | } 90 | 91 | get responded(): boolean { 92 | return this.#responded; 93 | } 94 | 95 | get response(): Promise { 96 | return this.#promise; 97 | } 98 | 99 | get url(): URL { 100 | return this.#url; 101 | } 102 | 103 | constructor(request: Request, { remoteAddr }: ServeHandlerInfo, env: Env) { 104 | this.#addr = remoteAddr; 105 | this.#request = request; 106 | this.#env = env; 107 | const { promise, reject, resolve } = createPromiseWithResolvers(); 108 | this.#promise = promise; 109 | this.#reject = reject; 110 | this.#resolve = resolve; 111 | this.#url = URL.parse(request.url, "http://localhost/") ?? 112 | new URL("http://localhost/"); 113 | } 114 | 115 | //deno-lint-ignore no-explicit-any 116 | error(reason?: any): void { 117 | if (this.#responded) { 118 | throw createHttpError( 119 | Status.InternalServerError, 120 | "Request already responded to.", 121 | ); 122 | } 123 | this.#responded = true; 124 | this.#reject(reason); 125 | } 126 | 127 | respond(response: Response): void { 128 | if (this.#responded) { 129 | throw createHttpError( 130 | Status.InternalServerError, 131 | "Request already responded to.", 132 | ); 133 | } 134 | this.#responded = true; 135 | this.#resolve(response); 136 | } 137 | 138 | upgrade(options?: UpgradeWebSocketOptions | undefined): WebSocket { 139 | if (this.#responded) { 140 | throw createHttpError( 141 | Status.InternalServerError, 142 | "Request already responded to.", 143 | ); 144 | } 145 | const { response, socket } = Deno.upgradeWebSocket(this.#request, options); 146 | this.#responded = true; 147 | this.#resolve(response); 148 | return socket; 149 | } 150 | 151 | [Symbol.for("Deno.customInspect")]( 152 | inspect: (value: unknown) => string, 153 | ): string { 154 | return `${this.constructor.name} ${ 155 | inspect({ 156 | addr: this.#addr, 157 | env: this.#env, 158 | id: this.#id, 159 | request: this.#request, 160 | responded: this.#responded, 161 | response: this.#promise, 162 | url: this.#url, 163 | }) 164 | }`; 165 | } 166 | } 167 | 168 | /** 169 | * The implementation of the server API for Deno runtime and Deno Deploy. 170 | */ 171 | export default class DenoServer< 172 | Env extends Record = Record, 173 | > implements RequestServer { 174 | #closed = true; 175 | #controller?: ReadableStreamDefaultController; 176 | #env: Env; 177 | #options: RequestServerOptions; 178 | #server?: HttpServer; 179 | #stream?: ReadableStream>; 180 | 181 | get closed(): boolean { 182 | return this.#closed; 183 | } 184 | 185 | constructor(options: RequestServerOptions) { 186 | this.#options = options; 187 | this.#options.signal.addEventListener("abort", async () => { 188 | if (this.#closed) { 189 | return; 190 | } 191 | 192 | this.#closed = true; 193 | if (this.#server) { 194 | this.#server.unref(); 195 | await this.#server.shutdown(); 196 | this.#server = undefined; 197 | } 198 | try { 199 | this.#controller?.close(); 200 | } catch { 201 | // just ignore here 202 | } 203 | this.#stream = undefined; 204 | this.#controller = undefined; 205 | this.#server = undefined; 206 | }); 207 | this.#env = Object.freeze(Deno.env.toObject()) as Env; 208 | } 209 | 210 | listen(): Promise { 211 | if (!this.#closed) { 212 | throw new Error("Server already listening."); 213 | } 214 | const { promise, resolve } = createPromiseWithResolvers(); 215 | this.#stream = new ReadableStream>({ 216 | start: (controller) => { 217 | if (!serve) { 218 | return controller.error( 219 | createHttpError( 220 | Status.InternalServerError, 221 | "Unable to start server, cannot find Deno.serve().", 222 | ), 223 | ); 224 | } 225 | this.#controller = controller; 226 | const { 227 | port, 228 | hostname, 229 | signal, 230 | tls: { key, cert } = {}, 231 | } = this.#options; 232 | if (!((!key && !cert) || (key && cert))) { 233 | throw createHttpError( 234 | Status.InternalServerError, 235 | "Invalid configuration of TLS.", 236 | ); 237 | } 238 | this.#server = serve({ 239 | handler: (request, info) => { 240 | const requestEvent = new DenoRequestEvent(request, info, this.#env); 241 | controller.enqueue(requestEvent); 242 | return requestEvent.response; 243 | }, 244 | onListen: ({ hostname, port }) => { 245 | this.#closed = false; 246 | resolve({ hostname, port, transport: "tcp" }); 247 | }, 248 | port, 249 | hostname, 250 | signal, 251 | key, 252 | cert, 253 | }); 254 | }, 255 | }); 256 | return promise; 257 | } 258 | 259 | [Symbol.asyncIterator](): AsyncIterableIterator> { 260 | if (!this.#stream) { 261 | throw createHttpError( 262 | Status.InternalServerError, 263 | "Server hasn't started listening.", 264 | ); 265 | } 266 | return this.#stream[Symbol.asyncIterator](); 267 | } 268 | 269 | [Symbol.for("Deno.customInspect")]( 270 | inspect: (value: unknown) => string, 271 | ): string { 272 | return `${this.constructor.name} ${inspect({ closed: this.#closed })}`; 273 | } 274 | } 275 | -------------------------------------------------------------------------------- /request_server_node.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { createHttpError } from "@oak/commons/http_errors"; 4 | import { Status } from "@oak/commons/status"; 5 | import hyperid from "hyperid"; 6 | import type { IncomingMessage, ServerResponse } from "node:http"; 7 | import type { AddressInfo } from "node:net"; 8 | import process from "node:process"; 9 | 10 | import type { 11 | Addr, 12 | RequestEvent, 13 | RequestServer, 14 | RequestServerOptions, 15 | } from "./types.ts"; 16 | import { createPromiseWithResolvers } from "./utils.ts"; 17 | 18 | const instance = hyperid({ urlSafe: true }); 19 | 20 | /** 21 | * The implementation of the {@linkcode RequestEvent} interface for Node.js. 22 | */ 23 | class NodeRequestEvent> 24 | implements RequestEvent { 25 | #id = instance(); 26 | #incomingMessage: IncomingMessage; 27 | #promise: Promise; 28 | //deno-lint-ignore no-explicit-any 29 | #reject: (reason?: any) => void; 30 | #request: Request; 31 | #resolve: (value: Response | PromiseLike) => void; 32 | #responded = false; 33 | #serverResponse: ServerResponse; 34 | #url: URL; 35 | 36 | get addr(): Addr { 37 | // deno-lint-ignore no-explicit-any 38 | const value: any = this.#incomingMessage.socket.address(); 39 | return { 40 | transport: "tcp", 41 | hostname: value?.address ?? "", 42 | port: value?.port ?? 0, 43 | }; 44 | } 45 | 46 | get env(): Env { 47 | return process.env as Env; 48 | } 49 | 50 | get id(): string { 51 | return this.#id; 52 | } 53 | 54 | get request(): Request { 55 | return this.#request; 56 | } 57 | 58 | get responded(): boolean { 59 | return this.#responded; 60 | } 61 | 62 | get response(): Promise { 63 | return this.#promise; 64 | } 65 | 66 | get url(): URL { 67 | return this.#url; 68 | } 69 | 70 | constructor( 71 | incomingMessage: IncomingMessage, 72 | serverResponse: ServerResponse, 73 | host: string, 74 | address: string | AddressInfo | null, 75 | ) { 76 | this.#incomingMessage = incomingMessage; 77 | this.#serverResponse = serverResponse; 78 | const { promise, resolve, reject } = createPromiseWithResolvers(); 79 | this.#promise = promise; 80 | this.#resolve = resolve; 81 | this.#reject = reject; 82 | const headers = incomingMessage.headers as Record; 83 | const method = incomingMessage.method ?? "GET"; 84 | const url = this.#url = new URL( 85 | incomingMessage.url ?? "/", 86 | address 87 | ? typeof address === "string" 88 | ? `http://${host}` 89 | : `http://${host}:${address.port}/` 90 | : `http://${host}/`, 91 | ); 92 | const body = (method === "GET" || method === "HEAD") 93 | ? null 94 | : new ReadableStream({ 95 | start: (controller) => { 96 | incomingMessage.on("data", (chunk) => controller.enqueue(chunk)); 97 | incomingMessage.on("error", (err) => controller.error(err)); 98 | incomingMessage.on("end", () => { 99 | try { 100 | controller.close(); 101 | } catch { 102 | // just swallow here 103 | } 104 | }); 105 | }, 106 | }); 107 | this.#request = new Request(url, { body, headers, method }); 108 | } 109 | 110 | // deno-lint-ignore no-explicit-any 111 | error(reason?: any): void { 112 | if (this.#responded) { 113 | throw createHttpError( 114 | Status.InternalServerError, 115 | "Request already responded to.", 116 | ); 117 | } 118 | this.#responded = true; 119 | this.#reject(reason); 120 | } 121 | 122 | async respond(response: Response): Promise { 123 | if (this.#responded) { 124 | throw createHttpError( 125 | Status.InternalServerError, 126 | "Request already responded to.", 127 | ); 128 | } 129 | this.#responded = true; 130 | const headers = new Map(); 131 | for (const [key, value] of response.headers) { 132 | if (!headers.has(key)) { 133 | headers.set(key, []); 134 | } 135 | headers.get(key)!.push(value); 136 | } 137 | for (const [key, value] of headers) { 138 | this.#serverResponse.setHeader(key, value); 139 | } 140 | if (response.body) { 141 | for await (const chunk of response.body) { 142 | const { promise, resolve, reject } = createPromiseWithResolvers(); 143 | this.#serverResponse.write(chunk, (err) => { 144 | if (err) { 145 | reject(err); 146 | } else { 147 | resolve(); 148 | } 149 | }); 150 | await promise; 151 | } 152 | } 153 | const { promise, resolve } = createPromiseWithResolvers(); 154 | this.#serverResponse.end(resolve); 155 | await promise; 156 | this.#resolve(response); 157 | } 158 | 159 | [Symbol.for("nodejs.util.inspect.custom")]( 160 | depth: number, 161 | // deno-lint-ignore no-explicit-any 162 | options: any, 163 | inspect: (value: unknown, options?: unknown) => string, 164 | // deno-lint-ignore no-explicit-any 165 | ): any { 166 | if (depth < 0) { 167 | return options.stylize(`[${this.constructor.name}]`, "special"); 168 | } 169 | 170 | const newOptions = Object.assign({}, options, { 171 | depth: options.depth === null ? null : options.depth - 1, 172 | }); 173 | return `${options.stylize(this.constructor.name, "special")} ${ 174 | inspect({ 175 | addr: this.addr, 176 | env: this.env, 177 | id: this.#id, 178 | request: this.#request, 179 | responded: this.#responded, 180 | response: this.#promise, 181 | url: this.#url, 182 | }, newOptions) 183 | }`; 184 | } 185 | } 186 | 187 | /** 188 | * The implementation of the {@linkcode RequestServer} interface for Node.js. 189 | */ 190 | export default class NodeRequestServer< 191 | Env extends Record = Record, 192 | > implements RequestServer { 193 | #address: string | AddressInfo | null = null; 194 | #closed = true; 195 | #hostname: string; 196 | #port: number; 197 | #signal: AbortSignal; 198 | #stream?: ReadableStream>; 199 | 200 | get closed(): boolean { 201 | return this.#closed; 202 | } 203 | 204 | constructor(options: RequestServerOptions) { 205 | const { hostname, port, signal } = options; 206 | this.#hostname = hostname ?? "127.0.0.1"; 207 | this.#port = port ?? 80; 208 | this.#signal = signal; 209 | } 210 | 211 | async listen(): Promise { 212 | if (!("Request" in globalThis) || !("Response" in globalThis)) { 213 | await import("npm:buffer@^6.0"); 214 | await import("npm:string_decoder@^1.3"); 215 | const { Request, Response } = await import("npm:undici@^6.18"); 216 | Object.defineProperties(globalThis, { 217 | "Request": { 218 | value: Request, 219 | writable: true, 220 | enumerable: false, 221 | configurable: true, 222 | }, 223 | "Response": { 224 | value: Response, 225 | writable: true, 226 | enumerable: false, 227 | configurable: true, 228 | }, 229 | }); 230 | } 231 | if (!("ReadableStream" in globalThis)) { 232 | const { ReadableStream } = await import("node:stream/web"); 233 | Object.defineProperty(globalThis, "ReadableStream", { 234 | value: ReadableStream, 235 | writable: true, 236 | enumerable: false, 237 | configurable: true, 238 | }); 239 | } 240 | const { createServer } = await import("node:http"); 241 | const { resolve, promise } = createPromiseWithResolvers(); 242 | this.#stream = new ReadableStream>({ 243 | start: (controller) => { 244 | const server = createServer( 245 | (incomingMessage, serverResponse) => { 246 | controller.enqueue( 247 | new NodeRequestEvent( 248 | incomingMessage, 249 | serverResponse, 250 | this.#hostname, 251 | this.#address, 252 | ), 253 | ); 254 | }, 255 | ); 256 | this.#closed = false; 257 | this.#signal.addEventListener("abort", () => { 258 | try { 259 | controller.close(); 260 | } catch { 261 | // just ignore here 262 | } 263 | }); 264 | server.listen( 265 | { 266 | port: this.#port, 267 | hostname: this.#hostname, 268 | signal: this.#signal, 269 | }, 270 | () => 271 | resolve({ 272 | port: this.#port, 273 | hostname: this.#hostname, 274 | transport: "tcp", 275 | }), 276 | ); 277 | }, 278 | }); 279 | return promise; 280 | } 281 | 282 | [Symbol.asyncIterator](): AsyncIterableIterator> { 283 | if (!this.#stream) { 284 | throw new TypeError("Server hasn't started listening."); 285 | } 286 | return this.#stream[Symbol.asyncIterator](); 287 | } 288 | 289 | [Symbol.for("nodejs.util.inspect.custom")]( 290 | depth: number, 291 | // deno-lint-ignore no-explicit-any 292 | options: any, 293 | inspect: (value: unknown, options?: unknown) => string, 294 | // deno-lint-ignore no-explicit-any 295 | ): any { 296 | if (depth < 0) { 297 | return options.stylize(`[${this.constructor.name}]`, "special"); 298 | } 299 | 300 | const newOptions = Object.assign({}, options, { 301 | depth: options.depth === null ? null : options.depth - 1, 302 | }); 303 | return `${options.stylize(this.constructor.name, "special")} ${ 304 | inspect({ closed: this.#closed }, newOptions) 305 | }`; 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /route.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import type { KeyRing } from "@oak/commons/cookie_map"; 4 | import { createHttpError } from "@oak/commons/http_errors"; 5 | import type { HttpMethod } from "@oak/commons/method"; 6 | import { Status, STATUS_TEXT } from "@oak/commons/status"; 7 | import { type Key, pathToRegexp } from "path-to-regexp"; 8 | import type { InferOutput } from "@valibot/valibot"; 9 | 10 | import { NOT_ALLOWED } from "./constants.ts"; 11 | import { Context } from "./context.ts"; 12 | import { getLogger, type Logger } from "./logger.ts"; 13 | import { 14 | type BodySchema, 15 | type QueryStringSchema, 16 | Schema, 17 | type SchemaDescriptor, 18 | } from "./schema.ts"; 19 | import type { 20 | NotAllowed, 21 | ParamsDictionary, 22 | RequestEvent, 23 | Route, 24 | RouteParameters, 25 | } from "./types.ts"; 26 | import { appendHeaders, decodeComponent } from "./utils.ts"; 27 | 28 | /** 29 | * A function that handles a route. The handler is provided a 30 | * {@linkcode Context} object which provides information about the request 31 | * being handled as well as other methods for interacting with the request. 32 | * A handler can return a {@linkcode Response} object, a value that can be 33 | * serialized to JSON, or `undefined`. If a value is returned, it will be 34 | * validated against the response schema of the route. If `undefined` is 35 | * returned, the response will be handled as a `204 No Content` response. 36 | * 37 | * The handler can also return a promise that resolves to any of the above 38 | * values. 39 | */ 40 | export interface RouteHandler< 41 | Env extends Record = Record, 42 | Params extends ParamsDictionary | undefined = ParamsDictionary | undefined, 43 | QSSchema extends QueryStringSchema = QueryStringSchema, 44 | QueryParams extends InferOutput = InferOutput, 45 | BSchema extends BodySchema = BodySchema, 46 | RequestBody extends InferOutput = InferOutput, 47 | ResSchema extends BodySchema = BodySchema, 48 | ResponseBody extends InferOutput = InferOutput, 49 | > { 50 | ( 51 | context: Context< 52 | Env, 53 | Params, 54 | QSSchema, 55 | QueryParams, 56 | BSchema, 57 | RequestBody, 58 | ResSchema 59 | >, 60 | ): 61 | | Promise 62 | | Response 63 | | ResponseBody 64 | | undefined; 65 | } 66 | 67 | /** 68 | * Options which can be set with on a route, related to how matching against a 69 | * path pattern works. 70 | */ 71 | export interface RouteOptions { 72 | /** 73 | * Allows the path delimiter (`/`) to be repeated arbitrarily. 74 | * 75 | * @default true 76 | */ 77 | loose?: boolean; 78 | /** 79 | * When matching route paths, enforce cases sensitive matches. 80 | * 81 | * @default false 82 | */ 83 | sensitive?: boolean; 84 | /** 85 | * When matching route paths, ensure that optional trailing slashes are not 86 | * matched. 87 | * 88 | * @default true 89 | */ 90 | trailing?: boolean; 91 | } 92 | 93 | /** 94 | * Encapsulation of logic for a registered router handler. 95 | */ 96 | export class PathRoute< 97 | Path extends string = string, 98 | Params extends RouteParameters = RouteParameters, 99 | Env extends Record = Record, 100 | QSSchema extends QueryStringSchema = QueryStringSchema, 101 | QueryParams extends InferOutput = InferOutput, 102 | BSchema extends BodySchema = BodySchema, 103 | RequestBody extends InferOutput | undefined = 104 | | InferOutput 105 | | undefined, 106 | ResSchema extends BodySchema = BodySchema, 107 | ResponseBody extends InferOutput = InferOutput, 108 | > implements Route { 109 | #expose: boolean; 110 | #handler: RouteHandler< 111 | Env, 112 | Params, 113 | QSSchema, 114 | QueryParams, 115 | BSchema, 116 | RequestBody, 117 | ResSchema, 118 | ResponseBody 119 | >; 120 | #keys?: KeyRing; 121 | #logger: Logger; 122 | #methods: HttpMethod[]; 123 | #params?: Params; 124 | #paramKeys: Key[]; 125 | #path: Path; 126 | #regexp: RegExp; 127 | #schema: Schema; 128 | 129 | /** 130 | * The methods that this route is registered to handle. 131 | */ 132 | get methods(): HttpMethod[] { 133 | return [...this.#methods]; 134 | } 135 | 136 | /** 137 | * Set when a route is matched, contains the values that are parsed out of 138 | * the matched route. 139 | */ 140 | get params(): Params | undefined { 141 | return this.#params; 142 | } 143 | 144 | /** 145 | * The path that this route is registered on, following the pattern matching 146 | * and parameter parsing syntax of 147 | * [path-to-regexp](https://github.com/pillarjs/path-to-regexp). 148 | */ 149 | get path(): Path { 150 | return this.#path; 151 | } 152 | 153 | /** 154 | * The path pattern that has been converted into {@linkcode RegExp}. 155 | */ 156 | get regex(): RegExp { 157 | return this.#regexp; 158 | } 159 | 160 | /** 161 | * If provided, the validation schema which is used to validate the body of 162 | * the request. 163 | */ 164 | get schema(): Schema { 165 | return this.#schema; 166 | } 167 | 168 | constructor( 169 | path: Path, 170 | methods: HttpMethod[], 171 | schemaDescriptor: SchemaDescriptor, 172 | handler: RouteHandler< 173 | Env, 174 | Params, 175 | QSSchema, 176 | QueryParams, 177 | BSchema, 178 | RequestBody, 179 | ResSchema, 180 | ResponseBody 181 | >, 182 | keys: KeyRing | undefined, 183 | expose: boolean, 184 | options?: RouteOptions, 185 | ) { 186 | this.#path = path; 187 | this.#methods = methods; 188 | this.#schema = new Schema(schemaDescriptor, expose); 189 | this.#handler = handler; 190 | this.#keys = keys; 191 | this.#expose = expose; 192 | const { regexp, keys: paramKeys } = pathToRegexp(path, { ...options }); 193 | this.#regexp = regexp; 194 | this.#paramKeys = paramKeys; 195 | this.#logger = getLogger("acorn.route"); 196 | this.#logger 197 | .debug(`created route with path: ${path} and methods: ${methods}`); 198 | } 199 | 200 | /** 201 | * Invokes the associated handler with the route and returns any response 202 | * from the handler. 203 | */ 204 | async handle( 205 | requestEvent: RequestEvent, 206 | responseHeaders: Headers, 207 | secure: boolean, 208 | ): Promise { 209 | this.#logger.debug(`[${this.#path}] ${requestEvent.id} route.handle()`); 210 | if (!this.#params) { 211 | throw createHttpError( 212 | Status.InternalServerError, 213 | "Route parameters missing.", 214 | ); 215 | } 216 | const context = new Context< 217 | Env, 218 | Params, 219 | QSSchema, 220 | QueryParams, 221 | BSchema, 222 | RequestBody, 223 | ResSchema 224 | >( 225 | requestEvent, 226 | responseHeaders, 227 | secure, 228 | this.#params, 229 | this.#schema, 230 | this.#keys, 231 | this.#expose, 232 | ); 233 | this.#logger.debug(`[${this.#path}] ${requestEvent.id} calling handler`); 234 | const result = await this.#handler(context); 235 | this.#logger 236 | .debug(`${requestEvent.id} handler returned with value: ${!!result}`); 237 | if (result instanceof Response) { 238 | this.#logger 239 | .debug( 240 | `[${this.#path}] ${requestEvent.id} handler returned a Response object`, 241 | ); 242 | return appendHeaders(result, responseHeaders); 243 | } 244 | if (result) { 245 | this.#logger 246 | .debug( 247 | `${requestEvent.id} handler returned a value, validating response`, 248 | ); 249 | const maybeValid = await this.#schema.validateResponse(result); 250 | if (maybeValid.output) { 251 | this.#logger 252 | .debug(`[${this.#path}] ${requestEvent.id} response is valid`); 253 | return Response.json(maybeValid.output, { headers: responseHeaders }); 254 | } else { 255 | this.#logger 256 | .error(`[${this.#path}] ${requestEvent.id} response is invalid`); 257 | return maybeValid.invalidResponse; 258 | } 259 | } 260 | this.#logger 261 | .debug(`[${this.#path}] ${requestEvent.id} handler returned no value`); 262 | return new Response(null, { 263 | status: Status.NoContent, 264 | statusText: STATUS_TEXT[Status.NoContent], 265 | }); 266 | } 267 | 268 | /** 269 | * Determines if the request should be handled by the route. 270 | */ 271 | matches(method: HttpMethod, pathname: string): boolean | NotAllowed { 272 | const match = pathname.match(this.#regexp); 273 | if (match) { 274 | if (!this.#methods.includes(method)) { 275 | return NOT_ALLOWED; 276 | } 277 | this.#logger 278 | .debug(`[${this.#path}] route matched: ${method} ${pathname}`); 279 | const params = {} as Params; 280 | const captures = match.slice(1); 281 | for (let i = 0; i < captures.length; i++) { 282 | if (this.#paramKeys[i]) { 283 | const capture = captures[i]; 284 | (params as Record)[this.#paramKeys[i].name] = 285 | decodeComponent(capture); 286 | } 287 | } 288 | this.#params = params; 289 | return true; 290 | } 291 | return false; 292 | } 293 | 294 | [Symbol.for("Deno.customInspect")]( 295 | inspect: (value: unknown) => string, 296 | ): string { 297 | return `${this.constructor.name} ${ 298 | inspect({ 299 | params: this.#params, 300 | path: this.#path, 301 | regex: this.#regexp, 302 | schema: this.#schema, 303 | }) 304 | }`; 305 | } 306 | 307 | [Symbol.for("nodejs.util.inspect.custom")]( 308 | depth: number, 309 | // deno-lint-ignore no-explicit-any 310 | options: any, 311 | inspect: (value: unknown, options?: unknown) => string, 312 | // deno-lint-ignore no-explicit-any 313 | ): any { 314 | if (depth < 0) { 315 | return options.stylize(`[${this.constructor.name}]`, "special"); 316 | } 317 | 318 | const newOptions = Object.assign({}, options, { 319 | depth: options.depth === null ? null : options.depth - 1, 320 | }); 321 | return `${options.stylize(this.constructor.name, "special")} ${ 322 | inspect({ 323 | params: this.#params, 324 | path: this.#path, 325 | regex: this.#regexp, 326 | schema: this.#schema, 327 | }, newOptions) 328 | }`; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /router.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { assert } from "jsr:@std/assert@^0.226/assert"; 4 | 5 | import { Router } from "./router.ts"; 6 | import { assertEquals } from "jsr:@std/assert@^0.226/assert-equals"; 7 | 8 | Deno.test({ 9 | name: "Router - register route - get - path and handler", 10 | fn() { 11 | const router = new Router(); 12 | router.get("/", () => { 13 | return { hello: "world" }; 14 | }); 15 | const route = router.match("GET", "/"); 16 | assert(route); 17 | assertEquals(route.path, "/"); 18 | }, 19 | }); 20 | 21 | Deno.test({ 22 | name: "Router - register route - head - path and handler", 23 | fn() { 24 | const router = new Router(); 25 | router.head("/", () => { 26 | return { hello: "world" }; 27 | }); 28 | const route = router.match("HEAD", "/"); 29 | assert(route); 30 | assertEquals(route.path, "/"); 31 | }, 32 | }); 33 | 34 | Deno.test({ 35 | name: "Router - register route - post - path and handler", 36 | fn() { 37 | const router = new Router(); 38 | router.post("/", () => { 39 | return { hello: "world" }; 40 | }); 41 | const route = router.match("POST", "/"); 42 | assert(route); 43 | assertEquals(route.path, "/"); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /routing.bench.ts: -------------------------------------------------------------------------------- 1 | import { pathToRegexp as pathToRegexp7 } from "npm:path-to-regexp@7.0.0"; 2 | import { pathToRegexp as pathToRegexp71 } from "npm:path-to-regexp@7.1.0"; 3 | import { pathToRegexp as pathToRegexp8 } from "npm:path-to-regexp@8.2.0"; 4 | import { pathToRegexp } from "npm:path-to-regexp@6.2.1"; 5 | import { URLPattern as URLPatternPolyfill } from "npm:urlpattern-polyfill@10.0.0"; 6 | 7 | const urlPatternPolyfill = new URLPatternPolyfill( 8 | "/book/:id", 9 | "http://localhost/", 10 | ); 11 | 12 | Deno.bench({ 13 | name: "URLPattern polyfill", 14 | fn() { 15 | if (urlPatternPolyfill.exec("http://localhost/book/1234")) { 16 | true; 17 | } 18 | }, 19 | }); 20 | 21 | const urlPattern = new URLPattern("/book/:id", "http://localhost/"); 22 | 23 | Deno.bench({ 24 | name: "URLPattern", 25 | fn() { 26 | if (urlPattern.exec("http://localhost/book/1234")) { 27 | true; 28 | } 29 | }, 30 | }); 31 | 32 | const regexp = pathToRegexp("/:id"); 33 | 34 | Deno.bench({ 35 | name: "pathToRegexp 6.2", 36 | fn() { 37 | if (regexp.exec("/1234")) { 38 | true; 39 | } 40 | }, 41 | }); 42 | 43 | const regexp7 = pathToRegexp7("/book/:id"); 44 | 45 | Deno.bench({ 46 | name: "pathToRegexp 7.0", 47 | fn() { 48 | if (regexp7.exec("/book/1234")) { 49 | true; 50 | } 51 | }, 52 | }); 53 | 54 | const regexp71 = pathToRegexp71("/book/:id", { strict: true }); 55 | 56 | Deno.bench({ 57 | name: "pathToRegexp 7.1", 58 | fn() { 59 | if (regexp71.exec("/book/1234")) { 60 | true; 61 | } 62 | }, 63 | }); 64 | 65 | const { regexp: regexp8 } = pathToRegexp8("/book/:id"); 66 | 67 | Deno.bench({ 68 | name: "pathToRegexp 8", 69 | fn() { 70 | if (regexp8.exec("/book/1234")) { 71 | true; 72 | } 73 | }, 74 | }); 75 | -------------------------------------------------------------------------------- /schema.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { isHttpError } from "@oak/commons/http_errors"; 4 | import { assert } from "@std/assert/assert"; 5 | import { assertEquals } from "@std/assert/equals"; 6 | import { assertRejects } from "@std/assert/rejects"; 7 | import * as v from "@valibot/valibot"; 8 | import { MockRequestEvent } from "./testing_utils.ts"; 9 | 10 | import { Schema } from "./schema.ts"; 11 | 12 | Deno.test({ 13 | name: "Schema - empty schema should passthrough values for querystring", 14 | async fn() { 15 | const schema = new Schema(undefined, false); 16 | const requestEvent = new MockRequestEvent("http://localhost/?a=1&b=2", { 17 | method: "POST", 18 | headers: { "Content-Type": "application/json" }, 19 | body: JSON.stringify({ c: 3 }), 20 | }); 21 | const result = await schema.validateQueryString(requestEvent); 22 | assertEquals(result, { output: { a: "1", b: "2" } }); 23 | }, 24 | }); 25 | 26 | Deno.test({ 27 | name: "Schema - empty schema should passthrough values for body", 28 | async fn() { 29 | const schema = new Schema(undefined, false); 30 | const requestEvent = new MockRequestEvent("http://localhost/?a=1&b=2", { 31 | method: "POST", 32 | headers: { "Content-Type": "application/json" }, 33 | body: JSON.stringify({ c: 3 }), 34 | }); 35 | const result = await schema.validateBody(requestEvent); 36 | assertEquals(result, { output: { c: 3 } }); 37 | }, 38 | }); 39 | 40 | Deno.test({ 41 | name: "Schema - empty schema should passthrough values for response", 42 | async fn() { 43 | const schema = new Schema(undefined, false); 44 | const result = await schema.validateResponse({ hello: "world" }); 45 | assertEquals(result, { output: { hello: "world" } }); 46 | }, 47 | }); 48 | 49 | Deno.test({ 50 | name: "Schema - querystring schema should validate querystring", 51 | async fn() { 52 | const schema = new Schema({ 53 | querystring: v.object({ a: v.string(), b: v.string() }), 54 | }, false); 55 | const requestEvent = new MockRequestEvent("http://localhost/?a=1&b=2", { 56 | method: "POST", 57 | headers: { "Content-Type": "application/json" }, 58 | body: JSON.stringify({ c: 3 }), 59 | }); 60 | const result = await schema.validateQueryString(requestEvent); 61 | assertEquals(result, { output: { a: "1", b: "2" } }); 62 | }, 63 | }); 64 | 65 | Deno.test({ 66 | name: "Schema - body schema should validate body", 67 | async fn() { 68 | const schema = new Schema({ body: v.object({ c: v.number() }) }, false); 69 | const requestEvent = new MockRequestEvent("http://localhost/?a=1&b=2", { 70 | method: "POST", 71 | headers: { "Content-Type": "application/json" }, 72 | body: JSON.stringify({ c: 3 }), 73 | }); 74 | const result = await schema.validateBody(requestEvent); 75 | assertEquals(result, { output: { c: 3 } }); 76 | }, 77 | }); 78 | 79 | Deno.test({ 80 | name: "Schema - response schema should validate response", 81 | async fn() { 82 | const schema = new Schema( 83 | { response: v.object({ hello: v.string() }) }, 84 | false, 85 | ); 86 | const result = await schema.validateResponse({ hello: "world" }); 87 | assertEquals(result, { output: { hello: "world" } }); 88 | }, 89 | }); 90 | 91 | Deno.test({ 92 | name: "Schema - invalid querystring should reject with 400", 93 | async fn() { 94 | const schema = new Schema({ 95 | querystring: v.object({ a: v.string(), b: v.string() }), 96 | }, false); 97 | const requestEvent = new MockRequestEvent("http://localhost/?a=1", { 98 | method: "POST", 99 | headers: { "Content-Type": "application/json" }, 100 | body: JSON.stringify({ c: 3 }), 101 | }); 102 | const error = await assertRejects(async () => { 103 | await schema.validateQueryString(requestEvent); 104 | }); 105 | assert(isHttpError(error)); 106 | assertEquals(error.status, 400); 107 | }, 108 | }); 109 | 110 | Deno.test({ 111 | name: "Schema - invalid body should reject with 400", 112 | async fn() { 113 | const schema = new Schema({ body: v.object({ c: v.string() }) }, false); 114 | const requestEvent = new MockRequestEvent("http://localhost/?a=1&b=2", { 115 | method: "POST", 116 | headers: { "Content-Type": "application/json" }, 117 | body: JSON.stringify({ c: 3 }), 118 | }); 119 | const error = await assertRejects(async () => { 120 | await schema.validateBody(requestEvent); 121 | }); 122 | assert(isHttpError(error)); 123 | assertEquals(error.status, 400); 124 | }, 125 | }); 126 | 127 | Deno.test({ 128 | name: "Schema - invalid response should should reject with 500", 129 | async fn() { 130 | const schema = new Schema( 131 | { response: v.object({ hello: v.number() }) }, 132 | false, 133 | ); 134 | const error = await assertRejects(async () => { 135 | await schema.validateResponse({ hello: "world" }); 136 | }); 137 | assert(isHttpError(error)); 138 | assertEquals(error.status, 500); 139 | }, 140 | }); 141 | 142 | Deno.test({ 143 | name: "Schema - invalid querystring should call invalid handler", 144 | async fn() { 145 | const schema = new Schema({ 146 | querystring: v.object({ a: v.string(), b: v.string() }), 147 | invalidHandler(type, issues) { 148 | assert(type === "querystring"); 149 | assertEquals(issues.length, 1); 150 | return new Response("Invalid querystring", { status: 400 }); 151 | }, 152 | }, false); 153 | const requestEvent = new MockRequestEvent("http://localhost/?a=1", { 154 | method: "POST", 155 | headers: { "Content-Type": "application/json" }, 156 | body: JSON.stringify({ c: 3 }), 157 | }); 158 | const result = await schema.validateQueryString(requestEvent); 159 | assert(result.invalidResponse instanceof Response); 160 | assertEquals(result.invalidResponse.status, 400); 161 | }, 162 | }); 163 | 164 | Deno.test({ 165 | name: "Schema - invalid body should call invalid handler", 166 | async fn() { 167 | const schema = new Schema({ 168 | body: v.object({ c: v.string() }), 169 | invalidHandler(type, issues) { 170 | assert(type === "body"); 171 | assertEquals(issues.length, 1); 172 | return new Response("Invalid querystring", { status: 400 }); 173 | }, 174 | }, false); 175 | const requestEvent = new MockRequestEvent("http://localhost/?a=1", { 176 | method: "POST", 177 | headers: { "Content-Type": "application/json" }, 178 | body: JSON.stringify({ c: 3 }), 179 | }); 180 | const result = await schema.validateBody(requestEvent); 181 | assert(result.invalidResponse instanceof Response); 182 | assertEquals(result.invalidResponse.status, 400); 183 | }, 184 | }); 185 | 186 | Deno.test({ 187 | name: "Schema - invalid response should should call invalid handler", 188 | async fn() { 189 | const schema = new Schema({ 190 | response: v.object({ hello: v.number() }), 191 | invalidHandler(type, issues) { 192 | assert(type === "response"); 193 | assertEquals(issues.length, 1); 194 | return new Response("Invalid querystring", { status: 400 }); 195 | }, 196 | }, false); 197 | const result = await schema.validateResponse({ hello: "world" }); 198 | assert(result.invalidResponse instanceof Response); 199 | assertEquals(result.invalidResponse.status, 400); 200 | }, 201 | }); 202 | 203 | Deno.test({ 204 | name: "Schema - throwing in invalid handler should throw 500 for querystring", 205 | async fn() { 206 | const schema = new Schema({ 207 | querystring: v.object({ a: v.string(), b: v.string() }), 208 | invalidHandler() { 209 | throw new Error("Boom"); 210 | }, 211 | }, false); 212 | const requestEvent = new MockRequestEvent("http://localhost/?a=1", { 213 | method: "POST", 214 | headers: { "Content-Type": "application/json" }, 215 | body: JSON.stringify({ c: 3 }), 216 | }); 217 | const error = await assertRejects(async () => { 218 | await schema.validateQueryString(requestEvent); 219 | }); 220 | assert(isHttpError(error)); 221 | assertEquals(error.status, 500); 222 | assert(error.cause instanceof Error); 223 | assertEquals(error.cause.message, "Boom"); 224 | }, 225 | }); 226 | 227 | Deno.test({ 228 | name: "Schema - throwing in invalid handler should throw 500 for body", 229 | async fn() { 230 | const schema = new Schema({ 231 | body: v.object({ c: v.string() }), 232 | invalidHandler() { 233 | throw new Error("Boom"); 234 | }, 235 | }, false); 236 | const requestEvent = new MockRequestEvent("http://localhost/?a=1", { 237 | method: "POST", 238 | headers: { "Content-Type": "application/json" }, 239 | body: JSON.stringify({ c: 3 }), 240 | }); 241 | const error = await assertRejects(async () => { 242 | await schema.validateBody(requestEvent); 243 | }); 244 | assert(isHttpError(error)); 245 | assertEquals(error.status, 500); 246 | assert(error.cause instanceof Error); 247 | assertEquals(error.cause.message, "Boom"); 248 | }, 249 | }); 250 | 251 | Deno.test({ 252 | name: "Schema - throwing in invalid handler should throw 500 for response", 253 | async fn() { 254 | const schema = new Schema({ 255 | response: v.object({ hello: v.number() }), 256 | invalidHandler() { 257 | throw new Error("Boom"); 258 | }, 259 | }, false); 260 | const error = await assertRejects(async () => { 261 | await schema.validateResponse({ hello: "world" }); 262 | }); 263 | assert(isHttpError(error)); 264 | assertEquals(error.status, 500); 265 | assert(error.cause instanceof Error); 266 | assertEquals(error.cause.message, "Boom"); 267 | }, 268 | }); 269 | -------------------------------------------------------------------------------- /schema.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { createHttpError } from "@oak/commons/http_errors"; 4 | import { Status } from "@oak/commons/status"; 5 | import { 6 | type BaseIssue, 7 | type BaseSchema, 8 | type BaseSchemaAsync, 9 | type Config, 10 | type ErrorMessage, 11 | type InferIssue, 12 | type InferOutput, 13 | type ObjectEntries, 14 | type ObjectEntriesAsync, 15 | type ObjectIssue, 16 | type ObjectSchema, 17 | type ObjectSchemaAsync, 18 | parseAsync, 19 | safeParseAsync, 20 | } from "@valibot/valibot"; 21 | import { parse } from "qs"; 22 | 23 | import { BODYLESS_METHODS } from "./constants.ts"; 24 | import { getLogger } from "./logger.ts"; 25 | import type { RequestEvent } from "./types.ts"; 26 | 27 | /** 28 | * A base type of the schema that can be applied to the body of a request or 29 | * response. 30 | */ 31 | export type BodySchema = 32 | | BaseSchema> 33 | | BaseSchemaAsync>; 34 | 35 | /** 36 | * A base type of the schema that can be applied to the querystring of a 37 | * request. 38 | */ 39 | export type QueryStringSchema = 40 | | ObjectSchema< 41 | ObjectEntries, 42 | ErrorMessage | undefined 43 | > 44 | | ObjectSchemaAsync< 45 | ObjectEntriesAsync, 46 | ErrorMessage | undefined 47 | >; 48 | 49 | /** 50 | * A function that can be called when a schema is invalid. 51 | * 52 | * `part` is a string that indicates which part of the schema is invalid. 53 | * `"querystring"` indicates that the querystring schema is invalid. `"body"` 54 | * indicates that the body schema is invalid. `"response"` indicates that the 55 | * response schema is invalid. 56 | * 57 | * `issues` is an array of issues that were found when validating the schema. 58 | * 59 | * The handler is expected to return a response which will be sent to the 60 | * client. If the handler throws an error, a `InternalServerError` HTTP error 61 | * will be thrown with the error as the `cause`. 62 | */ 63 | export interface InvalidHandler< 64 | QSSchema extends QueryStringSchema, 65 | BSchema extends BodySchema, 66 | ResSchema extends BodySchema, 67 | > { 68 | ( 69 | part: "querystring", 70 | issues: [InferIssue, ...InferIssue[]], 71 | ): Promise | Response; 72 | ( 73 | part: "body", 74 | issues: [InferIssue, ...InferIssue[]], 75 | ): Promise | Response; 76 | ( 77 | part: "response", 78 | issues: [InferIssue, ...InferIssue[]], 79 | ): Promise | Response; 80 | } 81 | 82 | /** Validation options which can be applied when validating. */ 83 | export type ValidationOptions< 84 | Schema extends BodySchema, 85 | > = Omit>, "skipPipe">; 86 | 87 | type MaybeValid = { output: T; invalidResponse?: undefined } | { 88 | output?: undefined; 89 | invalidResponse: Response; 90 | }; 91 | 92 | /** 93 | * A descriptor for a schema that can be applied to a request and response. 94 | * 95 | * @template QSSchema the schema that can be applied to the querystring of a 96 | * request. 97 | * @template BSchema the schema that can be applied to the body of a request. 98 | * @template ResSchema the schema that can be applied to the body of a response. 99 | */ 100 | export interface SchemaDescriptor< 101 | QSSchema extends QueryStringSchema, 102 | BSchema extends BodySchema, 103 | ResSchema extends BodySchema, 104 | > { 105 | /** 106 | * A schema that can be applied to the querystring of a request. 107 | */ 108 | querystring?: QSSchema; 109 | /** 110 | * A schema that can be applied to the body of a request. 111 | */ 112 | body?: BSchema; 113 | /** 114 | * A schema that can be applied to the body of a response. 115 | */ 116 | response?: ResSchema; 117 | /** 118 | * Options that can be applied to the validation of the schema. 119 | */ 120 | options?: ValidationOptions; 121 | /** 122 | * A handler that can be called when the schema is invalid. 123 | * 124 | * The handler is expected to return a response which will be sent to the 125 | * client. If the handler throws an error, a `InternalServerError` HTTP error 126 | * will be thrown with the error as the `cause`. 127 | */ 128 | invalidHandler?: InvalidHandler; 129 | } 130 | 131 | /** 132 | * A class that can apply validation schemas to the querystring and request and 133 | * response bodies. 134 | */ 135 | export class Schema< 136 | QSSchema extends QueryStringSchema, 137 | BSchema extends BodySchema, 138 | ResSchema extends BodySchema, 139 | > { 140 | #body?: BSchema; 141 | #expose: boolean; 142 | #invalidHandler?: InvalidHandler; 143 | #logger = getLogger("acorn.schema"); 144 | #options?: ValidationOptions; 145 | #querystring?: QSSchema; 146 | #response?: ResSchema; 147 | 148 | constructor( 149 | descriptor: SchemaDescriptor = {}, 150 | expose: boolean, 151 | ) { 152 | this.#querystring = descriptor.querystring; 153 | this.#body = descriptor.body; 154 | this.#response = descriptor.response; 155 | this.#options = descriptor.options; 156 | this.#invalidHandler = descriptor.invalidHandler; 157 | this.#expose = expose; 158 | } 159 | 160 | /** 161 | * Given a {@linkcode RequestEvent}, this method will attempt to parse the 162 | * `search` part of the URL and validate it against the schema provided. If 163 | * no schema was provided, the parsed search will be returned. If the schema 164 | * is provided and the parsed search is invalid, the invalid handler will be 165 | * called if provided, otherwise a `BadRequest` HTTP error will be thrown. 166 | */ 167 | async validateQueryString( 168 | requestEvent: RequestEvent, 169 | ): Promise> { 170 | const id = requestEvent.id; 171 | this.#logger.debug(`${id} schema.validateQueryString()`); 172 | const input = parse(requestEvent.url.search.slice(1)); 173 | if (!this.#querystring) { 174 | this.#logger.debug(`${id} no querystring schema provided.`); 175 | return { output: input }; 176 | } 177 | if (this.#invalidHandler) { 178 | this.#logger.debug(`${id} validating querystring.`); 179 | const result = await safeParseAsync( 180 | this.#querystring, 181 | input, 182 | this.#options, 183 | ); 184 | if (result.success) { 185 | this.#logger.debug(`${id} querystring is valid.`); 186 | return { output: result.output }; 187 | } else { 188 | try { 189 | this.#logger 190 | .debug(`${id} querystring is invalid, calling invalid handler.`); 191 | return { 192 | invalidResponse: await this.#invalidHandler( 193 | "querystring", 194 | result.issues, 195 | ), 196 | }; 197 | } catch (cause) { 198 | this.#logger.error(`${id} invalid handler failed.`); 199 | throw createHttpError( 200 | Status.InternalServerError, 201 | "Invalid handler failed", 202 | { cause }, 203 | ); 204 | } 205 | } 206 | } else { 207 | try { 208 | this.#logger.debug(`${id} validating querystring.`); 209 | return { 210 | output: await parseAsync(this.#querystring, input, this.#options), 211 | }; 212 | } catch (cause) { 213 | this.#logger.debug(`${id} querystring is invalid.`); 214 | throw createHttpError(Status.BadRequest, "Invalid querystring", { 215 | cause, 216 | expose: this.#expose, 217 | }); 218 | } 219 | } 220 | } 221 | 222 | /** 223 | * Given a {@linkcode RequestEvent}, this method will attempt to parse the 224 | * body of a request as JSON validate it against the schema provided. If no 225 | * schema was provided, the parsed search will be returned. If the schema is 226 | * provided and the parsed search is invalid, the invalid handler will be 227 | * called if provided, otherwise a `BadRequest` HTTP error will be thrown. 228 | * 229 | * If the request method is `GET` or `HEAD`, the body will always be 230 | * `undefined`. 231 | */ 232 | async validateBody(requestEvent: RequestEvent): Promise> { 233 | this.#logger.debug(`${requestEvent.id} schema.validateQueryString()`); 234 | if (BODYLESS_METHODS.includes(requestEvent.request.method)) { 235 | this.#logger.debug(`${requestEvent.id} method cannot have a body.`); 236 | return { output: undefined }; 237 | } 238 | const input = await requestEvent.request.json(); 239 | if (!this.#body) { 240 | this.#logger.debug(`${requestEvent.id} no body schema provided.`); 241 | return { output: input }; 242 | } 243 | if (this.#invalidHandler) { 244 | const result = await safeParseAsync( 245 | this.#body, 246 | input, 247 | this.#options, 248 | ); 249 | if (result.success) { 250 | this.#logger.debug(`${requestEvent.id} body is valid.`); 251 | return { output: result.output }; 252 | } else { 253 | try { 254 | this.#logger 255 | .debug( 256 | `${requestEvent.id} body is invalid, calling invalid handler.`, 257 | ); 258 | return { 259 | invalidResponse: await this.#invalidHandler( 260 | "body", 261 | result.issues, 262 | ), 263 | }; 264 | } catch (cause) { 265 | this.#logger.error(`${requestEvent.id} invalid handler failed.`); 266 | throw createHttpError( 267 | Status.InternalServerError, 268 | "Invalid handler failed", 269 | { cause }, 270 | ); 271 | } 272 | } 273 | } else { 274 | try { 275 | this.#logger.debug(`${requestEvent.id} validating body.`); 276 | return { 277 | output: await parseAsync(this.#body, input, this.#options), 278 | }; 279 | } catch (cause) { 280 | this.#logger.debug(`${requestEvent.id} body is invalid.`); 281 | throw createHttpError(Status.BadRequest, "Invalid body", { 282 | cause, 283 | expose: this.#expose, 284 | }); 285 | } 286 | } 287 | } 288 | 289 | /** 290 | * Given a response body, this method will attempt to validate it against the 291 | * schema provided. If no schema was provided, the response body will be 292 | * passed through. If the schema is provided and the response body is invalid, 293 | * the invalid handler will be called if provided, otherwise a `BadRequest` 294 | * HTTP error will be thrown. 295 | */ 296 | async validateResponse( 297 | input: unknown, 298 | ): Promise>> { 299 | if (!this.#response) { 300 | return { output: input }; 301 | } 302 | if (this.#invalidHandler) { 303 | const result = await safeParseAsync( 304 | this.#response, 305 | input, 306 | this.#options, 307 | ); 308 | if (result.success) { 309 | return { output: result.output }; 310 | } else { 311 | try { 312 | return { 313 | invalidResponse: await this.#invalidHandler( 314 | "response", 315 | result.issues, 316 | ), 317 | }; 318 | } catch (cause) { 319 | throw createHttpError( 320 | Status.InternalServerError, 321 | "Invalid handler failed", 322 | { cause }, 323 | ); 324 | } 325 | } 326 | } else { 327 | try { 328 | return { 329 | output: await parseAsync(this.#response, input, this.#options), 330 | }; 331 | } catch (cause) { 332 | throw createHttpError( 333 | Status.InternalServerError, 334 | "Response body was invalid.", 335 | { cause }, 336 | ); 337 | } 338 | } 339 | } 340 | 341 | [Symbol.for("Deno.customInspect")]( 342 | inspect: (value: unknown) => string, 343 | ): string { 344 | return `${this.constructor.name} ${inspect({})}`; 345 | } 346 | 347 | [Symbol.for("nodejs.util.inspect.custom")]( 348 | depth: number, 349 | // deno-lint-ignore no-explicit-any 350 | options: any, 351 | inspect: (value: unknown, options?: unknown) => string, 352 | // deno-lint-ignore no-explicit-any 353 | ): any { 354 | if (depth < 0) { 355 | return options.stylize(`[${this.constructor.name}]`, "special"); 356 | } 357 | 358 | const newOptions = Object.assign({}, options, { 359 | depth: options.depth === null ? null : options.depth - 1, 360 | }); 361 | return `${options.stylize(this.constructor.name, "special")} ${ 362 | inspect({}, newOptions) 363 | }`; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /status_route.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import type { KeyRing } from "@oak/commons/cookie_map"; 4 | import { 5 | isClientErrorStatus, 6 | isErrorStatus, 7 | isInformationalStatus, 8 | isRedirectStatus, 9 | isServerErrorStatus, 10 | isSuccessfulStatus, 11 | type Status, 12 | } from "@oak/commons/status"; 13 | import type { InferOutput } from "@valibot/valibot"; 14 | 15 | import { Context } from "./context.ts"; 16 | import { 17 | type BodySchema, 18 | type QueryStringSchema, 19 | Schema, 20 | type SchemaDescriptor, 21 | } from "./schema.ts"; 22 | import type { RequestEvent } from "./types.ts"; 23 | import { appendHeaders } from "./utils.ts"; 24 | 25 | /** 26 | * A string that represents a range of HTTP response {@linkcode Status} codes: 27 | * 28 | * - `"*"` - matches any status code 29 | * - `"info"` - matches information status codes (`100`-`199`) 30 | * - `"success"` - matches successful status codes (`200`-`299`) 31 | * - `"redirect"` - matches redirection status codes (`300`-`399`) 32 | * - `"client-error"` - matches client error status codes (`400`-`499`) 33 | * - `"server-error"` - matches server error status codes (`500`-`599`) 34 | * - `"error"` - matches any error status code (`400`-`599`) 35 | */ 36 | export type StatusRange = 37 | | "*" 38 | | "info" 39 | | "success" 40 | | "redirect" 41 | | "client-error" 42 | | "server-error" 43 | | "error"; 44 | 45 | /** 46 | * A function that handles a status route. The handler is provided a 47 | * {@linkcode Context} object which provides information about the request 48 | * being handled as well as other methods for interacting with the request. 49 | * The status is also provided to the handler, along with the current response. 50 | * 51 | * The status handler can return a {@linkcode Response} object or `undefined`. 52 | * If a value is returned, it will be used as the response instead of the 53 | * provided on. If `undefined` is returned, the original response will be sent 54 | * to the client. The handler can also return a promise that resolves to any of 55 | * the above values. 56 | */ 57 | export interface StatusHandler< 58 | S extends Status, 59 | Env extends Record = Record, 60 | QSSchema extends QueryStringSchema = QueryStringSchema, 61 | QueryParams extends InferOutput = InferOutput, 62 | > { 63 | ( 64 | context: Context< 65 | Env, 66 | undefined, 67 | QSSchema, 68 | QueryParams, 69 | BodySchema, 70 | undefined, 71 | BodySchema 72 | >, 73 | status: S, 74 | response: Response, 75 | ): 76 | | Promise 77 | | Response 78 | | undefined; 79 | } 80 | 81 | /** The descriptor for defining a status route. */ 82 | export interface StatusRouteDescriptor< 83 | S extends Status = Status, 84 | Env extends Record = Record, 85 | QSSchema extends QueryStringSchema = QueryStringSchema, 86 | QueryParams extends InferOutput = InferOutput, 87 | > { 88 | /** The statuses or status ranges that the handler should apply to. */ 89 | status: S | StatusRange | S[] | StatusRange[]; 90 | /** 91 | * The handler to be called when there is a match in the response to one of 92 | * the specified status or status ranges. 93 | */ 94 | handler: StatusHandler; 95 | schema?: SchemaDescriptor; 96 | } 97 | 98 | /** Initialization options when setting a status route. */ 99 | export interface StatusRouteInit< 100 | QSSchema extends QueryStringSchema = QueryStringSchema, 101 | > { 102 | /** 103 | * The schema to be used for validating the query string, on the request 104 | * when provided in the context to the handler. 105 | */ 106 | schema?: SchemaDescriptor; 107 | } 108 | 109 | /** 110 | * Encapsulation of logic for a registered status handler. 111 | */ 112 | export class StatusRoute< 113 | Env extends Record, 114 | QSSchema extends QueryStringSchema = QueryStringSchema, 115 | QueryParams extends InferOutput = InferOutput, 116 | > { 117 | #expose: boolean; 118 | #handler: StatusHandler; 119 | #keys?: KeyRing; 120 | #schema: Schema; 121 | #status: (Status | StatusRange)[]; 122 | 123 | /** 124 | * The statuses or status ranges that are handled by this status route. 125 | */ 126 | get status(): (Status | StatusRange)[] { 127 | return this.#status; 128 | } 129 | 130 | constructor( 131 | status: (Status | StatusRange)[], 132 | handler: StatusHandler, 133 | schemaDescriptor: SchemaDescriptor, 134 | keys: KeyRing | undefined, 135 | expose: boolean, 136 | ) { 137 | this.#status = status; 138 | this.#handler = handler; 139 | this.#keys = keys; 140 | this.#expose = expose; 141 | this.#schema = new Schema(schemaDescriptor, expose); 142 | } 143 | 144 | /** 145 | * Invokes the associated handler with the route and returns any response 146 | * from the handler. 147 | */ 148 | async handle( 149 | requestEvent: RequestEvent, 150 | responseHeaders: Headers, 151 | secure: boolean, 152 | response: Response, 153 | ): Promise { 154 | const context = new Context< 155 | Env, 156 | undefined, 157 | QSSchema, 158 | QueryParams, 159 | BodySchema, 160 | undefined, 161 | BodySchema 162 | >( 163 | requestEvent, 164 | responseHeaders, 165 | secure, 166 | undefined, 167 | this.#schema, 168 | this.#keys, 169 | this.#expose, 170 | ); 171 | const result = await this.#handler(context, response.status, response); 172 | if (result instanceof Response) { 173 | return appendHeaders(result, responseHeaders); 174 | } 175 | return response; 176 | } 177 | 178 | /** 179 | * Determines if the handler should be applied to the request and pending 180 | * response. 181 | */ 182 | matches(response: Response): boolean { 183 | const { status } = response; 184 | for (const item of this.#status) { 185 | if (typeof item === "number") { 186 | if (status === item) { 187 | return true; 188 | } else { 189 | continue; 190 | } 191 | } 192 | switch (item) { 193 | case "*": 194 | return true; 195 | case "info": 196 | if (isInformationalStatus(status)) { 197 | return true; 198 | } 199 | break; 200 | case "success": 201 | if (isSuccessfulStatus(status)) { 202 | return true; 203 | } 204 | break; 205 | case "redirect": 206 | if (isRedirectStatus(status)) { 207 | return true; 208 | } 209 | break; 210 | case "client-error": 211 | if (isClientErrorStatus(status)) { 212 | return true; 213 | } 214 | break; 215 | case "server-error": 216 | if (isServerErrorStatus(status)) { 217 | return true; 218 | } 219 | break; 220 | case "error": 221 | if (isErrorStatus(status)) { 222 | return true; 223 | } 224 | } 225 | } 226 | return false; 227 | } 228 | 229 | [Symbol.for("Deno.customInspect")]( 230 | inspect: (value: unknown) => string, 231 | ): string { 232 | return `${this.constructor.name} ${inspect({ status: this.#status })}`; 233 | } 234 | 235 | [Symbol.for("nodejs.util.inspect.custom")]( 236 | depth: number, 237 | // deno-lint-ignore no-explicit-any 238 | options: any, 239 | inspect: (value: unknown, options?: unknown) => string, 240 | // deno-lint-ignore no-explicit-any 241 | ): any { 242 | if (depth < 0) { 243 | return options.stylize(`[${this.constructor.name}]`, "special"); 244 | } 245 | 246 | const newOptions = Object.assign({}, options, { 247 | depth: options.depth === null ? null : options.depth - 1, 248 | }); 249 | return `${options.stylize(this.constructor.name, "special")} ${ 250 | inspect({ status: this.#status }, newOptions) 251 | }`; 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /testing_utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import { createHttpError } from "@oak/commons/http_errors"; 4 | import { Status } from "@oak/commons/status"; 5 | import hyperid from "hyperid"; 6 | 7 | import type { Addr, RequestEvent } from "./types.ts"; 8 | import { createPromiseWithResolvers } from "./utils.ts"; 9 | 10 | const instance = hyperid({ urlSafe: true }); 11 | 12 | export class MockRequestEvent implements RequestEvent { 13 | #addr: Addr; 14 | #id = instance(); 15 | //deno-lint-ignore no-explicit-any 16 | #reject: (reason?: any) => void; 17 | #request: Request; 18 | #resolve: (value: Response | PromiseLike) => void; 19 | #responded = false; 20 | #response: Promise; 21 | #url: URL; 22 | 23 | get addr(): Addr { 24 | return this.#addr; 25 | } 26 | 27 | get env(): Record { 28 | return {}; 29 | } 30 | 31 | get id(): string { 32 | return this.#id; 33 | } 34 | 35 | get request(): Request { 36 | return this.#request; 37 | } 38 | 39 | get response(): Promise { 40 | return this.#response; 41 | } 42 | 43 | get responded(): boolean { 44 | return this.#responded; 45 | } 46 | 47 | get url(): URL { 48 | return this.#url; 49 | } 50 | 51 | constructor( 52 | input: URL | string, 53 | init?: RequestInit, 54 | addr: Addr = { hostname: "localhost", port: 80, transport: "tcp" }, 55 | ) { 56 | this.#addr = addr; 57 | this.#request = new Request(input, init); 58 | const { promise, reject, resolve } = createPromiseWithResolvers(); 59 | this.#response = promise; 60 | this.#reject = reject; 61 | this.#resolve = resolve; 62 | this.#url = URL.parse(this.#request.url) ?? new URL("http://localhost/"); 63 | } 64 | 65 | // deno-lint-ignore no-explicit-any 66 | error(reason?: any): void { 67 | if (this.#responded) { 68 | throw createHttpError( 69 | Status.InternalServerError, 70 | "Request already responded to.", 71 | ); 72 | } 73 | this.#responded = true; 74 | this.#reject(reason); 75 | } 76 | 77 | respond(response: Response): void { 78 | if (this.#responded) { 79 | throw createHttpError( 80 | Status.InternalServerError, 81 | "Request already responded to.", 82 | ); 83 | } 84 | this.#responded = true; 85 | this.#resolve(response); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | import type { HttpMethod } from "@oak/commons/method"; 4 | import type { NOT_ALLOWED } from "./constants.ts"; 5 | 6 | /** 7 | * The interface that defines the Cloudflare Worker fetch handler. 8 | */ 9 | export interface CloudflareFetchHandler< 10 | Env extends Record = Record, 11 | > { 12 | /** A method that is compatible with the Cloudflare Worker 13 | * [Fetch Handler](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/) 14 | * and can be exported to handle Cloudflare Worker fetch requests. 15 | * 16 | * # Example 17 | * 18 | * ```ts 19 | * import { Application } from "@oak/oak"; 20 | * 21 | * const app = new Application(); 22 | * app.use((ctx) => { 23 | * ctx.response.body = "hello world!"; 24 | * }); 25 | * 26 | * export default { fetch: app.fetch }; 27 | * ``` 28 | */ 29 | ( 30 | request: Request, 31 | env: Env, 32 | ctx: CloudflareExecutionContext, 33 | ): Promise; 34 | } 35 | 36 | /** 37 | * A handle to something which can be removed from the router. 38 | */ 39 | export interface Removeable { 40 | /** 41 | * Removes the item from the router. 42 | */ 43 | remove(): void; 44 | } 45 | 46 | /** 47 | * The base type for parameters that are parsed from the path of a request. 48 | */ 49 | export interface ParamsDictionary { 50 | [key: string]: string; 51 | } 52 | 53 | /** 54 | * The base type of query parameters that are parsed from the query string of a 55 | * request. 56 | */ 57 | export interface QueryParamsDictionary { 58 | [key: string]: 59 | | undefined 60 | | string 61 | | string[] 62 | | QueryParamsDictionary 63 | | QueryParamsDictionary[]; 64 | } 65 | 66 | /** 67 | * The network address representation. 68 | */ 69 | export interface Addr { 70 | /** 71 | * The transport protocol used for the address. 72 | */ 73 | transport: "tcp" | "udp"; 74 | /** 75 | * The hostname or IP address. 76 | */ 77 | hostname: string; 78 | /** 79 | * The port number. 80 | */ 81 | port: number; 82 | } 83 | 84 | /** 85 | * Options that can be passed when upgrading a connection to a web socket. 86 | */ 87 | export interface UpgradeWebSocketOptions { 88 | /** Sets the `.protocol` property on the client side web socket to the 89 | * value provided here, which should be one of the strings specified in the 90 | * `protocols` parameter when requesting the web socket. This is intended 91 | * for clients and servers to specify sub-protocols to use to communicate to 92 | * each other. */ 93 | protocol?: string; 94 | /** If the client does not respond to this frame with a 95 | * `pong` within the timeout specified, the connection is deemed 96 | * unhealthy and is closed. The `close` and `error` event will be emitted. 97 | * 98 | * The default is 120 seconds. Set to `0` to disable timeouts. */ 99 | idleTimeout?: number; 100 | } 101 | 102 | /** 103 | * The abstract interface the defines the server abstraction that acorn relies 104 | * upon. Any sever implementation needs to adhere to this interface. 105 | */ 106 | export interface RequestServer< 107 | Env extends Record = Record, 108 | > { 109 | /** 110 | * Determines if the server is currently listening for requests. 111 | */ 112 | readonly closed: boolean; 113 | /** 114 | * Start listening for requests. 115 | */ 116 | listen(): Promise | Addr; 117 | /** 118 | * Yields up {@linkcode RequestEvent}s as they are received by the server. 119 | */ 120 | [Symbol.asyncIterator](): AsyncIterableIterator>; 121 | } 122 | 123 | /** 124 | * Options that can be passed to the server to configure the TLS settings 125 | * (HTTPS). 126 | */ 127 | export interface TlsOptions { 128 | key: string; 129 | cert: string; 130 | ca?: string | TypedArray | File | Array; 131 | passphrase?: string; 132 | dhParamsFile?: string; 133 | alpnProtocols?: string[]; 134 | } 135 | 136 | /** 137 | * Options that will be passed to a {@linkcode RequestServer} from acorn on 138 | * construction of the server. 139 | */ 140 | export interface RequestServerOptions { 141 | /** 142 | * The hostname and port that the server should listen on. 143 | */ 144 | hostname?: string; 145 | /** 146 | * The port that the server should listen on. 147 | */ 148 | port?: number; 149 | /** 150 | * The abort signal that should be used to abort the server. 151 | */ 152 | signal: AbortSignal; 153 | /** 154 | * The TLS options that should be used to configure the server for HTTPS. 155 | */ 156 | tls?: TlsOptions; 157 | } 158 | 159 | /** 160 | * The abstract interface that defines what a {@linkcode RequestServer} 161 | * constructor needs to adhere to. 162 | */ 163 | export interface RequestServerConstructor { 164 | new = Record>( 165 | options: RequestServerOptions, 166 | ): RequestServer; 167 | prototype: RequestServer; 168 | } 169 | 170 | /** 171 | * The abstract interface that defines what needs to be implemented for a 172 | * request event. 173 | */ 174 | export interface RequestEvent< 175 | Env extends Record = Record, 176 | > { 177 | /** 178 | * The address representation of the originator of the request. 179 | */ 180 | readonly addr: Addr; 181 | /** 182 | * A unique identifier for the request event. 183 | */ 184 | readonly id: string; 185 | /** 186 | * Provides access to environment variable keys and values. 187 | */ 188 | readonly env: Env | undefined; 189 | /** 190 | * The Fetch API standard {@linkcode Request} which should be processed. 191 | */ 192 | readonly request: Request; 193 | /** 194 | * A promise which should resolve with the supplied {@linkcode Response}. 195 | */ 196 | readonly response: Promise; 197 | /** 198 | * An indicator of if the response method has been invoked yet. 199 | */ 200 | readonly responded: boolean; 201 | /** 202 | * The parsed URL of the request. 203 | */ 204 | readonly url: URL; 205 | /** 206 | * Called to indicate an error occurred while processing the request. 207 | */ 208 | // deno-lint-ignore no-explicit-any 209 | error(reason?: any): void; 210 | /** 211 | * Called to indicate that the request has been processed and the response 212 | * is ready to be sent. 213 | */ 214 | respond(response: Response): void | Promise; 215 | /** 216 | * Upgrades the request to a web socket connection. 217 | */ 218 | upgrade?(options?: UpgradeWebSocketOptions): WebSocket; 219 | } 220 | 221 | /** 222 | * The execution context that is passed to the Cloudflare Worker fetch handler. 223 | */ 224 | export interface CloudflareExecutionContext { 225 | waitUntil(promise: Promise): void; 226 | passThroughOnException(): void; 227 | } 228 | 229 | type TypedArray = 230 | | Uint8Array 231 | | Uint16Array 232 | | Uint32Array 233 | | Int8Array 234 | | Int16Array 235 | | Int32Array 236 | | Float32Array 237 | | Float64Array 238 | | BigInt64Array 239 | | BigUint64Array 240 | | Uint8ClampedArray; 241 | 242 | type RemoveTail = S extends 243 | `${infer P}${Tail}` ? P : S; 244 | 245 | type GetRouteParameter = RemoveTail< 246 | RemoveTail, `-${string}`>, 247 | `.${string}` 248 | >; 249 | 250 | /** 251 | * A type which supports inferring parameters that will be parsed from the 252 | * route. 253 | * 254 | * @template Route the string literal used to infer the route parameters 255 | */ 256 | export type RouteParameters = string extends Route 257 | ? ParamsDictionary 258 | : Route extends `${string}(${string}` ? ParamsDictionary 259 | : Route extends `${string}:${infer Rest}` ? 260 | & ( 261 | GetRouteParameter extends never ? ParamsDictionary 262 | : GetRouteParameter extends `${infer ParamName}?` 263 | ? { [P in ParamName]?: string } 264 | : { [P in GetRouteParameter]: string } 265 | ) 266 | & (Rest extends `${GetRouteParameter}${infer Next}` 267 | ? RouteParameters 268 | : unknown) 269 | // deno-lint-ignore ban-types 270 | : {}; 271 | 272 | export type NotAllowed = typeof NOT_ALLOWED; 273 | 274 | /** The abstract interface that needs to be implemented for a route. */ 275 | export interface Route< 276 | Env extends Record = Record, 277 | > { 278 | /** The methods that the route should match on. */ 279 | readonly methods: HttpMethod[]; 280 | /** The path that the route should match on. */ 281 | readonly path: string; 282 | 283 | /** Handle the request event. */ 284 | handle( 285 | requestEvent: RequestEvent, 286 | responseHeaders: Headers, 287 | secure: boolean, 288 | ): Promise; 289 | /** Determines if the pathname and method are a match. */ 290 | matches(method: HttpMethod, pathname: string): boolean | NotAllowed; 291 | } 292 | -------------------------------------------------------------------------------- /utils.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2024 the oak authors. All rights reserved. 2 | 3 | const hasPromiseWithResolvers = "withResolvers" in Promise; 4 | 5 | /** Append a set of headers onto a response. */ 6 | export function appendHeaders(response: Response, headers: Headers): Response { 7 | for (const [key, value] of headers) { 8 | response.headers.append(key, value); 9 | } 10 | return response; 11 | } 12 | 13 | /** 14 | * Creates a promise with resolve and reject functions that can be called. 15 | * 16 | * Offloads to the native `Promise.withResolvers` when available. 17 | */ 18 | export function createPromiseWithResolvers(): { 19 | promise: Promise; 20 | resolve: (value: T | PromiseLike) => void; 21 | // deno-lint-ignore no-explicit-any 22 | reject: (reason?: any) => void; 23 | } { 24 | if (hasPromiseWithResolvers) { 25 | return Promise.withResolvers(); 26 | } 27 | let resolve; 28 | let reject; 29 | const promise = new Promise((res, rej) => { 30 | resolve = res; 31 | reject = rej; 32 | }); 33 | return { promise, resolve: resolve!, reject: reject! }; 34 | } 35 | 36 | /** 37 | * Safely decode a URI component, where if it fails, instead of throwing, 38 | * just returns the original string. 39 | */ 40 | export function decodeComponent(text: string) { 41 | try { 42 | return decodeURIComponent(text); 43 | } catch { 44 | return text; 45 | } 46 | } 47 | 48 | /** Determines if the runtime is Bun or not. */ 49 | export function isBun(): boolean { 50 | return "Bun" in globalThis; 51 | } 52 | 53 | /** Determines if the runtime is Node.js or not. */ 54 | export function isNode(): boolean { 55 | return "process" in globalThis && "global" in globalThis && 56 | !("Bun" in globalThis) && !("WebSocketPair" in globalThis) && 57 | !("Deno" in globalThis); 58 | } 59 | -------------------------------------------------------------------------------- /uuid.bench.ts: -------------------------------------------------------------------------------- 1 | import hyperid from "hyperid"; 2 | 3 | const instance = hyperid({ urlSafe: true }); 4 | 5 | Deno.bench({ 6 | name: "hyperid", 7 | fn() { 8 | instance(); 9 | }, 10 | }); 11 | 12 | Deno.bench({ 13 | name: "crypto.randomUUID", 14 | fn() { 15 | crypto.randomUUID(); 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------