├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .github ├── codecov.yml └── workflows │ ├── ci.yml │ ├── jekyll-gh-pages.yml │ └── publish.yml ├── .gitignore ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── application.test.ts ├── application.ts ├── body.test.ts ├── body.ts ├── context.test.ts ├── context.ts ├── deno.json ├── deps.ts ├── deps_test.ts ├── docs ├── CODE_OF_CONDUCT.md ├── FAQ.md ├── _config.yml ├── deploy.md ├── index.html ├── main.md ├── node.md ├── sse.md └── testing.md ├── examples ├── closeServer.ts ├── cookieServer.ts ├── countingServer.ts ├── echoServer.ts ├── httpsServer.ts ├── nestedRoutingServer.ts ├── proxyServer.ts ├── resources │ └── sseServer_index.html ├── routingServer.ts ├── server.ts ├── sseServer.ts ├── static │ ├── deno_logo.png.gz │ └── index.html ├── staticServer.ts └── tls │ ├── README.md │ ├── RootCA.crt │ ├── RootCA.key │ ├── RootCA.pem │ ├── domains.txt │ ├── localhost.crt │ └── localhost.key ├── fixtures ├── .test.json ├── .test │ └── test.json ├── test file.json ├── test.html ├── test.importmap ├── test.jpg ├── test.json ├── test.json.br └── test.json.gz ├── http_server_bun.test.ts ├── http_server_bun.ts ├── http_server_native.test.ts ├── http_server_native.ts ├── http_server_native_request.ts ├── http_server_node.test.ts ├── http_server_node.ts ├── middleware.test.ts ├── middleware.ts ├── middleware ├── etag.test.ts ├── etag.ts ├── proxy.test.ts ├── proxy.ts ├── serve.test.ts └── serve.ts ├── mod.test.ts ├── mod.ts ├── node_shims.ts ├── request.test.ts ├── request.ts ├── response.test.ts ├── response.ts ├── router.test.ts ├── router.ts ├── send.test.ts ├── send.ts ├── testing.test.ts ├── testing.ts ├── types.ts └── utils ├── clone_state.test.ts ├── clone_state.ts ├── consts.ts ├── create_promise_with_resolvers.ts ├── decode.test.ts ├── decode.ts ├── decode_component.test.ts ├── decode_component.ts ├── encode_url.ts ├── resolve_path.test.ts ├── resolve_path.ts ├── streams.ts └── type_guards.ts /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/vscode/devcontainers/base:debian-10 2 | 3 | ENV DENO_INSTALL=/deno 4 | RUN mkdir -p /deno \ 5 | && curl -fsSL https://deno.land/x/install/install.sh | sh \ 6 | && chown -R vscode /deno 7 | 8 | ENV PATH=${DENO_INSTALL}/bin:${PATH} \ 9 | DENO_DIR=${DENO_INSTALL}/.cache/deno 10 | 11 | # [Optional] Uncomment this section to install additional OS packages. 12 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 13 | # && apt-get -y install --no-install-recommends 14 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Deno", 3 | "dockerFile": "Dockerfile", 4 | "settings": { 5 | // Sets Deno as the default formatter for the project 6 | "editor.defaultFormatter": "denoland.vscode-deno" 7 | }, 8 | // This will install the vscode-deno extension 9 | "extensions": [ 10 | "denoland.vscode-deno" 11 | ], 12 | "remoteUser": "vscode" 13 | } 14 | -------------------------------------------------------------------------------- /.github/codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | codecov: 3 | require_ci_to_pass: true 4 | coverage: 5 | status: 6 | project: 7 | default: 8 | informational: true 9 | ignore: 10 | - ^examples.* 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | oak: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | version: ["v1.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 generating coverage 27 | run: deno task test:coverage 28 | 29 | - name: upload test results 30 | uses: codecov/test-results-action@v1 31 | with: 32 | token: ${{ secrets.CODECOV_TOKEN }} 33 | 34 | - name: generate lcov 35 | run: deno task coverage > cov.lcov 36 | 37 | - name: upload coverage 38 | uses: codecov/codecov-action@v4 39 | with: 40 | files: ./cov.lcov 41 | token: ${{ secrets.CODECOV_TOKEN }} 42 | -------------------------------------------------------------------------------- /.github/workflows/jekyll-gh-pages.yml: -------------------------------------------------------------------------------- 1 | # Sample workflow for building and deploying a Jekyll site to GitHub Pages 2 | name: Deploy Jekyll with GitHub Pages dependencies preinstalled 3 | 4 | on: 5 | # Runs on pushes targeting the default branch 6 | push: 7 | branches: ["main"] 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 13 | permissions: 14 | contents: read 15 | pages: write 16 | id-token: write 17 | 18 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 19 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 20 | concurrency: 21 | group: "pages" 22 | cancel-in-progress: false 23 | 24 | jobs: 25 | # Build job 26 | build: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - name: Checkout 30 | uses: actions/checkout@v4 31 | - name: Setup Pages 32 | uses: actions/configure-pages@v5 33 | - name: Build with Jekyll 34 | uses: actions/jekyll-build-pages@v1 35 | with: 36 | source: ./docs 37 | destination: ./_site 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | 41 | # Deployment job 42 | deploy: 43 | environment: 44 | name: github-pages 45 | url: ${{ steps.deployment.outputs.page_url }} 46 | runs-on: ubuntu-latest 47 | needs: build 48 | steps: 49 | - name: Deploy to GitHub Pages 50 | id: deployment 51 | uses: actions/deploy-pages@v4 52 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | !.vscode 2 | .tool-versions 3 | oak.bundle.js 4 | cov.lcov 5 | junit.xml 6 | cov/ 7 | npm/ -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Deno: Run", 9 | "request": "launch", 10 | "type": "pwa-node", 11 | "program": "examples/server.ts", 12 | "cwd": "${workspaceFolder}", 13 | "runtimeExecutable": "deno", 14 | "runtimeArgs": [ 15 | "run", 16 | "--unstable", 17 | "--inspect", 18 | "--allow-net" 19 | ], 20 | "attachSimplePort": 9229 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "deno.lint": true 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "deno", 6 | "command": "bundle", 7 | "args": [ 8 | "mod.ts", 9 | "oak.bundle.js" 10 | ], 11 | "problemMatcher": [ 12 | "$deno" 13 | ], 14 | "group": "build", 15 | "label": "Bundle oak" 16 | }, 17 | { 18 | "type": "deno", 19 | "command": "run", 20 | "args": [ 21 | "--allow-read", 22 | "--allow-write", 23 | "--allow-net", 24 | "--unstable", 25 | "examples/echoServer.ts" 26 | ], 27 | "problemMatcher": [ 28 | "$deno" 29 | ], 30 | "label": "Run: examples/echoServer.ts" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2025 the oak authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /body.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * Contains the oak abstraction to represent a request {@linkcode Body}. 5 | * 6 | * This is not normally used directly by end users. 7 | * 8 | * @module 9 | */ 10 | 11 | import { createHttpError, matches, parseFormData, Status } from "./deps.ts"; 12 | import type { ServerRequest } from "./types.ts"; 13 | 14 | type JsonReviver = (key: string, value: unknown) => unknown; 15 | 16 | export type BodyType = 17 | | "binary" 18 | | "form" 19 | | "form-data" 20 | | "json" 21 | | "text" 22 | | "unknown"; 23 | 24 | const KNOWN_BODY_TYPES: [bodyType: BodyType, knownMediaTypes: string[]][] = [ 25 | ["binary", ["image", "audio", "application/octet-stream"]], 26 | ["form", ["urlencoded"]], 27 | ["form-data", ["multipart"]], 28 | ["json", ["json", "application/*+json", "application/csp-report"]], 29 | ["text", ["text"]], 30 | ]; 31 | 32 | async function readBlob( 33 | body?: ReadableStream | null, 34 | type?: string | null, 35 | ): Promise { 36 | if (!body) { 37 | return new Blob(undefined, type ? { type } : undefined); 38 | } 39 | const chunks: Uint8Array[] = []; 40 | for await (const chunk of body) { 41 | chunks.push(chunk); 42 | } 43 | return new Blob(chunks, type ? { type } : undefined); 44 | } 45 | 46 | /** An object which encapsulates information around a request body. */ 47 | export class Body { 48 | #body?: ReadableStream | null; 49 | #memo: Promise | null = null; 50 | #memoType: "arrayBuffer" | "blob" | "formData" | "text" | null = null; 51 | #headers?: Headers; 52 | #request?: Request; 53 | #reviver?: JsonReviver; 54 | #type?: BodyType; 55 | #used = false; 56 | 57 | constructor( 58 | serverRequest: Pick, 59 | reviver?: JsonReviver, 60 | ) { 61 | if (serverRequest.request) { 62 | this.#request = serverRequest.request; 63 | } else { 64 | this.#headers = serverRequest.headers; 65 | this.#body = serverRequest.getBody(); 66 | } 67 | this.#reviver = reviver; 68 | } 69 | 70 | /** Is `true` if the request might have a body, otherwise `false`. 71 | * 72 | * **WARNING** this is an unreliable API. In HTTP/2 in many situations you 73 | * cannot determine if a request has a body or not unless you attempt to read 74 | * the body, due to the streaming nature of HTTP/2. As of Deno 1.16.1, for 75 | * HTTP/1.1, Deno also reflects that behavior. The only reliable way to 76 | * determine if a request has a body or not is to attempt to read the body. 77 | */ 78 | get has(): boolean { 79 | return !!(this.#request ? this.#request.body : this.#body); 80 | } 81 | 82 | /** Exposes the "raw" `ReadableStream` of the body. */ 83 | get stream(): ReadableStream | null { 84 | return this.#request ? this.#request.body : this.#body!; 85 | } 86 | 87 | /** Returns `true` if the body has been consumed yet, otherwise `false`. */ 88 | get used(): boolean { 89 | return this.#request?.bodyUsed ?? !!this.#used; 90 | } 91 | 92 | /** Return the body to be reused as BodyInit. */ 93 | async init(): Promise { 94 | if (!this.has) { 95 | return null; 96 | } 97 | return await this.#memo ?? this.stream; 98 | } 99 | 100 | /** Reads a body to the end and resolves with the value as an 101 | * {@linkcode ArrayBuffer} */ 102 | async arrayBuffer(): Promise { 103 | if (this.#memoType === "arrayBuffer") { 104 | return this.#memo as Promise; 105 | } else if (this.#memoType) { 106 | throw new TypeError("Body already used as a different type."); 107 | } 108 | this.#memoType = "arrayBuffer"; 109 | if (this.#request) { 110 | return this.#memo = this.#request.arrayBuffer(); 111 | } 112 | this.#used = true; 113 | return this.#memo = (await readBlob(this.#body)).arrayBuffer(); 114 | } 115 | 116 | /** Reads a body to the end and resolves with the value as a 117 | * {@linkcode Blob}. */ 118 | blob(): Promise { 119 | if (this.#memoType === "blob") { 120 | return this.#memo as Promise; 121 | } else if (this.#memoType) { 122 | throw new TypeError("Body already used as a different type."); 123 | } 124 | this.#memoType = "blob"; 125 | if (this.#request) { 126 | return this.#memo = this.#request.blob(); 127 | } 128 | this.#used = true; 129 | return this.#memo = readBlob( 130 | this.#body, 131 | this.#headers?.get("content-type"), 132 | ); 133 | } 134 | 135 | /** Reads a body as a URL encoded form, resolving the value as 136 | * {@linkcode URLSearchParams}. */ 137 | async form(): Promise { 138 | const text = await this.text(); 139 | return new URLSearchParams(text); 140 | } 141 | 142 | /** Reads a body to the end attempting to parse the body as a set of 143 | * {@linkcode FormData}. */ 144 | formData(): Promise { 145 | if (this.#memoType === "formData") { 146 | return this.#memo as Promise; 147 | } else if (this.#memoType) { 148 | throw new TypeError("Body already used as a different type."); 149 | } 150 | this.#memoType = "formData"; 151 | if (this.#request) { 152 | return this.#memo = this.#request.formData(); 153 | } 154 | this.#used = true; 155 | if (this.#body && this.#headers) { 156 | const contentType = this.#headers.get("content-type"); 157 | if (contentType) { 158 | return this.#memo = parseFormData(contentType, this.#body); 159 | } 160 | } 161 | throw createHttpError(Status.BadRequest, "Missing content type."); 162 | } 163 | 164 | /** Reads a body to the end attempting to parse the body as a JSON value. 165 | * 166 | * If a JSON reviver has been assigned, it will be used to parse the body. 167 | */ 168 | // deno-lint-ignore no-explicit-any 169 | async json(): Promise { 170 | try { 171 | return JSON.parse(await this.text(), this.#reviver); 172 | } catch (err) { 173 | if (err instanceof Error) { 174 | throw createHttpError(Status.BadRequest, err.message); 175 | } 176 | throw createHttpError(Status.BadRequest, JSON.stringify(err)); 177 | } 178 | } 179 | 180 | /** Reads the body to the end resolving with a string. */ 181 | async text(): Promise { 182 | if (this.#memoType === "text") { 183 | return this.#memo as Promise; 184 | } else if (this.#memoType) { 185 | throw new TypeError("Body already used as a different type."); 186 | } 187 | this.#memoType = "text"; 188 | if (this.#request) { 189 | return this.#memo = this.#request.text(); 190 | } 191 | this.#used = true; 192 | return this.#memo = (await readBlob(this.#body)).text(); 193 | } 194 | 195 | /** Attempts to determine what type of the body is to help determine how best 196 | * to attempt to decode the body. This performs analysis on the supplied 197 | * `Content-Type` header of the request. 198 | * 199 | * **Note** these are not authoritative and should only be used as guidance. 200 | * 201 | * There is the ability to provide custom types when attempting to discern 202 | * the type. Custom types are provided in the format of an object where the 203 | * key is on of {@linkcode BodyType} and the value is an array of media types 204 | * to attempt to match. Values supplied will be additive to known media types. 205 | * 206 | * The returned value is one of the following: 207 | * 208 | * - `"binary"` - The body appears to be binary data and should be consumed as 209 | * an array buffer, readable stream or blob. 210 | * - `"form"` - The value appears to be an URL encoded form and should be 211 | * consumed as a form (`URLSearchParams`). 212 | * - `"form-data"` - The value appears to be multipart form data and should be 213 | * consumed as form data. 214 | * - `"json"` - The value appears to be JSON data and should be consumed as 215 | * decoded JSON. 216 | * - `"text"` - The value appears to be text data and should be consumed as 217 | * text. 218 | * - `"unknown"` - Either there is no body or the body type could not be 219 | * determined. 220 | */ 221 | type(customMediaTypes?: Partial>): BodyType { 222 | if (this.#type && !customMediaTypes) { 223 | return this.#type; 224 | } 225 | customMediaTypes = customMediaTypes ?? {}; 226 | const headers = this.#request?.headers ?? this.#headers; 227 | const contentType = headers?.get("content-type"); 228 | if (contentType) { 229 | for (const [bodyType, knownMediaTypes] of KNOWN_BODY_TYPES) { 230 | const customTypes = customMediaTypes[bodyType] ?? []; 231 | if (matches(contentType, [...knownMediaTypes, ...customTypes])) { 232 | this.#type = bodyType; 233 | return this.#type; 234 | } 235 | } 236 | } 237 | return this.#type = "unknown"; 238 | } 239 | 240 | [Symbol.for("Deno.customInspect")]( 241 | inspect: (value: unknown) => string, 242 | ): string { 243 | const { has, used } = this; 244 | return `${this.constructor.name} ${inspect({ has, used })}`; 245 | } 246 | 247 | [Symbol.for("nodejs.util.inspect.custom")]( 248 | depth: number, 249 | // deno-lint-ignore no-explicit-any 250 | options: any, 251 | inspect: (value: unknown, options?: unknown) => string, 252 | // deno-lint-ignore no-explicit-any 253 | ): any { 254 | if (depth < 0) { 255 | return options.stylize(`[${this.constructor.name}]`, "special"); 256 | } 257 | 258 | const newOptions = Object.assign({}, options, { 259 | depth: options.depth === null ? null : options.depth - 1, 260 | }); 261 | const { has, used } = this; 262 | return `${options.stylize(this.constructor.name, "special")} ${ 263 | inspect( 264 | { has, used }, 265 | newOptions, 266 | ) 267 | }`; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@oak/oak", 3 | "version": "17.1.4", 4 | "exports": { 5 | ".": "./mod.ts", 6 | "./application": "./application.ts", 7 | "./body": "./body.ts", 8 | "./context": "./context.ts", 9 | "./etag": "./middleware/etag.ts", 10 | "./http_server_bun": "./http_server_bun.ts", 11 | "./http_server_native": "./http_server_native.ts", 12 | "./http_server_node": "./http_server_node.ts", 13 | "./middleware": "./middleware.ts", 14 | "./proxy": "./middleware/proxy.ts", 15 | "./request": "./request.ts", 16 | "./response": "./response.ts", 17 | "./router": "./router.ts", 18 | "./send": "./send.ts", 19 | "./serve": "./middleware/serve.ts", 20 | "./testing": "./testing.ts" 21 | }, 22 | "publish": { 23 | "exclude": [ 24 | ".devcontainer", 25 | ".github", 26 | ".vscode", 27 | "examples", 28 | "fixtures", 29 | "docs", 30 | "**/*.test.ts", 31 | "test_deps.ts" 32 | ] 33 | }, 34 | "tasks": { 35 | "coverage": "deno coverage --lcov ./cov", 36 | "example": "deno run --allow-net examples/echoServer.ts", 37 | "test": "deno test --allow-read --allow-write --allow-net --parallel --ignore=npm", 38 | "test:coverage": "deno test --coverage=./cov --junit-path=junit.xml --allow-read --allow-write --allow-net --cert ./examples/tls/RootCA.crt --parallel --ignore=npm" 39 | }, 40 | "fmt": { 41 | "exclude": ["README.md"] 42 | }, 43 | "lock": false 44 | } 45 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // This file contains the external dependencies that oak depends upon 4 | 5 | // jsr dependencies 6 | 7 | export { assert } from "jsr:@std/assert@^1.0/assert"; 8 | export { concat } from "jsr:@std/bytes@^1.0/concat"; 9 | export { 10 | eTag, 11 | type ETagOptions, 12 | type FileInfo, 13 | ifNoneMatch, 14 | } from "jsr:@std/http@^1.0/etag"; 15 | export { 16 | accepts, 17 | acceptsEncodings, 18 | acceptsLanguages, 19 | } from "jsr:@std/http@^1.0/negotiation"; 20 | export { UserAgent } from "jsr:@std/http@^1.0/user-agent"; 21 | export { contentType } from "jsr:@std/media-types@^1.0/content-type"; 22 | export { 23 | basename, 24 | extname, 25 | isAbsolute, 26 | join, 27 | normalize, 28 | parse, 29 | SEPARATOR, 30 | } from "jsr:@std/path@^1.0/"; 31 | 32 | // 3rd party dependencies 33 | 34 | export { 35 | SecureCookieMap, 36 | type SecureCookieMapGetOptions, 37 | type SecureCookieMapSetDeleteOptions, 38 | } from "jsr:@oak/commons@^1.0/cookie_map"; 39 | export { parse as parseFormData } from "jsr:@oak/commons@^1.0/form_data"; 40 | export { parse as parseForwarded } from "jsr:@oak/commons@^1.0/forwarded"; 41 | export { 42 | createHttpError, 43 | errors, 44 | HttpError, 45 | type HttpErrorOptions, 46 | isHttpError, 47 | } from "jsr:@oak/commons@^1.0/http_errors"; 48 | export { KeyStack } from "jsr:@oak/commons@^1.0/keystack"; 49 | export { matches } from "jsr:@oak/commons@^1.0/media_types"; 50 | export { type HttpMethod as HTTPMethods } from "jsr:@oak/commons@^1.0/method"; 51 | export { 52 | type ByteRange, 53 | range, 54 | responseRange, 55 | } from "jsr:@oak/commons@^1.0/range"; 56 | export { 57 | ServerSentEvent, 58 | type ServerSentEventInit, 59 | ServerSentEventStreamTarget, 60 | type ServerSentEventTarget, 61 | type ServerSentEventTargetOptions, 62 | } from "jsr:@oak/commons@^1.0/server_sent_event"; 63 | export { 64 | type ErrorStatus, 65 | isErrorStatus, 66 | isRedirectStatus, 67 | type RedirectStatus, 68 | Status, 69 | STATUS_TEXT, 70 | } from "jsr:@oak/commons@^1.0/status"; 71 | 72 | export { 73 | compile, 74 | type Key, 75 | parse as pathParse, 76 | type ParseOptions, 77 | pathToRegexp, 78 | type TokensToRegexpOptions, 79 | } from "npm:path-to-regexp@^6.3.0"; 80 | -------------------------------------------------------------------------------- /deps_test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | export { assertEquals } from "jsr:@std/assert@^1.0/equals"; 4 | export { assertInstanceOf } from "jsr:@std/assert@^1.0/instance-of"; 5 | export { assertRejects } from "jsr:@std/assert@^1.0/rejects"; 6 | export { assertStrictEquals } from "jsr:@std/assert@^1.0/strict-equals"; 7 | export { assertThrows } from "jsr:@std/assert@^1.0/throws"; 8 | export { unreachable } from "jsr:@std/assert@^1.0/unreachable"; 9 | export { timingSafeEqual } from "jsr:@std/crypto@^1.0/timing-safe-equal"; 10 | -------------------------------------------------------------------------------- /docs/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | . All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement 123 | ladder](https://github.com/mozilla/diversity). 124 | 125 | [homepage]: https://www.contributor-covenant.org 126 | 127 | For answers to common questions about this code of conduct, see the FAQ at 128 | https://www.contributor-covenant.org/faq. Translations are available at 129 | https://www.contributor-covenant.org/translations. 130 | -------------------------------------------------------------------------------- /docs/FAQ.md: -------------------------------------------------------------------------------- 1 | # Frequently Asked Questions 2 | 3 | ## Where can I find full API documentation? 4 | 5 | One of the advantages of Deno (and TypeScript) is that it is quite easy to 6 | inline documentation in the code. The `doc.deno.land` site provides all the 7 | documentation directly from the source code. The documentation for 8 | [oak's mod.ts](https://doc.deno.land/https/deno.land/x/oak/mod.ts) contains all 9 | the APIs that are considered "public". You can also get an output of the 10 | documentation directly via `deno doc https://deno.land/x/oak/mod.ts` to your 11 | console. 12 | 13 | ## Where is ctx.host, ctx.path, ctx.querystring, etc.? 14 | 15 | Instead of creating "aliases" to lots of different parts of the requested URL, 16 | oak provides a `ctx.request.url` which is a browser standard instance of 17 | [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) which contains all 18 | of the parts of the requested URL in a single object. 19 | 20 | ## How do I close a server? 21 | 22 | Oak uses the browser standard `AbortController` for closing a server. You pass 23 | the `.signal` of the controller as one of the listen options, and you would then 24 | `.abort()` on the controller when you want the server to close. For example: 25 | 26 | ```ts 27 | import { Application } from "https://deno.land/x/oak/mod.ts"; 28 | 29 | const app = new Application(); 30 | 31 | const controller = new AbortController(); 32 | const { signal } = controller; 33 | 34 | app.use((ctx) => { 35 | // after first request, the server will be closed 36 | controller.abort(); 37 | }); 38 | 39 | await app.listen({ port: 8000, signal }); 40 | console.log("Server closed."); 41 | ``` 42 | 43 | ## How to perform a redirect? 44 | 45 | In the `ctx.response` call the `.redirect()` method. For example: 46 | 47 | ```ts 48 | import { Application } from "https://deno.land/x/oak/mod.ts"; 49 | 50 | const app = new Application(); 51 | 52 | app.use((ctx) => { 53 | ctx.response.redirect("https://deno.land/"); 54 | }); 55 | 56 | await app.listen({ port: 8000 }); 57 | ``` 58 | 59 | The symbol `REDIRECT_BACK` can be used to redirect the requestor back to the 60 | referrer (if the request's `Referer` header has been set), and the second 61 | argument can be used to provide a "backup" if there is no referrer. For example: 62 | 63 | ```ts 64 | import { Application, REDIRECT_BACK } from "https://deno.land/x/oak/mod.ts"; 65 | 66 | const app = new Application(); 67 | 68 | app.use((ctx) => { 69 | ctx.response.redirect(REDIRECT_BACK, "/home"); 70 | }); 71 | 72 | await app.listen({ port: 8000 }); 73 | ``` 74 | 75 | ## How do I pass custom properties/state around? 76 | 77 | The Application and the Context share an object property named `.state`. This is 78 | designed for making custom application state available when processing requests. 79 | 80 | It can also be strongly typed in TypeScript by using generics. 81 | 82 | When a new context is created, by default the state of the application is 83 | cloned, so effectively changes to the context's `.state` will only endure for 84 | the lifetime of the request and response. There are other options for how the 85 | state for the context is initialized, which can be set by setting the 86 | `contextState` option when creating the application. Acceptable values are 87 | `"clone"`, `"prototype"`, `"alias"`, `"empty"`. `"clone"` is the default and 88 | clones the applications `.state` skipping any non-cloneable values like 89 | functions and symbols. `"prototype"` uses the application's `.state` as the 90 | prototype for the context `.state`, that means shallow property assignments on 91 | the context's state only last for the lifetime of the context, but other changes 92 | directly modify the shared state. `"alias"` means that the application's 93 | `.state` and the context's `.state` are the same object. `"empty"` will 94 | initialize the context's state with an empty object. 95 | 96 | If you wanted to create middleware that set a user ID in requests, you would do 97 | something like this: 98 | 99 | ```ts 100 | import { Application } from "https://deno.land/x/oak/mod.ts"; 101 | 102 | interface MyState { 103 | userId: number; 104 | } 105 | 106 | const app = new Application(); 107 | 108 | app.use(async (ctx, next) => { 109 | // do whatever checks to determine the user ID 110 | ctx.state.userId = userId; 111 | await next(); 112 | delete ctx.state.userId; // cleanup 113 | }); 114 | 115 | app.use(async (ctx, next) => { 116 | // now the state.userId will be set for the rest of the middleware 117 | ctx.state.userId; 118 | await next(); 119 | }); 120 | 121 | await app.listen(); 122 | ``` 123 | 124 | ## I am seeing `[uncaught application error]` in the output, what is going on? 125 | 126 | By default, `Application()` has a setting `logErrors` set to `true`. When this 127 | is the case, any errors that are thrown in middleware and uncaught, or occur 128 | outside the middleware (like some network errors) will result in a message being 129 | logged. 130 | 131 | Specifically error messages like 132 | `Http - connection closed before message completed` can occur when responding to 133 | requests where the connection drops before Deno has fully flushed the body or 134 | `UnexpectedEof - early eof` when the server is terminating a request for various 135 | reasons. In some network environments this is "normal", but neither Deno nor oak 136 | know that, so the error gets surfaced. There maybe no way to avoid 100% of these 137 | errors, and an application might want to respond to that (like clearing some 138 | sort of state), therefore oak does not just "ignore" them, but provides them. 139 | 140 | You can disabled automatic logging by setting `logErrors` to `false` in the 141 | `Application` options. You can also use the 142 | `.addEventListener("error", (evt) => {});` to register your own event handler 143 | for uncaught errors. 144 | 145 | ## I'm getting `"The response is not writable."` error unexpectedly. 146 | 147 | The most common cause of this is "dropping" the flow control of the middleware. 148 | Dropping the flow control is typically accomplished by not invoking `next()` 149 | within the middleware. When the flow control is dropped, the oak application 150 | assumes that all processing is done, stops calling other middleware in the 151 | stack, seals the response and sends it. Subsequent changes to the response are 152 | what then cause that error. 153 | 154 | For simple middleware, dropping the flow control usually is not an issue, for 155 | example if you are responding with static content with the router: 156 | 157 | ```ts 158 | import { Application, Router } from "https://deno.land/x/oak/mod.ts"; 159 | 160 | const router = new Router(); 161 | 162 | router.get("/", (ctx) => { 163 | ctx.response.body = "hello world"; 164 | }); 165 | 166 | const app = new Application(); 167 | app.use(router.routes()); 168 | app.use(router.allowedMethods()); 169 | 170 | app.listen(); 171 | ``` 172 | 173 | A much better solution is to always be explicit about the flow control of the 174 | middleware by ensuring that `next()` is invoked: 175 | 176 | ```ts 177 | import { Application, Router } from "https://deno.land/x/oak/mod.ts"; 178 | 179 | const router = new Router(); 180 | 181 | router.get("/", (ctx, next) => { 182 | ctx.response.body = "hello world"; 183 | return next(); 184 | }); 185 | 186 | const app = new Application(); 187 | app.use(router.routes()); 188 | app.use(router.allowedMethods()); 189 | 190 | app.listen(); 191 | ``` 192 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-minimal 2 | -------------------------------------------------------------------------------- /docs/deploy.md: -------------------------------------------------------------------------------- 1 | # oak and Deno Deploy 2 | 3 | oak v7.1.0 introduced support for [Deno Deploy](https://deno.com/deploy), and as 4 | of v10.0.0 removes the fetch event interface. Using oak with Deno Deploy is just 5 | like using oak with the Deno CLI, and most things should "just work". 6 | 7 | This guide focuses on writing oak for a Deno Deploy application, and does not 8 | cover in depth the usage of Deno Deploy. The 9 | [Deno Deploy Docs](https://deno.com/deploy/docs) should be used for that. 10 | 11 | ## Considerations 12 | 13 | There are a few considerations when currently with Deno Deploy: 14 | 15 | - Deno Deploy does not currently support web sockets. Trying to upgrade a 16 | connection to a web socket will fail. 17 | - The command line utility for Deploy 18 | ([`deployctl`](https://deno.com/deploy/docs/deployctl)) cannot properly type 19 | check oak at the moment. You should use `--no-check` to bypass type checking 20 | when using oak with `deployctl`. 21 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redirecting to https://oakserver.org/ 4 | 5 | 6 | -------------------------------------------------------------------------------- /docs/main.md: -------------------------------------------------------------------------------- 1 | # oak 2 | 3 | A middleware framework for Deno's native HTTP server, 4 | [Deno Deploy](https://deno.com/deploy) and Node.js 16.5 and later. It also 5 | includes a middleware router. 6 | 7 | This middleware framework is inspired by [Koa](https://github.com/koajs/koa) and 8 | middleware router inspired by [@koa/router](https://github.com/koajs/router/). 9 | 10 | - [API Documentation](https://doc.deno.land/https://deno.land/x/oak/mod.ts) 11 | - [oak and Deno Deploy](./deploy) 12 | - [oak and Node.js](./node) 13 | - [Testing oak](./testing) 14 | - [Frequently Asked Questions](./FAQ) 15 | - [Awesome oak](https://oakserver.github.io/awesome-oak/) - Community resources 16 | for oak. 17 | - [oak_middleware](https://oakserver.github.io/middleware/) - A collection of 18 | middleware maintained by us. 19 | - [Getting Started](#getting-started) 20 | - [Server Sent Events](./sse) 21 | 22 | ## Getting started 23 | 24 | Oak is designed with Deno in mind, and versions of oak are tagged for specific 25 | versions of Deno in mind. In the examples here, we will be referring to using 26 | oak off of `main`, though in practice you should _pin_ to a specific version of 27 | oak in order to ensure compatibility. 28 | 29 | For example if you wanted to use version 9.0.0 of oak, you would want to import 30 | oak from `https://deno.land/x/oak@v9.0.0/mod.ts`. 31 | 32 | All of the parts of oak that are intended to be used in creating a server are 33 | exported from `mod.ts` and most of the time, you will simply want to import the 34 | main class `Application` to create your server. 35 | 36 | To create a very basic "hello world" server, you would want to create a 37 | `server.ts` file with the following content: 38 | 39 | ```ts 40 | import { Application } from "https://deno.land/x/oak/mod.ts"; 41 | 42 | const app = new Application(); 43 | 44 | app.use((ctx) => { 45 | ctx.response.body = "Hello world!"; 46 | }); 47 | 48 | await app.listen({ port: 8000 }); 49 | ``` 50 | 51 | And then you would run the following command: 52 | 53 | ```shell 54 | $ deno run --allow-net server.ts 55 | ``` 56 | 57 | If you aren't overly familiar with Deno, by default it does not trust the code 58 | you are running, and need you to let it have access to your machines network, so 59 | `--allow-net` provides that. 60 | 61 | When navigating on your local machine to `http://localhost:8000/` you should see 62 | the `Hello world!` message in your browser. 63 | 64 | ## Middleware 65 | 66 | The main architecture of middleware frameworks like oak is, unsurprisingly, the 67 | concept of middleware. These are functions which are executed by the application 68 | in a predictable order between when the application receives a request and the 69 | response is sent. 70 | 71 | Middleware functions allow you to break up the logic of your server into 72 | discrete functions that encapsulate logic, as well as import in other middleware 73 | that can add functionality to your application in a very loosely coupled way. 74 | 75 | To get an application to use a middleware function, an instance of an 76 | application has a `.use()` method. Middleware functions are provided with two 77 | parameters, a context object, and a `next()` function. Because the processing of 78 | middleware is asynchronous by nature, middleware functions can return a promise 79 | to indicate when they are done processing. 80 | 81 | ### Control the execution of middleware 82 | 83 | Middleware gets executed in the order that it is registered with the application 84 | via the `.use()` method. Just executing the functions in order though is 85 | insufficient in a lot of cases to create useful middleware. This is where the 86 | `next()` function passed to a middleware function allows the function to control 87 | the flow of other middleware, without the other middleware having to be aware of 88 | it. `next()` indicates to the application that it should continue executing 89 | other middleware in the chain. `next()` always returns a promise which is 90 | resolved when the other middleware in the chain has resolved. 91 | 92 | If you use `next()`, almost all the time you will want to `await next();` so 93 | that the code in your middleware function executes as you expect. If you don't 94 | await `next()` the rest of the code in your function will execute without all 95 | the other middleware resolving, which is not usually what you want. 96 | 97 | There are few scenarios where you want to control with your middleware. There is 98 | when you want the middleware to do something just before the response is sent, 99 | like logging middleware. You would want to create a middleware function like 100 | this: 101 | 102 | ```ts 103 | const app = new Application(); 104 | 105 | app.use(async (ctx, next) => { 106 | await next(); 107 | /* Do some cool logging stuff here */ 108 | }); 109 | ``` 110 | 111 | Here, you are signalling to the application to go ahead and run all the other 112 | middleware, and when that is done, come back to this function and run the rest 113 | of it. 114 | 115 | Another scenario would be where there is a need to do some processing, typically 116 | of the request, like checking if there is a valid session ID for a user, and 117 | then allowing the rest of the middleware to run before finalising some things, 118 | like maybe checking if the response needs to refresh the session ID. You would 119 | want to create a middleware function like this: 120 | 121 | ```ts 122 | app.use(async (ctx, next) => { 123 | /* Do some checking of the request */ 124 | await next(); 125 | /* Do some finalising of the response */ 126 | }); 127 | ``` 128 | 129 | In situations where you want the rest of the middleware to run after a function 130 | has run, or it isn't important what order the middleware runs in, you could 131 | `await next()` before the end of your function, but that would be unnecessary. 132 | 133 | There is also the scenario where you might not want to hold up the sending of a 134 | response while you perform other asynchronous operations. In this case you could 135 | chose to simply not await `next()` or not return the promise related to the 136 | asynchronous work from the function. This is a bit risky though, because other 137 | middleware and even the application might behave in unexpected ways if the 138 | middleware function makes incorrect assumptions about how the context changes. 139 | You would want to make sure you understand the consequences of code like that. 140 | 141 | ### Context 142 | 143 | Each middleware function is passed a context when invoked. This context 144 | represents "everything" that the middleware should know about the current 145 | request and response that is being handled by the application. The context also 146 | includes some other information that is useful for processing requests. The 147 | properties of the context are: 148 | 149 | - `.app` 150 | 151 | A reference to the application that is invoking the middleware. 152 | 153 | - `.cookies` 154 | 155 | An interface to get and set cookies that abstracts the need to mediate between 156 | the request and response. If the `.keys` property has been set on the 157 | application, the cookies will be signed and verified automatically to help 158 | prevent with clients attempting to tamper with the keys. Because the cookies 159 | are signed and verified with the asynchronous web crypto APIs, the methods on 160 | the cookies object work asynchronously as well. 161 | 162 | - `.request` 163 | 164 | An interface to information about the request. This information is used to 165 | figure out what information is being requested and information about the 166 | capabilities of the client. 167 | 168 | - `.response` 169 | 170 | An interface to information about what the server will respond with. 171 | 172 | - `.state` 173 | 174 | An object that is "owned" by the application which is an easy way to persist 175 | information between requests. When using TypeScript this can be strongly typed 176 | to give structure to this state object. 177 | 178 | There is also currently a couple methods available on context: 179 | 180 | - `.assert()` 181 | 182 | Makes an assertion. If the assertion is not valid, throws an HTTP error based 183 | on the HTTP status code passed. 184 | 185 | - `.throw()` 186 | 187 | Throws an HTTP error based on the HTTP status code passed as the first 188 | argument. 189 | -------------------------------------------------------------------------------- /docs/node.md: -------------------------------------------------------------------------------- 1 | # Node.js 2 | 3 | > [!NOTE] 4 | > The version of oak published to npm is obsolete and no longer supported. 5 | > Node.js compatability is accomplished via a couple of light-weight shims and 6 | > hosting the package on JSR. 7 | 8 | oak 10.3 introduces experimental support for Node.js 16.5 and later. 9 | 10 | The package is available on npm as 11 | [`@oakserver/oak`](https://www.npmjs.com/package/@oakserver/oak) and can be 12 | installed with your preferred package manager. 13 | 14 | The package shares the same API as if it were being used under Deno CLI or Deno 15 | Deploy, and almost all functionality is the same. 16 | 17 | A few notes about the support: 18 | 19 | - The package includes all the type definitions, which should make it easy to 20 | use from within an intelligent IDE. 21 | - The package uses `Headers` from [undici](https://github.com/nodejs/undici) 22 | which operate slightly different than the Deno `Headers` class. Generally this 23 | shouldn't cause issues for users and code, but it hasn't been extensively 24 | tested yet and so there maybe some behavioral changes. 25 | - Currently `FormData` bodies that need to write out files to the file system do 26 | not work properly. This will be fixed in the future. 27 | - Currently the package does not support upgrading a connection to web sockets. 28 | There are plans for this in the future. 29 | - Currently the package only supports HTTP/1.1 server. There are plans to 30 | support HTTP/2 in the future. 31 | 32 | ## Usage 33 | 34 | As mentioned above, installing the package `@oakserver/oak` should be 35 | sufficient. 36 | 37 | If you love kittens, you should consider using ES modules in Node.js and 38 | importing from `"@oakserver/oak"`: 39 | 40 | **index.mjs** 41 | 42 | ```js 43 | import { Application } from "@oakserver/oak"; 44 | 45 | const app = new Application(); 46 | 47 | app.use((ctx) => { 48 | ctx.response.body = "Hello from oak on Node.js"; 49 | }); 50 | 51 | app.listen({ port: 8000 }); 52 | ``` 53 | 54 | If you want to import it in a CommonJS module, `require()` can be used: 55 | 56 | **index.js** 57 | 58 | ```js 59 | const { Application } = require("@oakserver/oak"); 60 | 61 | const app = new Application(); 62 | 63 | app.use((ctx) => { 64 | ctx.response.body = "Hello from oak on Node.js"; 65 | }); 66 | 67 | app.listen({ port: 8000 }); 68 | ``` 69 | -------------------------------------------------------------------------------- /docs/sse.md: -------------------------------------------------------------------------------- 1 | # Server-Sent Events 2 | 3 | Oak has built in support for server-sent events. Server-sent events are a one 4 | way communication protocol which is part of the 5 | [web standards](https://html.spec.whatwg.org/multipage/server-sent-events.html#server-sent-events). 6 | 7 | The typical flow of establishing a connection is that the client will create an 8 | `EventSource` object that points at a path on the server: 9 | 10 | ```js 11 | const eventSource = new EventSource("/sse"); 12 | ``` 13 | 14 | The server will respond by keeping open an HTTP connection which will be used to 15 | send messages: 16 | 17 | ```ts 18 | import { Application, Router } from "https://deno.land/x/oak/mod.ts"; 19 | 20 | const app = new Application(); 21 | const router = new Router(); 22 | 23 | router.get("/sse", (ctx) => { 24 | const target = ctx.sendEvents(); 25 | target.dispatchMessage({ hello: "world" }); 26 | }); 27 | 28 | app.use(router.routes()); 29 | await app.listen({ port: 80 }); 30 | ``` 31 | 32 | The far end can close the connection, which can be detected by the `close` event 33 | on the target: 34 | 35 | ```ts 36 | router.get("/sse", (ctx) => { 37 | const target = ctx.sendEvents(); 38 | target.addEventListener("close", (evt) => { 39 | // perform some cleanup activities 40 | }); 41 | target.dispatchMessage({ hello: "world" }); 42 | }); 43 | ``` 44 | 45 | The server side can also close the connection: 46 | 47 | ```ts 48 | router.get("/sse", async (ctx) => { 49 | const target = ctx.sendEvents(); 50 | target.dispatchMessage({ hello: "world" }); 51 | await target.close(); 52 | }); 53 | ``` 54 | 55 | The basic concept here is that events that are raised against the target 56 | returned from `.sendEvents()` are raised as events in the client's 57 | `EventSource`. Because both the server interface (`ServerSentEventTarget`) and 58 | the client interface (`EventSource`) extend `EventTarget` the server and client 59 | APIs are very similar. 60 | 61 | Oak provides a specialised event constructor which supports the features of the 62 | server-sent event protocol. A `ServerSentEvent` event that is created on the 63 | server and dispatched via the `ServerSentEventTarget` will be raised as a 64 | `MessageEvent` on the client's `EventSource`. So on the server side: 65 | 66 | ```ts 67 | router.get("/sse", async (ctx: Context) => { 68 | const target = ctx.sendEvents(); 69 | const event = new ServerSentEvent("ping", { hello: "world" }); 70 | target.dispatchEvent(event); 71 | }); 72 | ``` 73 | 74 | Would work like this on the client side: 75 | 76 | ```ts 77 | const source = new EventSource("/sse"); 78 | source.addEventListener("ping", (evt) => { 79 | console.log(evt.data); // should log a string of `{"hello":"world"}` 80 | }); 81 | ``` 82 | 83 | Events that are dispatched on the server `SeverSentEventTarget` can be listened 84 | to locally before they are sent to the client. This means if the event is 85 | cancellable, event listeners can `.preventDefault()` on the event to cancel the 86 | event, which will then not be sent so the client. 87 | 88 | In addition to `.dispatchEvent()` there are also `.dispatchMessage()` and 89 | `.dispatchComment()`. `.dispatchMessage()` will send a "data only" message to 90 | the client. The `EventSource` in the client makes these events available on the 91 | `.onmessage` property and the event type of `"message"`. `.dispatchComment()` is 92 | sent to the client, but does not raise itself in the `EventSource`. It is 93 | intended to be used for debugging purposes as well as a potential mechanism to 94 | help keep the connection alive. 95 | 96 | ## Establishing the connection 97 | 98 | Typically a client will utilise an `EventSource` to make a connection to the 99 | endpoint, but server-sent events are a standard of transferring information, so 100 | it is possible that a client might not be using an `EventSource` or a client has 101 | accidentally called an endpoint and implementations might want to ensure the 102 | client request intends to support server-sent events: 103 | 104 | ```ts 105 | ctx.request.accepts("text/event-stream"); 106 | ``` 107 | 108 | When `.sendEvents()` is called, the start of the response is sent to the client, 109 | which is a HTTP 200 OK response with specific headers of: 110 | 111 | ``` 112 | HTTP/1.1 200 OK 113 | Connection: Keep-Alive 114 | Content-Type: text/event-stream 115 | Cache-Control: no-cache 116 | Keep-Alive: timeout=9007199254740991 117 | ``` 118 | 119 | This should be sufficient for most scenarios, but if you require additional 120 | headers or need to override any of these headers, pass an instance of `Headers` 121 | when calling `.sendEvents()`: 122 | 123 | ```ts 124 | router.get("/sse", async (ctx: Context) => { 125 | const headers = new Headers([["X-Custom-Header", "custom value"]]); 126 | const target = ctx.sendEvents({ headers }); 127 | const event = new ServerSentEvent("ping", { hello: "world" }); 128 | target.dispatchEvent(event); 129 | }); 130 | ``` 131 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Unit testing your middleware can be challenging, especially if you have to open 4 | up a network port and send requests into an application just to see if your 5 | middleware responds as expected. 6 | 7 | To help with this, oak exposes its internal mocking library as part of the 8 | public API. 9 | 10 | The testing utilities are exported from the `/mod.ts` under a `testing` 11 | namespace. To import the namespace, you would want to import it like: 12 | 13 | ```ts 14 | import { testing } from "https://deno.land/x/oak/mod.ts"; 15 | ``` 16 | 17 | ## Usage 18 | 19 | The most common usage would be creating a mock context, which would include a 20 | request and response, and then using that context to call your middleware, and 21 | then making assertions against the result of that middleware. 22 | 23 | In the following example, we will create a middleware that checks if the request 24 | path is `"/a"` and if so, sets the body and a header in the response: 25 | 26 | ```ts 27 | import { testing } from "https://deno.land/x/oak/mod.ts"; 28 | import type { Middleware } from "https://deno.land/x/oak/mod.ts"; 29 | import { assert, assertEquals } from "https://deno.land/std/testing/asserts.ts"; 30 | 31 | const mw: Middleware = async (ctx, next) => { 32 | await next(); 33 | if (ctx.request.url.pathname === "/a") { 34 | ctx.response.body = "Hello a"; 35 | ctx.response.headers.set("x-hello-a", "hello"); 36 | } 37 | }; 38 | 39 | Deno.test({ 40 | name: "example test", 41 | async fn() { 42 | const ctx = testing.createMockContext({ 43 | path: "/a", 44 | }); 45 | const next = testing.createMockNext(); 46 | 47 | await mw(ctx, next); 48 | 49 | assertEquals(ctx.response.body, "Hello a"); 50 | assert(ctx.response.headers.has("x-hello-a")); 51 | }, 52 | }); 53 | ``` 54 | 55 | ## createMockApp() 56 | 57 | This creates a mock application which takes a single optional argument of 58 | `state` which represents the state of the application. 59 | 60 | ## createMockContext() 61 | 62 | This creates a mock context. This is the most useful feature of the library and 63 | accepts a set of options that adhere to this interface: 64 | 65 | ```ts 66 | export interface MockContextOptions< 67 | P extends RouteParams = RouteParams, 68 | S extends State = Record, 69 | > { 70 | app?: Application; 71 | method?: string; 72 | params?: P; 73 | path?: string; 74 | state?: S; 75 | } 76 | ``` 77 | 78 | The function generates a mock that can either be used as a regular `Context` or 79 | as a `RouterContext`. 80 | 81 | ## createMockNext() 82 | 83 | This creates a `next()` function which can be used when calling middleware. 84 | -------------------------------------------------------------------------------- /examples/closeServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of a server that closes when hitting the /close route. 3 | */ 4 | 5 | // Importing some console colors 6 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 7 | 8 | import { Application, type Context, Router, Status } from "../mod.ts"; 9 | 10 | function notFound(context: Context) { 11 | context.response.status = Status.NotFound; 12 | context.response.body = 13 | `

