├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── bun.lock ├── eslint.config.mjs ├── jest.config.js ├── package.json ├── src ├── conninfo.ts ├── globals.ts ├── index.ts ├── listener.ts ├── request.ts ├── response.ts ├── serve-static.ts ├── server.ts ├── types.ts ├── utils.ts ├── utils │ ├── response.ts │ └── response │ │ └── constants.ts └── vercel.ts ├── test ├── assets │ ├── .static │ │ └── plain.txt │ ├── favicon.ico │ ├── secret.txt │ ├── static-with-precompressed │ │ ├── hello.txt │ │ ├── hello.txt.br │ │ └── hello.txt.zst │ └── static │ │ ├── data.json │ │ ├── extensionless │ │ ├── hono.html │ │ ├── index.html │ │ └── plain.txt ├── conninfo.test.ts ├── fixtures │ └── keys │ │ ├── agent1-cert.pem │ │ └── agent1-key.pem ├── listener.test.ts ├── request.test.ts ├── response.test.ts ├── serve-static.test.ts ├── server.test.ts ├── setup.ts ├── utils.test.ts ├── utils │ └── response.test.ts └── vercel.test.ts ├── tsconfig.json └── tsup.config.ts /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: ['*'] 7 | 8 | permissions: 9 | contents: read 10 | pull-requests: read 11 | 12 | jobs: 13 | ci: 14 | runs-on: ubuntu-latest 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x, 22.x] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | - uses: oven-sh/setup-bun@v2 27 | - run: bun install 28 | - run: bun run format 29 | - run: bun run lint 30 | - run: bun run build 31 | - run: bun run test 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | 3 | node_modules 4 | .yarn/* 5 | yarn-error.log 6 | *.tgz 7 | 8 | # for debug or playing 9 | sandbox -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact" 7 | ], 8 | "editor.codeActionsOnSave": { 9 | "source.fixAll.eslint": "explicit" 10 | } 11 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node.js Adapter for Hono 2 | 3 | This adapter `@hono/node-server` allows you to run your Hono application on Node.js. 4 | Initially, Hono wasn't designed for Node.js, but with this adapter, you can now use Hono on Node.js. 5 | It utilizes web standard APIs implemented in Node.js version 18 or higher. 6 | 7 | ## Benchmarks 8 | 9 | Hono is 3.5 times faster than Express. 10 | 11 | Express: 12 | 13 | ```txt 14 | $ bombardier -d 10s --fasthttp http://localhost:3000/ 15 | 16 | Statistics Avg Stdev Max 17 | Reqs/sec 16438.94 1603.39 19155.47 18 | Latency 7.60ms 7.51ms 559.89ms 19 | HTTP codes: 20 | 1xx - 0, 2xx - 164494, 3xx - 0, 4xx - 0, 5xx - 0 21 | others - 0 22 | Throughput: 4.55MB/s 23 | ``` 24 | 25 | Hono + `@hono/node-server`: 26 | 27 | ```txt 28 | $ bombardier -d 10s --fasthttp http://localhost:3000/ 29 | 30 | Statistics Avg Stdev Max 31 | Reqs/sec 58296.56 5512.74 74403.56 32 | Latency 2.14ms 1.46ms 190.92ms 33 | HTTP codes: 34 | 1xx - 0, 2xx - 583059, 3xx - 0, 4xx - 0, 5xx - 0 35 | others - 0 36 | Throughput: 12.56MB/s 37 | ``` 38 | 39 | ## Requirements 40 | 41 | It works on Node.js versions greater than 18.x. The specific required Node.js versions are as follows: 42 | 43 | - 18.x => 18.14.1+ 44 | - 19.x => 19.7.0+ 45 | - 20.x => 20.0.0+ 46 | 47 | Essentially, you can simply use the latest version of each major release. 48 | 49 | ## Installation 50 | 51 | You can install it from the npm registry with `npm` command: 52 | 53 | ```sh 54 | npm install @hono/node-server 55 | ``` 56 | 57 | Or use `yarn`: 58 | 59 | ```sh 60 | yarn add @hono/node-server 61 | ``` 62 | 63 | ## Usage 64 | 65 | Just import `@hono/node-server` at the top and write the code as usual. 66 | The same code that runs on Cloudflare Workers, Deno, and Bun will work. 67 | 68 | ```ts 69 | import { serve } from '@hono/node-server' 70 | import { Hono } from 'hono' 71 | 72 | const app = new Hono() 73 | app.get('/', (c) => c.text('Hono meets Node.js')) 74 | 75 | serve(app, (info) => { 76 | console.log(`Listening on http://localhost:${info.port}`) // Listening on http://localhost:3000 77 | }) 78 | ``` 79 | 80 | For example, run it using `ts-node`. Then an HTTP server will be launched. The default port is `3000`. 81 | 82 | ```sh 83 | ts-node ./index.ts 84 | ``` 85 | 86 | Open `http://localhost:3000` with your browser. 87 | 88 | ## Options 89 | 90 | ### `port` 91 | 92 | ```ts 93 | serve({ 94 | fetch: app.fetch, 95 | port: 8787, // Port number, default is 3000 96 | }) 97 | ``` 98 | 99 | ### `createServer` 100 | 101 | ```ts 102 | import { createServer } from 'node:https' 103 | import fs from 'node:fs' 104 | 105 | //... 106 | 107 | serve({ 108 | fetch: app.fetch, 109 | createServer: createServer, 110 | serverOptions: { 111 | key: fs.readFileSync('test/fixtures/keys/agent1-key.pem'), 112 | cert: fs.readFileSync('test/fixtures/keys/agent1-cert.pem'), 113 | }, 114 | }) 115 | ``` 116 | 117 | ### `overrideGlobalObjects` 118 | 119 | The default value is `true`. The Node.js Adapter rewrites the global Request/Response and uses a lightweight Request/Response to improve performance. If you don't want to do that, set `false`. 120 | 121 | ```ts 122 | serve({ 123 | fetch: app.fetch, 124 | overrideGlobalObjects: false, 125 | }) 126 | ``` 127 | 128 | ## Middleware 129 | 130 | Most built-in middleware also works with Node.js. 131 | Read [the documentation](https://hono.dev/middleware/builtin/basic-auth) and use the Middleware of your liking. 132 | 133 | ```ts 134 | import { serve } from '@hono/node-server' 135 | import { Hono } from 'hono' 136 | import { prettyJSON } from 'hono/pretty-json' 137 | 138 | const app = new Hono() 139 | 140 | app.get('*', prettyJSON()) 141 | app.get('/', (c) => c.json({ 'Hono meets': 'Node.js' })) 142 | 143 | serve(app) 144 | ``` 145 | 146 | ## Serve Static Middleware 147 | 148 | Use Serve Static Middleware that has been created for Node.js. 149 | 150 | ```ts 151 | import { serveStatic } from '@hono/node-server/serve-static' 152 | 153 | //... 154 | 155 | app.use('/static/*', serveStatic({ root: './' })) 156 | ``` 157 | 158 | Note that `root` must be _relative_ to the current working directory from which the app was started. Absolute paths are not supported. 159 | 160 | This can cause confusion when running your application locally. 161 | 162 | Imagine your project structure is: 163 | 164 | ``` 165 | my-hono-project/ 166 | src/ 167 | index.ts 168 | static/ 169 | index.html 170 | ``` 171 | 172 | Typically, you would run your app from the project's root directory (`my-hono-project`), 173 | so you would need the following code to serve the `static` folder: 174 | 175 | ```ts 176 | app.use('/static/*', serveStatic({ root: './static' })) 177 | ``` 178 | 179 | Notice that `root` here is not relative to `src/index.ts`, rather to `my-hono-project`. 180 | 181 | ### Options 182 | 183 | #### `rewriteRequestPath` 184 | 185 | If you want to serve files in `./.foojs` with the request path `/__foo/*`, you can write like the following. 186 | 187 | ```ts 188 | app.use( 189 | '/__foo/*', 190 | serveStatic({ 191 | root: './.foojs/', 192 | rewriteRequestPath: (path: string) => path.replace(/^\/__foo/, ''), 193 | }) 194 | ) 195 | ``` 196 | 197 | #### `onFound` 198 | 199 | You can specify handling when the requested file is found with `onFound`. 200 | 201 | ```ts 202 | app.use( 203 | '/static/*', 204 | serveStatic({ 205 | // ... 206 | onFound: (_path, c) => { 207 | c.header('Cache-Control', `public, immutable, max-age=31536000`) 208 | }, 209 | }) 210 | ) 211 | ``` 212 | 213 | #### `onNotFound` 214 | 215 | The `onNotFound` is useful for debugging. You can write a handle for when a file is not found. 216 | 217 | ```ts 218 | app.use( 219 | '/static/*', 220 | serveStatic({ 221 | root: './non-existent-dir', 222 | onNotFound: (path, c) => { 223 | console.log(`${path} is not found, request to ${c.req.path}`) 224 | }, 225 | }) 226 | ) 227 | ``` 228 | 229 | #### `precompressed` 230 | 231 | The `precompressed` option checks if files with extensions like `.br` or `.gz` are available and serves them based on the `Accept-Encoding` header. It prioritizes Brotli, then Zstd, and Gzip. If none are available, it serves the original file. 232 | 233 | ```ts 234 | app.use( 235 | '/static/*', 236 | serveStatic({ 237 | precompressed: true, 238 | }) 239 | ) 240 | ``` 241 | 242 | ## ConnInfo Helper 243 | 244 | You can use the [ConnInfo Helper](https://hono.dev/docs/helpers/conninfo) by importing `getConnInfo` from `@hono/node-server/conninfo`. 245 | 246 | ```ts 247 | import { getConnInfo } from '@hono/node-server/conninfo' 248 | 249 | app.get('/', (c) => { 250 | const info = getConnInfo(c) // info is `ConnInfo` 251 | return c.text(`Your remote address is ${info.remote.address}`) 252 | }) 253 | ``` 254 | 255 | ## Accessing Node.js API 256 | 257 | You can access the Node.js API from `c.env` in Node.js. For example, if you want to specify a type, you can write the following. 258 | 259 | ```ts 260 | import { serve } from '@hono/node-server' 261 | import type { HttpBindings } from '@hono/node-server' 262 | import { Hono } from 'hono' 263 | 264 | const app = new Hono<{ Bindings: HttpBindings }>() 265 | 266 | app.get('/', (c) => { 267 | return c.json({ 268 | remoteAddress: c.env.incoming.socket.remoteAddress, 269 | }) 270 | }) 271 | 272 | serve(app) 273 | ``` 274 | 275 | The APIs that you can get from `c.env` are as follows. 276 | 277 | ```ts 278 | type HttpBindings = { 279 | incoming: IncomingMessage 280 | outgoing: ServerResponse 281 | } 282 | 283 | type Http2Bindings = { 284 | incoming: Http2ServerRequest 285 | outgoing: Http2ServerResponse 286 | } 287 | ``` 288 | 289 | ## Direct response from Node.js API 290 | 291 | You can directly respond to the client from the Node.js API. 292 | In that case, the response from Hono should be ignored, so return `RESPONSE_ALREADY_SENT`. 293 | 294 | > [!NOTE] 295 | > This feature can be used when migrating existing Node.js applications to Hono, but we recommend using Hono's API for new applications. 296 | 297 | ```ts 298 | import { serve } from '@hono/node-server' 299 | import type { HttpBindings } from '@hono/node-server' 300 | import { RESPONSE_ALREADY_SENT } from '@hono/node-server/utils/response' 301 | import { Hono } from 'hono' 302 | 303 | const app = new Hono<{ Bindings: HttpBindings }>() 304 | 305 | app.get('/', (c) => { 306 | const { outgoing } = c.env 307 | outgoing.writeHead(200, { 'Content-Type': 'text/plain' }) 308 | outgoing.end('Hello World\n') 309 | 310 | return RESPONSE_ALREADY_SENT 311 | }) 312 | 313 | serve(app) 314 | ``` 315 | 316 | ## Related projects 317 | 318 | - Hono - 319 | - Hono GitHub repository - 320 | 321 | ## Author 322 | 323 | Yusuke Wada 324 | 325 | ## License 326 | 327 | MIT 328 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@hono/eslint-config' 2 | 3 | export default [...baseConfig] 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ['**/test/**/*.+(ts)', '**/src/**/(*.)+(test).+(ts)'], 3 | modulePathIgnorePatterns: ["test/setup.ts"], 4 | transform: { 5 | '^.+\\.(ts)$': 'ts-jest', 6 | }, 7 | testEnvironment: 'node', 8 | setupFiles: ["/test/setup.ts"], 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hono/node-server", 3 | "version": "1.14.4", 4 | "description": "Node.js Adapter for Hono", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "files": [ 8 | "dist" 9 | ], 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "require": "./dist/index.js", 14 | "import": "./dist/index.mjs" 15 | }, 16 | "./serve-static": { 17 | "types": "./dist/serve-static.d.ts", 18 | "require": "./dist/serve-static.js", 19 | "import": "./dist/serve-static.mjs" 20 | }, 21 | "./vercel": { 22 | "types": "./dist/vercel.d.ts", 23 | "require": "./dist/vercel.js", 24 | "import": "./dist/vercel.mjs" 25 | }, 26 | "./utils/*": { 27 | "types": "./dist/utils/*.d.ts", 28 | "require": "./dist/utils/*.js", 29 | "import": "./dist/utils/*.mjs" 30 | }, 31 | "./conninfo": { 32 | "types": "./dist/conninfo.d.ts", 33 | "require": "./dist/conninfo.js", 34 | "import": "./dist/conninfo.mjs" 35 | } 36 | }, 37 | "typesVersions": { 38 | "*": { 39 | ".": [ 40 | "./dist/index.d.ts" 41 | ], 42 | "serve-static": [ 43 | "./dist/serve-static.d.ts" 44 | ], 45 | "vercel": [ 46 | "./dist/vercel.d.ts" 47 | ], 48 | "utils/*": [ 49 | "./dist/utils/*.d.ts" 50 | ], 51 | "conninfo": [ 52 | "./dist/conninfo.d.ts" 53 | ] 54 | } 55 | }, 56 | "scripts": { 57 | "test": "node --expose-gc ./node_modules/.bin/jest", 58 | "build": "tsup --external hono", 59 | "watch": "tsup --watch", 60 | "postbuild": "publint", 61 | "prerelease": "bun run build && bun run test", 62 | "release": "np", 63 | "lint": "eslint src test", 64 | "lint:fix": "eslint src test --fix", 65 | "format": "prettier --check \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\"", 66 | "format:fix": "prettier --write \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\"" 67 | }, 68 | "license": "MIT", 69 | "repository": { 70 | "type": "git", 71 | "url": "https://github.com/honojs/node-server.git" 72 | }, 73 | "homepage": "https://github.com/honojs/node-server", 74 | "author": "Yusuke Wada (https://github.com/yusukebe)", 75 | "publishConfig": { 76 | "registry": "https://registry.npmjs.org", 77 | "access": "public" 78 | }, 79 | "engines": { 80 | "node": ">=18.14.1" 81 | }, 82 | "devDependencies": { 83 | "@hono/eslint-config": "^1.0.1", 84 | "@types/jest": "^29.5.3", 85 | "@types/node": "^20.10.0", 86 | "@types/supertest": "^2.0.12", 87 | "@whatwg-node/fetch": "^0.9.14", 88 | "eslint": "^9.10.0", 89 | "hono": "^4.4.10", 90 | "jest": "^29.6.1", 91 | "np": "^7.7.0", 92 | "prettier": "^3.2.4", 93 | "publint": "^0.1.16", 94 | "supertest": "^6.3.3", 95 | "ts-jest": "^29.1.1", 96 | "tsup": "^7.2.0", 97 | "typescript": "^5.3.2" 98 | }, 99 | "peerDependencies": { 100 | "hono": "^4" 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/conninfo.ts: -------------------------------------------------------------------------------- 1 | import type { GetConnInfo } from 'hono/conninfo' 2 | import type { HttpBindings } from './types' 3 | 4 | /** 5 | * ConnInfo Helper for Node.js 6 | * @param c Context 7 | * @returns ConnInfo 8 | */ 9 | export const getConnInfo: GetConnInfo = (c) => { 10 | const bindings = (c.env.server ? c.env.server : c.env) as HttpBindings 11 | 12 | const address = bindings.incoming.socket.remoteAddress 13 | const port = bindings.incoming.socket.remotePort 14 | const family = bindings.incoming.socket.remoteFamily 15 | 16 | return { 17 | remote: { 18 | address, 19 | port, 20 | addressType: family === 'IPv4' ? 'IPv4' : family === 'IPv6' ? 'IPv6' : void 0, 21 | }, 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto' 2 | 3 | const webFetch = global.fetch 4 | 5 | /** jest dose not use crypto in the global, but this is OK for node 18 */ 6 | if (typeof global.crypto === 'undefined') { 7 | global.crypto = crypto as Crypto 8 | } 9 | 10 | global.fetch = (info, init?) => { 11 | init = { 12 | // Disable compression handling so people can return the result of a fetch 13 | // directly in the loader without messing with the Content-Encoding header. 14 | compress: false, 15 | ...init, 16 | } as RequestInit 17 | 18 | return webFetch(info as RequestInfo, init) 19 | } 20 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { serve, createAdaptorServer } from './server' 2 | export { getRequestListener } from './listener' 3 | export { RequestError } from './request' 4 | export type { HttpBindings, Http2Bindings, ServerType } from './types' 5 | -------------------------------------------------------------------------------- /src/listener.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http' 2 | import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' 3 | import { 4 | abortControllerKey, 5 | newRequest, 6 | Request as LightweightRequest, 7 | toRequestError, 8 | } from './request' 9 | import { cacheKey, Response as LightweightResponse } from './response' 10 | import type { InternalCache } from './response' 11 | import type { CustomErrorHandler, FetchCallback, HttpBindings } from './types' 12 | import { writeFromReadableStream, buildOutgoingHttpHeaders } from './utils' 13 | import { X_ALREADY_SENT } from './utils/response/constants' 14 | import './globals' 15 | 16 | const regBuffer = /^no$/i 17 | const regContentType = /^(application\/json\b|text\/(?!event-stream\b))/i 18 | 19 | const handleRequestError = (): Response => 20 | new Response(null, { 21 | status: 400, 22 | }) 23 | 24 | const handleFetchError = (e: unknown): Response => 25 | new Response(null, { 26 | status: 27 | e instanceof Error && (e.name === 'TimeoutError' || e.constructor.name === 'TimeoutError') 28 | ? 504 // timeout error emits 504 timeout 29 | : 500, 30 | }) 31 | 32 | const handleResponseError = (e: unknown, outgoing: ServerResponse | Http2ServerResponse) => { 33 | const err = (e instanceof Error ? e : new Error('unknown error', { cause: e })) as Error & { 34 | code: string 35 | } 36 | if (err.code === 'ERR_STREAM_PREMATURE_CLOSE') { 37 | console.info('The user aborted a request.') 38 | } else { 39 | console.error(e) 40 | if (!outgoing.headersSent) { 41 | outgoing.writeHead(500, { 'Content-Type': 'text/plain' }) 42 | } 43 | outgoing.end(`Error: ${err.message}`) 44 | outgoing.destroy(err) 45 | } 46 | } 47 | 48 | const flushHeaders = (outgoing: ServerResponse | Http2ServerResponse) => { 49 | // If outgoing is ServerResponse (HTTP/1.1), it requires this to flush headers. 50 | // However, Http2ServerResponse is sent without this. 51 | if ('flushHeaders' in outgoing && outgoing.writable) { 52 | outgoing.flushHeaders() 53 | } 54 | } 55 | 56 | const responseViaCache = async ( 57 | res: Response, 58 | outgoing: ServerResponse | Http2ServerResponse 59 | ): Promise => { 60 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 61 | let [status, body, header] = (res as any)[cacheKey] as InternalCache 62 | if (header instanceof Headers) { 63 | header = buildOutgoingHttpHeaders(header) 64 | } 65 | 66 | if (typeof body === 'string') { 67 | header['Content-Length'] = Buffer.byteLength(body) 68 | } else if (body instanceof Uint8Array) { 69 | header['Content-Length'] = body.byteLength 70 | } else if (body instanceof Blob) { 71 | header['Content-Length'] = body.size 72 | } 73 | 74 | outgoing.writeHead(status, header) 75 | if (typeof body === 'string' || body instanceof Uint8Array) { 76 | outgoing.end(body) 77 | } else if (body instanceof Blob) { 78 | outgoing.end(new Uint8Array(await body.arrayBuffer())) 79 | } else { 80 | flushHeaders(outgoing) 81 | return writeFromReadableStream(body, outgoing)?.catch( 82 | (e) => handleResponseError(e, outgoing) as undefined 83 | ) 84 | } 85 | } 86 | 87 | const responseViaResponseObject = async ( 88 | res: Response | Promise, 89 | outgoing: ServerResponse | Http2ServerResponse, 90 | options: { errorHandler?: CustomErrorHandler } = {} 91 | ) => { 92 | if (res instanceof Promise) { 93 | if (options.errorHandler) { 94 | try { 95 | res = await res 96 | } catch (err) { 97 | const errRes = await options.errorHandler(err) 98 | if (!errRes) { 99 | return 100 | } 101 | res = errRes 102 | } 103 | } else { 104 | res = await res.catch(handleFetchError) 105 | } 106 | } 107 | 108 | if (cacheKey in res) { 109 | return responseViaCache(res as Response, outgoing) 110 | } 111 | 112 | const resHeaderRecord: OutgoingHttpHeaders = buildOutgoingHttpHeaders(res.headers) 113 | 114 | if (res.body) { 115 | /** 116 | * If content-encoding is set, we assume that the response should be not decoded. 117 | * Else if transfer-encoding is set, we assume that the response should be streamed. 118 | * Else if content-length is set, we assume that the response content has been taken care of. 119 | * Else if x-accel-buffering is set to no, we assume that the response should be streamed. 120 | * Else if content-type is not application/json nor text/* but can be text/event-stream, 121 | * we assume that the response should be streamed. 122 | */ 123 | 124 | const { 125 | 'transfer-encoding': transferEncoding, 126 | 'content-encoding': contentEncoding, 127 | 'content-length': contentLength, 128 | 'x-accel-buffering': accelBuffering, 129 | 'content-type': contentType, 130 | } = resHeaderRecord 131 | 132 | if ( 133 | transferEncoding || 134 | contentEncoding || 135 | contentLength || 136 | // nginx buffering variant 137 | (accelBuffering && regBuffer.test(accelBuffering as string)) || 138 | !regContentType.test(contentType as string) 139 | ) { 140 | outgoing.writeHead(res.status, resHeaderRecord) 141 | flushHeaders(outgoing) 142 | 143 | await writeFromReadableStream(res.body, outgoing) 144 | } else { 145 | const buffer = await res.arrayBuffer() 146 | resHeaderRecord['content-length'] = buffer.byteLength 147 | 148 | outgoing.writeHead(res.status, resHeaderRecord) 149 | outgoing.end(new Uint8Array(buffer)) 150 | } 151 | } else if (resHeaderRecord[X_ALREADY_SENT]) { 152 | // do nothing, the response has already been sent 153 | } else { 154 | outgoing.writeHead(res.status, resHeaderRecord) 155 | outgoing.end() 156 | } 157 | } 158 | 159 | export const getRequestListener = ( 160 | fetchCallback: FetchCallback, 161 | options: { 162 | hostname?: string 163 | errorHandler?: CustomErrorHandler 164 | overrideGlobalObjects?: boolean 165 | } = {} 166 | ) => { 167 | if (options.overrideGlobalObjects !== false && global.Request !== LightweightRequest) { 168 | Object.defineProperty(global, 'Request', { 169 | value: LightweightRequest, 170 | }) 171 | Object.defineProperty(global, 'Response', { 172 | value: LightweightResponse, 173 | }) 174 | } 175 | 176 | return async ( 177 | incoming: IncomingMessage | Http2ServerRequest, 178 | outgoing: ServerResponse | Http2ServerResponse 179 | ) => { 180 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 181 | let res, req: any 182 | 183 | try { 184 | // `fetchCallback()` requests a Request object, but global.Request is expensive to generate, 185 | // so generate a pseudo Request object with only the minimum required information. 186 | req = newRequest(incoming, options.hostname) 187 | 188 | // Detect if request was aborted. 189 | outgoing.on('close', () => { 190 | const abortController = req[abortControllerKey] as AbortController | undefined 191 | if (!abortController) { 192 | return 193 | } 194 | 195 | if (incoming.errored) { 196 | req[abortControllerKey].abort(incoming.errored.toString()) 197 | } else if (!outgoing.writableFinished) { 198 | req[abortControllerKey].abort('Client connection prematurely closed.') 199 | } 200 | }) 201 | 202 | res = fetchCallback(req, { incoming, outgoing } as HttpBindings) as 203 | | Response 204 | | Promise 205 | if (cacheKey in res) { 206 | // synchronous, cacheable response 207 | return responseViaCache(res as Response, outgoing) 208 | } 209 | } catch (e: unknown) { 210 | if (!res) { 211 | if (options.errorHandler) { 212 | res = await options.errorHandler(req ? e : toRequestError(e)) 213 | if (!res) { 214 | return 215 | } 216 | } else if (!req) { 217 | res = handleRequestError() 218 | } else { 219 | res = handleFetchError(e) 220 | } 221 | } else { 222 | return handleResponseError(e, outgoing) 223 | } 224 | } 225 | 226 | try { 227 | return await responseViaResponseObject(res, outgoing, options) 228 | } catch (e) { 229 | return handleResponseError(e, outgoing) 230 | } 231 | } 232 | } 233 | -------------------------------------------------------------------------------- /src/request.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // Define prototype for lightweight pseudo Request object 3 | 4 | import type { IncomingMessage } from 'node:http' 5 | import { Http2ServerRequest } from 'node:http2' 6 | import { Readable } from 'node:stream' 7 | import type { TLSSocket } from 'node:tls' 8 | 9 | export class RequestError extends Error { 10 | constructor( 11 | message: string, 12 | options?: { 13 | cause?: unknown 14 | } 15 | ) { 16 | super(message, options) 17 | this.name = 'RequestError' 18 | } 19 | } 20 | 21 | export const toRequestError = (e: unknown): RequestError => { 22 | if (e instanceof RequestError) { 23 | return e 24 | } 25 | return new RequestError((e as Error).message, { cause: e }) 26 | } 27 | 28 | export const GlobalRequest = global.Request 29 | export class Request extends GlobalRequest { 30 | constructor(input: string | Request, options?: RequestInit) { 31 | if (typeof input === 'object' && getRequestCache in input) { 32 | input = (input as any)[getRequestCache]() 33 | } 34 | // Check if body is ReadableStream like. This makes it compatbile with ReadableStream polyfills. 35 | if (typeof (options?.body as ReadableStream)?.getReader !== 'undefined') { 36 | // node 18 fetch needs half duplex mode when request body is stream 37 | // if already set, do nothing since a Request object was passed to the options or explicitly set by the user. 38 | ;(options as any).duplex ??= 'half' 39 | } 40 | super(input, options) 41 | } 42 | } 43 | 44 | const newRequestFromIncoming = ( 45 | method: string, 46 | url: string, 47 | incoming: IncomingMessage | Http2ServerRequest, 48 | abortController: AbortController 49 | ): Request => { 50 | const headerRecord: [string, string][] = [] 51 | const rawHeaders = incoming.rawHeaders 52 | for (let i = 0; i < rawHeaders.length; i += 2) { 53 | const { [i]: key, [i + 1]: value } = rawHeaders 54 | if (key.charCodeAt(0) !== /*:*/ 0x3a) { 55 | headerRecord.push([key, value]) 56 | } 57 | } 58 | 59 | const init = { 60 | method: method, 61 | headers: headerRecord, 62 | signal: abortController.signal, 63 | } as RequestInit 64 | 65 | if (method === 'TRACE') { 66 | init.method = 'GET' 67 | const req = new Request(url, init) 68 | Object.defineProperty(req, 'method', { 69 | get() { 70 | return 'TRACE' 71 | }, 72 | }) 73 | return req 74 | } 75 | 76 | if (!(method === 'GET' || method === 'HEAD')) { 77 | if ('rawBody' in incoming && incoming.rawBody instanceof Buffer) { 78 | // In some environments (e.g. firebase functions), the body is already consumed. 79 | // So we need to re-read the request body from `incoming.rawBody` if available. 80 | init.body = new ReadableStream({ 81 | start(controller) { 82 | controller.enqueue(incoming.rawBody) 83 | controller.close() 84 | }, 85 | }) 86 | } else { 87 | // lazy-consume request body 88 | init.body = Readable.toWeb(incoming) as ReadableStream 89 | } 90 | } 91 | 92 | return new Request(url, init) 93 | } 94 | 95 | const getRequestCache = Symbol('getRequestCache') 96 | const requestCache = Symbol('requestCache') 97 | const incomingKey = Symbol('incomingKey') 98 | const urlKey = Symbol('urlKey') 99 | export const abortControllerKey = Symbol('abortControllerKey') 100 | export const getAbortController = Symbol('getAbortController') 101 | 102 | const requestPrototype: Record = { 103 | get method() { 104 | return this[incomingKey].method || 'GET' 105 | }, 106 | 107 | get url() { 108 | return this[urlKey] 109 | }, 110 | 111 | [getAbortController]() { 112 | this[getRequestCache]() 113 | return this[abortControllerKey] 114 | }, 115 | 116 | [getRequestCache]() { 117 | this[abortControllerKey] ||= new AbortController() 118 | return (this[requestCache] ||= newRequestFromIncoming( 119 | this.method, 120 | this[urlKey], 121 | this[incomingKey], 122 | this[abortControllerKey] 123 | )) 124 | }, 125 | } 126 | ;[ 127 | 'body', 128 | 'bodyUsed', 129 | 'cache', 130 | 'credentials', 131 | 'destination', 132 | 'headers', 133 | 'integrity', 134 | 'mode', 135 | 'redirect', 136 | 'referrer', 137 | 'referrerPolicy', 138 | 'signal', 139 | 'keepalive', 140 | ].forEach((k) => { 141 | Object.defineProperty(requestPrototype, k, { 142 | get() { 143 | return this[getRequestCache]()[k] 144 | }, 145 | }) 146 | }) 147 | ;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { 148 | Object.defineProperty(requestPrototype, k, { 149 | value: function () { 150 | return this[getRequestCache]()[k]() 151 | }, 152 | }) 153 | }) 154 | Object.setPrototypeOf(requestPrototype, Request.prototype) 155 | 156 | export const newRequest = ( 157 | incoming: IncomingMessage | Http2ServerRequest, 158 | defaultHostname?: string 159 | ) => { 160 | const req = Object.create(requestPrototype) 161 | req[incomingKey] = incoming 162 | 163 | const incomingUrl = incoming.url || '' 164 | 165 | // handle absolute URL in request.url 166 | if ( 167 | incomingUrl[0] !== '/' && // short-circuit for performance. most requests are relative URL. 168 | (incomingUrl.startsWith('http://') || incomingUrl.startsWith('https://')) 169 | ) { 170 | if (incoming instanceof Http2ServerRequest) { 171 | throw new RequestError('Absolute URL for :path is not allowed in HTTP/2') // RFC 9113 8.3.1. 172 | } 173 | 174 | try { 175 | const url = new URL(incomingUrl) 176 | req[urlKey] = url.href 177 | } catch (e) { 178 | throw new RequestError('Invalid absolute URL', { cause: e }) 179 | } 180 | 181 | return req 182 | } 183 | 184 | // Otherwise, relative URL 185 | const host = 186 | (incoming instanceof Http2ServerRequest ? incoming.authority : incoming.headers.host) || 187 | defaultHostname 188 | if (!host) { 189 | throw new RequestError('Missing host header') 190 | } 191 | 192 | let scheme: string 193 | if (incoming instanceof Http2ServerRequest) { 194 | scheme = incoming.scheme 195 | if (!(scheme === 'http' || scheme === 'https')) { 196 | throw new RequestError('Unsupported scheme') 197 | } 198 | } else { 199 | scheme = incoming.socket && (incoming.socket as TLSSocket).encrypted ? 'https' : 'http' 200 | } 201 | 202 | const url = new URL(`${scheme}://${host}${incomingUrl}`) 203 | 204 | // check by length for performance. 205 | // if suspicious, check by host. host header sometimes contains port. 206 | if (url.hostname.length !== host.length && url.hostname !== host.replace(/:\d+$/, '')) { 207 | throw new RequestError('Invalid host header') 208 | } 209 | 210 | req[urlKey] = url.href 211 | 212 | return req 213 | } 214 | -------------------------------------------------------------------------------- /src/response.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | // Define lightweight pseudo Response class and replace global.Response with it. 3 | 4 | import type { OutgoingHttpHeaders } from 'node:http' 5 | 6 | const responseCache = Symbol('responseCache') 7 | const getResponseCache = Symbol('getResponseCache') 8 | export const cacheKey = Symbol('cache') 9 | 10 | export type InternalCache = [ 11 | number, 12 | string | ReadableStream, 13 | Record | Headers | OutgoingHttpHeaders, 14 | ] 15 | interface LightResponse { 16 | [responseCache]?: globalThis.Response 17 | [cacheKey]?: InternalCache 18 | } 19 | 20 | export const GlobalResponse = global.Response 21 | export class Response { 22 | #body?: BodyInit | null 23 | #init?: ResponseInit; 24 | 25 | [getResponseCache](): globalThis.Response { 26 | delete (this as LightResponse)[cacheKey] 27 | return ((this as LightResponse)[responseCache] ||= new GlobalResponse(this.#body, this.#init)) 28 | } 29 | 30 | constructor(body?: BodyInit | null, init?: ResponseInit) { 31 | let headers: HeadersInit 32 | this.#body = body 33 | if (init instanceof Response) { 34 | const cachedGlobalResponse = (init as any)[responseCache] 35 | if (cachedGlobalResponse) { 36 | this.#init = cachedGlobalResponse 37 | // instantiate GlobalResponse cache and this object always returns value from global.Response 38 | this[getResponseCache]() 39 | return 40 | } else { 41 | this.#init = init.#init 42 | // clone headers to avoid sharing the same object between parent and child 43 | headers = new Headers((init.#init as ResponseInit).headers) 44 | } 45 | } else { 46 | this.#init = init 47 | } 48 | 49 | if ( 50 | typeof body === 'string' || 51 | typeof (body as ReadableStream)?.getReader !== 'undefined' || 52 | body instanceof Blob || 53 | body instanceof Uint8Array 54 | ) { 55 | headers ||= init?.headers || { 'content-type': 'text/plain; charset=UTF-8' } 56 | ;(this as any)[cacheKey] = [init?.status || 200, body, headers] 57 | } 58 | } 59 | 60 | get headers(): Headers { 61 | const cache = (this as LightResponse)[cacheKey] as InternalCache 62 | if (cache) { 63 | if (!(cache[2] instanceof Headers)) { 64 | cache[2] = new Headers(cache[2] as HeadersInit) 65 | } 66 | return cache[2] 67 | } 68 | return this[getResponseCache]().headers 69 | } 70 | 71 | get status() { 72 | return ( 73 | ((this as LightResponse)[cacheKey] as InternalCache | undefined)?.[0] ?? 74 | this[getResponseCache]().status 75 | ) 76 | } 77 | 78 | get ok() { 79 | const status = this.status 80 | return status >= 200 && status < 300 81 | } 82 | } 83 | ;['body', 'bodyUsed', 'redirected', 'statusText', 'trailers', 'type', 'url'].forEach((k) => { 84 | Object.defineProperty(Response.prototype, k, { 85 | get() { 86 | return this[getResponseCache]()[k] 87 | }, 88 | }) 89 | }) 90 | ;['arrayBuffer', 'blob', 'clone', 'formData', 'json', 'text'].forEach((k) => { 91 | Object.defineProperty(Response.prototype, k, { 92 | value: function () { 93 | return this[getResponseCache]()[k]() 94 | }, 95 | }) 96 | }) 97 | Object.setPrototypeOf(Response, GlobalResponse) 98 | Object.setPrototypeOf(Response.prototype, GlobalResponse.prototype) 99 | -------------------------------------------------------------------------------- /src/serve-static.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Env, MiddlewareHandler } from 'hono' 2 | import { getFilePath, getFilePathWithoutDefaultDocument } from 'hono/utils/filepath' 3 | import { getMimeType } from 'hono/utils/mime' 4 | import { createReadStream, lstatSync } from 'node:fs' 5 | import type { ReadStream, Stats } from 'node:fs' 6 | 7 | export type ServeStaticOptions = { 8 | /** 9 | * Root path, relative to current working directory from which the app was started. Absolute paths are not supported. 10 | */ 11 | root?: string 12 | path?: string 13 | index?: string // default is 'index.html' 14 | precompressed?: boolean 15 | rewriteRequestPath?: (path: string) => string 16 | onFound?: (path: string, c: Context) => void | Promise 17 | onNotFound?: (path: string, c: Context) => void | Promise 18 | } 19 | 20 | const COMPRESSIBLE_CONTENT_TYPE_REGEX = 21 | /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i 22 | const ENCODINGS = { 23 | br: '.br', 24 | zstd: '.zst', 25 | gzip: '.gz', 26 | } as const 27 | const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS) as (keyof typeof ENCODINGS)[] 28 | 29 | const createStreamBody = (stream: ReadStream) => { 30 | const body = new ReadableStream({ 31 | start(controller) { 32 | stream.on('data', (chunk) => { 33 | controller.enqueue(chunk) 34 | }) 35 | stream.on('end', () => { 36 | controller.close() 37 | }) 38 | }, 39 | 40 | cancel() { 41 | stream.destroy() 42 | }, 43 | }) 44 | return body 45 | } 46 | 47 | const addCurrentDirPrefix = (path: string) => { 48 | return `./${path}` 49 | } 50 | 51 | const getStats = (path: string) => { 52 | let stats: Stats | undefined 53 | try { 54 | stats = lstatSync(path) 55 | } catch {} 56 | return stats 57 | } 58 | 59 | export const serveStatic = (options: ServeStaticOptions = { root: '' }): MiddlewareHandler => { 60 | return async (c, next) => { 61 | // Do nothing if Response is already set 62 | if (c.finalized) { 63 | return next() 64 | } 65 | 66 | let filename: string 67 | 68 | try { 69 | filename = options.path ?? decodeURIComponent(c.req.path) 70 | } catch { 71 | await options.onNotFound?.(c.req.path, c) 72 | return next() 73 | } 74 | 75 | let path = getFilePathWithoutDefaultDocument({ 76 | filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename, 77 | root: options.root, 78 | }) 79 | 80 | if (path) { 81 | path = addCurrentDirPrefix(path) 82 | } else { 83 | return next() 84 | } 85 | 86 | let stats = getStats(path) 87 | 88 | if (stats && stats.isDirectory()) { 89 | path = getFilePath({ 90 | filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename, 91 | root: options.root, 92 | defaultDocument: options.index ?? 'index.html', 93 | }) 94 | 95 | if (path) { 96 | path = addCurrentDirPrefix(path) 97 | } else { 98 | return next() 99 | } 100 | 101 | stats = getStats(path) 102 | } 103 | 104 | if (!stats) { 105 | await options.onNotFound?.(path, c) 106 | return next() 107 | } 108 | await options.onFound?.(path, c) 109 | 110 | const mimeType = getMimeType(path) 111 | c.header('Content-Type', mimeType || 'application/octet-stream') 112 | 113 | if (options.precompressed && (!mimeType || COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType))) { 114 | const acceptEncodingSet = new Set( 115 | c.req 116 | .header('Accept-Encoding') 117 | ?.split(',') 118 | .map((encoding) => encoding.trim()) 119 | ) 120 | 121 | for (const encoding of ENCODINGS_ORDERED_KEYS) { 122 | if (!acceptEncodingSet.has(encoding)) { 123 | continue 124 | } 125 | const precompressedStats = getStats(path + ENCODINGS[encoding]) 126 | if (precompressedStats) { 127 | c.header('Content-Encoding', encoding) 128 | c.header('Vary', 'Accept-Encoding', { append: true }) 129 | stats = precompressedStats 130 | path = path + ENCODINGS[encoding] 131 | break 132 | } 133 | } 134 | } 135 | 136 | const size = stats.size 137 | 138 | if (c.req.method == 'HEAD' || c.req.method == 'OPTIONS') { 139 | c.header('Content-Length', size.toString()) 140 | c.status(200) 141 | return c.body(null) 142 | } 143 | 144 | const range = c.req.header('range') || '' 145 | 146 | if (!range) { 147 | c.header('Content-Length', size.toString()) 148 | return c.body(createStreamBody(createReadStream(path)), 200) 149 | } 150 | 151 | c.header('Accept-Ranges', 'bytes') 152 | c.header('Date', stats.birthtime.toUTCString()) 153 | 154 | const parts = range.replace(/bytes=/, '').split('-', 2) 155 | const start = parts[0] ? parseInt(parts[0], 10) : 0 156 | let end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1 157 | if (size < end - start + 1) { 158 | end = size - 1 159 | } 160 | 161 | const chunksize = end - start + 1 162 | const stream = createReadStream(path, { start, end }) 163 | 164 | c.header('Content-Length', chunksize.toString()) 165 | c.header('Content-Range', `bytes ${start}-${end}/${stats.size}`) 166 | 167 | return c.body(createStreamBody(stream), 206) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { createServer as createServerHTTP } from 'node:http' 2 | import type { AddressInfo } from 'node:net' 3 | import { getRequestListener } from './listener' 4 | import type { Options, ServerType } from './types' 5 | 6 | export const createAdaptorServer = (options: Options): ServerType => { 7 | const fetchCallback = options.fetch 8 | const requestListener = getRequestListener(fetchCallback, { 9 | hostname: options.hostname, 10 | overrideGlobalObjects: options.overrideGlobalObjects, 11 | }) 12 | // ts will complain about createServerHTTP and createServerHTTP2 not being callable, which works just fine 13 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 14 | const createServer: any = options.createServer || createServerHTTP 15 | const server: ServerType = createServer(options.serverOptions || {}, requestListener) 16 | return server 17 | } 18 | 19 | export const serve = ( 20 | options: Options, 21 | listeningListener?: (info: AddressInfo) => void 22 | ): ServerType => { 23 | const server = createAdaptorServer(options) 24 | server.listen(options?.port ?? 3000, options.hostname, () => { 25 | const serverInfo = server.address() as AddressInfo 26 | listeningListener && listeningListener(serverInfo) 27 | }) 28 | return server 29 | } 30 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | createServer, 3 | IncomingMessage, 4 | Server, 5 | ServerOptions as HttpServerOptions, 6 | ServerResponse as HttpServerResponse, 7 | } from 'node:http' 8 | import type { 9 | createSecureServer as createSecureHttp2Server, 10 | createServer as createHttp2Server, 11 | Http2ServerRequest, 12 | Http2Server, 13 | Http2ServerResponse, 14 | Http2SecureServer, 15 | SecureServerOptions as SecureHttp2ServerOptions, 16 | ServerOptions as Http2ServerOptions, 17 | } from 'node:http2' 18 | import type { 19 | createServer as createHttpsServer, 20 | ServerOptions as HttpsServerOptions, 21 | } from 'node:https' 22 | 23 | export type HttpBindings = { 24 | incoming: IncomingMessage 25 | outgoing: HttpServerResponse 26 | } 27 | 28 | export type Http2Bindings = { 29 | incoming: Http2ServerRequest 30 | outgoing: Http2ServerResponse 31 | } 32 | 33 | export type FetchCallback = ( 34 | request: Request, 35 | env: HttpBindings | Http2Bindings 36 | ) => Promise | unknown 37 | 38 | export type NextHandlerOption = { 39 | fetch: FetchCallback 40 | } 41 | 42 | export type ServerType = Server | Http2Server | Http2SecureServer 43 | 44 | type createHttpOptions = { 45 | serverOptions?: HttpServerOptions 46 | createServer?: typeof createServer 47 | } 48 | 49 | type createHttpsOptions = { 50 | serverOptions?: HttpsServerOptions 51 | createServer?: typeof createHttpsServer 52 | } 53 | 54 | type createHttp2Options = { 55 | serverOptions?: Http2ServerOptions 56 | createServer?: typeof createHttp2Server 57 | } 58 | 59 | type createSecureHttp2Options = { 60 | serverOptions?: SecureHttp2ServerOptions 61 | createServer?: typeof createSecureHttp2Server 62 | } 63 | 64 | export type ServerOptions = 65 | | createHttpOptions 66 | | createHttpsOptions 67 | | createHttp2Options 68 | | createSecureHttp2Options 69 | 70 | export type Options = { 71 | fetch: FetchCallback 72 | overrideGlobalObjects?: boolean 73 | port?: number 74 | hostname?: string 75 | } & ServerOptions 76 | 77 | export type CustomErrorHandler = (err: unknown) => void | Response | Promise 78 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { OutgoingHttpHeaders } from 'node:http' 2 | import type { Writable } from 'node:stream' 3 | 4 | export function writeFromReadableStream(stream: ReadableStream, writable: Writable) { 5 | if (stream.locked) { 6 | throw new TypeError('ReadableStream is locked.') 7 | } else if (writable.destroyed) { 8 | stream.cancel() 9 | return 10 | } 11 | const reader = stream.getReader() 12 | writable.on('close', cancel) 13 | writable.on('error', cancel) 14 | reader.read().then(flow, cancel) 15 | return reader.closed.finally(() => { 16 | writable.off('close', cancel) 17 | writable.off('error', cancel) 18 | }) 19 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 20 | function cancel(error?: any) { 21 | reader.cancel(error).catch(() => {}) 22 | if (error) { 23 | writable.destroy(error) 24 | } 25 | } 26 | function onDrain() { 27 | reader.read().then(flow, cancel) 28 | } 29 | function flow({ done, value }: ReadableStreamReadResult): void | Promise { 30 | try { 31 | if (done) { 32 | writable.end() 33 | } else if (!writable.write(value)) { 34 | writable.once('drain', onDrain) 35 | } else { 36 | return reader.read().then(flow, cancel) 37 | } 38 | } catch (e) { 39 | cancel(e) 40 | } 41 | } 42 | } 43 | 44 | export const buildOutgoingHttpHeaders = ( 45 | headers: Headers | HeadersInit | null | undefined 46 | ): OutgoingHttpHeaders => { 47 | const res: OutgoingHttpHeaders = {} 48 | if (!(headers instanceof Headers)) { 49 | headers = new Headers(headers ?? undefined) 50 | } 51 | 52 | const cookies = [] 53 | for (const [k, v] of headers) { 54 | if (k === 'set-cookie') { 55 | cookies.push(v) 56 | } else { 57 | res[k] = v 58 | } 59 | } 60 | if (cookies.length > 0) { 61 | res['set-cookie'] = cookies 62 | } 63 | res['content-type'] ??= 'text/plain; charset=UTF-8' 64 | 65 | return res 66 | } 67 | -------------------------------------------------------------------------------- /src/utils/response.ts: -------------------------------------------------------------------------------- 1 | import { X_ALREADY_SENT } from './response/constants' 2 | export const RESPONSE_ALREADY_SENT = new Response(null, { 3 | headers: { [X_ALREADY_SENT]: 'true' }, 4 | }) 5 | -------------------------------------------------------------------------------- /src/utils/response/constants.ts: -------------------------------------------------------------------------------- 1 | export const X_ALREADY_SENT = 'x-hono-already-sent' 2 | -------------------------------------------------------------------------------- /src/vercel.ts: -------------------------------------------------------------------------------- 1 | import type { Hono } from 'hono' 2 | import { getRequestListener } from './listener' 3 | 4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 5 | export const handle = (app: Hono) => { 6 | return getRequestListener(app.fetch) 7 | } 8 | -------------------------------------------------------------------------------- /test/assets/.static/plain.txt: -------------------------------------------------------------------------------- 1 | This is plain.txt -------------------------------------------------------------------------------- /test/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/honojs/node-server/0e35e65480f972d15982ccaa2b8c813c7cdf7da6/test/assets/favicon.ico -------------------------------------------------------------------------------- /test/assets/secret.txt: -------------------------------------------------------------------------------- 1 | secret -------------------------------------------------------------------------------- /test/assets/static-with-precompressed/hello.txt: -------------------------------------------------------------------------------- 1 | Hello Not Compressed -------------------------------------------------------------------------------- /test/assets/static-with-precompressed/hello.txt.br: -------------------------------------------------------------------------------- 1 | Hello br Compressed -------------------------------------------------------------------------------- /test/assets/static-with-precompressed/hello.txt.zst: -------------------------------------------------------------------------------- 1 | Hello zstd Compressed -------------------------------------------------------------------------------- /test/assets/static/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": 1, 3 | "name": "Foo Bar", 4 | "flag": true 5 | } -------------------------------------------------------------------------------- /test/assets/static/extensionless: -------------------------------------------------------------------------------- 1 | Extensionless -------------------------------------------------------------------------------- /test/assets/static/hono.html: -------------------------------------------------------------------------------- 1 |

This is Hono.html

-------------------------------------------------------------------------------- /test/assets/static/index.html: -------------------------------------------------------------------------------- 1 |

Hello Hono

-------------------------------------------------------------------------------- /test/assets/static/plain.txt: -------------------------------------------------------------------------------- 1 | This is plain.txt -------------------------------------------------------------------------------- /test/conninfo.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import type { AddressType, ConnInfo } from 'hono/conninfo' 3 | import { getConnInfo } from '../src/conninfo' 4 | 5 | describe('ConnInfo', () => { 6 | it('Should works', async () => { 7 | const app = new Hono().get('/', (c) => c.json(getConnInfo(c))) 8 | 9 | const socket = { 10 | remoteAddress: '0.0.0.0', 11 | remoteFamily: 'IPv4', 12 | remotePort: 3030, 13 | } 14 | expect( 15 | await ( 16 | await app.request( 17 | '/', 18 | {}, 19 | { 20 | incoming: { 21 | socket, 22 | }, 23 | } 24 | ) 25 | ).json() 26 | ).toEqual({ 27 | remote: { 28 | address: socket.remoteAddress, 29 | addressType: socket.remoteFamily as AddressType, 30 | port: socket.remotePort, 31 | }, 32 | } satisfies ConnInfo) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /test/fixtures/keys/agent1-cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIID6DCCAtCgAwIBAgIUFH02wcL3Qgben6tfIibXitsApCYwDQYJKoZIhvcNAQEL 3 | BQAwejELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMQswCQYDVQQHDAJTRjEPMA0G 4 | A1UECgwGSm95ZW50MRAwDgYDVQQLDAdOb2RlLmpzMQwwCgYDVQQDDANjYTExIDAe 5 | BgkqhkiG9w0BCQEWEXJ5QHRpbnljbG91ZHMub3JnMCAXDTIyMDkwMzIxNDAzN1oY 6 | DzIyOTYwNjE3MjE0MDM3WjB9MQswCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExCzAJ 7 | BgNVBAcMAlNGMQ8wDQYDVQQKDAZKb3llbnQxEDAOBgNVBAsMB05vZGUuanMxDzAN 8 | BgNVBAMMBmFnZW50MTEgMB4GCSqGSIb3DQEJARYRcnlAdGlueWNsb3Vkcy5vcmcw 9 | ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDUVjIK+yDTgnCT3CxChO0E 10 | 37q9VuHdrlKeKLeQzUJW2yczSfNzX/0zfHpjY+zKWie39z3HCJqWxtiG2wxiOI8c 11 | 3WqWOvzVmdWADlh6EfkIlg+E7VC6JaKDA+zabmhPvnuu3JzogBMnsWl68lCXzuPx 12 | deQAmEwNtqjrh74DtM+Ud0ulb//Ixjxo1q3rYKu+aaexSramuee6qJta2rjrB4l8 13 | B/bU+j1mDf9XQQfSjo9jRnp4hiTFdBl2k+lZzqE2L/rhu6EMjA2IhAq/7xA2MbLo 14 | 9cObVUin6lfoo5+JKRgT9Fp2xEgDOit+2EA/S6oUfPNeLSVUqmXOSWlXlwlb9Nxr 15 | AgMBAAGjYTBfMF0GCCsGAQUFBwEBBFEwTzAjBggrBgEFBQcwAYYXaHR0cDovL29j 16 | c3Aubm9kZWpzLm9yZy8wKAYIKwYBBQUHMAKGHGh0dHA6Ly9jYS5ub2RlanMub3Jn 17 | L2NhLmNlcnQwDQYJKoZIhvcNAQELBQADggEBAMM0mBBjLMt9pYXePtUeNO0VTw9y 18 | FWCM8nAcAO2kRNwkJwcsispNpkcsHZ5o8Xf5mpCotdvziEWG1hyxwU6nAWyNOLcN 19 | G0a0KUfbMO3B6ZYe1GwPDjXaQnv75SkAdxgX5zOzca3xnhITcjUUGjQ0fbDfwFV5 20 | ix8mnzvfXjDONdEznVa7PFcN6QliFUMwR/h8pCRHtE5+a10OSPeJSrGG+FtrGnRW 21 | G1IJUv6oiGF/MvWCr84REVgc1j78xomGANJIu2hN7bnD1nEMON6em8IfnDOUtynV 22 | 9wfWTqiQYD5Zifj6WcGa0aAHMuetyFG4lIfMAHmd3gaKpks7j9l26LwRPvI= 23 | -----END CERTIFICATE----- 24 | -------------------------------------------------------------------------------- /test/fixtures/keys/agent1-key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA1FYyCvsg04Jwk9wsQoTtBN+6vVbh3a5Snii3kM1CVtsnM0nz 3 | c1/9M3x6Y2Psylont/c9xwialsbYhtsMYjiPHN1qljr81ZnVgA5YehH5CJYPhO1Q 4 | uiWigwPs2m5oT757rtyc6IATJ7FpevJQl87j8XXkAJhMDbao64e+A7TPlHdLpW// 5 | yMY8aNat62CrvmmnsUq2prnnuqibWtq46weJfAf21Po9Zg3/V0EH0o6PY0Z6eIYk 6 | xXQZdpPpWc6hNi/64buhDIwNiIQKv+8QNjGy6PXDm1VIp+pX6KOfiSkYE/RadsRI 7 | AzorfthAP0uqFHzzXi0lVKplzklpV5cJW/TcawIDAQABAoIBAAvbtHfAhpjJVBgt 8 | 15rvaX04MWmZjIugzKRgib/gdq/7FTlcC+iJl85kSUF7tyGl30n62MxgwqFhAX6m 9 | hQ6HMhbelrFFIhGbwbyhEHfgwROlrcAysKt0pprCgVvBhrnNXYLqdyjU3jz9P3LK 10 | TY3s0/YMK2uNFdI+PTjKH+Z9Foqn9NZUnUonEDepGyuRO7fLeccWJPv2L4CR4a/5 11 | ku4VbDgVpvVSVRG3PSVzbmxobnpdpl52og+T7tPx1cLnIknPtVljXPWtZdfekh2E 12 | eAp2KxCCHOKzzG3ItBKsVu0woeqEpy8JcoO6LbgmEoVnZpgmtQClbBgef8+i+oGE 13 | BgW9nmECgYEA8gA63QQuZOUC56N1QXURexN2PogF4wChPaCTFbQSJXvSBkQmbqfL 14 | qRSD8P0t7GOioPrQK6pDwFf4BJB01AvkDf8Z6DxxOJ7cqIC7LOwDupXocWX7Q0Qk 15 | O6cwclBVsrDZK00v60uRRpl/a39GW2dx7IiQDkKQndLh3/0TbMIWHNcCgYEA4J6r 16 | yinZbLpKw2+ezhi4B4GT1bMLoKboJwpZVyNZZCzYR6ZHv+lS7HR/02rcYMZGoYbf 17 | n7OHwF4SrnUS7vPhG4g2ZsOhKQnMvFSQqpGmK1ZTuoKGAevyvtouhK/DgtLWzGvX 18 | 9fSahiq/UvfXs/z4M11q9Rv9ztPCmG1cwSEHlo0CgYEAogQNZJK8DMhVnYcNpXke 19 | 7uskqtCeQE/Xo06xqkIYNAgloBRYNpUYAGa/vsOBz1UVN/kzDUi8ezVp0oRz8tLT 20 | J5u2WIi+tE2HJTiqF3UbOfvK1sCT64DfUSCpip7GAQ/tFNRkVH8PD9kMOYfILsGe 21 | v+DdsO5Xq5HXrwHb02BNNZkCgYBsl8lt33WiPx5OBfS8pu6xkk+qjPkeHhM2bKZs 22 | nkZlS9j0KsudWGwirN/vkkYg8zrKdK5AQ0dqFRDrDuasZ3N5IA1M+V88u+QjWK7o 23 | B6pSYVXxYZDv9OZSpqC+vUrEQLJf+fNakXrzSk9dCT1bYv2Lt6ox/epix7XYg2bI 24 | Z/OHMQKBgQC2FUGhlndGeugTJaoJ8nhT/0VfRUX/h6sCgSerk5qFr/hNCBV4T022 25 | x0NDR2yLG6MXyqApJpG6rh3QIDElQoQCNlI3/KJ6JfEfmqrLLN2OigTvA5sE4fGU 26 | Dp/ha8OQAx95EwXuaG7LgARduvOIK3x8qi8KsZoUGJcg2ywurUbkWA== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/listener.test.ts: -------------------------------------------------------------------------------- 1 | import request from 'supertest' 2 | import { createServer } from 'node:http' 3 | import { getRequestListener } from '../src/listener' 4 | import { GlobalRequest, Request as LightweightRequest, RequestError } from '../src/request' 5 | import { GlobalResponse, Response as LightweightResponse } from '../src/response' 6 | 7 | describe('Invalid request', () => { 8 | describe('default error handler', () => { 9 | const requestListener = getRequestListener(jest.fn()) 10 | const server = createServer(requestListener) 11 | 12 | it('Should return server error for a request w/o host header', async () => { 13 | const res = await request(server).get('/').set('Host', '').send() 14 | expect(res.status).toBe(400) 15 | }) 16 | 17 | it('Should return server error for a request invalid host header', async () => { 18 | const res = await request(server).get('/').set('Host', 'a b').send() 19 | expect(res.status).toBe(400) 20 | }) 21 | }) 22 | 23 | describe('custom error handler', () => { 24 | const requestListener = getRequestListener(jest.fn(), { 25 | errorHandler: (e) => { 26 | if (e instanceof RequestError) { 27 | return new Response(e.message, { status: 400 }) 28 | } else { 29 | return new Response('unknown error', { status: 500 }) 30 | } 31 | }, 32 | }) 33 | const server = createServer(requestListener) 34 | 35 | it('Should return server error for a request w/o host header', async () => { 36 | const res = await request(server).get('/').set('Host', '').send() 37 | expect(res.status).toBe(400) 38 | }) 39 | 40 | it('Should return server error for a request invalid host header', async () => { 41 | const res = await request(server).get('/').set('Host', 'a b').send() 42 | expect(res.status).toBe(400) 43 | }) 44 | 45 | it('Should return server error for host header with path', async () => { 46 | const res = await request(server).get('/').set('Host', 'a/b').send() 47 | expect(res.status).toBe(400) 48 | }) 49 | }) 50 | 51 | describe('default hostname', () => { 52 | const requestListener = getRequestListener(() => new Response('ok'), { 53 | hostname: 'example.com', 54 | }) 55 | const server = createServer(requestListener) 56 | 57 | it('Should return 200 for a request w/o host header', async () => { 58 | const res = await request(server).get('/').set('Host', '').send() 59 | expect(res.status).toBe(200) 60 | }) 61 | 62 | it('Should return server error for a request invalid host header', async () => { 63 | const res = await request(server).get('/').set('Host', 'a b').send() 64 | expect(res.status).toBe(400) 65 | }) 66 | }) 67 | 68 | describe('malformed body response', () => { 69 | const malformedResponse = { 70 | body: 'content', 71 | } 72 | const requestListener = getRequestListener(() => malformedResponse, { 73 | hostname: 'example.com', 74 | }) 75 | const server = createServer(requestListener) 76 | 77 | it('Should return a 500 for a malformed response', async () => { 78 | const res = await request(server).get('/').send() 79 | expect(res.status).toBe(500) 80 | }) 81 | }) 82 | }) 83 | 84 | describe('Error handling - sync fetchCallback', () => { 85 | const fetchCallback = jest.fn(() => { 86 | throw new Error('thrown error') 87 | }) 88 | const errorHandler = jest.fn() 89 | 90 | const requestListener = getRequestListener(fetchCallback, { errorHandler }) 91 | 92 | const server = createServer(async (req, res) => { 93 | await requestListener(req, res) 94 | 95 | if (!res.writableEnded) { 96 | res.writeHead(500, { 'Content-Type': 'text/plain' }) 97 | res.end('error handler did not return a response') 98 | } 99 | }) 100 | 101 | beforeEach(() => { 102 | errorHandler.mockReset() 103 | }) 104 | 105 | it('Should set the response if error handler returns a response', async () => { 106 | errorHandler.mockImplementationOnce((err: Error) => { 107 | return new Response(`${err}`, { status: 500, headers: { 'my-custom-header': 'hi' } }) 108 | }) 109 | 110 | const res = await request(server).get('/throw-error') 111 | expect(res.status).toBe(500) 112 | expect(res.headers['my-custom-header']).toBe('hi') 113 | expect(res.text).toBe('Error: thrown error') 114 | }) 115 | 116 | it('Should not set the response if the error handler does not return a response', async () => { 117 | errorHandler.mockImplementationOnce(() => { 118 | // do something else, such as passing error to vite next middleware, etc 119 | }) 120 | 121 | const res = await request(server).get('/throw-error') 122 | expect(errorHandler).toHaveBeenCalledTimes(1) 123 | expect(res.status).toBe(500) 124 | expect(res.text).toBe('error handler did not return a response') 125 | }) 126 | }) 127 | 128 | describe('Error handling - async fetchCallback', () => { 129 | const fetchCallback = jest.fn(async () => { 130 | throw new Error('thrown error') 131 | }) 132 | const errorHandler = jest.fn() 133 | 134 | const requestListener = getRequestListener(fetchCallback, { errorHandler }) 135 | 136 | const server = createServer(async (req, res) => { 137 | await requestListener(req, res) 138 | 139 | if (!res.writableEnded) { 140 | res.writeHead(500, { 'Content-Type': 'text/plain' }) 141 | res.end('error handler did not return a response') 142 | } 143 | }) 144 | 145 | beforeEach(() => { 146 | errorHandler.mockReset() 147 | }) 148 | 149 | it('Should set the response if error handler returns a response', async () => { 150 | errorHandler.mockImplementationOnce((err: Error) => { 151 | return new Response(`${err}`, { status: 500, headers: { 'my-custom-header': 'hi' } }) 152 | }) 153 | 154 | const res = await request(server).get('/throw-error') 155 | expect(res.status).toBe(500) 156 | expect(res.headers['my-custom-header']).toBe('hi') 157 | expect(res.text).toBe('Error: thrown error') 158 | }) 159 | 160 | it('Should not set the response if the error handler does not return a response', async () => { 161 | errorHandler.mockImplementationOnce(() => { 162 | // do something else, such as passing error to vite next middleware, etc 163 | }) 164 | 165 | const res = await request(server).get('/throw-error') 166 | expect(errorHandler).toHaveBeenCalledTimes(1) 167 | expect(res.status).toBe(500) 168 | expect(res.text).toBe('error handler did not return a response') 169 | }) 170 | }) 171 | 172 | describe('Abort request', () => { 173 | let onAbort: (req: Request) => void 174 | let reqReadyResolve: () => void 175 | let reqReadyPromise: Promise 176 | const fetchCallback = async (req: Request) => { 177 | req.signal.addEventListener('abort', () => onAbort(req)) 178 | reqReadyResolve?.() 179 | await new Promise(() => {}) // never resolve 180 | } 181 | 182 | const requestListener = getRequestListener(fetchCallback) 183 | 184 | const server = createServer(async (req, res) => { 185 | await requestListener(req, res) 186 | }) 187 | 188 | beforeEach(() => { 189 | reqReadyPromise = new Promise((r) => { 190 | reqReadyResolve = r 191 | }) 192 | }) 193 | 194 | afterAll(() => { 195 | server.close() 196 | }) 197 | 198 | it.each(['get', 'put', 'patch', 'delete'] as const)( 199 | 'should emit an abort event when the nodejs %s request is aborted', 200 | async (method) => { 201 | const requests: Request[] = [] 202 | const abortedPromise = new Promise((resolve) => { 203 | onAbort = (req) => { 204 | requests.push(req) 205 | resolve() 206 | } 207 | }) 208 | 209 | const req = request(server) 210 | [method]('/abort') 211 | .end(() => {}) 212 | 213 | await reqReadyPromise 214 | 215 | req.abort() 216 | 217 | await abortedPromise 218 | 219 | expect(requests).toHaveLength(1) 220 | const abortedReq = requests[0] 221 | expect(abortedReq).toBeInstanceOf(Request) 222 | expect(abortedReq.signal.aborted).toBe(true) 223 | } 224 | ) 225 | 226 | it.each(['get', 'post', 'head', 'patch', 'delete', 'put'] as const)( 227 | 'should emit an abort event when the nodejs request is aborted on multiple %s requests', 228 | async (method) => { 229 | const requests: Request[] = [] 230 | 231 | { 232 | const abortedPromise = new Promise((resolve) => { 233 | onAbort = (req) => { 234 | requests.push(req) 235 | resolve() 236 | } 237 | }) 238 | 239 | reqReadyPromise = new Promise((r) => { 240 | reqReadyResolve = r 241 | }) 242 | 243 | const req = request(server) 244 | [method]('/abort') 245 | .end(() => {}) 246 | 247 | await reqReadyPromise 248 | 249 | req.abort() 250 | 251 | await abortedPromise 252 | } 253 | 254 | expect(requests).toHaveLength(1) 255 | 256 | for (const abortedReq of requests) { 257 | expect(abortedReq).toBeInstanceOf(Request) 258 | expect(abortedReq.signal.aborted).toBe(true) 259 | } 260 | 261 | { 262 | const abortedPromise = new Promise((resolve) => { 263 | onAbort = (req) => { 264 | requests.push(req) 265 | resolve() 266 | } 267 | }) 268 | 269 | reqReadyPromise = new Promise((r) => { 270 | reqReadyResolve = r 271 | }) 272 | 273 | const req = request(server) 274 | [method]('/abort') 275 | .end(() => {}) 276 | 277 | await reqReadyPromise 278 | 279 | req.abort() 280 | 281 | await abortedPromise 282 | } 283 | 284 | expect(requests).toHaveLength(2) 285 | 286 | for (const abortedReq of requests) { 287 | expect(abortedReq).toBeInstanceOf(Request) 288 | expect(abortedReq.signal.aborted).toBe(true) 289 | } 290 | } 291 | ) 292 | 293 | it('should handle request abort without requestCache', async () => { 294 | const fetchCallback = async () => { 295 | // NOTE: we don't req.signal 296 | await new Promise(() => {}) // never resolve 297 | } 298 | const requestListener = getRequestListener(fetchCallback) 299 | const server = createServer(requestListener) 300 | const req = request(server).post('/abort').timeout({ deadline: 1 }) 301 | await expect(req).rejects.toHaveProperty('timeout') 302 | }) 303 | }) 304 | 305 | describe('overrideGlobalObjects', () => { 306 | const fetchCallback = jest.fn() 307 | 308 | beforeEach(() => { 309 | Object.defineProperty(global, 'Request', { 310 | value: GlobalRequest, 311 | writable: true, 312 | }) 313 | Object.defineProperty(global, 'Response', { 314 | value: GlobalResponse, 315 | writable: true, 316 | }) 317 | }) 318 | 319 | describe('default', () => { 320 | it('Should be overridden', () => { 321 | getRequestListener(fetchCallback) 322 | expect(global.Request).toBe(LightweightRequest) 323 | expect(global.Response).toBe(LightweightResponse) 324 | }) 325 | }) 326 | 327 | describe('overrideGlobalObjects: true', () => { 328 | it('Should be overridden', () => { 329 | getRequestListener(fetchCallback, { 330 | overrideGlobalObjects: true, 331 | }) 332 | expect(global.Request).toBe(LightweightRequest) 333 | expect(global.Response).toBe(LightweightResponse) 334 | }) 335 | }) 336 | 337 | describe('overrideGlobalObjects: false', () => { 338 | it('Should not be overridden', () => { 339 | getRequestListener(fetchCallback, { 340 | overrideGlobalObjects: false, 341 | }) 342 | expect(global.Request).toBe(GlobalRequest) 343 | expect(global.Response).toBe(GlobalResponse) 344 | }) 345 | }) 346 | }) 347 | -------------------------------------------------------------------------------- /test/request.test.ts: -------------------------------------------------------------------------------- 1 | import { IncomingMessage } from 'node:http' 2 | import type { ServerHttp2Stream } from 'node:http2' 3 | import { Http2ServerRequest } from 'node:http2' 4 | import { Socket } from 'node:net' 5 | import { Duplex } from 'node:stream' 6 | import { 7 | newRequest, 8 | Request as LightweightRequest, 9 | GlobalRequest, 10 | getAbortController, 11 | abortControllerKey, 12 | RequestError, 13 | } from '../src/request' 14 | 15 | Object.defineProperty(global, 'Request', { 16 | value: LightweightRequest, 17 | }) 18 | 19 | describe('Request', () => { 20 | describe('newRequest', () => { 21 | it('Compatibility with standard Request object', async () => { 22 | const req = newRequest({ 23 | method: 'GET', 24 | url: '/', 25 | headers: { 26 | host: 'localhost', 27 | }, 28 | rawHeaders: ['host', 'localhost'], 29 | } as IncomingMessage) 30 | 31 | expect(req).toBeInstanceOf(global.Request) 32 | expect(req.method).toBe('GET') 33 | expect(req.url).toBe('http://localhost/') 34 | expect(req.headers.get('host')).toBe('localhost') 35 | expect(req.keepalive).toBe(false) 36 | }) 37 | 38 | it('Should resolve double dots in URL', async () => { 39 | const req = newRequest({ 40 | headers: { 41 | host: 'localhost', 42 | }, 43 | url: '/static/../foo.txt', 44 | } as IncomingMessage) 45 | expect(req).toBeInstanceOf(global.Request) 46 | expect(req.url).toBe('http://localhost/foo.txt') 47 | }) 48 | 49 | it('Should accept hostname and port in host header', async () => { 50 | const req = newRequest({ 51 | headers: { 52 | host: 'localhost:8080', 53 | }, 54 | url: '/static/../foo.txt', 55 | } as IncomingMessage) 56 | expect(req).toBeInstanceOf(global.Request) 57 | expect(req.url).toBe('http://localhost:8080/foo.txt') 58 | }) 59 | 60 | it('should generate only one `AbortController` per `Request` object created', async () => { 61 | const req = newRequest({ 62 | headers: { 63 | host: 'localhost', 64 | }, 65 | rawHeaders: ['host', 'localhost'], 66 | url: '/foo.txt', 67 | } as IncomingMessage) 68 | const req2 = newRequest({ 69 | headers: { 70 | host: 'localhost', 71 | }, 72 | rawHeaders: ['host', 'localhost'], 73 | url: '/foo.txt', 74 | } as IncomingMessage) 75 | 76 | const x = req[getAbortController]() 77 | const y = req[getAbortController]() 78 | const z = req2[getAbortController]() 79 | 80 | expect(x).toBeInstanceOf(AbortController) 81 | expect(y).toBeInstanceOf(AbortController) 82 | expect(z).toBeInstanceOf(AbortController) 83 | expect(x).toBe(y) 84 | expect(z).not.toBe(x) 85 | expect(z).not.toBe(y) 86 | }) 87 | 88 | it('should be able to safely check if an AbortController has been initialized by referencing the abortControllerKey', async () => { 89 | const req = newRequest({ 90 | headers: { 91 | host: 'localhost', 92 | }, 93 | rawHeaders: ['host', 'localhost'], 94 | url: '/foo.txt', 95 | } as IncomingMessage) 96 | 97 | expect(req[abortControllerKey]).toBeUndefined() // not initialized, do not initialize internal request object automatically 98 | 99 | expect(req[getAbortController]()).toBeDefined() 100 | expect(req[abortControllerKey]).toBeDefined() // initialized 101 | }) 102 | 103 | it('Should throw error if host header contains path', async () => { 104 | expect(() => { 105 | newRequest({ 106 | headers: { 107 | host: 'localhost/..', 108 | }, 109 | url: '/foo.txt', 110 | } as IncomingMessage) 111 | }).toThrow(RequestError) 112 | }) 113 | 114 | it('Should throw error if host header is empty', async () => { 115 | expect(() => { 116 | newRequest({ 117 | headers: { 118 | host: '', 119 | }, 120 | url: '/foo.txt', 121 | } as IncomingMessage) 122 | }).toThrow(RequestError) 123 | }) 124 | 125 | it('Should throw error if host header contains query parameter', async () => { 126 | expect(() => { 127 | newRequest({ 128 | headers: { 129 | host: 'localhost?foo=bar', 130 | }, 131 | url: '/foo.txt', 132 | } as IncomingMessage) 133 | }).toThrow(RequestError) 134 | }) 135 | 136 | it('Should be created request body from `req.rawBody` if it exists', async () => { 137 | const rawBody = Buffer.from('foo') 138 | const socket = new Socket() 139 | const incomingMessage = new IncomingMessage(socket) 140 | incomingMessage.method = 'POST' 141 | incomingMessage.headers = { 142 | host: 'localhost', 143 | } 144 | incomingMessage.url = '/foo.txt' 145 | ;(incomingMessage as IncomingMessage & { rawBody: Buffer }).rawBody = rawBody 146 | incomingMessage.push(rawBody) 147 | incomingMessage.push(null) 148 | 149 | for await (const chunk of incomingMessage) { 150 | // consume body 151 | expect(chunk).toBeDefined() 152 | } 153 | 154 | const req = newRequest(incomingMessage) 155 | const text = await req.text() 156 | expect(text).toBe('foo') 157 | }) 158 | 159 | describe('absolute-form for request-target', () => { 160 | it('should be created from valid absolute URL', async () => { 161 | const req = newRequest({ 162 | url: 'http://localhost/path/to/file.html', 163 | } as IncomingMessage) 164 | expect(req).toBeInstanceOf(GlobalRequest) 165 | expect(req.url).toBe('http://localhost/path/to/file.html') 166 | }) 167 | 168 | it('should throw error if host header is invalid', async () => { 169 | expect(() => { 170 | newRequest({ 171 | url: 'http://', 172 | } as IncomingMessage) 173 | }).toThrow(RequestError) 174 | }) 175 | 176 | it('should throw error if absolute-form is specified via HTTP/2', async () => { 177 | expect(() => { 178 | newRequest( 179 | new Http2ServerRequest( 180 | new Duplex() as ServerHttp2Stream, 181 | { 182 | ':scheme': 'http', 183 | ':authority': 'localhost', 184 | ':path': 'http://localhost/foo.txt', 185 | }, 186 | {}, 187 | [] 188 | ) 189 | ) 190 | }).toThrow(RequestError) 191 | }) 192 | }) 193 | 194 | describe('HTTP/2', () => { 195 | it('should be created from "http" scheme', async () => { 196 | const req = newRequest( 197 | new Http2ServerRequest( 198 | new Duplex() as ServerHttp2Stream, 199 | { 200 | ':scheme': 'http', 201 | ':authority': 'localhost', 202 | ':path': '/foo.txt', 203 | }, 204 | {}, 205 | [] 206 | ) 207 | ) 208 | expect(req).toBeInstanceOf(GlobalRequest) 209 | expect(req.url).toBe('http://localhost/foo.txt') 210 | }) 211 | 212 | it('should be created from "https" scheme', async () => { 213 | const req = newRequest( 214 | new Http2ServerRequest( 215 | new Duplex() as ServerHttp2Stream, 216 | { 217 | ':scheme': 'https', 218 | ':authority': 'localhost', 219 | ':path': '/foo.txt', 220 | }, 221 | {}, 222 | [] 223 | ) 224 | ) 225 | expect(req).toBeInstanceOf(GlobalRequest) 226 | expect(req.url).toBe('https://localhost/foo.txt') 227 | }) 228 | 229 | it('should throw error if scheme is missing', async () => { 230 | expect(() => { 231 | newRequest( 232 | new Http2ServerRequest( 233 | new Duplex() as ServerHttp2Stream, 234 | { 235 | ':authority': 'localhost', 236 | ':path': '/foo.txt', 237 | }, 238 | {}, 239 | [] 240 | ) 241 | ) 242 | }).toThrow(RequestError) 243 | }) 244 | 245 | it('should throw error if unsupported scheme is specified', async () => { 246 | expect(() => { 247 | newRequest( 248 | new Http2ServerRequest( 249 | new Duplex() as ServerHttp2Stream, 250 | { 251 | ':scheme': 'ftp', 252 | ':authority': 'localhost', 253 | ':path': '/foo.txt', 254 | }, 255 | {}, 256 | [] 257 | ) 258 | ) 259 | }).toThrow(RequestError) 260 | }) 261 | }) 262 | }) 263 | 264 | describe('GlobalRequest', () => { 265 | it('should be overrode by Request', () => { 266 | expect(Request).not.toBe(GlobalRequest) 267 | }) 268 | 269 | it('should be instance of GlobalRequest', () => { 270 | const req = new Request('http://localhost/') 271 | expect(req).toBeInstanceOf(GlobalRequest) 272 | }) 273 | 274 | it('should be success to create instance from old light weight instance', async () => { 275 | const req = newRequest({ 276 | method: 'GET', 277 | url: '/', 278 | headers: { 279 | host: 'localhost', 280 | }, 281 | rawHeaders: ['host', 'localhost'], 282 | } as IncomingMessage) 283 | const req2 = new Request(req, { 284 | method: 'POST', 285 | body: 'foo', 286 | }) 287 | expect(req2).toBeInstanceOf(GlobalRequest) 288 | expect(await req2.text()).toBe('foo') 289 | }) 290 | 291 | it('should set `duplex: "half"` automatically if body is a ReadableStream', async () => { 292 | const stream = new ReadableStream({ 293 | start(controller) { 294 | controller.enqueue(new TextEncoder().encode('bar')) 295 | controller.close() 296 | }, 297 | }) 298 | const req2 = new Request('http://localhost', { 299 | method: 'POST', 300 | body: stream, 301 | }) 302 | expect(req2).toBeInstanceOf(GlobalRequest) 303 | expect(req2.text()).resolves.toBe('bar') 304 | }) 305 | 306 | it('should skip to set `duplex: "half"` if init option is a Request object', async () => { 307 | const stream = new ReadableStream({ 308 | start(controller) { 309 | controller.enqueue(new TextEncoder().encode('bar')) 310 | controller.close() 311 | }, 312 | }) 313 | const req = new Request('http://localhost', { 314 | method: 'POST', 315 | body: stream, 316 | }) 317 | const req2 = new Request('http://localhost/subapp', req) 318 | expect(req2).toBeInstanceOf(GlobalRequest) 319 | expect(req2.text()).resolves.toBe('bar') 320 | }) 321 | }) 322 | }) 323 | 324 | describe('RequestError', () => { 325 | it('should have a static name property (class name)', () => { 326 | expect(RequestError.name).toBe('RequestError') 327 | expect(Object.hasOwn(RequestError, 'name')).toBe(true) 328 | }) 329 | 330 | it('should have an instance name property', () => { 331 | const error = new RequestError('message') 332 | expect(error.name).toBe('RequestError') 333 | expect(Object.hasOwn(error, 'name')).toBe(true) 334 | }) 335 | }) 336 | -------------------------------------------------------------------------------- /test/response.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from 'node:http' 2 | import type { Server } from 'node:http' 3 | import type { AddressInfo } from 'node:net' 4 | import { GlobalResponse, Response as LightweightResponse, cacheKey } from '../src/response' 5 | 6 | Object.defineProperty(global, 'Response', { 7 | value: LightweightResponse, 8 | }) 9 | 10 | class NextResponse extends LightweightResponse {} 11 | 12 | class UpperCaseStream extends TransformStream { 13 | constructor() { 14 | super({ 15 | transform(chunk, controller) { 16 | controller.enqueue( 17 | new TextEncoder().encode(new TextDecoder().decode(chunk).toString().toUpperCase()) 18 | ) 19 | }, 20 | }) 21 | } 22 | } 23 | 24 | describe('Response', () => { 25 | let server: Server 26 | let port: number 27 | beforeAll( 28 | async () => 29 | new Promise((resolve) => { 30 | server = createServer((_, res) => { 31 | res.writeHead(200, { 32 | 'Content-Type': 'application/json charset=UTF-8', 33 | }) 34 | res.end(JSON.stringify({ status: 'ok' })) 35 | }) 36 | .listen(0) 37 | .on('listening', () => { 38 | port = (server.address() as AddressInfo).port 39 | resolve() 40 | }) 41 | }) 42 | ) 43 | 44 | afterAll(() => { 45 | server.close() 46 | }) 47 | 48 | it('Should be overrode by Response', () => { 49 | expect(Response).not.toBe(GlobalResponse) 50 | }) 51 | 52 | it('Compatibility with standard Response object', async () => { 53 | // response name not changed 54 | expect(Response.name).toEqual('Response') 55 | 56 | // response prototype chain not changed 57 | expect(new Response()).toBeInstanceOf(GlobalResponse) 58 | 59 | // `fetch()` and `Response` are not changed 60 | const fetchRes = await fetch(`http://localhost:${port}`) 61 | expect(new Response()).toBeInstanceOf(fetchRes.constructor) 62 | const resJson = await fetchRes.json() 63 | expect(fetchRes.headers.get('content-type')).toEqual('application/json charset=UTF-8') 64 | expect(resJson).toEqual({ status: 'ok' }) 65 | 66 | // can only use new operator 67 | expect(() => { 68 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 69 | ;(Response as any)() 70 | }).toThrow() 71 | 72 | // support Response static method 73 | expect(Response.error).toEqual(expect.any(Function)) 74 | expect(Response.json).toEqual(expect.any(Function)) 75 | expect(Response.redirect).toEqual(expect.any(Function)) 76 | 77 | // support other class to extends from Response 78 | expect(new NextResponse()).toBeInstanceOf(Response) 79 | }) 80 | 81 | it('Should not lose header data', async () => { 82 | const parentResponse = new Response('OK', { 83 | headers: { 84 | 'content-type': 'application/json', 85 | }, 86 | }) 87 | const childResponse = new Response('OK', parentResponse) 88 | parentResponse.headers.delete('content-type') 89 | expect(childResponse.headers.get('content-type')).toEqual('application/json') 90 | }) 91 | 92 | it('Nested constructors should not cause an error even if ReadableStream is specified', async () => { 93 | const stream = new Response('hono').body 94 | const parentResponse = new Response(stream) 95 | const upperCaseStream = new UpperCaseStream() 96 | const childResponse = new Response( 97 | parentResponse.body!.pipeThrough(upperCaseStream), 98 | parentResponse 99 | ) 100 | expect(await childResponse.text()).toEqual('HONO') 101 | }) 102 | 103 | describe('Fallback to GlobalResponse object', () => { 104 | it('Should return value from internal cache', () => { 105 | const res = new Response('Hello! Node!') 106 | res.headers.set('x-test', 'test') 107 | expect(res.headers.get('x-test')).toEqual('test') 108 | expect(res.status).toEqual(200) 109 | expect(res.ok).toEqual(true) 110 | expect(cacheKey in res).toBe(true) 111 | }) 112 | 113 | it('Should return value from generated GlobalResponse object', () => { 114 | const res = new Response('Hello! Node!', { 115 | statusText: 'OK', 116 | }) 117 | expect(res.statusText).toEqual('OK') 118 | expect(cacheKey in res).toBe(false) 119 | }) 120 | }) 121 | }) 122 | -------------------------------------------------------------------------------- /test/serve-static.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import request from 'supertest' 3 | import { serveStatic } from './../src/serve-static' 4 | import { createAdaptorServer } from './../src/server' 5 | 6 | describe('Serve Static Middleware', () => { 7 | const app = new Hono() 8 | 9 | app.use( 10 | '/static/*', 11 | serveStatic({ 12 | root: './test/assets', 13 | onFound: (path, c) => { 14 | c.header('X-Custom', `Found the file at ${path}`) 15 | }, 16 | }) 17 | ) 18 | app.use('/favicon.ico', serveStatic({ path: './test/assets/favicon.ico' })) 19 | app.use( 20 | '/dot-static/*', 21 | serveStatic({ 22 | root: './test/assets', 23 | rewriteRequestPath: (path) => path.replace(/^\/dot-static/, '/.static'), 24 | }) 25 | ) 26 | 27 | let notFoundMessage = '' 28 | app.use( 29 | '/on-not-found/*', 30 | serveStatic({ 31 | root: './not-found', 32 | onNotFound: (path, c) => { 33 | notFoundMessage = `${path} is not found, request to ${c.req.path}` 34 | }, 35 | }) 36 | ) 37 | 38 | app.use( 39 | '/static-with-precompressed/*', 40 | serveStatic({ 41 | root: './test/assets', 42 | precompressed: true, 43 | }) 44 | ) 45 | 46 | const server = createAdaptorServer(app) 47 | 48 | it('Should return index.html', async () => { 49 | const res = await request(server).get('/static/') 50 | expect(res.status).toBe(200) 51 | expect(res.text).toBe('

Hello Hono

') 52 | expect(res.headers['content-type']).toBe('text/html; charset=utf-8') 53 | expect(res.headers['x-custom']).toBe('Found the file at ./test/assets/static/index.html') 54 | }) 55 | 56 | it('Should return hono.html', async () => { 57 | const res = await request(server).get('/static/hono.html') 58 | expect(res.status).toBe(200) 59 | expect(res.text).toBe('

This is Hono.html

') 60 | expect(res.headers['content-type']).toBe('text/html; charset=utf-8') 61 | }) 62 | 63 | it('Should return correct headers for icons', async () => { 64 | const res = await request(server).get('/favicon.ico') 65 | expect(res.status).toBe(200) 66 | expect(res.headers['content-type']).toBe('image/x-icon') 67 | }) 68 | 69 | it('Should return correct headers and data for json files', async () => { 70 | const res = await request(server).get('/static/data.json') 71 | expect(res.status).toBe(200) 72 | expect(res.body).toEqual({ 73 | id: 1, 74 | name: 'Foo Bar', 75 | flag: true, 76 | }) 77 | expect(res.headers['content-type']).toBe('application/json') 78 | }) 79 | 80 | it('Should return correct headers and data for text', async () => { 81 | const res = await request(server).get('/static/plain.txt') 82 | expect(res.status).toBe(200) 83 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 84 | expect(res.text).toBe('This is plain.txt') 85 | }) 86 | 87 | it('Should return 404 for non-existent files', async () => { 88 | const res = await request(server).get('/static/does-not-exist.html') 89 | expect(res.status).toBe(404) 90 | expect(res.headers['content-type']).toBe('text/plain; charset=UTF-8') 91 | expect(res.text).toBe('404 Not Found') 92 | }) 93 | 94 | it('Should return 200 with rewriteRequestPath', async () => { 95 | const res = await request(server).get('/dot-static/plain.txt') 96 | expect(res.status).toBe(200) 97 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 98 | expect(res.text).toBe('This is plain.txt') 99 | }) 100 | 101 | it('Should return 404 with rewriteRequestPath', async () => { 102 | const res = await request(server).get('/dot-static/does-no-exists.txt') 103 | expect(res.status).toBe(404) 104 | }) 105 | 106 | it('Should return 200 response to HEAD request', async () => { 107 | const res = await request(server).head('/static/plain.txt') 108 | expect(res.status).toBe(200) 109 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 110 | expect(res.headers['content-length']).toBe('17') 111 | expect(res.text).toBe(undefined) 112 | }) 113 | 114 | it('Should return correct headers and data with range headers', async () => { 115 | let res = await request(server).get('/static/plain.txt').set('range', '0-9') 116 | expect(res.status).toBe(206) 117 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 118 | expect(res.headers['content-length']).toBe('10') 119 | expect(res.headers['content-range']).toBe('bytes 0-9/17') 120 | expect(res.text.length).toBe(10) 121 | expect(res.text).toBe('This is pl') 122 | 123 | res = await request(server).get('/static/plain.txt').set('range', '10-16') 124 | expect(res.status).toBe(206) 125 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 126 | expect(res.headers['content-length']).toBe('7') 127 | expect(res.headers['content-range']).toBe('bytes 10-16/17') 128 | expect(res.text.length).toBe(7) 129 | expect(res.text).toBe('ain.txt') 130 | }) 131 | 132 | it('Should return correct headers and data if client range exceeds the data size', async () => { 133 | const res = await request(server).get('/static/plain.txt').set('range', '0-20') 134 | expect(res.status).toBe(206) 135 | expect(res.headers['content-type']).toBe('text/plain; charset=utf-8') 136 | expect(res.headers['content-length']).toBe('17') 137 | expect(res.headers['content-range']).toBe('bytes 0-16/17') 138 | expect(res.text.length).toBe(17) 139 | expect(res.text).toBe('This is plain.txt') 140 | }) 141 | 142 | it('Should handle the `onNotFound` option', async () => { 143 | const res = await request(server).get('/on-not-found/foo.txt') 144 | expect(res.status).toBe(404) 145 | expect(notFoundMessage).toBe( 146 | './not-found/on-not-found/foo.txt is not found, request to /on-not-found/foo.txt' 147 | ) 148 | }) 149 | 150 | it('Should handle double dots in URL', async () => { 151 | const res = await request(server).get('/static/../secret.txt') 152 | expect(res.status).toBe(404) 153 | }) 154 | 155 | it('Should handle URIError thrown while decoding URI component', async () => { 156 | const res = await request(server).get('/static/%c0%afsecret.txt') 157 | expect(res.status).toBe(404) 158 | }) 159 | 160 | it('Should handle an extension less files', async () => { 161 | const res = await request(server).get('/static/extensionless') 162 | expect(res.status).toBe(200) 163 | expect(res.headers['content-type']).toBe('application/octet-stream') 164 | expect(res.body.toString()).toBe('Extensionless') 165 | }) 166 | 167 | it('Should return a pre-compressed zstd response - /static-with-precompressed/hello.txt', async () => { 168 | // Check if it returns a normal response 169 | let res = await request(server).get('/static-with-precompressed/hello.txt') 170 | expect(res.status).toBe(200) 171 | expect(res.headers['content-length']).toBe('20') 172 | expect(res.text).toBe('Hello Not Compressed') 173 | 174 | res = await request(server) 175 | .get('/static-with-precompressed/hello.txt') 176 | .set('Accept-Encoding', 'zstd') 177 | expect(res.status).toBe(200) 178 | expect(res.headers['content-length']).toBe('21') 179 | expect(res.headers['content-encoding']).toBe('zstd') 180 | expect(res.headers['vary']).toBe('Accept-Encoding') 181 | expect(res.text).toBe('Hello zstd Compressed') 182 | }) 183 | 184 | it('Should return a pre-compressed brotli response - /static-with-precompressed/hello.txt', async () => { 185 | const res = await request(server) 186 | .get('/static-with-precompressed/hello.txt') 187 | .set('Accept-Encoding', 'wompwomp, gzip, br, deflate, zstd') 188 | expect(res.status).toBe(200) 189 | expect(res.headers['content-length']).toBe('19') 190 | expect(res.headers['content-encoding']).toBe('br') 191 | expect(res.headers['vary']).toBe('Accept-Encoding') 192 | expect(res.text).toBe('Hello br Compressed') 193 | }) 194 | 195 | it('Should not return a pre-compressed response - /static-with-precompressed/hello.txt', async () => { 196 | const res = await request(server) 197 | .get('/static-with-precompressed/hello.txt') 198 | .set('Accept-Encoding', 'wompwomp, unknown') 199 | expect(res.status).toBe(200) 200 | expect(res.headers['content-encoding']).toBeUndefined() 201 | expect(res.headers['vary']).toBeUndefined() 202 | expect(res.text).toBe('Hello Not Compressed') 203 | }) 204 | }) 205 | -------------------------------------------------------------------------------- /test/server.test.ts: -------------------------------------------------------------------------------- 1 | import { Response as PonyfillResponse } from '@whatwg-node/fetch' 2 | import { Hono } from 'hono' 3 | import { basicAuth } from 'hono/basic-auth' 4 | import { compress } from 'hono/compress' 5 | import { etag } from 'hono/etag' 6 | import { poweredBy } from 'hono/powered-by' 7 | import { stream } from 'hono/streaming' 8 | import request from 'supertest' 9 | import fs from 'node:fs' 10 | import { createServer as createHttp2Server } from 'node:http2' 11 | import { createServer as createHTTPSServer } from 'node:https' 12 | import { GlobalRequest, Request as LightweightRequest, getAbortController } from '../src/request' 13 | import { GlobalResponse, Response as LightweightResponse } from '../src/response' 14 | import { createAdaptorServer, serve } from '../src/server' 15 | import type { HttpBindings } from '../src/types' 16 | 17 | describe('Basic', () => { 18 | const app = new Hono() 19 | app.get('/', (c) => c.text('Hello! Node!')) 20 | app.get('/url', (c) => c.text(c.req.url)) 21 | 22 | app.get('/posts', (c) => { 23 | return c.text(`Page ${c.req.query('page')}`) 24 | }) 25 | app.get('/user-agent', (c) => { 26 | return c.text(c.req.header('user-agent') as string) 27 | }) 28 | app.post('/posts', (c) => { 29 | return c.redirect('/posts') 30 | }) 31 | app.delete('/posts/:id', (c) => { 32 | return c.text(`DELETE ${c.req.param('id')}`) 33 | }) 34 | // @ts-expect-error the response is string 35 | app.get('/invalid', () => { 36 | return '

HTML

' 37 | }) 38 | app.get('/ponyfill', () => { 39 | return new PonyfillResponse('Pony') 40 | }) 41 | 42 | app.on('trace', '/', (c) => { 43 | const headers = c.req.raw.headers // build new request object 44 | return c.text(`headers: ${JSON.stringify(headers)}`) 45 | }) 46 | 47 | const server = createAdaptorServer(app) 48 | 49 | it('Should return 200 response - GET /', async () => { 50 | const res = await request(server).get('/') 51 | expect(res.status).toBe(200) 52 | expect(res.headers['content-type']).toMatch('text/plain') 53 | expect(res.text).toBe('Hello! Node!') 54 | }) 55 | 56 | it('Should return 200 response - GET /url', async () => { 57 | const res = await request(server).get('/url').trustLocalhost() 58 | expect(res.status).toBe(200) 59 | expect(res.headers['content-type']).toMatch('text/plain') 60 | const url = new URL(res.text) 61 | expect(url.pathname).toBe('/url') 62 | expect(url.hostname).toBe('127.0.0.1') 63 | expect(url.protocol).toBe('http:') 64 | }) 65 | 66 | it('Should return 200 response - GET /posts?page=2', async () => { 67 | const res = await request(server).get('/posts?page=2') 68 | expect(res.status).toBe(200) 69 | expect(res.text).toBe('Page 2') 70 | }) 71 | 72 | it('Should return 200 response - GET /user-agent', async () => { 73 | const res = await request(server).get('/user-agent').set('user-agent', 'Hono') 74 | expect(res.status).toBe(200) 75 | expect(res.headers['content-type']).toMatch('text/plain') 76 | expect(res.text).toBe('Hono') 77 | }) 78 | 79 | it('Should return 302 response - POST /posts', async () => { 80 | const res = await request(server).post('/posts') 81 | expect(res.status).toBe(302) 82 | expect(res.headers['location']).toBe('/posts') 83 | }) 84 | 85 | it('Should return 201 response - DELETE /posts/123', async () => { 86 | const res = await request(server).delete('/posts/123') 87 | expect(res.status).toBe(200) 88 | expect(res.text).toBe('DELETE 123') 89 | }) 90 | 91 | it('Should return 500 response - GET /invalid', async () => { 92 | const res = await request(server).get('/invalid') 93 | expect(res.status).toBe(500) 94 | expect(res.headers['content-type']).toEqual('text/plain') 95 | }) 96 | 97 | it('Should return 200 response - GET /ponyfill', async () => { 98 | const res = await request(server).get('/ponyfill') 99 | expect(res.status).toBe(200) 100 | expect(res.headers['content-type']).toMatch('text/plain') 101 | expect(res.text).toBe('Pony') 102 | }) 103 | 104 | it('Should not raise error for TRACE method', async () => { 105 | const res = await request(server).trace('/') 106 | expect(res.text).toBe('headers: {}') 107 | }) 108 | }) 109 | 110 | describe('various response body types', () => { 111 | const app = new Hono() 112 | app.use('*', async (c, next) => { 113 | await next() 114 | 115 | // generate internal response object 116 | const status = c.res.status 117 | if (status > 999) { 118 | c.res = new Response('Internal Server Error', { status: 500 }) 119 | } 120 | }) 121 | app.get('/', () => { 122 | const response = new Response('Hello! Node!') 123 | return response 124 | }) 125 | app.get('/uint8array', () => { 126 | const response = new Response(new Uint8Array([1, 2, 3]), { 127 | headers: { 'content-type': 'application/octet-stream' }, 128 | }) 129 | return response 130 | }) 131 | app.get('/blob', () => { 132 | const response = new Response(new Blob([new Uint8Array([1, 2, 3])]), { 133 | headers: { 'content-type': 'application/octet-stream' }, 134 | }) 135 | return response 136 | }) 137 | let resolveReadableStreamPromise: () => void 138 | const readableStreamPromise = new Promise((resolve) => { 139 | resolveReadableStreamPromise = resolve 140 | }) 141 | app.get('/readable-stream', () => { 142 | const stream = new ReadableStream({ 143 | async start(controller) { 144 | await readableStreamPromise 145 | controller.enqueue('Hello!') 146 | controller.enqueue(' Node!') 147 | controller.close() 148 | }, 149 | }) 150 | return new Response(stream) 151 | }) 152 | app.get('/buffer', () => { 153 | const response = new Response(Buffer.from('Hello Hono!'), { 154 | headers: { 'content-type': 'text/plain' }, 155 | }) 156 | return response 157 | }) 158 | 159 | app.use('/etag/*', etag()) 160 | app.get('/etag/buffer', () => { 161 | const response = new Response(Buffer.from('Hello Hono!'), { 162 | headers: { 'content-type': 'text/plain' }, 163 | }) 164 | return response 165 | }) 166 | 167 | const server = createAdaptorServer(app) 168 | 169 | it('Should return 200 response - GET /', async () => { 170 | const res = await request(server).get('/') 171 | expect(res.status).toBe(200) 172 | expect(res.headers['content-type']).toMatch('text/plain') 173 | expect(res.headers['content-length']).toMatch('12') 174 | expect(res.text).toBe('Hello! Node!') 175 | }) 176 | 177 | it('Should return 200 response - GET /uint8array', async () => { 178 | const res = await request(server).get('/uint8array') 179 | expect(res.status).toBe(200) 180 | expect(res.headers['content-type']).toMatch('application/octet-stream') 181 | expect(res.headers['content-length']).toMatch('3') 182 | expect(res.body).toEqual(Buffer.from([1, 2, 3])) 183 | }) 184 | 185 | it('Should return 200 response - GET /blob', async () => { 186 | const res = await request(server).get('/blob') 187 | expect(res.status).toBe(200) 188 | expect(res.headers['content-type']).toMatch('application/octet-stream') 189 | expect(res.headers['content-length']).toMatch('3') 190 | expect(res.body).toEqual(Buffer.from([1, 2, 3])) 191 | }) 192 | 193 | it('Should return 200 response - GET /readable-stream', async () => { 194 | const expectedChunks = ['Hello!', ' Node!'] 195 | const resPromise = request(server) 196 | .get('/readable-stream') 197 | .parse((res, fn) => { 198 | // response header should be sent before sending data. 199 | expect(res.headers['transfer-encoding']).toBe('chunked') 200 | resolveReadableStreamPromise() 201 | 202 | res.on('data', (chunk) => { 203 | const str = chunk.toString() 204 | expect(str).toBe(expectedChunks.shift()) 205 | }) 206 | res.on('end', () => fn(null, '')) 207 | }) 208 | await new Promise((resolve) => setTimeout(resolve, 100)) 209 | const res = await resPromise 210 | expect(res.status).toBe(200) 211 | expect(res.headers['content-type']).toMatch('text/plain; charset=UTF-8') 212 | expect(res.headers['content-length']).toBeUndefined() 213 | expect(expectedChunks.length).toBe(0) // all chunks are received 214 | }) 215 | 216 | it('Should return 200 response - GET /buffer', async () => { 217 | const res = await request(server).get('/buffer') 218 | expect(res.status).toBe(200) 219 | expect(res.headers['content-type']).toMatch('text/plain') 220 | expect(res.headers['content-length']).toMatch('11') 221 | expect(res.text).toBe('Hello Hono!') 222 | }) 223 | 224 | it('Should return 200 response - GET /etag/buffer', async () => { 225 | const res = await request(server).get('/etag/buffer') 226 | expect(res.status).toBe(200) 227 | expect(res.headers['content-type']).toMatch('text/plain') 228 | expect(res.headers['etag']).toMatch('"7e03b9b8ed6156932691d111c81c34c3c02912f9"') 229 | expect(res.headers['content-length']).toMatch('11') 230 | expect(res.text).toBe('Hello Hono!') 231 | }) 232 | }) 233 | 234 | describe('Routing', () => { 235 | describe('Nested Route', () => { 236 | const book = new Hono() 237 | book.get('/', (c) => c.text('get /book')) 238 | book.get('/:id', (c) => { 239 | return c.text('get /book/' + c.req.param('id')) 240 | }) 241 | book.post('/', (c) => c.text('post /book')) 242 | 243 | const app = new Hono() 244 | app.route('/book', book) 245 | 246 | const server = createAdaptorServer(app) 247 | 248 | it('Should return responses from `/book/*`', async () => { 249 | let res = await request(server).get('/book') 250 | expect(res.status).toBe(200) 251 | expect(res.text).toBe('get /book') 252 | 253 | res = await request(server).get('/book/123') 254 | expect(res.status).toBe(200) 255 | expect(res.text).toBe('get /book/123') 256 | 257 | res = await request(server).post('/book') 258 | expect(res.status).toBe(200) 259 | expect(res.text).toBe('post /book') 260 | }) 261 | }) 262 | 263 | describe('Chained route', () => { 264 | const app = new Hono() 265 | 266 | app 267 | .get('/chained/:abc', (c) => { 268 | const abc = c.req.param('abc') 269 | return c.text(`GET for ${abc}`) 270 | }) 271 | .post((c) => { 272 | const abc = c.req.param('abc') 273 | return c.text(`POST for ${abc}`) 274 | }) 275 | const server = createAdaptorServer(app) 276 | 277 | it('Should return responses from `/chained/*`', async () => { 278 | let res = await request(server).get('/chained/abc') 279 | expect(res.status).toBe(200) 280 | expect(res.text).toBe('GET for abc') 281 | 282 | res = await request(server).post('/chained/abc') 283 | expect(res.status).toBe(200) 284 | expect(res.text).toBe('POST for abc') 285 | 286 | res = await request(server).put('/chained/abc') 287 | expect(res.status).toBe(404) 288 | }) 289 | }) 290 | }) 291 | 292 | describe('Request body', () => { 293 | const app = new Hono() 294 | app.post('/json', async (c) => { 295 | const data = await c.req.json() 296 | return c.json(data) 297 | }) 298 | app.post('/form', async (c) => { 299 | const data = await c.req.parseBody() 300 | return c.json(data) 301 | }) 302 | const server = createAdaptorServer(app) 303 | 304 | it('Should handle JSON body', async () => { 305 | const res = await request(server) 306 | .post('/json') 307 | .set('Content-Type', 'application/json') 308 | .send({ foo: 'bar' }) 309 | expect(res.status).toBe(200) 310 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 311 | }) 312 | 313 | it('Should handle form body', async () => { 314 | // to be `application/x-www-form-urlencoded` 315 | const res = await request(server).post('/form').type('form').send({ foo: 'bar' }) 316 | expect(res.status).toBe(200) 317 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 318 | }) 319 | }) 320 | 321 | describe('Response body', () => { 322 | describe('Cached Response', () => { 323 | const app = new Hono() 324 | app.get('/json', (c) => { 325 | return c.json({ foo: 'bar' }) 326 | }) 327 | app.get('/json-async', async (c) => { 328 | return c.json({ foo: 'async' }) 329 | }) 330 | app.get('/html', (c) => { 331 | return c.html('

Hello!

') 332 | }) 333 | app.get('/html-async', async (c) => { 334 | return c.html('

Hello!

') 335 | }) 336 | const server = createAdaptorServer(app) 337 | 338 | it('Should return JSON body', async () => { 339 | const res = await request(server).get('/json') 340 | expect(res.status).toBe(200) 341 | expect(res.headers['content-type']).toMatch('application/json') 342 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 343 | }) 344 | 345 | it('Should return JSON body from /json-async', async () => { 346 | const res = await request(server).get('/json-async') 347 | expect(res.status).toBe(200) 348 | expect(res.headers['content-type']).toMatch('application/json') 349 | expect(JSON.parse(res.text)).toEqual({ foo: 'async' }) 350 | }) 351 | 352 | it('Should return HTML', async () => { 353 | const res = await request(server).get('/html') 354 | expect(res.status).toBe(200) 355 | expect(res.headers['content-type']).toMatch('text/html') 356 | expect(res.text).toBe('

Hello!

') 357 | }) 358 | 359 | it('Should return HTML from /html-async', async () => { 360 | const res = await request(server).get('/html-async') 361 | expect(res.status).toBe(200) 362 | expect(res.headers['content-type']).toMatch('text/html') 363 | expect(res.text).toBe('

Hello!

') 364 | }) 365 | }) 366 | 367 | describe('Fallback to global.Response', () => { 368 | const app = new Hono() 369 | 370 | app.get('/json-blob', async () => { 371 | return new Response(new Blob([JSON.stringify({ foo: 'blob' })]), { 372 | headers: { 'content-type': 'application/json' }, 373 | }) 374 | }) 375 | 376 | app.get('/json-buffer', async () => { 377 | return new Response(new TextEncoder().encode(JSON.stringify({ foo: 'buffer' })).buffer, { 378 | headers: { 'content-type': 'application/json' }, 379 | }) 380 | }) 381 | 382 | const server = createAdaptorServer(app) 383 | 384 | it('Should return JSON body from /json-blob', async () => { 385 | const res = await request(server).get('/json-blob') 386 | expect(res.status).toBe(200) 387 | expect(res.headers['content-type']).toMatch('application/json') 388 | expect(JSON.parse(res.text)).toEqual({ foo: 'blob' }) 389 | }) 390 | 391 | it('Should return JSON body from /json-buffer', async () => { 392 | const res = await request(server).get('/json-buffer') 393 | expect(res.status).toBe(200) 394 | expect(res.headers['content-type']).toMatch('application/json') 395 | expect(JSON.parse(res.text)).toEqual({ foo: 'buffer' }) 396 | }) 397 | }) 398 | }) 399 | 400 | describe('Middleware', () => { 401 | const app = new Hono<{ Variables: { foo: string } }>() 402 | app.use('*', poweredBy()) 403 | app.use('*', async (c, next) => { 404 | c.set('foo', 'bar') 405 | await next() 406 | c.header('foo', c.get('foo')) 407 | }) 408 | app.get('/', (c) => c.text('Hello! Middleware!')) 409 | const server = createAdaptorServer(app) 410 | 411 | it('Should have correct header values', async () => { 412 | const res = await request(server).get('/') 413 | expect(res.status).toBe(200) 414 | expect(res.headers['x-powered-by']).toBe('Hono') 415 | expect(res.headers['foo']).toBe('bar') 416 | }) 417 | }) 418 | 419 | describe('Error handling', () => { 420 | const app = new Hono() 421 | app.notFound((c) => { 422 | return c.text('Custom NotFound', 404) 423 | }) 424 | app.onError((_, c) => { 425 | return c.text('Custom Error!', 500) 426 | }) 427 | app.get('/error', () => { 428 | throw new Error() 429 | }) 430 | const server = createAdaptorServer(app) 431 | 432 | it('Should return 404 response', async () => { 433 | const res = await request(server).get('/') 434 | expect(res.status).toBe(404) 435 | expect(res.text).toBe('Custom NotFound') 436 | }) 437 | 438 | it('Should return 500 response', async () => { 439 | const res = await request(server).get('/error') 440 | expect(res.status).toBe(500) 441 | expect(res.text).toBe('Custom Error!') 442 | }) 443 | 444 | it('Should return 404 response - PURGE method', async () => { 445 | const res = await request(server).purge('/') 446 | expect(res.status).toBe(404) 447 | }) 448 | }) 449 | 450 | describe('Basic Auth Middleware', () => { 451 | const app = new Hono() 452 | const username = 'hono-user-a' 453 | const password = 'hono-password-a' 454 | const unicodePassword = '炎' 455 | 456 | app.use( 457 | '/auth/*', 458 | basicAuth({ 459 | username, 460 | password, 461 | }) 462 | ) 463 | 464 | app.use( 465 | '/auth-unicode/*', 466 | basicAuth({ 467 | username: username, 468 | password: unicodePassword, 469 | }) 470 | ) 471 | 472 | app.get('/auth/*', (c) => c.text('auth')) 473 | app.get('/auth-unicode/*', (c) => c.text('auth')) 474 | 475 | const server = createAdaptorServer(app) 476 | 477 | it('Should not authorized', async () => { 478 | const res = await request(server).get('/auth/a') 479 | expect(res.status).toBe(401) 480 | expect(res.text).toBe('Unauthorized') 481 | }) 482 | 483 | it('Should authorized', async () => { 484 | const credential = Buffer.from(username + ':' + password).toString('base64') 485 | const res = await request(server).get('/auth/a').set('Authorization', `Basic ${credential}`) 486 | expect(res.status).toBe(200) 487 | expect(res.text).toBe('auth') 488 | }) 489 | 490 | it('Should authorize Unicode', async () => { 491 | const credential = Buffer.from(username + ':' + unicodePassword).toString('base64') 492 | const res = await request(server) 493 | .get('/auth-unicode/a') 494 | .set('Authorization', `Basic ${credential}`) 495 | expect(res.status).toBe(200) 496 | expect(res.text).toBe('auth') 497 | }) 498 | }) 499 | 500 | describe('Stream and non-stream response', () => { 501 | const app = new Hono() 502 | 503 | app.get('/json', (c) => c.json({ foo: 'bar' })) 504 | app.get('/text', (c) => c.text('Hello!')) 505 | app.get('/json-stream', (c) => { 506 | c.header('x-accel-buffering', 'no') 507 | c.header('content-type', 'application/json') 508 | return stream(c, async (stream) => { 509 | stream.write(JSON.stringify({ foo: 'bar' })) 510 | }) 511 | }) 512 | app.get('/stream', (c) => { 513 | const stream = new ReadableStream({ 514 | async start(controller) { 515 | controller.enqueue('data: Hello!\n\n') 516 | await new Promise((resolve) => setTimeout(resolve, 100)) 517 | controller.enqueue('data: end\n\n') 518 | controller.close() 519 | }, 520 | }) 521 | 522 | c.header('Content-Type', 'text/event-stream; charset=utf-8') 523 | return c.body(stream) 524 | }) 525 | 526 | app.get('/error-stream', (c) => { 527 | const stream = new ReadableStream({ 528 | async start(controller) { 529 | controller.enqueue('data: Hello!\n\n') 530 | await new Promise((resolve) => setTimeout(resolve, 100)) 531 | controller.enqueue('data: end\n\n') 532 | controller.error(new Error('test')) 533 | }, 534 | }) 535 | 536 | c.header('Content-Type', 'text/event-stream; charset=utf-8') 537 | return c.body(stream) 538 | }) 539 | 540 | const server = createAdaptorServer(app) 541 | 542 | it('Should return JSON body', async () => { 543 | const res = await request(server).get('/json') 544 | expect(res.status).toBe(200) 545 | expect(res.headers['content-length']).toMatch('13') 546 | expect(res.headers['content-type']).toMatch('application/json') 547 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 548 | }) 549 | 550 | it('Should return text body', async () => { 551 | const res = await request(server).get('/text') 552 | expect(res.status).toBe(200) 553 | expect(res.headers['content-length']).toMatch('6') 554 | expect(res.headers['content-type']).toMatch('text/plain') 555 | expect(res.text).toBe('Hello!') 556 | }) 557 | 558 | it('Should return JSON body - stream', async () => { 559 | const res = await request(server).get('/json-stream') 560 | expect(res.status).toBe(200) 561 | expect(res.headers['content-length']).toBeUndefined() 562 | expect(res.headers['content-type']).toMatch('application/json') 563 | expect(res.headers['transfer-encoding']).toMatch('chunked') 564 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 565 | }) 566 | 567 | it('Should return text body - stream', async () => { 568 | const res = await request(server) 569 | .get('/stream') 570 | .parse((res, fn) => { 571 | const chunks: string[] = ['data: Hello!\n\n', 'data: end\n\n'] 572 | let index = 0 573 | res.on('data', (chunk) => { 574 | const str = chunk.toString() 575 | expect(str).toBe(chunks[index++]) 576 | }) 577 | res.on('end', () => fn(null, '')) 578 | }) 579 | expect(res.status).toBe(200) 580 | expect(res.headers['content-length']).toBeUndefined() 581 | expect(res.headers['content-type']).toMatch('text/event-stream') 582 | expect(res.headers['transfer-encoding']).toMatch('chunked') 583 | }) 584 | 585 | it('Should return error - stream without app crashing', async () => { 586 | const result = request(server).get('/error-stream') 587 | await expect(result).rejects.toThrow('aborted') 588 | }) 589 | }) 590 | 591 | describe('SSL', () => { 592 | const app = new Hono() 593 | app.get('/', (c) => c.text('Hello! Node!')) 594 | app.get('/url', (c) => c.text(c.req.url)) 595 | 596 | const server = createAdaptorServer({ 597 | fetch: app.fetch, 598 | createServer: createHTTPSServer, 599 | serverOptions: { 600 | key: fs.readFileSync('test/fixtures/keys/agent1-key.pem'), 601 | cert: fs.readFileSync('test/fixtures/keys/agent1-cert.pem'), 602 | }, 603 | }) 604 | 605 | it('Should return 200 response - GET /', async () => { 606 | const res = await request(server).get('/').trustLocalhost() 607 | expect(res.status).toBe(200) 608 | expect(res.headers['content-type']).toMatch('text/plain') 609 | expect(res.text).toBe('Hello! Node!') 610 | }) 611 | 612 | it('Should return 200 response - GET /url', async () => { 613 | const res = await request(server).get('/url').trustLocalhost() 614 | expect(res.status).toBe(200) 615 | expect(res.headers['content-type']).toMatch('text/plain') 616 | const url = new URL(res.text) 617 | expect(url.pathname).toBe('/url') 618 | expect(url.hostname).toBe('127.0.0.1') 619 | expect(url.protocol).toBe('https:') 620 | }) 621 | }) 622 | 623 | describe('HTTP2', () => { 624 | const app = new Hono() 625 | app.get('/', (c) => c.text('Hello! Node!')) 626 | app.get('/headers', (c) => { 627 | // call newRequestFromIncoming 628 | c.req.header('Accept') 629 | return c.text('Hello! Node!') 630 | }) 631 | app.get('/url', (c) => c.text(c.req.url)) 632 | 633 | const server = createAdaptorServer({ 634 | fetch: app.fetch, 635 | createServer: createHttp2Server, 636 | }) 637 | 638 | it('Should return 200 response - GET /', async () => { 639 | const res = await request(server, { http2: true }).get('/').trustLocalhost() 640 | expect(res.status).toBe(200) 641 | expect(res.headers['content-type']).toMatch('text/plain') 642 | expect(res.text).toBe('Hello! Node!') 643 | }) 644 | 645 | it('Should return 200 response - GET /headers', async () => { 646 | const res = await request(server, { http2: true }).get('/headers').trustLocalhost() 647 | expect(res.status).toBe(200) 648 | expect(res.headers['content-type']).toMatch('text/plain') 649 | expect(res.text).toBe('Hello! Node!') 650 | }) 651 | 652 | // Use :authority as the host for the url. 653 | it('Should return 200 response - GET /url', async () => { 654 | const res = await request(server, { http2: true }) 655 | .get('/url') 656 | .set(':scheme', 'https') 657 | .set(':authority', '127.0.0.1') 658 | .trustLocalhost() 659 | expect(res.status).toBe(200) 660 | expect(res.headers['content-type']).toMatch('text/plain') 661 | const url = new URL(res.text) 662 | expect(url.pathname).toBe('/url') 663 | expect(url.hostname).toBe('127.0.0.1') 664 | expect(url.protocol).toBe('https:') 665 | }) 666 | }) 667 | 668 | describe('Hono compression default gzip', () => { 669 | const app = new Hono() 670 | app.use('*', compress()) 671 | 672 | app.notFound((c) => { 673 | return c.text('Custom NotFound', 404) 674 | }) 675 | 676 | app.onError((_, c) => { 677 | return c.text('Custom Error!', 500) 678 | }) 679 | 680 | app.get('/error', () => { 681 | throw new Error() 682 | }) 683 | 684 | app.get('/one', async (c) => { 685 | let body = 'one' 686 | 687 | for (let index = 0; index < 1000 * 1000; index++) { 688 | body += ' one' 689 | } 690 | return c.text(body) 691 | }) 692 | 693 | it('should return 200 response - GET /one', async () => { 694 | const server = createAdaptorServer(app) 695 | const res = await request(server).get('/one') 696 | expect(res.status).toBe(200) 697 | expect(res.headers['content-type']).toMatch('text/plain') 698 | expect(res.headers['content-encoding']).toMatch('gzip') 699 | }) 700 | 701 | it('should return 404 Custom NotFound', async () => { 702 | const server = createAdaptorServer(app) 703 | const res = await request(server).get('/err') 704 | expect(res.status).toBe(404) 705 | expect(res.text).toEqual('Custom NotFound') 706 | expect(res.headers['content-type']).toEqual('text/plain; charset=UTF-8') 707 | expect(res.headers['content-encoding']).toMatch('gzip') 708 | }) 709 | 710 | it('should return 500 Custom Error!', async () => { 711 | const server = createAdaptorServer(app) 712 | const res = await request(server).get('/error') 713 | expect(res.status).toBe(500) 714 | expect(res.text).toEqual('Custom Error!') 715 | expect(res.headers['content-type']).toEqual('text/plain; charset=UTF-8') 716 | expect(res.headers['content-encoding']).toMatch('gzip') 717 | }) 718 | }) 719 | 720 | describe('Hono compression deflate', () => { 721 | const app = new Hono() 722 | app.use('*', compress({ encoding: 'deflate' })) 723 | 724 | app.notFound((c) => { 725 | return c.text('Custom NotFound', 404) 726 | }) 727 | 728 | app.onError((_, c) => { 729 | return c.text('Custom Error!', 500) 730 | }) 731 | 732 | app.get('/error', () => { 733 | throw new Error() 734 | }) 735 | 736 | app.get('/one', async (c) => { 737 | let body = 'one' 738 | 739 | for (let index = 0; index < 1000 * 1000; index++) { 740 | body += ' one' 741 | } 742 | return c.text(body) 743 | }) 744 | 745 | it('should return 200 response - GET /one', async () => { 746 | const server = createAdaptorServer(app) 747 | const res = await request(server).get('/one') 748 | expect(res.status).toBe(200) 749 | expect(res.headers['content-type']).toMatch('text/plain') 750 | expect(res.headers['content-encoding']).toMatch('deflate') 751 | }) 752 | 753 | it('should return 404 Custom NotFound', async () => { 754 | const server = createAdaptorServer(app) 755 | const res = await request(server).get('/err') 756 | expect(res.status).toBe(404) 757 | expect(res.text).toEqual('Custom NotFound') 758 | expect(res.headers['content-type']).toEqual('text/plain; charset=UTF-8') 759 | expect(res.headers['content-encoding']).toMatch('deflate') 760 | }) 761 | 762 | it('should return 500 Custom Error!', async () => { 763 | const server = createAdaptorServer(app) 764 | const res = await request(server).get('/error') 765 | expect(res.status).toBe(500) 766 | expect(res.text).toEqual('Custom Error!') 767 | expect(res.headers['content-type']).toEqual('text/plain; charset=UTF-8') 768 | expect(res.headers['content-encoding']).toMatch('deflate') 769 | }) 770 | }) 771 | 772 | describe('set child response to c.res', () => { 773 | const app = new Hono() 774 | app.use('*', async (c, next) => { 775 | await next() 776 | c.res = new Response('', c.res) 777 | c.res.headers // If this is set, test fails 778 | }) 779 | 780 | app.get('/json', async (c) => { 781 | return c.json({}) 782 | }) 783 | 784 | it('Should return 200 response - GET /json', async () => { 785 | const server = createAdaptorServer(app) 786 | const res = await request(server).get('/json') 787 | expect(res.status).toBe(200) 788 | expect(res.headers['content-type']).toMatch('application/json') 789 | }) 790 | }) 791 | 792 | describe('forwarding IncomingMessage and ServerResponse in env', () => { 793 | const app = new Hono<{ Bindings: HttpBindings }>() 794 | app.get('/', (c) => 795 | c.json({ 796 | incoming: c.env.incoming.constructor.name, 797 | url: c.env.incoming.url, 798 | outgoing: c.env.outgoing.constructor.name, 799 | status: c.env.outgoing.statusCode, 800 | }) 801 | ) 802 | 803 | it('Should add `incoming` and `outgoing` to env', async () => { 804 | const server = createAdaptorServer(app) 805 | const res = await request(server).get('/') 806 | 807 | expect(res.status).toBe(200) 808 | expect(res.body.incoming).toBe('IncomingMessage') 809 | expect(res.body.url).toBe('/') 810 | expect(res.body.outgoing).toBe('ServerResponse') 811 | expect(res.body.status).toBe(200) 812 | }) 813 | }) 814 | 815 | describe('overrideGlobalObjects', () => { 816 | const app = new Hono() 817 | 818 | beforeEach(() => { 819 | Object.defineProperty(global, 'Request', { 820 | value: GlobalRequest, 821 | writable: true, 822 | }) 823 | Object.defineProperty(global, 'Response', { 824 | value: GlobalResponse, 825 | writable: true, 826 | }) 827 | }) 828 | 829 | describe('default', () => { 830 | it('Should be overridden', () => { 831 | createAdaptorServer(app) 832 | expect(global.Request).toBe(LightweightRequest) 833 | expect(global.Response).toBe(LightweightResponse) 834 | }) 835 | }) 836 | 837 | describe('overrideGlobalObjects: true', () => { 838 | it('Should be overridden', () => { 839 | createAdaptorServer({ overrideGlobalObjects: true, fetch: app.fetch }) 840 | expect(global.Request).toBe(LightweightRequest) 841 | expect(global.Response).toBe(LightweightResponse) 842 | }) 843 | }) 844 | 845 | describe('overrideGlobalObjects: false', () => { 846 | it('Should not be overridden', () => { 847 | createAdaptorServer({ overrideGlobalObjects: false, fetch: app.fetch }) 848 | expect(global.Request).toBe(GlobalRequest) 849 | expect(global.Response).toBe(GlobalResponse) 850 | }) 851 | }) 852 | }) 853 | 854 | describe('Memory leak test', () => { 855 | let counter = 0 856 | const registry = new FinalizationRegistry(() => { 857 | counter-- 858 | }) 859 | const app = new Hono() 860 | const server = createAdaptorServer(app) 861 | 862 | let onAbort: () => void 863 | let reqReadyResolve: () => void 864 | let reqReadyPromise: Promise 865 | 866 | app.use(async (c, next) => { 867 | counter++ 868 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 869 | registry.register((c.req.raw as any)[getAbortController](), 'abortController') 870 | await next() 871 | }) 872 | app.get('/', (c) => c.text('Hello! Node!')) 873 | app.post('/', async (c) => c.json(await c.req.json())) 874 | app.get('/abort', async (c) => { 875 | c.req.raw.signal.addEventListener('abort', () => onAbort()) 876 | reqReadyResolve?.() 877 | await new Promise(() => {}) // never resolve 878 | }) 879 | 880 | beforeEach(() => { 881 | counter = 0 882 | reqReadyPromise = new Promise((r) => { 883 | reqReadyResolve = r 884 | }) 885 | }) 886 | 887 | afterAll(() => { 888 | server.close() 889 | }) 890 | 891 | it('Should not have memory leak - GET /', async () => { 892 | await request(server).get('/') 893 | global.gc?.() 894 | await new Promise((resolve) => setTimeout(resolve, 10)) 895 | expect(counter).toBe(0) 896 | }) 897 | 898 | it('Should not have memory leak - POST /', async () => { 899 | await request(server).post('/').set('Content-Type', 'application/json').send({ foo: 'bar' }) 900 | global.gc?.() 901 | await new Promise((resolve) => setTimeout(resolve, 10)) 902 | expect(counter).toBe(0) 903 | }) 904 | 905 | it('Should not have memory leak - GET /abort', async () => { 906 | const abortedPromise = new Promise((resolve) => { 907 | onAbort = resolve 908 | }) 909 | 910 | const req = request(server) 911 | .get('/abort') 912 | .end(() => {}) 913 | await reqReadyPromise 914 | req.abort() 915 | await abortedPromise 916 | await new Promise((resolve) => setTimeout(resolve, 10)) 917 | 918 | global.gc?.() 919 | await new Promise((resolve) => setTimeout(resolve, 10)) 920 | expect(counter).toBe(0) 921 | }) 922 | }) 923 | 924 | describe('serve', () => { 925 | const app = new Hono() 926 | app.get('/', (c) => c.newResponse(null, 200)) 927 | serve(app) 928 | 929 | it('should serve on ipv4', async () => { 930 | const response = await fetch('http://localhost:3000') 931 | expect(response.status).toBe(200) 932 | }) 933 | 934 | it('should serve on ipv6', async () => { 935 | const response = await fetch('http://[::1]:3000') 936 | expect(response.status).toBe(200) 937 | }) 938 | }) 939 | -------------------------------------------------------------------------------- /test/setup.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(global, 'fetch', { 2 | value: global.fetch, 3 | writable: true, 4 | }) 5 | Object.defineProperty(global, 'Response', { 6 | value: global.Response, 7 | writable: true, 8 | }) 9 | Object.defineProperty(global, 'Request', { 10 | value: global.Request, 11 | writable: true, 12 | }) 13 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { buildOutgoingHttpHeaders } from '../src/utils' 2 | 3 | describe('buildOutgoingHttpHeaders', () => { 4 | it('original content-type is preserved', () => { 5 | const headers = new Headers({ 6 | a: 'b', 7 | 'content-type': 'text/html; charset=UTF-8', 8 | }) 9 | const result = buildOutgoingHttpHeaders(headers) 10 | expect(result).toEqual({ 11 | a: 'b', 12 | 'content-type': 'text/html; charset=UTF-8', 13 | }) 14 | }) 15 | 16 | it('multiple set-cookie', () => { 17 | const headers = new Headers() 18 | headers.append('set-cookie', 'a') 19 | headers.append('set-cookie', 'b') 20 | const result = buildOutgoingHttpHeaders(headers) 21 | expect(result).toEqual({ 22 | 'set-cookie': ['a', 'b'], 23 | 'content-type': 'text/plain; charset=UTF-8', 24 | }) 25 | }) 26 | 27 | it('Headers', () => { 28 | const headers = new Headers({ 29 | a: 'b', 30 | }) 31 | const result = buildOutgoingHttpHeaders(headers) 32 | expect(result).toEqual({ 33 | a: 'b', 34 | 'content-type': 'text/plain; charset=UTF-8', 35 | }) 36 | }) 37 | 38 | it('Record', () => { 39 | const headers = { 40 | a: 'b', 41 | 'Set-Cookie': 'c', // case-insensitive 42 | } 43 | const result = buildOutgoingHttpHeaders(headers) 44 | expect(result).toEqual({ 45 | a: 'b', 46 | 'set-cookie': ['c'], 47 | 'content-type': 'text/plain; charset=UTF-8', 48 | }) 49 | }) 50 | 51 | it('Record[]', () => { 52 | const headers: HeadersInit = [['a', 'b']] 53 | const result = buildOutgoingHttpHeaders(headers) 54 | expect(result).toEqual({ 55 | a: 'b', 56 | 'content-type': 'text/plain; charset=UTF-8', 57 | }) 58 | }) 59 | 60 | it('null', () => { 61 | const result = buildOutgoingHttpHeaders(null) 62 | expect(result).toEqual({ 63 | 'content-type': 'text/plain; charset=UTF-8', 64 | }) 65 | }) 66 | 67 | it('undefined', () => { 68 | const result = buildOutgoingHttpHeaders(undefined) 69 | expect(result).toEqual({ 70 | 'content-type': 'text/plain; charset=UTF-8', 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /test/utils/response.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import request from 'supertest' 3 | import type { HttpBindings } from '../../src/' 4 | import { createAdaptorServer } from '../../src/server' 5 | import { RESPONSE_ALREADY_SENT } from '../../src/utils/response' 6 | 7 | describe('RESPONSE_ALREADY_SENT', () => { 8 | const app = new Hono<{ Bindings: HttpBindings }>() 9 | app.get('/', (c) => { 10 | const { outgoing } = c.env 11 | outgoing.writeHead(200, { 'Content-Type': 'text/plain' }) 12 | outgoing.end('Hono!') 13 | return RESPONSE_ALREADY_SENT 14 | }) 15 | const server = createAdaptorServer(app) 16 | 17 | it('Should return 200 response - GET /', async () => { 18 | const res = await request(server).get('/') 19 | expect(res.status).toBe(200) 20 | expect(res.headers['content-type']).toMatch('text/plain') 21 | expect(res.text).toBe('Hono!') 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /test/vercel.test.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from 'hono' 2 | import { showRoutes } from 'hono/dev' 3 | import request from 'supertest' 4 | import { handle } from '../src/vercel' 5 | 6 | describe('Basic', () => { 7 | const app = new Hono().basePath('/api') 8 | app.get('/', (c) => c.text('Hello! Node!')) 9 | 10 | app.get('/posts', (c) => { 11 | return c.text(`Page ${c.req.query('page')}`) 12 | }) 13 | app.post('/posts', (c) => { 14 | return c.redirect('/posts') 15 | }) 16 | app.delete('/posts/:id', (c) => { 17 | return c.text(`DELETE ${c.req.param('id')}`) 18 | }) 19 | 20 | const server = handle(app) 21 | 22 | it('Should return 200 response - GET /api', async () => { 23 | const res = await request(server).get('/api') 24 | expect(res.status).toBe(200) 25 | expect(res.headers['content-type']).toMatch(/text\/plain/) 26 | expect(res.text).toBe('Hello! Node!') 27 | }) 28 | 29 | it('Should return 200 response - GET /api/posts?page=2', async () => { 30 | const res = await request(server).get('/api/posts?page=2') 31 | expect(res.status).toBe(200) 32 | expect(res.text).toBe('Page 2') 33 | }) 34 | 35 | it('Should return 302 response - POST /api/posts', async () => { 36 | const res = await request(server).post('/api/posts') 37 | expect(res.status).toBe(302) 38 | expect(res.headers['location']).toBe('/posts') 39 | }) 40 | 41 | it('Should return 201 response - DELETE /api/posts/123', async () => { 42 | const res = await request(server).delete('/api/posts/123') 43 | expect(res.status).toBe(200) 44 | expect(res.text).toBe('DELETE 123') 45 | }) 46 | }) 47 | 48 | describe('Routing', () => { 49 | describe('Nested Route', () => { 50 | const book = new Hono().basePath('/api') 51 | book.get('/', (c) => c.text('get /api')) 52 | book.get('/:id', (c) => { 53 | return c.text('get /api/' + c.req.param('id')) 54 | }) 55 | book.post('/', (c) => c.text('post /api')) 56 | 57 | const app = new Hono() 58 | app.route('/v2', book) 59 | 60 | showRoutes(app) 61 | 62 | const server = handle(app) 63 | 64 | it('Should return responses from `/v2/api/*`', async () => { 65 | let res = await request(server).get('/v2/api') 66 | expect(res.status).toBe(200) 67 | expect(res.text).toBe('get /api') 68 | 69 | res = await request(server).get('/v2/api/123') 70 | expect(res.status).toBe(200) 71 | expect(res.text).toBe('get /api/123') 72 | 73 | res = await request(server).post('/v2/api') 74 | expect(res.status).toBe(200) 75 | expect(res.text).toBe('post /api') 76 | }) 77 | }) 78 | }) 79 | 80 | describe('Request body', () => { 81 | const app = new Hono().basePath('/api') 82 | app.post('/json', async (c) => { 83 | const data = await c.req.json() 84 | return c.json(data) 85 | }) 86 | app.post('/form', async (c) => { 87 | const data = await c.req.parseBody() 88 | return c.json(data) 89 | }) 90 | const server = handle(app) 91 | 92 | it('Should handle JSON body', async () => { 93 | const res = await request(server) 94 | .post('/api/json') 95 | .set('Content-Type', 'application/json') 96 | .send({ foo: 'bar' }) 97 | expect(res.status).toBe(200) 98 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 99 | }) 100 | 101 | it('Should handle form body', async () => { 102 | // to be `application/x-www-form-urlencoded` 103 | const res = await request(server).post('/api/form').type('form').send({ foo: 'bar' }) 104 | expect(res.status).toBe(200) 105 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 106 | }) 107 | }) 108 | 109 | describe('Response body', () => { 110 | const app = new Hono().basePath('/api') 111 | app.get('/json', (c) => { 112 | return c.json({ foo: 'bar' }) 113 | }) 114 | app.get('/html', (c) => { 115 | return c.html('

Hello!

') 116 | }) 117 | const server = handle(app) 118 | 119 | it('Should return JSON body', async () => { 120 | const res = await request(server).get('/api/json') 121 | expect(res.status).toBe(200) 122 | expect(res.headers['content-type']).toMatch(/application\/json/) 123 | expect(JSON.parse(res.text)).toEqual({ foo: 'bar' }) 124 | }) 125 | 126 | it('Should return HTML', async () => { 127 | const res = await request(server).get('/api/html') 128 | expect(res.status).toBe(200) 129 | expect(res.headers['content-type']).toMatch(/text\/html/) 130 | expect(res.text).toBe('

Hello!

') 131 | }) 132 | }) 133 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "moduleResolution": "Node", 7 | "esModuleInterop": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "strict": true, 10 | "noUnusedLocals": true, 11 | "noUnusedParameters": true, 12 | "types": [ 13 | "jest", 14 | "node", 15 | ], 16 | "rootDir": ".", 17 | "outDir": "./dist", 18 | }, 19 | "include": [ 20 | "src/**/*.ts", 21 | "test/**/*.ts" 22 | ], 23 | } -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup' 2 | 3 | export default defineConfig({ 4 | entry: ['./src/**/*.ts'], 5 | format: ['esm', 'cjs'], 6 | dts: true, 7 | splitting: false, 8 | sourcemap: false, 9 | clean: true, 10 | }) 11 | --------------------------------------------------------------------------------