404 - Not Found

Path ${context.request.url} not found.`; 14 | } 15 | 16 | const controller = new AbortController(); 17 | 18 | const router = new Router(); 19 | router 20 | .get("/", (context) => { 21 | context.response.body = "Hello world!"; 22 | }) 23 | .get("/close", (context) => { 24 | context.response.body = "Bye!"; 25 | // This will cause the application to stop listening and stop processing 26 | // requests 27 | controller.abort(); 28 | }); 29 | 30 | const app = new Application(); 31 | 32 | // Logger 33 | app.use(async (context, next) => { 34 | await next(); 35 | const rt = context.response.headers.get("X-Response-Time"); 36 | console.log( 37 | `${green(context.request.method)} ${cyan(context.request.url.pathname)} - ${ 38 | bold( 39 | String(rt), 40 | ) 41 | }`, 42 | ); 43 | }); 44 | 45 | // Response Time 46 | app.use(async (context, next) => { 47 | const start = Date.now(); 48 | await next(); 49 | const ms = Date.now() - start; 50 | context.response.headers.set("X-Response-Time", `${ms}ms`); 51 | }); 52 | 53 | // Use the router 54 | app.use(router.routes()); 55 | app.use(router.allowedMethods()); 56 | 57 | // A basic 404 page 58 | app.use(notFound); 59 | 60 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 61 | console.log( 62 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 63 | ); 64 | console.log(bold(" using HTTP server: " + yellow(serverType))); 65 | }); 66 | 67 | // Utilise the signal from the controller 68 | const { signal } = controller; 69 | await app.listen({ hostname: "127.0.0.1", port: 8000, signal }); 70 | console.log(bold("Finished.")); 71 | -------------------------------------------------------------------------------- /examples/cookieServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a basic example of a test server which sets a last visit cookie. 3 | */ 4 | 5 | // Importing some console colors 6 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 7 | 8 | import { Application } from "../mod.ts"; 9 | 10 | const app = new Application({ 11 | // This will be used to sign cookies to help prevent cookie tampering 12 | keys: ["secret1"], 13 | }); 14 | 15 | // Logger 16 | app.use(async (ctx, next) => { 17 | await next(); 18 | const rt = ctx.response.headers.get("X-Response-Time"); 19 | console.log( 20 | `${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${ 21 | bold( 22 | String(rt), 23 | ) 24 | }`, 25 | ); 26 | }); 27 | 28 | app.use(async (ctx, next) => { 29 | const start = Date.now(); 30 | await next(); 31 | const ms = Date.now() - start; 32 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 33 | }); 34 | 35 | app.use(async (ctx) => { 36 | const lastVisit = await ctx.cookies.get("lastVisit"); 37 | await ctx.cookies.set("lastVisit", new Date().toISOString()); 38 | if (lastVisit) { 39 | ctx.response.body = `Welcome back. You were last here at ${lastVisit}.`; 40 | } else { 41 | ctx.response.body = `Welcome, I haven't seen your before.`; 42 | } 43 | }); 44 | 45 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 46 | console.log( 47 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 48 | ); 49 | console.log(bold(" using HTTP server: " + yellow(serverType))); 50 | }); 51 | 52 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 53 | console.log(bold("Finished.")); 54 | -------------------------------------------------------------------------------- /examples/countingServer.ts: -------------------------------------------------------------------------------- 1 | /* This is an example of how to use an object as a middleware. 2 | * `MiddlewareObject` can be ideal for when a middleware needs to encapsulate 3 | * large amounts of logic or its own state. */ 4 | 5 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 6 | 7 | import { 8 | Application, 9 | composeMiddleware, 10 | type Context, 11 | type MiddlewareObject, 12 | type Next, 13 | } from "../mod.ts"; 14 | 15 | const app = new Application(); 16 | 17 | class CountingMiddleware implements MiddlewareObject { 18 | #id = 0; 19 | #counter = 0; 20 | 21 | init() { 22 | const array = new Uint32Array(1); 23 | crypto.getRandomValues(array); 24 | this.#id = array[0]; 25 | } 26 | 27 | handleRequest(ctx: Context, next: Next) { 28 | ctx.response.headers.set("X-Response-Count", String(this.#counter++)); 29 | ctx.response.headers.set("X-Response-Counter-ID", String(this.#id)); 30 | return next(); 31 | } 32 | } 33 | 34 | class LoggerMiddleware implements MiddlewareObject { 35 | #composedMiddleware: (context: Context, next: Next) => Promise; 36 | 37 | constructor() { 38 | this.#composedMiddleware = composeMiddleware([ 39 | this.#handleLogger, 40 | this.#handleResponseTime, 41 | ]); 42 | } 43 | 44 | async #handleLogger(ctx: Context, next: Next) { 45 | await next(); 46 | const rt = ctx.response.headers.get("X-Response-Time"); 47 | console.log( 48 | `${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${ 49 | bold( 50 | String(rt), 51 | ) 52 | }`, 53 | ); 54 | } 55 | 56 | async #handleResponseTime(ctx: Context, next: Next) { 57 | const start = Date.now(); 58 | await next(); 59 | const ms = Date.now() - start; 60 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 61 | } 62 | 63 | handleRequest(ctx: Context, next: Next) { 64 | return this.#composedMiddleware(ctx, next); 65 | } 66 | } 67 | 68 | app.use(new CountingMiddleware()); 69 | app.use(new LoggerMiddleware()); 70 | 71 | app.use((ctx) => { 72 | ctx.response.body = "Hello World!"; 73 | }); 74 | 75 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 76 | console.log( 77 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 78 | ); 79 | console.log(bold(" using HTTP server: " + yellow(serverType))); 80 | }); 81 | 82 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 83 | console.log(bold("Finished.")); 84 | -------------------------------------------------------------------------------- /examples/echoServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of a server which will take whatever the request is and 3 | * respond back with information about the request. 4 | */ 5 | 6 | import { Application } from "../mod.ts"; 7 | 8 | const app = new Application(); 9 | 10 | // Logger 11 | app.use(async (ctx, next) => { 12 | await next(); 13 | const rt = ctx.response.headers.get("X-Response-Time"); 14 | console.log( 15 | `%c${ctx.request.method} %c${ctx.request.url.pathname}%c - %c${rt}`, 16 | "color:green", 17 | "color:cyan", 18 | "color:none", 19 | "font-weight:bold", 20 | ); 21 | const ua = ctx.request.userAgent; 22 | console.log( 23 | ` ${ua.browser.name}@${ua.browser.major} %c(${ua.os.name}@${ua.os.version})`, 24 | "color:grey", 25 | ); 26 | console.log(ua.ua); 27 | }); 28 | 29 | app.use(async (ctx, next) => { 30 | const start = Date.now(); 31 | await next(); 32 | const ms = Date.now() - start; 33 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 34 | }); 35 | 36 | const decoder = new TextDecoder(); 37 | 38 | app.use(async (ctx) => { 39 | if (ctx.request.hasBody) { 40 | const body = ctx.request.body; 41 | ctx.response.body = ` 42 |

Body type: "${body.type()}"

`; 43 | switch (body.type()) { 44 | case "form": 45 | ctx.response.body += 46 | ``; 47 | for (const [key, value] of await body.form()) { 48 | ctx.response.body += ``; 49 | } 50 | ctx.response.body += `
KeyValue
${key}${value}
`; 51 | break; 52 | case "form-data": { 53 | const formData = await body.formData(); 54 | ctx.response.body += 55 | ``; 56 | for (const [key, value] of formData) { 57 | ctx.response.body += ``; 62 | if (value instanceof File) { 63 | console.log(await value.arrayBuffer()); 64 | } 65 | } 66 | ctx.response.body += `
KeyValue
${key}${ 58 | typeof value === "string" 59 | ? value 60 | : `file: ${value.name}
size: ${value.size}
type: ${value.type}` 61 | }
`; 67 | break; 68 | } 69 | case "text": 70 | ctx.response.body += `
${await body.text()}
`; 71 | break; 72 | case "json": 73 | ctx.response.body += `
${
 74 |           JSON.stringify(await body.json(), undefined, "  ")
 75 |         }
`; 76 | break; 77 | case "binary": 78 | ctx.response.body += `

Content Type: "${ 79 | ctx.request.headers.get("content-type") 80 | }"

`; 81 | ctx.response.body += `
${
 82 |           decoder.decode(await body.arrayBuffer())
 83 |         }
`; 84 | break; 85 | case "unknown": 86 | ctx.response.body += 87 | `
Unable to determine body type.

Content Type: "${ 88 | ctx.request.headers.get("content-type") 89 | }"

`; 90 | break; 91 | default: 92 | ctx.response.body += `

Body is Undefined

`; 93 | } 94 | ctx.response.body += ``; 95 | } else { 96 | ctx.response.body = 97 | `

No Body

`; 98 | } 99 | }); 100 | 101 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 102 | console.log( 103 | `%cStart listening on %c${hostname}:${port}`, 104 | "font-weight:bold", 105 | "color:yellow;font-weight:normal", 106 | ); 107 | console.log( 108 | ` %cusing HTTP server: %c${serverType}`, 109 | "font-weight:bold", 110 | "color:yellow; font-weight:normal", 111 | ); 112 | }); 113 | 114 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 115 | console.log("%cFinished.", "font-weight:bold"); 116 | -------------------------------------------------------------------------------- /examples/httpsServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a basic example of a test server that listens on HTTPS and when using 3 | * the native HTTP server in Deno, will automatically server HTTP/2. 4 | * 5 | * This server uses self-signed encryption certificates, which also has a custom 6 | * root certificate authority. To use it as configured, you need to install and 7 | * trust the `tls/RootCA.crt` on your local system. 8 | */ 9 | 10 | // Importing some console colors 11 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 12 | 13 | import { Application } from "../mod.ts"; 14 | 15 | const app = new Application(); 16 | 17 | // Logger 18 | app.use(async (ctx, next) => { 19 | await next(); 20 | const rt = ctx.response.headers.get("X-Response-Time"); 21 | console.log( 22 | `${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${ 23 | bold( 24 | String(rt), 25 | ) 26 | }`, 27 | ); 28 | }); 29 | 30 | app.use(async (ctx, next) => { 31 | const start = Date.now(); 32 | await next(); 33 | const ms = Date.now() - start; 34 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 35 | }); 36 | 37 | app.use((ctx) => { 38 | ctx.response.body = "Hello World!"; 39 | }); 40 | 41 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 42 | console.log( 43 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 44 | ); 45 | console.log(bold(" using HTTP server: " + yellow(serverType))); 46 | }); 47 | 48 | await app.listen({ 49 | port: 8000, 50 | secure: true, 51 | cert: Deno.readTextFileSync("./examples/tls/localhost.crt"), 52 | key: Deno.readTextFileSync("./examples/tls/localhost.key"), 53 | }); 54 | console.log(bold("Finished.")); 55 | -------------------------------------------------------------------------------- /examples/nestedRoutingServer.ts: -------------------------------------------------------------------------------- 1 | import { Application } from "../application.ts"; 2 | import { Router } from "../router.ts"; 3 | 4 | const posts = new Router() 5 | .get("/", (ctx) => { 6 | ctx.response.body = `Forum: ${ctx.params.forumId}`; 7 | }) 8 | .get("/:postId", (ctx) => { 9 | ctx.response.body = 10 | `Forum: ${ctx.params.forumId}, Post: ${ctx.params.postId}`; 11 | }); 12 | 13 | const forums = new Router() 14 | .use("/forums/:forumId/posts", posts.routes(), posts.allowedMethods()); 15 | 16 | console.log( 17 | `Responds to "http://localhost:8000/forums/oak/posts" and "http://localhost:8000/forums/oak/posts/nested-routers"`, 18 | ); 19 | 20 | await new Application() 21 | .use(forums.routes()) 22 | .listen({ port: 8000 }); 23 | -------------------------------------------------------------------------------- /examples/proxyServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example proxy server. 3 | */ 4 | 5 | import { bold, cyan, green, red, yellow } from "jsr:@std/fmt@0.223/colors"; 6 | 7 | import { Application, HttpError, proxy, Status } from "../mod.ts"; 8 | 9 | const app = new Application(); 10 | 11 | // Error handler middleware 12 | app.use(async (context, next) => { 13 | try { 14 | await next(); 15 | } catch (e) { 16 | if (e instanceof HttpError) { 17 | // deno-lint-ignore no-explicit-any 18 | context.response.status = e.status as any; 19 | if (e.expose) { 20 | context.response.body = ` 21 | 22 | 23 |

${e.status} - ${e.message}

24 | 25 | `; 26 | } else { 27 | context.response.body = ` 28 | 29 | 30 |

${e.status} - ${Status[e.status]}

31 | 32 | `; 33 | } 34 | } else if (e instanceof Error) { 35 | context.response.status = 500; 36 | context.response.body = ` 37 | 38 | 39 |

500 - Internal Server Error

40 | 41 | `; 42 | console.log("Unhandled Error:", red(bold(e.message))); 43 | console.log(e.stack); 44 | } 45 | } 46 | }); 47 | 48 | // Logger 49 | app.use(async (context, next) => { 50 | await next(); 51 | const rt = context.response.headers.get("X-Response-Time"); 52 | console.log( 53 | `${green(context.request.method)} ${cyan(context.request.url.pathname)} - ${ 54 | bold( 55 | String(rt), 56 | ) 57 | }`, 58 | ); 59 | }); 60 | 61 | // Response Time 62 | app.use(async (context, next) => { 63 | const start = Date.now(); 64 | await next(); 65 | const ms = Date.now() - start; 66 | context.response.headers.set("X-Response-Time", `${ms}ms`); 67 | }); 68 | 69 | // Redirects requests to `/` to `/oak/` which is the appropriate path to 70 | // proxy. 71 | app.use((ctx, next) => { 72 | if (ctx.request.url.pathname === "/") { 73 | ctx.response.redirect("/oak/"); 74 | } else { 75 | return next(); 76 | } 77 | }); 78 | 79 | // Proxy requests 80 | app.use(proxy("https://oakserver.github.io/")); 81 | 82 | // Log when we start listening for requests 83 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 84 | console.log( 85 | `${bold("Start listening on ")}${yellow(`${hostname}:${port}`)}`, 86 | ); 87 | console.log(bold(` using HTTP server: ${yellow(serverType)}`)); 88 | }); 89 | 90 | // Start listening to requests 91 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 92 | -------------------------------------------------------------------------------- /examples/resources/sseServer_index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |

Hello world!

6 |
    7 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /examples/routingServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of a server that utilizes the router. 3 | */ 4 | 5 | // Importing some console colors 6 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 7 | 8 | import { 9 | Application, 10 | type Context, 11 | isHttpError, 12 | Router, 13 | type RouterContext, 14 | Status, 15 | } from "../mod.ts"; 16 | 17 | interface Book { 18 | id: string; 19 | title: string; 20 | author: string; 21 | } 22 | 23 | const books = new Map(); 24 | 25 | books.set("1234", { 26 | id: "1234", 27 | title: "The Hound of the Baskervilles", 28 | author: "Conan Doyle, Author", 29 | }); 30 | 31 | function notFound(context: Context) { 32 | context.response.status = Status.NotFound; 33 | context.response.body = 34 | `

    404 - Not Found

    Path ${context.request.url} not found.`; 35 | } 36 | 37 | const router = new Router(); 38 | router 39 | .get("/", (context) => { 40 | context.response.body = "Hello world!"; 41 | }) 42 | .get("/book", (context) => { 43 | context.response.body = Array.from(books.values()); 44 | }) 45 | .post("/book", async (context: RouterContext<"/book">) => { 46 | console.log("post book"); 47 | if (!context.request.hasBody) { 48 | context.throw(Status.BadRequest, "Bad Request"); 49 | } 50 | const body = context.request.body; 51 | let book: Partial | undefined; 52 | if (body.type() === "json") { 53 | book = await body.json(); 54 | } else if (body.type() === "form") { 55 | book = {}; 56 | for (const [key, value] of await body.form()) { 57 | book[key as keyof Book] = value; 58 | } 59 | } else if (body.type() === "form-data") { 60 | book = {}; 61 | for (const [key, value] of await body.formData()) { 62 | book[key as keyof Book] = value as string; 63 | } 64 | } 65 | if (book) { 66 | context.assert(book.id && typeof book.id === "string", Status.BadRequest); 67 | books.set(book.id, book as Book); 68 | context.response.status = Status.OK; 69 | context.response.body = book; 70 | context.response.type = "json"; 71 | return; 72 | } 73 | context.throw(Status.BadRequest, "Bad Request"); 74 | }) 75 | .get("/book/:id", (context) => { 76 | if (context.params && books.has(context.params.id)) { 77 | context.response.body = books.get(context.params.id); 78 | } else { 79 | return notFound(context); 80 | } 81 | }); 82 | 83 | const app = new Application(); 84 | 85 | // Logger 86 | app.use(async (context, next) => { 87 | await next(); 88 | const rt = context.response.headers.get("X-Response-Time"); 89 | console.log( 90 | `${green(context.request.method)} ${ 91 | cyan(decodeURIComponent(context.request.url.pathname)) 92 | } - ${ 93 | bold( 94 | String(rt), 95 | ) 96 | }`, 97 | ); 98 | }); 99 | 100 | // Response Time 101 | app.use(async (context, next) => { 102 | const start = Date.now(); 103 | await next(); 104 | const ms = Date.now() - start; 105 | context.response.headers.set("X-Response-Time", `${ms}ms`); 106 | }); 107 | 108 | // Error handler 109 | app.use(async (context, next) => { 110 | try { 111 | await next(); 112 | } catch (err) { 113 | if (isHttpError(err)) { 114 | context.response.status = err.status; 115 | const { message, status, stack } = err; 116 | if (context.request.accepts("json")) { 117 | context.response.body = { message, status, stack }; 118 | context.response.type = "json"; 119 | } else { 120 | context.response.body = `${status} ${message}\n\n${stack ?? ""}`; 121 | context.response.type = "text/plain"; 122 | } 123 | } else { 124 | console.log(err); 125 | throw err; 126 | } 127 | } 128 | }); 129 | 130 | // Use the router 131 | app.use(router.routes()); 132 | app.use(router.allowedMethods()); 133 | 134 | // A basic 404 page 135 | app.use(notFound); 136 | 137 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 138 | console.log( 139 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 140 | ); 141 | console.log(bold(" using HTTP server: " + yellow(serverType))); 142 | }); 143 | 144 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 145 | console.log(bold("Finished.")); 146 | -------------------------------------------------------------------------------- /examples/server.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a basic example of a test server which provides a logger middleware, 3 | * a response time middleware, and a basic "Hello World!" middleware. 4 | */ 5 | 6 | // Importing some console colors 7 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 8 | 9 | import { Application } from "../mod.ts"; 10 | 11 | const app = new Application(); 12 | 13 | // Logger 14 | app.use(async (ctx, next) => { 15 | await next(); 16 | const rt = ctx.response.headers.get("X-Response-Time"); 17 | console.log( 18 | `${green(ctx.request.method)} ${cyan(ctx.request.url.pathname)} - ${ 19 | bold( 20 | String(rt), 21 | ) 22 | }`, 23 | ); 24 | }); 25 | 26 | app.use(async (ctx, next) => { 27 | const start = Date.now(); 28 | await next(); 29 | const ms = Date.now() - start; 30 | ctx.response.headers.set("X-Response-Time", `${ms}ms`); 31 | }); 32 | 33 | app.use((ctx) => { 34 | ctx.response.body = "Hello World!"; 35 | }); 36 | 37 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 38 | console.log( 39 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 40 | ); 41 | console.log(bold(" using HTTP server: " + yellow(serverType))); 42 | }); 43 | 44 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 45 | console.log(bold("Finished.")); 46 | -------------------------------------------------------------------------------- /examples/sseServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of a server that utilizes the router. 3 | */ 4 | 5 | // Importing some console colors 6 | import { bold, cyan, green, yellow } from "jsr:@std/fmt@0.223/colors"; 7 | 8 | import { 9 | Application, 10 | type Context, 11 | isHttpError, 12 | Router, 13 | ServerSentEvent, 14 | Status, 15 | } from "../mod.ts"; 16 | 17 | interface Book { 18 | id: string; 19 | title: string; 20 | author: string; 21 | } 22 | 23 | function notFound(ctx: Context) { 24 | ctx.response.status = Status.NotFound; 25 | ctx.response.body = 26 | `

    404 - Not Found

    Path ${ctx.request.url} not found.`; 27 | } 28 | 29 | const router = new Router(); 30 | router 31 | .get("/", async (ctx) => { 32 | await ctx.send( 33 | { 34 | root: `${Deno.cwd()}/examples/resources`, 35 | path: "sseServer_index.html", 36 | }, 37 | ); 38 | }) 39 | // for any clients that request the `/sse` endpoint, we will send a message 40 | // every 2 seconds. 41 | .get("/sse", async (ctx: Context) => { 42 | ctx.assert( 43 | ctx.request.accepts("text/event-stream"), 44 | Status.UnsupportedMediaType, 45 | ); 46 | const connection = ctx.request.ip; 47 | const sse = await ctx.sendEvents(); 48 | console.log(`${green("SSE connect")} ${cyan(connection)}`); 49 | let counter = 0; 50 | const id = setInterval(() => { 51 | const evt = new ServerSentEvent( 52 | "message", 53 | { data: { hello: "world" }, id: counter++ }, 54 | ); 55 | sse.dispatchEvent(evt); 56 | console.log("dispatched"); 57 | }, 2000); 58 | sse.dispatchMessage({ hello: "world" }); 59 | sse.addEventListener("close", () => { 60 | console.log(`${green("SSE disconnect")} ${cyan(connection)}`); 61 | clearInterval(id); 62 | }); 63 | }); 64 | 65 | const app = new Application(); 66 | 67 | // Logger 68 | app.use(async (context, next) => { 69 | await next(); 70 | const rt = context.response.headers.get("X-Response-Time"); 71 | console.log( 72 | `${green(context.request.method)} ${cyan(context.request.url.pathname)} - ${ 73 | bold( 74 | String(rt), 75 | ) 76 | }`, 77 | ); 78 | }); 79 | 80 | // Response Time 81 | app.use(async (context, next) => { 82 | const start = Date.now(); 83 | await next(); 84 | const ms = Date.now() - start; 85 | context.response.headers.set("X-Response-Time", `${ms}ms`); 86 | }); 87 | 88 | // Error handler 89 | app.use(async (context, next) => { 90 | try { 91 | await next(); 92 | } catch (err) { 93 | if (isHttpError(err)) { 94 | context.response.status = err.status; 95 | const { message, status, stack } = err; 96 | if (context.request.accepts("json")) { 97 | context.response.body = { message, status, stack }; 98 | context.response.type = "json"; 99 | } else { 100 | context.response.body = `${status} ${message}\n\n${stack ?? ""}`; 101 | context.response.type = "text/plain"; 102 | } 103 | } else { 104 | console.log(err); 105 | throw err; 106 | } 107 | } 108 | }); 109 | 110 | // Use the router 111 | app.use(router.routes()); 112 | app.use(router.allowedMethods()); 113 | 114 | // A basic 404 page 115 | app.use(notFound); 116 | 117 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 118 | console.log( 119 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 120 | ); 121 | console.log(bold(" using HTTP server: " + yellow(serverType))); 122 | }); 123 | 124 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 125 | console.log(bold("Finished.")); 126 | -------------------------------------------------------------------------------- /examples/static/deno_logo.png.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oakserver/oak/baa38fa8a562fc197407e96be6ff396f760fd6f2/examples/static/deno_logo.png.gz -------------------------------------------------------------------------------- /examples/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 |

    Hello Static World!

    7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /examples/staticServer.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * This is an example of a server that will serve static content out of the 3 | * $CWD/examples/static path. 4 | */ 5 | 6 | import { bold, cyan, green, red, yellow } from "jsr:@std/fmt@0.223/colors"; 7 | 8 | import { Application, HttpError, Status } from "../mod.ts"; 9 | 10 | const app = new Application(); 11 | 12 | // Error handler middleware 13 | app.use(async (context, next) => { 14 | try { 15 | await next(); 16 | } catch (e) { 17 | if (e instanceof HttpError) { 18 | // deno-lint-ignore no-explicit-any 19 | context.response.status = e.status as any; 20 | if (e.expose) { 21 | context.response.body = ` 22 | 23 | 24 |

    ${e.status} - ${e.message}

    25 | 26 | `; 27 | } else { 28 | context.response.body = ` 29 | 30 | 31 |

    ${e.status} - ${Status[e.status]}

    32 | 33 | `; 34 | } 35 | } else if (e instanceof Error) { 36 | context.response.status = 500; 37 | context.response.body = ` 38 | 39 | 40 |

    500 - Internal Server Error

    41 | 42 | `; 43 | console.log("Unhandled Error:", red(bold(e.message))); 44 | console.log(e.stack); 45 | } 46 | } 47 | }); 48 | 49 | // Logger 50 | app.use(async (context, next) => { 51 | await next(); 52 | const rt = context.response.headers.get("X-Response-Time"); 53 | console.log( 54 | `${green(context.request.method)} ${cyan(context.request.url.pathname)} - ${ 55 | bold( 56 | String(rt), 57 | ) 58 | }`, 59 | ); 60 | }); 61 | 62 | // Response Time 63 | app.use(async (context, next) => { 64 | const start = Date.now(); 65 | await next(); 66 | const ms = Date.now() - start; 67 | context.response.headers.set("X-Response-Time", `${ms}ms`); 68 | }); 69 | 70 | // Send static content 71 | app.use(async (context) => { 72 | await context.send({ 73 | root: `${Deno.cwd()}/examples/static`, 74 | index: "index.html", 75 | }); 76 | }); 77 | 78 | app.addEventListener("listen", ({ hostname, port, serverType }) => { 79 | console.log( 80 | bold("Start listening on ") + yellow(`${hostname}:${port}`), 81 | ); 82 | console.log(bold(" using HTTP server: " + yellow(serverType))); 83 | }); 84 | 85 | await app.listen({ hostname: "127.0.0.1", port: 8000 }); 86 | -------------------------------------------------------------------------------- /examples/tls/README.md: -------------------------------------------------------------------------------- 1 | The certificates in this dir expire on Sept, 27th, 2118 2 | 3 | Certificates generated using original instructions from this gist: 4 | https://gist.github.com/cecilemuller/9492b848eb8fe46d462abeb26656c4f8 5 | 6 | ## Certificate authority (CA) 7 | 8 | Generate RootCA.pem, RootCA.key & RootCA.crt: 9 | 10 | ```shell 11 | openssl req -x509 -nodes -new -sha256 -days 36135 -newkey rsa:2048 -keyout RootCA.key -out RootCA.pem -subj "/C=US/CN=Example-Root-CA" 12 | openssl x509 -outform pem -in RootCA.pem -out RootCA.crt 13 | ``` 14 | 15 | Note that Example-Root-CA is an example, you can customize the name. 16 | 17 | ## Domain name certificate 18 | 19 | First, create a file domains.txt that lists all your local domains (here we only 20 | list localhost): 21 | 22 | ```shell 23 | authorityKeyIdentifier=keyid,issuer 24 | basicConstraints=CA:FALSE 25 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 26 | subjectAltName = @alt_names 27 | [alt_names] 28 | DNS.1 = localhost 29 | ``` 30 | 31 | Generate localhost.key, localhost.csr, and localhost.crt: 32 | 33 | ```shell 34 | openssl req -new -nodes -newkey rsa:2048 -keyout localhost.key -out localhost.csr -subj "/C=US/ST=YourState/L=YourCity/O=Example-Certificates/CN=localhost.local" 35 | openssl x509 -req -sha256 -days 36135 -in localhost.csr -CA RootCA.pem -CAkey RootCA.key -CAcreateserial -extfile domains.txt -out localhost.crt 36 | ``` 37 | 38 | Note that the country / state / city / name in the first command can be 39 | customized. 40 | 41 | For testing purposes we need following files: 42 | 43 | - `RootCA.crt` 44 | - `RootCA.key` 45 | - `RootCA.pem` 46 | - `locahost.crt` 47 | - `locahost.key` 48 | -------------------------------------------------------------------------------- /examples/tls/RootCA.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDIzCCAgugAwIBAgIJAMKPPW4tsOymMA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwIBcNMTkxMDIxMTYyODIy 4 | WhgPMjExODA5MjcxNjI4MjJaMCcxCzAJBgNVBAYTAlVTMRgwFgYDVQQDDA9FeGFt 5 | cGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMH/IO 6 | 2qtHfyBKwANNPB4K0q5JVSg8XxZdRpTTlz0CwU0oRO3uHrI52raCCfVeiQutyZop 7 | eFZTDWeXGudGAFA2B5m3orWt0s+touPi8MzjsG2TQ+WSI66QgbXTNDitDDBtTVcV 8 | 5G3Ic+3SppQAYiHSekLISnYWgXLl+k5CnEfTowg6cjqjVr0KjL03cTN3H7b+6+0S 9 | ws4rYbW1j4ExR7K6BFNH6572yq5qR20E6GqlY+EcOZpw4CbCk9lS8/CWuXze/vMs 10 | OfDcc6K+B625d27wyEGZHedBomT2vAD7sBjvO8hn/DP1Qb46a8uCHR6NSfnJ7bXO 11 | G1igaIbgY1zXirNdAgMBAAGjUDBOMB0GA1UdDgQWBBTzut+pwwDfqmMYcI9KNWRD 12 | hxcIpTAfBgNVHSMEGDAWgBTzut+pwwDfqmMYcI9KNWRDhxcIpTAMBgNVHRMEBTAD 13 | AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB9AqSbZ+hEglAgSHxAMCqRFdhVu7MvaQM0 14 | P090mhGlOCt3yB7kdGfsIrUW6nQcTz7PPQFRaJMrFHPvFvPootkBUpTYR4hTkdce 15 | H6RCRu2Jxl4Y9bY/uezd9YhGCYfUtfjA6/TH9FcuZfttmOOlxOt01XfNvVMIR6RM 16 | z/AYhd+DeOXjr35F/VHeVpnk+55L0PYJsm1CdEbOs5Hy1ecR7ACuDkXnbM4fpz9I 17 | kyIWJwk2zJReKcJMgi1aIinDM9ao/dca1G99PHOw8dnr4oyoTiv8ao6PWiSRHHMi 18 | MNf4EgWfK+tZMnuqfpfO9740KzfcVoMNo4QJD4yn5YxroUOO/Azi 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /examples/tls/RootCA.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDMH/IO2qtHfyBK 3 | wANNPB4K0q5JVSg8XxZdRpTTlz0CwU0oRO3uHrI52raCCfVeiQutyZopeFZTDWeX 4 | GudGAFA2B5m3orWt0s+touPi8MzjsG2TQ+WSI66QgbXTNDitDDBtTVcV5G3Ic+3S 5 | ppQAYiHSekLISnYWgXLl+k5CnEfTowg6cjqjVr0KjL03cTN3H7b+6+0Sws4rYbW1 6 | j4ExR7K6BFNH6572yq5qR20E6GqlY+EcOZpw4CbCk9lS8/CWuXze/vMsOfDcc6K+ 7 | B625d27wyEGZHedBomT2vAD7sBjvO8hn/DP1Qb46a8uCHR6NSfnJ7bXOG1igaIbg 8 | Y1zXirNdAgMBAAECggEASvdsicILZ42ryWgtjj8G9Yick7gft9RgPU9/txnzQUDG 9 | 2oQ+Mda6M/88ShPoNpj0XhYNdS+J3KSup9MsnwvcaYtvC/9I5BbpSObq9NzlErYn 10 | +A7WkE5kfRP2OCQUsJEqc+oUkqi7HQRekp+0+VMRAuD+B9s49VkDXq0H8vS8eF/e 11 | J9nj6c/RTK+Er5ccG5jSLrSy3kiIjAN1a6OIU/YPjPx7qv8ZZ6TLeRtvc8PV++cH 12 | wB1qapZg5cuKge9UEcg+WINCkD2n9iK1jKC1ULYsiuwUR6LX9YHLUwr6S5/Dwwqc 13 | Vb9nmftqJtCz+McrqRCdfeqSNGi0tjVEX7i+DtfZrQKBgQD7firgBE7nb53VDirG 14 | W8Leo6EhCS/hCZFo0QhSBUCeOpmSaMsCzUIlqqPIBIQXir0AtBno/qXYiIJ4HgUB 15 | lScrK+7KUirEO8o4x6xC2hbPk/A7fTgf0G5Mvj2TRidiLGGIupuRHeyjigiGa0mG 16 | yWLoil6MJX44usnE49qDVy77/wKBgQDPyHThAugFSsVedxy29NQx7Zp8s/htpGHZ 17 | wYksbunz+NlO/xzRvSu2OAps/WD6F+3KhCB5fV2tESVs7u2oQPLcjmIpurDtATWE 18 | DJAAvcBl1L+cpQGN4D8zUrrZO8rw01sUZSv+kAnfsC01exzZe64+VDl3a1cYZkDT 19 | A9RmbF/AowKBgDTYVxQJc7cH6idZub1CjNkRkwsJDimARDC9M71gYyqcb6anJHlr 20 | PgoCKDYgVM1Jlttt/L/Lunecf6XT0QN7HubgbWXQDDJ9yclSk6zcfMyTbnhhoIh2 21 | 2KaBlxi6Ng5X+wqrA4NjwVS/7XipVKLg8EqiwKk8O6CaB0m7AzB0AmhrAoGAcGsi 22 | YYNzCTn1IzEKxiocjI7jYMj2hkvD7U766qFvzuI6oLUCYLAa8FHNwj4ss+Mycrmd 23 | 4F1ly3dVamSzDK9nNtGKZs1tYC2hSLqLRvtjFzVOHnBgMOS9DQWbtmDVYgrYYmaC 24 | sQ45aV8mdqMPbtOt6GclWGkpDDh2pjSSPIAyJkUCgYAHw7dKqYO/YQPKmswVZm5t 25 | TelfdJJG6GCXnFryyqo4pmEMy/i5kzF1t9Cnchhx/WeU+wGxrWd3RMP/sqP7MW9q 26 | 6Ie9Jj2vk4lUBoeFKk+kLeBUr+TkLSdcVEI0DSOdX681AUmxkVzVjGKYeiNa+V6u 27 | XmgzS8JEYoMbNEAKXYX2qg== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /examples/tls/RootCA.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDIzCCAgugAwIBAgIJAMKPPW4tsOymMA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwIBcNMTkxMDIxMTYyODIy 4 | WhgPMjExODA5MjcxNjI4MjJaMCcxCzAJBgNVBAYTAlVTMRgwFgYDVQQDDA9FeGFt 5 | cGxlLVJvb3QtQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDMH/IO 6 | 2qtHfyBKwANNPB4K0q5JVSg8XxZdRpTTlz0CwU0oRO3uHrI52raCCfVeiQutyZop 7 | eFZTDWeXGudGAFA2B5m3orWt0s+touPi8MzjsG2TQ+WSI66QgbXTNDitDDBtTVcV 8 | 5G3Ic+3SppQAYiHSekLISnYWgXLl+k5CnEfTowg6cjqjVr0KjL03cTN3H7b+6+0S 9 | ws4rYbW1j4ExR7K6BFNH6572yq5qR20E6GqlY+EcOZpw4CbCk9lS8/CWuXze/vMs 10 | OfDcc6K+B625d27wyEGZHedBomT2vAD7sBjvO8hn/DP1Qb46a8uCHR6NSfnJ7bXO 11 | G1igaIbgY1zXirNdAgMBAAGjUDBOMB0GA1UdDgQWBBTzut+pwwDfqmMYcI9KNWRD 12 | hxcIpTAfBgNVHSMEGDAWgBTzut+pwwDfqmMYcI9KNWRDhxcIpTAMBgNVHRMEBTAD 13 | AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQB9AqSbZ+hEglAgSHxAMCqRFdhVu7MvaQM0 14 | P090mhGlOCt3yB7kdGfsIrUW6nQcTz7PPQFRaJMrFHPvFvPootkBUpTYR4hTkdce 15 | H6RCRu2Jxl4Y9bY/uezd9YhGCYfUtfjA6/TH9FcuZfttmOOlxOt01XfNvVMIR6RM 16 | z/AYhd+DeOXjr35F/VHeVpnk+55L0PYJsm1CdEbOs5Hy1ecR7ACuDkXnbM4fpz9I 17 | kyIWJwk2zJReKcJMgi1aIinDM9ao/dca1G99PHOw8dnr4oyoTiv8ao6PWiSRHHMi 18 | MNf4EgWfK+tZMnuqfpfO9740KzfcVoMNo4QJD4yn5YxroUOO/Azi 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /examples/tls/domains.txt: -------------------------------------------------------------------------------- 1 | authorityKeyIdentifier=keyid,issuer 2 | basicConstraints=CA:FALSE 3 | keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment 4 | subjectAltName = @alt_names 5 | [alt_names] 6 | DNS.1 = localhost 7 | -------------------------------------------------------------------------------- /examples/tls/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDajCCAlKgAwIBAgIJAOPyQVdy/UpPMA0GCSqGSIb3DQEBCwUAMCcxCzAJBgNV 3 | BAYTAlVTMRgwFgYDVQQDDA9FeGFtcGxlLVJvb3QtQ0EwIBcNMTkxMDIxMTYyODU4 4 | WhgPMjExODA5MjcxNjI4NThaMG0xCzAJBgNVBAYTAlVTMRIwEAYDVQQIDAlZb3Vy 5 | U3RhdGUxETAPBgNVBAcMCFlvdXJDaXR5MR0wGwYDVQQKDBRFeGFtcGxlLUNlcnRp 6 | ZmljYXRlczEYMBYGA1UEAwwPbG9jYWxob3N0LmxvY2FsMIIBIjANBgkqhkiG9w0B 7 | AQEFAAOCAQ8AMIIBCgKCAQEAz9svjVdf5jihUBtofd84XKdb8dEHQRJfDNKaJ4Ar 8 | baqMHAdnqi/fWtlqEEMn8gweZ7+4hshECY5mnx4Hhy7IAbePDsTTbSm01dChhlxF 9 | uvd9QuvzvrqSjSq+v4Jlau+pQIhUzzV12dF5bFvrIrGWxCZp+W7lLDZI6Pd6Su+y 10 | ZIeiwrUaPMzdUePNf2hZI/IvWCUMCIyoqrrKHdHoPuvQCW17IyxsnFQJNbmN+Rtp 11 | BQilhtwvBbggCBWhHxEdiqBaZHDw6Zl+bU7ejx1mu9A95wpQ9SCL2cRkAlz2LDOy 12 | wznrTAwGcvqvFKxlV+3HsaD7rba4kCA1Ihp5mm/dS2k94QIDAQABo1EwTzAfBgNV 13 | HSMEGDAWgBTzut+pwwDfqmMYcI9KNWRDhxcIpTAJBgNVHRMEAjAAMAsGA1UdDwQE 14 | AwIE8DAUBgNVHREEDTALgglsb2NhbGhvc3QwDQYJKoZIhvcNAQELBQADggEBAKVu 15 | vVpu5nPGAGn1SX4FQUcbn9Z5wgBkjnZxfJHJQX4sYIRlcirZviPHCZGPWex4VHC+ 16 | lFMm+70YEN2uoe5jGrdgcugzx2Amc7/mLrsvvpMsaS0PlxNMcqhdM1WHbGjjdNln 17 | XICVITSKnB1fSGH6uo9CMCWw5kgPS9o4QWrLLkxnds3hoz7gVEUyi/6V65mcfFNA 18 | lof9iKcK9JsSHdBs35vpv7UKLX+96RM7Nm2Mu0yue5JiS79/zuMA/Kryxot4jv5z 19 | ecdWFl0eIyQBZmBzMw2zPUqkxEnXLiKjV8jutEg/4qovTOB6YiA41qbARXdzNA2V 20 | FYuchcTcWmnmVVRFyyU= 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /examples/tls/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDP2y+NV1/mOKFQ 3 | G2h93zhcp1vx0QdBEl8M0pongCttqowcB2eqL99a2WoQQyfyDB5nv7iGyEQJjmaf 4 | HgeHLsgBt48OxNNtKbTV0KGGXEW6931C6/O+upKNKr6/gmVq76lAiFTPNXXZ0Xls 5 | W+sisZbEJmn5buUsNkjo93pK77Jkh6LCtRo8zN1R481/aFkj8i9YJQwIjKiqusod 6 | 0eg+69AJbXsjLGycVAk1uY35G2kFCKWG3C8FuCAIFaEfER2KoFpkcPDpmX5tTt6P 7 | HWa70D3nClD1IIvZxGQCXPYsM7LDOetMDAZy+q8UrGVX7cexoPuttriQIDUiGnma 8 | b91LaT3hAgMBAAECggEBAJABfn+BQorBP1m9s3ZJmcXvmW7+7/SwYrQCkRS+4te2 9 | 6h1dMAAj7K4HpUkhDeLPbJ1aoeCXjTPFuemRp4uL6Lvvzahgy059L7FXOyFYemMf 10 | pmQgDx5cKr6tF7yc/eDJrExuZ7urgTvouiRNxqmhuh+psZBDuXkZHwhwtQSH7uNg 11 | KBDKu0qWO73vFLcLckdGEU3+H9oIWs5xcvvOkWzyvHbRGFJSihgcRpPPHodF5xB9 12 | T/gZIoJHMmCbUMlWaSasUyNXTuvCnkvBDol8vXrMJCVzKZj9GpPDcIFdc08GSn4I 13 | pTdSNwzUcHbdERzdVU28Xt+t6W5rvp/4FWrssi4IzkUCgYEA//ZcEcBguRD4OFrx 14 | 6wbSjzCcUW1NWhzA8uTOORZi4SvndcH1cU4S2wznuHNubU1XlrGwJX6PUGebmY/l 15 | 53B5PJvStbVtZCVIxllR+ZVzRuL8wLodRHzlYH8GOzHwoa4ivSupkzl72ij1u/tI 16 | NMLGfYEKVdNd8zXIESUY88NszvsCgYEAz+MDp3xOhFaCe+CPv80A592cJcfzc8Al 17 | +rahEOu+VdN2QBZf86PIf2Bfv/t0QvnRvs1z648TuH6h83YSggOAbmfHyd789jkq 18 | UWlktIaXbVn+VaHmPTcBWTg3ZTlvG+fiFCbZXiYhm+UUf1MDqZHdiifAoyVIjV/Z 19 | YhCNJo3q39MCgYEAknrpK5t9fstwUcfyA/9OhnVaL9suVjB4V0iLn+3ovlXCywgp 20 | ryLv9X3IKi2c9144jtu3I23vFCOGz3WjKzSZnQ7LogNmy9XudNxu5jcZ1mpWHPEl 21 | iKk1F2j6Juwoek5OQRX4oHFYKHwiTOa75r3Em9Q6Fu20KVgQ24bwZafj3/sCgYAy 22 | k0AoVw2jFIjaKl/Ogclen4OFjYek+XJD9Hpq62964d866Dafx5DXrFKfGkXGpZBp 23 | owI4pK5fjC9KU8dc6g0szwLEEgPowy+QbtuZL8VXTTWbD7A75E3nrs2LStXFLDzM 24 | OkdXqF801h6Oe1vAvUPwgItVJZTpEBCK0wwD/TLPEQKBgQDRkhlTtAoHW7W6STd0 25 | A/OWc0dxhzMurpxg0bLgCqUjw1ESGrSCGhffFn0IWa8sv19VWsZuBhTgjNatZsYB 26 | AhDs/6OosT/3nJoh2/t0hYDj1FBI0lPXWYD4pesuZ5yIMrmSaAOtIzp4BGY7ui8N 27 | wOqcq/jdiHj/MKEdqOXy3YAJrA== 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /fixtures/.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "hidden": "file" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/.test/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "inside": "hidden" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/test file.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello World! 5 | 6 | 7 | -------------------------------------------------------------------------------- /fixtures/test.importmap: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "lodash": "https://esm.sh/lodash" 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /fixtures/test.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oakserver/oak/baa38fa8a562fc197407e96be6ff396f760fd6f2/fixtures/test.jpg -------------------------------------------------------------------------------- /fixtures/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "hello": "world" 3 | } 4 | -------------------------------------------------------------------------------- /fixtures/test.json.br: -------------------------------------------------------------------------------- 1 | !X{ 2 | "hello": "world" 3 | } 4 |  -------------------------------------------------------------------------------- /fixtures/test.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oakserver/oak/baa38fa8a562fc197407e96be6ff396f760fd6f2/fixtures/test.json.gz -------------------------------------------------------------------------------- /http_server_bun.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // deno-lint-ignore-file no-explicit-any 4 | 5 | import { assert } from "./deps.ts"; 6 | import { assertEquals } from "./deps_test.ts"; 7 | import { createMockApp } from "./testing.ts"; 8 | 9 | import { Server } from "./http_server_bun.ts"; 10 | 11 | interface SocketAddress { 12 | address: string; 13 | port: number; 14 | family: "IPv4" | "IPv6"; 15 | } 16 | 17 | let currentServer: MockBunServer | undefined; 18 | let requests: Request[] = []; 19 | 20 | class MockBunServer { 21 | stoppedCount = 0; 22 | fetch: ( 23 | req: Request, 24 | server: this, 25 | ) => Response | Promise; 26 | responses: Response[] = []; 27 | runPromise: Promise; 28 | 29 | development: boolean; 30 | hostname: string; 31 | port: number; 32 | pendingRequests = 0; 33 | 34 | async #run() { 35 | for (const req of requests) { 36 | const res = await this.fetch(req, this); 37 | this.responses.push(res); 38 | } 39 | } 40 | 41 | constructor( 42 | { fetch, hostname, port, development }: { 43 | fetch: ( 44 | req: Request, 45 | server: unknown, 46 | ) => Response | Promise; 47 | hostname?: string; 48 | port?: number; 49 | development?: boolean; 50 | error?: (error: Error) => Response | Promise; 51 | tls?: { 52 | key?: string; 53 | cert?: string; 54 | }; 55 | }, 56 | ) { 57 | this.fetch = fetch; 58 | this.development = development ?? false; 59 | this.hostname = hostname ?? "localhost"; 60 | this.port = port ?? 567890; 61 | currentServer = this; 62 | this.runPromise = this.#run(); 63 | } 64 | 65 | requestIP(_req: Request): SocketAddress | null { 66 | return { address: "127.0.0.0", port: 567890, family: "IPv4" }; 67 | } 68 | 69 | stop(): void { 70 | this.stoppedCount++; 71 | } 72 | } 73 | 74 | function setup(reqs?: Request[]) { 75 | if (reqs) { 76 | requests = reqs; 77 | } 78 | (globalThis as any)["Bun"] = { 79 | serve(options: any) { 80 | return new MockBunServer(options); 81 | }, 82 | }; 83 | } 84 | 85 | function teardown() { 86 | delete (globalThis as any)["Bun"]; 87 | currentServer = undefined; 88 | } 89 | 90 | Deno.test({ 91 | name: "bun server can listen", 92 | async fn() { 93 | setup(); 94 | const server = new Server(createMockApp(), { port: 8080 }); 95 | const listener = await server.listen(); 96 | assertEquals(listener, { addr: { hostname: "localhost", port: 8080 } }); 97 | assert(currentServer); 98 | assertEquals(currentServer.stoppedCount, 0); 99 | await server.close(); 100 | assertEquals(currentServer.stoppedCount, 1); 101 | teardown(); 102 | }, 103 | }); 104 | 105 | Deno.test({ 106 | name: "bun server can process requests", 107 | // this is working but there is some sort of hanging promise somewhere I can't 108 | // narrow down at the moment 109 | ignore: true, 110 | async fn() { 111 | setup([new Request(new URL("http://localhost:8080/"))]); 112 | const server = new Server(createMockApp(), { port: 8080 }); 113 | const listener = await server.listen(); 114 | assertEquals(listener, { addr: { hostname: "localhost", port: 8080 } }); 115 | assert(currentServer); 116 | for await (const req of server) { 117 | assert(!req.body); 118 | assertEquals(req.url, "/"); 119 | await req.respond(new Response("hello world")); 120 | } 121 | await server.close(); 122 | await currentServer.runPromise; 123 | assertEquals(currentServer.stoppedCount, 1); 124 | assertEquals(currentServer.responses.length, 1); 125 | teardown(); 126 | }, 127 | }); 128 | 129 | Deno.test({ 130 | name: "bun server closes on abort signal", 131 | // this is working but there is some sort of hanging promise somewhere I can't 132 | // narrow down at the moment 133 | ignore: true, 134 | async fn() { 135 | setup([new Request(new URL("http://localhost:8080/"))]); 136 | const controller = new AbortController(); 137 | const { signal } = controller; 138 | const server = new Server(createMockApp(), { port: 8080, signal }); 139 | const listener = await server.listen(); 140 | assertEquals(listener, { addr: { hostname: "localhost", port: 8080 } }); 141 | assert(currentServer); 142 | for await (const req of server) { 143 | assert(!req.body); 144 | assertEquals(req.url, "/"); 145 | await req.respond(new Response("hello world")); 146 | } 147 | controller.abort(); 148 | await currentServer.runPromise; 149 | assertEquals(currentServer.stoppedCount, 1); 150 | assertEquals(currentServer.responses.length, 1); 151 | teardown(); 152 | }, 153 | }); 154 | -------------------------------------------------------------------------------- /http_server_bun.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** The abstraction that oak uses when dealing with requests and responses 4 | * within the Bun runtime that leverages the built in HTTP server. 5 | * 6 | * @module 7 | */ 8 | 9 | import type { Application } from "./application.ts"; 10 | import type { 11 | Listener, 12 | OakServer, 13 | ServeOptions, 14 | ServerRequest, 15 | ServeTlsOptions, 16 | } from "./types.ts"; 17 | import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; 18 | 19 | type TypedArray = 20 | | Uint8Array 21 | | Uint16Array 22 | | Uint32Array 23 | | Int8Array 24 | | Int16Array 25 | | Int32Array 26 | | Float32Array 27 | | Float64Array 28 | | BigInt64Array 29 | | BigUint64Array 30 | | Uint8ClampedArray; 31 | type BunFile = File; 32 | 33 | interface Bun { 34 | serve(options: { 35 | fetch: (req: Request, server: BunServer) => Response | Promise; 36 | hostname?: string; 37 | port?: number; 38 | development?: boolean; 39 | error?: (error: Error) => Response | Promise; 40 | tls?: { 41 | key?: 42 | | string 43 | | TypedArray 44 | | BunFile 45 | | Array; 46 | cert?: 47 | | string 48 | | TypedArray 49 | | BunFile 50 | | Array; 51 | ca?: string | TypedArray | BunFile | Array; 52 | passphrase?: string; 53 | dhParamsFile?: string; 54 | }; 55 | maxRequestBodySize?: number; 56 | lowMemoryMode?: boolean; 57 | }): BunServer; 58 | } 59 | 60 | interface BunServer { 61 | development: boolean; 62 | hostname: string; 63 | port: number; 64 | pendingRequests: number; 65 | requestIP(req: Request): SocketAddress | null; 66 | stop(): void; 67 | upgrade(req: Request, options?: { 68 | headers?: HeadersInit; 69 | //deno-lint-ignore no-explicit-any 70 | data?: any; 71 | }): boolean; 72 | } 73 | 74 | interface SocketAddress { 75 | address: string; 76 | port: number; 77 | family: "IPv4" | "IPv6"; 78 | } 79 | 80 | declare const Bun: Bun; 81 | 82 | function isServeTlsOptions( 83 | value: Omit, 84 | ): value is Omit { 85 | return !!("cert" in value && "key" in value); 86 | } 87 | 88 | class BunRequest implements ServerRequest { 89 | #hostname: string | undefined; 90 | // deno-lint-ignore no-explicit-any 91 | #reject: (reason?: any) => void; 92 | #request: Request; 93 | #resolve: (value: Response) => void; 94 | #resolved = false; 95 | #promise: Promise; 96 | 97 | get body(): ReadableStream | null { 98 | return this.#request.body; 99 | } 100 | 101 | get headers(): Headers { 102 | return this.#request.headers; 103 | } 104 | 105 | get method(): string { 106 | return this.#request.method; 107 | } 108 | 109 | get remoteAddr(): string | undefined { 110 | return this.#hostname; 111 | } 112 | 113 | get request(): Request { 114 | return this.#request; 115 | } 116 | 117 | get response(): Promise { 118 | return this.#promise; 119 | } 120 | 121 | get url(): string { 122 | try { 123 | const url = new URL(this.#request.url); 124 | return this.#request.url.replace(url.origin, ""); 125 | } catch { 126 | // we don't care about errors, we just want to fall back 127 | } 128 | return this.#request.url; 129 | } 130 | 131 | get rawUrl(): string { 132 | return this.#request.url; 133 | } 134 | 135 | constructor(request: Request, server: BunServer) { 136 | this.#request = request; 137 | this.#hostname = server.requestIP(request)?.address; 138 | const { resolve, reject, promise } = createPromiseWithResolvers(); 139 | this.#resolve = resolve; 140 | this.#reject = reject; 141 | this.#promise = promise; 142 | } 143 | 144 | // deno-lint-ignore no-explicit-any 145 | error(reason?: any): void { 146 | if (this.#resolved) { 147 | throw new Error("Request already responded to."); 148 | } 149 | this.#resolved = true; 150 | this.#reject(reason); 151 | } 152 | 153 | getBody(): ReadableStream | null { 154 | return this.#request.body; 155 | } 156 | 157 | respond(response: Response): void | Promise { 158 | if (this.#resolved) { 159 | throw new Error("Request already responded to."); 160 | } 161 | this.#resolved = true; 162 | this.#resolve(response); 163 | } 164 | } 165 | 166 | /** An implementation of the oak server abstraction for handling requests on 167 | * Bun using the built in Bun http server. */ 168 | export class Server implements OakServer { 169 | #options: ServeOptions | ServeTlsOptions; 170 | #server?: BunServer; 171 | #stream?: ReadableStream; 172 | 173 | constructor( 174 | _app: Application, 175 | options: ServeOptions | ServeTlsOptions, 176 | ) { 177 | this.#options = options; 178 | } 179 | 180 | close(): void | Promise { 181 | if (this.#server) { 182 | this.#server.stop(); 183 | } 184 | } 185 | 186 | listen(): Listener | Promise { 187 | if (this.#server) { 188 | throw new Error("Server already listening."); 189 | } 190 | const { onListen, hostname, port, signal } = this.#options; 191 | const tls = isServeTlsOptions(this.#options) 192 | ? { key: this.#options.key, cert: this.#options.cert } 193 | : undefined; 194 | const { promise, resolve } = createPromiseWithResolvers(); 195 | this.#stream = new ReadableStream({ 196 | start: (controller) => { 197 | this.#server = Bun.serve({ 198 | fetch(req, server) { 199 | const request = new BunRequest(req, server); 200 | controller.enqueue(request); 201 | return request.response; 202 | }, 203 | hostname, 204 | port, 205 | tls, 206 | }); 207 | signal?.addEventListener("abort", () => { 208 | controller.close(); 209 | this.close(); 210 | }, { once: true }); 211 | { 212 | const { hostname, port } = this.#server; 213 | if (onListen) { 214 | onListen({ hostname, port }); 215 | } 216 | resolve({ addr: { hostname, port } }); 217 | } 218 | }, 219 | }); 220 | return promise; 221 | } 222 | 223 | [Symbol.asyncIterator](): AsyncIterableIterator { 224 | if (!this.#stream) { 225 | throw new TypeError("Server hasn't started listening."); 226 | } 227 | return this.#stream[Symbol.asyncIterator](); 228 | } 229 | 230 | static type: "bun" = "bun"; 231 | } 232 | -------------------------------------------------------------------------------- /http_server_native.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals, assertStrictEquals, unreachable } from "./deps_test.ts"; 4 | 5 | import { Server } from "./http_server_native.ts"; 6 | import { NativeRequest } from "./http_server_native_request.ts"; 7 | 8 | import { Application } from "./application.ts"; 9 | import { isNode } from "./utils/type_guards.ts"; 10 | 11 | function createMockNetAddr(): Deno.NetAddr { 12 | return { transport: "tcp", hostname: "remote", port: 4567 }; 13 | } 14 | 15 | Deno.test({ 16 | name: "NativeRequest", 17 | ignore: isNode(), 18 | async fn() { 19 | const respondWithStack: Array> = []; 20 | const request = new Request("http://localhost:8000/", { 21 | method: "POST", 22 | body: `{"a":"b"}`, 23 | }); 24 | const remoteAddr = createMockNetAddr(); 25 | const nativeRequest = new NativeRequest(request, { remoteAddr }); 26 | assertEquals(nativeRequest.url, `/`); 27 | const response = new Response("hello deno"); 28 | nativeRequest.respond(response); 29 | respondWithStack.push(await nativeRequest.response); 30 | assertStrictEquals(await respondWithStack[0], response); 31 | }, 32 | }); 33 | 34 | Deno.test({ 35 | name: "HttpServer closes gracefully after serving requests", 36 | ignore: isNode(), 37 | async fn() { 38 | const abortController = new AbortController(); 39 | const app = new Application(); 40 | const listenOptions = { port: 4505, signal: abortController.signal }; 41 | 42 | const server = new Server(app, listenOptions); 43 | server.listen(); 44 | 45 | const expectedBody = "test-body"; 46 | 47 | (async () => { 48 | for await (const nativeRequest of server) { 49 | nativeRequest.respond(new Response(expectedBody)); 50 | } 51 | })(); 52 | 53 | try { 54 | const response = await fetch(`http://localhost:${listenOptions.port}`); 55 | assertEquals(await response.text(), expectedBody); 56 | } catch (e) { 57 | console.error(e); 58 | unreachable(); 59 | } finally { 60 | abortController.abort(); 61 | } 62 | }, 63 | }); 64 | 65 | Deno.test({ 66 | name: 67 | "HttpServer manages errors from mis-use in the application handler gracefully", 68 | ignore: isNode(), 69 | async fn() { 70 | const app = new Application(); 71 | const listenOptions = { port: 4506 }; 72 | 73 | const server = new Server(app, listenOptions); 74 | server.listen(); 75 | 76 | (async () => { 77 | for await (const nativeRequest of server) { 78 | // deno-lint-ignore no-explicit-any 79 | nativeRequest.respond(null as any); 80 | } 81 | })(); 82 | 83 | const res = await fetch(`http://localhost:${listenOptions.port}`); 84 | assertEquals(res.status, 500); 85 | await res.text(); 86 | return server.close(); 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /http_server_native.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** The abstraction that oak uses when dealing with requests and responses 4 | * within the Deno runtime. 5 | * 6 | * @module 7 | */ 8 | 9 | import type { Application, State } from "./application.ts"; 10 | import { NativeRequest } from "./http_server_native_request.ts"; 11 | import type { 12 | HttpServer, 13 | Listener, 14 | OakServer, 15 | ServeInit, 16 | ServeOptions, 17 | ServeTlsOptions, 18 | } from "./types.ts"; 19 | import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; 20 | 21 | const serve: 22 | | (( 23 | options: ServeInit & (ServeOptions | ServeTlsOptions), 24 | ) => HttpServer) 25 | | undefined = "Deno" in globalThis && "serve" in globalThis.Deno 26 | ? globalThis.Deno.serve.bind(globalThis.Deno) 27 | : undefined; 28 | 29 | /** The oak abstraction of the Deno native HTTP server which is used internally 30 | * for handling native HTTP requests. Generally users of oak do not need to 31 | * worry about this class. */ 32 | // deno-lint-ignore no-explicit-any 33 | export class Server> 34 | implements OakServer { 35 | #app: Application; 36 | #closed = false; 37 | #httpServer?: HttpServer; 38 | #options: ServeOptions | ServeTlsOptions; 39 | #stream?: ReadableStream; 40 | 41 | constructor( 42 | app: Application, 43 | options: Omit, 44 | ) { 45 | if (!serve) { 46 | throw new Error( 47 | "The native bindings for serving HTTP are not available.", 48 | ); 49 | } 50 | this.#app = app; 51 | this.#options = options; 52 | } 53 | 54 | get app(): Application { 55 | return this.#app; 56 | } 57 | 58 | get closed(): boolean { 59 | return this.#closed; 60 | } 61 | 62 | async close(): Promise { 63 | if (this.#closed) { 64 | return; 65 | } 66 | 67 | if (this.#httpServer) { 68 | this.#httpServer.unref(); 69 | await this.#httpServer.shutdown(); 70 | this.#httpServer = undefined; 71 | } 72 | this.#closed = true; 73 | } 74 | 75 | listen(): Promise { 76 | if (this.#httpServer) { 77 | throw new Error("Server already listening."); 78 | } 79 | const { signal } = this.#options; 80 | const { onListen, ...options } = this.#options; 81 | const { promise, resolve } = createPromiseWithResolvers(); 82 | this.#stream = new ReadableStream({ 83 | start: (controller) => { 84 | this.#httpServer = serve?.({ 85 | handler: (req, info) => { 86 | const nativeRequest = new NativeRequest(req, info); 87 | controller.enqueue(nativeRequest); 88 | return nativeRequest.response; 89 | }, 90 | onListen({ hostname, port }) { 91 | if (onListen) { 92 | onListen({ hostname, port }); 93 | } 94 | resolve({ addr: { hostname, port } }); 95 | }, 96 | signal, 97 | ...options, 98 | }); 99 | }, 100 | }); 101 | 102 | signal?.addEventListener("abort", () => this.close(), { once: true }); 103 | return promise; 104 | } 105 | 106 | [Symbol.asyncIterator](): AsyncIterableIterator { 107 | if (!this.#stream) { 108 | throw new TypeError("Server hasn't started listening."); 109 | } 110 | return this.#stream[Symbol.asyncIterator](); 111 | } 112 | 113 | static type: "native" = "native"; 114 | } 115 | -------------------------------------------------------------------------------- /http_server_native_request.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import type { 4 | NetAddr, 5 | ServerRequest, 6 | UpgradeWebSocketFn, 7 | UpgradeWebSocketOptions, 8 | } from "./types.ts"; 9 | import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; 10 | 11 | // deno-lint-ignore no-explicit-any 12 | export const DomResponse: typeof Response = (globalThis as any).Response ?? 13 | class MockResponse {}; 14 | 15 | const maybeUpgradeWebSocket: UpgradeWebSocketFn | undefined = 16 | "Deno" in globalThis && "upgradeWebSocket" in globalThis.Deno 17 | // deno-lint-ignore no-explicit-any 18 | ? (Deno as any).upgradeWebSocket.bind(Deno) 19 | : undefined; 20 | 21 | export function isNativeRequest(r: ServerRequest): r is NativeRequest { 22 | return r instanceof NativeRequest; 23 | } 24 | 25 | export interface NativeRequestInfo { 26 | remoteAddr?: NetAddr; 27 | upgradeWebSocket?: UpgradeWebSocketFn; 28 | } 29 | 30 | /** An internal oak abstraction for handling a Deno native request. Most users 31 | * of oak do not need to worry about this abstraction. */ 32 | export class NativeRequest implements ServerRequest { 33 | #remoteAddr?: NetAddr; 34 | // deno-lint-ignore no-explicit-any 35 | #reject: (reason?: any) => void; 36 | #request: Request; 37 | #resolve: (value: Response) => void; 38 | #resolved = false; 39 | #response: Promise; 40 | #upgradeWebSocket?: UpgradeWebSocketFn; 41 | 42 | constructor( 43 | request: Request, 44 | info: NativeRequestInfo, 45 | ) { 46 | this.#remoteAddr = info.remoteAddr; 47 | // this allows for the value to be explicitly undefined in the options 48 | this.#upgradeWebSocket = "upgradeWebSocket" in info 49 | ? info.upgradeWebSocket 50 | : maybeUpgradeWebSocket; 51 | this.#request = request; 52 | const { resolve, reject, promise } = createPromiseWithResolvers(); 53 | this.#resolve = resolve; 54 | this.#reject = reject; 55 | this.#response = promise; 56 | } 57 | 58 | get body(): ReadableStream | null { 59 | return this.#request.body; 60 | } 61 | 62 | get headers(): Headers { 63 | return this.#request.headers; 64 | } 65 | 66 | get method(): string { 67 | return this.#request.method; 68 | } 69 | 70 | get remoteAddr(): string | undefined { 71 | return this.#remoteAddr?.hostname; 72 | } 73 | 74 | get request(): Request { 75 | return this.#request; 76 | } 77 | 78 | get response(): Promise { 79 | return this.#response; 80 | } 81 | 82 | get url(): string { 83 | try { 84 | const url = new URL(this.#request.url); 85 | return this.#request.url.replace(url.origin, ""); 86 | } catch { 87 | // we don't care about errors, we just want to fall back 88 | } 89 | return this.#request.url; 90 | } 91 | 92 | get rawUrl(): string { 93 | return this.#request.url; 94 | } 95 | 96 | // deno-lint-ignore no-explicit-any 97 | error(reason?: any): void { 98 | if (this.#resolved) { 99 | throw new Error("Request already responded to."); 100 | } 101 | this.#reject(reason); 102 | this.#resolved = true; 103 | } 104 | 105 | getBody(): ReadableStream | null { 106 | return this.#request.body; 107 | } 108 | 109 | respond(response: Response): void { 110 | if (this.#resolved) { 111 | throw new Error("Request already responded to."); 112 | } 113 | this.#resolved = true; 114 | this.#resolve(response); 115 | } 116 | 117 | upgrade(options?: UpgradeWebSocketOptions): WebSocket { 118 | if (this.#resolved) { 119 | throw new Error("Request already responded to."); 120 | } 121 | if (!this.#upgradeWebSocket) { 122 | throw new TypeError("Upgrading web sockets not supported."); 123 | } 124 | const { response, socket } = this.#upgradeWebSocket( 125 | this.#request, 126 | options, 127 | ); 128 | this.#resolve(response); 129 | this.#resolved = true; 130 | return socket; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /http_server_node.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // deno-lint-ignore-file no-explicit-any 4 | 5 | import { assertEquals, unreachable } from "./deps_test.ts"; 6 | 7 | import { 8 | type IncomingMessage, 9 | NodeRequest, 10 | Server, 11 | type ServerResponse, 12 | } from "./http_server_node.ts"; 13 | 14 | import { Application } from "./application.ts"; 15 | 16 | const destroyCalls: any[][] = []; 17 | const setHeaderCalls: any[][] = []; 18 | const writeCalls: any[][] = []; 19 | const writeHeadCalls: any[][] = []; 20 | 21 | function createMockReqRes( 22 | url = "/", 23 | headers: Record = {}, 24 | method = "GET", 25 | address = "127.0.0.1", 26 | ): [req: IncomingMessage, res: ServerResponse] { 27 | destroyCalls.length = 0; 28 | setHeaderCalls.length = 0; 29 | writeCalls.length = 0; 30 | writeHeadCalls.length = 0; 31 | const req = { 32 | headers, 33 | method, 34 | socket: { 35 | address() { 36 | return { 37 | addr: { 38 | address, 39 | }, 40 | }; 41 | }, 42 | }, 43 | url, 44 | on(_method: string, _listener: (arg?: any) => void) {}, 45 | }; 46 | const res = { 47 | destroy(...args: any[]) { 48 | destroyCalls.push(args); 49 | }, 50 | end(callback?: () => void) { 51 | if (callback) { 52 | callback(); 53 | } 54 | }, 55 | setHeader(...args: any[]) { 56 | setHeaderCalls.push(args); 57 | }, 58 | write(chunk: unknown, callback?: (err: Error | null) => void) { 59 | writeCalls.push([chunk, callback]); 60 | if (callback) { 61 | callback(null); 62 | } 63 | }, 64 | writeHead(...args: any[]) { 65 | writeHeadCalls.push(args); 66 | }, 67 | }; 68 | return [req, res]; 69 | } 70 | 71 | Deno.test({ 72 | name: "NodeRequest", 73 | async fn() { 74 | const nodeRequest = new NodeRequest( 75 | ...createMockReqRes("/", {}, "POST", "127.0.0.1"), 76 | ); 77 | assertEquals(nodeRequest.url, `/`); 78 | const response = new Response("hello deno"); 79 | await nodeRequest.respond(response); 80 | assertEquals(writeHeadCalls, [[200, ""]]); 81 | }, 82 | }); 83 | 84 | Deno.test({ 85 | name: "HttpServer closes gracefully after serving requests", 86 | // TODO(@kitsonk) this is failing locally for me, figure out what is wrong. 87 | ignore: true, 88 | async fn() { 89 | const app = new Application(); 90 | const listenOptions = { port: 4508 }; 91 | 92 | const server = new Server(app, listenOptions); 93 | await server.listen(); 94 | 95 | const expectedBody = "test-body"; 96 | 97 | (async () => { 98 | for await (const nodeRequest of server) { 99 | nodeRequest.respond(new Response(expectedBody)); 100 | } 101 | })(); 102 | 103 | try { 104 | const response = await fetch(`http://localhost:${listenOptions.port}`); 105 | assertEquals(await response.text(), expectedBody); 106 | } catch { 107 | unreachable(); 108 | } finally { 109 | server.close(); 110 | } 111 | }, 112 | }); 113 | -------------------------------------------------------------------------------- /http_server_node.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** The abstraction that oak uses when dealing with requests and responses 4 | * within the Node.js runtime. 5 | * 6 | * @module 7 | */ 8 | 9 | import type { 10 | Listener, 11 | OakServer, 12 | ServeOptions, 13 | ServerRequest, 14 | ServeTlsOptions, 15 | } from "./types.ts"; 16 | import { createPromiseWithResolvers } from "./utils/create_promise_with_resolvers.ts"; 17 | 18 | // There are quite a few differences between Deno's `std/node/http` and the 19 | // typings for Node.js for `"http"`. Since we develop everything in Deno, but 20 | // type check in Deno and Node.js we have to provide the API surface we depend 21 | // on here, instead of accepting what comes in via the import. 22 | export type IncomingMessage = { 23 | headers: Record; 24 | method: string | null; 25 | socket: { 26 | address(): { 27 | addr: null | { 28 | address: string; 29 | }; 30 | }; 31 | }; 32 | url: string | null; 33 | 34 | on(method: "data", listener: (chunk: Uint8Array) => void): void; 35 | on(method: "error", listener: (err: Error) => void): void; 36 | on(method: "end", listener: () => void): void; 37 | }; 38 | type NodeHttpServer = { 39 | listen(options: { port: number; host: string; signal: AbortSignal }): void; 40 | }; 41 | export type ServerResponse = { 42 | destroy(error?: Error): void; 43 | end(callback?: () => void): void; 44 | setHeader(key: string, value: string): void; 45 | write(chunk: unknown, callback?: (err: Error | null) => void): void; 46 | writeHead(status: number, statusText?: string): void; 47 | }; 48 | 49 | export class NodeRequest implements ServerRequest { 50 | #request: IncomingMessage; 51 | #response: ServerResponse; 52 | #responded = false; 53 | 54 | get remoteAddr(): string | undefined { 55 | const addr = this.#request.socket.address(); 56 | // deno-lint-ignore no-explicit-any 57 | return addr && (addr as any)?.address; 58 | } 59 | 60 | get headers(): Headers { 61 | return new Headers(this.#request.headers as Record); 62 | } 63 | 64 | get method(): string { 65 | return this.#request.method ?? "GET"; 66 | } 67 | 68 | get url(): string { 69 | return this.#request.url ?? ""; 70 | } 71 | 72 | constructor( 73 | request: IncomingMessage, 74 | response: ServerResponse, 75 | ) { 76 | this.#request = request; 77 | this.#response = response; 78 | } 79 | 80 | // deno-lint-ignore no-explicit-any 81 | error(reason?: any) { 82 | if (this.#responded) { 83 | throw new Error("Request already responded to."); 84 | } 85 | let error; 86 | if (reason) { 87 | error = reason instanceof Error ? reason : new Error(String(reason)); 88 | } 89 | this.#response.destroy(error); 90 | this.#responded = true; 91 | } 92 | 93 | getBody(): ReadableStream | null { 94 | let body: ReadableStream | null; 95 | if (this.method === "GET" || this.method === "HEAD") { 96 | body = null; 97 | } else { 98 | body = new ReadableStream({ 99 | start: (controller) => { 100 | this.#request.on("data", (chunk: Uint8Array) => { 101 | controller.enqueue(chunk); 102 | }); 103 | this.#request.on("error", (err: Error) => { 104 | controller.error(err); 105 | }); 106 | this.#request.on("end", () => { 107 | controller.close(); 108 | }); 109 | }, 110 | }); 111 | } 112 | return body; 113 | } 114 | 115 | async respond(response: Response) { 116 | if (this.#responded) { 117 | throw new Error("Requested already responded to."); 118 | } 119 | for (const [key, value] of response.headers) { 120 | this.#response.setHeader(key, value); 121 | } 122 | this.#response.writeHead(response.status, response.statusText); 123 | if (response.body) { 124 | for await (const chunk of response.body) { 125 | const { promise, resolve, reject } = createPromiseWithResolvers(); 126 | // deno-lint-ignore no-explicit-any 127 | this.#response.write(chunk, (err: any) => { 128 | if (err) { 129 | reject(err); 130 | } else { 131 | resolve(); 132 | } 133 | }); 134 | await promise; 135 | } 136 | } 137 | const { promise, resolve } = createPromiseWithResolvers(); 138 | this.#response.end(resolve); 139 | await promise; 140 | this.#responded = true; 141 | } 142 | } 143 | 144 | export class Server implements OakServer { 145 | #abortController = new AbortController(); 146 | #host: string; 147 | #port: number; 148 | #requestStream: ReadableStream | undefined; 149 | 150 | constructor( 151 | _app: unknown, 152 | options: ServeOptions | ServeTlsOptions, 153 | ) { 154 | this.#host = options.hostname ?? "127.0.0.1"; 155 | this.#port = options.port ?? 80; 156 | options.signal?.addEventListener("abort", () => { 157 | this.close(); 158 | }, { once: true }); 159 | } 160 | 161 | close(): void { 162 | this.#abortController.abort(); 163 | } 164 | 165 | async listen(): Promise { 166 | const { createServer } = await import("node:http"); 167 | let server: NodeHttpServer; 168 | this.#requestStream = new ReadableStream({ 169 | start: (controller) => { 170 | server = createServer((req, res) => { 171 | // deno-lint-ignore no-explicit-any 172 | controller.enqueue(new NodeRequest(req as any, res as any)); 173 | }); 174 | this.#abortController.signal.addEventListener( 175 | "abort", 176 | () => controller.close(), 177 | { once: true }, 178 | ); 179 | }, 180 | }); 181 | server!.listen({ 182 | port: this.#port, 183 | host: this.#host, 184 | signal: this.#abortController.signal, 185 | }); 186 | return { 187 | addr: { 188 | port: this.#port, 189 | hostname: this.#host, 190 | }, 191 | }; 192 | } 193 | 194 | [Symbol.asyncIterator](): AsyncIterableIterator { 195 | if (!this.#requestStream) { 196 | throw new TypeError("stream not properly initialized"); 197 | } 198 | return this.#requestStream[Symbol.asyncIterator](); 199 | } 200 | 201 | static type: "node" = "node"; 202 | } 203 | -------------------------------------------------------------------------------- /middleware.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // deno-lint-ignore-file 4 | 5 | import { assertEquals, assertStrictEquals } from "./deps_test.ts"; 6 | import { assert, errors } from "./deps.ts"; 7 | import { createMockContext } from "./testing.ts"; 8 | import { 9 | compose, 10 | isMiddlewareObject, 11 | type Middleware, 12 | type MiddlewareObject, 13 | type Next, 14 | } from "./middleware.ts"; 15 | import { Context } from "./context.ts"; 16 | 17 | Deno.test({ 18 | name: "test compose()", 19 | async fn() { 20 | const callStack: number[] = []; 21 | const mockContext = createMockContext(); 22 | const mw1: Middleware = async (context, next) => { 23 | assertStrictEquals(context, mockContext); 24 | assertEquals(typeof next, "function"); 25 | callStack.push(1); 26 | await next(); 27 | }; 28 | const mw2: Middleware = async (context, next) => { 29 | assertStrictEquals(context, mockContext); 30 | assertEquals(typeof next, "function"); 31 | callStack.push(2); 32 | await next(); 33 | }; 34 | await compose([mw1, mw2])(mockContext); 35 | assertEquals(callStack, [1, 2]); 36 | }, 37 | }); 38 | 39 | Deno.test({ 40 | name: "isMiddlewareObject()", 41 | async fn() { 42 | class MockMiddlewareObject implements MiddlewareObject { 43 | handleRequest( 44 | _context: Context, Record>, 45 | _next: Next, 46 | ): unknown { 47 | return; 48 | } 49 | } 50 | 51 | assert(isMiddlewareObject(new MockMiddlewareObject())); 52 | assert(isMiddlewareObject({ handleRequest() {} })); 53 | assert(!isMiddlewareObject(function () {})); 54 | }, 55 | }); 56 | 57 | Deno.test({ 58 | name: "middleware objects are composed correctly", 59 | async fn() { 60 | const callStack: number[] = []; 61 | const mockContext = createMockContext(); 62 | 63 | class MockMiddlewareObject implements MiddlewareObject { 64 | #counter = 0; 65 | 66 | async handleRequest(_context: any, next: Next) { 67 | assertEquals(typeof next, "function"); 68 | callStack.push(this.#counter++); 69 | await next(); 70 | } 71 | } 72 | 73 | const mwo = new MockMiddlewareObject(); 74 | const fn = compose([mwo]); 75 | 76 | await fn(mockContext); 77 | await fn(mockContext); 78 | assertEquals(callStack, [0, 1]); 79 | }, 80 | }); 81 | 82 | Deno.test({ 83 | name: "next() is catchable", 84 | async fn() { 85 | let caught: any; 86 | const mw1: Middleware = async (ctx, next) => { 87 | try { 88 | await next(); 89 | } catch (err) { 90 | caught = err; 91 | } 92 | }; 93 | const mw2: Middleware = async (ctx) => { 94 | ctx.throw(500); 95 | }; 96 | const context = createMockContext(); 97 | await compose([mw1, mw2])(context); 98 | assert(caught instanceof errors.InternalServerError); 99 | }, 100 | }); 101 | 102 | Deno.test({ 103 | name: "composed middleware accepts next middleware", 104 | async fn() { 105 | const callStack: number[] = []; 106 | const mockContext = createMockContext(); 107 | 108 | const mw0: Middleware = async (context, next): Promise => { 109 | assertEquals(typeof next, "function"); 110 | callStack.push(3); 111 | await next(); 112 | }; 113 | 114 | const mw1: Middleware = async (context, next) => { 115 | assertEquals(typeof next, "function"); 116 | callStack.push(1); 117 | await next(); 118 | }; 119 | const mw2: Middleware = async (context, next) => { 120 | assertEquals(typeof next, "function"); 121 | callStack.push(2); 122 | await next(); 123 | }; 124 | 125 | await compose([mw1, mw2])(mockContext, mw0 as () => Promise); 126 | assertEquals(callStack, [1, 2, 3]); 127 | }, 128 | }); 129 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * The internal middleware interfaces and abstractions used by oak. 5 | * 6 | * Outside of the {@linkcode Middleware} interface, items are not generally 7 | * used by end users, but {@linkcode compose} can be used for advanced use 8 | * cases. 9 | * 10 | * @module 11 | */ 12 | 13 | // deno-lint-ignore-file 14 | 15 | import type { State } from "./application.ts"; 16 | import type { Context } from "./context.ts"; 17 | 18 | /** A function for chaining middleware. */ 19 | export type Next = () => Promise; 20 | 21 | /** Middleware are functions which are chained together to deal with 22 | * requests. */ 23 | export interface Middleware< 24 | S extends State = Record, 25 | T extends Context = Context, 26 | > { 27 | (context: T, next: Next): Promise | unknown; 28 | } 29 | 30 | /** Middleware objects allow encapsulation of middleware along with the ability 31 | * to initialize the middleware upon listen. */ 32 | export interface MiddlewareObject< 33 | S extends State = Record, 34 | T extends Context = Context, 35 | > { 36 | /** Optional function for delayed initialization which will be called when 37 | * the application starts listening. */ 38 | init?: () => Promise | unknown; 39 | /** The method to be called to handle the request. */ 40 | handleRequest(context: T, next: Next): Promise | unknown; 41 | } 42 | 43 | /** Type that represents {@linkcode Middleware} or 44 | * {@linkcode MiddlewareObject}. */ 45 | export type MiddlewareOrMiddlewareObject< 46 | S extends State = Record, 47 | T extends Context = Context, 48 | > = Middleware | MiddlewareObject; 49 | 50 | /** A type guard that returns true if the value is 51 | * {@linkcode MiddlewareObject}. */ 52 | export function isMiddlewareObject< 53 | S extends State = Record, 54 | T extends Context = Context, 55 | >(value: MiddlewareOrMiddlewareObject): value is MiddlewareObject { 56 | return value && typeof value === "object" && "handleRequest" in value; 57 | } 58 | 59 | /** Compose multiple middleware functions into a single middleware function. */ 60 | export function compose< 61 | S extends State = Record, 62 | T extends Context = Context, 63 | >( 64 | middleware: MiddlewareOrMiddlewareObject[], 65 | ): (context: T, next?: Next) => Promise { 66 | return function composedMiddleware( 67 | context: T, 68 | next?: Next, 69 | ): Promise { 70 | let index = -1; 71 | 72 | async function dispatch(i: number): Promise { 73 | if (i <= index) { 74 | throw new Error("next() called multiple times."); 75 | } 76 | index = i; 77 | let m: MiddlewareOrMiddlewareObject | undefined = middleware[i]; 78 | let fn: Middleware | undefined; 79 | if (typeof m === "function") { 80 | fn = m; 81 | } else if (m && typeof m.handleRequest === "function") { 82 | fn = (m as MiddlewareObject).handleRequest.bind(m); 83 | } 84 | if (i === middleware.length) { 85 | fn = next; 86 | } 87 | if (!fn) { 88 | return; 89 | } 90 | await fn(context, dispatch.bind(null, i + 1)); 91 | } 92 | 93 | return dispatch(0); 94 | }; 95 | } 96 | -------------------------------------------------------------------------------- /middleware/etag.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assert } from "../deps.ts"; 4 | import { assertEquals } from "../deps_test.ts"; 5 | import { 6 | createMockApp, 7 | createMockContext, 8 | mockContextState, 9 | } from "../testing.ts"; 10 | 11 | import type { Application } from "../application.ts"; 12 | import type { Context } from "../context.ts"; 13 | import type { RouteParams } from "../router.ts"; 14 | 15 | import { factory } from "./etag.ts"; 16 | 17 | function setup< 18 | // deno-lint-ignore no-explicit-any 19 | S extends Record = Record, 20 | >( 21 | path = "/", 22 | method = "GET", 23 | ): { 24 | app: Application; 25 | context: Context; 26 | } { 27 | mockContextState.encodingsAccepted = "identity"; 28 | // deno-lint-ignore no-explicit-any 29 | const app = createMockApp(); 30 | const context = createMockContext, S>({ 31 | app, 32 | path, 33 | method, 34 | }); 35 | return { app, context }; 36 | } 37 | 38 | const encoder = new TextEncoder(); 39 | 40 | Deno.test({ 41 | name: "etag - middleware - body string", 42 | async fn() { 43 | const { context } = setup(); 44 | function next() { 45 | context.response.body = "hello deno"; 46 | return Promise.resolve(); 47 | } 48 | 49 | const mw = factory(); 50 | await mw(context, next); 51 | assertEquals( 52 | context.response.headers.get("etag"), 53 | `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, 54 | ); 55 | }, 56 | }); 57 | 58 | Deno.test({ 59 | name: "etag - middleware - body Uint8Array", 60 | async fn() { 61 | const { context } = setup(); 62 | function next() { 63 | context.response.body = encoder.encode("hello deno"); 64 | return Promise.resolve(); 65 | } 66 | 67 | const mw = factory(); 68 | await mw(context, next); 69 | assertEquals( 70 | context.response.headers.get("etag"), 71 | `"a-YdfmHmj2RiwOVqJupcf3PLK9PuJ"`, 72 | ); 73 | }, 74 | }); 75 | 76 | Deno.test({ 77 | name: "etag - middleware - body File", 78 | async fn() { 79 | const { context } = setup(); 80 | let file: Deno.FsFile; 81 | async function next() { 82 | file = await Deno.open("./fixtures/test.jpg", { 83 | read: true, 84 | }); 85 | context.response.body = file; 86 | } 87 | 88 | const mw = factory(); 89 | await mw(context, next); 90 | const actual = context.response.headers.get("etag"); 91 | assert(actual && actual.startsWith(`W/"4a3b7-`)); 92 | 93 | file!.close(); 94 | }, 95 | }); 96 | 97 | Deno.test({ 98 | name: "etag - middleware - body JSON-like", 99 | async fn() { 100 | const { context } = setup(); 101 | function next() { 102 | context.response.body = { msg: "hello deno" }; 103 | return Promise.resolve(); 104 | } 105 | 106 | const mw = factory(); 107 | await mw(context, next); 108 | assertEquals( 109 | context.response.headers.get("etag"), 110 | `"14-JvQev/2QpYTuhshiKlzH0ZRXxAP"`, 111 | ); 112 | }, 113 | }); 114 | 115 | Deno.test({ 116 | name: "etag - middleware - body function", 117 | async fn() { 118 | // if we call the body function in the middleware, we cause problems with 119 | // the response, so we just have to ignore body functions 120 | const { context } = setup(); 121 | function next() { 122 | context.response.body = () => Promise.resolve("hello deno"); 123 | return Promise.resolve(); 124 | } 125 | 126 | const mw = factory(); 127 | await mw(context, next); 128 | assertEquals( 129 | context.response.headers.get("etag"), 130 | null, 131 | ); 132 | }, 133 | }); 134 | 135 | Deno.test({ 136 | name: "etag - middleware - body async iterator", 137 | async fn() { 138 | // The only async readable we can really support is Deno.FsFile, because we 139 | // know how to get the meta data in order to build a weak tag. Other async 140 | // iterables should be ignored and not serialized as JSON. 141 | const { context } = setup(); 142 | function next() { 143 | context.response.body = new ReadableStream({ 144 | start(controller) { 145 | controller.enqueue("hello deno"); 146 | controller.close(); 147 | }, 148 | }); 149 | return Promise.resolve(); 150 | } 151 | 152 | const mw = factory(); 153 | await mw(context, next); 154 | assertEquals( 155 | context.response.headers.get("etag"), 156 | null, 157 | ); 158 | }, 159 | }); 160 | -------------------------------------------------------------------------------- /middleware/etag.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * A collection of oak specific APIs for management of ETags. 5 | * 6 | * @module 7 | */ 8 | 9 | import type { State } from "../application.ts"; 10 | import type { Context } from "../context.ts"; 11 | import { eTag, type ETagOptions } from "../deps.ts"; 12 | import type { Middleware } from "../middleware.ts"; 13 | import { BODY_TYPES } from "../utils/consts.ts"; 14 | import { isAsyncIterable, isFsFile } from "../utils/type_guards.ts"; 15 | 16 | /** For a given Context, try to determine the response body entity that an ETag 17 | * can be calculated from. */ 18 | // deno-lint-ignore no-explicit-any 19 | export function getEntity>( 20 | context: Context, 21 | ): Promise { 22 | const { body } = context.response; 23 | if (isFsFile(body)) { 24 | return body.stat(); 25 | } 26 | if (body instanceof Uint8Array) { 27 | return Promise.resolve(body); 28 | } 29 | if (BODY_TYPES.includes(typeof body)) { 30 | return Promise.resolve(String(body)); 31 | } 32 | if (isAsyncIterable(body)) { 33 | return Promise.resolve(undefined); 34 | } 35 | if (typeof body === "object" && body !== null) { 36 | try { 37 | const bodyText = JSON.stringify(body); 38 | return Promise.resolve(bodyText); 39 | } catch { 40 | // We don't really care about errors here 41 | } 42 | } 43 | return Promise.resolve(undefined); 44 | } 45 | 46 | /** 47 | * Create middleware that will attempt to decode the response.body into 48 | * something that can be used to generate an `ETag` and add the `ETag` header to 49 | * the response. 50 | */ 51 | // deno-lint-ignore no-explicit-any 52 | export function factory>( 53 | options?: ETagOptions, 54 | ): Middleware { 55 | return async function etag(context: Context, next) { 56 | await next(); 57 | if (!context.response.headers.has("ETag")) { 58 | const entity = await getEntity(context); 59 | if (entity) { 60 | // @ts-ignore the overloads aren't quite right in the upstream library 61 | const etag = await eTag(entity, options); 62 | if (etag) { 63 | context.response.headers.set("ETag", etag); 64 | } 65 | } 66 | } 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /middleware/proxy.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { Application } from "../application.ts"; 4 | import type { Context } from "../context.ts"; 5 | import { assert } from "../deps.ts"; 6 | import { Router } from "../router.ts"; 7 | import { createMockContext, createMockNext } from "../testing.ts"; 8 | import { assertEquals, assertStrictEquals } from "../deps_test.ts"; 9 | 10 | import { proxy } from "./proxy.ts"; 11 | 12 | const decoder = new TextDecoder(); 13 | 14 | async function readStream(stream: ReadableStream): Promise { 15 | const chunks: Uint8Array[] = []; 16 | for await (const chunk of stream) { 17 | chunks.push(chunk); 18 | } 19 | const len = chunks.reduce((len, c) => c.length + len, 0); 20 | const result = new Uint8Array(len); 21 | let offset = 0; 22 | for (const chunk of chunks) { 23 | result.set(chunk, offset); 24 | offset += chunk.length; 25 | } 26 | return decoder.decode(result); 27 | } 28 | 29 | Deno.test({ 30 | name: "proxy - app - type assignment", 31 | fn() { 32 | const app = new Application(); 33 | app.use(proxy("https://oakserver.github.io/oak/")); 34 | }, 35 | }); 36 | 37 | Deno.test({ 38 | name: "proxy - router - type assignment", 39 | fn() { 40 | const router = new Router(); 41 | router.get("/", proxy("https://oakserver.github.io/oak/")); 42 | }, 43 | }); 44 | 45 | Deno.test({ 46 | name: "proxy - no options", 47 | async fn() { 48 | function fetch(request: Request): Promise { 49 | assertEquals(request.url, "https://oakserver.github.io/oak/FAQ"); 50 | assertEquals(request.headers.get("x-forwarded-for"), "127.0.0.1"); 51 | 52 | return Promise.resolve( 53 | new Response("hello world", { 54 | headers: { 55 | "content-type": "plain/text", 56 | }, 57 | status: 200, 58 | statusText: "OK", 59 | }), 60 | ); 61 | } 62 | 63 | const mw = proxy("https://oakserver.github.io/", { fetch }); 64 | const ctx = createMockContext({ 65 | path: "/oak/FAQ", 66 | }); 67 | const next = createMockNext(); 68 | await mw(ctx, next); 69 | assert(ctx.response.body instanceof ReadableStream); 70 | assertEquals(await readStream(ctx.response.body), "hello world"); 71 | assertStrictEquals(ctx.response.status, 200); 72 | assertStrictEquals(ctx.response.headers.get("Content-Type"), "plain/text"); 73 | }, 74 | }); 75 | 76 | Deno.test({ 77 | name: "proxy - matches - string", 78 | async fn() { 79 | function fetch(_request: Request) { 80 | return Promise.resolve(new Response("hello world")); 81 | } 82 | 83 | const mw = proxy("https://oakserver.github.io/", { fetch, match: "/oak" }); 84 | const next = createMockNext(); 85 | 86 | const ctx1 = createMockContext({ 87 | path: "/oak/FAQ", 88 | }); 89 | await mw(ctx1, next); 90 | assert(ctx1.response.body instanceof ReadableStream); 91 | 92 | const ctx2 = createMockContext({ 93 | path: "/", 94 | }); 95 | await mw(ctx2, next); 96 | assertStrictEquals(ctx2.response.body, undefined); 97 | }, 98 | }); 99 | 100 | Deno.test({ 101 | name: "proxy - matches - regex", 102 | async fn() { 103 | function fetch(_request: Request) { 104 | return Promise.resolve(new Response("hello world")); 105 | } 106 | 107 | const mw = proxy("https://oakserver.github.io/", { fetch, match: /\.ts$/ }); 108 | const next = createMockNext(); 109 | 110 | const ctx1 = createMockContext({ 111 | path: "/oak/index.ts", 112 | }); 113 | await mw(ctx1, next); 114 | assert(ctx1.response.body instanceof ReadableStream); 115 | 116 | const ctx2 = createMockContext({ 117 | path: "/oak/index.js", 118 | }); 119 | await mw(ctx2, next); 120 | assertStrictEquals(ctx2.response.body, undefined); 121 | }, 122 | }); 123 | 124 | Deno.test({ 125 | name: "proxy - matches - fn", 126 | async fn() { 127 | function fetch(_request: Request) { 128 | return Promise.resolve(new Response("hello world")); 129 | } 130 | 131 | const mw = proxy("https://oakserver.github.io/", { 132 | fetch, 133 | match(ctx) { 134 | return ctx.request.url.pathname.startsWith("/oak"); 135 | }, 136 | }); 137 | const next = createMockNext(); 138 | 139 | const ctx1 = createMockContext({ 140 | path: "/oak/FAQ", 141 | }); 142 | await mw(ctx1, next); 143 | assert(ctx1.response.body instanceof ReadableStream); 144 | 145 | const ctx2 = createMockContext({ 146 | path: "/", 147 | }); 148 | await mw(ctx2, next); 149 | assertStrictEquals(ctx2.response.body, undefined); 150 | }, 151 | }); 152 | 153 | Deno.test({ 154 | name: "proxy - contentType", 155 | async fn() { 156 | function fetch(_request: Request) { 157 | return Promise.resolve( 158 | new Response(`console.log("hello world");`, { 159 | headers: { "Content-Type": "text/plain" }, 160 | }), 161 | ); 162 | } 163 | 164 | const mw = proxy("https://oakserver.github.io/", { 165 | fetch, 166 | contentType(url, contentType) { 167 | assertStrictEquals(url, ""); 168 | assertStrictEquals(contentType, "text/plain"); 169 | return "text/html"; 170 | }, 171 | }); 172 | 173 | const next = createMockNext(); 174 | const ctx = createMockContext({ 175 | path: "/oak/index.html", 176 | }); 177 | await mw(ctx, next); 178 | assertStrictEquals(ctx.response.headers.get("content-type"), "text/html"); 179 | }, 180 | }); 181 | 182 | Deno.test({ 183 | name: "proxy - preserves - search params", 184 | async fn() { 185 | function fetch(request: Request) { 186 | const url = new URL(request.url); 187 | assertStrictEquals(url.search, ctx.request.url.search); 188 | return Promise.resolve(new Response("hello world")); 189 | } 190 | 191 | const mw = proxy("https://oakserver.github.io/", { fetch }); 192 | 193 | const next = createMockNext(); 194 | const ctx = createMockContext({ 195 | path: "/oak/index.html?query=foobar&page=42", 196 | }); 197 | await mw(ctx, next); 198 | }, 199 | }); 200 | 201 | Deno.test({ 202 | name: "proxy - forwarded - regex test", 203 | async fn() { 204 | function fetch(request: Request): Promise { 205 | assertEquals( 206 | request.headers.get("forwarded"), 207 | "for=127.0.0.1, for=127.0.0.1", 208 | ); 209 | return Promise.resolve(new Response("hello world")); 210 | } 211 | 212 | const mw = proxy("https://oakserver.github.io/", { fetch }); 213 | const ctx = createMockContext({ path: "/oak/FAQ" }); 214 | ctx.request.headers.append("forwarded", "for=127.0.0.1"); 215 | const next = createMockNext(); 216 | await mw(ctx, next); 217 | 218 | const mw2 = proxy("https://oakserver.github.io/", { fetch }); 219 | const ctx2 = createMockContext({ 220 | path: "/oak/FAQ2", 221 | }); 222 | ctx2.request.headers.append("forwarded", "for=127.0.0.1"); 223 | const next2 = createMockNext(); 224 | await mw2(ctx2, next2); 225 | }, 226 | }); 227 | 228 | Deno.test({ 229 | name: "proxy - avoid DDOS of exponential regex", 230 | async fn() { 231 | function fetch(): Promise { 232 | return Promise.resolve(new Response("hello world")); 233 | } 234 | 235 | const mw = proxy("https://oakserver.github.io", { fetch }); 236 | const ctx = createMockContext({ path: "/oak/FAQ" }); 237 | ctx.request.headers.append( 238 | "forwarded", 239 | ',;!="\\\\' + "t\\\\t\\\\".repeat(28) + "t,", 240 | ); 241 | const next = createMockNext(); 242 | performance.mark("start"); 243 | await mw(ctx, next); 244 | const measure = performance.measure("request", { start: "start" }); 245 | // this is set to a very safe number, pre-fix this would just churn the 246 | // CPU for a super long time 247 | assert(measure.duration < 20); 248 | }, 249 | }); 250 | 251 | Deno.test({ 252 | name: "proxy - fetch contains context", 253 | async fn() { 254 | function fetch( 255 | _input: Request, 256 | { context }: { context: Context }, 257 | ) { 258 | assert(context); 259 | assertEquals(context.request.url.toString(), "http://localhost/oak/FAQ"); 260 | return Promise.resolve(new Response("hello world")); 261 | } 262 | 263 | const mw = proxy("https://oakserver.github.io", { fetch }); 264 | const ctx = createMockContext({ path: "/oak/FAQ" }); 265 | const next = createMockNext(); 266 | await mw(ctx, next); 267 | }, 268 | }); 269 | 270 | Deno.test({ 271 | name: "proxy - consumed body", 272 | async fn() { 273 | async function fetch(request: Request): Promise { 274 | const body = await request.text(); 275 | assertEquals(body, "hello world"); 276 | return new Response(body); 277 | } 278 | 279 | const mw = proxy("https://oakserver.github.io/", { fetch }); 280 | const stream = ReadableStream.from([ 281 | new TextEncoder().encode("hello world"), 282 | ]); 283 | const ctx = createMockContext({ 284 | method: "POST", 285 | path: "/oak/FAQ", 286 | body: stream, 287 | }); 288 | const next = createMockNext(); 289 | 290 | assertEquals(await ctx.request.body.text(), "hello world"); 291 | await mw(ctx, next); 292 | }, 293 | }); 294 | -------------------------------------------------------------------------------- /middleware/proxy.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** Middleware for oak that allows back-to-back proxies of requests to be 4 | * used. 5 | * 6 | * @module 7 | */ 8 | 9 | import type { State } from "../application.ts"; 10 | import type { Context } from "../context.ts"; 11 | import { parseForwarded } from "../deps.ts"; 12 | import type { Middleware } from "../middleware.ts"; 13 | import type { 14 | RouteParams, 15 | RouterContext, 16 | RouterMiddleware, 17 | } from "../router.ts"; 18 | import { isRouterContext } from "../utils/type_guards.ts"; 19 | 20 | type Fetch = ( 21 | input: Request, 22 | init: { context: Context }, 23 | ) => Promise; 24 | 25 | type ProxyMatchFunction< 26 | R extends string, 27 | P extends RouteParams = RouteParams, 28 | // deno-lint-ignore no-explicit-any 29 | S extends State = Record, 30 | > = (ctx: Context | RouterContext) => boolean; 31 | 32 | type ProxyMapFunction> = ( 33 | path: R, 34 | params?: P, 35 | ) => R; 36 | 37 | type ProxyHeadersFunction = ( 38 | ctx: Context, 39 | ) => HeadersInit | Promise; 40 | 41 | type ProxyRouterHeadersFunction< 42 | R extends string, 43 | P extends RouteParams, 44 | S extends State, 45 | > = (ctx: RouterContext) => HeadersInit | Promise; 46 | 47 | /** Options which can be specified on the {@linkcode proxy} middleware. */ 48 | export interface ProxyOptions< 49 | R extends string, 50 | P extends RouteParams = RouteParams, 51 | // deno-lint-ignore no-explicit-any 52 | S extends State = Record, 53 | > { 54 | /** A callback hook that is called after the response is received which allows 55 | * the response content type to be adjusted. This is for situations where the 56 | * content type provided by the proxy server might not be suitable for 57 | * responding with. */ 58 | contentType?( 59 | url: string, 60 | contentType?: string, 61 | ): Promise | string | undefined; 62 | /** The fetch function to use to proxy the request. This defaults to the 63 | * global {@linkcode fetch} function. It will always be called with a 64 | * second argument which contains an object of `{ context }` which the 65 | * `context` property will be an instance of {@linkcode RouterContext}. 66 | * 67 | * This is designed for mocking purposes or implementing a `fetch()` 68 | * callback that needs access the current context when it is called. */ 69 | fetch?: Fetch; 70 | /** Additional headers that should be set in the response. The value can 71 | * be a headers init value or a function that returns or resolves with a 72 | * headers init value. */ 73 | headers?: 74 | | HeadersInit 75 | | ProxyHeadersFunction 76 | | ProxyRouterHeadersFunction; 77 | /** Either a record or a proxy map function that will allow proxied requests 78 | * being handled by the middleware to be remapped to a different remote 79 | * path. */ 80 | map?: Record | ProxyMapFunction; 81 | /** A string, regular expression or proxy match function what determines if 82 | * the proxy middleware should proxy the request. 83 | * 84 | * If the value is a string the match will be true if the requests pathname 85 | * starts with the string. In the case of a regular expression, if the 86 | * pathname 87 | */ 88 | match?: 89 | | string 90 | | RegExp 91 | | ProxyMatchFunction; 92 | /** A flag that indicates if traditional proxy headers should be set in the 93 | * response. This defaults to `true`. 94 | */ 95 | proxyHeaders?: boolean; 96 | /** A callback hook which will be called before each proxied fetch request 97 | * to allow the native `Request` to be modified or replaced. */ 98 | request?(req: Request): Request | Promise; 99 | /** A callback hook which will be called after each proxied fetch response 100 | * is received to allow the native `Response` to be modified or replaced. */ 101 | response?(res: Response): Response | Promise; 102 | } 103 | 104 | function createMatcher< 105 | R extends string, 106 | P extends RouteParams, 107 | S extends State, 108 | >( 109 | { match }: ProxyOptions, 110 | ) { 111 | return function matches(ctx: RouterContext): boolean { 112 | if (!match) { 113 | return true; 114 | } 115 | if (typeof match === "string") { 116 | return ctx.request.url.pathname.startsWith(match); 117 | } 118 | if (match instanceof RegExp) { 119 | return match.test(ctx.request.url.pathname); 120 | } 121 | return match(ctx); 122 | }; 123 | } 124 | 125 | async function createRequest< 126 | R extends string, 127 | P extends RouteParams, 128 | S extends State, 129 | >( 130 | target: string | URL, 131 | ctx: Context | RouterContext, 132 | { headers: optHeaders, map, proxyHeaders = true, request: reqFn }: 133 | ProxyOptions, 134 | ): Promise { 135 | let path = ctx.request.url.pathname as R; 136 | let params: P | undefined; 137 | if (isRouterContext(ctx)) { 138 | params = ctx.params; 139 | } 140 | if (map && typeof map === "function") { 141 | path = map(path, params); 142 | } else if (map) { 143 | path = map[path] ?? path; 144 | } 145 | const url = new URL(String(target)); 146 | if (url.pathname.endsWith("/") && path.startsWith("/")) { 147 | url.pathname = `${url.pathname}${path.slice(1)}`; 148 | } else if (!url.pathname.endsWith("/") && !path.startsWith("/")) { 149 | url.pathname = `${url.pathname}/${path}`; 150 | } else { 151 | url.pathname = `${url.pathname}${path}`; 152 | } 153 | url.search = ctx.request.url.search; 154 | 155 | const body = await ctx.request.body?.init() ?? null; 156 | const headers = new Headers(ctx.request.headers); 157 | if (optHeaders) { 158 | if (typeof optHeaders === "function") { 159 | optHeaders = await optHeaders(ctx as RouterContext); 160 | } 161 | for (const [key, value] of iterableHeaders(optHeaders)) { 162 | headers.set(key, value); 163 | } 164 | } 165 | if (proxyHeaders) { 166 | const maybeForwarded = headers.get("forwarded"); 167 | const ip = ctx.request.ip.startsWith("[") 168 | ? `"${ctx.request.ip}"` 169 | : ctx.request.ip; 170 | const host = headers.get("host"); 171 | if (maybeForwarded && parseForwarded(maybeForwarded)) { 172 | let value = `for=${ip}`; 173 | if (host) { 174 | value += `;host=${host}`; 175 | } 176 | headers.append("forwarded", value); 177 | } else { 178 | headers.append("x-forwarded-for", ip); 179 | if (host) { 180 | headers.append("x-forwarded-host", host); 181 | } 182 | } 183 | } 184 | 185 | const init: RequestInit = { 186 | body, 187 | headers, 188 | method: ctx.request.method, 189 | redirect: "follow", 190 | }; 191 | let request = new Request(url.toString(), init); 192 | if (reqFn) { 193 | request = await reqFn(request); 194 | } 195 | return request; 196 | } 197 | 198 | function iterableHeaders( 199 | headers: HeadersInit, 200 | ): IterableIterator<[string, string]> { 201 | if (headers instanceof Headers) { 202 | return headers.entries(); 203 | } else if (Array.isArray(headers)) { 204 | return headers.values() as IterableIterator<[string, string]>; 205 | } else { 206 | return Object.entries(headers).values() as IterableIterator< 207 | [string, string] 208 | >; 209 | } 210 | } 211 | 212 | async function processResponse< 213 | R extends string, 214 | P extends RouteParams, 215 | S extends State, 216 | >( 217 | response: Response, 218 | ctx: Context | RouterContext, 219 | { contentType: contentTypeFn, response: resFn }: ProxyOptions, 220 | ) { 221 | if (resFn) { 222 | response = await resFn(response); 223 | } 224 | if (response.body) { 225 | ctx.response.body = response.body; 226 | } else { 227 | ctx.response.body = null; 228 | } 229 | ctx.response.status = response.status; 230 | for (const [key, value] of response.headers) { 231 | ctx.response.headers.append(key, value); 232 | } 233 | if (contentTypeFn) { 234 | const value = await contentTypeFn( 235 | response.url, 236 | ctx.response.headers.get("content-type") ?? undefined, 237 | ); 238 | if (value != null) { 239 | ctx.response.headers.set("content-type", value); 240 | } 241 | } 242 | } 243 | 244 | /** 245 | * Middleware that provides a back-to-back proxy for requests. 246 | * 247 | * @param target 248 | * @param options 249 | */ 250 | export function proxy( 251 | target: string | URL, 252 | options?: ProxyOptions, S>, 253 | ): Middleware; 254 | export function proxy< 255 | R extends string, 256 | P extends RouteParams, 257 | S extends State, 258 | >( 259 | target: string | URL, 260 | options: ProxyOptions = {}, 261 | ): RouterMiddleware { 262 | const matches = createMatcher(options); 263 | return async function proxy(context, next) { 264 | if (!matches(context)) { 265 | return next(); 266 | } 267 | const request = await createRequest(target, context, options); 268 | const { fetch = globalThis.fetch } = options; 269 | const response = await fetch(request, { context }); 270 | await processResponse(response, context, options); 271 | return next(); 272 | }; 273 | } 274 | -------------------------------------------------------------------------------- /middleware/serve.test.ts: -------------------------------------------------------------------------------- 1 | import type { State } from "../application.ts"; 2 | import { Context } from "../context.ts"; 3 | import { assert } from "../deps.ts"; 4 | import { NativeRequest } from "../http_server_native_request.ts"; 5 | import type { Next } from "../middleware.ts"; 6 | import { type RouteParams, Router, type RouterContext } from "../router.ts"; 7 | import { assertEquals, assertStrictEquals } from "../deps_test.ts"; 8 | import { createMockApp, createMockNext } from "../testing.ts"; 9 | import { isNode } from "../utils/type_guards.ts"; 10 | 11 | import { route, serve } from "./serve.ts"; 12 | 13 | function setup( 14 | request: Request, 15 | remoteAddr: Deno.NetAddr = { 16 | transport: "tcp", 17 | hostname: "localhost", 18 | port: 8080, 19 | }, 20 | ): [Context, RouterContext, S>, Next] { 21 | const app = createMockApp(); 22 | const serverRequest = new NativeRequest(request, { remoteAddr }); 23 | const context = new Context(app, serverRequest, app.state as S); 24 | const routerContext = new Context( 25 | app, 26 | serverRequest, 27 | app.state, 28 | ) as RouterContext, S>; 29 | Object.assign(routerContext, { 30 | captures: [], 31 | params: { "a": "b" }, 32 | router: {} as Router, 33 | routeName: "c", 34 | routePath: "d", 35 | }); 36 | return [context, routerContext, createMockNext()]; 37 | } 38 | 39 | Deno.test({ 40 | name: "serve - source request and response are strictly equal", 41 | async fn() { 42 | const request = new Request("http://localhost:8888/index.html"); 43 | const [context, , next] = setup(request); 44 | let response: Response; 45 | const mw = serve((req) => { 46 | assertStrictEquals(req, request); 47 | return response = new Response(); 48 | }); 49 | await mw(context, next); 50 | assertStrictEquals(await context.response.toDomResponse(), response!); 51 | }, 52 | }); 53 | 54 | Deno.test({ 55 | name: "serve - context is valid", 56 | async fn() { 57 | const request = new Request("http://localhost:8888/index.html"); 58 | const [context, , next] = setup(request); 59 | const mw = serve((_req, ctx) => { 60 | assert(ctx.app); 61 | assert(ctx.state); 62 | assertEquals(typeof ctx.assert, "function"); 63 | assertEquals(typeof ctx.throw, "function"); 64 | return new Response(); 65 | }); 66 | await mw(context, next); 67 | }, 68 | }); 69 | 70 | Deno.test({ 71 | name: "serve - inspection is expected", 72 | async fn() { 73 | const request = new Request("http://localhost:8888/index.html"); 74 | const [context, , next] = setup(request); 75 | const mw = serve((_req, ctx) => { 76 | assertEquals( 77 | Deno.inspect(ctx), 78 | isNode() 79 | ? `ServeContext { app: MockApplication {}, ip: 'localhost', ips: [], state: {} }` 80 | : `ServeContext { app: MockApplication {}, ip: "localhost", ips: [], state: {} }`, 81 | ); 82 | return new Response(); 83 | }); 84 | await mw(context, next); 85 | }, 86 | }); 87 | 88 | Deno.test({ 89 | name: "route - source request and response are strictly equal", 90 | async fn() { 91 | const request = new Request("http://localhost:8888/index.html"); 92 | const [, context, next] = setup(request); 93 | let response: Response; 94 | const mw = route<"/", RouteParams<"/">, State>((req) => { 95 | assertStrictEquals(req, request); 96 | return response = new Response(); 97 | }); 98 | await mw(context, next); 99 | assertStrictEquals(await context.response.toDomResponse(), response!); 100 | }, 101 | }); 102 | 103 | Deno.test({ 104 | name: "route - context is valid", 105 | async fn() { 106 | const request = new Request("http://localhost:8888/book/1234"); 107 | const [context, , next] = setup(request); 108 | const router = new Router(); 109 | router.get( 110 | "/book/:id", 111 | route((_req, ctx) => { 112 | assertEquals(ctx.captures, ["1234"]); 113 | assertEquals(ctx.params, { id: "1234" }); 114 | assertEquals(ctx.routeName, undefined); 115 | assertStrictEquals(ctx.router, router); 116 | assertEquals(ctx.routerPath, undefined); 117 | return new Response(); 118 | }), 119 | ); 120 | const mw = router.routes(); 121 | await mw(context, next); 122 | }, 123 | }); 124 | 125 | Deno.test({ 126 | name: "route - inspection is expected", 127 | async fn() { 128 | const request = new Request("http://localhost:8888/book/1234"); 129 | const [context, , next] = setup(request); 130 | const router = new Router(); 131 | router.get( 132 | "/book/:id", 133 | route((_req, ctx) => { 134 | assertEquals( 135 | Deno.inspect(ctx), 136 | isNode() 137 | ? `RouteContext {\n app: MockApplication {},\n captures: [ '1234' ],\n matched: [ [Layer] ],\n ip: 'localhost',\n ips: [],\n params: { id: '1234' },\n router: Router { '#params': {}, '#stack': [Array] },\n routeName: undefined,\n routerPath: undefined,\n state: {}\n}` 138 | : `RouteContext {\n app: MockApplication {},\n captures: [ "1234" ],\n matched: [\n Layer {\n methods: [ "HEAD", "GET" ],\n middleware: [ [AsyncFunction (anonymous)] ],\n options: {\n end: undefined,\n sensitive: undefined,\n strict: undefined,\n ignoreCaptures: undefined\n },\n paramNames: [ "id" ],\n path: "/book/:id",\n regexp: /^\\/book(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$/i\n}\n ],\n ip: "localhost",\n ips: [],\n params: { id: "1234" },\n router: Router {\n "#params": {},\n "#stack": [\n Layer {\n methods: [ "HEAD", "GET" ],\n middleware: [ [AsyncFunction (anonymous)] ],\n options: {\n end: undefined,\n sensitive: undefined,\n strict: undefined,\n ignoreCaptures: undefined\n },\n paramNames: [ "id" ],\n path: "/book/:id",\n regexp: /^\\/book(?:\\/([^\\/#\\?]+?))[\\/#\\?]?$/i\n}\n ]\n},\n routeName: undefined,\n routerPath: undefined,\n state: {}\n}`, 139 | ); 140 | return new Response(); 141 | }), 142 | ); 143 | const mw = router.routes(); 144 | await mw(context, next); 145 | }, 146 | }); 147 | -------------------------------------------------------------------------------- /mod.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assert } from "./deps.ts"; 4 | import { assertEquals } from "./deps_test.ts"; 5 | import * as mod from "./mod.ts"; 6 | 7 | Deno.test({ 8 | name: "public API assertions", 9 | fn() { 10 | assert(mod != null); 11 | assertEquals(typeof mod.Application, "function"); 12 | assertEquals(typeof mod.Context, "function"); 13 | assertEquals(typeof mod.etag, "object"); 14 | assertEquals(typeof mod.etag.getEntity, "function"); 15 | assertEquals(typeof mod.etag.factory, "function"); 16 | assertEquals(typeof mod.HttpError, "function"); 17 | assertEquals(typeof mod.httpErrors, "object"); 18 | assertEquals(typeof mod.HttpServerNative, "function"); 19 | assertEquals(typeof mod.isErrorStatus, "function"); 20 | assertEquals(typeof mod.isHttpError, "function"); 21 | assertEquals(typeof mod.isRedirectStatus, "function"); 22 | assertEquals(typeof mod.composeMiddleware, "function"); 23 | assertEquals(typeof mod.Cookies, "function"); 24 | assertEquals(typeof mod.proxy, "function"); 25 | assertEquals(typeof mod.REDIRECT_BACK, "symbol"); 26 | assertEquals(typeof mod.Request, "function"); 27 | assertEquals(typeof mod.Response, "function"); 28 | assertEquals(typeof mod.route, "function"); 29 | assertEquals(typeof mod.RouteContext, "function"); 30 | assertEquals(typeof mod.Router, "function"); 31 | assertEquals(typeof mod.ServerSentEvent, "function"); 32 | assertEquals(typeof mod.serve, "function"); 33 | assertEquals(typeof mod.ServeContext, "function"); 34 | assertEquals(typeof mod.STATUS_TEXT, "object"); 35 | assertEquals(typeof mod.Status, "object"); 36 | assertEquals(typeof mod.send, "function"); 37 | assertEquals(typeof mod.testing, "object"); 38 | assertEquals(Object.keys(mod.testing).length, 4); 39 | assertEquals(Object.keys(mod).length, 26); 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * A middleware framework for handling HTTP with [Deno CLI](https://deno.land), 5 | * [Deno Deploy](https://deno.com/deploy), 6 | * [Cloudflare Workers](https://workers.cloudflare.com/), 7 | * [Node.js](https://nodejs.org/), and [Bun](https://bun.sh/). 8 | * 9 | * oak is inspired by [koa](https://koajs.com/). 10 | * 11 | * ## Example server 12 | * 13 | * A minimal router server which responds with content on `/`. 14 | * 15 | * ### Deno CLI and Deno Deploy 16 | * 17 | * ```ts 18 | * import { Application } from "jsr:@oak/oak/application"; 19 | * import { Router } from "jsr:@oak/oak/router"; 20 | * 21 | * const router = new Router(); 22 | * router.get("/", (ctx) => { 23 | * ctx.response.body = ` 24 | * 25 | * Hello oak! 26 | * 27 | *

    Hello oak!

    28 | * 29 | * 30 | * `; 31 | * }); 32 | * 33 | * const app = new Application(); 34 | * app.use(router.routes()); 35 | * app.use(router.allowedMethods()); 36 | * 37 | * app.listen({ port: 8080 }); 38 | * ``` 39 | * 40 | * ### Node.js and Bun 41 | * 42 | * You will have to install the package and then: 43 | * 44 | * ```ts 45 | * import { Application } from "@oak/oak/application"; 46 | * import { Router } from "@oak/oak/router"; 47 | * 48 | * const router = new Router(); 49 | * router.get("/", (ctx) => { 50 | * ctx.response.body = ` 51 | * 52 | * Hello oak! 53 | * 54 | *

    Hello oak!

    55 | * 56 | * 57 | * `; 58 | * }); 59 | * 60 | * const app = new Application(); 61 | * app.use(router.routes()); 62 | * app.use(router.allowedMethods()); 63 | * 64 | * app.listen({ port: 8080 }); 65 | * ``` 66 | * 67 | * ### Cloudflare Workers 68 | * 69 | * You will have to install the package and then: 70 | * 71 | * ```ts 72 | * import { Application } from "@oak/oak/application"; 73 | * import { Router } from "@oak/oak/router"; 74 | * 75 | * const router = new Router(); 76 | * router.get("/", (ctx) => { 77 | * ctx.response.body = ` 78 | * 79 | * Hello oak! 80 | * 81 | *

    Hello oak!

    82 | * 83 | * 84 | * `; 85 | * }); 86 | * 87 | * const app = new Application(); 88 | * app.use(router.routes()); 89 | * app.use(router.allowedMethods()); 90 | * 91 | * export default { fetch: app.fetch }; 92 | * ``` 93 | * 94 | * @module 95 | */ 96 | 97 | export { 98 | Application, 99 | type ApplicationOptions, 100 | type ListenOptions, 101 | type ListenOptionsBase, 102 | type ListenOptionsTls, 103 | type State, 104 | } from "./application.ts"; 105 | export type { BodyType } from "./body.ts"; 106 | export { Context, type ContextSendOptions } from "./context.ts"; 107 | export { Server as HttpServerNative } from "./http_server_native.ts"; 108 | export { type NativeRequest } from "./http_server_native_request.ts"; 109 | export * as etag from "./middleware/etag.ts"; 110 | export { proxy, type ProxyOptions } from "./middleware/proxy.ts"; 111 | export { 112 | route, 113 | RouteContext, 114 | serve, 115 | ServeContext, 116 | } from "./middleware/serve.ts"; 117 | export { 118 | compose as composeMiddleware, 119 | type Middleware, 120 | type MiddlewareObject, 121 | type MiddlewareOrMiddlewareObject, 122 | type Next, 123 | } from "./middleware.ts"; 124 | export { Request } from "./request.ts"; 125 | export { REDIRECT_BACK, Response } from "./response.ts"; 126 | export { 127 | type Route, 128 | type RouteParams, 129 | Router, 130 | type RouterAllowedMethodsOptions, 131 | type RouterContext, 132 | type RouterMiddleware, 133 | type RouterOptions, 134 | type RouterParamMiddleware, 135 | } from "./router.ts"; 136 | export { send, type SendOptions } from "./send.ts"; 137 | /** Utilities for making testing oak servers easier. */ 138 | export * as testing from "./testing.ts"; 139 | export { type ServerConstructor } from "./types.ts"; 140 | 141 | // Re-exported from `std/http` 142 | export { 143 | createHttpError, 144 | errors as httpErrors, 145 | type ErrorStatus, 146 | HttpError, 147 | type HTTPMethods, 148 | isErrorStatus, 149 | isHttpError, 150 | isRedirectStatus, 151 | type RedirectStatus, 152 | SecureCookieMap as Cookies, 153 | type SecureCookieMapGetOptions as CookiesGetOptions, 154 | type SecureCookieMapSetDeleteOptions as CookiesSetDeleteOptions, 155 | ServerSentEvent, 156 | type ServerSentEventInit, 157 | type ServerSentEventTarget, 158 | Status, 159 | STATUS_TEXT, 160 | } from "./deps.ts"; 161 | -------------------------------------------------------------------------------- /node_shims.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | class ErrorEvent extends Event { 4 | #message: string; 5 | #filename: string; 6 | #lineno: number; 7 | #colno: number; 8 | // deno-lint-ignore no-explicit-any 9 | #error: any; 10 | 11 | get message(): string { 12 | return this.#message; 13 | } 14 | get filename(): string { 15 | return this.#filename; 16 | } 17 | get lineno(): number { 18 | return this.#lineno; 19 | } 20 | get colno(): number { 21 | return this.#colno; 22 | } 23 | // deno-lint-ignore no-explicit-any 24 | get error(): any { 25 | return this.#error; 26 | } 27 | 28 | constructor(type: string, eventInitDict: ErrorEventInit = {}) { 29 | super(type, eventInitDict); 30 | const { message = "error", filename = "", lineno = 0, colno = 0, error } = 31 | eventInitDict; 32 | this.#message = message; 33 | this.#filename = filename; 34 | this.#lineno = lineno; 35 | this.#colno = colno; 36 | this.#error = error; 37 | } 38 | } 39 | 40 | if (!("ErrorEvent" in globalThis)) { 41 | Object.defineProperty(globalThis, "ErrorEvent", { 42 | value: ErrorEvent, 43 | writable: true, 44 | enumerable: false, 45 | configurable: true, 46 | }); 47 | } 48 | 49 | if (!("ReadableStream" in globalThis) || !("TransformStream" in globalThis)) { 50 | (async () => { 51 | const { ReadableStream, TransformStream } = await import("node:stream/web"); 52 | Object.defineProperties(globalThis, { 53 | "ReadableStream": { 54 | value: ReadableStream, 55 | writable: true, 56 | enumerable: false, 57 | configurable: true, 58 | }, 59 | "TransformStream": { 60 | value: TransformStream, 61 | writable: true, 62 | enumerable: false, 63 | configurable: true, 64 | }, 65 | }); 66 | })(); 67 | } 68 | -------------------------------------------------------------------------------- /request.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // deno-lint-ignore-file 4 | 5 | import { assert, isHttpError, Status } from "./deps.ts"; 6 | import { 7 | assertEquals, 8 | assertRejects, 9 | assertStrictEquals, 10 | } from "./deps_test.ts"; 11 | import { NativeRequest } from "./http_server_native_request.ts"; 12 | import type { NativeRequestInfo } from "./http_server_native_request.ts"; 13 | import { Request } from "./request.ts"; 14 | import { isNode } from "./utils/type_guards.ts"; 15 | 16 | function createMockNativeRequest( 17 | url = "http://localhost/index.html", 18 | requestInit: RequestInit = {}, 19 | options: NativeRequestInfo = {}, 20 | ) { 21 | const request: globalThis.Request = new (globalThis as any).Request( 22 | url, 23 | requestInit, 24 | ); 25 | 26 | return new NativeRequest(request, options); 27 | } 28 | 29 | Deno.test({ 30 | name: "request.searchParams", 31 | fn() { 32 | const request = new Request( 33 | createMockNativeRequest("http://localhost/foo?bar=baz&qat=qux"), 34 | {}, 35 | ); 36 | assertEquals(request.url.pathname, "/foo"); 37 | assertEquals(request.url.search, "?bar=baz&qat=qux"); 38 | assertEquals(request.method, "GET"); 39 | assertEquals(Array.from(request.url.searchParams.entries()), [ 40 | ["bar", "baz"], 41 | ["qat", "qux"], 42 | ]); 43 | }, 44 | }); 45 | 46 | Deno.test({ 47 | name: "request.url", 48 | fn() { 49 | const mockServerRequest = createMockNativeRequest( 50 | "https://oakserver.github.io:8080/foo/bar/baz?up=down", 51 | ); 52 | const request = new Request(mockServerRequest, { 53 | proxy: false, 54 | secure: true, 55 | }); 56 | assert(request.url instanceof URL); 57 | assertEquals(request.url.protocol, "https:"); 58 | assertEquals(request.url.hostname, "oakserver.github.io"); 59 | assertEquals(request.url.host, "oakserver.github.io:8080"); 60 | assertEquals(request.url.pathname, "/foo/bar/baz"); 61 | }, 62 | }); 63 | 64 | Deno.test({ 65 | name: "request.userAgent", 66 | fn() { 67 | const mockServerRequest = createMockNativeRequest( 68 | "https://localhost/index.html", 69 | { 70 | headers: { 71 | "User-Agent": 72 | "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36", 73 | }, 74 | }, 75 | ); 76 | const request = new Request(mockServerRequest); 77 | assertStrictEquals(request.userAgent.browser.name, "Chrome"); 78 | assertStrictEquals(request.userAgent.device.model, "Macintosh"); 79 | }, 80 | }); 81 | 82 | Deno.test({ 83 | name: "request.serverRequest", 84 | fn() { 85 | const mockServerRequest = createMockNativeRequest(); 86 | const request = new Request(mockServerRequest); 87 | assertStrictEquals(request.originalRequest, mockServerRequest); 88 | }, 89 | }); 90 | 91 | Deno.test({ 92 | name: "request.acceptsEncodings", 93 | fn() { 94 | const request = new Request( 95 | createMockNativeRequest("https://localhost/index.html", { 96 | headers: { 97 | "Accept-Encoding": "gzip, compress;q=0.2, identity;q=0.5", 98 | }, 99 | }), 100 | ); 101 | assertEquals(request.acceptsEncodings("gzip", "identity"), "gzip"); 102 | }, 103 | }); 104 | 105 | Deno.test({ 106 | name: "request.acceptsEncodings - no header", 107 | fn() { 108 | const request = new Request( 109 | createMockNativeRequest("https://localhost/index.html"), 110 | ); 111 | assertEquals(request.acceptsEncodings("gzip", "identity"), "gzip"); 112 | }, 113 | }); 114 | 115 | Deno.test({ 116 | name: "request.acceptsEncodings - no header no encodings", 117 | fn() { 118 | const request = new Request( 119 | createMockNativeRequest("https://localhost/index.html"), 120 | ); 121 | assertEquals(request.acceptsEncodings(), ["*"]); 122 | }, 123 | }); 124 | 125 | Deno.test({ 126 | name: "request.accepts()", 127 | fn() { 128 | const request = new Request( 129 | createMockNativeRequest("https://localhost/index.html", { 130 | headers: { 131 | Accept: "application/json;q=0.2, text/html", 132 | }, 133 | }), 134 | ); 135 | assertEquals(request.accepts("application/json", "text/html"), "text/html"); 136 | }, 137 | }); 138 | 139 | Deno.test({ 140 | name: "request.accepts not provided", 141 | fn() { 142 | const request = new Request( 143 | createMockNativeRequest("https://localhost/index.html", { 144 | headers: { 145 | Accept: "application/json;q=0.2, text/html", 146 | }, 147 | }), 148 | ); 149 | assertEquals(request.accepts(), ["text/html", "application/json"]); 150 | }, 151 | }); 152 | 153 | Deno.test({ 154 | name: "request.accepts no header", 155 | fn() { 156 | const request = new Request(createMockNativeRequest("https://localhost/")); 157 | assertEquals(request.accepts("application/json"), "application/json"); 158 | }, 159 | }); 160 | 161 | Deno.test({ 162 | name: "request.accepts no header, no args", 163 | fn() { 164 | const request = new Request(createMockNativeRequest("https://localhost/")); 165 | assertEquals(request.accepts(), ["*/*"]); 166 | }, 167 | }); 168 | 169 | Deno.test({ 170 | name: "request.accepts no match", 171 | fn() { 172 | const request = new Request( 173 | createMockNativeRequest("https://localhost/index.html", { 174 | headers: { Accept: "text/html" }, 175 | }), 176 | ); 177 | assertEquals(request.accepts("application/json"), undefined); 178 | }, 179 | }); 180 | 181 | Deno.test({ 182 | name: "request.body()", 183 | async fn() { 184 | const body = JSON.stringify({ hello: "world" }); 185 | const request = new Request( 186 | createMockNativeRequest("https://localhost/index.html", { 187 | body, 188 | method: "POST", 189 | headers: { 190 | "content-type": "application/json", 191 | "content-length": String(body.length), 192 | }, 193 | }), 194 | ); 195 | assert(request.hasBody); 196 | assert(request.body.has); 197 | const actual = await request.body.json(); 198 | assertEquals(actual, { hello: "world" }); 199 | }, 200 | }); 201 | 202 | Deno.test({ 203 | name: "request.secure is false", 204 | fn() { 205 | const request = new Request(createMockNativeRequest()); 206 | assertEquals(request.secure, false); 207 | }, 208 | }); 209 | 210 | Deno.test({ 211 | name: "request.secure is true", 212 | fn() { 213 | const request = new Request( 214 | createMockNativeRequest("https://localhost/index.html"), 215 | { proxy: false, secure: true }, 216 | ); 217 | assertEquals(request.secure, true); 218 | }, 219 | }); 220 | 221 | Deno.test({ 222 | name: "request with proxy true", 223 | fn() { 224 | const request = new Request( 225 | createMockNativeRequest("https://example.com/index.html", { 226 | headers: { 227 | "x-forwarded-host": "10.10.10.10", 228 | "x-forwarded-proto": "http", 229 | "x-forwarded-for": "10.10.10.10, 192.168.1.1, 10.255.255.255", 230 | }, 231 | }, { 232 | remoteAddr: { 233 | transport: "tcp", 234 | port: 8080, 235 | hostname: "10.255.255.255", 236 | }, 237 | }), 238 | { proxy: true, secure: true }, 239 | ); 240 | assertEquals(request.secure, true); 241 | assertEquals(request.url.hostname, "10.10.10.10"); 242 | assertEquals(request.url.protocol, "http:"); 243 | assertEquals(request.ip, "10.10.10.10"); 244 | assertEquals(request.ips, ["10.10.10.10", "192.168.1.1", "10.255.255.255"]); 245 | }, 246 | }); 247 | 248 | Deno.test({ 249 | name: "request with invalid JSON", 250 | async fn() { 251 | const body = "random text, but not JSON"; 252 | const request = new Request( 253 | createMockNativeRequest("http://localhost/index.html", { 254 | body, 255 | method: "POST", 256 | headers: { 257 | "content-type": "application/json", 258 | "content-length": String(body.length), 259 | }, 260 | }), 261 | ); 262 | assert(request.hasBody, "should have body"); 263 | const err = await assertRejects( 264 | async () => { 265 | await request.body.json(); 266 | }, 267 | ); 268 | assert(isHttpError(err)); 269 | assertEquals(err.status, Status.BadRequest); 270 | }, 271 | }); 272 | 273 | Deno.test({ 274 | name: "Request - inspecting", 275 | fn() { 276 | assertEquals( 277 | Deno.inspect( 278 | new Request( 279 | createMockNativeRequest("http://localhost/foo?bar=baz&qat=qux", { 280 | headers: { host: "localhost" }, 281 | }), 282 | ), 283 | ), 284 | isNode() 285 | ? `Request {\n body: Body { has: false, used: false },\n hasBody: false,\n headers: HeadersList {\n cookies: null,\n [Symbol(headers map)]: [Map],\n [Symbol(headers map sorted)]: null\n },\n ip: '',\n ips: [],\n method: 'GET',\n secure: false,\n url: URL {\n href: 'http://localhost/foo?bar=baz&qat=qux',\n origin: 'http://localhost',\n protocol: 'http:',\n username: '',\n password: '',\n host: 'localhost',\n hostname: 'localhost',\n port: '',\n pathname: '/foo',\n search: '?bar=baz&qat=qux',\n searchParams: URLSearchParams { 'bar' => 'baz', 'qat' => 'qux' },\n hash: ''\n },\n userAgent: UserAgent {\n browser: [Object],\n cpu: [Object],\n device: [Object],\n engine: [Object],\n os: [Object],\n ua: ''\n }\n}` 286 | : `Request {\n body: Body { has: false, used: false },\n hasBody: false,\n headers: Headers { host: "localhost" },\n ip: "",\n ips: [],\n method: "GET",\n secure: false,\n url: "http://localhost/foo?bar=baz&qat=qux",\n userAgent: UserAgent {\n browser: { name: undefined, version: undefined, major: undefined },\n cpu: { architecture: undefined },\n device: { model: undefined, type: undefined, vendor: undefined },\n engine: { name: undefined, version: undefined },\n os: { name: undefined, version: undefined },\n ua: ""\n}\n}`, 287 | ); 288 | }, 289 | }); 290 | -------------------------------------------------------------------------------- /send.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Adapted from koa-send at https://github.com/koajs/send and which is licensed 3 | * with the MIT license. 4 | */ 5 | 6 | /** 7 | * Contains the send function which can be used to send static assets while 8 | * supporting a range of HTTP capabilities. 9 | * 10 | * This is integrated into the oak context via the `.send()` method. 11 | * 12 | * @module 13 | */ 14 | 15 | import type { Context } from "./context.ts"; 16 | import { 17 | basename, 18 | type ByteRange, 19 | contentType, 20 | createHttpError, 21 | eTag, 22 | extname, 23 | type FileInfo, 24 | ifNoneMatch, 25 | parse, 26 | range, 27 | responseRange, 28 | Status, 29 | } from "./deps.ts"; 30 | import type { Response } from "./response.ts"; 31 | import { isNode } from "./utils/type_guards.ts"; 32 | import { decode } from "./utils/decode.ts"; 33 | import { resolvePath } from "./utils/resolve_path.ts"; 34 | 35 | if (isNode()) { 36 | console.warn("oak send() does not work under Node.js."); 37 | } 38 | 39 | const MAXBUFFER_DEFAULT = 1_048_576; // 1MiB; 40 | 41 | /** Options which can be specified when using the {@linkcode send} 42 | * middleware. */ 43 | export interface SendOptions { 44 | /** Try to serve the brotli version of a file automatically when brotli is 45 | * supported by a client and if the requested file with `.br` extension 46 | * exists. (defaults to `true`) */ 47 | brotli?: boolean; 48 | 49 | /** A record of extensions and content types that should be used when 50 | * determining the content of a file being served. By default, the 51 | * [`media_type`](https://github.com/oakserver/media_types/) database is used 52 | * to map an extension to the served content-type. The keys of the map are 53 | * extensions, and values are the content types to use. The content type can 54 | * be a partial content type, which will be resolved to a full content type 55 | * header. 56 | * 57 | * Any extensions matched will override the default behavior. Key should 58 | * include the leading dot (e.g. `.ext` instead of just `ext`). 59 | * 60 | * ### Example 61 | * 62 | * ```ts 63 | * app.use((ctx) => { 64 | * return send(ctx, ctx.request.url.pathname, { 65 | * contentTypes: { 66 | * ".importmap": "application/importmap+json" 67 | * }, 68 | * root: ".", 69 | * }) 70 | * }); 71 | * ``` 72 | */ 73 | contentTypes?: Record; 74 | 75 | /** Try to match extensions from passed array to search for file when no 76 | * extension is sufficed in URL. First found is served. (defaults to 77 | * `undefined`) */ 78 | extensions?: string[]; 79 | 80 | /** If `true`, format the path to serve static file servers and not require a 81 | * trailing slash for directories, so that you can do both `/directory` and 82 | * `/directory/`. (defaults to `true`) */ 83 | format?: boolean; 84 | 85 | /** Try to serve the gzipped version of a file automatically when gzip is 86 | * supported by a client and if the requested file with `.gz` extension 87 | * exists. (defaults to `true`). */ 88 | gzip?: boolean; 89 | 90 | /** Allow transfer of hidden files. (defaults to `false`) */ 91 | hidden?: boolean; 92 | 93 | /** Tell the browser the resource is immutable and can be cached 94 | * indefinitely. (defaults to `false`) */ 95 | immutable?: boolean; 96 | 97 | /** Name of the index file to serve automatically when visiting the root 98 | * location. (defaults to none) */ 99 | index?: string; 100 | 101 | /** Browser cache max-age in milliseconds. (defaults to `0`) */ 102 | maxage?: number; 103 | 104 | /** A size in bytes where if the file is less than this size, the file will 105 | * be read into memory by send instead of returning a file handle. Files less 106 | * than the byte size will send an "strong" `ETag` header while those larger 107 | * than the bytes size will only be able to send a "weak" `ETag` header (as 108 | * they cannot hash the contents of the file). (defaults to 1MiB) 109 | */ 110 | maxbuffer?: number; 111 | 112 | /** Root directory to restrict file access. */ 113 | root: string; 114 | } 115 | 116 | function isHidden(path: string) { 117 | const pathArr = path.split("/"); 118 | for (const segment of pathArr) { 119 | if (segment[0] === "." && segment !== "." && segment !== "..") { 120 | return true; 121 | } 122 | return false; 123 | } 124 | } 125 | 126 | async function exists(path: string): Promise { 127 | try { 128 | return (await Deno.stat(path)).isFile; 129 | } catch { 130 | return false; 131 | } 132 | } 133 | 134 | async function getEntity( 135 | path: string, 136 | mtime: number, 137 | stats: Deno.FileInfo, 138 | maxbuffer: number, 139 | response: Response, 140 | ): Promise<[Uint8Array | Deno.FsFile, Uint8Array | FileInfo, FileInfo]> { 141 | let body: Uint8Array | Deno.FsFile; 142 | let entity: Uint8Array | FileInfo; 143 | const fileInfo = { mtime: new Date(mtime), size: stats.size }; 144 | if (stats.size < maxbuffer) { 145 | const buffer = await Deno.readFile(path); 146 | body = entity = buffer; 147 | } else { 148 | const file = await Deno.open(path, { read: true }); 149 | response.addResource(file); 150 | body = file; 151 | entity = fileInfo; 152 | } 153 | return [body, entity, fileInfo]; 154 | } 155 | 156 | /** Asynchronously fulfill a response with a file from the local file 157 | * system. 158 | * 159 | * Requires Deno read permission for the `root` directory. */ 160 | export async function send( 161 | // deno-lint-ignore no-explicit-any 162 | { request, response }: Context, 163 | path: string, 164 | options: SendOptions = { root: "" }, 165 | ): Promise { 166 | const { 167 | brotli = true, 168 | contentTypes = {}, 169 | extensions, 170 | format = true, 171 | gzip = true, 172 | hidden = false, 173 | immutable = false, 174 | index, 175 | maxbuffer = MAXBUFFER_DEFAULT, 176 | maxage = 0, 177 | root, 178 | } = options; 179 | const trailingSlash = path[path.length - 1] === "/"; 180 | path = decode(path.substring(parse(path).root.length)); 181 | if (index && trailingSlash) { 182 | path += index; 183 | } 184 | 185 | if (!hidden && isHidden(path)) { 186 | throw createHttpError(403); 187 | } 188 | 189 | path = resolvePath(root, path); 190 | 191 | let encodingExt = ""; 192 | if ( 193 | brotli && 194 | request.acceptsEncodings("br", "identity") === "br" && 195 | (await exists(`${path}.br`)) 196 | ) { 197 | path = `${path}.br`; 198 | response.headers.set("Content-Encoding", "br"); 199 | response.headers.delete("Content-Length"); 200 | encodingExt = ".br"; 201 | } else if ( 202 | gzip && 203 | request.acceptsEncodings("gzip", "identity") === "gzip" && 204 | (await exists(`${path}.gz`)) 205 | ) { 206 | path = `${path}.gz`; 207 | response.headers.set("Content-Encoding", "gzip"); 208 | response.headers.delete("Content-Length"); 209 | encodingExt = ".gz"; 210 | } 211 | 212 | if (extensions && !/\.[^/]*$/.exec(path)) { 213 | for (let ext of extensions) { 214 | if (!/^\./.exec(ext)) { 215 | ext = `.${ext}`; 216 | } 217 | if (await exists(`${path}${ext}`)) { 218 | path += ext; 219 | break; 220 | } 221 | } 222 | } 223 | 224 | let stats: Deno.FileInfo; 225 | try { 226 | stats = await Deno.stat(path); 227 | 228 | if (stats.isDirectory) { 229 | if (format && index) { 230 | path += `/${index}`; 231 | stats = await Deno.stat(path); 232 | } else { 233 | return; 234 | } 235 | } 236 | } catch (err) { 237 | if (err instanceof Deno.errors.NotFound) { 238 | throw createHttpError(404, err.message); 239 | } 240 | // TODO(@kitsonk) remove when https://github.com/denoland/node_deno_shims/issues/87 resolved 241 | if (err instanceof Error && err.message.startsWith("ENOENT:")) { 242 | throw createHttpError(404, err.message); 243 | } 244 | throw createHttpError( 245 | 500, 246 | err instanceof Error ? err.message : "[non-error thrown]", 247 | ); 248 | } 249 | 250 | let mtime: number | null = null; 251 | if (response.headers.has("Last-Modified")) { 252 | mtime = new Date(response.headers.get("Last-Modified")!).getTime(); 253 | } else if (stats.mtime) { 254 | // Round down to second because it's the precision of the UTC string. 255 | mtime = stats.mtime.getTime(); 256 | mtime -= mtime % 1000; 257 | response.headers.set("Last-Modified", new Date(mtime).toUTCString()); 258 | } 259 | 260 | if (!response.headers.has("Cache-Control")) { 261 | const directives = [`max-age=${(maxage / 1000) | 0}`]; 262 | if (immutable) { 263 | directives.push("immutable"); 264 | } 265 | response.headers.set("Cache-Control", directives.join(",")); 266 | } 267 | if (!response.type) { 268 | response.type = encodingExt !== "" 269 | ? extname(basename(path, encodingExt)) 270 | : contentTypes[extname(path)] ?? extname(path); 271 | } 272 | 273 | let entity: Uint8Array | FileInfo | null = null; 274 | let body: Uint8Array | Deno.FsFile | null = null; 275 | let fileInfo: FileInfo | null = null; 276 | 277 | if (request.headers.has("If-None-Match") && mtime) { 278 | [body, entity, fileInfo] = await getEntity( 279 | path, 280 | mtime, 281 | stats, 282 | maxbuffer, 283 | response, 284 | ); 285 | const etag = await eTag(entity as FileInfo); 286 | if ( 287 | etag && (!ifNoneMatch(request.headers.get("If-None-Match")!, etag)) 288 | ) { 289 | response.headers.set("ETag", etag); 290 | response.status = 304; 291 | return path; 292 | } 293 | } 294 | 295 | if (request.headers.has("If-Modified-Since") && mtime) { 296 | const ifModifiedSince = new Date(request.headers.get("If-Modified-Since")!); 297 | if (ifModifiedSince.getTime() >= mtime) { 298 | response.status = 304; 299 | return path; 300 | } 301 | } 302 | 303 | if (!body || !entity || !fileInfo) { 304 | [body, entity, fileInfo] = await getEntity( 305 | path, 306 | mtime ?? 0, 307 | stats, 308 | maxbuffer, 309 | response, 310 | ); 311 | } 312 | 313 | let returnRanges: ByteRange[] | undefined = undefined; 314 | let size: number | undefined = undefined; 315 | 316 | if (request.source && body && entity) { 317 | const { ok, ranges } = ArrayBuffer.isView(body) 318 | ? await range(request.source, body, fileInfo) 319 | : await range(request.source, fileInfo); 320 | if (ok && ranges) { 321 | size = ArrayBuffer.isView(entity) ? entity.byteLength : entity.size; 322 | returnRanges = ranges; 323 | } else if (!ok) { 324 | response.status = Status.RequestedRangeNotSatisfiable; 325 | } 326 | } 327 | 328 | if (!response.headers.has("ETag")) { 329 | const etag = await eTag(entity as FileInfo); 330 | if (etag) { 331 | response.headers.set("ETag", etag); 332 | } 333 | } 334 | 335 | if (returnRanges && size) { 336 | response.with( 337 | responseRange(body, size, returnRanges, { headers: response.headers }, { 338 | type: response.type ? contentType(response.type) : "", 339 | }), 340 | ); 341 | } else { 342 | response.body = body; 343 | } 344 | 345 | return path; 346 | } 347 | -------------------------------------------------------------------------------- /testing.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assert } from "./deps.ts"; 4 | import { assertEquals, assertStrictEquals } from "./deps_test.ts"; 5 | 6 | import { 7 | createMockApp, 8 | createMockContext, 9 | createMockNext, 10 | mockContextState, 11 | } from "./testing.ts"; 12 | 13 | Deno.test({ 14 | name: "testing - createMockApp()", 15 | fn() { 16 | const app = createMockApp(); 17 | assertEquals(app.state, {}); 18 | }, 19 | }); 20 | 21 | Deno.test({ 22 | name: "testing - createMockApp() - with state", 23 | fn() { 24 | const app = createMockApp({ a: "a" }); 25 | assertEquals(app.state, { a: "a" }); 26 | }, 27 | }); 28 | 29 | Deno.test({ 30 | name: "testing - createMockContext()", 31 | fn() { 32 | const ctx = createMockContext(); 33 | assert(ctx.app); 34 | assertEquals(ctx.request.method, "GET"); 35 | assertStrictEquals(ctx.params, undefined); 36 | assertEquals(ctx.request.url.pathname, "/"); 37 | assertEquals(ctx.state, {}); 38 | assertEquals(ctx.request.acceptsEncodings("identity"), "identity"); 39 | ctx.response.redirect("/hello/world"); 40 | assertEquals(ctx.response.headers.get("Location"), "/hello/world"); 41 | }, 42 | }); 43 | 44 | Deno.test({ 45 | name: "testing - mockContextState", 46 | fn() { 47 | mockContextState.encodingsAccepted = "gzip"; 48 | const ctx = createMockContext(); 49 | try { 50 | assertEquals(ctx.request.acceptsEncodings("gzip"), "gzip"); 51 | } finally { 52 | mockContextState.encodingsAccepted = "identity"; 53 | } 54 | }, 55 | }); 56 | 57 | Deno.test({ 58 | name: "testing - ctx.cookies.set()", 59 | async fn() { 60 | const ctx = createMockContext(); 61 | await ctx.cookies.set( 62 | "sessionID", 63 | "S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro", 64 | { httpOnly: true }, 65 | ); 66 | assertEquals([...ctx.response.headers], [ 67 | [ 68 | "set-cookie", 69 | "sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro; path=/; httponly", 70 | ], 71 | ]); 72 | }, 73 | }); 74 | 75 | Deno.test({ 76 | name: "testing - ctx.cookies.get()", 77 | async fn() { 78 | const ctx = createMockContext({ 79 | headers: [[ 80 | "cookie", 81 | "sessionID=S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro;", 82 | ]], 83 | }); 84 | assertEquals( 85 | await ctx.cookies.get("sessionID"), 86 | "S7GhXzJF3n4j8JwTupr7H-h25qtt_vs0stdETXZb-Ro", 87 | ); 88 | }, 89 | }); 90 | 91 | Deno.test({ 92 | name: "testing - createMockNext()", 93 | fn() { 94 | const next = createMockNext(); 95 | assertStrictEquals(typeof next, "function"); 96 | assert(next() instanceof Promise); 97 | }, 98 | }); 99 | -------------------------------------------------------------------------------- /testing.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | // deno-lint-ignore-file no-explicit-any 4 | 5 | /** 6 | * A collection of utility APIs which can make testing of an oak application 7 | * easier. 8 | * 9 | * @module 10 | */ 11 | 12 | import type { Application, State } from "./application.ts"; 13 | import { 14 | accepts, 15 | createHttpError, 16 | type ErrorStatus, 17 | SecureCookieMap, 18 | } from "./deps.ts"; 19 | import { Body } from "./body.ts"; 20 | import type { RouteParams, RouterContext } from "./router.ts"; 21 | import type { Request } from "./request.ts"; 22 | import { Response } from "./response.ts"; 23 | 24 | /** Creates a mock of `Application`. */ 25 | export function createMockApp< 26 | S extends Record = Record, 27 | >( 28 | state: S = {} as S, 29 | ): Application { 30 | const app = { 31 | state, 32 | use() { 33 | return app; 34 | }, 35 | [Symbol.for("Deno.customInspect")]() { 36 | return "MockApplication {}"; 37 | }, 38 | [Symbol.for("nodejs.util.inspect.custom")]( 39 | depth: number, 40 | options: any, 41 | inspect: (value: unknown, options?: unknown) => string, 42 | ) { 43 | if (depth < 0) { 44 | return options.stylize(`[MockApplication]`, "special"); 45 | } 46 | 47 | const newOptions = Object.assign({}, options, { 48 | depth: options.depth === null ? null : options.depth - 1, 49 | }); 50 | return `${options.stylize("MockApplication", "special")} ${ 51 | inspect({}, newOptions) 52 | }`; 53 | }, 54 | } as any; 55 | return app; 56 | } 57 | 58 | /** Options that can be set in a mock context. */ 59 | export interface MockContextOptions< 60 | R extends string, 61 | P extends RouteParams = RouteParams, 62 | S extends State = Record, 63 | > { 64 | app?: Application; 65 | ip?: string; 66 | method?: string; 67 | params?: P; 68 | path?: string; 69 | state?: S; 70 | headers?: [string, string][]; 71 | body?: ReadableStream; 72 | } 73 | 74 | /** Allows external parties to modify the context state. */ 75 | export const mockContextState = { 76 | /** Adjusts the return value of the `acceptedEncodings` in the context's 77 | * `request` object. */ 78 | encodingsAccepted: "identity", 79 | }; 80 | 81 | /** Create a mock of `Context` or `RouterContext`. */ 82 | export function createMockContext< 83 | R extends string, 84 | P extends RouteParams = RouteParams, 85 | S extends State = Record, 86 | >( 87 | { 88 | ip = "127.0.0.1", 89 | method = "GET", 90 | params, 91 | path = "/", 92 | state, 93 | app = createMockApp(state), 94 | headers: requestHeaders, 95 | body = undefined, 96 | }: MockContextOptions = {}, 97 | ): RouterContext { 98 | function createMockRequest(): Request { 99 | const headers = new Headers(requestHeaders); 100 | return { 101 | get source(): globalThis.Request | undefined { 102 | return new globalThis.Request(new URL(path, "http://localhost/"), { 103 | method, 104 | headers, 105 | }); 106 | }, 107 | accepts(...types: string[]) { 108 | if (!headers.has("Accept")) { 109 | return; 110 | } 111 | if (types.length) { 112 | return accepts({ headers }, ...types); 113 | } 114 | return accepts({ headers }); 115 | }, 116 | acceptsEncodings() { 117 | return mockContextState.encodingsAccepted; 118 | }, 119 | headers, 120 | ip, 121 | method, 122 | path, 123 | search: undefined, 124 | searchParams: new URLSearchParams(), 125 | url: new URL(path, "http://localhost/"), 126 | hasBody: !!body, 127 | body: body ? new Body({ headers, getBody: () => body }) : undefined, 128 | } as any; 129 | } 130 | 131 | const request = createMockRequest(); 132 | const response = new Response(request); 133 | const cookies = new SecureCookieMap(request, { response }); 134 | 135 | return ({ 136 | app, 137 | params, 138 | request, 139 | cookies, 140 | response, 141 | state: Object.assign({}, app.state), 142 | assert( 143 | condition: any, 144 | errorStatus: ErrorStatus = 500, 145 | message?: string, 146 | props?: Record, 147 | ): asserts condition { 148 | if (condition) { 149 | return; 150 | } 151 | const err = createHttpError(errorStatus, message); 152 | if (props) { 153 | Object.assign(err, props); 154 | } 155 | throw err; 156 | }, 157 | throw( 158 | errorStatus: ErrorStatus, 159 | message?: string, 160 | props?: Record, 161 | ): never { 162 | const err = createHttpError(errorStatus, message); 163 | if (props) { 164 | Object.assign(err, props); 165 | } 166 | throw err; 167 | }, 168 | [Symbol.for("Deno.customInspect")]() { 169 | return `MockContext {}`; 170 | }, 171 | [Symbol.for("nodejs.util.inspect.custom")]( 172 | depth: number, 173 | options: any, 174 | inspect: (value: unknown, options?: unknown) => string, 175 | ) { 176 | if (depth < 0) { 177 | return options.stylize(`[MockContext]`, "special"); 178 | } 179 | 180 | const newOptions = Object.assign({}, options, { 181 | depth: options.depth === null ? null : options.depth - 1, 182 | }); 183 | return `${options.stylize("MockContext", "special")} ${ 184 | inspect({}, newOptions) 185 | }`; 186 | }, 187 | } as unknown) as RouterContext; 188 | } 189 | 190 | /** Creates a mock `next()` function which can be used when calling 191 | * middleware. */ 192 | export function createMockNext(): () => Promise { 193 | return async function next() {}; 194 | } 195 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import type { Application, State } from "./application.ts"; 4 | 5 | export interface Listener { 6 | addr: { hostname: string; port: number }; 7 | } 8 | 9 | export interface OakServer extends AsyncIterable { 10 | close(): void | Promise; 11 | listen(): Listener | Promise; 12 | [Symbol.asyncIterator](): AsyncIterableIterator; 13 | } 14 | 15 | export interface ServerRequest { 16 | readonly headers: Headers; 17 | readonly method: string; 18 | readonly rawUrl?: string; 19 | readonly remoteAddr: string | undefined; 20 | readonly request?: Request; 21 | readonly url: string; 22 | // deno-lint-ignore no-explicit-any 23 | error(reason?: any): void; 24 | getBody(): ReadableStream | null; 25 | respond(response: Response): void | Promise; 26 | upgrade?(options?: UpgradeWebSocketOptions): WebSocket; 27 | } 28 | 29 | /** The abstract constructor interface that custom servers need to adhere to 30 | * when using with an {@linkcode Application}. */ 31 | export interface ServerConstructor { 32 | // deno-lint-ignore no-explicit-any 33 | new >( 34 | app: Application, 35 | options: Omit, 36 | ): OakServer; 37 | prototype: OakServer; 38 | type?: "native" | "node" | "bun"; 39 | } 40 | 41 | export type Data = string | number[] | ArrayBuffer | Uint8Array; 42 | export type Key = string | number[] | ArrayBuffer | Uint8Array; 43 | 44 | export interface UpgradeWebSocketOptions { 45 | protocol?: string; 46 | } 47 | 48 | export type UpgradeWebSocketFn = ( 49 | request: Request, 50 | options?: UpgradeWebSocketOptions, 51 | ) => WebSocketUpgrade; 52 | 53 | interface WebSocketUpgrade { 54 | response: Response; 55 | socket: WebSocket; 56 | } 57 | 58 | export interface NetAddr { 59 | transport: "tcp" | "udp"; 60 | hostname: string; 61 | port: number; 62 | } 63 | 64 | export interface ServeHandlerInfo { 65 | remoteAddr: Deno.NetAddr; 66 | } 67 | 68 | export type ServeHandler = ( 69 | request: Request, 70 | info: ServeHandlerInfo, 71 | ) => Response | Promise; 72 | 73 | export interface ServeOptions { 74 | port?: number; 75 | hostname?: string; 76 | signal?: AbortSignal; 77 | reusePort?: boolean; 78 | onError?: (error: unknown) => Response | Promise; 79 | onListen?: (params: { hostname: string; port: number }) => void; 80 | } 81 | 82 | export interface ServeTlsOptions extends ServeOptions { 83 | cert: string; 84 | key: string; 85 | } 86 | 87 | export interface ServeInit { 88 | handler: ServeHandler; 89 | } 90 | 91 | export interface HttpServer extends AsyncDisposable { 92 | finished: Promise; 93 | ref(): void; 94 | unref(): void; 95 | shutdown(): Promise; 96 | } 97 | -------------------------------------------------------------------------------- /utils/clone_state.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals } from "../deps_test.ts"; 4 | 5 | import { assert } from "../deps.ts"; 6 | import { cloneState } from "./clone_state.ts"; 7 | 8 | Deno.test({ 9 | name: "basic cloning", 10 | fn() { 11 | const fixture = { a: "a", b: 2, c: true }; 12 | const actual = cloneState(fixture); 13 | assert(actual !== fixture); 14 | assertEquals(actual, fixture); 15 | }, 16 | }); 17 | 18 | Deno.test({ 19 | name: "cloning state with functions", 20 | fn() { 21 | const fixture = { a: "a", b: () => {}, c: true }; 22 | const actual = cloneState(fixture); 23 | // @ts-ignore we shouldn't have type inference in asserts! 24 | assertEquals(actual, { a: "a", c: true }); 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /utils/clone_state.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** Clones a state object, skipping any values that cannot be cloned. */ 4 | // deno-lint-ignore no-explicit-any 5 | export function cloneState>(state: S): S { 6 | const clone = {} as S; 7 | for (const [key, value] of Object.entries(state)) { 8 | try { 9 | const clonedValue = structuredClone(value); 10 | clone[key as keyof S] = clonedValue; 11 | } catch { 12 | // we just no-op values that cannot be cloned 13 | } 14 | } 15 | return clone; 16 | } 17 | -------------------------------------------------------------------------------- /utils/consts.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** Body types which will be coerced into strings before being sent. */ 4 | export const BODY_TYPES = ["string", "number", "bigint", "boolean", "symbol"]; 5 | -------------------------------------------------------------------------------- /utils/create_promise_with_resolvers.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** Memoisation of the feature detection of `Promise.withResolvers` */ 4 | const hasPromiseWithResolvers = "withResolvers" in Promise; 5 | 6 | /** 7 | * Offloads to the native `Promise.withResolvers` when available. 8 | * 9 | * Currently Node.js does not support it, while Deno does. 10 | */ 11 | export function createPromiseWithResolvers(): PromiseWithResolvers { 12 | if (hasPromiseWithResolvers) { 13 | return Promise.withResolvers(); 14 | } 15 | let resolve; 16 | let reject; 17 | const promise = new Promise((res, rej) => { 18 | resolve = res; 19 | reject = rej; 20 | }); 21 | return { promise, resolve: resolve!, reject: reject! }; 22 | } 23 | -------------------------------------------------------------------------------- /utils/decode.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assert, isHttpError } from "../deps.ts"; 4 | import { assertEquals } from "../deps_test.ts"; 5 | import { decode } from "./decode.ts"; 6 | 7 | Deno.test({ 8 | name: "decodeComponent - throws HTTP error", 9 | fn() { 10 | try { 11 | decode("%"); 12 | } catch (err) { 13 | assert(isHttpError(err)); 14 | assertEquals(err.status, 400); 15 | assertEquals(err.expose, false); 16 | return; 17 | } 18 | throw Error("unaccessible code"); 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /utils/decode.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { createHttpError } from "../deps.ts"; 4 | 5 | /** 6 | * Safely decode a URI component, where if it fails, instead of throwing, 7 | * just returns the original string 8 | */ 9 | export function decode(pathname: string): string { 10 | try { 11 | return decodeURI(pathname); 12 | } catch (err) { 13 | if (err instanceof URIError) { 14 | throw createHttpError(400, "Failed to decode URI", { expose: false }); 15 | } 16 | throw err; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /utils/decode_component.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { assertEquals } from "../deps_test.ts"; 4 | import { decodeComponent } from "./decode_component.ts"; 5 | 6 | Deno.test({ 7 | name: "decodeComponent", 8 | fn() { 9 | // with decodeURIComponent, this would throw: 10 | assertEquals(decodeComponent("%"), "%"); 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /utils/decode_component.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | /** 4 | * Safely decode a URI component, where if it fails, instead of throwing, 5 | * just returns the original string 6 | */ 7 | export function decodeComponent(text: string) { 8 | try { 9 | return decodeURIComponent(text); 10 | } catch { 11 | return text; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /utils/encode_url.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | const UNMATCHED_SURROGATE_PAIR_REGEXP = 4 | /(^|[^\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF]([^\uDC00-\uDFFF]|$)/g; 5 | const UNMATCHED_SURROGATE_PAIR_REPLACE = "$1\uFFFD$2"; 6 | const ENCODE_CHARS_REGEXP = 7 | /(?:[^\x21\x25\x26-\x3B\x3D\x3F-\x5B\x5D\x5F\x61-\x7A\x7E]|%(?:[^0-9A-Fa-f]|[0-9A-Fa-f][^0-9A-Fa-f]|$))+/g; 8 | 9 | /** Encodes the url preventing double encoding */ 10 | export function encodeUrl(url: string) { 11 | return String(url) 12 | .replace(UNMATCHED_SURROGATE_PAIR_REGEXP, UNMATCHED_SURROGATE_PAIR_REPLACE) 13 | .replace(ENCODE_CHARS_REGEXP, encodeURI); 14 | } 15 | -------------------------------------------------------------------------------- /utils/resolve_path.test.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { resolvePath } from "./resolve_path.ts"; 4 | 5 | import { assert, errors } from "../deps.ts"; 6 | import { assertEquals, assertThrows } from "../deps_test.ts"; 7 | 8 | Deno.test({ 9 | name: "resolvePath", 10 | fn() { 11 | assertEquals( 12 | resolvePath("./foo/bar").replace(/\\/g, "/"), 13 | `foo/bar`, 14 | ); 15 | }, 16 | }); 17 | 18 | Deno.test({ 19 | name: "resolvePath outside of root", 20 | fn() { 21 | assertThrows(() => { 22 | resolvePath("../foo/bar"); 23 | }, errors.Forbidden); 24 | }, 25 | }); 26 | 27 | Deno.test({ 28 | name: "resolvePath outside of root devious", 29 | fn() { 30 | assertThrows(() => { 31 | resolvePath("foo/../../bar"); 32 | }, errors.Forbidden); 33 | }, 34 | }); 35 | 36 | Deno.test({ 37 | name: "resolvePath absolute", 38 | fn() { 39 | assertThrows( 40 | () => { 41 | resolvePath("/dev/null"); 42 | }, 43 | errors.BadRequest, 44 | "Malicious Path", 45 | ); 46 | }, 47 | }); 48 | 49 | Deno.test({ 50 | name: "resolvePath contains null", 51 | fn() { 52 | assertThrows( 53 | () => { 54 | resolvePath("./foo/bar\0baz"); 55 | }, 56 | errors.BadRequest, 57 | "Malicious Path", 58 | ); 59 | }, 60 | }); 61 | 62 | Deno.test({ 63 | name: "resolvePath from root", 64 | fn() { 65 | assert( 66 | resolvePath("/public", "./foo/bar").replace(/\\/g, "/").endsWith( 67 | "/public/foo/bar", 68 | ), 69 | ); 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /utils/resolve_path.ts: -------------------------------------------------------------------------------- 1 | /*! 2 | * Adapted directly from https://github.com/pillarjs/resolve-path 3 | * which is licensed as follows: 4 | * 5 | * The MIT License (MIT) 6 | * 7 | * Copyright (c) 2014 Jonathan Ong 8 | * Copyright (c) 2015-2018 Douglas Christopher Wilson 9 | * 10 | * Permission is hereby granted, free of charge, to any person obtaining 11 | * a copy of this software and associated documentation files (the 12 | * 'Software'), to deal in the Software without restriction, including 13 | * without limitation the rights to use, copy, modify, merge, publish, 14 | * distribute, sublicense, and/or sell copies of the Software, and to 15 | * permit persons to whom the Software is furnished to do so, subject to 16 | * the following conditions: 17 | * 18 | * The above copyright notice and this permission notice shall be 19 | * included in all copies or substantial portions of the Software. 20 | * 21 | * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 22 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 23 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 24 | * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 25 | * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 26 | * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 27 | * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | */ 29 | 30 | import { 31 | createHttpError, 32 | isAbsolute, 33 | join, 34 | normalize, 35 | SEPARATOR, 36 | } from "../deps.ts"; 37 | 38 | const UP_PATH_REGEXP = /(?:^|[\\/])\.\.(?:[\\/]|$)/; 39 | 40 | export function resolvePath(relativePath: string): string; 41 | export function resolvePath(rootPath: string, relativePath: string): string; 42 | export function resolvePath(rootPath: string, relativePath?: string): string { 43 | let path = relativePath; 44 | let root = rootPath; 45 | 46 | // root is optional, similar to root.resolve 47 | if (relativePath === undefined) { 48 | path = rootPath; 49 | root = "."; 50 | } 51 | 52 | if (path == null) { 53 | throw new TypeError("Argument relativePath is required."); 54 | } 55 | 56 | // containing NULL bytes is malicious 57 | if (path.includes("\0")) { 58 | throw createHttpError(400, "Malicious Path"); 59 | } 60 | 61 | // path should never be absolute 62 | if (isAbsolute(path)) { 63 | throw createHttpError(400, "Malicious Path"); 64 | } 65 | 66 | // path outside root 67 | if (UP_PATH_REGEXP.test(normalize(`.${SEPARATOR}${path}`))) { 68 | throw createHttpError(403); 69 | } 70 | 71 | // join the relative path 72 | return normalize(join(root, path)); 73 | } 74 | -------------------------------------------------------------------------------- /utils/streams.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import { BODY_TYPES } from "./consts.ts"; 4 | 5 | const encoder = new TextEncoder(); 6 | 7 | /** 8 | * Create a `ReadableStream` from an `AsyncIterable`. 9 | */ 10 | export function readableStreamFromAsyncIterable( 11 | source: AsyncIterable, 12 | ): ReadableStream { 13 | return new ReadableStream({ 14 | async start(controller) { 15 | for await (const chunk of source) { 16 | if (BODY_TYPES.includes(typeof chunk)) { 17 | controller.enqueue(encoder.encode(String(chunk))); 18 | } else if (chunk instanceof Uint8Array) { 19 | controller.enqueue(chunk); 20 | } else if (ArrayBuffer.isView(chunk)) { 21 | controller.enqueue(new Uint8Array(chunk.buffer)); 22 | } else if (chunk instanceof ArrayBuffer) { 23 | controller.enqueue(new Uint8Array(chunk)); 24 | } else { 25 | try { 26 | controller.enqueue(encoder.encode(JSON.stringify(chunk))); 27 | } catch { 28 | // we just swallow errors here 29 | } 30 | } 31 | } 32 | controller.close(); 33 | }, 34 | }); 35 | } 36 | 37 | /** A utility class that transforms "any" chunk into an `Uint8Array`. */ 38 | export class Uint8ArrayTransformStream 39 | extends TransformStream { 40 | constructor() { 41 | const init = { 42 | async transform( 43 | chunk: unknown, 44 | controller: TransformStreamDefaultController, 45 | ) { 46 | chunk = await chunk; 47 | switch (typeof chunk) { 48 | case "object": 49 | if (chunk === null) { 50 | controller.terminate(); 51 | } else if (ArrayBuffer.isView(chunk)) { 52 | controller.enqueue( 53 | new Uint8Array( 54 | chunk.buffer, 55 | chunk.byteOffset, 56 | chunk.byteLength, 57 | ), 58 | ); 59 | } else if ( 60 | Array.isArray(chunk) && 61 | chunk.every((value) => typeof value === "number") 62 | ) { 63 | controller.enqueue(new Uint8Array(chunk)); 64 | } else if ( 65 | typeof chunk.valueOf === "function" && chunk.valueOf() !== chunk 66 | ) { 67 | this.transform(chunk.valueOf(), controller); 68 | } else if ("toJSON" in chunk) { 69 | this.transform(JSON.stringify(chunk), controller); 70 | } 71 | break; 72 | case "symbol": 73 | controller.error( 74 | new TypeError("Cannot transform a symbol to a Uint8Array"), 75 | ); 76 | break; 77 | case "undefined": 78 | controller.error( 79 | new TypeError("Cannot transform undefined to a Uint8Array"), 80 | ); 81 | break; 82 | default: 83 | controller.enqueue(this.encoder.encode(String(chunk))); 84 | } 85 | }, 86 | encoder, 87 | }; 88 | super(init); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /utils/type_guards.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2018-2025 the oak authors. All rights reserved. MIT license. 2 | 3 | import type { State } from "../application.ts"; 4 | import type { Context } from "../context.ts"; 5 | import type { RouteParams, RouterContext } from "../router.ts"; 6 | import type { NetAddr } from "../types.ts"; 7 | 8 | import "../node_shims.ts"; 9 | 10 | /** Guard for Async Iterables */ 11 | export function isAsyncIterable( 12 | value: unknown, 13 | ): value is AsyncIterable { 14 | return typeof value === "object" && value !== null && 15 | Symbol.asyncIterator in value && 16 | // deno-lint-ignore no-explicit-any 17 | typeof (value as any)[Symbol.asyncIterator] === "function"; 18 | } 19 | 20 | export function isBun(): boolean { 21 | return "Bun" in globalThis; 22 | } 23 | 24 | /** Determines if a string "looks" like HTML */ 25 | export function isHtml(value: string): boolean { 26 | return /^\s*<(?:!DOCTYPE|html|body)/i.test(value); 27 | } 28 | 29 | export function isListenTlsOptions( 30 | value: unknown, 31 | ): value is Deno.ListenTlsOptions { 32 | return typeof value === "object" && value !== null && 33 | ("cert" in value || "certFile" in value) && 34 | ("key" in value || "keyFile" in value) && "port" in value; 35 | } 36 | 37 | export function isNetAddr(value: unknown): value is NetAddr { 38 | return typeof value === "object" && value != null && "transport" in value && 39 | "hostname" in value && "port" in value; 40 | } 41 | 42 | export function isNode(): boolean { 43 | return "process" in globalThis && "global" in globalThis && 44 | !("Bun" in globalThis) && !("WebSocketPair" in globalThis); 45 | } 46 | 47 | export function isFsFile(value: unknown): value is Deno.FsFile { 48 | return !!(value && typeof value === "object" && "stat" in value && 49 | typeof value.stat === "function"); 50 | } 51 | 52 | export function isRouterContext< 53 | R extends string, 54 | P extends RouteParams, 55 | S extends State, 56 | >( 57 | value: Context, 58 | ): value is RouterContext { 59 | return "params" in value; 60 | } 61 | --------------------------------------------------------------------------------