Repository: honojs/hono Branch: main Commit: fe689eceb783 Files: 479 Total size: 2.3 MB Directory structure: gitextract_1kthqqhw/ ├── .devcontainer/ │ ├── Dockerfile │ ├── devcontainer.json │ └── docker-compose.yml ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── 1-bug-report.yml │ │ ├── 2-feature-request.yml │ │ └── config.yml │ ├── actions/ │ │ └── perf-measures/ │ │ └── action.yml │ ├── pull_request_template.md │ └── workflows/ │ ├── autofix.yml │ ├── ci.yml │ ├── cr.yml │ ├── no-response.yml │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── .prettierrc ├── .tool-versions ├── .vitest.config/ │ └── setup-vitest.ts ├── .vscode/ │ ├── extensions.json │ └── settings.json ├── LICENSE ├── README.md ├── benchmarks/ │ ├── deno/ │ │ ├── .gitignore │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── fast.ts │ │ ├── faster.ts │ │ ├── hono.ts │ │ ├── magalo.ts │ │ ├── oak.ts │ │ └── opine.ts │ ├── handle-event/ │ │ ├── index.js │ │ └── package.json │ ├── http-server/ │ │ ├── .gitignore │ │ ├── README.md │ │ └── benchmark.ts │ ├── jsx/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── benchmark.ts │ │ │ ├── hono.ts │ │ │ ├── nano.ts │ │ │ ├── page-react.tsx │ │ │ ├── page.tsx │ │ │ ├── preact.ts │ │ │ ├── react-jsx/ │ │ │ │ ├── benchmark.ts │ │ │ │ ├── hono.ts │ │ │ │ ├── nano.ts │ │ │ │ ├── page-hono.tsx │ │ │ │ ├── page-nano.tsx │ │ │ │ ├── page-preact.tsx │ │ │ │ ├── page-react.tsx │ │ │ │ ├── preact.ts │ │ │ │ ├── react.ts │ │ │ │ └── tsconfig.json │ │ │ └── react.ts │ │ └── tsconfig.json │ ├── query-param/ │ │ ├── bun.lockb │ │ ├── package.json │ │ └── src/ │ │ ├── bench.mts │ │ ├── fast-querystring.mts │ │ ├── hono.mts │ │ └── qs.mts │ ├── routers/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── bench-includes-init.mts │ │ │ ├── bench.mts │ │ │ ├── express.mts │ │ │ ├── find-my-way.mts │ │ │ ├── hono.mts │ │ │ ├── koa-router.mts │ │ │ ├── koa-tree-router.mts │ │ │ ├── medley-router.mts │ │ │ ├── memoirist.mts │ │ │ ├── radix3.mts │ │ │ ├── rou3.mts │ │ │ ├── tool.mts │ │ │ └── trek-router.mts │ │ └── tsconfig.json │ ├── routers-deno/ │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── README.md │ │ ├── deno.json │ │ └── src/ │ │ ├── bench.mts │ │ ├── find-my-way.mts │ │ ├── hono.mts │ │ ├── koa-router.mts │ │ ├── koa-tree-router.mts │ │ ├── medley-router.mts │ │ ├── tool.mts │ │ └── trek-router.mts │ ├── utils/ │ │ ├── .gitignore │ │ ├── package.json │ │ └── src/ │ │ ├── get-path.ts │ │ └── loop.js │ └── webapp/ │ ├── .gitignore │ ├── hono.js │ ├── itty-router.js │ ├── package.json │ └── sunder.js ├── build/ │ ├── build.ts │ ├── remove-private-fields.test.ts │ ├── remove-private-fields.ts │ ├── validate-exports.test.ts │ └── validate-exports.ts ├── bunfig.toml ├── codecov.yml ├── docs/ │ ├── CODE_OF_CONDUCT.md │ ├── CONTRIBUTING.md │ ├── MIGRATION.md │ └── images/ │ ├── hono-logo.pxm │ └── hono-title.pxm ├── eslint.config.mjs ├── jsr.json ├── package.cjs.json ├── package.json ├── perf-measures/ │ ├── .octocov.consolidated.perf-measures.main.yml │ ├── .octocov.consolidated.perf-measures.yml │ ├── bundle-check/ │ │ ├── .gitignore │ │ └── scripts/ │ │ └── check-bundle-size.ts │ └── type-check/ │ ├── .gitignore │ ├── client.ts │ ├── scripts/ │ │ ├── generate-app.ts │ │ ├── process-results.ts │ │ └── tsconfig.json │ └── tsconfig.build.json ├── runtime-tests/ │ ├── bun/ │ │ ├── .static/ │ │ │ └── plain.txt │ │ ├── color.test.ts │ │ ├── index.test.tsx │ │ ├── static/ │ │ │ ├── download │ │ │ ├── hello.world/ │ │ │ │ └── index.html │ │ │ ├── helloworld/ │ │ │ │ └── index.html │ │ │ └── plain.txt │ │ ├── static-absolute-root/ │ │ │ └── plain.txt │ │ └── tsconfig.json │ ├── deno/ │ │ ├── .static/ │ │ │ └── plain.txt │ │ ├── .vscode/ │ │ │ └── settings.json │ │ ├── deno.json │ │ ├── hono.test.ts │ │ ├── middleware.test.tsx │ │ ├── ssg.test.tsx │ │ ├── static/ │ │ │ ├── download │ │ │ ├── hello.world/ │ │ │ │ └── index.html │ │ │ ├── helloworld/ │ │ │ │ └── index.html │ │ │ └── plain.txt │ │ ├── static-absolute-root/ │ │ │ └── plain.txt │ │ └── stream.test.ts │ ├── deno-jsx/ │ │ ├── deno.precompile.json │ │ ├── deno.react-jsx.json │ │ └── jsx.test.tsx │ ├── fastly/ │ │ ├── index.test.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── lambda/ │ │ ├── index.test.ts │ │ ├── mock.ts │ │ ├── stream-mock.ts │ │ ├── stream.test.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── lambda-edge/ │ │ ├── index.test.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ ├── node/ │ │ ├── index.test.ts │ │ ├── tsconfig.json │ │ └── vitest.config.ts │ └── workerd/ │ ├── index.test.ts │ ├── index.ts │ ├── tsconfig.json │ └── vitest.config.ts ├── src/ │ ├── adapter/ │ │ ├── aws-lambda/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── handler.test.ts │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── bun/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── index.ts │ │ │ ├── serve-static.ts │ │ │ ├── server.test.ts │ │ │ ├── server.ts │ │ │ ├── ssg.ts │ │ │ ├── websocket.test.ts │ │ │ └── websocket.ts │ │ ├── cloudflare-pages/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── handler.test.ts │ │ │ ├── handler.ts │ │ │ └── index.ts │ │ ├── cloudflare-workers/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── index.ts │ │ │ ├── serve-static-module.ts │ │ │ ├── serve-static.test.ts │ │ │ ├── serve-static.ts │ │ │ ├── utils.test.ts │ │ │ ├── utils.ts │ │ │ ├── websocket.test.ts │ │ │ └── websocket.ts │ │ ├── deno/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── deno.d.ts │ │ │ ├── index.ts │ │ │ ├── serve-static.ts │ │ │ ├── ssg.ts │ │ │ ├── websocket.test.ts │ │ │ └── websocket.ts │ │ ├── lambda-edge/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── handler.test.ts │ │ │ ├── handler.ts │ │ │ └── index.ts │ │ ├── netlify/ │ │ │ ├── conninfo.test.ts │ │ │ ├── conninfo.ts │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ └── mod.ts │ │ ├── service-worker/ │ │ │ ├── handler.test.ts │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ └── vercel/ │ │ ├── conninfo.test.ts │ │ ├── conninfo.ts │ │ ├── handler.test.ts │ │ ├── handler.ts │ │ └── index.ts │ ├── client/ │ │ ├── client.test.ts │ │ ├── client.ts │ │ ├── fetch-result-please.ts │ │ ├── index.ts │ │ ├── types.test.ts │ │ ├── types.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── compose.test.ts │ ├── compose.ts │ ├── context.test.ts │ ├── context.ts │ ├── helper/ │ │ ├── accepts/ │ │ │ ├── accepts.test.ts │ │ │ ├── accepts.ts │ │ │ └── index.ts │ │ ├── adapter/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── conninfo/ │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── cookie/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── css/ │ │ │ ├── common.case.test.tsx │ │ │ ├── common.ts │ │ │ ├── index.test.tsx │ │ │ └── index.ts │ │ ├── dev/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── factory/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── html/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── proxy/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── route/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── ssg/ │ │ │ ├── index.ts │ │ │ ├── middleware.ts │ │ │ ├── plugins.test.tsx │ │ │ ├── plugins.ts │ │ │ ├── ssg.test.tsx │ │ │ ├── ssg.ts │ │ │ ├── utils.test.ts │ │ │ └── utils.ts │ │ ├── streaming/ │ │ │ ├── index.ts │ │ │ ├── sse.test.tsx │ │ │ ├── sse.ts │ │ │ ├── stream.test.ts │ │ │ ├── stream.ts │ │ │ ├── text.test.ts │ │ │ ├── text.ts │ │ │ └── utils.ts │ │ ├── testing/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ └── websocket/ │ │ ├── index.test.ts │ │ └── index.ts │ ├── hono-base.ts │ ├── hono.test.ts │ ├── hono.ts │ ├── http-exception.test.ts │ ├── http-exception.ts │ ├── index.ts │ ├── jsx/ │ │ ├── base.test.tsx │ │ ├── base.ts │ │ ├── children.test.ts │ │ ├── children.ts │ │ ├── components.test.tsx │ │ ├── components.ts │ │ ├── constants.ts │ │ ├── context.ts │ │ ├── dom/ │ │ │ ├── client.test.tsx │ │ │ ├── client.ts │ │ │ ├── components.test.tsx │ │ │ ├── components.ts │ │ │ ├── context.test.tsx │ │ │ ├── context.ts │ │ │ ├── css.test.tsx │ │ │ ├── css.ts │ │ │ ├── hooks/ │ │ │ │ ├── index.test.tsx │ │ │ │ └── index.ts │ │ │ ├── index.test.tsx │ │ │ ├── index.ts │ │ │ ├── intrinsic-element/ │ │ │ │ ├── components.test.tsx │ │ │ │ └── components.ts │ │ │ ├── jsx-dev-runtime.ts │ │ │ ├── jsx-runtime.ts │ │ │ ├── render.ts │ │ │ ├── server.test.tsx │ │ │ ├── server.ts │ │ │ └── utils.ts │ │ ├── hooks/ │ │ │ ├── dom.test.tsx │ │ │ ├── index.ts │ │ │ └── string.test.tsx │ │ ├── index.test.tsx │ │ ├── index.ts │ │ ├── intrinsic-element/ │ │ │ ├── common.ts │ │ │ ├── components.test.tsx │ │ │ └── components.ts │ │ ├── intrinsic-elements.ts │ │ ├── jsx-dev-runtime.ts │ │ ├── jsx-runtime.test.tsx │ │ ├── jsx-runtime.ts │ │ ├── streaming.test.tsx │ │ ├── streaming.ts │ │ ├── types.ts │ │ ├── utils.test.ts │ │ └── utils.ts │ ├── middleware/ │ │ ├── basic-auth/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── bearer-auth/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── body-limit/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── cache/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── combine/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── compress/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── context-storage/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── cors/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── csrf/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── etag/ │ │ │ ├── digest.ts │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── ip-restriction/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── jsx-renderer/ │ │ │ ├── index.test.tsx │ │ │ └── index.ts │ │ ├── jwk/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── jwk.ts │ │ │ └── keys.test.json │ │ ├── jwt/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── jwt.ts │ │ ├── language/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── language.ts │ │ ├── logger/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── method-override/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── powered-by/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── pretty-json/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── request-id/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── request-id.ts │ │ ├── secure-headers/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── permissions-policy.ts │ │ │ └── secure-headers.ts │ │ ├── serve-static/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ ├── path.test.ts │ │ │ └── path.ts │ │ ├── timeout/ │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── timing/ │ │ │ ├── index.test.ts │ │ │ ├── index.ts │ │ │ └── timing.ts │ │ └── trailing-slash/ │ │ ├── index.test.ts │ │ └── index.ts │ ├── preset/ │ │ ├── quick.test.ts │ │ ├── quick.ts │ │ ├── tiny.test.ts │ │ └── tiny.ts │ ├── request/ │ │ └── constants.ts │ ├── request.test.ts │ ├── request.ts │ ├── router/ │ │ ├── common.case.test.ts │ │ ├── linear-router/ │ │ │ ├── index.ts │ │ │ ├── router.test.ts │ │ │ └── router.ts │ │ ├── pattern-router/ │ │ │ ├── index.ts │ │ │ ├── router.test.ts │ │ │ └── router.ts │ │ ├── reg-exp-router/ │ │ │ ├── index.ts │ │ │ ├── matcher.ts │ │ │ ├── node.ts │ │ │ ├── prepared-router.test.ts │ │ │ ├── prepared-router.ts │ │ │ ├── router.test.ts │ │ │ ├── router.ts │ │ │ └── trie.ts │ │ ├── smart-router/ │ │ │ ├── index.ts │ │ │ ├── router.test.ts │ │ │ └── router.ts │ │ └── trie-router/ │ │ ├── index.ts │ │ ├── node.test.ts │ │ ├── node.ts │ │ ├── router.test.ts │ │ └── router.ts │ ├── router.ts │ ├── types.test.ts │ ├── types.ts │ ├── utils/ │ │ ├── accept.test.ts │ │ ├── accept.ts │ │ ├── basic-auth.test.ts │ │ ├── basic-auth.ts │ │ ├── body.test.ts │ │ ├── body.ts │ │ ├── buffer.test.ts │ │ ├── buffer.ts │ │ ├── color.test.ts │ │ ├── color.ts │ │ ├── compress.ts │ │ ├── concurrent.test.ts │ │ ├── concurrent.ts │ │ ├── constants.ts │ │ ├── cookie.test.ts │ │ ├── cookie.ts │ │ ├── crypto.test.ts │ │ ├── crypto.ts │ │ ├── encode.test.ts │ │ ├── encode.ts │ │ ├── filepath.test.ts │ │ ├── filepath.ts │ │ ├── handler.ts │ │ ├── headers.ts │ │ ├── html.test.ts │ │ ├── html.ts │ │ ├── http-status.ts │ │ ├── ipaddr.test.ts │ │ ├── ipaddr.ts │ │ ├── jwt/ │ │ │ ├── index.ts │ │ │ ├── jwa.test.ts │ │ │ ├── jwa.ts │ │ │ ├── jws.ts │ │ │ ├── jwt.test.ts │ │ │ ├── jwt.ts │ │ │ ├── types.ts │ │ │ └── utf8.ts │ │ ├── mime.test.ts │ │ ├── mime.ts │ │ ├── stream.test.ts │ │ ├── stream.ts │ │ ├── types.test.ts │ │ ├── types.ts │ │ ├── url.test.ts │ │ └── url.ts │ └── validator/ │ ├── index.ts │ ├── utils.test.ts │ ├── utils.ts │ ├── validator.test.ts │ └── validator.ts ├── tsconfig.base.json ├── tsconfig.build.json ├── tsconfig.json ├── tsconfig.spec.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .devcontainer/Dockerfile ================================================ FROM mcr.microsoft.com/devcontainers/typescript-node:20 # Install Deno ENV DENO_INSTALL=/usr/local RUN curl -fsSL https://deno.land/install.sh | sh # Install Bun ENV BUN_INSTALL=/usr/local RUN curl -fsSL https://bun.sh/install | bash WORKDIR /hono ================================================ FILE: .devcontainer/devcontainer.json ================================================ { "build": { "dockerfile": "Dockerfile" }, "containerEnv": { "HOME": "/home/node" }, "customizations": { "vscode": { "settings": { "deno.enable": false, "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } }, "extensions": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] } } } ================================================ FILE: .devcontainer/docker-compose.yml ================================================ services: hono: build: . container_name: hono volumes: - ../:/hono networks: - hono command: bash stdin_open: true tty: true restart: 'no' networks: hono: driver: bridge ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 insert_final_newline = true end_of_line = lf [*.{js,jsx,ts,tsx,json,json5,jsonc}] indent_style = space indent_size = 2 trim_trailing_whitespace = true [*.{md,yaml,yml}] indent_style = space indent_size = 2 trim_trailing_whitespace = false [*.{lockb,pxm}] charset = unset insert_final_newline = unset end_of_line = unset ================================================ FILE: .github/FUNDING.yml ================================================ github: ['yusukebe', 'usualoma'] ================================================ FILE: .github/ISSUE_TEMPLATE/1-bug-report.yml ================================================ name: 🐛 Bug Report description: Report an issue that should be fixed labels: [triage] body: - type: markdown attributes: value: | Thank you for submitting a bug report. It helps make Hono better. If you need help or support using Hono, and are not reporting a bug, please ask questions in [our Discord](https://discord.gg/KMh2eNSdxV) or [GitHub Discussions](https://github.com/orgs/honojs/discussions). Please try to include as much information as possible. - type: input attributes: label: What version of Hono are you using? placeholder: 0.0.0 validations: required: true - type: input attributes: label: What runtime/platform is your app running on? (with version if possible) placeholder: Cloudflare Workers, Deno, Bun, etc. validations: required: true - type: textarea attributes: label: What steps can reproduce the bug? description: Explain the bug and provide a code snippet that can reproduce it. validations: required: true - type: textarea attributes: label: What is the expected behavior? - type: textarea attributes: label: What do you see instead? - type: textarea attributes: label: Additional information description: Is there anything else you think we should know? ================================================ FILE: .github/ISSUE_TEMPLATE/2-feature-request.yml ================================================ name: 🚀 Feature Request description: Suggest an idea, feature, or enhancement labels: [enhancement] body: - type: markdown attributes: value: | Thank you for submitting an idea. It helps make Hono better. - type: textarea attributes: label: What is the feature you are proposing? description: A clear description of what you want to happen. validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: ❓ Questions url: https://github.com/orgs/honojs/discussions about: Ask your questions on the GitHub Discussions. - name: 🗣️ Discord url: https://discord.gg/KMh2eNSdxV about: Join our Discord server to chat. ================================================ FILE: .github/actions/perf-measures/action.yml ================================================ name: 'Performance Measures' description: 'Run type check and bundle size performance measurements' inputs: target-ref: description: 'Target ref (main or auto). Set to "auto" to delegate ref detection to octocov.' required: false default: 'auto' runs: using: 'composite' steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: | bun install --frozen-lockfile bun tsc --build shell: bash - name: Performance measurement of type check (tsc) run: | bun scripts/generate-app.ts bun tsc -p tsconfig.build.json --diagnostics | bun scripts/process-results.ts > diagnostics-tsc.json shell: bash working-directory: perf-measures/type-check env: BENCHMARK_TS_IMPL_LABEL: tsc - name: Performance measurement of type check (typescript-go) run: | bun scripts/generate-app.ts bun tsgo -p tsconfig.build.json --diagnostics | bun scripts/process-results.ts > diagnostics-tsgo.json shell: bash working-directory: perf-measures/type-check env: BENCHMARK_TS_IMPL_LABEL: typescript-go - name: Performance measurement of bundle check run: | bun run build bun perf-measures/bundle-check/scripts/check-bundle-size.ts > perf-measures/bundle-check/size.json shell: bash - name: Run octocov if: ${{ inputs.target-ref == 'auto' }} uses: k1LoW/octocov-action@v1 with: config: perf-measures/.octocov.consolidated.perf-measures.yml env: OCTOCOV_CUSTOM_METRICS_BUNDLE_SIZE_CHECK: perf-measures/bundle-check/size.json OCTOCOV_CUSTOM_METRICS_DIAGNOSTICS_TSC: perf-measures/type-check/diagnostics-tsc.json OCTOCOV_CUSTOM_METRICS_DIAGNOSTICS_TSGO: perf-measures/type-check/diagnostics-tsgo.json - name: Run octocov with custom target ref if: ${{ inputs.target-ref == 'main' }} uses: k1LoW/octocov-action@v1 with: config: perf-measures/.octocov.consolidated.perf-measures.main.yml env: OCTOCOV_GITHUB_REF: 'refs/heads/main' OCTOCOV_CUSTOM_METRICS_BUNDLE_SIZE_CHECK: perf-measures/bundle-check/size.json OCTOCOV_CUSTOM_METRICS_DIAGNOSTICS_TSC: perf-measures/type-check/diagnostics-tsc.json OCTOCOV_CUSTOM_METRICS_DIAGNOSTICS_TSGO: perf-measures/type-check/diagnostics-tsgo.json ================================================ FILE: .github/pull_request_template.md ================================================ ### The author should do the following, if applicable - [ ] Add tests - [ ] Run tests - [ ] `bun run format:fix && bun run lint:fix` to format the code - [ ] Add [TSDoc](https://tsdoc.org/)/[JSDoc](https://jsdoc.app/about-getting-started) to document the code ================================================ FILE: .github/workflows/autofix.yml ================================================ name: autofix.ci on: pull_request: push: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: autofix: name: autofix runs-on: ubuntu-latest if: ${{ github.event_name == 'push' || !github.event.pull_request.draft }} steps: - name: Checkout uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run format:fix - run: bun run lint:fix - name: Apply fixes uses: autofix-ci/action@v1 with: commit-message: 'ci: apply automated fixes' ================================================ FILE: .github/workflows/ci.yml ================================================ name: ci on: push: branches: [main, next] pull_request: branches: ['*'] paths-ignore: - 'docs/**' - '.vscode/**' - 'README.md' - '.gitignore' - 'LICENSE' jobs: coverage: name: 'Coverage' runs-on: ubuntu-latest needs: - main - bun - deno steps: - uses: actions/checkout@v6 - uses: actions/download-artifact@v6 with: pattern: coverage-* merge-multiple: true path: ./coverage - uses: codecov/codecov-action@v5 with: fail_ci_if_error: true directory: ./coverage main: name: 'Main' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: '.tool-versions' - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run format - run: bun run lint - run: bun run editorconfig-checker -format github-actions - run: bun run build - run: bun run test - uses: actions/upload-artifact@v5 with: name: coverage-main path: coverage/ jsr-dry-run: name: "Checking if it's valid for JSR" runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: denoland/setup-deno@v2 with: deno-version-file: '.tool-versions' - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bunx jsr publish --dry-run deno: name: 'Deno' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: denoland/setup-deno@v2 with: deno-version-file: '.tool-versions' - run: env NAME=Deno deno test --coverage=coverage/raw/deno-runtime --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno - run: deno test -c runtime-tests/deno-jsx/deno.precompile.json --coverage=coverage/raw/deno-precompile-jsx runtime-tests/deno-jsx - run: deno test -c runtime-tests/deno-jsx/deno.react-jsx.json --coverage=coverage/raw/deno-react-jsx runtime-tests/deno-jsx - run: grep -R '"url":' coverage | grep -v runtime-tests | sed -e 's/.*file:..//;s/.,//' | xargs deno cache --unstable-sloppy-imports - run: deno coverage --lcov > coverage/deno-runtime-coverage-lcov.info - uses: actions/upload-artifact@v5 with: name: coverage-deno path: coverage/ bun: name: 'Bun' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run test:bun - uses: actions/upload-artifact@v5 with: name: coverage-bun path: coverage/ bun-windows: name: 'Bun - Windows' runs-on: windows-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun run test:bun fastly: name: 'Fastly Compute' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run build - run: bun run test:fastly node: name: 'Node.js v${{ matrix.node }}' runs-on: ubuntu-latest strategy: matrix: node: ['18.18.2', '20.x', '22.x'] steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node }} - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run build - run: bun run test:node workerd: name: 'workerd' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version-file: '.tool-versions' - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run build - run: bun run test:workerd lambda: name: 'AWS Lambda' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run build - run: bun run test:lambda lambda-edge: name: 'Lambda@Edge' runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - run: bun run build - run: bun run test:lambda-edge perf-measures-check-on-pr: name: 'Type & Bundle size Check on PR' runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 - uses: ./.github/actions/perf-measures with: target-ref: 'auto' http-benchmark-on-pr: name: 'HTTP Speed Check on PR' runs-on: ubuntu-latest if: github.event_name == 'pull_request' steps: - uses: actions/checkout@v6 - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - run: bun install --frozen-lockfile - name: Install bombardier run: | wget -O bombardier https://github.com/codesenberg/bombardier/releases/download/v2.0.1/bombardier-linux-amd64 chmod +x bombardier sudo mv bombardier /usr/local/bin/ - name: Run HTTP benchmark run: | cd benchmarks/http-server bun run benchmark.ts - name: Comment PR uses: actions/github-script@v7 if: github.event.pull_request.head.repo.full_name == github.repository with: script: | const fs = require('fs'); const results = fs.readFileSync('benchmarks/http-server/benchmark-results.md', 'utf8'); // Minimize previous benchmark comments const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number }); for (const comment of comments.data) { if (comment.body.includes('## HTTP Performance Benchmark')) { await github.graphql(` mutation { minimizeComment(input: { subjectId: "${comment.node_id}", classifier: OUTDATED }) { minimizedComment { isMinimized } } } `); } } // Post new comment await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: results }); - name: Show benchmark results for forks if: github.event.pull_request.head.repo.full_name != github.repository run: | echo "## HTTP Performance Benchmark Results" echo "Note: Cannot post comment due to security restrictions on fork PRs" cat benchmarks/http-server/benchmark-results.md perf-measures-check-on-main: name: 'Type & Bundle size Check on Main' runs-on: ubuntu-latest if: github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v6 - uses: ./.github/actions/perf-measures with: target-ref: 'main' ================================================ FILE: .github/workflows/cr.yml ================================================ name: cr on: push: branches: [main] tags: ['!**'] # Avoid publishing on tags pull_request: types: [opened, synchronize, labeled] # Run on PR creation, updates, and when labels are added concurrency: group: ${{ github.workflow }}-${{ github.event.number }} # Concurrency group for each PR cancel-in-progress: true # Cancel in progress builds for the same PR jobs: publish: if: github.repository == 'honojs/hono' && (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'cr-tracked')) runs-on: ubuntu-latest name: 'Publish: pkg.pr.new' steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version-file: '.tool-versions' - uses: oven-sh/setup-bun@v2 with: bun-version-file: '.tool-versions' - name: Install Dependencies run: bun install --frozen-lockfile - name: Build run: bun run build - name: Publish to StackBlitz run: | bun pkg-pr-new publish --compact ================================================ FILE: .github/workflows/no-response.yml ================================================ name: Close stale issues with "not bug" label on: schedule: - cron: '0 0 * * *' permissions: contents: write issues: write jobs: stale: runs-on: ubuntu-latest steps: - name: Close stale issues with "not bug" label uses: actions/stale@v8 with: days-before-stale: 7 days-before-close: 2 stale-issue-message: 'This issue has been marked as stale due to inactivity.' close-issue-message: 'Closing this issue due to inactivity.' exempt-issue-labels: '' stale-issue-label: 'stale' only-labels: 'not bug' operations-per-run: 30 remove-stale-when-updated: true ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - '*' jobs: jsr: name: publish-to-jsr runs-on: ubuntu-latest permissions: contents: read id-token: write steps: - uses: actions/checkout@v6 - name: Install deno uses: denoland/setup-deno@v2 with: deno-version-file: '.tool-versions' - run: deno install --no-lock --allow-scripts - name: Publish to JSR run: deno run -A jsr:@david/publish-on-tag@0.1.4 ================================================ FILE: .gitignore ================================================ dist sandbox # Cloudflare Workers worker .wrangler # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* lerna-debug.log* .pnpm-debug.log* # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage *.lcov # nyc test coverage .nyc_output # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ # TypeScript cache *.tsbuildinfo # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional stylelint cache .stylelintcache # Microbundle cache .rpt2_cache/ .rts2_cache_cjs/ .rts2_cache_es/ .rts2_cache_umd/ # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variable files .env .env.development.local .env.test.local .env.production.local .env.local # parcel-bundler cache (https://parceljs.org/) .cache .parcel-cache # Next.js build output .next out # Nuxt.js build / generate output .nuxt # Gatsby files .cache/ # Comment in the public line in if your project uses Gatsby and not Next.js # https://nextjs.org/blog/next-9-1#public-directory-support # public # vuepress build output .vuepress/dist # vuepress v2.x temp and cache directory .temp .cache # Serverless directories .serverless/ # FuseBox cache .fusebox/ # DynamoDB Local files .dynamodb/ # TernJS port file .tern-port # Stores VSCode versions used for testing VSCode extensions .vscode-test # yarn v2 .yarn/cache .yarn/unplugged .yarn/build-state.yml .yarn/install-state.gz .pnp.* # IDE-specific settings .idea # Claude Code local files CLAUDE.local.md settings.local.json ================================================ FILE: .gitpod.yml ================================================ tasks: - name: Setup init: bun install --frozen-lockfile image: file: ./.devcontainer/Dockerfile vscode: extensions: - oven.bun-vscode - vitest.explorer ================================================ FILE: .prettierrc ================================================ { "printWidth": 100, "trailingComma": "es5", "tabWidth": 2, "semi": false, "singleQuote": true, "jsxSingleQuote": true, "endOfLine": "lf" } ================================================ FILE: .tool-versions ================================================ nodejs 24.7.0 bun 1.2.19 deno 2.4.5 ================================================ FILE: .vitest.config/setup-vitest.ts ================================================ import * as nodeCrypto from 'node:crypto' import { vi } from 'vitest' /** * crypto */ if (!globalThis.crypto) { vi.stubGlobal('crypto', nodeCrypto) vi.stubGlobal('CryptoKey', nodeCrypto.webcrypto.CryptoKey) } /** * Cache API */ type StoreMap = Map class MockCache { name: string store: StoreMap constructor(name: string, store: StoreMap) { this.name = name this.store = store } async match(key: Request | string): Promise { return this.store.get(key) || null } async keys() { return this.store.keys() } async put(key: Request | string, response: Response): Promise { this.store.set(key, response) } } const globalStore: Map = new Map() const caches = { open: (name: string) => { return new MockCache(name, globalStore) }, } vi.stubGlobal('caches', caches) ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["EditorConfig.EditorConfig"] } ================================================ FILE: .vscode/settings.json ================================================ { "deno.enable": false, "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "typescript.tsdk": "node_modules/typescript/lib" } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 - present, Yusuke Wada and Hono contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
Hono

[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/honojs/hono/ci.yml?branch=main)](https://github.com/honojs/hono/actions) [![GitHub](https://img.shields.io/github/license/honojs/hono)](https://github.com/honojs/hono/blob/main/LICENSE) [![npm](https://img.shields.io/npm/v/hono)](https://www.npmjs.com/package/hono) [![npm](https://img.shields.io/npm/dm/hono)](https://www.npmjs.com/package/hono) [![JSR](https://jsr.io/badges/@hono/hono)](https://jsr.io/@hono/hono) [![Bundle Size](https://img.shields.io/bundlephobia/min/hono)](https://bundlephobia.com/result?p=hono) [![Bundle Size](https://img.shields.io/bundlephobia/minzip/hono)](https://bundlephobia.com/result?p=hono) [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/honojs/hono)](https://github.com/honojs/hono/pulse) [![GitHub last commit](https://img.shields.io/github/last-commit/honojs/hono)](https://github.com/honojs/hono/commits/main) [![codecov](https://codecov.io/github/honojs/hono/graph/badge.svg)](https://codecov.io/github/honojs/hono) [![Discord badge](https://img.shields.io/discord/1011308539819597844?label=Discord&logo=Discord)](https://discord.gg/KMh2eNSdxV) [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/honojs/hono) Hono - _**means flame🔥 in Japanese**_ - is a small, simple, and ultrafast web framework built on Web Standards. It works on any JavaScript runtime: Cloudflare Workers, Fastly Compute, Deno, Bun, Vercel, AWS Lambda, Lambda@Edge, and Node.js. Fast, but not only fast. ```ts import { Hono } from 'hono' const app = new Hono() app.get('/', (c) => c.text('Hono!')) export default app ``` ## Quick Start ```bash npm create hono@latest ``` ## Features - **Ultrafast** 🚀 - The router `RegExpRouter` is really fast. Not using linear loops. Fast. - **Lightweight** 🪶 - The `hono/tiny` preset is under 12kB. Hono has zero dependencies and uses only the Web Standard API. - **Multi-runtime** 🌍 - Works on Cloudflare Workers, Fastly Compute, Deno, Bun, AWS Lambda, Lambda@Edge, or Node.js. The same code runs on all platforms. - **Batteries Included** 🔋 - Hono has built-in middleware, custom middleware, and third-party middleware. Batteries included. - **Delightful DX** 😃 - Super clean APIs. First-class TypeScript support. Now, we've got "Types". ## Documentation The documentation is available on [hono.dev](https://hono.dev). ## Migration The migration guide is available on [docs/MIGRATION.md](docs/MIGRATION.md). ## Communication [X](https://x.com/honojs) and [Discord channel](https://discord.gg/KMh2eNSdxV) are available. ## Contributing Contributions Welcome! You can contribute in the following ways. - Create an Issue - Propose a new feature. Report a bug. - Pull Request - Fix a bug or typo. Refactor the code. - Create third-party middleware - See instructions below. - Share - Share your thoughts on the Blog, X, and others. - Make your application - Please try to use Hono. For more details, see [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md). ## Contributors Thanks to [all contributors](https://github.com/honojs/hono/graphs/contributors)! ## Authors Yusuke Wada _RegExpRouter_, _SmartRouter_, _LinearRouter_, and _PatternRouter_ are created by Taku Amano ## License Distributed under the MIT License. See [LICENSE](LICENSE) for more information. ================================================ FILE: benchmarks/deno/.gitignore ================================================ *.sqlite ================================================ FILE: benchmarks/deno/.vscode/settings.json ================================================ { "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "deno.enable": true } ================================================ FILE: benchmarks/deno/fast.ts ================================================ import fast from 'https://deno.land/x/fast@4.0.0-beta.1/mod.ts' import type { Context } from 'https://deno.land/x/fast@4.0.0-beta.1/mod.ts' const app = fast() app.get('/user', () => {}) app.get('/user/comments', () => {}) app.get('/user/avatar', () => {}) app.get('/user/lookup/email/:address', () => {}) app.get('/event/:id', () => {}) app.get('/event/:id/comments', () => {}) app.post('/event/:id/comments', () => {}) app.post('/status', () => {}) app.get('/very/deeply/nested/route/hello/there', () => {}) app.get('/user/lookup/username/:username', (ctx: Context) => { return { message: `Hello ${ctx.params.username}` } }) await app.serve({ port: 8000, }) ================================================ FILE: benchmarks/deno/faster.ts ================================================ import { res, Server } from 'https://deno.land/x/faster@v5.7/mod.ts' const app = new Server() app.get('/user', () => {}) app.get('/user/comments', () => {}) app.get('/user/avatar', () => {}) app.get('/user/lookup/email/:address', () => {}) app.get('/event/:id', () => {}) app.get('/event/:id/comments', () => {}) app.post('/event/:id/comments', () => {}) app.post('/status', () => {}) app.get('/very/deeply/nested/route/hello/there', () => {}) app.get('/user/lookup/username/:username', res('json'), async (ctx: any, next: any) => { ctx.res.body = { message: `Hello ${ctx.params.username}` } await next() }) await app.listen({ port: 8000 }) ================================================ FILE: benchmarks/deno/hono.ts ================================================ import { Hono } from '../../src/index.ts' import { RegExpRouter } from '../../src/router/reg-exp-router/index.ts' const app = new Hono({ router: new RegExpRouter() }) app.get('/user', (c) => c.text('User')) app.get('/user/comments', (c) => c.text('User Comments')) app.get('/user/avatar', (c) => c.text('User Avatar')) app.get('/user/lookup/email/:address', (c) => c.text('User Lookup Email Address')) app.get('/event/:id', (c) => c.text('Event')) app.get('/event/:id/comments', (c) => c.text('Event Comments')) app.post('/event/:id/comments', (c) => c.text('POST Event Comments')) app.post('/status', (c) => c.text('Status')) app.get('/very/deeply/nested/route/hello/there', (c) => c.text('Very Deeply Nested Route')) app.get('/user/lookup/username/:username', (c) => { return c.json({ message: `Hello ${c.req.param('username')}` }) }) Deno.serve(app.fetch, { port: 8000, }) ================================================ FILE: benchmarks/deno/magalo.ts ================================================ import { Megalo } from 'https://deno.land/x/megalo@v0.3.0/mod.ts' const app = new Megalo() app.get('/user', () => {}) app.get('/user/comments', () => {}) app.get('/user/avatar', () => {}) app.get('/user/lookup/email/:address', () => {}) app.get('/event/:id', () => {}) app.get('/event/:id/comments', () => {}) app.post('/event/:id/comments', () => {}) app.post('/status', () => {}) app.get('/very/deeply/nested/route/hello/there', () => {}) app.get('/user/lookup/username/:username', ({ params }, res) => { res.json({ message: `Hello ${params.username}`, }) }) app.listen({ port: 8000 }) ================================================ FILE: benchmarks/deno/oak.ts ================================================ import { Application, Router } from 'https://deno.land/x/oak@v10.5.1/mod.ts' const router = new Router() router.get('/user', () => {}) router.get('/user/comments', () => {}) router.get('/user/avatar', () => {}) router.get('/user/lookup/email/:address', () => {}) router.get('/event/:id', () => {}) router.get('/event/:id/comments', () => {}) router.post('/event/:id/comments', () => {}) router.post('/status', () => {}) router.get('/very/deeply/nested/route/hello/there', () => {}) router.get('/user/lookup/username/:username', (ctx) => { ctx.response.body = { message: `Hello ${ctx.params.username}`, } }) const app = new Application() app.use(router.routes()) app.use(router.allowedMethods()) await app.listen({ port: 8000 }) ================================================ FILE: benchmarks/deno/opine.ts ================================================ import { opine } from 'https://deno.land/x/opine@2.2.0/mod.ts' const app = opine() app.get('/user', () => {}) app.get('/user/comments', () => {}) app.get('/user/avatar', () => {}) app.get('/user/lookup/email/:address', () => {}) app.get('/event/:id', () => {}) app.get('/event/:id/comments', () => {}) app.post('/event/:id/comments', () => {}) app.post('/status', () => {}) app.get('/very/deeply/nested/route/hello/there', () => {}) app.get('/user/lookup/username/:username', (req, res) => { res.send({ message: `Hello ${req.params.username}` }) }) app.listen(8000) ================================================ FILE: benchmarks/handle-event/index.js ================================================ import Benchmark from 'benchmark' import { makeEdgeEnv } from 'edge-mock' import { Router as IttyRouter } from 'itty-router' import { Request, Response } from 'node-fetch' import { Router as SunderRouter, Sunder } from 'sunder' import { Router as WorktopRouter } from 'worktop' import { Hono } from '../../dist/hono' import { RegExpRouter } from '../../dist/router/reg-exp-router' globalThis.Request = Request globalThis.Response = Response const initHono = (hono) => { hono.get('/user', (c) => c.text('User')) hono.get('/user/comments', (c) => c.text('User Comments')) hono.get('/user/avatar', (c) => c.text('User Avatar')) hono.get('/user/lookup/email/:address', (c) => c.text('User Lookup Email Address')) hono.get('/event/:id', (c) => c.text('Event')) hono.get('/event/:id/comments', (c) => c.text('Event Comments')) hono.post('/event/:id/comments', (c) => c.text('POST Event Comments')) hono.post('/status', (c) => c.text('Status')) hono.get('/very/deeply/nested/route/hello/there', (c) => c.text('Very Deeply Nested Route')) hono.get('/user/lookup/username/:username', (c) => { return c.text(`Hello ${c.req.param('username')}`) }) return hono } const hono = initHono(new Hono({ router: new RegExpRouter() })) // itty-router const ittyRouter = IttyRouter() ittyRouter.get('/user', () => new Response('User')) ittyRouter.get('/user/comments', () => new Response('User Comments')) ittyRouter.get('/user/avatar', () => new Response('User Avatar')) ittyRouter.get('/user/lookup/email/:address', () => new Response('User Lookup Email Address')) ittyRouter.get('/event/:id', () => new Response('Event')) ittyRouter.get('/event/:id/comments', () => new Response('Event Comments')) ittyRouter.post('/event/:id/comments', () => new Response('POST Event Comments')) ittyRouter.post('/status', () => new Response('Status')) ittyRouter.get( '/very/deeply/nested/route/hello/there', () => new Response('Very Deeply Nested Route') ) ittyRouter.get('/user/lookup/username/:username', ({ params }) => { return new Response(`Hello ${params.username}`, { status: 200, headers: { 'Content-Type': 'text/plain;charset=UTF-8', }, }) }) // Sunder const sunderRouter = new SunderRouter() sunderRouter.get('/user', (ctx) => { ctx.response.body = 'User' }) sunderRouter.get('/user/comments', (ctx) => { ctx.response.body = 'User Comments' }) sunderRouter.get('/user/avatar', (ctx) => { ctx.response.body = 'User Avatar' }) sunderRouter.get('/user/lookup/email/:address', (ctx) => { ctx.response.body = 'User Lookup Email Address' }) sunderRouter.get('/event/:id', (ctx) => { ctx.response.body = 'Event' }) sunderRouter.get('/event/:id/comments', (ctx) => { ctx.response.body = 'Event Comments' }) sunderRouter.post('/event/:id/comments', (ctx) => { ctx.response.body = 'POST Event Comments' }) sunderRouter.post('/status', (ctx) => { ctx.response.body = 'Status' }) sunderRouter.get('/very/deeply/nested/route/hello/there', (ctx) => { ctx.response.body = 'Very Deeply Nested Route' }) //sunderRouter.get('/static/*', () => {}) sunderRouter.get('/user/lookup/username/:username', (ctx) => { ctx.response.body = `Hello ${ctx.params.username}` }) const sunderApp = new Sunder() sunderApp.use(sunderRouter.middleware) // worktop const worktopRouter = new WorktopRouter() worktopRouter.add('GET', '/user', async (_req, res) => res.send(200, 'User')) worktopRouter.add('GET', '/user/comments', (_req, res) => res.send(200, 'User Comments')) worktopRouter.add('GET', '/user/avatar', (_req, res) => res.send(200, 'User Avatar')) worktopRouter.add('GET', '/user/lookup/email/:address', (_req, res) => res.send(200, 'User Lookup Email Address') ) worktopRouter.add('GET', '/event/:id', (_req, res) => res.send(200, 'Event')) worktopRouter.add('POST', '/event/:id/comments', (_req, res) => res.send(200, 'POST Event Comments') ) worktopRouter.add('POST', '/status', (_req, res) => res.send(200, 'Status')) worktopRouter.add('GET', '/very/deeply/nested/route/hello/there', (_req, res) => res.send(200, 'Very Deeply Nested Route') ) worktopRouter.add('GET', '/user/lookup/username/:username', (req, res) => res.send(200, `Hello ${req.params.username}`) ) // Request Object const request = new Request('http://localhost/user/lookup/username/hey', { method: 'GET' }) makeEdgeEnv() // FetchEvent Object // eslint-disable-next-line no-undef const event = new FetchEvent('fetch', { request }) const fn = async () => { let res = await hono.fetch(event.request) console.log(await res.text()) res = await ittyRouter.handle(event.request) console.log(await res.text()) res = await sunderApp.handle(event) console.log(await res.text()) res = await worktopRouter.run(event) console.log(await res.text()) } fn() const suite = new Benchmark.Suite() suite .add('Hono', async () => { await hono.fetch(event.request) }) .add('itty-router', async () => { await ittyRouter.handle(event.request) }) .add('sunder', async () => { await sunderApp.handle(event) }) .add('worktop', async () => { await worktopRouter.run(event) }) .on('cycle', (event) => { console.log(String(event.target)) }) .on('complete', function () { console.log(`Fastest is ${this.filter('fastest').map('name')}`) }) .run({ async: true }) ================================================ FILE: benchmarks/handle-event/package.json ================================================ { "name": "hono-benchmark", "version": "0.0.1", "description": "", "main": "index.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node --experimental-specifier-resolution=node index.js" }, "type": "module", "author": "Yusuke Wada (https://github.com/yusukebe)", "license": "MIT", "devDependencies": { "benchmark": "^2.1.4", "edge-mock": "^0.0.15", "itty-router": "^3.0.11", "node-fetch": "^3.2.10", "sunder": "^0.10.1", "worktop": "^0.7.3" } } ================================================ FILE: benchmarks/http-server/.gitignore ================================================ .benchmark-temp/ benchmark-results.md ================================================ FILE: benchmarks/http-server/README.md ================================================ # Hono HTTP Benchmark HTTP performance benchmarking tool that compares main vs current versions. ## Usage ### In Pull Requests HTTP benchmarks are automatically run for each pull request and results are commented on the PR. ### Local Development ```bash cd benchmarks/http-server bun run benchmark.ts ``` ## Prerequisites - Bun v1.0+ - bombardier (`brew install bombardier` on macOS) ================================================ FILE: benchmarks/http-server/benchmark.ts ================================================ /** * Hono HTTP Performance Benchmark * * Inspired by https://github.com/SaltyAom/bun-http-framework-benchmark * * Usage: * bun run benchmark.ts [options] * * Options: * --baseline= Git reference for baseline (default: main) * --target= Git reference for target (default: current) * --runs= Number of benchmark runs (default: 1) * --duration= Duration of each test in seconds (default: 10) * --skip-tests Skip endpoint validation tests */ import { spawn } from 'node:child_process' import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' import { join } from 'node:path' // Configuration from command line arguments const baseline = process.argv.find((arg) => arg.startsWith('--baseline='))?.split('=')[1] || 'origin/main' const target = process.argv.find((arg) => arg.startsWith('--target='))?.split('=')[1] || 'current' const runs = parseInt(process.argv.find((arg) => arg.startsWith('--runs='))?.split('=')[1] || '1') const duration = parseInt( process.argv.find((arg) => arg.startsWith('--duration='))?.split('=')[1] || '10' ) const concurrency = 500 const skipTests = process.argv.includes('--skip-tests') const SCRIPT_DIR = import.meta.dirname const TEMP_DIR = join(SCRIPT_DIR, '.benchmark-temp') const HONO_ROOT = join(SCRIPT_DIR, '../..') // Test app template (embedded to avoid file dependency issues) const getAppTemplate = () => `import { Hono } from './src/index.ts' import { RegExpRouter } from './src/router/reg-exp-router/index.ts' const app = new Hono({ router: new RegExpRouter() }) app .get('/', (c) => c.text('Hi')) .post('/json', (c) => c.req.json().then(c.json)) .get('/id/:id', (c) => { const id = c.req.param('id') const name = c.req.query('name') c.header('x-powered-by', 'benchmark') return c.text(\`\${id} \${name}\`) }) export default app` const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) const runCommand = async (command: string, cwd: string) => { const parts = command.split(' ') const proc = spawn(parts[0], parts.slice(1), { cwd }) let stdout = '' let stderr = '' proc.stdout.on('data', (data) => { stdout += data }) proc.stderr.on('data', (data) => { stderr += data }) const exitCode = await new Promise((resolve) => { proc.on('close', resolve) }) if (exitCode !== 0) { console.error(`Command failed: ${command}`) console.error(`Exit code: ${exitCode}`) console.error(`Stdout: ${stdout}`) console.error(`Stderr: ${stderr}`) throw new Error(`Command failed: ${command}`) } return { stdout, stderr } } const setupTemp = () => { if (existsSync(TEMP_DIR)) { rmSync(TEMP_DIR, { recursive: true }) } mkdirSync(TEMP_DIR, { recursive: true }) writeFileSync(join(TEMP_DIR, 'body.json'), '{"hello":"world"}') } const buildVersion = async (version: string, name: string) => { console.log(`📦 Preparing ${name} (${version})...`) let needsRestore = false let stashRef = '' if (version === 'current') { // No build needed - use src directly } else { // Ensure we have the latest remote refs await runCommand('git fetch origin', HONO_ROOT) try { const stashResult = await runCommand('git stash push -m "benchmark-temp"', HONO_ROOT) needsRestore = stashResult.stdout.includes('Saved working directory') if (needsRestore) { stashRef = 'stash@{0}' } } catch { // No changes to stash } await runCommand(`git checkout ${version}`, HONO_ROOT) await runCommand('bun install --frozen-lockfile', HONO_ROOT) // No build needed - use src directly } const versionDir = join(TEMP_DIR, name) mkdirSync(versionDir, { recursive: true }) await runCommand(`cp -r ${HONO_ROOT}/src ${versionDir}/src`, process.cwd()) const appPath = join(versionDir, 'app.ts') writeFileSync(appPath, getAppTemplate()) // Test endpoints (optional) if (!skipTests) { console.log(`🧪 Testing endpoints for ${name}...`) const server = spawn('bun', [appPath], { cwd: TEMP_DIR, env: { ...process.env, NODE_ENV: 'production' }, }) await sleep(2000) try { const res1 = await fetch('http://127.0.0.1:3000/') if ((await res1.text()) !== 'Hi') { throw new Error('[GET /] test failed') } const res2 = await fetch('http://127.0.0.1:3000/id/1?name=bun') if (res2.headers.get('x-powered-by') !== 'benchmark' || (await res2.text()) !== '1 bun') { throw new Error('[GET /id/:id] test failed') } const body = JSON.stringify({ hello: 'world' }) const res3 = await fetch('http://127.0.0.1:3000/json', { method: 'POST', body, headers: { 'content-type': 'application/json', 'content-length': body.length.toString() }, }) if ( !res3.headers.get('content-type')?.includes('application/json') || (await res3.text()) !== body ) { throw new Error('[POST /json] test failed') } console.log(` ✅ Tests passed for ${name}`) } finally { server.kill() await sleep(1000) } } else { console.log(` ⏭️ Skipping endpoint tests for ${name}`) } // Restore git state if (version !== 'current' && needsRestore) { await runCommand('git checkout -', HONO_ROOT) await runCommand(`git stash pop ${stashRef}`, HONO_ROOT) } else if (version !== 'current') { await runCommand('git checkout -', HONO_ROOT) } return appPath } const runBenchmark = async (appPath: string, name: string) => { console.log(`⚡ Running HTTP benchmark for ${name}...`) const bodyFile = join(TEMP_DIR, 'body.json') const commands = [ `bombardier --fasthttp -c ${concurrency} -d ${duration}s http://127.0.0.1:3000/`, `bombardier --fasthttp -c ${concurrency} -d ${duration}s http://127.0.0.1:3000/id/1?name=bun`, `bombardier --fasthttp -c ${concurrency} -d ${duration}s -m POST -H Content-Type:application/json -f ${bodyFile} http://127.0.0.1:3000/json`, ] const allRuns: number[][] = [] for (let run = 0; run < runs; run++) { console.log(` Run ${run + 1}/${runs}`) const server = spawn('bun', [appPath], { cwd: TEMP_DIR, env: { ...process.env, NODE_ENV: 'production' }, }) await sleep(1000) const runResults: number[] = [] try { for (const command of commands) { const result = await runCommand(command, process.cwd()) console.log(result.stdout) const match = result.stdout.match(/Reqs\/sec\s+(\d+[.|,]\d+)/) if (match) { runResults.push(parseFloat(match[1].replace(',', ''))) } else { console.log('❌ Failed to parse result') runResults.push(0) } } } finally { server.kill() await sleep(500) } allRuns.push(runResults) } const average = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length const ping = average(allRuns.map((run) => run[0])) const query = average(allRuns.map((run) => run[1])) const body = average(allRuns.map((run) => run[2])) const overall = (ping + query + body) / 3 return { name, average: overall, ping, query, body, runs: allRuns.map((run) => average(run)) } } const main = async () => { console.log('🏁 Hono HTTP Benchmark') console.log('======================') console.log(`Baseline: ${baseline}`) console.log(`Target: ${target}`) console.log(`Runs: ${runs}`) console.log(`Duration: ${duration}s`) console.log(`Concurrency: ${concurrency}`) console.log(`Skip Tests: ${skipTests}`) console.log('') setupTemp() try { // Compare baseline vs target const baselinePath = await buildVersion(baseline, 'baseline') const targetPath = await buildVersion(target, 'target') const baselineResult = await runBenchmark(baselinePath, 'baseline') const targetResult = await runBenchmark(targetPath, 'target') // Calculate changes const calculateChange = (target: number, baseline: number) => (((target - baseline) / baseline) * 100).toFixed(2) const changes = { average: calculateChange(targetResult.average, baselineResult.average), ping: calculateChange(targetResult.ping, baselineResult.ping), query: calculateChange(targetResult.query, baselineResult.query), body: calculateChange(targetResult.body, baselineResult.body), } // Format numbers const format = (num: number) => num.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',') const formatChange = (change: string) => (Number(change) >= 0 ? '+' : '') + change + '%' // Generate table data const rows = [ { framework: `hono (${baseline})`, runtime: 'bun', average: format(baselineResult.average), ping: format(baselineResult.ping), query: format(baselineResult.query), body: format(baselineResult.body), }, { framework: `hono (${target})`, runtime: 'bun', average: format(targetResult.average), ping: format(targetResult.ping), query: format(targetResult.query), body: format(targetResult.body), }, { framework: 'Change', runtime: '', average: formatChange(changes.average), ping: formatChange(changes.ping), query: formatChange(changes.query), body: formatChange(changes.body), }, ] const table = [ '| Framework | Runtime | Average | Ping | Query | Body |', '| --- | --- | --- | --- | --- | --- |', ...rows.map( (row) => `| ${row.framework} | ${row.runtime} | ${row.average} | ${row.ping} | ${row.query} | ${row.body} |` ), ] // Console output console.log('') table.forEach((line) => console.log(line)) console.log('') // Markdown output const markdownOutput = ['## HTTP Performance Benchmark', '', ...table].join('\n') writeFileSync(join(SCRIPT_DIR, 'benchmark-results.md'), markdownOutput) } catch (error) { console.error('❌ Benchmark failed:', error) throw error } finally { if (existsSync(TEMP_DIR)) { rmSync(TEMP_DIR, { recursive: true }) } } } main() ================================================ FILE: benchmarks/jsx/package.json ================================================ { "name": "jsx", "version": "1.0.0", "main": "index.js", "scripts": { "bench:node": "esbuild --bundle src/benchmark.ts | node", "bench:bun": "bun run src/benchmark.ts", "bench:react-jsx:node": "esbuild --bundle src/react-jsx/benchmark.ts | node", "compare-bundle-size": "esbuild --minify --minify-syntax --tree-shaking=true --bundle src/{hono,react,preact,nano}.ts --outdir=dist" }, "license": "MIT", "devDependencies": { "esbuild": "^0.19.8", "node-html-parser": "^6.1.11" }, "dependencies": { "@types/benchmark": "^2.1.5", "@types/react": "^18.2.40", "@types/react-dom": "^18.2.17", "benchmark": "^2.1.4", "hono": "^3.10.4", "nano-jsx": "^0.1.0", "preact": "^10.19.2", "preact-render-to-string": "^6.3.1", "react": "^18.2.0", "react-dom": "^18.2.0", "typescript": "^5.3.2" } } ================================================ FILE: benchmarks/jsx/src/benchmark.ts ================================================ import { Suite } from 'benchmark' import { parse } from 'node-html-parser' import { render as renderHono } from './hono' import { render as renderNano } from './nano' import { render as renderPreact } from './preact' import { render as renderReact } from './react' const suite = new Suite() ;[renderHono, renderReact, renderPreact, renderNano].forEach((render) => { const html = render() const doc = parse(html) if (doc.querySelector('p#c').textContent !== '3\nc') { throw new Error('Invalid output') } }) suite .add('Hono', () => { renderHono() }) .add('React', () => { renderReact() }) .add('Preact', () => { renderPreact() }) .add('Nano', () => { renderNano() }) .on('cycle', (ev) => { console.log(String(ev.target)) }) .on('complete', (ev) => { console.log(`Fastest is ${ev.currentTarget.filter('fastest').map('name')}`) }) .run({ async: true }) ================================================ FILE: benchmarks/jsx/src/hono.ts ================================================ import { jsx, Fragment } from '../../../src/jsx' import { buildPage } from './page' export const render = () => buildPage({ jsx, Fragment })().toString() ================================================ FILE: benchmarks/jsx/src/nano.ts ================================================ import { h, Fragment, renderSSR } from 'nano-jsx' import { buildPage } from './page' export const render = () => renderSSR(buildPage({ jsx: h, Fragment })) ================================================ FILE: benchmarks/jsx/src/page-react.tsx ================================================ /** @jsx jsx */ /** @jsxFrag Fragment */ export const buildPage = ({ jsx, Fragment }: { jsx: any; Fragment: any }) => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => (
) return () => (
) } ================================================ FILE: benchmarks/jsx/src/page.tsx ================================================ /** @jsx jsx */ /** @jsxFrag Fragment */ export const buildPage = ({ jsx, Fragment }: { jsx: any; Fragment: any }) => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => ( ) return () => (
) } ================================================ FILE: benchmarks/jsx/src/preact.ts ================================================ import { h, Fragment } from 'preact' import { renderToString } from 'preact-render-to-string' import { buildPage } from './page' export const render = () => renderToString(buildPage({ jsx: h, Fragment })() as any) ================================================ FILE: benchmarks/jsx/src/react-jsx/benchmark.ts ================================================ import { Suite } from 'benchmark' import { parse } from 'node-html-parser' import { render as renderHono } from './hono' import { render as renderNano } from './nano' import { render as renderPreact } from './preact' import { render as renderReact } from './react' const suite = new Suite() ;[renderHono, renderReact, renderPreact, renderNano].forEach((render) => { const html = render() const doc = parse(html) if (doc.querySelector('p#c').textContent !== '3\nc') { throw new Error('Invalid output') } }) suite .add('Hono', () => { renderHono() }) .add('React', () => { renderReact() }) .add('Preact', () => { renderPreact() }) .add('Nano', () => { renderNano() }) .on('cycle', (ev) => { console.log(String(ev.target)) }) .on('complete', (ev) => { console.log(`Fastest is ${ev.currentTarget.filter('fastest').map('name')}`) }) .run({ async: true }) ================================================ FILE: benchmarks/jsx/src/react-jsx/hono.ts ================================================ import { buildPage } from './page-hono' export const render = () => buildPage()().toString() ================================================ FILE: benchmarks/jsx/src/react-jsx/nano.ts ================================================ import { renderSSR } from 'nano-jsx' import { buildPage } from './page-nano.tsx' export const render = () => renderSSR(buildPage()) ================================================ FILE: benchmarks/jsx/src/react-jsx/page-hono.tsx ================================================ /** @jsxImportSource ../../../../src/jsx **/ export const buildPage = () => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => ( ) return () => (
) } ================================================ FILE: benchmarks/jsx/src/react-jsx/page-nano.tsx ================================================ /** @jsxImportSource nano-jsx/lib **/ export const buildPage = () => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => ( ) return () => (
) } ================================================ FILE: benchmarks/jsx/src/react-jsx/page-preact.tsx ================================================ /** @jsxImportSource preact **/ export const buildPage = () => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => ( ) return () => (
) } ================================================ FILE: benchmarks/jsx/src/react-jsx/page-react.tsx ================================================ /** @jsxImportSource react **/ export const buildPage = () => { const Content = () => ( <>

1
a

2
b

3
c

' }} /> {null} {undefined} ) const Form = () => ( ) return () => (
) } ================================================ FILE: benchmarks/jsx/src/react-jsx/preact.ts ================================================ import { renderToString } from 'preact-render-to-string' import { buildPage } from './page-preact' export const render = () => renderToString(buildPage()() as any) ================================================ FILE: benchmarks/jsx/src/react-jsx/react.ts ================================================ import { renderToString } from 'react-dom/server' import { buildPage } from './page-react.tsx' export const render = () => renderToString(buildPage()() as any) ================================================ FILE: benchmarks/jsx/src/react-jsx/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react-jsx" } } ================================================ FILE: benchmarks/jsx/src/react.ts ================================================ import { createElement, Fragment } from 'react' import { renderToString } from 'react-dom/server' import { buildPage } from './page-react' export const render = () => renderToString(buildPage({ jsx: createElement, Fragment })() as any) ================================================ FILE: benchmarks/jsx/tsconfig.json ================================================ { "compilerOptions": { "jsx": "react" } } ================================================ FILE: benchmarks/query-param/package.json ================================================ { "scripts": { "bench:node": "tsx ./src/bench.mts", "bench:bun": "bun run ./src/bench.mts" }, "license": "MIT", "devDependencies": { "@types/qs": "^6.9.17", "tsx": "^3.12.2" }, "dependencies": { "fast-querystring": "^1.1.1", "mitata": "^0.1.6", "qs": "^6.13.0" } } ================================================ FILE: benchmarks/query-param/src/bench.mts ================================================ import { run, group, bench } from 'mitata' import { getQueryStrings } from '../../../src/utils/url' import fastQuerystring from './fast-querystring.mts' import hono from './hono.mts' import qs from './qs.mts' ;[ { url: 'http://example.com/?page=1', key: 'page', }, { url: 'http://example.com/?url=http://example.com&page=1', key: 'page', }, { url: 'http://example.com/?page=1', key: undefined, }, { url: 'http://example.com/?url=http://example.com&page=1', key: undefined, }, { url: 'http://example.com/?url=http://example.com/very/very/deep/path/to/something&search=very-long-search-string', key: undefined, }, { url: 'http://example.com/?search=Hono+is+a+small,+simple,+and+ultrafast+web+framework+for+the+Edge.&page=1', key: undefined, }, { url: 'http://example.com/?a=1&b=2&c=3&d=4&e=5&f=6&g=7&h=8&i=9&j=10', key: undefined, }, ].forEach((data) => { const { url, key } = data group(JSON.stringify(data), () => { bench('hono', () => hono(url, key)) bench('fastQuerystring', () => fastQuerystring(url, key)) bench('qs', () => qs(url, key)) bench('URLSearchParams', () => { const params = new URLSearchParams(getQueryStrings(url)) if (key) { return params.get(key) } const obj = {} for (const [k, v] of params) { obj[k] = v } return obj }) }) }) run() ================================================ FILE: benchmarks/query-param/src/fast-querystring.mts ================================================ import { parse } from 'fast-querystring' const getQueryStringFromURL = (url: string): string => { const queryIndex = url.indexOf('?', 8) const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' return result } export default (url, key?) => { const data = parse(getQueryStringFromURL(url)) return key !== undefined ? data[key] : data } ================================================ FILE: benchmarks/query-param/src/hono.mts ================================================ import { getQueryParam } from '../../../src/utils/url' export default (url, key?) => { return getQueryParam(url, key) } ================================================ FILE: benchmarks/query-param/src/qs.mts ================================================ import qs from 'qs' const getQueryStringFromURL = (url: string): string => { const queryIndex = url.indexOf('?', 8) const result = queryIndex !== -1 ? url.slice(queryIndex + 1) : '' return result } export default (url, key?) => { const data = qs.parse(getQueryStringFromURL(url)) return key !== undefined ? data[key] : data } ================================================ FILE: benchmarks/routers/README.md ================================================ # Router benchmarks Benchmark of the most commonly used HTTP routers. Tested routes: - [find-my-way](https://github.com/delvedor/find-my-way) - [express](https://www.npmjs.com/package/express) - [koa-router](https://github.com/alexmingoia/koa-router) - [koa-tree-router](https://github.com/steambap/koa-tree-router) - [trek-router](https://www.npmjs.com/package/trek-router) - [@medley/router](https://www.npmjs.com/package/@medley/router) - [Hono RegExpRouter](https://github.com/honojs/hono) - [Hono TrieRouter](https://github.com/honojs/hono) Install: ``` bun install --frozen-lockfile ``` For Node.js: ``` bun run bench:node ``` For Bun: ``` bun run bench:bun ``` This project is heavily impaired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) ## License MIT ================================================ FILE: benchmarks/routers/package.json ================================================ { "scripts": { "bench:node": "tsx ./src/bench.mts", "bench:bun": "bun run ./src/bench.mts", "bench-includes-init:node": "tsx ./src/bench-includes-init.mts", "bench-includes-init:bun": "bun run ./src/bench-includes-init.mts" }, "license": "MIT", "devDependencies": { "tsx": "^4.20.5" }, "dependencies": { "@medley/router": "^0.2.1", "express": "^4.21.2", "find-my-way": "^9.3.0", "koa-router": "^12.0.1", "koa-tree-router": "^0.13.1", "memoirist": "^0.4.0", "mitata": "^1.0.34", "radix3": "^1.1.2", "rou3": "^0.7.3", "trek-router": "^1.2.0" } } ================================================ FILE: benchmarks/routers/src/bench-includes-init.mts ================================================ import MedleyRouter from '@medley/router' import type { HTTPMethod } from 'find-my-way' import findMyWay from 'find-my-way' import KoaRouter from 'koa-tree-router' import { run, bench, group } from 'mitata' import TrekRouter from 'trek-router' import { LinearRouter } from '../../../src/router/linear-router/index.ts' import { RegExpRouter, PreparedRegExpRouter, buildInitParams, } from '../../../src/router/reg-exp-router/index.ts' import { TrieRouter } from '../../../src/router/trie-router/index.ts' import type { Route } from './tool.mts' import { routes } from './tool.mts' const preparedParams = buildInitParams({ paths: routes.map((r) => r.path), }) const benchRoutes: (Route & { name: string })[] = [ { name: 'short static', method: 'GET', path: '/user', }, { name: 'static with same radix', method: 'GET', path: '/user/comments', }, { name: 'dynamic route', method: 'GET', path: '/user/lookup/username/hey', }, { name: 'mixed static dynamic', method: 'GET', path: '/event/abcd1234/comments', }, { name: 'post', method: 'POST', path: '/event/abcd1234/comment', }, { name: 'long static', method: 'GET', path: '/very/deeply/nested/route/hello/there', }, { name: 'wildcard', method: 'GET', path: '/static/index.html', }, ] for (const benchRoute of benchRoutes) { group(`${benchRoute.method} ${benchRoute.path}`, () => { bench('RegExpRouter', () => { const router = new RegExpRouter() for (const route of routes) { router.add(route.method, route.path, () => {}) } router.match(benchRoute.method, benchRoute.path) }) bench('PreparedRegExpRouter', () => { const router = new PreparedRegExpRouter(preparedParams[0], preparedParams[1]) for (const route of routes) { router.add(route.method, route.path, () => {}) } router.match(benchRoute.method, benchRoute.path) }) bench('TrieRouter', () => { const router = new TrieRouter() for (const route of routes) { router.add(route.method, route.path, () => {}) } router.match(benchRoute.method, benchRoute.path) }) bench('LinearRouter', () => { const router = new LinearRouter() for (const route of routes) { router.add(route.method, route.path, () => {}) } router.match(benchRoute.method, benchRoute.path) }) bench('MedleyRouter', () => { const router = new MedleyRouter() for (const route of routes) { const store = router.register(route.path) store[route.method] = () => {} } const match = router.find(benchRoute.path) match.store[benchRoute.method] // get handler }) bench('FindMyWay', () => { const router = findMyWay() for (const route of routes) { router.on(route.method as HTTPMethod, route.path, () => {}) } router.find(benchRoute.method as HTTPMethod, benchRoute.path) }) bench('KoaTreeRouter', () => { const router = new KoaRouter() for (const route of routes) { router.on(route.method, route.path.replace('*', '*foo'), () => {}) } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore router.find(benchRoute.method, benchRoute.path) }) bench('TrekRouter', () => { const router = new TrekRouter() for (const route of routes) { router.add(route.method, route.path, () => {}) } router.find(benchRoute.method, benchRoute.path) }) }) } await run() ================================================ FILE: benchmarks/routers/src/bench.mts ================================================ import { run, bench, group, summary } from 'mitata' import { expressRouter } from './express.mts' import { findMyWayRouter } from './find-my-way.mts' import { regExpRouter, trieRouter, patternRouter } from './hono.mts' import { koaRouter } from './koa-router.mts' import { koaTreeRouter } from './koa-tree-router.mts' import { medleyRouter } from './medley-router.mts' import { memoiristRouter } from './memoirist.mts' import { radix3Router } from './radix3.mts' import { rou3Router } from './rou3.mts' import type { Route, RouterInterface } from './tool.mts' import { trekRouter } from './trek-router.mts' const routers: RouterInterface[] = [ regExpRouter, trieRouter, patternRouter, medleyRouter, findMyWayRouter, koaTreeRouter, trekRouter, expressRouter, koaRouter, radix3Router, memoiristRouter, rou3Router, ] medleyRouter.match({ method: 'GET', path: '/user' }) const routes: (Route & { name: string })[] = [ { name: 'short static', method: 'GET', path: '/user', }, { name: 'static with same radix', method: 'GET', path: '/user/comments', }, { name: 'dynamic route', method: 'GET', path: '/user/lookup/username/hey', }, { name: 'mixed static dynamic', method: 'GET', path: '/event/abcd1234/comments', }, { name: 'post', method: 'POST', path: '/event/abcd1234/comment', }, { name: 'long static', method: 'GET', path: '/very/deeply/nested/route/hello/there', }, { name: 'wildcard', method: 'GET', path: '/static/index.html', }, ] for (const route of routes) { summary(() => { group(`${route.name} - ${route.method} ${route.path}`, () => { for (const router of routers) { bench(router.name, async () => { router.match(route) }) } }) }) } group('all together', () => { summary(() => { for (const router of routers) { bench(router.name, async () => { for (const route of routes) { router.match(route) } }) } }) }) await run() ================================================ FILE: benchmarks/routers/src/express.mts ================================================ import routerFunc from 'express/lib/router/index.js' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const router = routerFunc() const name = 'express (WARNING: includes handling)' for (const route of routes) { if (route.method === 'GET') { router.route(route.path).get(handler) } else { router.route(route.path).post(handler) } } export const expressRouter: RouterInterface = { name, match: (route) => { router.handle({ method: route.method, url: route.path }) }, } ================================================ FILE: benchmarks/routers/src/find-my-way.mts ================================================ import type { HTTPMethod } from 'find-my-way' import findMyWay from 'find-my-way' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'find-my-way' const router = findMyWay() for (const route of routes) { router.on(route.method as HTTPMethod, route.path, handler) } export const findMyWayRouter: RouterInterface = { name, match: (route) => { router.find(route.method as HTTPMethod, route.path) }, } ================================================ FILE: benchmarks/routers/src/hono.mts ================================================ import { PatternRouter } from '../../../src/router/pattern-router/index.ts' import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts' import { TrieRouter } from '../../../src/router/trie-router/index.ts' import type { Router } from '../../../src/router.ts' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const createHonoRouter = (name: string, router: Router): RouterInterface => { for (const route of routes) { router.add(route.method, route.path, handler) } return { name: `Hono ${name}`, match: (route) => { router.match(route.method, route.path) }, } } export const regExpRouter = createHonoRouter('RegExpRouter', new RegExpRouter()) export const trieRouter = createHonoRouter('TrieRouter', new TrieRouter()) export const patternRouter = createHonoRouter('PatternRouter', new PatternRouter()) ================================================ FILE: benchmarks/routers/src/koa-router.mts ================================================ import KoaRouter from 'koa-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'koa-router' const router = new KoaRouter() for (const route of routes) { if (route.method === 'GET') { router.get(route.path.replace('*', '(.*)'), handler) } else { router.post(route.path, handler) } } export const koaRouter: RouterInterface = { name, match: (route) => { router.match(route.path, route.method) // only matching }, } ================================================ FILE: benchmarks/routers/src/koa-tree-router.mts ================================================ import KoaRouter from 'koa-tree-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'koa-tree-router' const router = new KoaRouter() for (const route of routes) { router.on(route.method, route.path.replace('*', '*foo'), handler) } export const koaTreeRouter: RouterInterface = { name, match: (route) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore router.find(route.method, route.path) }, } ================================================ FILE: benchmarks/routers/src/medley-router.mts ================================================ import Router from '@medley/router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = '@medley/router' const router = new Router() for (const route of routes) { const store = router.register(route.path) store[route.method] = handler } export const medleyRouter: RouterInterface = { name, match: (route) => { const match = router.find(route.path) match.store[route.method] // get handler }, } ================================================ FILE: benchmarks/routers/src/memoirist.mts ================================================ import { Memoirist } from 'memoirist' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'Memoirist' const router = new Memoirist() for (const route of routes) { router.add(route.method, route.path, handler) } export const memoiristRouter: RouterInterface = { name, match: (route) => { router.find(route.method, route.path) }, } ================================================ FILE: benchmarks/routers/src/radix3.mts ================================================ import { createRouter } from 'radix3' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'radix3' const router = createRouter() for (const route of routes) { router.insert(route.path, handler) } export const radix3Router: RouterInterface = { name, match: (route) => { router.lookup(route.path) }, } ================================================ FILE: benchmarks/routers/src/rou3.mts ================================================ import { addRoute, createRouter, findRoute } from 'rou3' import type { RouterInterface } from './tool.mts' import { handler, routes } from './tool.mts' const name = 'rou3' const router = createRouter() for (const route of routes) { addRoute(router, route.path, route.method, handler) } export const rou3Router: RouterInterface = { name, match: (route) => { findRoute(router, route.path, route.method, { ignoreParams: false, // Don't ignore params }) }, } ================================================ FILE: benchmarks/routers/src/tool.mts ================================================ export const handler = () => {} export type Route = { method: 'GET' | 'POST' path: string } export interface RouterInterface { name: string match: (route: Route) => unknown } export const routes: Route[] = [ { method: 'GET', path: '/user' }, { method: 'GET', path: '/user/comments' }, { method: 'GET', path: '/user/avatar' }, { method: 'GET', path: '/user/lookup/username/:username' }, { method: 'GET', path: '/user/lookup/email/:address' }, { method: 'GET', path: '/event/:id' }, { method: 'GET', path: '/event/:id/comments' }, { method: 'POST', path: '/event/:id/comment' }, { method: 'GET', path: '/map/:location/events' }, { method: 'GET', path: '/status' }, { method: 'GET', path: '/very/deeply/nested/route/hello/there' }, { method: 'GET', path: '/static/*' }, ] ================================================ FILE: benchmarks/routers/src/trek-router.mts ================================================ import TrekRouter from 'trek-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'trek-router' const router = new TrekRouter() for (const route of routes) { router.add(route.method, route.path, handler()) } export const trekRouter: RouterInterface = { name, match: (route) => { router.find(route.method, route.path) }, } ================================================ FILE: benchmarks/routers/tsconfig.json ================================================ { "compilerOptions": { "allowImportingTsExtensions": true, "esModuleInterop": true, "module": "NodeNext" }, "include": ["./src"] } ================================================ FILE: benchmarks/routers-deno/.vscode/settings.json ================================================ { "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": true }, "deno.enable": true } ================================================ FILE: benchmarks/routers-deno/README.md ================================================ # Router benchmarks Benchmark of the most commonly used HTTP routers. Tested routes: - [find-my-way](https://github.com/delvedor/find-my-way) - [koa-router](https://github.com/alexmingoia/koa-router) - [koa-tree-router](https://github.com/steambap/koa-tree-router) - [trek-router](https://www.npmjs.com/package/trek-router) - [@medley/router](https://www.npmjs.com/package/@medley/router) - [Hono RegExpRouter](https://github.com/honojs/hono) - [Hono TrieRouter](https://github.com/honojs/hono) For Deno: ``` deno run --allow-read --allow-run src/bench.mts ``` This project is heavily impaired by [delvedor/router-benchmark](https://github.com/delvedor/router-benchmark) ## License MIT ================================================ FILE: benchmarks/routers-deno/deno.json ================================================ { "imports": { "npm/": "https://unpkg.com/" } } ================================================ FILE: benchmarks/routers-deno/src/bench.mts ================================================ import { run, bench, group } from 'npm:mitata' import { findMyWayRouter } from './find-my-way.mts' import { regExpRouter, trieRouter, patternRouter } from './hono.mts' import { koaRouter } from './koa-router.mts' import { koaTreeRouter } from './koa-tree-router.mts' import { medleyRouter } from './medley-router.mts' import type { Route, RouterInterface } from './tool.mts' import { trekRouter } from './trek-router.mts' const routers: RouterInterface[] = [ regExpRouter, trieRouter, patternRouter, medleyRouter, findMyWayRouter, koaTreeRouter, trekRouter, koaRouter, ] medleyRouter.match({ method: 'GET', path: '/user' }) const routes: (Route & { name: string })[] = [ { name: 'short static', method: 'GET', path: '/user', }, { name: 'static with same radix', method: 'GET', path: '/user/comments', }, { name: 'dynamic route', method: 'GET', path: '/user/lookup/username/hey', }, { name: 'mixed static dynamic', method: 'GET', path: '/event/abcd1234/comments', }, { name: 'post', method: 'POST', path: '/event/abcd1234/comment', }, { name: 'long static', method: 'GET', path: '/very/deeply/nested/route/hello/there', }, { name: 'wildcard', method: 'GET', path: '/static/index.html', }, ] for (const route of routes) { group(`${route.name} - ${route.method} ${route.path}`, () => { for (const router of routers) { bench(router.name, async () => { router.match(route) }) } }) } group('all together', () => { for (const router of routers) { bench(router.name, async () => { for (const route of routes) { router.match(route) } }) } }) await run() ================================================ FILE: benchmarks/routers-deno/src/find-my-way.mts ================================================ import type { HTTPMethod } from 'npm:find-my-way' import findMyWay from 'npm:find-my-way' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'find-my-way' const router = findMyWay() for (const route of routes) { router.on(route.method as HTTPMethod, route.path, handler) } export const findMyWayRouter: RouterInterface = { name, match: (route) => { router.find(route.method as HTTPMethod, route.path) }, } ================================================ FILE: benchmarks/routers-deno/src/hono.mts ================================================ import { PatternRouter } from '../../../src/router/pattern-router/index.ts' import { RegExpRouter } from '../../../src/router/reg-exp-router/index.ts' import { TrieRouter } from '../../../src/router/trie-router/index.ts' import type { Router } from '../../../src/router.ts' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const createHonoRouter = (name: string, router: Router): RouterInterface => { for (const route of routes) { router.add(route.method, route.path, handler) } return { name: `Hono ${name}`, match: (route) => { router.match(route.method, route.path) }, } } export const regExpRouter = createHonoRouter('RegExpRouter', new RegExpRouter()) export const trieRouter = createHonoRouter('TrieRouter', new TrieRouter()) export const patternRouter = createHonoRouter('PatternRouter', new PatternRouter()) ================================================ FILE: benchmarks/routers-deno/src/koa-router.mts ================================================ import KoaRouter from 'npm:koa-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'koa-router' const router = new KoaRouter() for (const route of routes) { if (route.method === 'GET') { router.get(route.path.replace('*', '(.*)'), handler) } else { router.post(route.path, handler) } } export const koaRouter: RouterInterface = { name, match: (route) => { router.match(route.path, route.method) // only matching }, } ================================================ FILE: benchmarks/routers-deno/src/koa-tree-router.mts ================================================ import KoaRouter from 'npm:koa-tree-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'koa-tree-router' const router = new KoaRouter() for (const route of routes) { router.on(route.method, route.path.replace('*', '*foo'), handler) } export const koaTreeRouter: RouterInterface = { name, match: (route) => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore router.find(route.method, route.path) }, } ================================================ FILE: benchmarks/routers-deno/src/medley-router.mts ================================================ import Router from 'npm:@medley/router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = '@medley/router' const router = new Router() for (const route of routes) { const store = router.register(route.path) store[route.method] = handler } export const medleyRouter: RouterInterface = { name, match: (route) => { const match = router.find(route.path) match.store[route.method] // get handler }, } ================================================ FILE: benchmarks/routers-deno/src/tool.mts ================================================ export const handler = () => {} export type Route = { method: 'GET' | 'POST' path: string } export interface RouterInterface { name: string match: (route: Route) => unknown } export const routes: Route[] = [ { method: 'GET', path: '/user' }, { method: 'GET', path: '/user/comments' }, { method: 'GET', path: '/user/avatar' }, { method: 'GET', path: '/user/lookup/username/:username' }, { method: 'GET', path: '/user/lookup/email/:address' }, { method: 'GET', path: '/event/:id' }, { method: 'GET', path: '/event/:id/comments' }, { method: 'POST', path: '/event/:id/comment' }, { method: 'GET', path: '/map/:location/events' }, { method: 'GET', path: '/status' }, { method: 'GET', path: '/very/deeply/nested/route/hello/there' }, { method: 'GET', path: '/static/*' }, ] ================================================ FILE: benchmarks/routers-deno/src/trek-router.mts ================================================ import TrekRouter from 'npm:trek-router' import type { RouterInterface } from './tool.mts' import { routes, handler } from './tool.mts' const name = 'trek-router' const router = new TrekRouter() for (const route of routes) { router.add(route.method, route.path, handler()) } export const trekRouter: RouterInterface = { name, match: (route) => { router.find(route.method, route.path) }, } ================================================ FILE: benchmarks/utils/.gitignore ================================================ yarn.lock bun.lockb ================================================ FILE: benchmarks/utils/package.json ================================================ { "type": "module", "devDependencies": { "mitata": "^0.1.11" } } ================================================ FILE: benchmarks/utils/src/get-path.ts ================================================ import { run, group, bench } from 'mitata' bench('noop', () => {}) const request = new Request('http://localhost/about/me') group('getPath', () => { bench('slice + indexOf : w/o decodeURI', () => { const url = request.url const queryIndex = url.indexOf('?', 8) return url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) }) bench('regexp : w/o decodeURI', () => { const match = request.url.match(/^https?:\/\/[^/]+(\/[^?]*)/) return match ? match[1] : '' }) bench('slice + indexOf', () => { const url = request.url const queryIndex = url.indexOf('?', 8) const path = url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) return path.includes('%') ? decodeURIComponent(path) : path }) bench('slice + for-loop + flag', () => { const url = request.url const start = url.indexOf('/', 8) let i = start let hasPercentEncoding = false for (; i < url.length; i++) { const charCode = url.charCodeAt(i) if (charCode === 37) { // '%' hasPercentEncoding = true } else if (charCode === 63) { // '?' break } } return hasPercentEncoding ? decodeURIComponent(url.slice(start, i)) : url.slice(start, i) }) bench('slice + for-loop + immediate return', () => { const url = request.url const start = url.indexOf('/', 8) let i = start for (; i < url.length; i++) { const charCode = url.charCodeAt(i) if (charCode === 37) { // '%' // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately. // Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding. const queryIndex = url.indexOf('?', i) const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex) return decodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path) } else if (charCode === 63) { // '?' break } } return url.slice(start, i) }) }) run() ================================================ FILE: benchmarks/utils/src/loop.js ================================================ import { run, group, bench } from 'mitata' const arr = new Array(100000).fill(Math.random()) bench('noop', () => {}) group('loop', () => { bench('map', () => { const newArr = [] arr.map((e) => { newArr.push(e) }) }) bench('forEach', () => { const newArr = [] arr.forEach((e) => { newArr.push(e) }) }) bench('for of', () => { const newArr = [] for (const e of arr) { newArr.push(e) } }) bench('for', () => { const newArr = [] for (let i = 0; i < arr.length; i++) { newArr.push(arr[i]) } }) }) run() ================================================ FILE: benchmarks/webapp/.gitignore ================================================ yarn.lock ================================================ FILE: benchmarks/webapp/hono.js ================================================ import { Hono } from '../../dist/hono' //import { Hono } from 'hono' const hono = new Hono() hono.get('/user', (c) => c.text('User')) hono.get('/user/comments', (c) => c.text('User Comments')) hono.get('/user/avatar', (c) => c.text('User Avatar')) hono.get('/user/lookup/email/:address', (c) => c.text('User Lookup Email Address')) hono.get('/event/:id', (c) => c.text('Event')) hono.get('/event/:id/comments', (c) => c.text('Event Comments')) hono.post('/event/:id/comments', (c) => c.text('POST Event Comments')) hono.post('/status', (c) => c.text('Status')) hono.get('/very/deeply/nested/route/hello/there', (c) => c.text('Very Deeply Nested Route')) hono.get('/user/lookup/username/:username', (c) => { return new Response(`Hello ${c.req.param('username')}`) }) hono.fire() ================================================ FILE: benchmarks/webapp/itty-router.js ================================================ import { Router } from 'itty-router' const ittyRouter = Router() ittyRouter.get('/user', () => new Response('User')) ittyRouter.get('/user/comments', () => new Response('User Comments')) ittyRouter.get('/user/avatar', () => new Response('User Avatar')) ittyRouter.get('/user/lookup/email/:address', () => new Response('User Lookup Email Address')) ittyRouter.get('/event/:id', () => new Response('Event')) ittyRouter.get('/event/:id/comments', () => new Response('Event Comments')) ittyRouter.post('/event/:id/comments', () => new Response('POST Event Comments')) ittyRouter.post('/status', () => new Response('Status')) ittyRouter.get( '/very/deeply/nested/route/hello/there', () => new Response('Very Deeply Nested Route') ) ittyRouter.get('/user/lookup/username/:username', ({ params }) => { return new Response(`Hello ${params.username}`, { status: 200, headers: { 'Content-Type': 'text/plain;charset=UTF-8', }, }) }) addEventListener('fetch', (event) => event.respondWith(ittyRouter.handle(event.request))) ================================================ FILE: benchmarks/webapp/package.json ================================================ { "name": "webapp", "version": "1.0.0", "main": "index.js", "scripts": { "start:hono": "wrangler dev hono.js --local --port 8787", "start:itty-router": "wrangler dev itty-router.js --local --port 8788", "start:sunder": "wrangler dev sunder.js --local --port 8789" }, "license": "MIT", "dependencies": { "itty-router": "^2.6.1", "sunder": "^0.10.1" } } ================================================ FILE: benchmarks/webapp/sunder.js ================================================ import { Sunder, Router } from 'sunder' const sunderRouter = new Router() sunderRouter.get('/user', (ctx) => { ctx.response.body = 'User' }) sunderRouter.get('/user/comments', (ctx) => { ctx.response.body = 'User Comments' }) sunderRouter.get('/user/avatar', (ctx) => { ctx.response.body = 'User Avatar' }) sunderRouter.get('/user/lookup/email/:address', (ctx) => { ctx.response.body = 'User Lookup Email Address' }) sunderRouter.get('/event/:id', (ctx) => { ctx.response.body = 'Event' }) sunderRouter.get('/event/:id/comments', (ctx) => { ctx.response.body = 'Event Comments' }) sunderRouter.post('/event/:id/comments', (ctx) => { ctx.response.body = 'POST Event Comments' }) sunderRouter.post('/status', (ctx) => { ctx.response.body = 'Status' }) sunderRouter.get('/very/deeply/nested/route/hello/there', (ctx) => { ctx.response.body = 'Very Deeply Nested Route' }) //sunderRouter.get('/static/*', () => {}) sunderRouter.get('/user/lookup/username/:username', (ctx) => { ctx.response.body = `Hello ${ctx.params.username}` }) const sunderApp = new Sunder() sunderApp.use(sunderRouter.middleware) addEventListener('fetch', (event) => { event.respondWith(sunderApp.handle(event)) }) ================================================ FILE: build/build.ts ================================================ /* This script is heavily inspired by `built.ts` used in @kaze-style/react. https://github.com/taishinaritomi/kaze-style/blob/main/scripts/build.ts MIT License Copyright (c) 2022 Taishi Naritomi */ /// import arg from 'arg' import { $ } from 'bun' import { build, context } from 'esbuild' import type { Plugin, PluginBuild, BuildOptions } from 'esbuild' import * as glob from 'glob' import fs from 'fs' import path from 'path' import { removePrivateFields } from './remove-private-fields' import { validateExports } from './validate-exports' const args = arg({ '--watch': Boolean, }) const isWatch = args['--watch'] || false const readJsonExports = (path: string) => JSON.parse(fs.readFileSync(path, 'utf-8')).exports const [packageJsonExports, jsrJsonExports] = ['./package.json', './jsr.json'].map(readJsonExports) // Validate exports of package.json and jsr.json validateExports(packageJsonExports, jsrJsonExports, 'jsr.json') validateExports(jsrJsonExports, packageJsonExports, 'package.json') const entryPoints = glob.sync('./src/**/*.ts', { ignore: ['./src/**/*.test.ts', './src/mod.ts', './src/middleware.ts', './src/deno/**/*.ts'], }) /* This plugin is inspired by the following. https://github.com/evanw/esbuild/issues/622#issuecomment-769462611 */ const addExtension = (extension: string = '.js', fileExtension: string = '.ts'): Plugin => ({ name: 'add-extension', setup(build: PluginBuild) { build.onResolve({ filter: /.*/ }, (args) => { if (args.importer) { const p = path.join(args.resolveDir, args.path) let tsPath = `${p}${fileExtension}` let importPath = '' if (fs.existsSync(tsPath)) { importPath = args.path + extension } else { tsPath = path.join(args.resolveDir, args.path, `index${fileExtension}`) if (fs.existsSync(tsPath)) { if (args.path.endsWith('/')) { importPath = `${args.path}index${extension}` } else { importPath = `${args.path}/index${extension}` } } } return { path: importPath, external: true } } }) }, }) const commonOptions: BuildOptions = { entryPoints, logLevel: 'info', platform: 'node', } const cjsConfig: BuildOptions = { ...commonOptions, outbase: './src', outdir: './dist/cjs', format: 'cjs', } const esmConfig: BuildOptions = { ...commonOptions, bundle: true, outbase: './src', outdir: './dist', format: 'esm', plugins: [addExtension('.js')], } const runBuild = async (config: BuildOptions) => { if (isWatch) { const ctx = await context(config) await ctx.watch() } else { await build(config) } } await Promise.all([ runBuild(esmConfig), runBuild(cjsConfig), $`tsc ${ isWatch ? '-w' : '' } --emitDeclarationOnly --declaration --project tsconfig.build.json`.nothrow(), ]) // Remove #private fields const dtsEntries = glob.globSync('./dist/types/**/*.d.ts') await removePrivateFields(dtsEntries) ================================================ FILE: build/remove-private-fields.test.ts ================================================ /// import { parseSync } from 'oxc-parser' import { removePrivateFieldFromSourceCode } from './remove-private-fields' describe('removePrivateFields', () => { it('should remove private fields from declarations', () => { const sourceCode = ` import type { Result, Router } from '../../router'; export declare class PatternRouter implements Router { #private; name: string; add(method: string, path: string, handler: T): void; match(method: string, path: string): Result; } `.trim() const ast = parseSync('types.d.ts', sourceCode) const result = removePrivateFieldFromSourceCode(ast, sourceCode) expect(result).toBeDefined() // expected code should be same, but the `#private;` is replaced with spaces const expected = sourceCode.replace('#private;', ' '.repeat(9)) expect(result).toMatch(expected) }) }) ================================================ FILE: build/remove-private-fields.ts ================================================ import type { PropertyDefinition, ParseResult } from 'oxc-parser' import { parseSync, Visitor } from 'oxc-parser' import { readFile, writeFile } from 'fs/promises' export async function removePrivateFields(files: string[]) { const start = performance.now() const parsed = await Promise.all( files.map(async (file) => { const sourceCode = await readFile(file, 'utf-8') const ast = parseSync(file, sourceCode) return { file, sourceCode, ast } }) ) await Promise.all( parsed.map(async ({ file, sourceCode, ast }) => { const sourceCodeWithoutPrivateFields = removePrivateFieldFromSourceCode(ast, sourceCode) if (sourceCodeWithoutPrivateFields) { await writeFile(file, sourceCodeWithoutPrivateFields) } }) ) const end = performance.now() console.log(`Done removing private fields in ${(end - start).toFixed(2)}ms`) } export function removePrivateFieldFromSourceCode(ast: ParseResult, sourceCode: string) { const removals: PropertyDefinition[] = [] new Visitor({ ClassDeclaration: (node) => { node.body.body.forEach((elem) => { if (elem.type === 'PropertyDefinition' && elem.key.type === 'PrivateIdentifier') { removals.push(elem) } }) }, }).visit(ast.program) if (removals.length === 0) { return } let sourceCodeWithoutPrivateFields = sourceCode for (const elem of removals) { sourceCodeWithoutPrivateFields = removeRange( sourceCodeWithoutPrivateFields, elem.start, elem.end ) } return sourceCodeWithoutPrivateFields } function removeRange(str: string, start: number, end: number) { return str.slice(0, start) + ' '.repeat(end - start) + str.slice(end) } ================================================ FILE: build/validate-exports.test.ts ================================================ /// import { validateExports } from './validate-exports' const mockExports1 = { './a': './a.ts', './b': './b.ts', './c/a': './c.ts', './d/*': './d/*.ts', } const mockExports2 = { './a': './a.ts', './b': './b.ts', './c/a': './c.ts', './d/a': './d/a.ts', } const mockExports3 = { './a': './a.ts', './c/a': './c.ts', './d/*': './d/*.ts', } describe('validateExports', () => { it('Works', async () => { expect(() => validateExports(mockExports1, mockExports1, 'package.json')).not.toThrowError() expect(() => validateExports(mockExports1, mockExports2, 'jsr.json')).not.toThrowError() expect(() => validateExports(mockExports1, mockExports3, 'package.json')).toThrowError() }) }) ================================================ FILE: build/validate-exports.ts ================================================ export const validateExports = ( source: Record, target: Record, fileName: string ) => { const isEntryInTarget = (entry: string): boolean => { if (entry in target) { return true } // e.g., "./utils/*" -> "./utils" const wildcardPrefix = entry.replace(/\/\*$/, '') if (entry.endsWith('/*')) { return Object.keys(target).some( (targetEntry) => targetEntry.startsWith(wildcardPrefix + '/') && targetEntry !== wildcardPrefix ) } const separatedEntry = entry.split('/') while (separatedEntry.length > 0) { const pattern = `${separatedEntry.join('/')}/*` if (pattern in target) { return true } separatedEntry.pop() } return false } Object.keys(source).forEach((sourceEntry) => { if (!isEntryInTarget(sourceEntry)) { throw new Error(`Missing "${sourceEntry}" in '${fileName}'`) } }) } ================================================ FILE: bunfig.toml ================================================ [test] coverage = true coverageReporter = ["text", "lcov"] coverageDir = "coverage/raw/bun" ================================================ FILE: codecov.yml ================================================ # Edit "test.coverage.exclude" option in vitest.config.ts to exclude specific files from coverage reports. # We can also use "ignore" option in codecov.yml, but it is not recognized by vitest, so results may differ on local. coverage: status: patch: default: target: 80% informational: true # Don't fail the build even if coverage is below target project: default: target: 75% threshold: 1% ================================================ FILE: docs/CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, or offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at yusuke@kamawada.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or a series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of the community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. ================================================ FILE: docs/CONTRIBUTING.md ================================================ # Contribution Guide Contributions Welcome! We will be glad for your help. You can contribute in the following ways. - Create an Issue - Propose a new feature. Report a bug. - Pull Request - Fix a bug or typo. Refactor the code. - Create third-party middleware - See instructions below. - Share - Share your thoughts on the Blog, X, and others. - Make your application - Please try to use Hono. Note: This project is started by Yusuke Wada ([@yusukebe](https://github.com/yusukebe)) for the hobby proposal. It was just for fun. For now, this stance has not been changed basically. I want to write the code as I like. So, if you propose great ideas, but I do not appropriate them, the idea may not be accepted. Although, don't worry! Hono is tested well, polished by the contributors, and used by many developers. And I'll try my best to make Hono cool and hot, beautiful, and ultrafast. ## Installing dependencies The `honojs/hono` project uses [Bun](https://bun.sh/) as its package manager. Developers should install Bun. After that, please install the dependency environment. ```bash bun install --frozen-lockfile ``` ## PRs Please ensure your PR passes tests with `bun run test`. ## Third-party middleware Third-party middleware is not in the core. It is allowed to depend on other libraries or work only in specific environments, such as Cloudflare Workers. For example: - GraphQL Server middleware - Firebase Auth middleware - Sentry middleware You can make a third-party middleware by yourself. It may be under the "honojs organization" and distributed in the `@honojs` namespace. The monorepo "[honojs/middleware](https://github.com/honojs/middleware)" manages these middleware. If you want to do it, create an issue about your middleware. ## Local Development ```bash git clone git@github.com:honojs/hono.git && cd hono/.devcontainer && bun install --frozen-lockfile docker compose up -d --build docker compose exec hono bash ``` ================================================ FILE: docs/MIGRATION.md ================================================ # Migration Guide ## v4.3.11 to v4.4.0 ### `deno.land/x` to JSR There is no breaking change, but we no longer publish the module from `deno.land/x`. If you want to use Hono on Deno, use JSR instead of it. If you migrate, replace the path `deno.land/x` with JSR's. ```ts // From import { Hono } from 'https://deno.land/x/hono/mod.ts' // To import { Hono } from 'jsr:@hono/hono' ``` You can see more details on our website: https://hono.dev/getting-started/deno ## v3.12.x to v4.0.0 There are some breaking changes. ### Removal of deprecated features - AWS Lambda Adapter - `LambdaFunctionUrlRequestContext` is obsolete. Use `ApiGatewayRequestContextV2` instead. - Next.js Adapter - `hono/nextjs` is obsolete. Use `hono/vercel` instead. - Context - `c.jsonT()` is obsolete. Use `c.json()` instead. - Context - `c.stream()` and `c.streamText()` are obsolete. Use `stream()` and `streamText()` in `hono/streaming` instead. - Context - `c.env()` is obsolete. Use `getRuntimeKey()` in `hono/adapter` instead. - Hono - `app.showRoutes()` is obsolete. Use `showRoutes()` in `hono/dev` instead. - Hono - `app.routerName` is obsolete. Use `getRouterName()` in `hono/dev` instead. - Hono - `app.head()` is no longer used. `app.get()` implicitly handles the HEAD method. - Hono - `app.handleEvent()` is obsolete. Use `app.fetch()` instead. - HonoRequest - `req.cookie()` is obsolete. Use `getCookie()` in `hono/cookie` instead. - HonoRequest - `headers()`, `body()`, `bodyUsed()`, `integrity()`, `keepalive()`, `referrer()`, and `signal()` are obsolete. Use the methods in `req.raw` such as `req.raw.headers()`. ### `serveStatic` in Cloudflare Workers Adapter requires `manifest` If you use the Cloudflare Workers adapter's `serve-static`, you should specify the `manifest` option. ```ts import manifest from '__STATIC_CONTENT_MANIFEST' // ... app.use('/static/*', serveStatic({ root: './assets', manifest })) ``` ### Others - The default value of the `docType` option in JSX Renderer Middleware is now `true`. - `FC` in `hono/jsx` does not pass `children`. Use `PropsWithChildren`. - Some Mime Types are removed https://github.com/honojs/hono/pull/2119. - Types for chaining routes with middleware matter: https://github.com/honojs/hono/pull/2046. - Types for the validator matter: https://github.com/honojs/hono/pull/2130. ## v2.7.8 to v3.0.0 There are some breaking changes. In addition to the following, type mismatches may occur. ### `c.req` is now `HonoRequest` `c.req` becomes `HonoRequest`, not `Request`. Although APIs are almost the same, if you want to access `Request`, use `c.req.raw`. ```ts app.post('/', async (c) => { const metadata = c.req.raw.cf?.hostMetadata? ... }) ``` ### StaticRouter is obsolete You can't use `StaticRouter`. ### Validator has been changed Previous Validator Middleware is obsolete. You can still use `hono/validator`, but the API has been changed. See [the document](https://hono.dev). ### `serveStatic` is provided from the Adapter Serve Static Middleware is obsolete. Use Adapters instead. ```ts // For Cloudflare Workers import { serveStatic } from 'hono/cloudflare-workers' // For Bun // import { serveStatic } from 'hono/bun' // For Deno // import { serveStatic } from 'npm:hono/deno' // ... app.get('/static/*', serveStatic({ root: './' })) ``` ### `serveStatic` for Cloudflare Workers "Service Worker mode" is obsolete For Cloudflare Workers, the `serveStatic` is obsolete in Service Worker mode. Note: Service Worker mode is that using `app.fire()`. We recommend use "Module Worker" mode with `export default app`. ### Use `type` to define the Generics for `new Hono` You must use `type` to define the Generics for `new Hono`. Do not use `interface`. ```ts // Should use `type` type Bindings = { TOKEN: string } const app = new Hono<{ Bindings: Bindings }>() ``` ## v2.7.1 - v2.x.x ### Current Validator Middleware is deprecated At the next major version, Validator Middleware will be changed with "breaking changes". Therefore, the current Validator Middleware will be deprecated; please use 3rd-party Validator libraries such as [Zod](https://zod.dev) or [TypeBox](https://github.com/sinclairzx81/typebox). ```ts import * as z from 'zod' //... const schema = z.object({ title: z.string().max(100), }) app.post('/posts', async (c) => { const body = await c.req.parseBody() const res = schema.safeParse(body) if (!res.success) { return c.text('Invalid!', 400) } return c.text('Valid!') }) ``` ## v2.2.5 to v2.3.0 There is a breaking change associated with the security update. ### Basic Auth Middleware and Bearer Auth Middleware If you are using Basic Auth and Bearer Auth in your Handler (nested), change as follows: ```ts app.use('/auth/*', async (c, next) => { const auth = basicAuth({ username: c.env.USERNAME, password: c.env.PASSWORD }) return auth(c, next) // Older: `await auth(c, next)` }) ``` ## v2.0.9 to v2.1.0 There are two BREAKING CHANGES. ### `c.req.parseBody` does not parse JSON, text, and ArrayBuffer **DO NOT** use `c.req.parseBody` for parsing **JSON**, **text**, or **ArrayBuffer**. `c.req.parseBody` now only parses FormData with content type `multipart/form` or `application/x-www-form-urlencoded`. If you want to parse JSON, text, or ArrayBuffer, use `c.req.json()`, `c.req.text()`, or `c.req.arrayBuffer()`. ```ts // `multipart/form` or `application/x-www-form-urlencoded` const data = await c.req.parseBody() const jsonData = await c.req.json() // for JSON body const text = await c.req.text() // for text body const arrayBuffer = await c.req.arrayBuffer() // for ArrayBuffer ``` ### The arguments of Generics for `new Hono` have been changed Now, the constructor of "Hono" receives `Variables` and `Bindings`. "Bindings" is for types of environment variables for Cloudflare Workers. "Variables" is for types of `c.set`/`c.get` ```ts type Bindings = { KV: KVNamespace Storage: R2Bucket } type WebClient = { user: string pass: string } type Variables = { client: WebClient } const app = new Hono<{ Variables: Variables; Bindings: Bindings }>() app.get('/foo', (c) => { const client = c.get('client') // client is WebClient const kv = c.env.KV // kv is KVNamespace //... }) ``` ## v1.6.4 to v2.0.0 There are many BREAKING CHANGES. Please follow the instructions below. ### The way to import Middleware on Deno has been changed **DO NOT** import middleware from `hono/mod.ts`. ```ts import { Hono, poweredBy } from 'https://deno.land/x/hono/mod.ts' // <--- NG ``` `hono/mod.ts` does not export middleware. To import middleware, use `hono/middleware.ts`: ```ts import { Hono } from 'https://deno.land/x/hono/mod.ts' import { poweredBy, basicAuth } from 'https://deno.land/x/hono/middleware.ts' ``` ### Cookie middleware is obsolete **DO NOT** use `cookie` middleware. ```ts import { cookie } from 'hono/cookie' // <--- Obsolete! ``` You do not have to use Cookie middleware to parse or set cookies. They become default functions: ```ts // Parse cookie app.get('/entry/:id', (c) => { const value = c.req.cookie('name') ... }) ``` ```ts app.get('/', (c) => { c.cookie('delicious_cookie', 'choco') return c.text('Do you like cookie?') }) ``` ### Body parse middleware is obsolete **DO NOT** use `body-parse` middleware. ```ts import { bodyParse } from 'hono/body-parse' // <--- Obsolete! ``` You do not have to use Body parse middleware to parse the request body. Use `c.req.parseBody()` method instead. ```ts // Parse Request body app.post('', (c) => { const body = c.req.parseBody() ... }) ``` ### GraphQL Server middleware is obsolete **DO NOT** use `graphql-server` middleware. ```ts import { graphqlServer } from 'hono/graphql-server' // <--- Obsolete! ``` It might be distributed as third-party middleware. ### Mustache middleware is obsolete **DO NOT** use `mustache` middleware. ```ts import { mustache } from 'hono/mustache' // <--- Obsolete! ``` It will no longer be implemented. ================================================ FILE: eslint.config.mjs ================================================ import baseConfig from '@hono/eslint-config' import { defineConfig, globalIgnores } from 'eslint/config' // Disable all TypeScript rules that require type information const typeCheckedRules = { '@typescript-eslint/await-thenable': 'off', '@typescript-eslint/no-base-to-string': 'off', '@typescript-eslint/no-confusing-void-expression': 'off', '@typescript-eslint/no-duplicate-type-constituents': 'off', '@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-for-in-array': 'off', '@typescript-eslint/no-implied-eval': 'off', '@typescript-eslint/no-meaningless-void-operator': 'off', '@typescript-eslint/no-misused-promises': 'off', '@typescript-eslint/no-mixed-enums': 'off', '@typescript-eslint/no-redundant-type-constituents': 'off', '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'off', '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-unnecessary-template-expression': 'off', '@typescript-eslint/no-unnecessary-type-arguments': 'off', '@typescript-eslint/no-unnecessary-type-assertion': 'off', "@typescript-eslint/no-unnecessary-type-conversion": 'off', '@typescript-eslint/no-unsafe-argument': 'off', '@typescript-eslint/no-unsafe-assignment': 'off', '@typescript-eslint/no-unsafe-call': 'off', '@typescript-eslint/no-unsafe-enum-comparison': 'off', '@typescript-eslint/no-unsafe-member-access': 'off', '@typescript-eslint/no-unsafe-return': 'off', '@typescript-eslint/no-unsafe-unary-minus': 'off', '@typescript-eslint/only-throw-error': 'off', '@typescript-eslint/prefer-includes': 'off', '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/prefer-optional-chain': 'off', '@typescript-eslint/prefer-promise-reject-errors': 'off', '@typescript-eslint/prefer-reduce-type-parameter': 'off', '@typescript-eslint/prefer-regexp-exec': 'off', '@typescript-eslint/prefer-return-this-type': 'off', '@typescript-eslint/prefer-string-starts-ends-with': 'off', '@typescript-eslint/require-await': 'off', '@typescript-eslint/restrict-plus-operands': 'off', '@typescript-eslint/restrict-template-expressions': 'off', '@typescript-eslint/return-await': 'off', '@typescript-eslint/strict-boolean-expressions': 'off', '@typescript-eslint/unbound-method': 'off', '@typescript-eslint/use-unknown-in-catch-callback-variable': 'off', '@typescript-eslint/prefer-find': 'off', '@typescript-eslint/no-misused-spread': 'off', '@typescript-eslint/related-getter-setter-pairs': 'off', '@typescript-eslint/prefer-literal-enum-member': 'off', // Stylistic rules '@typescript-eslint/consistent-indexed-object-style': 'off', '@typescript-eslint/consistent-type-definitions': 'off', '@typescript-eslint/dot-notation': 'off', '@typescript-eslint/no-array-delete': 'off', '@typescript-eslint/no-confusing-non-null-assertion': 'off', '@typescript-eslint/no-deprecated': 'off', '@typescript-eslint/no-dynamic-delete': 'off', '@typescript-eslint/no-invalid-void-type': 'off', '@typescript-eslint/no-non-null-assertion': 'off', '@typescript-eslint/no-unnecessary-type-parameters': 'off', '@typescript-eslint/no-useless-constructor': 'off', "@typescript-eslint/no-useless-default-assignment": 'off', '@typescript-eslint/non-nullable-type-assertion-style': 'off', '@typescript-eslint/prefer-for-of': 'off', '@typescript-eslint/prefer-function-type': 'off', '@typescript-eslint/unified-signatures': 'off', '@typescript-eslint/consistent-generic-constructors': 'off', '@typescript-eslint/array-type': 'off', '@typescript-eslint/no-extraneous-class': 'off', } export default defineConfig(globalIgnores(['.wrangler', '**/coverage', '**/dist']), { extends: baseConfig, linterOptions: { reportUnusedDisableDirectives: 'error', reportUnusedInlineConfigs: 'error', }, rules: typeCheckedRules, }) ================================================ FILE: jsr.json ================================================ { "name": "@hono/hono", "version": "0.0.0", "compilerOptions": { "lib": ["dom", "dom.iterable", "deno.ns"] }, "unstable": ["sloppy-imports"], "exports": { ".": "./src/index.ts", "./request": "./src/request.ts", "./types": "./src/types.ts", "./hono-base": "./src/hono-base.ts", "./tiny": "./src/preset/tiny.ts", "./quick": "./src/preset/quick.ts", "./http-exception": "./src/http-exception.ts", "./basic-auth": "./src/middleware/basic-auth/index.ts", "./bearer-auth": "./src/middleware/bearer-auth/index.ts", "./body-limit": "./src/middleware/body-limit/index.ts", "./ip-restriction": "./src/middleware/ip-restriction/index.ts", "./cache": "./src/middleware/cache/index.ts", "./route": "./src/helper/route/index.ts", "./cookie": "./src/helper/cookie/index.ts", "./accepts": "./src/helper/accepts/index.ts", "./compress": "./src/middleware/compress/index.ts", "./context-storage": "./src/middleware/context-storage/index.ts", "./cors": "./src/middleware/cors/index.ts", "./csrf": "./src/middleware/csrf/index.ts", "./etag": "./src/middleware/etag/index.ts", "./trailing-slash": "./src/middleware/trailing-slash/index.ts", "./html": "./src/helper/html/index.ts", "./css": "./src/helper/css/index.ts", "./jsx": "./src/jsx/index.ts", "./jsx/jsx-dev-runtime": "./src/jsx/jsx-dev-runtime.ts", "./jsx/jsx-runtime": "./src/jsx/jsx-runtime.ts", "./jsx/streaming": "./src/jsx/streaming.ts", "./jsx-renderer": "./src/middleware/jsx-renderer/index.ts", "./jsx/dom": "./src/jsx/dom/index.ts", "./jsx/dom/jsx-dev-runtime": "./src/jsx/dom/jsx-dev-runtime.ts", "./jsx/dom/jsx-runtime": "./src/jsx/dom/jsx-runtime.ts", "./jsx/dom/client": "./src/jsx/dom/client.ts", "./jsx/dom/css": "./src/jsx/dom/css.ts", "./jsx/dom/server": "./src/jsx/dom/server.ts", "./jwt": "./src/middleware/jwt/jwt.ts", "./jwk": "./src/middleware/jwk/jwk.ts", "./timeout": "./src/middleware/timeout/index.ts", "./timing": "./src/middleware/timing/timing.ts", "./logger": "./src/middleware/logger/index.ts", "./method-override": "./src/middleware/method-override/index.ts", "./powered-by": "./src/middleware/powered-by/index.ts", "./pretty-json": "./src/middleware/pretty-json/index.ts", "./request-id": "./src/middleware/request-id/request-id.ts", "./language": "./src/middleware/language/language.ts", "./secure-headers": "./src/middleware/secure-headers/secure-headers.ts", "./combine": "./src/middleware/combine/index.ts", "./ssg": "./src/helper/ssg/index.ts", "./streaming": "./src/helper/streaming/index.ts", "./validator": "./src/validator/index.ts", "./router": "./src/router.ts", "./router/reg-exp-router": "./src/router/reg-exp-router/index.ts", "./router/smart-router": "./src/router/smart-router/index.ts", "./router/trie-router": "./src/router/trie-router/index.ts", "./router/pattern-router": "./src/router/pattern-router/index.ts", "./router/linear-router": "./src/router/linear-router/index.ts", "./client": "./src/client/index.ts", "./adapter": "./src/helper/adapter/index.ts", "./factory": "./src/helper/factory/index.ts", "./serve-static": "./src/middleware/serve-static/index.ts", "./cloudflare-workers": "./src/adapter/cloudflare-workers/index.ts", "./cloudflare-pages": "./src/adapter/cloudflare-pages/index.ts", "./deno": "./src/adapter/deno/index.ts", "./bun": "./src/adapter/bun/index.ts", "./aws-lambda": "./src/adapter/aws-lambda/index.ts", "./vercel": "./src/adapter/vercel/index.ts", "./netlify": "./src/adapter/netlify/index.ts", "./lambda-edge": "./src/adapter/lambda-edge/index.ts", "./service-worker": "./src/adapter/service-worker/index.ts", "./testing": "./src/helper/testing/index.ts", "./dev": "./src/helper/dev/index.ts", "./ws": "./src/helper/websocket/index.ts", "./conninfo": "./src/helper/conninfo/index.ts", "./proxy": "./src/helper/proxy/index.ts", "./utils/body": "./src/utils/body.ts", "./utils/buffer": "./src/utils/buffer.ts", "./utils/color": "./src/utils/color.ts", "./utils/concurrent": "./src/utils/concurrent.ts", "./utils/cookie": "./src/utils/cookie.ts", "./utils/crypto": "./src/utils/crypto.ts", "./utils/encode": "./src/utils/encode.ts", "./utils/filepath": "./src/utils/filepath.ts", "./utils/handler": "./src/utils/handler.ts", "./utils/headers": "./src/utils/headers.ts", "./utils/html": "./src/utils/html.ts", "./utils/http-status": "./src/utils/http-status.ts", "./utils/accept": "./src/utils/accept.ts", "./utils/jwt": "./src/utils/jwt/index.ts", "./utils/jwt/jwa": "./src/utils/jwt/jwa.ts", "./utils/jwt/jws": "./src/utils/jwt/jws.ts", "./utils/jwt/jwt": "./src/utils/jwt/jwt.ts", "./utils/jwt/types": "./src/utils/jwt/types.ts", "./utils/jwt/utf8": "./src/utils/jwt/utf8.ts", "./utils/mime": "./src/utils/mime.ts", "./utils/stream": "./src/utils/stream.ts", "./utils/types": "./src/utils/types.ts", "./utils/url": "./src/utils/url.ts", "./utils/ipaddr": "./src/utils/ipaddr.ts" }, "publish": { "include": ["jsr.json", "LICENSE", "README.md", "src/**/*.ts"], "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"] } } ================================================ FILE: package.cjs.json ================================================ { "type": "commonjs" } ================================================ FILE: package.json ================================================ { "name": "hono", "version": "4.12.8", "description": "Web framework built on Web Standards", "main": "dist/cjs/index.js", "type": "module", "module": "dist/index.js", "types": "dist/types/index.d.ts", "files": [ "dist" ], "scripts": { "test": "tsc --noEmit && vitest --run", "test:watch": "vitest --watch", "test:deno": "deno test --allow-read --allow-env --allow-write --allow-net -c runtime-tests/deno/deno.json runtime-tests/deno && deno test --no-lock -c runtime-tests/deno-jsx/deno.precompile.json runtime-tests/deno-jsx && deno test --no-lock -c runtime-tests/deno-jsx/deno.react-jsx.json runtime-tests/deno-jsx", "test:bun": "bun test --jsx-import-source ../../src/jsx runtime-tests/bun/*", "test:fastly": "vitest --run --project fastly", "test:node": "vitest --run --project node", "test:workerd": "vitest --run --project workerd", "test:lambda": "vitest --run --project lambda", "test:lambda-edge": "vitest --run --project lambda-edge", "test:all": "bun run test && bun test:deno && bun test:bun", "lint": "eslint src runtime-tests build perf-measures benchmarks", "lint:fix": "eslint src runtime-tests build perf-measures benchmarks --fix", "format": "prettier --check --cache \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"", "format:fix": "prettier --write --cache --cache-strategy metadata \"src/**/*.{js,ts,tsx}\" \"runtime-tests/**/*.{js,ts,tsx}\" \"build/**/*.{js,ts,tsx}\" \"perf-measures/**/*.{js,ts,tsx}\" \"benchmarks/**/*.{js,ts,tsx}\"", "editorconfig-checker": "editorconfig-checker", "copy:package.cjs.json": "cp ./package.cjs.json ./dist/cjs/package.json && cp ./package.cjs.json ./dist/types/package.json", "build": "bun run --shell bun remove-dist && bun ./build/build.ts && bun run copy:package.cjs.json", "postbuild": "publint", "watch": "bun run --shell bun remove-dist && bun ./build/build.ts --watch && bun run copy:package.cjs.json", "coverage": "vitest --run --coverage", "prerelease": "bun test:deno && bun run build", "release": "np", "remove-dist": "rm -rf dist" }, "exports": { ".": { "types": "./dist/types/index.d.ts", "import": "./dist/index.js", "require": "./dist/cjs/index.js" }, "./request": { "types": "./dist/types/request.d.ts", "import": "./dist/request.js", "require": "./dist/cjs/request.js" }, "./types": { "types": "./dist/types/types.d.ts", "import": "./dist/types.js", "require": "./dist/cjs/types.js" }, "./hono-base": { "types": "./dist/types/hono-base.d.ts", "import": "./dist/hono-base.js", "require": "./dist/cjs/hono-base.js" }, "./tiny": { "types": "./dist/types/preset/tiny.d.ts", "import": "./dist/preset/tiny.js", "require": "./dist/cjs/preset/tiny.js" }, "./quick": { "types": "./dist/types/preset/quick.d.ts", "import": "./dist/preset/quick.js", "require": "./dist/cjs/preset/quick.js" }, "./http-exception": { "types": "./dist/types/http-exception.d.ts", "import": "./dist/http-exception.js", "require": "./dist/cjs/http-exception.js" }, "./basic-auth": { "types": "./dist/types/middleware/basic-auth/index.d.ts", "import": "./dist/middleware/basic-auth/index.js", "require": "./dist/cjs/middleware/basic-auth/index.js" }, "./bearer-auth": { "types": "./dist/types/middleware/bearer-auth/index.d.ts", "import": "./dist/middleware/bearer-auth/index.js", "require": "./dist/cjs/middleware/bearer-auth/index.js" }, "./body-limit": { "types": "./dist/types/middleware/body-limit/index.d.ts", "import": "./dist/middleware/body-limit/index.js", "require": "./dist/cjs/middleware/body-limit/index.js" }, "./ip-restriction": { "types": "./dist/types/middleware/ip-restriction/index.d.ts", "import": "./dist/middleware/ip-restriction/index.js", "require": "./dist/cjs/middleware/ip-restriction/index.js" }, "./cache": { "types": "./dist/types/middleware/cache/index.d.ts", "import": "./dist/middleware/cache/index.js", "require": "./dist/cjs/middleware/cache/index.js" }, "./route": { "types": "./dist/types/helper/route/index.d.ts", "import": "./dist/helper/route/index.js", "require": "./dist/cjs/helper/route/index.js" }, "./cookie": { "types": "./dist/types/helper/cookie/index.d.ts", "import": "./dist/helper/cookie/index.js", "require": "./dist/cjs/helper/cookie/index.js" }, "./accepts": { "types": "./dist/types/helper/accepts/index.d.ts", "import": "./dist/helper/accepts/index.js", "require": "./dist/cjs/helper/accepts/index.js" }, "./compress": { "types": "./dist/types/middleware/compress/index.d.ts", "import": "./dist/middleware/compress/index.js", "require": "./dist/cjs/middleware/compress/index.js" }, "./context-storage": { "types": "./dist/types/middleware/context-storage/index.d.ts", "import": "./dist/middleware/context-storage/index.js", "require": "./dist/cjs/middleware/context-storage/index.js" }, "./cors": { "types": "./dist/types/middleware/cors/index.d.ts", "import": "./dist/middleware/cors/index.js", "require": "./dist/cjs/middleware/cors/index.js" }, "./csrf": { "types": "./dist/types/middleware/csrf/index.d.ts", "import": "./dist/middleware/csrf/index.js", "require": "./dist/cjs/middleware/csrf/index.js" }, "./etag": { "types": "./dist/types/middleware/etag/index.d.ts", "import": "./dist/middleware/etag/index.js", "require": "./dist/cjs/middleware/etag/index.js" }, "./trailing-slash": { "types": "./dist/types/middleware/trailing-slash/index.d.ts", "import": "./dist/middleware/trailing-slash/index.js", "require": "./dist/cjs/middleware/trailing-slash/index.js" }, "./html": { "types": "./dist/types/helper/html/index.d.ts", "import": "./dist/helper/html/index.js", "require": "./dist/cjs/helper/html/index.js" }, "./css": { "types": "./dist/types/helper/css/index.d.ts", "import": "./dist/helper/css/index.js", "require": "./dist/cjs/helper/css/index.js" }, "./jsx": { "types": "./dist/types/jsx/index.d.ts", "import": "./dist/jsx/index.js", "require": "./dist/cjs/jsx/index.js" }, "./jsx/jsx-dev-runtime": { "types": "./dist/types/jsx/jsx-dev-runtime.d.ts", "import": "./dist/jsx/jsx-dev-runtime.js", "require": "./dist/cjs/jsx/jsx-dev-runtime.js" }, "./jsx/jsx-runtime": { "types": "./dist/types/jsx/jsx-runtime.d.ts", "import": "./dist/jsx/jsx-runtime.js", "require": "./dist/cjs/jsx/jsx-runtime.js" }, "./jsx/streaming": { "types": "./dist/types/jsx/streaming.d.ts", "import": "./dist/jsx/streaming.js", "require": "./dist/cjs/jsx/streaming.js" }, "./jsx-renderer": { "types": "./dist/types/middleware/jsx-renderer/index.d.ts", "import": "./dist/middleware/jsx-renderer/index.js", "require": "./dist/cjs/middleware/jsx-renderer/index.js" }, "./jsx/dom": { "types": "./dist/types/jsx/dom/index.d.ts", "import": "./dist/jsx/dom/index.js", "require": "./dist/cjs/jsx/dom/index.js" }, "./jsx/dom/jsx-dev-runtime": { "types": "./dist/types/jsx/dom/jsx-dev-runtime.d.ts", "import": "./dist/jsx/dom/jsx-dev-runtime.js", "require": "./dist/cjs/jsx/dom/jsx-dev-runtime.js" }, "./jsx/dom/jsx-runtime": { "types": "./dist/types/jsx/dom/jsx-runtime.d.ts", "import": "./dist/jsx/dom/jsx-runtime.js", "require": "./dist/cjs/jsx/dom/jsx-runtime.js" }, "./jsx/dom/client": { "types": "./dist/types/jsx/dom/client.d.ts", "import": "./dist/jsx/dom/client.js", "require": "./dist/cjs/jsx/dom/client.js" }, "./jsx/dom/css": { "types": "./dist/types/jsx/dom/css.d.ts", "import": "./dist/jsx/dom/css.js", "require": "./dist/cjs/jsx/dom/css.js" }, "./jsx/dom/server": { "types": "./dist/types/jsx/dom/server.d.ts", "import": "./dist/jsx/dom/server.js", "require": "./dist/cjs/jsx/dom/server.js" }, "./jwt": { "types": "./dist/types/middleware/jwt/index.d.ts", "import": "./dist/middleware/jwt/index.js", "require": "./dist/cjs/middleware/jwt/index.js" }, "./jwk": { "types": "./dist/types/middleware/jwk/index.d.ts", "import": "./dist/middleware/jwk/index.js", "require": "./dist/cjs/middleware/jwk/index.js" }, "./timeout": { "types": "./dist/types/middleware/timeout/index.d.ts", "import": "./dist/middleware/timeout/index.js", "require": "./dist/cjs/middleware/timeout/index.js" }, "./timing": { "types": "./dist/types/middleware/timing/index.d.ts", "import": "./dist/middleware/timing/index.js", "require": "./dist/cjs/middleware/timing/index.js" }, "./logger": { "types": "./dist/types/middleware/logger/index.d.ts", "import": "./dist/middleware/logger/index.js", "require": "./dist/cjs/middleware/logger/index.js" }, "./method-override": { "types": "./dist/types/middleware/method-override/index.d.ts", "import": "./dist/middleware/method-override/index.js", "require": "./dist/cjs/middleware/method-override/index.js" }, "./powered-by": { "types": "./dist/types/middleware/powered-by/index.d.ts", "import": "./dist/middleware/powered-by/index.js", "require": "./dist/cjs/middleware/powered-by/index.js" }, "./pretty-json": { "types": "./dist/types/middleware/pretty-json/index.d.ts", "import": "./dist/middleware/pretty-json/index.js", "require": "./dist/cjs/middleware/pretty-json/index.js" }, "./request-id": { "types": "./dist/types/middleware/request-id/index.d.ts", "import": "./dist/middleware/request-id/index.js", "require": "./dist/cjs/middleware/request-id/index.js" }, "./language": { "types": "./dist/types/middleware/language/index.d.ts", "import": "./dist/middleware/language/index.js", "require": "./dist/cjs/middleware/language/index.js" }, "./secure-headers": { "types": "./dist/types/middleware/secure-headers/index.d.ts", "import": "./dist/middleware/secure-headers/index.js", "require": "./dist/cjs/middleware/secure-headers/index.js" }, "./combine": { "types": "./dist/types/middleware/combine/index.d.ts", "import": "./dist/middleware/combine/index.js", "require": "./dist/cjs/middleware/combine/index.js" }, "./ssg": { "types": "./dist/types/helper/ssg/index.d.ts", "import": "./dist/helper/ssg/index.js", "require": "./dist/cjs/helper/ssg/index.js" }, "./streaming": { "types": "./dist/types/helper/streaming/index.d.ts", "import": "./dist/helper/streaming/index.js", "require": "./dist/cjs/helper/streaming/index.js" }, "./validator": { "types": "./dist/types/validator/index.d.ts", "import": "./dist/validator/index.js", "require": "./dist/cjs/validator/index.js" }, "./router": { "types": "./dist/types/router.d.ts", "import": "./dist/router.js", "require": "./dist/cjs/router.js" }, "./router/reg-exp-router": { "types": "./dist/types/router/reg-exp-router/index.d.ts", "import": "./dist/router/reg-exp-router/index.js", "require": "./dist/cjs/router/reg-exp-router/index.js" }, "./router/smart-router": { "types": "./dist/types/router/smart-router/index.d.ts", "import": "./dist/router/smart-router/index.js", "require": "./dist/cjs/router/smart-router/index.js" }, "./router/trie-router": { "types": "./dist/types/router/trie-router/index.d.ts", "import": "./dist/router/trie-router/index.js", "require": "./dist/cjs/router/trie-router/index.js" }, "./router/pattern-router": { "types": "./dist/types/router/pattern-router/index.d.ts", "import": "./dist/router/pattern-router/index.js", "require": "./dist/cjs/router/pattern-router/index.js" }, "./router/linear-router": { "types": "./dist/types/router/linear-router/index.d.ts", "import": "./dist/router/linear-router/index.js", "require": "./dist/cjs/router/linear-router/index.js" }, "./utils/jwt": { "types": "./dist/types/utils/jwt/index.d.ts", "import": "./dist/utils/jwt/index.js", "require": "./dist/cjs/utils/jwt/index.js" }, "./utils/*": { "types": "./dist/types/utils/*.d.ts", "import": "./dist/utils/*.js", "require": "./dist/cjs/utils/*.js" }, "./client": { "types": "./dist/types/client/index.d.ts", "import": "./dist/client/index.js", "require": "./dist/cjs/client/index.js" }, "./adapter": { "types": "./dist/types/helper/adapter/index.d.ts", "import": "./dist/helper/adapter/index.js", "require": "./dist/cjs/helper/adapter/index.js" }, "./factory": { "types": "./dist/types/helper/factory/index.d.ts", "import": "./dist/helper/factory/index.js", "require": "./dist/cjs/helper/factory/index.js" }, "./serve-static": { "types": "./dist/types/middleware/serve-static/index.d.ts", "import": "./dist/middleware/serve-static/index.js", "require": "./dist/cjs/middleware/serve-static/index.js" }, "./cloudflare-workers": { "types": "./dist/types/adapter/cloudflare-workers/index.d.ts", "import": "./dist/adapter/cloudflare-workers/index.js", "require": "./dist/cjs/adapter/cloudflare-workers/index.js" }, "./cloudflare-pages": { "types": "./dist/types/adapter/cloudflare-pages/index.d.ts", "import": "./dist/adapter/cloudflare-pages/index.js", "require": "./dist/cjs/adapter/cloudflare-pages/index.js" }, "./deno": { "types": "./dist/types/adapter/deno/index.d.ts", "import": "./dist/adapter/deno/index.js", "require": "./dist/cjs/adapter/deno/index.js" }, "./bun": { "types": "./dist/types/adapter/bun/index.d.ts", "import": "./dist/adapter/bun/index.js", "require": "./dist/cjs/adapter/bun/index.js" }, "./aws-lambda": { "types": "./dist/types/adapter/aws-lambda/index.d.ts", "import": "./dist/adapter/aws-lambda/index.js", "require": "./dist/cjs/adapter/aws-lambda/index.js" }, "./vercel": { "types": "./dist/types/adapter/vercel/index.d.ts", "import": "./dist/adapter/vercel/index.js", "require": "./dist/cjs/adapter/vercel/index.js" }, "./netlify": { "types": "./dist/types/adapter/netlify/index.d.ts", "import": "./dist/adapter/netlify/index.js", "require": "./dist/cjs/adapter/netlify/index.js" }, "./lambda-edge": { "types": "./dist/types/adapter/lambda-edge/index.d.ts", "import": "./dist/adapter/lambda-edge/index.js", "require": "./dist/cjs/adapter/lambda-edge/index.js" }, "./service-worker": { "types": "./dist/types/adapter/service-worker/index.d.ts", "import": "./dist/adapter/service-worker/index.js", "require": "./dist/cjs/adapter/service-worker/index.js" }, "./testing": { "types": "./dist/types/helper/testing/index.d.ts", "import": "./dist/helper/testing/index.js", "require": "./dist/cjs/helper/testing/index.js" }, "./dev": { "types": "./dist/types/helper/dev/index.d.ts", "import": "./dist/helper/dev/index.js", "require": "./dist/cjs/helper/dev/index.js" }, "./ws": { "types": "./dist/types/helper/websocket/index.d.ts", "import": "./dist/helper/websocket/index.js", "require": "./dist/cjs/helper/websocket/index.js" }, "./conninfo": { "types": "./dist/types/helper/conninfo/index.d.ts", "import": "./dist/helper/conninfo/index.js", "require": "./dist/cjs/helper/conninfo/index.js" }, "./proxy": { "types": "./dist/types/helper/proxy/index.d.ts", "import": "./dist/helper/proxy/index.js", "require": "./dist/cjs/helper/proxy/index.js" } }, "typesVersions": { "*": { "request": [ "./dist/types/request" ], "types": [ "./dist/types/types" ], "hono-base": [ "./dist/types/hono-base" ], "tiny": [ "./dist/types/preset/tiny" ], "quick": [ "./dist/types/preset/quick" ], "http-exception": [ "./dist/types/http-exception" ], "basic-auth": [ "./dist/types/middleware/basic-auth" ], "bearer-auth": [ "./dist/types/middleware/bearer-auth" ], "body-limit": [ "./dist/types/middleware/body-limit" ], "ip-restriction": [ "./dist/types/middleware/ip-restriction" ], "cache": [ "./dist/types/middleware/cache" ], "route": [ "./dist/types/helper/route" ], "cookie": [ "./dist/types/helper/cookie" ], "accepts": [ "./dist/types/helper/accepts" ], "compress": [ "./dist/types/middleware/compress" ], "context-storage": [ "./dist/types/middleware/context-storage" ], "cors": [ "./dist/types/middleware/cors" ], "csrf": [ "./dist/types/middleware/csrf" ], "etag": [ "./dist/types/middleware/etag" ], "trailing-slash": [ "./dist/types/middleware/trailing-slash" ], "html": [ "./dist/types/helper/html" ], "css": [ "./dist/types/helper/css" ], "jsx": [ "./dist/types/jsx" ], "jsx/jsx-runtime": [ "./dist/types/jsx/jsx-runtime.d.ts" ], "jsx/jsx-dev-runtime": [ "./dist/types/jsx/jsx-dev-runtime.d.ts" ], "jsx/streaming": [ "./dist/types/jsx/streaming.d.ts" ], "jsx-renderer": [ "./dist/types/middleware/jsx-renderer" ], "jsx/dom": [ "./dist/types/jsx/dom" ], "jsx/dom/client": [ "./dist/types/jsx/dom/client.d.ts" ], "jsx/dom/css": [ "./dist/types/jsx/dom/css.d.ts" ], "jsx/dom/server": [ "./dist/types/jsx/dom/server.d.ts" ], "jwt": [ "./dist/types/middleware/jwt" ], "timeout": [ "./dist/types/middleware/timeout" ], "timing": [ "./dist/types/middleware/timing" ], "logger": [ "./dist/types/middleware/logger" ], "method-override": [ "./dist/types/middleware/method-override" ], "powered-by": [ "./dist/types/middleware/powered-by" ], "pretty-json": [ "./dist/types/middleware/pretty-json" ], "request-id": [ "./dist/types/middleware/request-id" ], "language": [ "./dist/types/middleware/language" ], "streaming": [ "./dist/types/helper/streaming" ], "ssg": [ "./dist/types/helper/ssg" ], "secure-headers": [ "./dist/types/middleware/secure-headers" ], "combine": [ "./dist/types/middleware/combine" ], "validator": [ "./dist/types/validator/index.d.ts" ], "router": [ "./dist/types/router.d.ts" ], "router/reg-exp-router": [ "./dist/types/router/reg-exp-router/router.d.ts" ], "router/smart-router": [ "./dist/types/router/smart-router/router.d.ts" ], "router/trie-router": [ "./dist/types/router/trie-router/router.d.ts" ], "router/pattern-router": [ "./dist/types/router/pattern-router/router.d.ts" ], "router/linear-router": [ "./dist/types/router/linear-router/router.d.ts" ], "utils/jwt": [ "./dist/types/utils/jwt/index.d.ts" ], "utils/*": [ "./dist/types/utils/*" ], "client": [ "./dist/types/client/index.d.ts" ], "adapter": [ "./dist/types/helper/adapter/index.d.ts" ], "factory": [ "./dist/types/helper/factory/index.d.ts" ], "serve-static": [ "./dist/types/middleware/serve-static" ], "cloudflare-workers": [ "./dist/types/adapter/cloudflare-workers" ], "cloudflare-pages": [ "./dist/types/adapter/cloudflare-pages" ], "deno": [ "./dist/types/adapter/deno" ], "bun": [ "./dist/types/adapter/bun" ], "nextjs": [ "./dist/types/adapter/nextjs" ], "aws-lambda": [ "./dist/types/adapter/aws-lambda" ], "vercel": [ "./dist/types/adapter/vercel" ], "lambda-edge": [ "./dist/types/adapter/lambda-edge" ], "service-worker": [ "./dist/types/adapter/service-worker" ], "testing": [ "./dist/types/helper/testing" ], "dev": [ "./dist/types/helper/dev" ], "ws": [ "./dist/types/helper/websocket" ], "conninfo": [ "./dist/types/helper/conninfo" ], "proxy": [ "./dist/types/helper/proxy" ] } }, "author": "Yusuke Wada (https://github.com/yusukebe)", "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/honojs/hono.git" }, "publishConfig": { "registry": "https://registry.npmjs.org" }, "homepage": "https://hono.dev", "keywords": [ "hono", "web", "app", "http", "application", "framework", "router", "cloudflare", "workers", "fastly", "compute", "deno", "bun", "lambda", "nodejs" ], "devDependencies": { "@hono/eslint-config": "^2.1.0", "@hono/node-server": "^1.13.5", "@types/glob": "^9.0.0", "@types/jsdom": "^21.1.7", "@types/node": "^24.3.0", "@types/ws": "^8.18.1", "@typescript/native-preview": "7.0.0-dev.20260210.1", "@vitest/coverage-v8": "^3.2.4", "arg": "^5.0.2", "bun-types": "^1.2.20", "editorconfig-checker": "6.1.1", "esbuild": "^0.27.1", "eslint": "^9.39.3", "glob": "^11.0.0", "jsdom": "22.1.0", "msw": "^2.6.0", "np": "10.2.0", "oxc-parser": "^0.96.0", "pkg-pr-new": "^0.0.53", "prettier": "3.7.4", "publint": "0.3.15", "typescript": "^5.9.2", "undici": "^6.21.3", "vite-plugin-fastly-js-compute": "^0.4.2", "vitest": "^3.2.4", "wrangler": "4.12.0", "ws": "^8.18.0", "zod": "^3.23.8" }, "packageManager": "bun@1.2.20", "engines": { "node": ">=16.9.0" } } ================================================ FILE: perf-measures/.octocov.consolidated.perf-measures.main.yml ================================================ locale: 'en' repository: ${GITHUB_REPOSITORY}/perf-measures coverage: if: false codeToTestRatio: if: false testExecutionTime: if: false report: datastores: - artifact://${GITHUB_REPOSITORY} summary: if: true customMetrics: bundle-size-check: key: bundle-size-check speed-check: key: speed-check diagnostics-tsc: key: diagnostics-tsc diagnostics-typescript-go: key: diagnostics-typescript-go ================================================ FILE: perf-measures/.octocov.consolidated.perf-measures.yml ================================================ locale: 'en' repository: ${GITHUB_REPOSITORY}/perf-measures coverage: if: false codeToTestRatio: if: false testExecutionTime: if: false diff: datastores: - artifact://${GITHUB_REPOSITORY} comment: if: is_pull_request summary: if: true customMetrics: bundle-size-check: key: bundle-size-check diagnostics-tsc: key: diagnostics-tsc diagnostics-typescript-go: key: diagnostics-typescript-go ================================================ FILE: perf-measures/bundle-check/.gitignore ================================================ generated !generated/.gitkeep size.json ================================================ FILE: perf-measures/bundle-check/scripts/check-bundle-size.ts ================================================ import * as esbuild from 'esbuild' import * as fs from 'node:fs' import * as os from 'os' import * as path from 'path' async function main() { const tempDir = os.tmpdir() const tempFilePath = path.join(tempDir, 'bundle.tmp.js') try { await esbuild.build({ entryPoints: ['dist/index.js'], bundle: true, minify: true, format: 'esm' as esbuild.Format, target: 'es2022', outfile: tempFilePath, }) const bundleSize = fs.statSync(tempFilePath).size const metrics = [] metrics.push({ key: 'bundle-size-b', name: 'Bundle Size (B)', value: bundleSize, unit: 'B', }) metrics.push({ key: 'bundle-size-kb', name: 'Bundle Size (KB)', value: parseFloat((bundleSize / 1024).toFixed(2)), unit: 'K', }) const benchmark = { key: 'bundle-size-check', name: 'Bundle size check', metrics, } console.log(JSON.stringify(benchmark, null, 2)) } catch (error) { console.error('Build failed:', error) } finally { if (fs.existsSync(tempFilePath)) { fs.unlinkSync(tempFilePath) } } } main() ================================================ FILE: perf-measures/type-check/.gitignore ================================================ generated !generated/.gitkeep trace *result.txt diagnostics.json ================================================ FILE: perf-measures/type-check/client.ts ================================================ import { hc } from '../../src/client' import type { app } from './generated/app' // eslint-disable-next-line @typescript-eslint/no-unused-vars const client = hc('/') ================================================ FILE: perf-measures/type-check/scripts/generate-app.ts ================================================ import { writeFile } from 'node:fs' import * as path from 'node:path' const count = 200 const generateRoutes = (count: number) => { let routes = `import { Hono } from '../../../src' export const app = new Hono()` for (let i = 1; i <= count; i++) { routes += ` .get('/route${i}/:id', (c) => { return c.json({ ok: true }) })` } return routes } const routes = generateRoutes(count) writeFile(path.join(import.meta.dirname, '../generated/app.ts'), routes, (err) => { if (err) { throw err } console.log(`${count} routes have been written to app.ts`) }) ================================================ FILE: perf-measures/type-check/scripts/process-results.ts ================================================ import * as readline from 'node:readline' async function main() { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false, }) const tsImplLabel = process.env['BENCHMARK_TS_IMPL_LABEL'] if (!tsImplLabel) { throw new Error('BENCHMARK_TS_IMPL_LABEL must be set') } const toKebabCase = (str: string): string => { return str .replace(/([a-z])([A-Z])/g, '$1-$2') .replace(/[\s_\/]+/g, '-') .toLowerCase() } const metrics = [] for await (const line of rl) { if (!line || line.trim() === '') { continue } const [name, value] = line.split(':') const unitMatch = value?.trim().match(/^(\d+(\.\d+)?)([a-zA-Z]*)$/) if (unitMatch) { const [, number, , unit] = unitMatch metrics.push({ key: toKebabCase(name?.trim()), name: name?.trim(), value: parseFloat(number), unit: unit || undefined, }) } else { metrics.push({ key: toKebabCase(name?.trim()), name: name?.trim(), value: parseFloat(value?.trim()), }) } } const benchmark = { key: `diagnostics-${toKebabCase(tsImplLabel)}`, name: `Compiler Diagnostics (${tsImplLabel})`, metrics, } console.log(JSON.stringify(benchmark, null, 2)) } main() ================================================ FILE: perf-measures/type-check/scripts/tsconfig.json ================================================ { "extends": "../../../tsconfig.base.json", "compilerOptions": { "module": "esnext", "noEmit": true } } ================================================ FILE: perf-measures/type-check/tsconfig.build.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true }, "exclude": ["dist", "scripts"], "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/bun/.static/plain.txt ================================================ Bun!! ================================================ FILE: runtime-tests/bun/color.test.ts ================================================ import { expect, test } from 'bun:test' test('Bun.build compatibility test', async () => { try { const result = await Bun.build({ entrypoints: ['./src/utils/color.ts'], format: 'esm', minify: true, external: [], }) expect(result.success).toBe(true) expect(result.logs).toHaveLength(0) expect(result.outputs).toHaveLength(1) const outputContent = await result.outputs[0].text() expect(outputContent).toBeDefined() } catch (error) { throw new Error(`Bun.build failed: ${error}`) } }) ================================================ FILE: runtime-tests/bun/index.test.tsx ================================================ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import fs from 'fs/promises' import path from 'path' import { stream, streamSSE } from '../..//src/helper/streaming' import { serveStatic, toSSG } from '../../src/adapter/bun' import { createBunWebSocket } from '../../src/adapter/bun/websocket' import type { BunWebSocketData } from '../../src/adapter/bun/websocket' import { Context } from '../../src/context' import { env, getRuntimeKey } from '../../src/helper/adapter' import type { WSMessageReceive } from '../../src/helper/websocket' import { Hono } from '../../src/index' import type { PropsWithChildren } from '../../src/jsx' import { basicAuth } from '../../src/middleware/basic-auth' import { jwt } from '../../src/middleware/jwt' declare module '../../src/index' { interface ContextRenderer { (content: string | Promise, head: { title: string }): Response | Promise } } // Test just only minimal patterns. // Because others are tested well in Cloudflare Workers environment already. Bun.env.NAME = 'Bun' describe('Basic', () => { const app = new Hono() app.get('/a/:foo', (c) => { c.header('x-param', c.req.param('foo')) c.header('x-query', c.req.query('q')) return c.text('Hello Bun!') }) it('Should return 200 Response', async () => { const req = new Request('http://localhost/a/foo?q=bar') const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('Hello Bun!') expect(res.headers.get('x-param')).toBe('foo') expect(res.headers.get('x-query')).toBe('bar') }) it('returns current runtime (bun)', async () => { expect(getRuntimeKey()).toBe('bun') }) }) describe('Environment Variables', () => { it('Should return the environment variable', async () => { const c = new Context(new Request('http://localhost/')) const { NAME } = env<{ NAME: string }>(c) expect(NAME).toBe('Bun') }) }) describe('Basic Auth Middleware', () => { const app = new Hono() const username = 'hono-user-a' const password = 'hono-password-a' app.use( '/auth/*', basicAuth({ username, password, }) ) app.get('/auth/*', () => new Response('auth')) it('Should not authorize, return 401 Response', async () => { const req = new Request('http://localhost/auth/a') const res = await app.request(req) expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) it('Should authorize, return 200 Response', async () => { const credential = 'aG9uby11c2VyLWE6aG9uby1wYXNzd29yZC1h' const req = new Request('http://localhost/auth/a') req.headers.set('Authorization', `Basic ${credential}`) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('auth') }) }) describe('Serve Static Middleware', () => { const app = new Hono() const onNotFound = vi.fn(() => {}) app.all('/favicon.ico', serveStatic({ path: './runtime-tests/bun/favicon.ico' })) app.all( '/favicon-notfound.ico', serveStatic({ path: './runtime-tests/bun/favicon-notfound.ico', onNotFound }) ) app.use('/favicon-notfound.ico', async (c, next) => { await next() c.header('X-Custom', 'Bun') }) app.get( '/static/*', serveStatic({ root: './runtime-tests/bun/', onNotFound, }) ) app.get( '/dot-static/*', serveStatic({ root: './runtime-tests/bun/', rewriteRequestPath: (path) => path.replace(/^\/dot-static/, './.static'), }) ) app.all('/static-absolute-root/*', serveStatic({ root: path.dirname(__filename) })) beforeEach(() => onNotFound.mockClear()) it('Should return static file correctly', async () => { const res = await app.request(new Request('http://localhost/favicon.ico')) await res.arrayBuffer() expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('image/x-icon') }) it('Should return 404 response', async () => { const res = await app.request(new Request('http://localhost/favicon-notfound.ico')) expect(res.status).toBe(404) expect(res.headers.get('X-Custom')).toBe('Bun') expect(onNotFound).toHaveBeenCalledWith( process.platform === 'win32' ? 'runtime-tests\\bun\\favicon-notfound.ico' : 'runtime-tests/bun/favicon-notfound.ico', expect.anything() ) }) it('Should return 200 response - /static/plain.txt', async () => { const res = await app.request(new Request('http://localhost/static/plain.txt')) expect(res.status).toBe(200) expect(await res.text()).toMatch(/^Bun!(\r?\n)?$/) expect(onNotFound).not.toHaveBeenCalled() }) it('Should return 200 response - /static/download', async () => { const res = await app.request(new Request('http://localhost/static/download')) expect(res.status).toBe(200) expect(await res.text()).toMatch(/^download(\r?\n)?$/) expect(onNotFound).not.toHaveBeenCalled() }) it('Should return 200 response - /dot-static/plain.txt', async () => { const res = await app.request(new Request('http://localhost/dot-static/plain.txt')) expect(res.status).toBe(200) expect(await res.text()).toMatch(/^Bun!!(\r?\n)?$/) }) it('Should return 200 response - /static/helloworld', async () => { const res = await app.request('http://localhost/static/helloworld') expect(res.status).toBe(200) expect(await res.text()).toMatch(/Hi\r?\n/) }) it('Should return 200 response - /static/hello.world', async () => { const res = await app.request('http://localhost/static/hello.world') expect(res.status).toBe(200) expect(await res.text()).toMatch(/Hi\r?\n/) }) it('Should return 200 response - /static-absolute-root/plain.txt', async () => { const res = await app.request('http://localhost/static-absolute-root/plain.txt') expect(res.status).toBe(200) expect(await res.text()).toMatch(/^Bun!(\r?\n)?$/) expect(onNotFound).not.toHaveBeenCalled() }) }) // Bun support WebCrypto since v0.2.2 // So, JWT middleware works well. describe('JWT Auth Middleware', () => { const app = new Hono() app.use('/jwt/*', jwt({ secret: 'a-secret', alg: 'HS256' })) app.get('/jwt/a', (c) => c.text('auth')) it('Should not authorize, return 401 Response', async () => { const req = new Request('http://localhost/jwt/a') const res = await app.request(req) expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) it('Should authorize, return 200 Response', async () => { const credential = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const req = new Request('http://localhost/jwt/a') req.headers.set('Authorization', `Bearer ${credential}`) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('auth') }) }) // To enable JSX middleware, // set "jsxImportSource": "hono/jsx" in the tsconfig.json describe('JSX Middleware', () => { const app = new Hono() const Layout = (props: PropsWithChildren) => { return {props.children} } app.get('/', (c) => { return c.html(

Hello

) }) app.get('/nest', (c) => { return c.html(

Hello

) }) app.get('/layout', (c) => { return c.html(

hello

) }) it('Should return rendered HTML', async () => { const res = await app.request(new Request('http://localhost/')) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') expect(await res.text()).toBe('

Hello

') }) it('Should return rendered HTML with nest', async () => { const res = await app.request(new Request('http://localhost/nest')) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') expect(await res.text()).toBe('

Hello

') }) it('Should return rendered HTML with Layout', async () => { const res = await app.request(new Request('http://localhost/layout')) expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') expect(await res.text()).toBe('

hello

') }) }) describe('toSSG function', () => { let app: Hono beforeEach(() => { app = new Hono() app.get('/', (c) => c.text('Hello, World!')) app.get('/about', (c) => c.text('About Page')) app.get('/about/some', (c) => c.text('About Page 2tier')) app.post('/about/some/thing', (c) => c.text('About Page 3tier')) app.get('/bravo', (c) => c.html('Bravo Page')) app.get('/Charlie', async (c, next) => { c.setRenderer((content, head) => { return c.html( {head.title || ''}

{content}

) }) await next() }) app.get('/Charlie', (c) => { return c.render('Hello!', { title: 'Charlies Page' }) }) }) it('Should correctly generate static HTML files for Hono routes', async () => { const result = await toSSG(app, { dir: './static' }) expect(result.success).toBeTruthy() expect(result.error).toBeUndefined() expect(result.files).toBeDefined() afterAll(async () => { await deleteDirectory('./static') }) }) }) describe('WebSockets Helper', () => { const app = new Hono() const { websocket, upgradeWebSocket } = createBunWebSocket() it('Should websockets is working', async () => { const receivedMessagePromise = new Promise((resolve) => app.get( '/ws', upgradeWebSocket(() => ({ onMessage(evt) { resolve(evt.data) }, })) ) ) const upgradedData = await new Promise((resolve) => app.fetch(new Request('http://localhost/ws'), { upgrade: (_req: Request, data: { data: BunWebSocketData }) => { resolve(data.data) }, }) ) const message = Math.random().toString() websocket.message( { close: () => undefined, readyState: 3, data: upgradedData, send: () => undefined, }, message ) const receivedMessage = await receivedMessagePromise expect(receivedMessage).toBe(message) }) }) async function deleteDirectory(dirPath: string) { if ( await fs .stat(dirPath) .then((stat) => stat.isDirectory()) .catch(() => false) ) { for (const entry of await fs.readdir(dirPath)) { const entryPath = path.join(dirPath, entry) await deleteDirectory(entryPath) } await fs.rmdir(dirPath) } else { await fs.unlink(dirPath) } } describe('streaming', () => { const app = new Hono() let server: ReturnType let aborted = false app.get('/stream', (c) => { return stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) app.get('/streamHello', (c) => { return stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) app.get('/streamSSE', (c) => { return streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) app.get('/streamSSEHello', (c) => { return streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) beforeEach(() => { aborted = false server = Bun.serve({ port: 0, fetch: app.fetch }) }) afterEach(() => { server.stop() }) describe('stream', () => { it('Should call onAbort', async () => { const ac = new AbortController() const req = new Request(`http://localhost:${server.port}/stream`, { signal: ac.signal, }) expect(aborted).toBe(false) const res = fetch(req).catch(() => {}) await new Promise((resolve) => setTimeout(resolve, 10)) ac.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } expect(aborted).toBe(true) }) it('Should not be called onAbort if already closed', async () => { expect(aborted).toBe(false) const res = await fetch(`http://localhost:${server.port}/streamHello`) expect(await res.text()).toBe('Hello') expect(aborted).toBe(false) }) }) describe('streamSSE', () => { it('Should call onAbort', async () => { const ac = new AbortController() const req = new Request(`http://localhost:${server.port}/streamSSE`, { signal: ac.signal, }) const res = fetch(req).catch(() => {}) await new Promise((resolve) => setTimeout(resolve, 10)) ac.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } expect(aborted).toBe(true) }) it('Should not be called onAbort if already closed', async () => { expect(aborted).toBe(false) const res = await fetch(`http://localhost:${server.port}/streamSSEHello`) expect(await res.text()).toBe('Hello') expect(aborted).toBe(false) }) }) }) describe('Buffers', () => { const app = new Hono().get('/', async (c) => { return c.body(Buffer.from('hello')) }) it('should allow returning buffers', async () => { const res = await app.request(new Request('http://localhost/')) expect(res.status).toBe(200) expect(await res.text()).toBe('hello') }) }) ================================================ FILE: runtime-tests/bun/static/download ================================================ download ================================================ FILE: runtime-tests/bun/static/hello.world/index.html ================================================ Hi ================================================ FILE: runtime-tests/bun/static/helloworld/index.html ================================================ Hi ================================================ FILE: runtime-tests/bun/static/plain.txt ================================================ Bun! ================================================ FILE: runtime-tests/bun/static-absolute-root/plain.txt ================================================ Bun! ================================================ FILE: runtime-tests/bun/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "noEmit": true, "types": ["bun-types"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/deno/.static/plain.txt ================================================ Deno!! ================================================ FILE: runtime-tests/deno/.vscode/settings.json ================================================ { "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"], "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, "deno.enable": true } ================================================ FILE: runtime-tests/deno/deno.json ================================================ { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "lib": ["deno.ns", "dom", "dom.iterable"] }, "unstable": ["sloppy-imports"], "imports": { "@std/assert": "jsr:@std/assert@^1.0.3", "@std/path": "jsr:@std/path@^1.0.3", "@std/testing": "jsr:@std/testing@^1.0.1", "hono/jsx/jsx-runtime": "../../src/jsx/jsx-runtime.ts" } } ================================================ FILE: runtime-tests/deno/hono.test.ts ================================================ import { assertEquals } from '@std/assert' import { Context } from '../../src/context.ts' import { env, getRuntimeKey } from '../../src/helper/adapter/index.ts' import { Hono } from '../../src/hono.ts' // Test just only minimal patterns. // Because others are tested well in Cloudflare Workers environment already. Deno.env.set('NAME', 'Deno') Deno.test('Hello World', async () => { const app = new Hono() app.get('/:foo', (c) => { c.header('x-param', c.req.param('foo')) c.header('x-query', c.req.query('q') || '') return c.text('Hello Deno!') }) const res = await app.request('/foo?q=bar') assertEquals(res.status, 200) assertEquals(await res.text(), 'Hello Deno!') assertEquals(res.headers.get('x-param'), 'foo') assertEquals(res.headers.get('x-query'), 'bar') }) Deno.test('runtime', () => { assertEquals(getRuntimeKey(), 'deno') }) Deno.test('environment variables', () => { const c = new Context(new Request('http://localhost/')) const { NAME } = env<{ NAME: string }>(c) assertEquals(NAME, 'Deno') }) ================================================ FILE: runtime-tests/deno/middleware.test.tsx ================================================ import { assertEquals, assertMatch } from '@std/assert' import { dirname, fromFileUrl } from '@std/path' import { assertSpyCall, assertSpyCalls, spy } from '@std/testing/mock' import { serveStatic } from '../../src/adapter/deno/index.ts' import { Hono } from '../../src/hono.ts' import { basicAuth } from '../../src/middleware/basic-auth/index.ts' import { jwt } from '../../src/middleware/jwt/index.ts' // Test just only minimal patterns. // Because others are already tested well in Cloudflare Workers environment. Deno.test('Basic Auth Middleware', async () => { const app = new Hono() const username = 'hono' const password = 'ahotproject' app.use( '/auth/*', basicAuth({ username, password, }) ) app.get('/auth/*', () => new Response('auth')) const res = await app.request('http://localhost/auth/a') assertEquals(res.status, 401) assertEquals(await res.text(), 'Unauthorized') const credential = 'aG9ubzphaG90cHJvamVjdA==' const req = new Request('http://localhost/auth/a') req.headers.set('Authorization', `Basic ${credential}`) const resOK = await app.request(req) assertEquals(resOK.status, 200) assertEquals(await resOK.text(), 'auth') const invalidCredential = 'G9ubzphY29vbHByb2plY3Q=' const req2 = new Request('http://localhost/auth/a') req2.headers.set('Authorization', `Basic ${invalidCredential}`) const resNG = await app.request(req2) assertEquals(resNG.status, 401) assertEquals(await resNG.text(), 'Unauthorized') }) Deno.test('JSX middleware', async () => { const app = new Hono() app.get('/', (c) => { return c.html(

Hello

) }) const res = await app.request('http://localhost/') assertEquals(res.status, 200) assertEquals(res.headers.get('Content-Type'), 'text/html; charset=UTF-8') assertEquals(await res.text(), '

Hello

') // Fragment const template = ( <>

1

2

) assertEquals(template.toString(), '

1

2

') }) Deno.test('Serve Static middleware', async () => { const app = new Hono() const onNotFound = spy(() => {}) app.all('/favicon.ico', serveStatic({ path: './runtime-tests/deno/favicon.ico' })) app.all( '/favicon-notfound.ico', serveStatic({ path: './runtime-tests/deno/favicon-notfound.ico', onNotFound }) ) app.use('/favicon-notfound.ico', async (c, next) => { await next() c.header('X-Custom', 'Deno') }) app.get( '/static/*', serveStatic({ root: './runtime-tests/deno', onNotFound, }) ) app.get( '/dot-static/*', serveStatic({ root: './runtime-tests/deno', rewriteRequestPath: (path) => path.replace(/^\/dot-static/, './.static'), }) ) app.get('/static-absolute-root/*', serveStatic({ root: dirname(fromFileUrl(import.meta.url)) })) let res = await app.request('http://localhost/favicon.ico') assertEquals(res.status, 200) assertEquals(res.headers.get('Content-Type'), 'image/x-icon') await res.body?.cancel() res = await app.request('http://localhost/favicon-notfound.ico') assertEquals(res.status, 404) assertMatch(res.headers.get('Content-Type') || '', /^text\/plain/) assertEquals(res.headers.get('X-Custom'), 'Deno') assertSpyCall(onNotFound, 0) res = await app.request('http://localhost/static/plain.txt') assertEquals(res.status, 200) assertMatch(await res.text(), /^Deno!(\r?\n)?$/) res = await app.request('http://localhost/static/download') assertEquals(res.status, 200) assertMatch(await res.text(), /^download(\r?\n)?$/) res = await app.request('http://localhost/dot-static/plain.txt') assertEquals(res.status, 200) assertMatch(await res.text(), /^Deno!!(\r?\n)?$/) assertSpyCalls(onNotFound, 1) res = await app.fetch({ method: 'GET', url: 'http://localhost/static/%2e%2e/static/plain.txt', } as Request) assertEquals(res.status, 404) assertEquals(await res.text(), '404 Not Found') res = await app.request('http://localhost/static/helloworld') assertEquals(res.status, 200) assertEquals(await res.text(), 'Hi\n') res = await app.request('http://localhost/static/hello.world') assertEquals(res.status, 200) assertEquals(await res.text(), 'Hi\n') res = await app.request('http://localhost/static-absolute-root/plain.txt') assertEquals(res.status, 200) assertMatch(await res.text(), /^Deno!(\r?\n)?$/) res = await app.request('http://localhost/static') assertEquals(res.status, 404) assertEquals(await res.text(), '404 Not Found') res = await app.request('http://localhost/static/dir') assertEquals(res.status, 404) assertEquals(await res.text(), '404 Not Found') res = await app.request('http://localhost/static/helloworld/nested') assertEquals(res.status, 404) assertEquals(await res.text(), '404 Not Found') res = await app.request('http://localhost/static/helloworld/../') assertEquals(res.status, 404) assertEquals(await res.text(), '404 Not Found') }) Deno.test('JWT Authentication middleware', async () => { const app = new Hono<{ Variables: { 'x-foo': string } }>() app.use('/*', async (c, next) => { await next() c.header('x-foo', c.get('x-foo') || '') }) app.use('/auth/*', jwt({ secret: 'a-secret', alg: 'HS256' })) app.get('/auth/*', (c) => { c.set('x-foo', 'bar') return new Response('auth') }) const req = new Request('http://localhost/auth/a') const res = await app.request(req) assertEquals(res.status, 401) assertEquals(await res.text(), 'Unauthorized') assertEquals(res.headers.get('x-foo'), '') const credential = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const reqOK = new Request('http://localhost/auth/a') reqOK.headers.set('Authorization', `Bearer ${credential}`) const resOK = await app.request(reqOK) assertEquals(resOK.status, 200) assertEquals(await resOK.text(), 'auth') assertEquals(resOK.headers.get('x-foo'), 'bar') }) ================================================ FILE: runtime-tests/deno/ssg.test.tsx ================================================ import { assertEquals } from '@std/assert' import { toSSG } from '../../src/adapter/deno/ssg.ts' import { Hono } from '../../src/hono.ts' Deno.test('toSSG function', async () => { const app = new Hono() app.get('/', (c) => c.text('Hello, World!')) app.get('/about', (c) => c.text('About Page')) app.get('/about/some', (c) => c.text('About Page 2tier')) app.post('/about/some/thing', (c) => c.text('About Page 3tier')) app.get('/bravo', (c) => c.html('Bravo Page')) app.get('/Charlie', async (c, next) => { c.setRenderer((content) => { return c.html(

{content}

) }) await next() }) app.get('/Charlie', (c) => { return c.render('Hello!') }) const result = await toSSG(app, { dir: './ssg-static' }) assertEquals(result.success, true) assertEquals(result.error, undefined) assertEquals(result.files !== undefined, true) await deleteDirectory('./ssg-static') }) async function deleteDirectory(dirPath: string): Promise { try { const stat = await Deno.stat(dirPath) if (stat.isDirectory) { for await (const dirEntry of Deno.readDir(dirPath)) { const entryPath = `${dirPath}/${dirEntry.name}` await deleteDirectory(entryPath) } await Deno.remove(dirPath) } else { await Deno.remove(dirPath) } } catch (error) { console.error(`Error deleting directory: ${error}`) } } ================================================ FILE: runtime-tests/deno/static/download ================================================ download ================================================ FILE: runtime-tests/deno/static/hello.world/index.html ================================================ Hi ================================================ FILE: runtime-tests/deno/static/helloworld/index.html ================================================ Hi ================================================ FILE: runtime-tests/deno/static/plain.txt ================================================ Deno! ================================================ FILE: runtime-tests/deno/static-absolute-root/plain.txt ================================================ Deno! ================================================ FILE: runtime-tests/deno/stream.test.ts ================================================ import { assertEquals } from '@std/assert' import { stream, streamSSE } from '../../src/helper/streaming/index.ts' import { Hono } from '../../src/hono.ts' Deno.test('Should call onAbort via stream', async () => { const app = new Hono() let streamStarted = false let aborted = false app.get('/stream', (c) => { return stream(c, (stream) => { streamStarted = true stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) const server = Deno.serve({ port: 0 }, app.fetch) const ac = new AbortController() const req = new Request(`http://localhost:${server.addr.port}/stream`, { signal: ac.signal, }) const res = fetch(req).catch(() => {}) assertEquals(aborted, false) while (!streamStarted) { await new Promise((resolve) => setTimeout(resolve, 10)) } ac.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } assertEquals(aborted, true) await server.shutdown() }) Deno.test('Should not call onAbort via stream if already closed', async () => { const app = new Hono() let aborted = false app.get('/stream', (c) => { return stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) const server = Deno.serve({ port: 0 }, app.fetch) assertEquals(aborted, false) const res = await fetch(`http://localhost:${server.addr.port}/stream`) assertEquals(await res.text(), 'Hello') assertEquals(aborted, false) await server.shutdown() }) Deno.test('Should call onAbort via streamSSE', async () => { const app = new Hono() let streamStarted = false let aborted = false app.get('/stream', (c) => { return streamSSE(c, (stream) => { streamStarted = true stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) const server = Deno.serve({ port: 0 }, app.fetch) const ac = new AbortController() const req = new Request(`http://localhost:${server.addr.port}/stream`, { signal: ac.signal, }) const res = fetch(req).catch(() => {}) assertEquals(aborted, false) while (!streamStarted) { await new Promise((resolve) => setTimeout(resolve, 10)) } ac.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } assertEquals(aborted, true) await server.shutdown() }) Deno.test('Should not call onAbort via streamSSE if already closed', async () => { const app = new Hono() let aborted = false app.get('/stream', (c) => { return streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) const server = Deno.serve({ port: 0 }, app.fetch) assertEquals(aborted, false) const res = await fetch(`http://localhost:${server.addr.port}/stream`) assertEquals(await res.text(), 'Hello') assertEquals(aborted, false) await server.shutdown() }) ================================================ FILE: runtime-tests/deno-jsx/deno.precompile.json ================================================ { "compilerOptions": { "jsx": "precompile", "jsxImportSource": "hono/jsx", "lib": ["deno.ns", "dom", "dom.iterable"] }, "unstable": ["sloppy-imports"], "imports": { "@std/assert": "jsr:@std/assert@^1.0.3", "hono/jsx/jsx-runtime": "../../src/jsx/jsx-runtime.ts" } } ================================================ FILE: runtime-tests/deno-jsx/deno.react-jsx.json ================================================ { "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "hono/jsx", "lib": ["deno.ns", "dom", "dom.iterable"] }, "unstable": ["sloppy-imports"], "imports": { "@std/assert": "jsr:@std/assert@^1.0.3", "hono/jsx/jsx-runtime": "../../src/jsx/jsx-runtime.ts" } } ================================================ FILE: runtime-tests/deno-jsx/jsx.test.tsx ================================================ /** @jsxImportSource ../../src/jsx */ import { assertEquals } from '@std/assert' import { Style, css } from '../../src/helper/css/index.ts' import { Suspense, renderToReadableStream } from '../../src/jsx/streaming.ts' import type { HtmlEscapedString } from '../../src/utils/html.ts' import { HtmlEscapedCallbackPhase, resolveCallback } from '../../src/utils/html.ts' Deno.test('JSX', () => { const Component = ({ name }: { name: string }) => {name} const html = (

'}> '} />

) assertEquals(html.toString(), '

<Hono>

') }) Deno.test('JSX: Fragment', () => { const fragment = ( <>

1

2

) assertEquals(fragment.toString(), '

1

2

') }) Deno.test('JSX: Empty Fragment', () => { const Component = () => <> const html = assertEquals(html.toString(), '') }) Deno.test('JSX: Async Component', async () => { const Component = async ({ name }: { name: string }) => new Promise((resolve) => setTimeout(() => resolve({name}), 10)) const stream = renderToReadableStream(
'} />
) const chunks: string[] = [] const textDecoder = new TextDecoder() // eslint-disable-next-line @typescript-eslint/no-explicit-any for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } assertEquals(chunks.join(''), '
<Hono>
') }) Deno.test('JSX: Suspense', async () => { const Content = () => { const content = new Promise((resolve) => setTimeout(() => resolve(

Hello

), 10) ) return content } const stream = renderToReadableStream( Loading...

}>
) const chunks: string[] = [] const textDecoder = new TextDecoder() // eslint-disable-next-line @typescript-eslint/no-explicit-any for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } assertEquals(chunks, [ '

Loading...

', ``, ]) }) Deno.test('JSX: css', async () => { const className = css` color: red; ` const html = (
' ) }) Deno.test('JSX: css with CSP nonce', async () => { const className = css` color: red; ` const html = (
' ) }) Deno.test('JSX: normalize key', async () => { const className =
const htmlFor =
const crossOrigin =
const httpEquiv =
const itemProp =
const fetchPriority =
const noModule =
const formAction =
assertEquals(className.toString(), '
') assertEquals(htmlFor.toString(), '
') assertEquals(crossOrigin.toString(), '
') assertEquals(httpEquiv.toString(), '
') assertEquals(itemProp.toString(), '
') assertEquals(fetchPriority.toString(), '
') assertEquals(noModule.toString(), '
') assertEquals(formAction.toString(), '
') }) Deno.test('JSX: null or undefined', async () => { const nullHtml =
const undefinedHtml =
// react-jsx :
// precompile :
// Extra whitespace is allowed because it is a specification. assertEquals(nullHtml.toString().replace(/\s+/g, ''), '
') assertEquals(undefinedHtml.toString().replace(/\s+/g, ''), '
') }) Deno.test('JSX: boolean attributes', async () => { const trueHtml =
const falseHtml =
// output is different, but semantics as HTML is the same, so both are OK // react-jsx :
// precompile :
assertEquals(trueHtml.toString().replace('=""', ''), '
') assertEquals(falseHtml.toString(), '
') }) Deno.test('JSX: number', async () => { const html =
assertEquals(html.toString(), '
') }) Deno.test('JSX: style', async () => { const html =
assertEquals(html.toString(), '
') }) ================================================ FILE: runtime-tests/fastly/index.test.ts ================================================ import { createHash } from 'crypto' import { getRuntimeKey } from '../../src/helper/adapter' import { Hono } from '../../src/index' import { basicAuth } from '../../src/middleware/basic-auth' import { jwt } from '../../src/middleware/jwt' declare global { var __fastlyComputeNodeDefaultCrypto: boolean | undefined } beforeAll(() => { vi.stubGlobal('fastly', true) vi.stubGlobal('navigator', undefined) }) afterAll(() => { vi.unstubAllGlobals() }) const app = new Hono() describe('Hello World', () => { app.get('/', (c) => c.text('Hello! Compute!')) app.get('/runtime-name', (c) => { return c.text(getRuntimeKey()) }) it('Should return 200', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello! Compute!') }) it('Should return the correct runtime name', async () => { const res = await app.request('http://localhost/runtime-name') expect(res.status).toBe(200) expect(await res.text()).toBe('fastly') }) }) describe('Basic Auth Middleware without `hashFunction`', () => { const app = new Hono() const username = 'hono-user-a' const password = 'hono-password-a' app.use( '/auth/*', basicAuth({ username, password, }) ) app.get('/auth/*', () => new Response('auth')) it('Should not authorize, return 401 Response', async () => { const req = new Request('http://localhost/auth/a') const res = await app.request(req) expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) }) describe('Basic Auth Middleware with `hashFunction`', () => { const app = new Hono() const username = 'hono-user-a' const password = 'hono-password-a' app.use( '/auth/*', basicAuth({ username, password, hashFunction: (m: string) => createHash('sha256').update(m).digest('hex'), }) ) app.get('/auth/*', () => new Response('auth')) it('Should not authorize, return 401 Response', async () => { const req = new Request('http://localhost/auth/a') const res = await app.request(req) expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) it('Should authorize, return 200 Response', async () => { const credential = 'aG9uby11c2VyLWE6aG9uby1wYXNzd29yZC1h' const req = new Request('http://localhost/auth/a') req.headers.set('Authorization', `Basic ${credential}`) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('auth') }) }) describe('JWT Auth Middleware does not work', () => { const app = new Hono() // Since nodejs 20 or later, global WebCrypto object becomes stable (experimental on nodejs 18) // but WebCrypto does not have compatibility with Fastly Compute runtime (lacking some objects/methods in Fastly) // so following test should run only be polyfill-ed via vite-plugin-fastly-js-compute plugin. // To confirm polyfill-ed or not, check __fastlyComputeNodeDefaultCrypto field is true. it.runIf(!globalThis.__fastlyComputeNodeDefaultCrypto)('Should throw error', () => { expect(() => { app.use('/jwt/*', jwt({ alg: 'HS256', secret: 'secret' })) }).toThrow(/`crypto.subtle.importKey` is undefined/) }) }) ================================================ FILE: runtime-tests/fastly/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/fastly/vitest.config.ts ================================================ import fastlyCompute from 'vite-plugin-fastly-js-compute' import { defineProject } from 'vitest/config' export default defineProject({ plugins: [fastlyCompute()], test: { globals: true, name: 'fastly', }, }) ================================================ FILE: runtime-tests/lambda/index.test.ts ================================================ import { Readable } from 'stream' import { ALBProcessor, EventV1Processor, EventV2Processor, getProcessor, handle, streamHandle, } from '../../src/adapter/aws-lambda/handler' import type { ALBProxyEvent, APIGatewayProxyEventV2, LatticeProxyEventV2, LambdaEvent, } from '../../src/adapter/aws-lambda/handler' import type { ApiGatewayRequestContext, ApiGatewayRequestContextV2, LatticeRequestContextV2, LambdaContext, } from '../../src/adapter/aws-lambda/types' import { getCookie, setCookie } from '../../src/helper/cookie' import { streamSSE, streamText } from '../../src/helper/streaming' import { Hono } from '../../src/hono' import { basicAuth } from '../../src/middleware/basic-auth' import './mock' type Bindings = { event: LambdaEvent lambdaContext: LambdaContext requestContext: ApiGatewayRequestContext | ApiGatewayRequestContextV2 } const testApiGatewayRequestContextV2 = { accountId: '123456789012', apiId: 'urlid', authentication: null, authorizer: { iam: { accessKey: 'AKIA...', accountId: '111122223333', callerId: 'AIDA...', cognitoIdentity: null, principalOrgId: null, userArn: 'arn:aws:iam::111122223333:user/example-user', userId: 'AIDA...', }, }, domainName: 'example.com', domainPrefix: '', http: { method: 'POST', path: '/my/path', protocol: 'HTTP/1.1', sourceIp: '123.123.123.123', userAgent: 'agent', }, requestId: 'id', routeKey: '$default', stage: '$default', time: '12/Mar/2020:19:03:58 +0000', timeEpoch: 1583348638390, customProperty: 'customValue', } const testLatticeRequestContext: LatticeRequestContextV2 = { serviceNetworkArn: 'arn:aws:vpc-lattice:us-east-1:111122223333:servicenetwork/sn-a1b2c3', serviceArn: 'arn:aws:vpc-lattice:us-east-1:111122223333:service/svc-a1b2c3', targetGroupArn: 'arn:aws:vpc-lattice:us-east-1:111122223333:targetgroup/tg-a1b2c3', region: 'us-east-1', timeEpoch: '1759915938150314', identity: { sourceVpcArn: 'arn:aws:ec2:us-east-1:111122223333:vpc/vpc-a1b2c3', }, } describe('AWS Lambda Adapter for Hono', () => { const app = new Hono<{ Bindings: Bindings }>() app.get('/', (c) => { return c.text('Hello Lambda!') }) app.get('/binary', (c) => { return c.body('Fake Image', 200, { 'Content-Type': 'image/png', }) }) app.post('/post', async (c) => { const body = (await c.req.parseBody()) as { message: string } return c.text(body.message) }) app.post('/post/binary', async (c) => { const body = await c.req.blob() return c.text(`${body.size} bytes`) }) const username = 'hono-user-a' const password = 'hono-password-a' app.use('/auth/*', basicAuth({ username, password })) app.get('/auth/abc', (c) => c.text('Good Night Lambda!')) app.get('/lambda-event', (c) => { const event = c.env.event return c.json(event) }) app.get('/lambda-context', (c) => { const fnctx = c.env.lambdaContext return c.json(fnctx) }) app.get('/custom-context/v1/apigw', (c) => { const lambdaContext = c.env.requestContext return c.json(lambdaContext) }) app.get('/custom-context/apigw', (c) => { const lambdaContext = c.env.event.requestContext return c.json(lambdaContext) }) app.get('/custom-context/v1/lambda', (c) => { const lambdaContext = c.env.requestContext return c.json(lambdaContext) }) app.get('/custom-context/lambda', (c) => { const lambdaContext = c.env.event.requestContext return c.json(lambdaContext) }) app.get('/query-params', (c) => { const queryParams = c.req.query() return c.json(queryParams) }) app.get('/multi-query-params', (c) => { const multiQueryParams = c.req.queries() return c.json(multiQueryParams) }) const testCookie1 = { key: 'id', value: crypto.randomUUID(), get serialized() { return `${this.key}=${this.value}; Path=/` }, } const testCookie2 = { key: 'secret', value: crypto.randomUUID(), get serialized() { return `${this.key}=${this.value}; Path=/` }, } app.post('/cookie', (c) => { setCookie(c, testCookie1.key, testCookie1.value) setCookie(c, testCookie2.key, testCookie2.value) return c.text('Cookies Set') }) app.get('/cookie', (c) => { const validCookies = getCookie(c, testCookie1.key) === testCookie1.value && getCookie(c, testCookie2.key) === testCookie2.value if (!validCookies) { return c.text('Invalid Cookies') } return c.text('Valid Cookies') }) app.post('/headers', (c) => { if (c.req.header('foo')?.includes('bar')) { return c.json({ message: 'ok' }) } return c.json({ message: 'fail' }, 400) }) const handler = handle(app) const testApiGatewayRequestContext = { accountId: '123456789012', apiId: 'id', authorizer: { claims: null, scopes: null, }, domainName: 'example.com', domainPrefix: 'id', extendedRequestId: 'request-id', httpMethod: 'GET', identity: { sourceIp: 'IP', userAgent: 'user-agent', }, path: '/my/path', protocol: 'HTTP/1.1', requestId: 'id=', requestTime: '04/Mar/2020:19:15:17 +0000', requestTimeEpoch: 1583349317135, resourcePath: '/', stage: '$default', customProperty: 'customValue', } const testALBRequestContext = { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a', }, } it('Should handle a GET request and return a 200 response', async () => { const event = { version: '1.0', resource: '/', httpMethod: 'GET', headers: { 'content-type': 'text/plain' }, path: '/', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Hello Lambda!') expect(response.headers['content-type']).toMatch(/^text\/plain/) expect(response.multiValueHeaders).toBeUndefined() expect(response.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response with binary', async () => { const event = { version: '1.0', resource: '/binary', httpMethod: 'GET', headers: {}, path: '/binary', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('RmFrZSBJbWFnZQ==') expect(response.headers['content-type']).toMatch(/^image\/png/) expect(response.multiValueHeaders).toBeUndefined() expect(response.isBase64Encoded).toBe(true) }) it('Should handle a GET request and return a 200 response (LambdaFunctionUrlEvent)', async () => { const event = { version: '2.0', routeKey: '$default', headers: { 'content-type': 'text/plain' }, rawPath: '/', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } testApiGatewayRequestContextV2.http.method = 'GET' const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Hello Lambda!') expect(response.headers['content-type']).toMatch(/^text\/plain/) expect(response.multiValueHeaders).toBeUndefined() expect(response.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response (ALBEvent)', async () => { const event = { headers: { 'content-type': 'text/plain' }, httpMethod: 'GET', path: '/', queryStringParameters: { query: '1234ABCD', }, body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Hello Lambda!') expect(response.headers['content-type']).toMatch(/^text\/plain/) expect(response.multiValueHeaders).toBeUndefined() expect(response.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response (LatticeProxyEvent)', async () => { const event: LatticeProxyEventV2 = { version: '2.0', path: '/?query=1234ABCD', method: 'GET', headers: { 'content-type': ['text/plain'] }, queryStringParameters: {}, body: null, isBase64Encoded: false, requestContext: testLatticeRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Hello Lambda!') expect(response.headers['content-type']).toMatch(/^text\/plain/) expect(response.multiValueHeaders).toBeUndefined() expect(response.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 404 response', async () => { const event = { version: '1.0', resource: '/nothing', httpMethod: 'GET', headers: { 'content-type': 'text/plain' }, path: '/nothing', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(404) }) it('Should handle a POST request and return a 200 response', async () => { const searchParam = new URLSearchParams() searchParam.append('message', 'Good Morning Lambda!') const event = { version: '1.0', resource: '/post', httpMethod: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, path: '/post', body: Buffer.from(searchParam.toString()).toString('base64'), isBase64Encoded: true, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Good Morning Lambda!') }) it('Should handle a POST request and return a 200 response (LambdaFunctionUrlEvent)', async () => { const searchParam = new URLSearchParams() searchParam.append('message', 'Good Morning Lambda!') const event = { version: '2.0', routeKey: '$default', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, rawPath: '/post', rawQueryString: '', body: Buffer.from(searchParam.toString()).toString('base64'), isBase64Encoded: true, requestContext: testApiGatewayRequestContextV2, } testApiGatewayRequestContextV2.http.method = 'POST' const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Good Morning Lambda!') }) it('Should handle a POST request with binary and return a 200 response', async () => { const array = new Uint8Array([0xc0, 0xff, 0xee]) const buffer = Buffer.from(array) const event = { version: '1.0', resource: '/post/binary', httpMethod: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, path: '/post/binary', body: buffer.toString('base64'), isBase64Encoded: true, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('3 bytes') }) it('Should handle a request and return a 401 response with Basic auth', async () => { const event = { version: '1.0', resource: '/auth/abc', httpMethod: 'GET', headers: { 'Content-Type': 'plain/text', }, path: '/auth/abc', body: null, isBase64Encoded: true, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(401) }) it('Should handle a request and return a 200 response with Basic auth', async () => { const credential = 'aG9uby11c2VyLWE6aG9uby1wYXNzd29yZC1h' const event = { version: '1.0', resource: '/auth/abc', httpMethod: 'GET', headers: { 'Content-Type': 'plain/text', Authorization: `Basic ${credential}`, }, path: '/auth/abc', body: null, isBase64Encoded: true, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(response.body).toBe('Good Night Lambda!') }) it('Should handle a GET request and return custom context', async () => { const event = { version: '1.0', resource: '/custom-context/apigw', httpMethod: 'GET', headers: { 'content-type': 'application/json' }, path: '/custom-context/apigw', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) expect(JSON.parse(response.body).customProperty).toEqual('customValue') }) it('Should handle a GET request and context', async () => { const event = { version: '1.0', resource: '/lambda-context', httpMethod: 'GET', headers: { 'content-type': 'application/json' }, path: '/lambda-context', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const context: LambdaContext = { callbackWaitsForEmptyEventLoop: false, functionName: 'myLambdaFunction', functionVersion: '1.0.0', invokedFunctionArn: 'arn:aws:lambda:us-west-2:123456789012:function:myLambdaFunction', memoryLimitInMB: '128', awsRequestId: 'c6af9ac6-a7b0-11e6-80f5-76304dec7eb7', logGroupName: '/aws/lambda/myLambdaFunction', logStreamName: '2016/11/14/[$LATEST]f2d4b21cfb33490da2e8f8ef79a483s4', getRemainingTimeInMillis: () => { return 60000 // 60 seconds }, } const response = await handler(event, context) expect(response.statusCode).toBe(200) expect(JSON.parse(response.body).callbackWaitsForEmptyEventLoop).toEqual(false) }) it('Should handle a POST request and return a 200 response with cookies set (APIGatewayProxyEvent V1 and V2)', async () => { const apiGatewayEvent = { version: '1.0', resource: '/cookie', httpMethod: 'POST', headers: { 'content-type': 'text/plain' }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const apiGatewayResponse = await handler(apiGatewayEvent) expect(apiGatewayResponse.statusCode).toBe(200) expect(apiGatewayResponse.multiValueHeaders).toHaveProperty('set-cookie', [ testCookie1.serialized, testCookie2.serialized, ]) const apiGatewayEventV2 = { version: '2.0', routeKey: '$default', httpMethod: 'POST', headers: { 'content-type': 'text/plain' }, rawPath: '/cookie', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } const apiGatewayResponseV2 = await handler(apiGatewayEventV2) expect(apiGatewayResponseV2.statusCode).toBe(200) expect(apiGatewayResponseV2).toHaveProperty('cookies', [ testCookie1.serialized, testCookie2.serialized, ]) }) it('Should handle a POST request and return a 200 response with cookies set (LatticeProxyEvent V2)', async () => { const latticeProxyEvent: LatticeProxyEventV2 = { version: '2.0', path: '/cookie', method: 'POST', headers: { 'content-type': ['text/plain'] }, queryStringParameters: {}, body: null, isBase64Encoded: false, requestContext: testLatticeRequestContext, } const latticeResponse = await handler(latticeProxyEvent) expect(latticeResponse.statusCode).toBe(200) expect(latticeResponse.headers).toHaveProperty( 'set-cookie', [testCookie1.serialized, testCookie2.serialized].join(', ') ) }) describe('headers', () => { describe('single-value headers', () => { it('Should extract single-value headers and return 200 (ALBProxyEvent)', async () => { const event = { body: '{}', httpMethod: 'POST', isBase64Encoded: false, path: '/headers', headers: { host: 'localhost', foo: 'bar', }, requestContext: testALBRequestContext, } const albResponse = await handler(event) expect(albResponse.statusCode).toBe(200) expect(albResponse.headers).toEqual( expect.objectContaining({ 'content-type': 'application/json', }) ) expect(albResponse.multiValueHeaders).toBeUndefined() }) it('Should extract single-value headers and return 200 (APIGatewayProxyEvent)', async () => { const apigatewayProxyEvent = { version: '1.0', resource: '/headers', httpMethod: 'POST', headers: { host: 'localhost', foo: 'bar', }, path: '/headers', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const apiGatewayResponseV2 = await handler(apigatewayProxyEvent) expect(apiGatewayResponseV2.statusCode).toBe(200) }) it('Should extract single-value headers and return 200 (APIGatewayProxyEventV2)', async () => { const apigatewayProxyV2Event = { version: '2.0', routeKey: '$default', headers: { host: 'localhost', foo: 'bar', }, rawPath: '/headers', rawQueryString: '', requestContext: testApiGatewayRequestContextV2, resource: '/headers', body: null, isBase64Encoded: false, } const apiGatewayResponseV2 = await handler(apigatewayProxyV2Event) expect(apiGatewayResponseV2.statusCode).toBe(200) }) }) describe('multi-value headers', () => { it('Should extract multi-value headers and return 200 (ALBProxyEvent)', async () => { const event = { body: '{}', httpMethod: 'POST', isBase64Encoded: false, path: '/headers', multiValueHeaders: { host: ['localhost'], foo: ['bar'], }, requestContext: testALBRequestContext, } const albResponse = await handler(event) expect(albResponse.statusCode).toBe(200) expect(albResponse.multiValueHeaders).toBeDefined() expect(albResponse.multiValueHeaders).toEqual( expect.objectContaining({ 'content-type': ['application/json'], }) ) }) it('Should extract multi-value headers and return 200 (APIGatewayProxyEvent)', async () => { const apigatewayProxyEvent = { version: '1.0', resource: '/headers', httpMethod: 'POST', headers: {}, multiValueHeaders: { host: ['localhost'], foo: ['bar'], }, path: '/headers', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const apiGatewayResponseV2 = await handler(apigatewayProxyEvent) expect(apiGatewayResponseV2.statusCode).toBe(200) }) it('Should extract multi-value headers and return 200 (LatticeProxyEvent)', async () => { const event: LatticeProxyEventV2 = { version: '2.0', path: '/headers', method: 'POST', headers: { host: ['localhost'], foo: ['bar'], }, queryStringParameters: {}, body: null, isBase64Encoded: false, requestContext: testLatticeRequestContext, } const response = await handler(event) expect(response.statusCode).toBe(200) }) }) }) it('Should handle a POST request and return a 200 response if cookies match (APIGatewayProxyEvent V1 and V2)', async () => { const apiGatewayEvent = { version: '1.0', resource: '/cookie', httpMethod: 'GET', headers: { 'content-type': 'text/plain', cookie: [testCookie1.serialized, testCookie2.serialized].join('; '), }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContext, } const apiGatewayResponse = await handler(apiGatewayEvent) expect(apiGatewayResponse.statusCode).toBe(200) expect(apiGatewayResponse.body).toBe('Valid Cookies') expect(apiGatewayResponse.headers['content-type']).toMatch(/^text\/plain/) expect(apiGatewayResponse.isBase64Encoded).toBe(false) const apiGatewayEventV2 = { version: '2.0', routeKey: '$default', headers: { 'content-type': 'text/plain' }, rawPath: '/cookie', cookies: [testCookie1.serialized, testCookie2.serialized], rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } testApiGatewayRequestContextV2.http.method = 'GET' const apiGatewayResponseV2 = await handler(apiGatewayEventV2) expect(apiGatewayResponseV2.statusCode).toBe(200) expect(apiGatewayResponseV2.body).toBe('Valid Cookies') expect(apiGatewayResponseV2.headers['content-type']).toMatch(/^text\/plain/) expect(apiGatewayResponseV2.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response if cookies match (ALBProxyEvent) with default headers', async () => { const albEventDefaultHeaders = { version: '1.0', resource: '/cookie', httpMethod: 'GET', headers: { 'content-type': 'text/plain', cookie: [testCookie1.serialized, testCookie2.serialized].join('; '), }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toBe('Valid Cookies') expect(albResponse.headers['content-type']).toMatch(/^text\/plain/) expect(albResponse.multiValueHeaders).toBeUndefined() expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response if cookies match (ALBProxyEvent) with multi value headers', async () => { const albEventMultiValueHeaders = { version: '1.0', resource: '/cookie', httpMethod: 'GET', multiValueHeaders: { 'content-type': ['text/plain'], cookie: [testCookie1.serialized, testCookie2.serialized], }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventMultiValueHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toBe('Valid Cookies') expect(albResponse.headers).toBeUndefined() expect(albResponse.multiValueHeaders['content-type']).toEqual([ expect.stringMatching(/^text\/plain/), ]) expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a POST request and return a 200 response with cookies (ALBProxyEvent) with default headers', async () => { const albEventDefaultHeaders = { version: '1.0', resource: '/cookie', httpMethod: 'POST', headers: { 'content-type': 'text/plain', cookie: [testCookie1.serialized, testCookie2.serialized].join(', '), }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toBe('Cookies Set') expect(albResponse.headers['content-type']).toMatch(/^text\/plain/) expect(albResponse.multiValueHeaders).toBeUndefined() expect(albResponse.headers['set-cookie']).toEqual( [testCookie1.serialized, testCookie2.serialized].join(', ') ) expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a POST request and return a 200 response with cookies (ALBProxyEvent) with multi value headers', async () => { const albEventDefaultHeaders = { version: '1.0', resource: '/cookie', httpMethod: 'POST', multiValueHeaders: { 'content-type': ['text/plain'], cookie: [testCookie1.serialized, testCookie2.serialized], }, path: '/cookie', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toBe('Cookies Set') expect(albResponse.headers).toBeUndefined() expect(albResponse.multiValueHeaders['set-cookie']).toEqual( expect.arrayContaining([testCookie1.serialized, testCookie2.serialized]) ) expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response with queryStringParameters (ALBProxyEvent)', async () => { const albEventDefaultHeaders = { resource: '/query-params', httpMethod: 'GET', headers: { 'content-type': 'application/json', }, queryStringParameters: { key1: 'value1', key2: 'value2', }, path: '/query-params', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toContain( JSON.stringify({ key1: 'value1', key2: 'value2', }) ) expect(albResponse.headers['content-type']).toMatch(/^application\/json/) expect(albResponse.multiValueHeaders).toBeUndefined() expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response with single value multiQueryStringParameters (ALBProxyEvent)', async () => { const albEventDefaultHeaders = { resource: '/query-params', httpMethod: 'GET', multiValueHeaders: { 'content-type': ['application/json'], }, multiValueQueryStringParameters: { key1: ['value1'], key2: ['value2'], }, path: '/query-params', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toContain( JSON.stringify({ key1: 'value1', key2: 'value2', }) ) expect(albResponse.headers).toBeUndefined() expect(albResponse.multiValueHeaders['content-type']).toEqual([ expect.stringMatching(/^application\/json/), ]) expect(albResponse.isBase64Encoded).toBe(false) }) it('Should handle a GET request and return a 200 response with multi value multiQueryStringParameters (ALBProxyEvent)', async () => { const albEventDefaultHeaders = { resource: '/query-params', httpMethod: 'GET', multiValueHeaders: { 'content-type': ['application/json'], }, multiValueQueryStringParameters: { key1: ['value1'], key2: ['value2', 'otherValue2'], }, path: '/multi-query-params', body: null, isBase64Encoded: false, requestContext: testALBRequestContext, } const albResponse = await handler(albEventDefaultHeaders) expect(albResponse.statusCode).toBe(200) expect(albResponse.body).toContain( JSON.stringify({ key1: ['value1'], key2: ['value2', 'otherValue2'], }) ) expect(albResponse.headers).toBeUndefined() expect(albResponse.multiValueHeaders['content-type']).toEqual([ expect.stringMatching(/^application\/json/), ]) expect(albResponse.isBase64Encoded).toBe(false) }) }) describe('streamHandle function', () => { const app = new Hono<{ Bindings: Bindings }>() app.get('/', (c) => { return c.text('Hello Lambda!') }) app.get('/stream/text', async (c) => { return streamText(c, async (stream) => { for (let i = 0; i < 3; i++) { await stream.writeln(`${i}`) await stream.sleep(1) } }) }) app.get('/sse', async (c) => { return streamSSE(c, async (stream) => { let id = 0 const maxIterations = 2 while (id < maxIterations) { const message = `Message\nIt is ${id}` await stream.writeSSE({ data: message, event: 'time-update', id: String(id++) }) await stream.sleep(10) } }) }) const handler = streamHandle(app) it('Should streamHandle a GET request and return a 200 response (LambdaFunctionUrlEvent)', async () => { const event = { headers: { 'content-type': ' binary/octet-stream' }, rawPath: '/stream/text', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } testApiGatewayRequestContextV2.http.method = 'GET' const mockReadableStream = new Readable({ // eslint-disable-next-line @typescript-eslint/no-empty-function read() {}, }) mockReadableStream.push('0\n') mockReadableStream.push('1\n') mockReadableStream.push('2\n') mockReadableStream.push('3\n') mockReadableStream.push(null) // EOF // @ts-expect-error should this be a ReadbleStream? await handler(event, mockReadableStream, vi.fn()) const chunks = [] for await (const chunk of mockReadableStream) { chunks.push(chunk) } expect(chunks.join('')).toContain('0\n1\n2\n3\n') }) it('Should handle a GET request for an SSE stream and return the correct chunks', async () => { const event = { headers: { 'content-type': 'text/event-stream' }, rawPath: '/sse', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } testApiGatewayRequestContextV2.http.method = 'GET' const mockReadableStream = new Readable({ // eslint-disable-next-line @typescript-eslint/no-empty-function read() {}, }) const initContentType = { 'Content-Type': 'application/vnd.awslambda.http-integration-response', } mockReadableStream.push(JSON.stringify(initContentType)) // Send JSON formatted response headers, followed by 8 NULL characters as a separator const httpResponseMetadata = { statusCode: 200, headers: { 'Custom-Header': 'value' }, cookies: ['session=abcd1234'], } const jsonResponsePrelude = JSON.stringify(httpResponseMetadata) + Buffer.alloc(8, 0).toString() mockReadableStream.push(jsonResponsePrelude) mockReadableStream.push('data: Message\ndata: It is 0\n\n') mockReadableStream.push('data: Message\ndata: It is 1\n\n') mockReadableStream.push(null) // EOF // @ts-expect-error should this be a ReadbleStream? await handler(event, mockReadableStream, vi.fn()) const chunks = [] for await (const chunk of mockReadableStream) { chunks.push(chunk) } // If you have chunks, you might want to convert them to strings before checking const output = Buffer.concat(chunks).toString() expect(output).toContain('data: Message\ndata: It is 0\n\n') expect(output).toContain('data: Message\ndata: It is 1\n\n') // Assertions for the newly added header and prelude expect(output).toContain('application/vnd.awslambda.http-integration-response') expect(output).toContain('Custom-Header') expect(output).toContain('session=abcd1234') // Check for JSON prelude and NULL sequence const nullSequence = '\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000' expect(output).toContain(jsonResponsePrelude.replace(nullSequence, '')) }) }) describe('getProcessor function', () => { it('Should return ALBProcessor for an ALBProxyEvent event', () => { const event: ALBProxyEvent = { httpMethod: 'GET', path: '/', body: null, isBase64Encoded: false, requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:us-east-1:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a', }, }, } const processor = getProcessor(event) expect(processor).toBeInstanceOf(ALBProcessor) }) it('Should return EventV1Processor for an event without requestContext', () => { const event = { httpMethod: 'GET', path: '/', body: null, isBase64Encoded: false, } // while LambdaEvent RequestContext property is mandatory, it can be absent when testing through invoke-api or AWS Console // in such cases, a V1 processor should be returned const processor = getProcessor(event as unknown as LambdaEvent) expect(processor).toBeInstanceOf(EventV1Processor) }) it('Should return EventV2Processor for an APIGatewayProxyEventV2 event', () => { const event: APIGatewayProxyEventV2 = { version: '2.0', routeKey: '$default', headers: { 'content-type': 'text/plain' }, rawPath: '/', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } const processor = getProcessor(event) expect(processor).toBeInstanceOf(EventV2Processor) }) }) ================================================ FILE: runtime-tests/lambda/mock.ts ================================================ import { vi } from 'vitest' import type { LambdaEvent } from '../../src/adapter/aws-lambda/handler' import type { LambdaContext } from '../../src/adapter/aws-lambda/types' type StreamifyResponseHandler = ( handlerFunc: ( event: LambdaEvent, responseStream: NodeJS.WritableStream, context: LambdaContext ) => Promise ) => (event: LambdaEvent, context: LambdaContext) => Promise const mockStreamifyResponse: StreamifyResponseHandler = (handlerFunc) => { return async (event, context) => { const mockWritableStream: NodeJS.WritableStream = new (require('stream').Writable)({ write(chunk: Buffer, _encoding: string, callback: () => void) { console.log('Writing chunk:', chunk.toString()) callback() }, final(callback: () => void) { console.log('Finalizing stream.') callback() }, }) mockWritableStream.on('finish', () => { console.log('Stream has finished') }) await handlerFunc(event, mockWritableStream, context) mockWritableStream.end() return mockWritableStream } } const awslambda = { streamifyResponse: mockStreamifyResponse, } vi.stubGlobal('awslambda', awslambda) ================================================ FILE: runtime-tests/lambda/stream-mock.ts ================================================ import { vi } from 'vitest' import { Writable } from 'node:stream' import type { APIGatewayProxyEvent, APIGatewayProxyEventV2, } from '../../src/adapter/aws-lambda/handler' import type { LambdaContext } from '../../src/adapter/aws-lambda/types' type StreamifyResponseHandler = ( handlerFunc: ( event: APIGatewayProxyEvent | APIGatewayProxyEventV2, responseStream: Writable, context: LambdaContext ) => Promise ) => (event: APIGatewayProxyEvent, context: LambdaContext) => Promise const mockStreamifyResponse: StreamifyResponseHandler = (handlerFunc) => { return async (event, context) => { const chunks: unknown[] = [] const mockWritableStream = new Writable({ write(chunk, _encoding, callback) { chunks.push(chunk) callback() }, }) // @ts-expect-error chunks property for testing mockWritableStream.chunks = chunks await handlerFunc(event, mockWritableStream, context) mockWritableStream.end() return mockWritableStream } } const awslambda = { streamifyResponse: mockStreamifyResponse, HttpResponseStream: { from: (stream: Writable, httpResponseMetadata: unknown): Writable => { stream.write(Buffer.from(JSON.stringify(httpResponseMetadata))) return stream }, }, } vi.stubGlobal('awslambda', awslambda) ================================================ FILE: runtime-tests/lambda/stream.test.ts ================================================ import { streamHandle } from '../../src/adapter/aws-lambda/handler' import type { LambdaEvent } from '../../src/adapter/aws-lambda/handler' import type { ApiGatewayRequestContext, ApiGatewayRequestContextV2, LambdaContext, } from '../../src/adapter/aws-lambda/types' import { Hono } from '../../src/hono' import './stream-mock' type Bindings = { event: LambdaEvent lambdaContext: LambdaContext requestContext: ApiGatewayRequestContext | ApiGatewayRequestContextV2 } const testApiGatewayRequestContextV2 = { accountId: '123456789012', apiId: 'urlid', authentication: null, authorizer: { iam: { accessKey: 'AKIA...', accountId: '111122223333', callerId: 'AIDA...', cognitoIdentity: null, principalOrgId: null, userArn: 'arn:aws:iam::111122223333:user/example-user', userId: 'AIDA...', }, }, domainName: 'example.com', domainPrefix: '', http: { method: 'GET', path: '/my/path', protocol: 'HTTP/1.1', sourceIp: '123.123.123.123', userAgent: 'agent', }, requestId: 'id', routeKey: '$default', stage: '$default', time: '12/Mar/2020:19:03:58 +0000', timeEpoch: 1583348638390, customProperty: 'customValue', } describe('streamHandle function', () => { const app = new Hono<{ Bindings: Bindings }>() app.get('/cookies', async (c) => { c.res.headers.append('Set-Cookie', 'cookie1=value1') c.res.headers.append('Set-Cookie', 'cookie2=value2') return c.text('Cookies Set') }) const handler = streamHandle(app) it('to write multiple cookies into the headers', async () => { const event = { headers: { 'content-type': 'text/plain' }, rawPath: '/cookies', rawQueryString: '', body: null, isBase64Encoded: false, requestContext: testApiGatewayRequestContextV2, } const stream = await handler(event, {} as LambdaContext, vi.fn()) const metadata = JSON.parse(stream.chunks[0].toString()) expect(metadata.cookies).toEqual(['cookie1=value1', 'cookie2=value2']) }) }) ================================================ FILE: runtime-tests/lambda/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/lambda/vitest.config.ts ================================================ import { defineProject } from 'vitest/config' export default defineProject({ test: { env: { NAME: 'Node', }, globals: true, name: 'lambda', }, }) ================================================ FILE: runtime-tests/lambda-edge/index.test.ts ================================================ import type { Callback, CloudFrontConfig, CloudFrontRequest, CloudFrontResponse, } from '../../src/adapter/lambda-edge/handler' import { handle } from '../../src/adapter/lambda-edge/handler' import { Hono } from '../../src/hono' import { basicAuth } from '../../src/middleware/basic-auth' type Bindings = { callback: Callback config: CloudFrontConfig request: CloudFrontRequest response: CloudFrontResponse } describe('Lambda@Edge Adapter for Hono', () => { const app = new Hono<{ Bindings: Bindings }>() app.get('/', (c) => { return c.text('Hello Lambda!') }) app.get('/binary', (c) => { return c.body('Fake Image', 200, { 'Content-Type': 'image/png', }) }) app.post('/post', async (c) => { const body = (await c.req.parseBody()) as { message: string } return c.text(body.message) }) app.get('/callback/request', async (c, next) => { await next() c.env.callback(null, c.env.request) }) app.get('/config/eventCheck', async (c, next) => { await next() if (c.env.config.eventType in ['viewer-request', 'origin-request']) { c.env.callback(null, c.env.request) } else { c.env.callback(null, c.env.response) } }) app.get('/callback/response', async (c, next) => { await next() c.env.callback(null, c.env.response) }) app.post('/post/binary', async (c) => { const body = await c.req.blob() return c.text(`${body.size} bytes`) }) const username = 'hono-user-a' const password = 'hono-password-a' app.use('/auth/*', basicAuth({ username, password })) app.get('/auth/abc', (c) => c.text('Good Night Lambda!')) app.get('/header/add', async (c, next) => { c.env.response.headers['Strict-Transport-Security'.toLowerCase()] = [ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubdomains; preload', }, ] c.env.response.headers['X-Custom'.toLowerCase()] = [ { key: 'X-Custom', value: 'Foo', }, ] await next() c.env.callback(null, c.env.response) }) const handler = handle(app) it('Should handle a GET request and return a 200 response (Lambda@Edge viewer request)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'd111111abcdef8.cloudfront.net', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-request', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { host: [ { key: 'Host', value: 'd111111abcdef8.cloudfront.net', }, ], 'user-agent': [ { key: 'User-Agent', value: 'curl/7.66.0', }, ], accept: [ { key: 'accept', value: '*/*', }, ], }, method: 'GET', querystring: '', uri: '/', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('Hello Lambda!') if (response.headers && response.headers['content-type']) { expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/) } else { throw new Error("'content-type' header is missing in the response") } }) it('Should handle a GET request and return a 200 response (Lambda@Edge origin request)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'd111111abcdef8.cloudfront.net', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'origin-request', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { 'x-forwarded-for': [ { key: 'X-Forwarded-For', value: '203.0.113.178', }, ], 'user-agent': [ { key: 'User-Agent', value: 'Amazon CloudFront', }, ], via: [ { key: 'Via', value: '2.0 2afae0d44e2540f472c0635ab62c232b.cloudfront.net (CloudFront)', }, ], host: [ { key: 'Host', value: 'example.org', }, ], 'cache-control': [ { key: 'Cache-Control', value: 'no-cache', }, ], }, method: 'GET', origin: { custom: { customHeaders: {}, domainName: 'example.org', keepaliveTimeout: 5, path: '', port: 443, protocol: 'https', readTimeout: 30, sslProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], }, }, querystring: '', uri: '/', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('Hello Lambda!') if (response.headers && response.headers['content-type']) { expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/) } else { throw new Error("'content-type' header is missing in the response") } }) it('Should handle a GET request and return a 200 response (Lambda@Edge viewer response)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'd111111abcdef8.cloudfront.net', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-response', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { host: [ { key: 'Host', value: 'd111111abcdef8.cloudfront.net', }, ], 'user-agent': [ { key: 'User-Agent', value: 'curl/7.66.0', }, ], accept: [ { key: 'accept', value: '*/*', }, ], }, method: 'GET', querystring: '', uri: '/', }, response: { headers: { 'access-control-allow-credentials': [ { key: 'Access-Control-Allow-Credentials', value: 'true', }, ], 'access-control-allow-origin': [ { key: 'Access-Control-Allow-Origin', value: '*', }, ], date: [ { key: 'Date', value: 'Mon, 13 Jan 2020 20:14:56 GMT', }, ], 'referrer-policy': [ { key: 'Referrer-Policy', value: 'no-referrer-when-downgrade', }, ], server: [ { key: 'Server', value: 'ExampleCustomOriginServer', }, ], 'x-content-type-options': [ { key: 'X-Content-Type-Options', value: 'nosniff', }, ], 'x-frame-options': [ { key: 'X-Frame-Options', value: 'DENY', }, ], 'x-xss-protection': [ { key: 'X-XSS-Protection', value: '1; mode=block', }, ], age: [ { key: 'Age', value: '2402', }, ], 'content-type': [ { key: 'Content-Type', value: 'text/html; charset=utf-8', }, ], 'content-length': [ { key: 'Content-Length', value: '9593', }, ], }, status: '200', statusDescription: 'OK', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('Hello Lambda!') if (response.headers && response.headers['content-type']) { expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/) } else { throw new Error("'content-type' header is missing in the response") } }) it('Should handle a GET request and return a 200 response (Lambda@Edge origin response)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'd111111abcdef8.cloudfront.net', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'origin-response', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { 'x-forwarded-for': [ { key: 'X-Forwarded-For', value: '203.0.113.178', }, ], 'user-agent': [ { key: 'User-Agent', value: 'Amazon CloudFront', }, ], via: [ { key: 'Via', value: '2.0 8f22423015641505b8c857a37450d6c0.cloudfront.net (CloudFront)', }, ], host: [ { key: 'Host', value: 'example.org', }, ], 'cache-control': [ { key: 'Cache-Control', value: 'no-cache', }, ], }, method: 'GET', origin: { custom: { customHeaders: {}, domainName: 'example.org', keepaliveTimeout: 5, path: '', port: 443, protocol: 'https', readTimeout: 30, sslProtocols: ['TLSv1', 'TLSv1.1', 'TLSv1.2'], }, }, querystring: '', uri: '/', }, response: { headers: { 'access-control-allow-credentials': [ { key: 'Access-Control-Allow-Credentials', value: 'true', }, ], 'access-control-allow-origin': [ { key: 'Access-Control-Allow-Origin', value: '*', }, ], date: [ { key: 'Date', value: 'Mon, 13 Jan 2020 20:12:38 GMT', }, ], 'referrer-policy': [ { key: 'Referrer-Policy', value: 'no-referrer-when-downgrade', }, ], server: [ { key: 'Server', value: 'ExampleCustomOriginServer', }, ], 'x-content-type-options': [ { key: 'X-Content-Type-Options', value: 'nosniff', }, ], 'x-frame-options': [ { key: 'X-Frame-Options', value: 'DENY', }, ], 'x-xss-protection': [ { key: 'X-XSS-Protection', value: '1; mode=block', }, ], 'content-type': [ { key: 'Content-Type', value: 'text/html; charset=utf-8', }, ], 'content-length': [ { key: 'Content-Length', value: '9593', }, ], }, status: '200', statusDescription: 'OK', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('Hello Lambda!') if (response.headers && response.headers['content-type']) { expect(response.headers['content-type'][0].value).toMatch(/^text\/plain/) } else { throw new Error("'content-type' header is missing in the response") } }) it('Should handle a GET request and return a 200 response with binary', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], }, method: 'GET', querystring: '', uri: '/binary', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('RmFrZSBJbWFnZQ==') // base64 encoded fake image if (response.headers && response.headers['content-type']) { expect(response.headers['content-type'][0].value).toMatch(/^image\/png/) } else { throw new Error("'content-type' header is missing in the response") } }) it('Should handle a GET request and return a 404 response', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], }, method: 'GET', querystring: '', uri: '/nothing', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('404') }) it('Should handle a POST request and return a 200 response', async () => { const searchParam = new URLSearchParams() searchParam.append('message', 'Good Morning Lambda!') const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'content-type': [ { key: 'Content-Type', value: 'application/x-www-form-urlencoded', }, ], }, method: 'POST', querystring: '', uri: '/post', body: { inputTruncated: false, action: 'read-only', encoding: 'base64', data: Buffer.from(searchParam.toString()).toString('base64'), }, }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('Good Morning Lambda!') }) it('Should handle a POST request with binary and return a 200 response', async () => { const array = new Uint8Array([0xc0, 0xff, 0xee]) const buffer = Buffer.from(array) const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'content-type': [ { key: 'Content-Type', value: 'application/x-www-form-urlencoded', }, ], }, method: 'POST', querystring: '', uri: '/post/binary', body: { inputTruncated: false, action: 'read-only', encoding: 'base64', data: buffer.toString('base64'), }, }, }, }, ], } const response = await handler(event) expect(response.status).toBe('200') expect(response.body).toBe('3 bytes') }) it('Should handle a request and return a 401 response with Basic auth', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'content-type': [ { key: 'Content-Type', value: 'plain/text', }, ], }, method: 'GET', querystring: '', uri: '/auth/abc', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('401') }) it('Should handle a request and return a 401 response with Basic auth', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'content-type': [ { key: 'Content-Type', value: 'plain/text', }, ], }, method: 'GET', querystring: '', uri: '/auth/abc', }, }, }, ], } const response = await handler(event) expect(response.status).toBe('401') }) it('Should call a callback to continue processing the request', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: {}, method: 'GET', querystring: '', uri: '/callback/request', }, }, }, ], } let called = false let requestClientIp = '' await handler(event, {}, (_err, result) => { if (result && 'clientIp' in result) { requestClientIp = result.clientIp } called = true }) expect(called).toBe(true) expect(requestClientIp).toBe('123.123.123.123') }) it('Should call a callback to continue processing the response', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-response', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'user-agent': [ { key: 'User-Agent', value: 'curl/7.66.0', }, ], accept: [ { key: 'accept', value: '*/*', }, ], }, method: 'GET', querystring: '', uri: '/callback/response', }, response: { headers: { 'access-control-allow-credentials': [ { key: 'Access-Control-Allow-Credentials', value: 'true', }, ], 'access-control-allow-origin': [ { key: 'Access-Control-Allow-Origin', value: '*', }, ], date: [ { key: 'Date', value: 'Mon, 13 Jan 2020 20:14:56 GMT', }, ], 'referrer-policy': [ { key: 'Referrer-Policy', value: 'no-referrer-when-downgrade', }, ], server: [ { key: 'Server', value: 'ExampleCustomOriginServer', }, ], 'x-content-type-options': [ { key: 'X-Content-Type-Options', value: 'nosniff', }, ], 'x-frame-options': [ { key: 'X-Frame-Options', value: 'DENY', }, ], 'x-xss-protection': [ { key: 'X-XSS-Protection', value: '1; mode=block', }, ], age: [ { key: 'Age', value: '2402', }, ], 'content-type': [ { key: 'Content-Type', value: 'text/html; charset=utf-8', }, ], 'content-length': [ { key: 'Content-Length', value: '9593', }, ], }, status: '200', statusDescription: 'OK', }, }, }, ], } interface CloudFrontHeaders { [name: string]: [ { key: string value: string }, ] } let called = false let headers: CloudFrontHeaders = {} await handler(event, {}, (_err, result) => { if (result && result.headers) { headers = result.headers as CloudFrontHeaders } called = true }) expect(called).toBe(true) expect(headers['access-control-allow-credentials']).toEqual([ { key: 'Access-Control-Allow-Credentials', value: 'true', }, ]) expect(headers['access-control-allow-origin']).toEqual([ { key: 'Access-Control-Allow-Origin', value: '*', }, ]) }) it('Should handle a GET request and add header (Lambda@Edge viewer response)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-response', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { host: [ { key: 'Host', value: 'example.com', }, ], 'user-agent': [ { key: 'User-Agent', value: 'curl/7.66.0', }, ], accept: [ { key: 'accept', value: '*/*', }, ], }, method: 'GET', querystring: '', uri: '/header/add', }, response: { headers: { 'access-control-allow-credentials': [ { key: 'Access-Control-Allow-Credentials', value: 'true', }, ], 'access-control-allow-origin': [ { key: 'Access-Control-Allow-Origin', value: '*', }, ], date: [ { key: 'Date', value: 'Mon, 13 Jan 2020 20:14:56 GMT', }, ], 'referrer-policy': [ { key: 'Referrer-Policy', value: 'no-referrer-when-downgrade', }, ], server: [ { key: 'Server', value: 'ExampleCustomOriginServer', }, ], 'x-content-type-options': [ { key: 'X-Content-Type-Options', value: 'nosniff', }, ], 'x-frame-options': [ { key: 'X-Frame-Options', value: 'DENY', }, ], 'x-xss-protection': [ { key: 'X-XSS-Protection', value: '1; mode=block', }, ], age: [ { key: 'Age', value: '2402', }, ], 'content-type': [ { key: 'Content-Type', value: 'text/html; charset=utf-8', }, ], 'content-length': [ { key: 'Content-Length', value: '9593', }, ], }, status: '200', statusDescription: 'OK', }, }, }, ], } interface CloudFrontHeaders { [name: string]: [ { key: string value: string }, ] } let called = false let headers: CloudFrontHeaders = {} await handler(event, {}, (_err, result) => { if (result && result.headers) { headers = result.headers as CloudFrontHeaders } called = true }) expect(called).toBe(true) expect(headers['strict-transport-security']).toEqual([ { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubdomains; preload', }, ]) expect(headers['x-custom']).toEqual([ { key: 'X-Custom', value: 'Foo', }, ]) }) it('Callback Event (Lambda@Edge response)', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-response', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '203.0.113.178', headers: { host: [ { key: 'Host', value: 'example.com', }, ], }, method: 'GET', querystring: '', uri: '/config/eventCheck', }, }, }, ], } let called = false await handler(event, {}, () => { called = true }) expect(called).toBe(true) }) it('Should return a response where bodyEncoding is "base64" with binary', async () => { const event = { Records: [ { cf: { config: { distributionDomainName: 'example.com', distributionId: 'EXAMPLE123', eventType: 'viewer-request', requestId: 'exampleRequestId', }, request: { clientIp: '123.123.123.123', headers: { host: [ { key: 'Host', value: 'example.com', }, ], }, method: 'GET', querystring: '', uri: '/binary', }, }, }, ], } const response = await handler(event) expect(response.body).toBe('RmFrZSBJbWFnZQ==') // base64 encoded "Fake Image" expect(response.bodyEncoding).toBe('base64') }) }) ================================================ FILE: runtime-tests/lambda-edge/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/lambda-edge/vitest.config.ts ================================================ import { defineProject } from 'vitest/config' export default defineProject({ test: { env: { NAME: 'Node', }, globals: true, name: 'lambda-edge', }, }) ================================================ FILE: runtime-tests/node/index.test.ts ================================================ import { createAdaptorServer, serve } from '@hono/node-server' import * as undici from 'undici' import { once } from 'node:events' import type { Server } from 'node:http' import type { AddressInfo } from 'node:net' import { Hono } from '../../src' import { Context } from '../../src/context' import { env, getRuntimeKey } from '../../src/helper/adapter' import { stream, streamSSE } from '../../src/helper/streaming' import { basicAuth } from '../../src/middleware/basic-auth' import { compress } from '../../src/middleware/compress' import { jwt } from '../../src/middleware/jwt' // Test only minimal patterns. // See for more tests and information. describe('Basic', () => { const app = new Hono() app.get('/', (c) => { return c.text('Hello! Node.js!') }) app.get('/runtime-name', (c) => { return c.text(getRuntimeKey()) }) const agent = createAgent(app) it('Should return 200 response', async () => { const res = await agent.get('/') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('Hello! Node.js!') }) it('Should return correct runtime name', async () => { const res = await agent.get('/runtime-name') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('node') }) }) describe('Environment Variables', () => { it('Should return the environment variable', async () => { const c = new Context(new Request('http://localhost/')) const { NAME } = env<{ NAME: string }>(c) expect(NAME).toBe('Node') }) }) describe('Basic Auth Middleware', () => { const app = new Hono() const username = 'hono-user-a' const password = 'hono-password-a' app.use( '/auth/*', basicAuth({ username, password, }) ) app.get('/auth/*', () => new Response('auth')) const agent = createAgent(app) it('Should not authorize, return 401 Response', async () => { const res = await agent.get('/auth/a') expect(res.status).toBe(401) await expect(res.text()).resolves.toBe('Unauthorized') }) it('Should authorize, return 200 Response', async () => { const credential = 'aG9uby11c2VyLWE6aG9uby1wYXNzd29yZC1h' const res = await agent.get('/auth/a', { headers: { Authorization: `Basic ${credential}` } }) expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('auth') }) }) describe('JWT Auth Middleware', () => { const app = new Hono() app.use('/jwt/*', jwt({ secret: 'a-secret', alg: 'HS256' })) app.get('/jwt/a', (c) => c.text('auth')) const agent = createAgent(app) it('Should not authorize, return 401 Response', async () => { const res = await agent.get('/jwt/a') expect(res.status).toBe(401) await expect(res.text()).resolves.toBe('Unauthorized') }) it('Should authorize, return 200 Response', async () => { const credential = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXNzYWdlIjoiaGVsbG8gd29ybGQifQ.B54pAqIiLbu170tGQ1rY06Twv__0qSHTA0ioQPIOvFE' const res = await agent.get('/jwt/a', { headers: { Authorization: `Bearer ${credential}` } }) expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('auth') }) }) describe('stream', () => { const app = new Hono() let aborted = false app.get('/stream', (c) => { return stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) app.get('/streamHello', (c) => { return stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) const agent = createAgent(app) beforeEach(() => { aborted = false }) it('Should call onAbort', async () => { const controller = new AbortController() const res = expect(agent.get('/stream', { signal: controller.signal })).rejects.toThrow( 'This operation was aborted' ) expect(aborted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 10)) controller.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } expect(aborted).toBe(true) }) it('Should not be called onAbort if already closed', async () => { expect(aborted).toBe(false) const res = await agent.get('/streamHello') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('Hello') expect(aborted).toBe(false) }) }) describe('streamSSE', () => { const app = new Hono() let aborted = false app.get('/stream', (c) => { return streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) return new Promise((resolve) => { stream.onAbort(resolve) }) }) }) app.get('/streamHello', (c) => { return streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.write('Hello') }) }) const agent = createAgent(app) beforeEach(() => { aborted = false }) it('Should call onAbort', async () => { const controller = new AbortController() const res = expect(agent.get('/stream', { signal: controller.signal })).rejects.toThrow( 'This operation was aborted' ) expect(aborted).toBe(false) await new Promise((resolve) => setTimeout(resolve, 10)) controller.abort() await res while (!aborted) { await new Promise((resolve) => setTimeout(resolve)) } expect(aborted).toBe(true) }) it('Should not be called onAbort if already closed', async () => { expect(aborted).toBe(false) const res = await agent.get('/streamHello') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('Hello') expect(aborted).toBe(false) }) }) describe('compress', async () => { const cssContent = Array.from({ length: 60 }, () => 'body { color: red; }').join('\n') const [externalServer, serverInfo] = await new Promise<[Server, AddressInfo]>((resolve) => { const externalApp = new Hono() externalApp.get('/style.css', (c) => c.text(cssContent, { headers: { 'Content-Type': 'text/css', }, }) ) const server = serve( { fetch: externalApp.fetch, port: 0, hostname: '0.0.0.0', }, (serverInfo) => { resolve([server as Server, serverInfo]) } ) }) const app = new Hono() app.use(compress()) app.get('/fetch/:file', (c) => { return fetch(`http://${serverInfo.address}:${serverInfo.port}/${c.req.param('file')}`) }) const agent = createAgent(app) afterAll(() => { externalServer.close() }) it('Should be compressed a fetch response', async () => { const res = await agent.get('/fetch/style.css') expect(res.status).toBe(200) expect(res.headers.get('content-encoding')).toBe('gzip') await expect(res.text()).resolves.toBe(cssContent) }) }) describe('Buffers', () => { const app = new Hono() .get('/', async (c) => { return c.body(Buffer.from('hello')) }) .get('/uint8array', async (c) => { return c.body(Uint8Array.from('hello'.split(''), (c) => c.charCodeAt(0))) }) const agent = createAgent(app) it('should allow returning buffers', async () => { const res = await agent.get('/') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('hello') }) it('should allow returning uint8array as well', async () => { const res = await agent.get('/uint8array') expect(res.status).toBe(200) await expect(res.text()).resolves.toBe('hello') }) }) function createAgent(app: Hono) { const server = createAdaptorServer(app) const listening = once(server.listen(), 'listening') return { async get(path: string, init?: undici.RequestInit) { await listening const url = new URL(path, getOrigin()) return undici.fetch(url, init) }, } function getOrigin(): string { let address = server.address() if (typeof address === 'object') { address = address?.port ? `http://localhost:${address.port}` : 'http://localhost' } return address } } ================================================ FILE: runtime-tests/node/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/node/vitest.config.ts ================================================ import { defineProject } from 'vitest/config' export default defineProject({ test: { env: { NAME: 'Node', }, globals: true, name: 'node', }, }) ================================================ FILE: runtime-tests/workerd/index.test.ts ================================================ import { unstable_dev } from 'wrangler' import type { Unstable_DevWorker } from 'wrangler' import { WebSocket } from 'ws' describe('workerd', () => { let worker: Unstable_DevWorker beforeAll(async () => { worker = await unstable_dev('./runtime-tests/workerd/index.ts', { vars: { NAME: 'Hono', }, experimental: { disableExperimentalWarning: true }, }) }) afterAll(async () => { await worker.stop() }) it('Should return 200 response with the runtime key', async () => { const res = await worker.fetch('/') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello from workerd') }) it('Should return 200 response with the environment variable', async () => { const res = await worker.fetch('/env') expect(res.status).toBe(200) expect(await res.text()).toBe('Hono') }) it('Should return 200 response with the true message', async () => { const res = await worker.fetch('/color') expect(res.status).toBe(200) expect(await res.text()).toBe('True') }) }) describe('workerd with WebSocket', () => { // worker.fetch does not support WebSocket: // https://github.com/cloudflare/workers-sdk/issues/4573#issuecomment-1850420973 it('Should handle the WebSocket connection correctly', async () => { const worker = await unstable_dev('./runtime-tests/workerd/index.ts', { experimental: { disableExperimentalWarning: true }, }) const ws = new WebSocket(`ws://${worker.address}:${worker.port}/ws`) const openHandler = vi.fn() const messageHandler = vi.fn() const closeHandler = vi.fn() const waitForOpen = new Promise((resolve) => { ws.addEventListener('open', () => { openHandler() ws.send('Hello') }) ws.addEventListener('close', async () => { closeHandler() resolve(undefined) }) ws.addEventListener('message', async (event) => { messageHandler(event.data) ws.close() }) }) await waitForOpen await worker.stop() expect(openHandler).toHaveBeenCalled() expect(messageHandler).toHaveBeenCalledWith('Hello') expect(closeHandler).toHaveBeenCalled() }) }) describe('workerd with NO_COLOR', () => { let worker: Unstable_DevWorker beforeAll(async () => { worker = await unstable_dev('./runtime-tests/workerd/index.ts', { vars: { NO_COLOR: true, }, experimental: { disableExperimentalWarning: true }, }) }) afterAll(async () => { await worker.stop() }) it('Should return 200 response with the false message', async () => { const res = await worker.fetch('/color') expect(res.status).toBe(200) expect(await res.text()).toBe('False') }) }) ================================================ FILE: runtime-tests/workerd/index.ts ================================================ import { upgradeWebSocket } from '../../src/adapter/cloudflare-workers' import { env, getRuntimeKey } from '../../src/helper/adapter' import { Hono } from '../../src/hono' import { getColorEnabledAsync } from '../../src/utils/color' const app = new Hono() app.get('/', (c) => c.text(`Hello from ${getRuntimeKey()}`)) app.get('/env', (c) => { const { NAME } = env<{ NAME: string }>(c) return c.text(NAME) }) app.get( '/ws', upgradeWebSocket(() => { return { onMessage(event, ws) { ws.send(event.data as string) }, } }) ) app.get('/color', async (c) => { return c.text((await getColorEnabledAsync()) ? 'True' : 'False') }) export default app ================================================ FILE: runtime-tests/workerd/tsconfig.json ================================================ { "extends": "../../tsconfig.base.json", "compilerOptions": { "noEmit": true, "types": ["vitest/globals"] }, "references": [ { "path": "../../tsconfig.build.json" } ] } ================================================ FILE: runtime-tests/workerd/vitest.config.ts ================================================ import { defineProject } from 'vitest/config' export default defineProject({ test: { globals: true, name: 'workerd', }, }) ================================================ FILE: src/adapter/aws-lambda/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { describe('API Gateway v1', () => { it('Should return the client IP from identity.sourceIp', () => { const ip = '203.0.113.42' const c = new Context(new Request('http://localhost/'), { env: { requestContext: { identity: { sourceIp: ip, userAgent: 'test', }, accountId: '123', apiId: 'abc', authorizer: {}, domainName: 'example.com', domainPrefix: 'api', extendedRequestId: 'xxx', httpMethod: 'GET', path: '/', protocol: 'HTTP/1.1', requestId: 'req-1', requestTime: '', requestTimeEpoch: 0, resourcePath: '/', stage: 'prod', }, }, }) const info = getConnInfo(c) expect(info.remote.address).toBe(ip) }) }) describe('API Gateway v2', () => { it('Should return the client IP from http.sourceIp', () => { const ip = '198.51.100.23' const c = new Context(new Request('http://localhost/'), { env: { requestContext: { http: { method: 'GET', path: '/', protocol: 'HTTP/1.1', sourceIp: ip, userAgent: 'test', }, accountId: '123', apiId: 'abc', authentication: null, authorizer: {}, domainName: 'example.com', domainPrefix: 'api', requestId: 'req-1', routeKey: 'GET /', stage: 'prod', time: '', timeEpoch: 0, }, }, }) const info = getConnInfo(c) expect(info.remote.address).toBe(ip) }) }) describe('ALB', () => { it.each([ { description: 'ALB appends real client IP', xff: '10.0.0.1, 192.0.2.50', expected: '192.0.2.50', }, { description: 'attacker-controlled first IP', xff: '127.0.0.1, 192.168.1.100', expected: '192.168.1.100', }, ])('Should return the last IP from x-forwarded-for ($description)', ({ xff, expected }) => { const req = new Request('http://localhost/', { headers: { 'x-forwarded-for': xff }, }) const c = new Context(req, { env: { requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:...', }, }, }, }) const info = getConnInfo(c) expect(info.remote.address).toBe(expected) }) it('Should return undefined when no x-forwarded-for header', () => { const c = new Context(new Request('http://localhost/'), { env: { requestContext: { elb: { targetGroupArn: 'arn:aws:elasticloadbalancing:...', }, }, }, }) const info = getConnInfo(c) expect(info.remote.address).toBeUndefined() }) }) }) ================================================ FILE: src/adapter/aws-lambda/conninfo.ts ================================================ import type { Context } from '../../context' import type { GetConnInfo } from '../../helper/conninfo' import type { ApiGatewayRequestContext, ApiGatewayRequestContextV2, ALBRequestContext, } from './types' type LambdaRequestContext = | ApiGatewayRequestContext | ApiGatewayRequestContextV2 | ALBRequestContext type Env = { Bindings: { requestContext: LambdaRequestContext } } /** * Get connection information from AWS Lambda * * Extracts client IP from various Lambda event sources: * - API Gateway v1 (REST API): requestContext.identity.sourceIp * - API Gateway v2 (HTTP API/Function URLs): requestContext.http.sourceIp * - ALB: Falls back to x-forwarded-for header * * @param c - Context * @returns Connection information including remote address * @example * ```ts * import { Hono } from 'hono' * import { handle, getConnInfo } from 'hono/aws-lambda' * * const app = new Hono() * * app.get('/', (c) => { * const info = getConnInfo(c) * return c.text(`Your IP: ${info.remote.address}`) * }) * * export const handler = handle(app) * ``` */ export const getConnInfo: GetConnInfo = (c: Context) => { const requestContext = c.env.requestContext let address: string | undefined // API Gateway v1 - has identity object if ('identity' in requestContext && requestContext.identity?.sourceIp) { address = requestContext.identity.sourceIp } // API Gateway v2 - has http object else if ('http' in requestContext && requestContext.http?.sourceIp) { address = requestContext.http.sourceIp } // ALB - use X-Forwarded-For header else { const xff = c.req.header('x-forwarded-for') if (xff) { const ips = xff.split(',') // ALB appends the real client IP to the end of the header address = ips[ips.length - 1].trim() } } return { remote: { address, }, } } ================================================ FILE: src/adapter/aws-lambda/handler.test.ts ================================================ import type { LambdaEvent, LatticeProxyEventV2 } from './handler' import { getProcessor, isContentEncodingBinary, defaultIsContentTypeBinary } from './handler' // Base event objects to reduce duplication const baseV1Event: LambdaEvent = { version: '1.0', resource: '/my/path', path: '/my/path', httpMethod: 'GET', headers: {}, multiValueHeaders: {}, queryStringParameters: {}, requestContext: { accountId: '123456789012', apiId: 'id', authorizer: { claims: null, scopes: null }, domainName: 'id.execute-api.us-east-1.amazonaws.com', domainPrefix: 'id', extendedRequestId: 'request-id', httpMethod: 'GET', identity: { sourceIp: '192.0.2.1', userAgent: 'user-agent', clientCert: { clientCertPem: 'CERT_CONTENT', subjectDN: 'www.example.com', issuerDN: 'Example issuer', serialNumber: 'a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1:a1', validity: { notBefore: 'May 28 12:30:02 2019 GMT', notAfter: 'Aug 5 09:36:04 2021 GMT', }, }, }, path: '/my/path', protocol: 'HTTP/1.1', requestId: 'id=', requestTime: '04/Mar/2020:19:15:17 +0000', requestTimeEpoch: 1583349317135, resourcePath: '/my/path', stage: '$default', }, pathParameters: {}, stageVariables: {}, body: null, isBase64Encoded: false, } const baseV2Event: LambdaEvent = { version: '2.0', routeKey: '$default', rawPath: '/my/path', rawQueryString: '', cookies: [], headers: {}, queryStringParameters: {}, requestContext: { accountId: '123456789012', apiId: 'api-id', authentication: null, authorizer: {}, domainName: 'id.execute-api.us-east-1.amazonaws.com', domainPrefix: 'id', http: { method: 'POST', path: '/my/path', protocol: 'HTTP/1.1', sourceIp: '192.0.2.1', userAgent: 'agent', }, requestId: 'id', routeKey: '$default', stage: '$default', time: '12/Mar/2020:19:03:58 +0000', timeEpoch: 1583348638390, }, body: null, pathParameters: {}, isBase64Encoded: false, stageVariables: {}, } describe('isContentTypeBinary', () => { it.each([ ['image/png', true], ['font/woff2', true], ['image/svg+xml', false], ['image/svg+xml; charset=UTF-8', false], ['text/plain', false], ['text/plain; charset=UTF-8', false], ['text/css', false], ['text/javascript', false], ['application/json', false], ['application/ld+json', false], ['application/json', false], ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', true], ['application/msword', true], ['application/epub+zip', true], ['application/ld+json', false], ['application/vnd.oasis.opendocument.text', true], ])('Should determine whether %s it is binary', (mimeType: string, expected: boolean) => { expect(defaultIsContentTypeBinary(mimeType)).toBe(expected) }) }) describe('isContentEncodingBinary', () => { it('Should determine whether it is compressed', () => { expect(isContentEncodingBinary('gzip')).toBe(true) expect(isContentEncodingBinary('compress')).toBe(true) expect(isContentEncodingBinary('deflate')).toBe(true) expect(isContentEncodingBinary('br')).toBe(true) expect(isContentEncodingBinary('deflate, gzip')).toBe(true) expect(isContentEncodingBinary('')).toBe(false) expect(isContentEncodingBinary('unknown')).toBe(false) }) }) describe('EventProcessor.createResult with contentTypesAsBinary', () => { const event = baseV1Event it('Should encode as base64 when content-type is in contentTypesAsBinary array', async () => { const processor = getProcessor(event) const response = new Response('test content', { headers: { 'content-type': 'application/custom' }, }) const result = await processor.createResult(event, response, { isContentTypeBinary: (contentType: string) => contentType === 'application/custom', }) expect(result.isBase64Encoded).toBe(true) expect(result.body).toBe('dGVzdCBjb250ZW50') }) it('Should not encode as base64 when content-type is not in isContentTypeBinary array', async () => { const processor = getProcessor(event) const response = new Response('test content', { headers: { 'content-type': 'application/json' }, }) const result = await processor.createResult(event, response, { isContentTypeBinary: (contentType: string) => contentType === 'application/custom', }) expect(result.isBase64Encoded).toBe(false) expect(result.body).toBe('test content') }) it('Should use defaultIsContentTypeBinary when isContentTypeBinary is undefined with binary content', async () => { const processor = getProcessor(event) const response = new Response('test image content', { headers: { 'content-type': 'image/png' }, }) // Pass undefined for isContentTypeBinary to test default behavior const result = await processor.createResult(event, response, { isContentTypeBinary: undefined, }) expect(result.isBase64Encoded).toBe(true) expect(result.body).toBe('dGVzdCBpbWFnZSBjb250ZW50') }) it('Should use defaultIsContentTypeBinary when isContentTypeBinary is undefined with non-binary content', async () => { const processor = getProcessor(event) const response = new Response('test text content', { headers: { 'content-type': 'text/plain' }, }) // Pass undefined for isContentTypeBinary to test default behavior const result = await processor.createResult(event, response, { isContentTypeBinary: undefined, }) expect(result.isBase64Encoded).toBe(false) expect(result.body).toBe('test text content') }) }) describe('EventProcessor.createRequest', () => { it('Should preserve percent-encoded values in query string for version 1.0', () => { const event: LambdaEvent = { ...baseV1Event, // API Gateway provides decoded values multiValueQueryStringParameters: { path: ['/book/{bookId}/'], // Originally %7BbookId%7D name: ['John Doe'], // Originally John%20Doe tag: ['日本語'], // Originally %E6%97%A5%E6%9C%AC%E8%AA%9E }, } const processor = getProcessor(event) const request = processor.createRequest(event) // URL should contain properly encoded values expect(request.url).toEqual( 'https://id.execute-api.us-east-1.amazonaws.com/my/path?path=%2Fbook%2F%7BbookId%7D%2F&name=John%20Doe&tag=%E6%97%A5%E6%9C%AC%E8%AA%9E' ) }) it('Should handle special characters correctly in queryStringParameters for version 1.0', () => { const event: LambdaEvent = { ...baseV1Event, queryStringParameters: { 'key with spaces': 'value with spaces', 'special!@#$%^&*()': 'chars!@#$%^&*()', equals: 'a=b=c', ampersand: 'a&b&c', }, } const processor = getProcessor(event) const request = processor.createRequest(event) // Verify the URL is properly encoded const url = new URL(request.url) expect(url.searchParams.get('key with spaces')).toBe('value with spaces') expect(url.searchParams.get('special!@#$%^&*()')).toBe('chars!@#$%^&*()') expect(url.searchParams.get('equals')).toBe('a=b=c') expect(url.searchParams.get('ampersand')).toBe('a&b&c') }) it('Should return valid Request object from version 1.0 API Gateway event', () => { const event: LambdaEvent = { ...baseV1Event, headers: { 'content-type': 'application/json', header1: 'value1', header2: 'value1', }, multiValueHeaders: { header1: ['value1'], header2: ['value1', 'value2', 'value3'], }, // This value doesn't match multi value's content. // We want to assert handler is using the multi value's content when both are available. queryStringParameters: { parameter2: 'value', }, multiValueQueryStringParameters: { parameter1: ['value1', 'value2'], parameter2: ['value'], }, } const processor = getProcessor(event) const request = processor.createRequest(event) expect(request.method).toEqual('GET') // Note: Values are now properly encoded expect(request.url).toEqual( 'https://id.execute-api.us-east-1.amazonaws.com/my/path?parameter1=value1¶meter1=value2¶meter2=value' ) expect(Object.fromEntries(request.headers)).toEqual({ 'content-type': 'application/json', header1: 'value1', header2: 'value1, value2, value3', }) }) it('Should return valid Request object from version 2.0 API Gateway event', () => { const event: LambdaEvent = { ...baseV2Event, rawQueryString: 'parameter1=value1¶meter1=value2¶meter2=value', cookies: ['cookie1', 'cookie2'], headers: { 'content-type': 'application/json', header1: 'value1', header2: 'value1,value2', }, queryStringParameters: { parameter1: 'value1,value2', parameter2: 'value', }, body: 'Hello from Lambda', pathParameters: { parameter1: 'value1', }, stageVariables: { stageVariable1: 'value1', stageVariable2: 'value2', }, } const processor = getProcessor(event) const request = processor.createRequest(event) expect(request.method).toEqual('POST') expect(request.url).toEqual( 'https://id.execute-api.us-east-1.amazonaws.com/my/path?parameter1=value1¶meter1=value2¶meter2=value' ) expect(Object.fromEntries(request.headers)).toEqual({ 'content-type': 'application/json', cookie: 'cookie1; cookie2', header1: 'value1', header2: 'value1,value2', }) }) it('Should return valid Request object from version 2.0 Lattice event', async () => { const event: LatticeProxyEventV2 = { version: '2.0', // query string parameters from the path take precedence over the explicit notation below path: '/my/path?parameter1=value1¶meter1=value2¶meter2=value', method: 'POST', headers: { cookie: ['cookie1=value1; cookie2=value2'], 'content-type': ['application/x-www-form-urlencoded'], header1: ['value1'], header2: ['value1', 'value2'], host: ['my-service-a1b2c3.x1y2z3.vpc-lattice-svcs.us-east-1.on.aws'], }, queryStringParameters: { parameter1: ['value1', 'value2'], parameter2: ['value'], }, body: 'SGVsbG8gZnJvbSBMYW1iZGE=', isBase64Encoded: true, requestContext: { serviceNetworkArn: '', serviceArn: '', targetGroupArn: '', identity: {}, region: 'us-east-1', timeEpoch: '1583348638390123', }, } const processor = getProcessor(event) const request = processor.createRequest(event) expect(await request.text()).toEqual('Hello from Lambda') expect(request.method).toEqual('POST') expect(request.url).toEqual( 'https://my-service-a1b2c3.x1y2z3.vpc-lattice-svcs.us-east-1.on.aws/my/path?parameter1=value1¶meter1=value2¶meter2=value' ) expect(Object.fromEntries(request.headers)).toEqual({ 'content-type': 'application/x-www-form-urlencoded', cookie: 'cookie1=value1; cookie2=value2', header1: 'value1', header2: 'value1, value2', host: 'my-service-a1b2c3.x1y2z3.vpc-lattice-svcs.us-east-1.on.aws', }) }) describe('non-ASCII header value processing', () => { it('Should encode non-ASCII header values with encodeURIComponent', async () => { const event: LambdaEvent = { ...baseV1Event, headers: { 'x-city': '炎', // Non-ASCII character }, } const processor = getProcessor(event) const request = processor.createRequest(event) const xCity = request.headers.get('x-city') ?? '' expect(decodeURIComponent(xCity)).toBe('炎') }) }) }) ================================================ FILE: src/adapter/aws-lambda/handler.ts ================================================ import type { Hono } from '../../hono' import type { Env, Schema } from '../../types' import { decodeBase64, encodeBase64 } from '../../utils/encode' import type { ALBRequestContext, ApiGatewayRequestContext, ApiGatewayRequestContextV2, Handler, LambdaContext, LatticeRequestContextV2, } from './types' function sanitizeHeaderValue(value: string): string { // Check if the value contains non-ASCII characters (char codes > 127) // eslint-disable-next-line no-control-regex const hasNonAscii = /[^\x00-\x7F]/.test(value) if (!hasNonAscii) { return value } return encodeURIComponent(value) } export type LambdaEvent = | APIGatewayProxyEvent | APIGatewayProxyEventV2 | ALBProxyEvent | LatticeProxyEventV2 export interface LatticeProxyEventV2 { version: string path: string method: string headers: Record queryStringParameters: Record body: string | null isBase64Encoded: boolean requestContext: LatticeRequestContextV2 } // When calling HTTP API or Lambda directly through function urls export interface APIGatewayProxyEventV2 { version: string routeKey: string headers: Record multiValueHeaders?: undefined cookies?: string[] rawPath: string rawQueryString: string body: string | null isBase64Encoded: boolean requestContext: ApiGatewayRequestContextV2 queryStringParameters?: { [name: string]: string | undefined } pathParameters?: { [name: string]: string | undefined } stageVariables?: { [name: string]: string | undefined } } // When calling Lambda through an API Gateway export interface APIGatewayProxyEvent { version: string httpMethod: string headers: Record multiValueHeaders?: { [headerKey: string]: string[] } path: string body: string | null isBase64Encoded: boolean queryStringParameters?: Record requestContext: ApiGatewayRequestContext resource: string multiValueQueryStringParameters?: { [parameterKey: string]: string[] } pathParameters?: Record stageVariables?: Record } // When calling Lambda through an Application Load Balancer export interface ALBProxyEvent { httpMethod: string headers?: Record multiValueHeaders?: Record path: string body: string | null isBase64Encoded: boolean queryStringParameters?: Record multiValueQueryStringParameters?: { [parameterKey: string]: string[] } requestContext: ALBRequestContext } type WithHeaders = { headers: Record multiValueHeaders?: undefined } type WithMultiValueHeaders = { headers?: undefined multiValueHeaders: Record } export type APIGatewayProxyResult = { statusCode: number statusDescription?: string body: string cookies?: string[] isBase64Encoded: boolean } & (WithHeaders | WithMultiValueHeaders) const getRequestContext = ( event: LambdaEvent ): | ApiGatewayRequestContext | ApiGatewayRequestContextV2 | ALBRequestContext | LatticeRequestContextV2 => { return event.requestContext } const streamToNodeStream = async ( reader: ReadableStreamDefaultReader, writer: NodeJS.WritableStream ): Promise => { let readResult = await reader.read() while (!readResult.done) { writer.write(readResult.value) readResult = await reader.read() } writer.end() } export const streamHandle = < E extends Env = Env, S extends Schema = {}, BasePath extends string = '/', >( app: Hono ): Handler => { // @ts-expect-error awslambda is not a standard API return awslambda.streamifyResponse( async (event: LambdaEvent, responseStream: NodeJS.WritableStream, context: LambdaContext) => { const processor = getProcessor(event) try { const req = processor.createRequest(event) const requestContext = getRequestContext(event) const res = await app.fetch(req, { event, requestContext, context, }) const headers: Record = {} const cookies: string[] = [] res.headers.forEach((value, name) => { if (name === 'set-cookie') { cookies.push(value) } else { headers[name] = value } }) // Check content type const httpResponseMetadata = { statusCode: res.status, headers, cookies, } // Update response stream // @ts-expect-error awslambda is not a standard API responseStream = awslambda.HttpResponseStream.from(responseStream, httpResponseMetadata) if (res.body) { await streamToNodeStream(res.body.getReader(), responseStream) } else { responseStream.write('') } } catch (error) { console.error('Error processing request:', error) responseStream.write('Internal Server Error') } finally { responseStream.end() } } ) } type HandleOptions = { isContentTypeBinary: ((contentType: string) => boolean) | undefined } /** * Converts a Hono application to an AWS Lambda handler. * * Accepts events from API Gateway (v1 and v2), Application Load Balancer (ALB), * and Lambda Function URLs. * * @param app - The Hono application instance * @param options - Optional configuration * @param options.isContentTypeBinary - A function to determine if the content type is binary. * If not provided, the default function will be used. * @returns Lambda handler function * * @example * ```js * import { Hono } from 'hono' * import { handle } from 'hono/aws-lambda' * * const app = new Hono() * * app.get('/', (c) => c.text('Hello from Lambda')) * app.get('/json', (c) => c.json({ message: 'Hello JSON' })) * * export const handler = handle(app) * ``` * * @example * ```js * // With custom binary content type detection * import { handle, defaultIsContentTypeBinary } from 'hono/aws-lambda' * export const handler = handle(app, { * isContentTypeBinary: (contentType) => { * if (defaultIsContentTypeBinary(contentType)) { * // default logic same as prior to v4.8.4 * return true * } * return contentType.startsWith('image/') || contentType === 'application/pdf' * } * }) * ``` */ export const handle = ( app: Hono, { isContentTypeBinary }: HandleOptions = { isContentTypeBinary: undefined } ): (( event: L, lambdaContext?: LambdaContext ) => Promise< APIGatewayProxyResult & (L extends { multiValueHeaders: Record } ? WithMultiValueHeaders : WithHeaders) >) => { // @ts-expect-error FIXME: Fix return typing return async (event, lambdaContext?) => { const processor = getProcessor(event) const req = processor.createRequest(event) const requestContext = getRequestContext(event) const res = await app.fetch(req, { event, requestContext, lambdaContext, }) return processor.createResult(event, res, { isContentTypeBinary }) } } export abstract class EventProcessor { protected abstract getPath(event: E): string protected abstract getMethod(event: E): string protected abstract getQueryString(event: E): string protected abstract getHeaders(event: E): Headers protected abstract getCookies(event: E, headers: Headers): void protected abstract setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void protected getHeaderValue(headers: E['headers'], key: string): string | undefined { const value = headers ? Array.isArray(headers[key]) ? headers[key][0] : headers[key] : undefined return value } protected getDomainName(event: E): string | undefined { if (event.requestContext && 'domainName' in event.requestContext) { return event.requestContext.domainName } const hostFromHeaders = this.getHeaderValue(event.headers, 'host') if (hostFromHeaders) { return hostFromHeaders } const multiValueHeaders = 'multiValueHeaders' in event ? event.multiValueHeaders : {} const hostFromMultiValueHeaders = this.getHeaderValue(multiValueHeaders, 'host') return hostFromMultiValueHeaders } createRequest(event: E): Request { const queryString = this.getQueryString(event) const domainName = this.getDomainName(event) const path = this.getPath(event) const urlPath = `https://${domainName}${path}` const url = queryString ? `${urlPath}?${queryString}` : urlPath const headers = this.getHeaders(event) const method = this.getMethod(event) const requestInit: RequestInit = { headers, method, } if (event.body) { requestInit.body = event.isBase64Encoded ? decodeBase64(event.body) : event.body } return new Request(url, requestInit) } async createResult( event: E, res: Response, options: Pick ): Promise { // determine whether the response body should be base64 encoded const contentType = res.headers.get('content-type') const isContentTypeBinary = options.isContentTypeBinary ?? defaultIsContentTypeBinary // overwrite default function if provided let isBase64Encoded = contentType && isContentTypeBinary(contentType) ? true : false if (!isBase64Encoded) { const contentEncoding = res.headers.get('content-encoding') isBase64Encoded = isContentEncodingBinary(contentEncoding) } const body = isBase64Encoded ? encodeBase64(await res.arrayBuffer()) : await res.text() const result: APIGatewayProxyResult = { body: body, statusCode: res.status, isBase64Encoded, ...('multiValueHeaders' in event && event.multiValueHeaders ? { multiValueHeaders: {}, } : { headers: {}, }), } this.setCookies(event, res, result) if (result.multiValueHeaders) { res.headers.forEach((value, key) => { result.multiValueHeaders[key] = [value] }) } else { res.headers.forEach((value, key) => { result.headers[key] = value }) } return result } setCookies(_event: E, res: Response, result: APIGatewayProxyResult) { if (res.headers.has('set-cookie')) { const cookies = res.headers.getSetCookie ? res.headers.getSetCookie() : Array.from(res.headers.entries()) .filter(([k]) => k === 'set-cookie') .map(([, v]) => v) if (Array.isArray(cookies)) { this.setCookiesToResult(result, cookies) res.headers.delete('set-cookie') } } } } export class EventV2Processor extends EventProcessor { protected getPath(event: APIGatewayProxyEventV2): string { return event.rawPath } protected getMethod(event: APIGatewayProxyEventV2): string { return event.requestContext.http.method } protected getQueryString(event: APIGatewayProxyEventV2): string { return event.rawQueryString } protected getCookies(event: APIGatewayProxyEventV2, headers: Headers): void { if (Array.isArray(event.cookies)) { headers.set('Cookie', event.cookies.join('; ')) } } protected setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void { result.cookies = cookies } protected getHeaders(event: APIGatewayProxyEventV2): Headers { const headers = new Headers() this.getCookies(event, headers) if (event.headers) { for (const [k, v] of Object.entries(event.headers)) { if (v) { headers.set(k, v) } } } return headers } } const v2Processor: EventV2Processor = new EventV2Processor() export class EventV1Processor extends EventProcessor { protected getPath(event: APIGatewayProxyEvent): string { return event.path } protected getMethod(event: APIGatewayProxyEvent): string { return event.httpMethod } protected getQueryString(event: APIGatewayProxyEvent): string { // In the case of gateway Integration either queryStringParameters or multiValueQueryStringParameters can be present not both // API Gateway passes decoded values, so we need to re-encode them to preserve the original URL if (event.multiValueQueryStringParameters) { return Object.entries(event.multiValueQueryStringParameters || {}) .filter(([, value]) => value) .map(([key, values]) => values.map((value) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&') ) .join('&') } else { return Object.entries(event.queryStringParameters || {}) .filter(([, value]) => value) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value || '')}`) .join('&') } } protected getCookies(_event: APIGatewayProxyEvent, _headers: Headers): void { // nop } protected getHeaders(event: APIGatewayProxyEvent): Headers { const headers = new Headers() this.getCookies(event, headers) if (event.headers) { for (const [k, v] of Object.entries(event.headers)) { if (v) { headers.set(k, sanitizeHeaderValue(v)) } } } if (event.multiValueHeaders) { for (const [k, values] of Object.entries(event.multiValueHeaders)) { if (values) { // avoid duplicating already set headers const foundK = headers.get(k) values.forEach((v) => { const sanitizedValue = sanitizeHeaderValue(v) return ( (!foundK || !foundK.includes(sanitizedValue)) && headers.append(k, sanitizedValue) ) }) } } } return headers } protected setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void { result.multiValueHeaders = { 'set-cookie': cookies, } } } const v1Processor: EventV1Processor = new EventV1Processor() export class ALBProcessor extends EventProcessor { protected getHeaders(event: ALBProxyEvent): Headers { const headers = new Headers() // if multiValueHeaders is present the ALB will use it instead of the headers field // https://docs.aws.amazon.com/elasticloadbalancing/latest/application/lambda-functions.html#multi-value-headers if (event.multiValueHeaders) { for (const [key, values] of Object.entries(event.multiValueHeaders)) { if (values && Array.isArray(values)) { // https://www.rfc-editor.org/rfc/rfc9110.html#name-common-rules-for-defining-f const sanitizedValue = sanitizeHeaderValue(values.join('; ')) headers.set(key, sanitizedValue) } } } else { for (const [key, value] of Object.entries(event.headers ?? {})) { if (value) { headers.set(key, sanitizeHeaderValue(value)) } } } return headers } protected getPath(event: ALBProxyEvent): string { return event.path } protected getMethod(event: ALBProxyEvent): string { return event.httpMethod } protected getQueryString(event: ALBProxyEvent): string { // In the case of ALB Integration either queryStringParameters or multiValueQueryStringParameters can be present not both /* In other cases like when using the serverless framework, the event object does contain both queryStringParameters and multiValueQueryStringParameters: Below is an example event object for this URL: /payment/b8c55e69?select=amount&select=currency { ... queryStringParameters: { select: 'currency' }, multiValueQueryStringParameters: { select: [ 'amount', 'currency' ] }, } The expected results is for select to be an array with two items. However the pre-fix code is only returning one item ('currency') in the array. A simple fix would be to invert the if statement and check the multiValueQueryStringParameters first. */ if (event.multiValueQueryStringParameters) { return Object.entries(event.multiValueQueryStringParameters || {}) .filter(([, value]) => value) .map(([key, value]) => `${key}=${value.join(`&${key}=`)}`) .join('&') } else { return Object.entries(event.queryStringParameters || {}) .filter(([, value]) => value) .map(([key, value]) => `${key}=${value}`) .join('&') } } protected getCookies(event: ALBProxyEvent, headers: Headers): void { let cookie if (event.multiValueHeaders) { cookie = event.multiValueHeaders['cookie']?.join('; ') } else { cookie = event.headers ? event.headers['cookie'] : undefined } if (cookie) { headers.append('Cookie', cookie) } } protected setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void { // when multi value headers is enabled if (result.multiValueHeaders) { result.multiValueHeaders['set-cookie'] = cookies } else { // otherwise serialize the set-cookie result.headers['set-cookie'] = cookies.join(', ') } } } const albProcessor: ALBProcessor = new ALBProcessor() export class LatticeV2Processor extends EventProcessor { protected getPath(event: LatticeProxyEventV2): string { return event.path } protected getMethod(event: LatticeProxyEventV2): string { return event.method } protected getQueryString(): string { return '' } protected getHeaders(event: LatticeProxyEventV2): Headers { const headers = new Headers() if (event.headers) { for (const [k, values] of Object.entries(event.headers)) { if (values) { // avoid duplicating already set headers const foundK = headers.get(k) values.forEach((v) => { const sanitizedValue = sanitizeHeaderValue(v) return ( (!foundK || !foundK.includes(sanitizedValue)) && headers.append(k, sanitizedValue) ) }) } } } return headers } protected getCookies(): void { // nop } protected setCookiesToResult(result: APIGatewayProxyResult, cookies: string[]): void { result.headers = { ...result.headers, 'set-cookie': cookies.join(', '), } } } const latticeV2Processor: LatticeV2Processor = new LatticeV2Processor() export const getProcessor = (event: LambdaEvent): EventProcessor => { if (isProxyEventALB(event)) { return albProcessor } if (isProxyEventV2(event)) { return v2Processor } if (isLatticeEventV2(event)) { return latticeV2Processor } return v1Processor } const isProxyEventALB = (event: LambdaEvent): event is ALBProxyEvent => { if (event.requestContext) { return Object.hasOwn(event.requestContext, 'elb') } return false } const isProxyEventV2 = (event: LambdaEvent): event is APIGatewayProxyEventV2 => { return Object.hasOwn(event, 'rawPath') } const isLatticeEventV2 = (event: LambdaEvent): event is LatticeProxyEventV2 => { if (event.requestContext) { return Object.hasOwn(event.requestContext, 'serviceArn') } return false } /** * Check if the given content type is binary. * This is a default function and may be overwritten by the user via `isContentTypeBinary` option in handler(). * @param contentType The content type to check. * @returns True if the content type is binary, false otherwise. */ export const defaultIsContentTypeBinary = (contentType: string): boolean => { return !/^text\/(?:plain|html|css|javascript|csv)|(?:\/|\+)(?:json|xml)\s*(?:;|$)/.test( contentType ) } export const isContentEncodingBinary = (contentEncoding: string | null) => { if (contentEncoding === null) { return false } return /^(gzip|deflate|compress|br)/.test(contentEncoding) } ================================================ FILE: src/adapter/aws-lambda/index.ts ================================================ /** * @module * AWS Lambda Adapter for Hono. */ export { handle, streamHandle, defaultIsContentTypeBinary } from './handler' export { getConnInfo } from './conninfo' export type { APIGatewayProxyResult, LambdaEvent } from './handler' export type { ApiGatewayRequestContext, ApiGatewayRequestContextV2, ALBRequestContext, LambdaContext, } from './types' ================================================ FILE: src/adapter/aws-lambda/types.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ export interface CognitoIdentity { cognitoIdentityId: string cognitoIdentityPoolId: string } export interface ClientContext { client: ClientContextClient Custom?: any env: ClientContextEnv } export interface ClientContextClient { installationId: string appTitle: string appVersionName: string appVersionCode: string appPackageName: string } export interface ClientContextEnv { platformVersion: string platform: string make: string model: string locale: string } /** * {@link Handler} context parameter. * See {@link https://docs.aws.amazon.com/lambda/latest/dg/nodejs-prog-model-context.html AWS documentation}. */ export interface LambdaContext { callbackWaitsForEmptyEventLoop: boolean functionName: string functionVersion: string invokedFunctionArn: string memoryLimitInMB: string awsRequestId: string logGroupName: string logStreamName: string identity?: CognitoIdentity | undefined clientContext?: ClientContext | undefined getRemainingTimeInMillis(): number } type Callback = (error?: Error | string | null, result?: TResult) => void export type Handler = ( event: TEvent, context: LambdaContext, callback: Callback ) => void | Promise interface ClientCert { clientCertPem: string subjectDN: string issuerDN: string serialNumber: string validity: { notBefore: string notAfter: string } } interface Identity { accessKey?: string accountId?: string caller?: string cognitoAuthenticationProvider?: string cognitoAuthenticationType?: string cognitoIdentityId?: string cognitoIdentityPoolId?: string principalOrgId?: string sourceIp: string user?: string userAgent: string userArn?: string clientCert?: ClientCert } export interface ApiGatewayRequestContext { accountId: string apiId: string authorizer: { claims?: unknown scopes?: unknown } domainName: string domainPrefix: string extendedRequestId: string httpMethod: string identity: Identity path: string protocol: string requestId: string requestTime: string requestTimeEpoch: number resourceId?: string resourcePath: string stage: string } interface Authorizer { iam?: { accessKey: string accountId: string callerId: string cognitoIdentity: null principalOrgId: null userArn: string userId: string } } export interface ApiGatewayRequestContextV2 { accountId: string apiId: string authentication: null authorizer: Authorizer domainName: string domainPrefix: string http: { method: string path: string protocol: string sourceIp: string userAgent: string } requestId: string routeKey: string stage: string time: string timeEpoch: number } export interface ALBRequestContext { elb: { targetGroupArn: string } } export interface LatticeRequestContextV2 { serviceNetworkArn: string serviceArn: string targetGroupArn: string region: string timeEpoch: string identity: { sourceVpcArn?: string type?: string principal?: string principalOrgID?: string sessionName?: string x509IssuerOu?: string x509SanDns?: string x509SanNameCn?: string x509SanUri?: string x509SubjectCn?: string } } ================================================ FILE: src/adapter/bun/conninfo.test.ts ================================================ import { Context } from '../../context' import type { AddressType } from '../../helper/conninfo' import { getConnInfo } from './conninfo' const createRandomBunServer = ({ address = Math.random().toString(), port = Math.floor(Math.random() * (65535 + 1)), family = 'IPv6', }: { address?: string port?: number family?: AddressType | string } = {}) => { return { address, port, server: { requestIP() { return { address, family, port, } }, }, } } describe('getConnInfo', () => { it('Should info is valid', () => { const { port, server, address } = createRandomBunServer() const c = new Context(new Request('http://localhost/'), { env: server }) const info = getConnInfo(c) expect(info.remote.port).toBe(port) expect(info.remote.address).toBe(address) expect(info.remote.addressType).toBe('IPv6') expect(info.remote.transport).toBeUndefined() }) it('Should getConnInfo works when env is { server: server }', () => { const { port, server, address } = createRandomBunServer() const c = new Context(new Request('http://localhost/'), { env: { server } }) const info = getConnInfo(c) expect(info.remote.port).toBe(port) expect(info.remote.address).toBe(address) expect(info.remote.addressType).toBe('IPv6') expect(info.remote.transport).toBeUndefined() }) it('should return undefined when addressType is invalid string', () => { const { server } = createRandomBunServer({ family: 'invalid' }) const c = new Context(new Request('http://localhost/'), { env: { server } }) const info = getConnInfo(c) expect(info.remote.addressType).toBeUndefined() }) it('Should throw error when user did not give server', () => { const c = new Context(new Request('http://localhost/'), { env: {} }) expect(() => getConnInfo(c)).toThrowError(TypeError) }) it('Should throw error when requestIP is not function', () => { const c = new Context(new Request('http://localhost/'), { env: { requestIP: 0, }, }) expect(() => getConnInfo(c)).toThrowError(TypeError) }) it('Should return empty remote when requestIP returns null', () => { const c = new Context(new Request('http://localhost/'), { env: { requestIP() { return null }, }, }) const info = getConnInfo(c) expect(info.remote).toEqual({}) }) }) ================================================ FILE: src/adapter/bun/conninfo.ts ================================================ import type { Context } from '../..' import type { GetConnInfo } from '../../helper/conninfo' import { getBunServer } from './server' /** * Get ConnInfo with Bun * @param c Context * @returns ConnInfo */ export const getConnInfo: GetConnInfo = (c: Context) => { const server = getBunServer<{ requestIP?: (req: Request) => { address: string family: string port: number } | null }>(c) if (!server) { throw new TypeError('env has to include the 2nd argument of fetch.') } if (typeof server.requestIP !== 'function') { throw new TypeError('server.requestIP is not a function.') } // https://bun.sh/docs/runtime/http/server#server-requestip-request // Returns null for closed requests or Unix domain sockets. const info = server.requestIP(c.req.raw) if (!info) { return { remote: {}, } } return { remote: { address: info.address, addressType: info.family === 'IPv6' || info.family === 'IPv4' ? info.family : undefined, port: info.port, }, } } ================================================ FILE: src/adapter/bun/index.ts ================================================ /** * @module * Bun Adapter for Hono. */ export { serveStatic } from './serve-static' export { bunFileSystemModule, toSSG } from './ssg' export { createBunWebSocket, upgradeWebSocket, websocket } from './websocket' export type { BunWebSocketData, BunWebSocketHandler } from './websocket' export { getConnInfo } from './conninfo' export { getBunServer } from './server' ================================================ FILE: src/adapter/bun/serve-static.ts ================================================ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { stat } from 'node:fs/promises' import { join } from 'node:path' import { serveStatic as baseServeStatic } from '../../middleware/serve-static' import type { ServeStaticOptions } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' export const serveStatic = ( options: ServeStaticOptions ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { // @ts-ignore const file = Bun.file(path) return (await file.exists()) ? file : null } const isDir = async (path: string) => { let isDir try { const stats = await stat(path) isDir = stats.isDirectory() } catch {} return isDir } return baseServeStatic({ ...options, getContent, join, isDir, })(c, next) } } ================================================ FILE: src/adapter/bun/server.test.ts ================================================ import { Context } from '../../context' import { getBunServer } from './server' describe('getBunServer', () => { it('Should success to pick Server', () => { const server = {} expect(getBunServer(new Context(new Request('http://localhost/'), { env: server }))).toBe( server ) expect(getBunServer(new Context(new Request('http://localhost/'), { env: { server } }))).toBe( server ) }) }) ================================================ FILE: src/adapter/bun/server.ts ================================================ /** * Getting Bun Server Object for Bun adapters * @module */ import type { Context } from '../../context' /** * Get Bun Server Object from Context * @template T - The type of Bun Server * @param c Context * @returns Bun Server */ export const getBunServer = (c: Context): T | undefined => ('server' in c.env ? c.env.server : c.env) as T | undefined ================================================ FILE: src/adapter/bun/ssg.ts ================================================ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { toSSG as baseToSSG } from '../../helper/ssg' import type { FileSystemModule, ToSSGAdaptorInterface } from '../../helper/ssg' // @ts-ignore const { write } = Bun /** * @experimental * `bunFileSystemModule` is an experimental feature. * The API might be changed. */ export const bunFileSystemModule: FileSystemModule = { writeFile: async (path, data) => { await write(path, data) }, mkdir: async () => {}, } /** * @experimental * `toSSG` is an experimental feature. * The API might be changed. */ export const toSSG: ToSSGAdaptorInterface = async (app, options) => { return baseToSSG(app, bunFileSystemModule, options) } ================================================ FILE: src/adapter/bun/websocket.test.ts ================================================ import { Context } from '../../context' import type { BunWebSocketData, BunServerWebSocket } from './websocket' import { createWSContext, websocket, upgradeWebSocket, createBunWebSocket } from './websocket' describe('createWSContext()', () => { it('Should send() and close() works', () => { const send = vi.fn() const close = vi.fn() const ws = createWSContext({ send(data) { send(data) }, close(code, reason) { close(code, reason) }, data: {}, } as BunServerWebSocket) ws.send('message') expect(send).toBeCalled() ws.close() expect(close).toBeCalled() }) }) describe('upgradeWebSocket()', () => { beforeAll(() => { // @ts-expect-error patch global globalThis.CloseEvent = Event }) afterAll(() => { // @ts-expect-error patch global delete globalThis.CloseEvent }) it('Should throw error when server is null', async () => { const run = async () => await upgradeWebSocket(() => ({}))( new Context(new Request('http://localhost'), { env: { server: null, }, }), () => Promise.resolve() ) await expect(run).rejects.toThrowError(/env has/) }) it('Should response null when upgraded', async () => { const upgraded = await upgradeWebSocket(() => ({}))( new Context(new Request('http://localhost'), { env: { upgrade: () => true, }, }), () => Promise.resolve() ) expect(upgraded).toBeTruthy() }) it('Should response undefined when upgrade failed', async () => { const upgraded = await upgradeWebSocket(() => ({}))( new Context(new Request('http://localhost'), { env: { upgrade: () => undefined, }, }), () => Promise.resolve() ) expect(upgraded).toBeFalsy() }) it('Should events are called', async () => { const open = vi.fn() const message = vi.fn() const close = vi.fn() const ws = { data: { events: { // eslint-disable-next-line @typescript-eslint/no-unused-vars onOpen(evt, ws) { open() }, // eslint-disable-next-line @typescript-eslint/no-unused-vars onMessage(evt, ws) { message() if (evt.data instanceof ArrayBuffer) { receivedArrayBuffer = evt.data } }, // eslint-disable-next-line @typescript-eslint/no-unused-vars onClose(evt, ws) { close() }, }, }, } as BunServerWebSocket let receivedArrayBuffer: ArrayBuffer | undefined = undefined await upgradeWebSocket(() => ({}))( new Context(new Request('http://localhost'), { env: { upgrade() { return true }, }, }), () => Promise.resolve() ) websocket.open(ws) expect(open).toBeCalled() websocket.message(ws, 'message') expect(message).toBeCalled() websocket.message(ws, new Uint8Array(16)) expect(receivedArrayBuffer).toBeInstanceOf(ArrayBuffer) expect(receivedArrayBuffer!.byteLength).toBe(16) websocket.close(ws) expect(close).toBeCalled() }) }) describe('createBunWebSocket()', () => { it('Should return upgradeWebSocket and websocket', () => { const result = createBunWebSocket() expect(result.upgradeWebSocket).toBe(upgradeWebSocket) expect(result.websocket).toBe(websocket) }) }) ================================================ FILE: src/adapter/bun/websocket.ts ================================================ import type { UpgradeWebSocket, WSEvents, WSMessageReceive } from '../../helper/websocket' import { createWSMessageEvent, defineWebSocketHelper, WSContext } from '../../helper/websocket' import { getBunServer } from './server' /** * @internal */ export interface BunServerWebSocket { send(data: string | ArrayBuffer | Uint8Array, compress?: boolean): void close(code?: number, reason?: string): void data: T readyState: 0 | 1 | 2 | 3 } export interface BunWebSocketHandler { open(ws: BunServerWebSocket): void close(ws: BunServerWebSocket, code?: number, reason?: string): void message(ws: BunServerWebSocket, message: string | { buffer: ArrayBufferLike }): void } interface CreateWebSocket { upgradeWebSocket: UpgradeWebSocket websocket: BunWebSocketHandler } export interface BunWebSocketData { events: WSEvents url: URL protocol: string } /** * @internal */ export const createWSContext = (ws: BunServerWebSocket): WSContext => { return new WSContext({ send: (source, options) => { ws.send(source, options?.compress) }, raw: ws, readyState: ws.readyState, url: ws.data.url, protocol: ws.data.protocol, close(code, reason) { ws.close(code, reason) }, }) } export const upgradeWebSocket: UpgradeWebSocket = defineWebSocketHelper((c, events) => { const server = getBunServer<{ upgrade( req: Request, options?: { data: T } ): boolean }>(c) if (!server) { throw new TypeError('env has to include the 2nd argument of fetch.') } const upgradeResult = server.upgrade(c.req.raw, { data: { events, url: new URL(c.req.url), protocol: c.req.url, }, }) if (upgradeResult) { return new Response(null) } return // failed }) export const websocket: BunWebSocketHandler = { open(ws) { const websocketListeners = ws.data.events if (websocketListeners.onOpen) { websocketListeners.onOpen(new Event('open'), createWSContext(ws)) } }, close(ws, code, reason) { const websocketListeners = ws.data.events if (websocketListeners.onClose) { websocketListeners.onClose( new CloseEvent('close', { code, reason, }), createWSContext(ws) ) } }, message(ws, message) { const websocketListeners = ws.data.events if (websocketListeners.onMessage) { const normalizedReceiveData: WSMessageReceive = typeof message === 'string' ? message : message.buffer websocketListeners.onMessage(createWSMessageEvent(normalizedReceiveData), createWSContext(ws)) } }, } /** * @deprecated Import `upgradeWebSocket` and `websocket` directly from `hono/bun` instead. * @returns A function to create a Bun WebSocket handler. */ export const createBunWebSocket = (): CreateWebSocket => ({ upgradeWebSocket, websocket, }) ================================================ FILE: src/adapter/cloudflare-pages/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { it('Should return the client IP from cf-connecting-ip header', () => { const address = Math.random().toString() const req = new Request('http://localhost/', { headers: { 'cf-connecting-ip': address, }, }) const c = new Context(req) const info = getConnInfo(c) expect(info.remote.address).toBe(address) expect(info.remote.addressType).toBeUndefined() }) it('Should return undefined when cf-connecting-ip header is not present', () => { const c = new Context(new Request('http://localhost/')) const info = getConnInfo(c) expect(info.remote.address).toBeUndefined() }) }) ================================================ FILE: src/adapter/cloudflare-pages/conninfo.ts ================================================ import type { GetConnInfo } from '../../helper/conninfo' /** * Get connection information from Cloudflare Pages * @param c - Context * @returns Connection information including remote address * @example * ```ts * import { Hono } from 'hono' * import { handle, getConnInfo } from 'hono/cloudflare-pages' * * const app = new Hono() * * app.get('/', (c) => { * const info = getConnInfo(c) * return c.text(`Your IP: ${info.remote.address}`) * }) * * export const onRequest = handle(app) * ``` */ export const getConnInfo: GetConnInfo = (c) => ({ remote: { address: c.req.header('cf-connecting-ip'), }, }) ================================================ FILE: src/adapter/cloudflare-pages/handler.test.ts ================================================ import { getCookie } from '../../helper/cookie' import { Hono } from '../../hono' import { HTTPException } from '../../http-exception' import type { EventContext } from './handler' import { handle, handleMiddleware, serveStatic } from './handler' type Env = { Bindings: { TOKEN: string } } function createEventContext( context: Partial> ): EventContext { return { data: {}, env: { ...context.env, ASSETS: { fetch: vi.fn(), ...context.env?.ASSETS }, TOKEN: context.env?.TOKEN ?? 'HONOISHOT', }, functionPath: '_worker.js', next: vi.fn(), params: {}, passThroughOnException: vi.fn(), props: {}, request: new Request('http://localhost/api/foo'), waitUntil: vi.fn(), ...context, } } describe('Adapter for Cloudflare Pages', () => { it('Should return 200 response', async () => { const request = new Request('http://localhost/api/foo') const env = { ASSETS: { fetch }, TOKEN: 'HONOISHOT', } const waitUntil = vi.fn() const passThroughOnException = vi.fn() const props = {} const eventContext = createEventContext({ request, env, waitUntil, passThroughOnException, }) const app = new Hono() const appFetchSpy = vi.spyOn(app, 'fetch') app.get('/api/foo', (c) => { return c.json({ TOKEN: c.env.TOKEN, requestURL: c.req.url }) }) const handler = handle(app) const res = await handler(eventContext) expect(appFetchSpy).toHaveBeenCalledWith( request, { ...env, eventContext }, { waitUntil, passThroughOnException, props } ) expect(res.status).toBe(200) expect(await res.json()).toEqual({ TOKEN: 'HONOISHOT', requestURL: 'http://localhost/api/foo', }) }) it('Should not use `basePath()` if path argument is not passed', async () => { const request = new Request('http://localhost/api/error') const eventContext = createEventContext({ request }) const app = new Hono().basePath('/api') app.onError((e) => { throw e }) app.get('/error', () => { throw new Error('Custom Error') }) const handler = handle(app) // It does throw the error if app is NOT "subApp" expect(() => handler(eventContext)).toThrowError('Custom Error') }) }) describe('Middleware adapter for Cloudflare Pages', () => { it('Should return the middleware response', async () => { const request = new Request('http://localhost/api/foo', { headers: { Cookie: 'my_cookie=1234', }, }) const next = vi.fn().mockResolvedValue(Response.json('From Cloudflare Pages')) const eventContext = createEventContext({ request, next }) const handler = handleMiddleware(async (c, next) => { const cookie = getCookie(c, 'my_cookie') await next() return c.json({ cookie, response: await c.res.json() }) }) const res = await handler(eventContext) expect(next).toHaveBeenCalled() expect(await res.json()).toEqual({ cookie: '1234', response: 'From Cloudflare Pages', }) }) it('Should return the middleware response when exceptions are handled', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware(async (c, next) => { await next() return c.json({ error: c.error?.message }) }) const next = vi.fn().mockRejectedValue(new Error('Error from next()')) const eventContext = createEventContext({ request, next }) const res = await handler(eventContext) expect(next).toHaveBeenCalled() expect(await res.json()).toEqual({ error: 'Error from next()', }) }) it('Should return the middleware response if next() is not called', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware(async (c) => { return c.json({ response: 'Skip Cloudflare Pages' }) }) const next = vi.fn() const eventContext = createEventContext({ request, next }) const res = await handler(eventContext) expect(next).not.toHaveBeenCalled() expect(await res.json()).toEqual({ response: 'Skip Cloudflare Pages', }) }) it('Should return the Pages response if the middleware does not return a response', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware((_c, next) => next()) const next = vi.fn().mockResolvedValue(Response.json('From Cloudflare Pages')) const eventContext = createEventContext({ request, next }) const res = await handler(eventContext) expect(next).toHaveBeenCalled() expect(await res.json()).toEqual('From Cloudflare Pages') }) it('Should handle a HTTPException by returning error.getResponse()', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware(() => { const res = new Response('Unauthorized', { status: 401 }) throw new HTTPException(401, { res }) }) const next = vi.fn() const eventContext = createEventContext({ request, next }) const res = await handler(eventContext) expect(next).not.toHaveBeenCalled() expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) it('Should handle an HTTPException thrown by next()', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware((_c, next) => next()) const next = vi .fn() .mockRejectedValue(new HTTPException(401, { res: Response.json('Unauthorized') })) const eventContext = createEventContext({ request, next }) const res = await handler(eventContext) expect(next).toHaveBeenCalled() expect(await res.json()).toEqual('Unauthorized') }) it('Should handle an Error thrown by next()', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware((_c, next) => next()) const next = vi.fn().mockRejectedValue(new Error('Error from next()')) const eventContext = createEventContext({ request, next }) await expect(handler(eventContext)).rejects.toThrowError('Error from next()') expect(next).toHaveBeenCalled() }) it('Should handle a non-Error thrown by next()', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware((_c, next) => next()) const next = vi.fn().mockRejectedValue('Error from next()') const eventContext = createEventContext({ request, next }) await expect(handler(eventContext)).rejects.toThrowError('Error from next()') expect(next).toHaveBeenCalled() }) it('Should rethrow an Error', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware(() => { throw new Error('Something went wrong') }) const next = vi.fn() const eventContext = createEventContext({ request, next }) await expect(handler(eventContext)).rejects.toThrowError('Something went wrong') expect(next).not.toHaveBeenCalled() }) it('Should rethrow non-Error exceptions', async () => { const request = new Request('http://localhost/api/foo') const handler = handleMiddleware(() => Promise.reject('Something went wrong')) const next = vi.fn() const eventContext = createEventContext({ request, next }) await expect(handler(eventContext)).rejects.toThrowError('Something went wrong') expect(next).not.toHaveBeenCalled() }) it('Should set the data in eventContext.data', async () => { const next = vi.fn() const eventContext = createEventContext({ next }) const handler = handleMiddleware(async (c, next) => { c.env.eventContext.data.user = 'Joe' await next() }) expect(eventContext.data.user).toBeUndefined() await handler(eventContext) expect(eventContext.data.user).toBe('Joe') }) }) describe('serveStatic()', () => { it('Should pass the raw request to ASSETS.fetch', async () => { const assetsFetch = vi.fn().mockResolvedValue(new Response('foo.png')) const request = new Request('http://localhost/foo.png') const env = { ASSETS: { fetch: assetsFetch }, TOKEN: 'HONOISHOT', } const eventContext = createEventContext({ request, env }) const app = new Hono() app.use(serveStatic()) const handler = handle(app) const res = await handler(eventContext) expect(assetsFetch).toHaveBeenCalledWith(request) expect(res.status).toBe(200) expect(await res.text()).toBe('foo.png') }) it('Should respond with 404 if ASSETS.fetch returns a 404 response', async () => { const assetsFetch = vi.fn().mockResolvedValue(new Response(null, { status: 404 })) const request = new Request('http://localhost/foo.png') const env = { ASSETS: { fetch: assetsFetch }, TOKEN: 'HONOISHOT', } const eventContext = createEventContext({ request, env }) const app = new Hono() app.use(serveStatic()) const handler = handle(app) const res = await handler(eventContext) expect(assetsFetch).toHaveBeenCalledWith(request) expect(res.status).toBe(404) }) }) ================================================ FILE: src/adapter/cloudflare-pages/handler.ts ================================================ import { Context } from '../../context' import type { Hono } from '../../hono' import { HTTPException } from '../../http-exception' import type { BlankSchema, Env, Input, MiddlewareHandler, Schema } from '../../types' // Ref: https://github.com/cloudflare/workerd/blob/main/types/defines/pages.d.ts // eslint-disable-next-line @typescript-eslint/no-explicit-any type Params

= Record // eslint-disable-next-line @typescript-eslint/no-explicit-any export type EventContext> = { request: Request functionPath: string waitUntil: (promise: Promise) => void passThroughOnException: () => void // eslint-disable-next-line @typescript-eslint/no-explicit-any props: any next: (input?: Request | string, init?: RequestInit) => Promise env: Env & { ASSETS: { fetch: typeof fetch } } params: Params

data: Data } declare type PagesFunction< Env = unknown, // eslint-disable-next-line @typescript-eslint/no-explicit-any Params extends string = any, Data extends Record = Record, > = (context: EventContext) => Response | Promise export const handle = ( app: Hono ): PagesFunction => (eventContext) => { return app.fetch( eventContext.request, { ...eventContext.env, eventContext }, { waitUntil: eventContext.waitUntil, passThroughOnException: eventContext.passThroughOnException, props: {}, } ) } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function handleMiddleware( middleware: MiddlewareHandler< E & { Bindings: { eventContext: EventContext } }, P, I > ): PagesFunction { return async (executionCtx) => { const context = new Context(executionCtx.request, { env: { ...executionCtx.env, eventContext: executionCtx }, executionCtx, }) let response: Response | void = undefined try { response = await middleware(context, async () => { try { context.res = await executionCtx.next() } catch (error) { if (error instanceof Error) { context.error = error } else { throw error } } }) } catch (error) { if (error instanceof Error) { context.error = error } else { throw error } } if (response) { return response } if (context.error instanceof HTTPException) { return context.error.getResponse() } if (context.error) { throw context.error } return context.res } } declare abstract class FetcherLike { fetch(input: RequestInfo, init?: RequestInit): Promise } /** * * @description `serveStatic()` is for advanced mode: * https://developers.cloudflare.com/pages/platform/functions/advanced-mode/#set-up-a-function * */ export const serveStatic = (): MiddlewareHandler => { return async (c) => { const env = c.env as { ASSETS: FetcherLike } const res = await env.ASSETS.fetch(c.req.raw) if (res.status === 404) { return c.notFound() } return res } } ================================================ FILE: src/adapter/cloudflare-pages/index.ts ================================================ /** * @module * Cloudflare Pages Adapter for Hono. */ export { handle, handleMiddleware, serveStatic } from './handler' export { getConnInfo } from './conninfo' export type { EventContext } from './handler' ================================================ FILE: src/adapter/cloudflare-workers/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { it('Should getConnInfo works', () => { const address = Math.random().toString() const req = new Request('http://localhost/', { headers: { 'cf-connecting-ip': address, }, }) const c = new Context(req) const info = getConnInfo(c) expect(info.remote.address).toBe(address) expect(info.remote.addressType).toBeUndefined() }) }) ================================================ FILE: src/adapter/cloudflare-workers/conninfo.ts ================================================ import type { GetConnInfo } from '../../helper/conninfo' export const getConnInfo: GetConnInfo = (c) => ({ remote: { address: c.req.header('cf-connecting-ip'), }, }) ================================================ FILE: src/adapter/cloudflare-workers/index.ts ================================================ /** * @module * Cloudflare Workers Adapter for Hono. */ export { serveStatic } from './serve-static-module' export { upgradeWebSocket } from './websocket' export { getConnInfo } from './conninfo' ================================================ FILE: src/adapter/cloudflare-workers/serve-static-module.ts ================================================ // For ES module mode import type { Env, MiddlewareHandler } from '../../types' import type { ServeStaticOptions } from './serve-static' import { serveStatic } from './serve-static' const module = ( options: Omit, 'namespace'> ): MiddlewareHandler => { return serveStatic(options) } export { module as serveStatic } ================================================ FILE: src/adapter/cloudflare-workers/serve-static.test.ts ================================================ import type { Context } from '../../context' import { Hono } from '../../hono' import type { Next } from '../../types' import { serveStatic } from './serve-static' // Mock const store: Record = { 'assets/static/plain.abcdef.txt': 'This is plain.txt', 'assets/static/hono.abcdef.html': '

Hono!

', 'assets/static/top/index.abcdef.html': '

Top

', 'static-no-root/plain.abcdef.txt': 'That is plain.txt', 'assets/static/options/foo.abcdef.txt': 'With options', 'assets/.static/plain.abcdef.txt': 'In the dot', 'assets/static/video/morning-routine.abcdef.m3u8': 'Good morning', 'assets/static/video/morning-routine1.abcdef.ts': 'Good', 'assets/static/video/introduction.abcdef.mp4': 'Let me introduce myself', 'assets/static/download': 'download', } const manifest = JSON.stringify({ 'assets/static/plain.txt': 'assets/static/plain.abcdef.txt', 'assets/static/hono.html': 'assets/static/hono.abcdef.html', 'assets/static/top/index.html': 'assets/static/top/index.abcdef.html', 'static-no-root/plain.txt': 'static-no-root/plain.abcdef.txt', 'assets/.static/plain.txt': 'assets/.static/plain.abcdef.txt', 'assets/static/download': 'assets/static/download', }) Object.assign(global, { __STATIC_CONTENT_MANIFEST: manifest }) Object.assign(global, { __STATIC_CONTENT: { get: (path: string) => { return store[path] }, }, }) describe('ServeStatic Middleware', () => { const app = new Hono() const onNotFound = vi.fn(() => {}) app.use('/static/*', serveStatic({ root: './assets', onNotFound, manifest })) app.use('/static-no-root/*', serveStatic({ manifest })) app.use( '/dot-static/*', serveStatic({ root: './assets', rewriteRequestPath: (path) => path.replace(/^\/dot-static/, '/.static'), manifest, }) ) beforeEach(() => onNotFound.mockClear()) it('Should return plain.txt', async () => { const res = await app.request('http://localhost/static/plain.txt') expect(res.status).toBe(200) expect(await res.text()).toBe('This is plain.txt') expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') expect(onNotFound).not.toHaveBeenCalled() }) it('Should return hono.html', async () => { const res = await app.request('http://localhost/static/hono.html') expect(res.status).toBe(200) expect(await res.text()).toBe('

Hono!

') expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8') expect(onNotFound).not.toHaveBeenCalled() }) it('Should return 404 response', async () => { const res = await app.request('http://localhost/static/not-found.html') expect(res.status).toBe(404) expect(onNotFound).toHaveBeenCalledWith('assets/static/not-found.html', expect.anything()) }) it('Should return plan.txt', async () => { const res = await app.request('http://localhost/static-no-root/plain.txt') expect(res.status).toBe(200) expect(await res.text()).toBe('That is plain.txt') expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') }) // Serve static on Cloudflare Workers cannot determine whether the target path is a directory or not it.skip('Should return index.html', async () => { const res = await app.request('http://localhost/static/top') expect(res.status).toBe(200) expect(await res.text()).toBe('

Top

') expect(res.headers.get('Content-Type')).toBe('text/html; charset=utf-8') }) it('Should return plain.txt with a rewriteRequestPath option', async () => { const res = await app.request('http://localhost/dot-static/plain.txt') expect(res.status).toBe(200) expect(await res.text()).toBe('In the dot') expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') }) }) describe('With options', () => { const manifest = { 'assets/static/options/foo.txt': 'assets/static/options/foo.abcdef.txt', } const app = new Hono() app.use('/static/*', serveStatic({ root: './assets', manifest: manifest })) it('Should return foo.txt', async () => { const res = await app.request('http://localhost/static/options/foo.txt') expect(res.status).toBe(200) expect(await res.text()).toBe('With options') expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') }) }) describe('With `file` options', () => { const app = new Hono() app.get('/foo/*', serveStatic({ path: './assets/static/hono.html', manifest })) app.get('/bar/*', serveStatic({ path: './static/hono.html', root: './assets', manifest })) it('Should return hono.html', async () => { const res = await app.request('http://localhost/foo/fallback') expect(res.status).toBe(200) expect(await res.text()).toBe('

Hono!

') }) it('Should return hono.html - with `root` option', async () => { const res = await app.request('http://localhost/bar/fallback') expect(res.status).toBe(200) expect(await res.text()).toBe('

Hono!

') }) }) describe('With `mimes` options', () => { const mimes = { m3u8: 'application/vnd.apple.mpegurl', ts: 'video/mp2t', } const manifest = { 'assets/static/video/morning-routine.m3u8': 'assets/static/video/morning-routine.abcdef.m3u8', 'assets/static/video/morning-routine1.ts': 'assets/static/video/morning-routine1.abcdef.ts', 'assets/static/video/introduction.mp4': 'assets/static/video/introduction.abcdef.mp4', } const app = new Hono() app.use('/static/*', serveStatic({ root: './assets', mimes, manifest })) it('Should return content-type of m3u8', async () => { const res = await app.request('http://localhost/static/video/morning-routine.m3u8') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('application/vnd.apple.mpegurl') }) it('Should return content-type of ts', async () => { const res = await app.request('http://localhost/static/video/morning-routine1.ts') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('video/mp2t') }) it('Should return content-type of default on Hono', async () => { const res = await app.request('http://localhost/static/video/introduction.mp4') expect(res.status).toBe(200) expect(res.headers.get('Content-Type')).toBe('video/mp4') }) }) describe('With middleware', () => { const app = new Hono() const md1 = async (c: Context, next: Next) => { await next() c.res.headers.append('x-foo', 'bar') } const md2 = async (c: Context, next: Next) => { await next() c.res.headers.append('x-foo2', 'bar2') } app.use('/static/*', md1) app.use('/static/*', md2) app.use('/static/*', serveStatic({ root: './assets', manifest })) app.get('/static/foo', (c) => { return c.text('bar') }) it('Should return plain.txt with correct headers', async () => { const res = await app.request('http://localhost/static/plain.txt') expect(res.status).toBe(200) expect(await res.text()).toBe('This is plain.txt') expect(res.headers.get('Content-Type')).toBe('text/plain; charset=utf-8') expect(res.headers.get('x-foo')).toBe('bar') expect(res.headers.get('x-foo2')).toBe('bar2') }) it('Should return 200 Response', async () => { const res = await app.request('http://localhost/static/foo') expect(res.status).toBe(200) expect(await res.text()).toBe('bar') }) it('Should handle a file without an extension', async () => { const res = await app.request('http://localhost/static/download') expect(res.status).toBe(200) }) }) describe('Types of middleware', () => { it('Should pass env type from generics of serveStatic', async () => { type Env = { Bindings: { HOGE: string } } const app = new Hono() app.use( '/static/*', serveStatic({ root: './assets', onNotFound: (_, c) => { expectTypeOf(c.env).toEqualTypeOf() }, manifest, }) ) }) }) ================================================ FILE: src/adapter/cloudflare-workers/serve-static.ts ================================================ import { serveStatic as baseServeStatic } from '../../middleware/serve-static' import type { ServeStaticOptions as BaseServeStaticOptions } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' import { getContentFromKVAsset } from './utils' export type ServeStaticOptions = BaseServeStaticOptions & { // namespace is KVNamespace namespace?: unknown manifest: object | string } /** * @deprecated * `serveStatic` in the Cloudflare Workers adapter is deprecated. * You can serve static files directly using Cloudflare Static Assets. * @see https://developers.cloudflare.com/workers/static-assets/ * Cloudflare Static Assets is currently in open beta. If this doesn't work for you, * please consider using Cloudflare Pages. You can start to create the Cloudflare Pages * application with the `npm create hono@latest` command. */ export const serveStatic = ( options: ServeStaticOptions ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { return getContentFromKVAsset(path, { manifest: options.manifest, // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore namespace: options.namespace ? options.namespace : c.env ? c.env.__STATIC_CONTENT : undefined, }) } return baseServeStatic({ ...options, getContent, })(c, next) } } ================================================ FILE: src/adapter/cloudflare-workers/utils.test.ts ================================================ import { getContentFromKVAsset } from './utils' // Mock const store: { [key: string]: string } = { 'index.abcdef.html': 'This is index', 'assets/static/plain.abcdef.txt': 'Asset text', } const manifest = JSON.stringify({ 'index.html': 'index.abcdef.html', 'assets/static/plain.txt': 'assets/static/plain.abcdef.txt', }) Object.assign(global, { __STATIC_CONTENT_MANIFEST: manifest }) Object.assign(global, { __STATIC_CONTENT: { get: (path: string) => { return store[path] }, }, }) describe('Utils for Cloudflare Workers', () => { it('getContentFromKVAsset', async () => { let content = await getContentFromKVAsset('not-found.txt') expect(content).toBeFalsy() content = await getContentFromKVAsset('index.html') expect(content).toBeTruthy() expect(content).toBe('This is index') content = await getContentFromKVAsset('assets/static/plain.txt') expect(content).toBeTruthy() expect(content).toBe('Asset text') }) }) ================================================ FILE: src/adapter/cloudflare-workers/utils.ts ================================================ // __STATIC_CONTENT is KVNamespace declare const __STATIC_CONTENT: unknown declare const __STATIC_CONTENT_MANIFEST: string export type KVAssetOptions = { manifest?: object | string // namespace is KVNamespace namespace?: unknown } export const getContentFromKVAsset = async ( path: string, options?: KVAssetOptions ): Promise => { let ASSET_MANIFEST: Record if (options && options.manifest) { if (typeof options.manifest === 'string') { ASSET_MANIFEST = JSON.parse(options.manifest) } else { ASSET_MANIFEST = options.manifest as Record } } else { if (typeof __STATIC_CONTENT_MANIFEST === 'string') { ASSET_MANIFEST = JSON.parse(__STATIC_CONTENT_MANIFEST) } else { ASSET_MANIFEST = __STATIC_CONTENT_MANIFEST } } // ASSET_NAMESPACE is KVNamespace let ASSET_NAMESPACE: unknown if (options && options.namespace) { ASSET_NAMESPACE = options.namespace } else { ASSET_NAMESPACE = __STATIC_CONTENT } const key = ASSET_MANIFEST[path] if (!key) { return null } // @ts-expect-error ASSET_NAMESPACE is not typed const content = await ASSET_NAMESPACE.get(key, { type: 'stream' }) if (!content) { return null } return content as unknown as ReadableStream } ================================================ FILE: src/adapter/cloudflare-workers/websocket.test.ts ================================================ import { Hono } from '../..' import { Context } from '../../context' import { upgradeWebSocket } from '.' describe('upgradeWebSocket middleware', () => { const server = new EventTarget() // @ts-expect-error Cloudflare API globalThis.WebSocketPair = class { 0: WebSocket // client 1: WebSocket // server constructor() { this[0] = {} as WebSocket this[1] = server as WebSocket } } const app = new Hono() const wsPromise = new Promise((resolve) => app.get( '/ws', upgradeWebSocket(() => ({ onMessage(evt, ws) { resolve([evt.data, ws.readyState || 1]) }, })) ) ) it('Should receive message and readyState is valid', async () => { const sendingData = Math.random().toString() await app.request('/ws', { headers: { Upgrade: 'websocket', }, }) server.dispatchEvent( new MessageEvent('message', { data: sendingData, }) ) expect([sendingData, 1]).toStrictEqual(await wsPromise) }) it('Should call next() when header does not have upgrade', async () => { const next = vi.fn() await upgradeWebSocket(() => ({}))( new Context( new Request('http://localhost', { headers: { Upgrade: 'example', }, }) ), next ) expect(next).toBeCalled() }) }) ================================================ FILE: src/adapter/cloudflare-workers/websocket.ts ================================================ import { WSContext, defineWebSocketHelper } from '../../helper/websocket' import type { UpgradeWebSocket, WSEvents, WSReadyState } from '../../helper/websocket' // Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332 export const upgradeWebSocket: UpgradeWebSocket< WebSocket, // eslint-disable-next-line @typescript-eslint/no-explicit-any any, Omit, 'onOpen'> > = defineWebSocketHelper(async (c, events) => { const upgradeHeader = c.req.header('Upgrade') if (upgradeHeader !== 'websocket') { return } // @ts-expect-error WebSocketPair is not typed const webSocketPair = new WebSocketPair() const client: WebSocket = webSocketPair[0] const server: WebSocket = webSocketPair[1] const wsContext = new WSContext({ close: (code, reason) => server.close(code, reason), get protocol() { return server.protocol }, raw: server, get readyState() { return server.readyState as WSReadyState }, url: server.url ? new URL(server.url) : null, send: (source) => server.send(source), }) // note: cloudflare workers doesn't support 'open' event if (events.onClose) { server.addEventListener('close', (evt: CloseEvent) => events.onClose?.(evt, wsContext)) } if (events.onMessage) { server.addEventListener('message', (evt: MessageEvent) => events.onMessage?.(evt, wsContext)) } if (events.onError) { server.addEventListener('error', (evt: Event) => events.onError?.(evt, wsContext)) } // @ts-expect-error - server.accept is not typed server.accept?.() return new Response(null, { status: 101, // @ts-expect-error - webSocket is not typed webSocket: client, }) }) ================================================ FILE: src/adapter/deno/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { it('Should info is valid', () => { const transport = 'tcp' const address = Math.random().toString() const port = Math.floor(Math.random() * (65535 + 1)) const c = new Context(new Request('http://localhost/'), { env: { remoteAddr: { transport, hostname: address, port, }, }, }) const info = getConnInfo(c) expect(info.remote.port).toBe(port) expect(info.remote.address).toBe(address) expect(info.remote.addressType).toBeUndefined() expect(info.remote.transport).toBe(transport) }) }) ================================================ FILE: src/adapter/deno/conninfo.ts ================================================ import type { GetConnInfo } from '../../helper/conninfo' /** * Get conninfo with Deno * @param c Context * @returns ConnInfo */ export const getConnInfo: GetConnInfo = (c) => { const { remoteAddr } = c.env return { remote: { address: remoteAddr.hostname, port: remoteAddr.port, transport: remoteAddr.transport, }, } } ================================================ FILE: src/adapter/deno/deno.d.ts ================================================ declare namespace Deno { interface FileHandleLike { readonly readable: ReadableStream } /** * Open the file using the specified path. * * @param path The path to open the file. * @returns FileHandle object. */ export function open(path: string): Promise interface StatsLike { isDirectory: boolean } /** * Get stats with the specified path. * * @param path The path to get stats. * @returns Stats object. */ export function lstatSync(path: string): StatsLike /** * Creates a new directory with the specified path. * * @param path The path to create a directory. * @param options Options for creating a directory. * @returns A promise that resolves when the directory is created. */ export function mkdir(path: string, options?: { recursive?: boolean }): Promise /** * Write a new file, with the specified path and data. * * @param path The path to the file to write. * @param data The data to write into the file. * @returns A promise that resolves when the file is written. */ export function writeFile(path: string, data: Uint8Array): Promise /** * Errors of Deno */ export const errors: Record export function upgradeWebSocket( req: Request, options: UpgradeWebSocketOptions ): { response: Response socket: WebSocket } /** * Options of `upgradeWebSocket` */ export interface UpgradeWebSocketOptions { /** * Sets the `.protocol` property on the client-side web socket to the * value provided here, which should be one of the strings specified in the * `protocols` parameter when requesting the web socket. This is intended * for clients and servers to specify sub-protocols to use to communicate to * each other. */ protocol?: string /** * If the client does not respond to this frame with a * `pong` within the timeout specified, the connection is deemed * unhealthy and is closed. The `close` and `error` events will be emitted. * * The unit is seconds, with a default of 30. * Set to `0` to disable timeouts. */ idleTimeout?: number } } ================================================ FILE: src/adapter/deno/index.ts ================================================ /** * @module * Deno Adapter for Hono. */ export { serveStatic } from './serve-static' export { toSSG, denoFileSystemModule } from './ssg' export { upgradeWebSocket } from './websocket' export { getConnInfo } from './conninfo' ================================================ FILE: src/adapter/deno/serve-static.ts ================================================ import { join } from 'node:path' import type { ServeStaticOptions } from '../../middleware/serve-static' import { serveStatic as baseServeStatic } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' const { open, lstatSync, errors } = Deno export const serveStatic = ( options: ServeStaticOptions ): MiddlewareHandler => { return async function serveStatic(c, next) { const getContent = async (path: string) => { try { if (isDir(path)) { return null } const file = await open(path) return file.readable } catch (e) { if (!(e instanceof errors.NotFound)) { console.warn(`${e}`) } return null } } const isDir = (path: string) => { let isDir try { const stat = lstatSync(path) isDir = stat.isDirectory } catch {} return isDir } return baseServeStatic({ ...options, getContent, join, isDir, })(c, next) } } ================================================ FILE: src/adapter/deno/ssg.ts ================================================ import { toSSG as baseToSSG } from '../../helper/ssg/index' import type { FileSystemModule, ToSSGAdaptorInterface } from '../../helper/ssg/index' /** * @experimental * `denoFileSystemModule` is an experimental feature. * The API might be changed. */ export const denoFileSystemModule: FileSystemModule = { writeFile: async (path, data) => { const uint8Data = typeof data === 'string' ? new TextEncoder().encode(data) : new Uint8Array(data) await Deno.writeFile(path, uint8Data) }, mkdir: async (path, options) => { return Deno.mkdir(path, { recursive: options?.recursive ?? false }) }, } /** * @experimental * `toSSG` is an experimental feature. * The API might be changed. */ export const toSSG: ToSSGAdaptorInterface = async (app, options) => { return baseToSSG(app, denoFileSystemModule, options) } ================================================ FILE: src/adapter/deno/websocket.test.ts ================================================ import { Hono } from '../..' import { Context } from '../../context' import { upgradeWebSocket } from './websocket' globalThis.Deno = {} as typeof Deno describe('WebSockets', () => { let app: Hono beforeEach(() => { app = new Hono() }) it('Should receive data is valid', async () => { const messagePromise = new Promise((resolve) => app.get( '/ws', upgradeWebSocket(() => ({ onMessage: (evt) => resolve(evt.data), })) ) ) const socket = new EventTarget() as WebSocket Deno.upgradeWebSocket = () => { return { response: new Response(), socket, } } await app.request('/ws', { headers: { upgrade: 'websocket', }, }) const data = Math.random().toString() socket.onmessage && socket.onmessage( new MessageEvent('message', { data, }) ) expect(await messagePromise).toBe(data) }) it('Should receive data is valid with Options', async () => { const messagePromise = new Promise((resolve) => app.get( '/ws', upgradeWebSocket( () => ({ onMessage: (evt) => resolve(evt.data), }), { idleTimeout: 5000, } ) ) ) const socket = new EventTarget() as WebSocket Deno.upgradeWebSocket = () => { return { response: new Response(), socket, } } await app.request('/ws', { headers: { upgrade: 'websocket', }, }) const data = Math.random().toString() socket.onmessage && socket.onmessage( new MessageEvent('message', { data, }) ) expect(await messagePromise).toBe(data) }) it('Should call next() when header does not have upgrade', async () => { const next = vi.fn() await upgradeWebSocket(() => ({}))( new Context( new Request('http://localhost', { headers: { Upgrade: 'example', }, }) ), next ) expect(next).toBeCalled() }) }) ================================================ FILE: src/adapter/deno/websocket.ts ================================================ import type { UpgradeWebSocket, WSReadyState } from '../../helper/websocket' import { WSContext, defineWebSocketHelper } from '../../helper/websocket' export const upgradeWebSocket: UpgradeWebSocket = defineWebSocketHelper(async (c, events, options) => { if (c.req.header('upgrade') !== 'websocket') { return } const { response, socket } = Deno.upgradeWebSocket(c.req.raw, options ?? {}) const wsContext: WSContext = new WSContext({ close: (code, reason) => socket.close(code, reason), get protocol() { return socket.protocol }, raw: socket, get readyState() { return socket.readyState as WSReadyState }, url: socket.url ? new URL(socket.url) : null, send: (source) => socket.send(source), }) socket.onopen = (evt) => events.onOpen?.(evt, wsContext) socket.onmessage = (evt) => events.onMessage?.(evt, wsContext) socket.onclose = (evt) => events.onClose?.(evt, wsContext) socket.onerror = (evt) => events.onError?.(evt, wsContext) return response }) ================================================ FILE: src/adapter/lambda-edge/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' import type { CloudFrontEdgeEvent } from './handler' describe('getConnInfo', () => { it('Should info is valid', () => { const clientIp = Math.random().toString() const env = { event: { Records: [ { cf: { request: { clientIp, }, }, }, ], } as CloudFrontEdgeEvent, } const c = new Context(new Request('http://localhost/'), { env }) const info = getConnInfo(c) expect(info.remote.address).toBe(clientIp) }) }) ================================================ FILE: src/adapter/lambda-edge/conninfo.ts ================================================ import type { Context } from '../../context' import type { GetConnInfo } from '../../helper/conninfo' import type { CloudFrontEdgeEvent } from './handler' type Env = { Bindings: { event: CloudFrontEdgeEvent } } export const getConnInfo: GetConnInfo = (c: Context) => ({ remote: { address: c.env.event.Records[0].cf.request.clientIp, }, }) ================================================ FILE: src/adapter/lambda-edge/handler.test.ts ================================================ import { describe } from 'vitest' import { setCookie } from '../../helper/cookie' import { Hono } from '../../hono' import { encodeBase64 } from '../../utils/encode' import type { Callback, CloudFrontEdgeEvent } from './handler' import { createBody, handle, isContentTypeBinary } from './handler' describe('isContentTypeBinary', () => { it('Should determine whether it is binary', () => { expect(isContentTypeBinary('image/png')).toBe(true) expect(isContentTypeBinary('font/woff2')).toBe(true) expect(isContentTypeBinary('image/svg+xml')).toBe(false) expect(isContentTypeBinary('image/svg+xml; charset=UTF-8')).toBe(false) expect(isContentTypeBinary('text/plain')).toBe(false) expect(isContentTypeBinary('text/plain; charset=UTF-8')).toBe(false) expect(isContentTypeBinary('text/css')).toBe(false) expect(isContentTypeBinary('text/javascript')).toBe(false) expect(isContentTypeBinary('application/json')).toBe(false) expect(isContentTypeBinary('application/ld+json')).toBe(false) expect(isContentTypeBinary('application/json')).toBe(false) }) }) describe('createBody', () => { it('Should the request be a GET or HEAD, the Request must not include a Body', () => { const encoder = new TextEncoder() const data = encoder.encode('test') const body = { action: 'read-only', data: encodeBase64(data.buffer), encoding: 'base64', inputTruncated: false, } expect(createBody('GET', body)).toEqual(undefined) expect(createBody('GET', body)).not.toEqual(data) expect(createBody('HEAD', body)).toEqual(undefined) expect(createBody('HEAD', body)).not.toEqual(data) expect(createBody('POST', body)).toEqual(data) expect(createBody('POST', body)).not.toEqual(undefined) }) }) describe('handle', () => { const cloudFrontEdgeEvent: CloudFrontEdgeEvent = { Records: [ { cf: { config: { distributionDomainName: 'd111111abcdef8.cloudfront.net', distributionId: 'EDFDVBD6EXAMPLE', eventType: 'viewer-request', requestId: '4TyzHTaYWb1GX1qTfsHhEqV6HUDd_BzoBZnwfnvQc_1oF26ClkoUSEQ==', }, request: { clientIp: '1.2.3.4', headers: { host: [ { key: 'Host', value: 'hono.dev', }, ], accept: [ { key: 'accept', value: '*/*', }, ], }, method: 'GET', querystring: '', uri: '/test-path', }, }, }, ], } it('Should support alternate domain names', async () => { const app = new Hono() app.get('/test-path', (c) => { return c.text(c.req.url) }) const handler = handle(app) const res = await handler(cloudFrontEdgeEvent) expect(res.body).toBe('https://hono.dev/test-path') }) it('Should expose async handler arity compatible with NODEJS_24_X', () => { const app = new Hono() const handler = handle(app) expect(handler.length).toBeLessThanOrEqual(2) }) it('Should preserve positional callback compatibility', async () => { type Env = { Bindings: { callback: Callback } } const app = new Hono() const callback = vi.fn() app.get('/test-path', (c) => { c.env.callback?.(null, { status: '200', headers: { 'x-test': [{ key: 'x-test', value: 'ok' }], }, }) return c.text('ok') }) const handler = handle(app) await handler(cloudFrontEdgeEvent, undefined, callback) expect(callback).toHaveBeenCalledWith(null, { status: '200', headers: { 'x-test': [{ key: 'x-test', value: 'ok' }], }, }) }) it('Should support multiple cookies', async () => { const app = new Hono() app.get('/test-path', (c) => { setCookie(c, 'cookie1', 'value1') setCookie(c, 'cookie2', 'value2') return c.text('') }) const handler = handle(app) const res = await handler(cloudFrontEdgeEvent) expect(res.headers).toEqual({ 'content-type': [ { key: 'content-type', value: 'text/plain; charset=UTF-8', }, ], 'set-cookie': [ { key: 'set-cookie', value: 'cookie1=value1; Path=/', }, { key: 'set-cookie', value: 'cookie2=value2; Path=/', }, ], }) }) }) ================================================ FILE: src/adapter/lambda-edge/handler.ts ================================================ import crypto from 'node:crypto' import type { Hono } from '../../hono' import { decodeBase64, encodeBase64 } from '../../utils/encode' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore globalThis.crypto ??= crypto interface CloudFrontHeader { key: string value: string } interface CloudFrontHeaders { [name: string]: CloudFrontHeader[] } interface CloudFrontCustomOrigin { customHeaders: CloudFrontHeaders domainName: string keepaliveTimeout: number path: string port: number protocol: string readTimeout: number sslProtocols: string[] } // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html interface CloudFrontS3Origin { authMethod: 'origin-access-identity' | 'none' customHeaders: CloudFrontHeaders domainName: string path: string region: string } type CloudFrontOrigin = | { s3: CloudFrontS3Origin; custom?: never } | { custom: CloudFrontCustomOrigin; s3?: never } export interface CloudFrontRequest { clientIp: string headers: CloudFrontHeaders method: string querystring: string uri: string body?: { inputTruncated: boolean action: string encoding: string data: string } origin?: CloudFrontOrigin } export interface CloudFrontResponse { headers: CloudFrontHeaders status: string statusDescription?: string } export interface CloudFrontConfig { distributionDomainName: string distributionId: string eventType: string requestId: string } interface CloudFrontEvent { cf: { config: CloudFrontConfig request: CloudFrontRequest response?: CloudFrontResponse } } export interface CloudFrontEdgeEvent { Records: CloudFrontEvent[] } type CloudFrontContext = {} export interface Callback { (err: Error | null, result?: CloudFrontRequest | CloudFrontResult): void } // https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-generating-http-responses-in-requests.html#lambda-generating-http-responses-programming-model interface CloudFrontResult { status: string statusDescription?: string headers?: { [header: string]: { key: string value: string }[] } body?: string bodyEncoding?: 'text' | 'base64' } /** * Accepts events from 'Lambda@Edge' event * https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html */ const convertHeaders = (headers: Headers): CloudFrontHeaders => { const cfHeaders: CloudFrontHeaders = {} headers.forEach((value, key) => { cfHeaders[key.toLowerCase()] = [ ...(cfHeaders[key.toLowerCase()] || []), { key: key.toLowerCase(), value }, ] }) return cfHeaders } export const handle = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any app: Hono ): (( event: CloudFrontEdgeEvent, context?: CloudFrontContext, callback?: Callback ) => Promise) => { return async (event, ...args: [context?: CloudFrontContext, callback?: Callback]) => { const [context, callback] = args const res = await app.fetch(createRequest(event), { event, context, callback: (err: Error | null, result?: CloudFrontResult | CloudFrontRequest) => { callback?.(err, result) }, config: event.Records[0].cf.config, request: event.Records[0].cf.request, response: event.Records[0].cf.response, }) return createResult(res) } } const createResult = async (res: Response): Promise => { const isBase64Encoded = isContentTypeBinary(res.headers.get('content-type') || '') const body = isBase64Encoded ? encodeBase64(await res.arrayBuffer()) : await res.text() return { status: res.status.toString(), headers: convertHeaders(res.headers), body, ...(isBase64Encoded && { bodyEncoding: 'base64' }), } } const createRequest = (event: CloudFrontEdgeEvent): Request => { const queryString = event.Records[0].cf.request.querystring const host = event.Records[0].cf.request.headers?.host?.[0]?.value || event.Records[0].cf.config.distributionDomainName const urlPath = `https://${host}${event.Records[0].cf.request.uri}` const url = queryString ? `${urlPath}?${queryString}` : urlPath const headers = new Headers() Object.entries(event.Records[0].cf.request.headers).forEach(([k, v]) => { v.forEach((header) => headers.set(k, header.value)) }) const requestBody = event.Records[0].cf.request.body const method = event.Records[0].cf.request.method const body = createBody(method, requestBody) return new Request(url, { headers, method, body, }) } export const createBody = ( method: string, requestBody: CloudFrontRequest['body'] ): string | Uint8Array | undefined => { if (!requestBody || !requestBody.data) { return undefined } if (method === 'GET' || method === 'HEAD') { return undefined } if (requestBody.encoding === 'base64') { return decodeBase64(requestBody.data) } return requestBody.data } export const isContentTypeBinary = (contentType: string): boolean => { return !/^(text\/(plain|html|css|javascript|csv).*|application\/(.*json|.*xml).*|image\/svg\+xml.*)$/.test( contentType ) } ================================================ FILE: src/adapter/lambda-edge/index.ts ================================================ /** * @module * Lambda@Edge Adapter for Hono. */ export { handle } from './handler' export { getConnInfo } from './conninfo' export type { Callback, CloudFrontConfig, CloudFrontRequest, CloudFrontResponse, CloudFrontEdgeEvent, } from './handler' ================================================ FILE: src/adapter/netlify/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { it('Should return the client IP from context.ip', () => { const ip = '203.0.113.50' const c = new Context(new Request('http://localhost/'), { env: { context: { ip, }, }, }) const info = getConnInfo(c) expect(info.remote.address).toBe(ip) }) it('Should return undefined when context.ip is not present', () => { const c = new Context(new Request('http://localhost/'), { env: { context: {}, }, }) const info = getConnInfo(c) expect(info.remote.address).toBeUndefined() }) it('Should return undefined when context is not present', () => { const c = new Context(new Request('http://localhost/'), { env: {}, }) const info = getConnInfo(c) expect(info.remote.address).toBeUndefined() }) }) ================================================ FILE: src/adapter/netlify/conninfo.ts ================================================ import type { Context } from '../../context' import type { GetConnInfo } from '../../helper/conninfo' /** * Netlify context type * @see https://docs.netlify.com/functions/api/ */ type NetlifyContext = { ip?: string geo?: { city?: string country?: { code?: string name?: string } subdivision?: { code?: string name?: string } latitude?: number longitude?: number timezone?: string postalCode?: string } requestId?: string } type Env = { Bindings: { context: NetlifyContext } } /** * Get connection information from Netlify * @param c - Context * @returns Connection information including remote address * @example * ```ts * import { Hono } from 'hono' * import { handle, getConnInfo } from 'hono/netlify' * * const app = new Hono() * * app.get('/', (c) => { * const info = getConnInfo(c) * return c.text(`Your IP: ${info.remote.address}`) * }) * * export default handle(app) * ``` */ export const getConnInfo: GetConnInfo = (c: Context) => ({ remote: { address: c.env.context?.ip, }, }) ================================================ FILE: src/adapter/netlify/handler.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Hono } from '../../hono' export const handle = ( app: Hono ): ((req: Request, context: any) => Response | Promise) => { return (req: Request, context: any) => { return app.fetch(req, { context }) } } ================================================ FILE: src/adapter/netlify/index.ts ================================================ /** * @module * Netlify Adapter for Hono. */ export * from './mod' ================================================ FILE: src/adapter/netlify/mod.ts ================================================ export { handle } from './handler' export { getConnInfo } from './conninfo' ================================================ FILE: src/adapter/service-worker/handler.test.ts ================================================ import { Hono } from '../../hono' import { handle } from './handler' import type { FetchEvent } from './types' beforeAll(() => { // fetch errors when it's not bound to globalThis in service worker // set a fetch stub to emulate that behavior vi.stubGlobal( 'fetch', function fetch(this: undefined | typeof globalThis, arg0: string | Request) { if (this !== globalThis) { const error = new Error( "Failed to execute 'fetch' on 'WorkerGlobalScope': Illegal invocation" ) error.name = 'TypeError' throw error } if (arg0 instanceof Request && arg0.url === 'http://localhost/fallback') { return new Response('hello world') } return Response.error() } ) }) afterAll(() => { vi.unstubAllGlobals() }) describe('handle', () => { it('Success to fetch', async () => { const app = new Hono() app.get('/', (c) => { return c.json({ hello: 'world' }) }) const handler = handle(app) const json = await new Promise((resolve) => { handler({ request: new Request('http://localhost/'), respondWith(res) { resolve(res) }, } as FetchEvent) }).then((res) => res.json()) expect(json).toStrictEqual({ hello: 'world' }) }) it('Fallback 404', async () => { const app = new Hono() const handler = handle(app) const text = await new Promise((resolve) => { handler({ request: new Request('http://localhost/fallback'), respondWith(res) { resolve(res) }, } as FetchEvent) }).then((res) => res.text()) expect(text).toBe('hello world') }) it('Fallback 404 with explicit fetch', async () => { const app = new Hono() const handler = handle(app, { async fetch() { return new Response('hello world') }, }) const text = await new Promise((resolve) => { handler({ request: new Request('http://localhost/'), respondWith(res) { resolve(res) }, } as FetchEvent) }).then((res) => res.text()) expect(text).toBe('hello world') }) it('Do not fallback 404 when fetch is undefined', async () => { const app = new Hono() app.get('/', (c) => c.text('Not found', 404)) const handler = handle(app, { fetch: undefined, }) const result = await new Promise((resolve) => handler({ request: new Request('https://localhost/'), respondWith(r) { resolve(r) }, } as FetchEvent) ) expect(result.status).toBe(404) expect(await result.text()).toBe('Not found') }) it('Should pass FetchEvent as second argument to app.fetch', async () => { const app = new Hono() app.get('/', (c) => { return c.json({ // @ts-expect-error executionCtx is FetchEvent but not typed well clientId: c.executionCtx.clientId, }) }) const handler = handle(app) // @ts-expect-error Force mocking FetchEvent including custom values const mockFetchEvent = { clientId: 'test-client-id', respondWith: vi.fn(), request: new Request('http://localhost'), } as FetchEvent const response = await new Promise((resolve) => { mockFetchEvent.respondWith = resolve handler(mockFetchEvent) }) const json = await response.json() expect(json.clientId).toBe('test-client-id') }) }) ================================================ FILE: src/adapter/service-worker/handler.ts ================================================ /** * Handler for Service Worker * @module */ import type { Hono } from '../../hono' import type { Env, Schema } from '../../types' import type { FetchEvent } from './types' type Handler = (evt: FetchEvent) => void export type HandleOptions = { fetch?: typeof fetch } /** * Adapter for Service Worker */ export const handle = ( app: Hono, opts: HandleOptions = { // To use `fetch` on a Service Worker correctly, bind it to `globalThis`. fetch: globalThis.fetch.bind(globalThis), } ): Handler => { return (evt) => { evt.respondWith( (async () => { // @ts-expect-error Passing FetchEvent but app.fetch expects ExecutionContext const res = await app.fetch(evt.request, {}, evt) if (opts.fetch && res.status === 404) { return await opts.fetch(evt.request) } return res })() ) } } ================================================ FILE: src/adapter/service-worker/index.ts ================================================ /** * Service Worker Adapter for Hono. * @module */ import type { Hono } from '../../hono' import type { Env, Schema } from '../../types' import { handle } from './handler' import type { HandleOptions } from './handler' /** * Registers a Hono app to handle fetch events in a service worker. * This sets up `addEventListener('fetch', handle(app, options))` for the provided app. * * @param app - The Hono application instance * @param options - Options for handling requests (fetch defaults to undefined) * @example * ```ts * import { Hono } from 'hono' * import { fire } from 'hono/service-worker' * * const app = new Hono() * * app.get('/', (c) => c.text('Hi')) * * fire(app) * ``` */ const fire = ( app: Hono, options: HandleOptions = { fetch: undefined, } ): void => { // @ts-expect-error addEventListener is not typed well in ServiceWorker-like contexts, see: https://github.com/microsoft/TypeScript/issues/14877 addEventListener('fetch', handle(app, options)) } export { handle, fire } ================================================ FILE: src/adapter/service-worker/types.ts ================================================ interface ExtendableEvent extends Event { // eslint-disable-next-line @typescript-eslint/no-explicit-any waitUntil(f: Promise): void } export interface FetchEvent extends ExtendableEvent { readonly clientId: string readonly handled: Promise // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly preloadResponse: Promise readonly request: Request readonly resultingClientId: string respondWith(r: Response | PromiseLike): void } ================================================ FILE: src/adapter/vercel/conninfo.test.ts ================================================ import { Context } from '../../context' import { getConnInfo } from './conninfo' describe('getConnInfo', () => { it('Should getConnInfo works', () => { const address = Math.random().toString() const req = new Request('http://localhost/', { headers: { 'x-real-ip': address, }, }) const c = new Context(req) const info = getConnInfo(c) expect(info.remote.address).toBe(address) }) }) ================================================ FILE: src/adapter/vercel/conninfo.ts ================================================ import type { GetConnInfo } from '../../helper/conninfo' export const getConnInfo: GetConnInfo = (c) => ({ remote: { // https://github.com/vercel/vercel/blob/b70bfb5fbf28a4650d4042ce68ca5c636d37cf44/packages/edge/src/edge-headers.ts#L10-L12C32 address: c.req.header('x-real-ip'), }, }) ================================================ FILE: src/adapter/vercel/handler.test.ts ================================================ import { Hono } from '../../hono' import { handle } from './handler' describe('Adapter for Next.js', () => { it('Should return 200 response', async () => { const app = new Hono() app.get('/api/author/:name', async (c) => { const name = c.req.param('name') return c.json({ path: '/api/author/:name', name, }) }) const handler = handle(app) const req = new Request('http://localhost/api/author/hono') const res = await handler(req) expect(res.status).toBe(200) expect(await res.json()).toEqual({ path: '/api/author/:name', name: 'hono', }) }) it('Should not use `route()` if path argument is not passed', async () => { const app = new Hono().basePath('/api') app.onError((e) => { throw e }) app.get('/error', () => { throw new Error('Custom Error') }) const handler = handle(app) const req = new Request('http://localhost/api/error') expect(() => handler(req)).toThrowError('Custom Error') }) }) ================================================ FILE: src/adapter/vercel/handler.ts ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Hono } from '../../hono' export const handle = (app: Hono) => (req: Request): Response | Promise => { return app.fetch(req) } ================================================ FILE: src/adapter/vercel/index.ts ================================================ /** * @module * Vercel Adapter for Hono. */ export { handle } from './handler' export { getConnInfo } from './conninfo' ================================================ FILE: src/client/client.test.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { expectTypeOf, vi } from 'vitest' import { upgradeWebSocket } from '../adapter/deno/websocket' import { Hono } from '../hono' import { parse } from '../utils/cookie' import type { Equal, Expect, JSONValue, SimplifyDeepArray } from '../utils/types' import { validator } from '../validator' import { hc } from './client' import type { ClientResponse, InferRequestType, InferResponseType, ApplyGlobalResponse, } from './types' class SafeBigInt { unsafe = BigInt(42) toJSON() { return { value: '42n', } } } describe('Basic - JSON', () => { const app = new Hono() const route = app .post( '/posts', validator('cookie', () => { return {} as { debug: string } }), validator('header', () => { return {} as { 'x-message': string } }), validator('json', () => { return {} as { id: number title: string } }), (c) => { return c.json({ success: true, message: 'dummy', requestContentType: 'dummy', requestHono: 'dummy', requestMessage: 'dummy', requestBody: { id: 123, title: 'dummy', }, }) } ) .get('/hello-not-found', (c) => c.notFound()) .get('/null', (c) => c.json(null)) .get('/empty', (c) => c.json({})) .get('/bigint', (c) => c.json({ value: BigInt(42) })) .get('/safe-bigint', (c) => c.json(new SafeBigInt())) type AppType = typeof route const server = setupServer( http.post('http://localhost/posts', async ({ request }) => { const requestContentType = request.headers.get('content-type') const requestHono = request.headers.get('x-hono') const requestMessage = request.headers.get('x-message') const requestBody = await request.json() const payload = { message: 'Hello!', success: true, requestContentType, requestHono, requestMessage, requestBody, } return HttpResponse.json(payload) }), http.get('http://localhost/hello-not-found', () => { return HttpResponse.text(null, { status: 404, }) }), http.get('http://localhost/null', () => { return HttpResponse.json(null) }), http.get('http://localhost/empty', () => { return HttpResponse.json({}) }), http.get('http://localhost/bigint', () => { return HttpResponse.json({ value: BigInt(42) }) }), http.get('http://localhost/safe-bigint', () => { return HttpResponse.json(new SafeBigInt()) }), http.get('http://localhost/api/string', () => { return HttpResponse.json('a-string') }), http.get('http://localhost/api/number', async () => { return HttpResponse.json(37) }), http.get('http://localhost/api/boolean', async () => { return HttpResponse.json(true) }), http.get('http://localhost/api/generic', async () => { return HttpResponse.json(Math.random() > 0.5 ? Boolean(Math.random()) : Math.random()) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const payload = { id: 123, title: 'Hello! Hono!', } const client = hc('http://localhost', { headers: { 'x-hono': 'hono' } }) it('Should get 200 response', async () => { const res = await client.posts.$post( { json: payload, header: { 'x-message': 'foobar', }, cookie: { debug: 'true', }, }, {} ) expect(res.ok).toBe(true) const data = await res.json() expect(data.success).toBe(true) expect(data.message).toBe('Hello!') expect(data.requestContentType).toBe('application/json') expect(data.requestHono).toBe('hono') expect(data.requestMessage).toBe('foobar') expect(data.requestBody).toEqual(payload) }) it('Should get 404 response', async () => { const res = await client['hello-not-found'].$get() expect(res.status).toBe(404) }) it('Should get a `null` content', async () => { const client = hc('http://localhost') const res = await client.null.$get() const data = await res.json() expectTypeOf(data).toMatchTypeOf() expect(data).toBe(null) }) it('Should get a `{}` content', async () => { const client = hc('http://localhost') const res = await client.empty.$get() const data = await res.json() expectTypeOf(data).toMatchTypeOf<{}>() expect(data).toStrictEqual({}) }) it('Should get a `{}` content', async () => { const client = hc('http://localhost') const res = await client['safe-bigint'].$get() const data = await res.json() expectTypeOf(data).toMatchTypeOf<{ value: string }>() expect(data).toStrictEqual({ value: '42n' }) }) it('Should get an error response', async () => { const client = hc('http://localhost') const res = await client.bigint.$get() const data = await res.json() expectTypeOf(data).toMatchTypeOf() expect(res.status).toBe(500) expect(data).toMatchObject({ message: 'Do not know how to serialize a BigInt', name: 'TypeError', }) }) it('Should have correct types - primitives', async () => { const app = new Hono() const route = app .get('/api/string', (c) => c.json('a-string')) .get('/api/number', (c) => c.json(37)) .get('/api/boolean', (c) => c.json(true)) .get('/api/generic', (c) => c.json(Math.random() > 0.5 ? Boolean(Math.random()) : Math.random()) ) type AppType = typeof route const client = hc('http://localhost') const stringFetch = await client.api.string.$get() const stringRes = await stringFetch.json() const numberFetch = await client.api.number.$get() const numberRes = await numberFetch.json() const booleanFetch = await client.api.boolean.$get() const booleanRes = await booleanFetch.json() const genericFetch = await client.api.generic.$get() const genericRes = await genericFetch.json() type stringVerify = Expect> expect(stringRes).toBe('a-string') type numberVerify = Expect> expect(numberRes).toBe(37) type booleanVerify = Expect> expect(booleanRes).toBe(true) type genericVerify = Expect> expect(typeof genericRes === 'number' || typeof genericRes === 'boolean').toBe(true) // using .text() on json endpoint should return string type textTest = Expect, ReturnType>> }) }) describe('Basic - query, queries, form, path params, header and cookie', () => { const app = new Hono() const route = app .get( '/search', validator('query', () => { return {} as { q: string; tag: string[]; filter: string } }), (c) => { return c.json({ q: 'fake', tag: ['fake'], filter: 'fake', }) } ) .put( '/posts/:id', validator('form', () => { return { title: 'Hello', } }), (c) => { const data = c.req.valid('form') return c.json(data) } ) .get( '/header', validator('header', () => { return { 'x-message-id': 'Hello', } }), (c) => { const data = c.req.valid('header') return c.json(data) } ) .get( '/cookie', validator('cookie', () => { return { hello: 'world', } }), (c) => { const data = c.req.valid('cookie') return c.json(data) } ) const server = setupServer( http.get('http://localhost/api/search', ({ request }) => { const url = new URL(request.url) const query = url.searchParams.get('q') const tag = url.searchParams.getAll('tag') const filter = url.searchParams.get('filter') return HttpResponse.json({ q: query, tag, filter, }) }), http.get('http://localhost/api/posts', ({ request }) => { const url = new URL(request.url) const tags = url.searchParams.getAll('tags') return HttpResponse.json({ tags: tags, }) }), http.put('http://localhost/api/posts/123', async ({ request }) => { const buffer = await request.arrayBuffer() // @ts-ignore const string = String.fromCharCode.apply('', new Uint8Array(buffer)) return HttpResponse.text(string) }), http.get('http://localhost/api/header', async ({ request }) => { const message = await request.headers.get('x-message-id') return HttpResponse.json({ 'x-message-id': message }) }), http.get('http://localhost/api/cookie', async ({ request }) => { const obj = parse(request.headers.get('cookie') || '') const value = obj['hello'] return HttpResponse.json({ hello: value }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) type AppType = typeof route const client = hc('http://localhost/api') it('Should get 200 response - query', async () => { const res = await client.search.$get({ query: { q: 'foobar', tag: ['a', 'b'], // @ts-expect-error filter: undefined, }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ q: 'foobar', tag: ['a', 'b'], filter: null, }) }) it('Should get 200 response - form, params', async () => { const res = await client.posts[':id'].$put({ form: { title: 'Good Night', }, param: { id: '123', }, }) expect(res.status).toBe(200) expect(await res.text()).toMatch('Good Night') }) it('Should get 200 response - header', async () => { const header = { 'x-message-id': 'Hello', } const res = await client.header.$get({ header, }) expect(res.status).toBe(200) expect(await res.json()).toEqual(header) }) it('Should get 200 response - cookie', async () => { const cookie = { hello: 'world', } const res = await client.cookie.$get({ cookie, }) expect(res.status).toBe(200) expect(await res.json()).toEqual(cookie) }) }) describe('Basic - $url()', () => { const api = new Hono().get('/', (c) => c.text('API')).get('/posts/:id', (c) => c.text('Post')) const content = new Hono().get( '/search', validator('query', () => { return { page: '1', limit: '10' } }), (c) => c.text('Search') ) const app = new Hono() .get('/', (c) => c.text('Index')) .route('/api', api) .route('/content', content) it('Should return a correct url via $url().href', async () => { const client = hc('http://fake') expect(client.index.$url().href).toBe('http://fake/') expect( client.index.$url({ query: { page: '123', limit: '20', }, }).href ).toBe('http://fake/?page=123&limit=20') expect(client.api.$url().href).toBe('http://fake/api') expect( client.api.posts[':id'].$url({ param: { id: '123', }, }).href ).toBe('http://fake/api/posts/123') expect( client.content.search.$url({ query: { page: '123', limit: '20', }, }).href ).toBe('http://fake/content/search?page=123&limit=20') }) it.each(['http://fake', 'http://fake/', 'http://fake//', 'http://fake/api'])( 'Should return a correct path via $path() regardless of %s', async (baseURL) => { const client = hc(baseURL) expect(client.index.$path()).toBe('/') expect( client.index.$path({ query: { page: '123', limit: '20', }, }) ).toBe('/?page=123&limit=20') expect(client.api.$path()).toBe('/api') expect( client.api.posts[':id'].$path({ param: { id: '123', }, }) ).toBe('/api/posts/123') expect( client.content.search.$path({ query: { page: '123', limit: '20', }, }) ).toBe('/content/search?page=123&limit=20') } ) }) describe('Form - Multiple Values', () => { const server = setupServer( http.post('http://localhost/multiple-values', async ({ request }) => { const data = await request.formData() return HttpResponse.json(data.getAll('key')) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const client = hc('http://localhost/') it('Should get 200 response - query', async () => { // @ts-expect-error `client['multiple-values'].$post` is not typed const res = await client['multiple-values'].$post({ form: { key: ['foo', 'bar'], }, }) expect(res.status).toBe(200) expect(await res.json()).toEqual(['foo', 'bar']) }) }) describe('Form - Undefined Values', () => { const server = setupServer( http.post('http://localhost/form-undefined', async ({ request }) => { const data = await request.formData() return HttpResponse.json({ keys: [...data.keys()], title: data.get('title'), optional: data.get('optional'), }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const client = hc('http://localhost/') it('Should skip undefined values in form data', async () => { // @ts-expect-error `client['form-undefined'].$post` is not typed const res = await client['form-undefined'].$post({ form: { title: 'Hello', optional: undefined, }, }) expect(res.status).toBe(200) const json = await res.json() expect(json).toEqual({ keys: ['title'], title: 'Hello', optional: null, }) }) }) describe('Infer the response/request type', () => { const app = new Hono() const route = app.get( '/', validator('query', () => { return { name: 'dummy', age: 'dummy', } }), validator('header', () => { return { 'x-request-id': 'dummy', } }), validator('cookie', () => { return { name: 'dummy', } }), (c) => c.json({ id: 123, title: 'Morning!', }) ) type AppType = typeof route it('Should infer response type the type correctly', () => { const client = hc('/') const req = client.index.$get type Actual = InferResponseType type Expected = { id: number title: string } type verify = Expect> }) it('Should infer request type the type correctly', () => { const client = hc('/') const req = client.index.$get type Actual = InferRequestType type Expected = { age: string | string[] name: string | string[] } type verify = Expect> }) it('Should infer request header type the type correctly', () => { const client = hc('/') const req = client.index.$get type c = typeof req type Actual = InferRequestType type Expected = { 'x-request-id': string } type verify = Expect> }) it('Should infer request cookie type the type correctly', () => { const client = hc('/') const req = client.index.$get type c = typeof req type Actual = InferRequestType type Expected = { name: string } type verify = Expect> }) describe('Without input', () => { const route = app.get('/', (c) => c.json({ ok: true })) type AppType = typeof route it('Should infer response type the type correctly', () => { const client = hc('/') const req = client.index.$get type Actual = InferResponseType type Expected = { ok: true } type verify = Expect> }) it('Should infer request type the type correctly', () => { const client = hc('/') const req = client.index.$get type Actual = InferRequestType type Expected = {} type verify = Expect> }) }) }) describe('Merge path with `app.route()`', () => { const server = setupServer( http.get('http://localhost/api/search', async () => { return HttpResponse.json({ ok: true, }) }), http.get('http://localhost/api/searchArray', async () => { return HttpResponse.json([ { ok: true, }, ]) }), http.get('http://localhost/api/foo', async () => { return HttpResponse.json({ ok: true, }) }), http.post('http://localhost/api/bar', async () => { return HttpResponse.json({ ok: true, }) }), http.get('http://localhost/v1/book', async () => { return HttpResponse.json({ ok: true, }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) type Env = { Bindings: { TOKEN: string } } it('Should have correct types', async () => { const api = new Hono().get('/search', (c) => c.json({ ok: true })) const app = new Hono().route('/api', api) type AppType = typeof app const client = hc('http://localhost') const res = await client.api.search.$get() const data = await res.json() type verify = Expect> expect(data.ok).toBe(true) }) it('Should have correct types - basePath() then get()', async () => { const base = new Hono().basePath('/api') const app = base.get('/search', (c) => c.json({ ok: true })) type AppType = typeof app const client = hc('http://localhost') const res = await client.api.search.$get() const data = await res.json() type verify = Expect> expect(data.ok).toBe(true) }) it('Should have correct types - basePath(), route(), get()', async () => { const book = new Hono().get('/', (c) => c.json({ ok: true })) const app = new Hono().basePath('/v1').route('/book', book) type AppType = typeof app const client = hc('http://localhost') const res = await client.v1.book.$get() const data = await res.json() type verify = Expect> expect(data.ok).toBe(true) }) it('Should have correct types - with interface', async () => { interface Result { ok: boolean okUndefined?: boolean } const result: Result = { ok: true } const base = new Hono().basePath('/api') const app = base.get('/search', (c) => c.json(result)) type AppType = typeof app const client = hc('http://localhost') const res = await client.api.search.$get() const data = await res.json() type verify = Expect> expect(data.ok).toBe(true) // A few more types only tests interface DeepInterface { l2: { l3: Result } } interface ExtraDeepInterface { l4: DeepInterface } type verifyDeepInterface = Expect< Equal extends JSONValue ? true : false, true> > type verifyExtraDeepInterface = Expect< Equal extends JSONValue ? true : false, true> > }) it('Should have correct types - with array of interfaces', async () => { interface Result { ok: boolean okUndefined?: boolean } type Results = Result[] const results: Results = [{ ok: true }] const base = new Hono().basePath('/api') const app = base.get('/searchArray', (c) => c.json(results)) type AppType = typeof app const client = hc('http://localhost') const res = await client.api.searchArray.$get() const data = await res.json() type verify = Expect> expect(data[0].ok).toBe(true) // A few more types only tests type verifyNestedArrayTyped = Expect< Equal extends JSONValue ? true : false, true> > type verifyNestedArrayInterfaceArray = Expect< Equal extends JSONValue ? true : false, true> > type verifyExtraNestedArrayTyped = Expect< Equal extends JSONValue ? true : false, true> > type verifyExtraNestedArrayInterfaceArray = Expect< Equal extends JSONValue ? true : false, true> > }) it('Should allow a Date object and return it as a string', async () => { const app = new Hono() const route = app.get('/api/foo', (c) => c.json({ datetime: new Date() })) type AppType = typeof route const client = hc('http://localhost') const res = await client.api.foo.$get() const { datetime } = await res.json() type verify = Expect> }) describe('Multiple endpoints', () => { const api = new Hono() .get('/foo', (c) => c.json({ foo: '' })) .post('/bar', (c) => c.json({ bar: 0 })) const app = new Hono().route('/api', api) type AppType = typeof app const client = hc('http://localhost') it('Should return correct types - GET /api/foo', async () => { const res = await client.api.foo.$get() const data = await res.json() type verify = Expect> }) it('Should return correct types - POST /api/bar', async () => { const res = await client.api.bar.$post() const data = await res.json() type verify = Expect> }) it('Should work with $url', async () => { const url = client.api.bar.$url() expect(url.href).toBe('http://localhost/api/bar') }) it('Should work with $path', async () => { const path = client.api.bar.$path() expect(path).toBe('/api/bar') }) }) describe('With a blank path', () => { const app = new Hono().basePath('/api/v1') const routes = app.route( '/me', new Hono().route( '', new Hono().get('', async (c) => { return c.json({ name: 'hono' }) }) ) ) const client = hc('http://localhost') it('Should infer paths correctly', async () => { // Should not a throw type error const url = client.api.v1.me.$url() expectTypeOf(url) expect(url.href).toBe('http://localhost/api/v1/me') const path = client.api.v1.me.$path() expectTypeOf<'/api/v1/me'>(path) expect(path).toBe('/api/v1/me') }) }) describe('With endpoint pathname', () => { const app = new Hono().basePath('/api/v1') const routes = app.route( '/me', new Hono().route( '', new Hono().get('', async (c) => { return c.json({ name: 'hono' }) }) ) ) const client = hc('http://localhost/proxy') it('Should infer paths correctly', async () => { // Should not a throw type error const url = client.api.v1.me.$url() expectTypeOf(url) expect(url.href).toBe('http://localhost/proxy/api/v1/me') const path = client.api.v1.me.$path() expectTypeOf<'/api/v1/me'>(path) expect(path).toBe('/api/v1/me') }) }) }) describe('Use custom fetch method', () => { it('Should call the custom fetch method when provided', async () => { const fetchMock = vi.fn() const api = new Hono().get('/search', (c) => c.json({ ok: true })) const app = new Hono().route('/api', api) type AppType = typeof app const client = hc('http://localhost', { fetch: fetchMock }) await client.api.search.$get() expect(fetchMock).toHaveBeenCalledTimes(1) }) it('Should return Response from custom fetch method', async () => { const fetchMock = vi.fn() const returnValue = new Response(null, { status: 200 }) fetchMock.mockReturnValueOnce(returnValue) const api = new Hono().get('/search', (c) => c.json({ ok: true })) const app = new Hono().route('/api', api) type AppType = typeof app const client = hc('http://localhost', { fetch: fetchMock }) const res = await client.api.search.$get() expect(res.ok).toBe(true) expect(res).toEqual(returnValue) }) }) describe('Use custom fetch (app.request) method', () => { it('Should return Response from app request method', async () => { const app = new Hono().get('/search', (c) => c.json({ ok: true })) type AppType = typeof app const client = hc('', { fetch: app.request }) const res = await client.search.$get() expect(res.ok).toBe(true) }) }) describe('Optional parameters in JSON response', () => { it('Should return the correct type', async () => { const app = new Hono().get('/', (c) => { return c.json({ message: 'foo' } as { message?: string }) }) type AppType = typeof app const client = hc('', { fetch: app.request }) const res = await client.index.$get() const data = await res.json() expectTypeOf(data).toEqualTypeOf<{ message?: string }>() }) }) describe('ClientResponse.json() returns a Union type correctly', () => { const condition = () => true const app = new Hono().get('/', async (c) => { const ok = condition() if (ok) { return c.json({ data: 'foo' }) } return c.json({ message: 'error' }) }) const client = hc('', { fetch: app.request }) it('Should be a Union type', async () => { const res = await client.index.$get() const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ data: string } | { message: string }>() }) }) describe('Response with different status codes', () => { const condition = () => true const app = new Hono().get('/', async (c) => { const ok = condition() if (ok) { return c.json({ data: 'foo' }, 200) } if (!ok) { return c.json({ message: 'error' }, 400) } return c.json(null) }) const client = hc('', { fetch: app.request }) it('all', async () => { const res = await client.index.$get() const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ data: string } | { message: string } | null>() }) it('status 200', async () => { const res = await client.index.$get() if (res.status === 200) { const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ data: string } | null>() } }) it('status 400', async () => { const res = await client.index.$get() if (res.status === 400) { const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ message: string } | null>() } }) it('response is ok', async () => { const res = await client.index.$get() if (res.ok) { const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ data: string } | null>() } }) it('response is not ok', async () => { const res = await client.index.$get() if (!res.ok) { const json = await res.json() expectTypeOf(json).toEqualTypeOf<{ message: string } | null>() } }) }) describe('Infer the response type with different status codes', () => { const condition = () => true const app = new Hono().get('/', async (c) => { const ok = condition() if (ok) { return c.json({ data: 'foo' }, 200) } if (!ok) { return c.json({ message: 'error' }, 400) } return c.json(null) }) const client = hc('', { fetch: app.request }) it('Should infer response type correctly', () => { const req = client.index.$get type Actual = InferResponseType type Expected = | { data: string } | { message: string } | null type verify = Expect> }) it('Should infer response type of status 200 correctly', () => { const req = client.index.$get type Actual = InferResponseType type Expected = { data: string } | null type verify = Expect> }) }) describe('Infer the response types from middlewares', () => { const app = new Hono() .get( '/', validator('query', (input, c) => { if (!input.page || typeof input.page !== 'string') { return c.json({ error: 'Bad request' as const }, 400) } return input as { page: string } }), async (c) => { const query = c.req.valid('query') return c.json({ data: 'foo', page: query.page }, 200) } ) .post( '/posts', async (c, next) => { const auth = c.req.header('authorization') if (!auth || !auth.startsWith('Bearer ')) { return c.json({ error: 'Unauthorized' as const }, 401) } return next() }, validator('json', (input, c) => { if (!input.title) { return c.json({ error: 'Bad request' as const }, 400) } return input as { title: string } }), (c) => { const data = c.req.valid('json') return c.json(data, 200) } ) type AppType = typeof app const client = hc('', { fetch: app.request }) it('Should infer response type of status 200 correctly', () => { const req = client.posts.$post type Actual = InferResponseType type Expected = { title: string } type verify = Expect> }) it('Should infer response type of status 400 correctly', () => { const req = client.posts.$post type Actual = InferResponseType type Expected = { error: 'Bad request' } type verify = Expect> }) it('Should infer response type of status 401 correctly', () => { const req = client.posts.$post type Actual = InferResponseType type Expected = { error: 'Unauthorized' } type verify = Expect> }) it('Should infer all possible response statuses', async () => { const req = await client.posts.$post({ json: { title: 'hello', }, }) type Actual = typeof req.status type Expected = 200 | 400 | 401 type verify = Expect> }) it('Should properly assign response to corresponding status', async () => { const req = await client.posts.$post({ json: { title: 'hello', }, }) if (req.status === 200) { const data = await req.json() expectTypeOf(data).toEqualTypeOf<{ title: string }>() } else if (req.status === 400) { const data = await req.json() expectTypeOf(data).toEqualTypeOf<{ error: 'Bad request' }>() } else if (req.status === 401) { const data = await req.json() expectTypeOf(data).toEqualTypeOf<{ error: 'Unauthorized' }>() } }) }) const pathname = (value: T): string => value instanceof URL ? value.pathname : value describe.each(['$path', '$url'] as const)('%s() with a param option', (cmd) => { const app = new Hono() .get('/posts/:id/comments', (c) => c.json({ ok: true })) .get('/something/:firstId/:secondId/:version?', (c) => c.json({ ok: true })) type AppType = typeof app const client = hc('http://localhost') it('Should return the correct url path - /posts/123/comments', async () => { const value = client.posts[':id'].comments[cmd]({ param: { id: '123', }, }) expect(pathname(value)).toBe('/posts/123/comments') }) it('Should return the correct path - /posts/:id/comments', async () => { const value = client.posts[':id'].comments[cmd]() expect(pathname(value)).toBe('/posts/:id/comments') }) it('Should return the correct path - /something/123/456', async () => { const value = client.something[':firstId'][':secondId'][':version?'][cmd]({ param: { firstId: '123', secondId: '456', version: undefined, }, }) expect(pathname(value)).toBe('/something/123/456') }) }) describe('$url() / $path() with a query option', () => { const app = new Hono().get( '/posts', validator('query', () => { return {} as { filter: 'test' } }), (c) => c.json({ ok: true }) ) type AppType = typeof app const client = hc('http://localhost') it('Should return the correct path - /posts?filter=test', async () => { const url = client.posts.$url({ query: { filter: 'test', }, }) expect(url.search).toBe('?filter=test') const path = client.posts.$path({ query: { filter: 'test', }, }) expect(path).toBe('/posts?filter=test') }) }) describe('Client can be awaited', () => { it('Can be awaited without side effects', async () => { const client = hc('http://localhost') const awaited = await client expect(awaited).toEqual(client) }) }) describe('Dynamic headers', () => { const app = new Hono() const route = app.post('/posts', (c) => { return c.json({ requestDynamic: 'dummy', }) }) type AppType = typeof route const server = setupServer( http.post('http://localhost/posts', async ({ request }) => { const requestDynamic = request.headers.get('x-dynamic') const payload = { requestDynamic, } return HttpResponse.json(payload) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) let dynamic = '' const client = hc('http://localhost', { headers: () => ({ 'x-hono': 'hono', 'x-dynamic': dynamic }), }) it('Should have "x-dynamic": "one"', async () => { dynamic = 'one' const res = await client.posts.$post() expect(res.ok).toBe(true) const data = await res.json() expect(data.requestDynamic).toEqual('one') }) it('Should have "x-dynamic": "two"', async () => { dynamic = 'two' const res = await client.posts.$post() expect(res.ok).toBe(true) const data = await res.json() expect(data.requestDynamic).toEqual('two') }) }) describe('RequestInit work as expected', () => { const app = new Hono() const route = app .get('/credentials', (c) => { return c.text('' as RequestCredentials) }) .get('/headers', (c) => { return c.json({} as Record) }) .post('/headers', (c) => c.text('Not found', 404)) type AppType = typeof route const server = setupServer( http.get('http://localhost/credentials', ({ request }) => { return HttpResponse.text(request.credentials) }), http.get('http://localhost/headers', ({ request }) => { const allHeaders: Record = {} for (const [k, v] of request.headers.entries()) { allHeaders[k] = v } return HttpResponse.json(allHeaders) }), http.post('http://localhost/headers', () => { return HttpResponse.text('Should not be here', { status: 400, }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const client = hc('http://localhost', { headers: { 'x-hono': 'fire' }, init: { credentials: 'include', }, }) it('Should overwrite method and fail', async () => { const res = await client.headers.$get(undefined, { init: { method: 'POST' } }) expect(res.ok).toBe(false) }) it('Should clear headers', async () => { const res = await client.headers.$get(undefined, { init: { headers: undefined } }) expect(res.ok).toBe(true) const data = await res.json() expect(data).toEqual({}) }) it('Should overwrite headers', async () => { const res = await client.headers.$get(undefined, { init: { headers: new Headers({ 'x-hono': 'awesome' }) }, }) expect(res.ok).toBe(true) const data = await res.json() expect(data).toEqual({ 'x-hono': 'awesome' }) }) it('credentials is include', async () => { const res = await client.credentials.$get() expect(res.ok).toBe(true) const data = await res.text() expect(data).toEqual('include') }) it('deepMerge should works and not unset credentials', async () => { const res = await client.credentials.$get(undefined, { init: { headers: { hi: 'hello' } } }) expect(res.ok).toBe(true) const data = await res.text() expect(data).toEqual('include') }) it('Should unset credentials', async () => { const res = await client.credentials.$get(undefined, { init: { credentials: undefined } }) expect(res.ok).toBe(true) const data = await res.text() expect(data).toEqual('same-origin') }) }) describe('WebSocket URL Protocol Translation', () => { const app = new Hono() const route = app.get( '/', upgradeWebSocket((c) => ({ onMessage(event, ws) { console.log(`Message from client: ${event.data}`) ws.send('Hello from server!') }, onClose: () => { console.log('Connection closed') }, })) ) type AppType = typeof route const server = setupServer() const webSocketMock = vi.fn() beforeAll(() => server.listen()) beforeEach(() => { vi.stubGlobal('WebSocket', webSocketMock) }) afterEach(() => { vi.clearAllMocks() server.resetHandlers() }) afterAll(() => server.close()) it('Translates HTTP to ws', async () => { const client = hc('http://localhost') client.index.$ws() expect(webSocketMock).toHaveBeenCalledWith('ws://localhost/index') }) it('Translates HTTPS to wss', async () => { const client = hc('https://localhost') client.index.$ws() expect(webSocketMock).toHaveBeenCalledWith('wss://localhost/index') }) it('Keeps ws unchanged', async () => { const client = hc('ws://localhost') client.index.$ws() expect(webSocketMock).toHaveBeenCalledWith('ws://localhost/index') }) it('Keeps wss unchanged', async () => { const client = hc('wss://localhost') client.index.$ws() expect(webSocketMock).toHaveBeenCalledWith('wss://localhost/index') }) }) describe('WebSocket URL Protocol Translation with Query Parameters', () => { const app = new Hono() const route = app.get( '/', upgradeWebSocket((c) => ({ onMessage(event, ws) { ws.send('Hello from server!') }, onClose: () => { console.log('Connection closed') }, })) ) type AppType = typeof route const server = setupServer() const webSocketMock = vi.fn() beforeAll(() => server.listen()) beforeEach(() => { vi.stubGlobal('WebSocket', webSocketMock) }) afterEach(() => { vi.clearAllMocks() server.resetHandlers() }) afterAll(() => server.close()) it('Translates HTTP to ws and includes query parameters', async () => { const client = hc('http://localhost') client.index.$ws({ query: { id: '123', type: 'test', tag: ['a', 'b'], }, }) expect(webSocketMock).toHaveBeenCalledWith('ws://localhost/index?id=123&type=test&tag=a&tag=b') }) it('Translates HTTPS to wss and includes query parameters', async () => { const client = hc('https://localhost') client.index.$ws({ query: { id: '456', type: 'secure', }, }) expect(webSocketMock).toHaveBeenCalledWith('wss://localhost/index?id=456&type=secure') }) it('Keeps ws unchanged and includes query parameters', async () => { const client = hc('ws://localhost') client.index.$ws({ query: { id: '789', type: 'plain', }, }) expect(webSocketMock).toHaveBeenCalledWith('ws://localhost/index?id=789&type=plain') }) it('Keeps wss unchanged and includes query parameters', async () => { const client = hc('wss://localhost') client.index.$ws({ query: { id: '1011', type: 'secure', }, }) expect(webSocketMock).toHaveBeenCalledWith('wss://localhost/index?id=1011&type=secure') }) }) describe('Client can be console.log in react native', () => { it('Returns a function name with function.name.toString', async () => { const client = hc('http://localhost') // @ts-ignore expect(client.posts.name.toString()).toEqual('posts') }) it('Returns a function name with function.name.valueOf', async () => { const client = hc('http://localhost') // @ts-ignore expect(client.posts.name.valueOf()).toEqual('posts') }) it('Returns a function with function.valueOf', async () => { const client = hc('http://localhost') expect(typeof client.posts.valueOf()).toEqual('function') }) it('Returns a function source with function.toString', async () => { const client = hc('http://localhost') expect(client.posts.toString()).toMatch('function proxyCallback') }) }) describe('Text response', () => { const text = 'My name is Hono' const obj = { ok: true } const server = setupServer( http.get('http://localhost/about/me', async () => { return HttpResponse.text(text) }), http.get('http://localhost/api', async ({ request }) => { return HttpResponse.json(obj) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const app = new Hono().get('/about/me', (c) => c.text(text)).get('/api', (c) => c.json(obj)) const client = hc('http://localhost/') it('Should be never with res.json() - /about/me', async () => { const res = await client.about.me.$get() type Actual = ReturnType type Expected = Promise type verify = Expect> }) it('Should be "Hello, World!" with res.text() - /about/me', async () => { const res = await client.about.me.$get() const data = await res.text() expectTypeOf(data).toEqualTypeOf<'My name is Hono'>() expect(data).toBe(text) }) /** * Also check the type of JSON response with res.text(). */ it('Should be string with res.text() - /api', async () => { const res = await client.api.$get() type Actual = ReturnType type Expected = Promise type verify = Expect> }) }) describe('Redirect response - only types', () => { const server = setupServer( http.get('http://localhost/', async () => { return HttpResponse.redirect('/') }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) const condition = () => true const app = new Hono().get('/', async (c) => { const ok = condition() const temporary = condition() if (ok) { return c.json({ ok: true }, 200) } if (temporary) { return c.redirect('/302') } return c.redirect('/301', 301) }) const client = hc('http://localhost/') const req = client.index.$get it('Should infer request type the type correctly', () => { type Actual = InferResponseType type Expected = | { ok: true } | undefined type verify = Expect> }) it('Should infer response type correctly', async () => { const res = await req() if (res.ok) { const data = await res.json() expectTypeOf(data).toMatchTypeOf({ ok: true }) } if (res.status === 301) { type Expected = ClientResponse type verify = Expect> } if (res.status === 302) { type Expected = ClientResponse type verify = Expect> } }) }) describe('WebSocket Provider Integration', () => { const app = new Hono() const route = app.get( '/', upgradeWebSocket((c) => ({ onMessage(event, ws) { ws.send('Hello from server!') }, onClose() { console.log('Connection closed') }, })) ) type AppType = typeof route const server = setupServer() beforeAll(() => server.listen()) afterEach(() => { vi.clearAllMocks() server.resetHandlers() }) afterAll(() => server.close()) it.each([ { description: 'should initialize the WebSocket provider correctly', url: 'http://localhost', query: undefined, expectedUrl: 'ws://localhost/index', }, { description: 'should correctly add query parameters to the WebSocket URL', url: 'http://localhost', query: { id: '123', type: 'test', tag: ['a', 'b'] }, expectedUrl: 'ws://localhost/index?id=123&type=test&tag=a&tag=b', }, ])('$description', ({ url, expectedUrl, query }) => { const webSocketMock = vi.fn() const client = hc(url, { webSocket(url, options) { return webSocketMock(url, options) }, }) client.index.$ws({ query }) expect(webSocketMock).toHaveBeenCalledWith(expectedUrl, undefined) }) }) describe('Custom buildSearchParams', () => { const app = new Hono() const route = app.get( '/search', validator('query', () => { return {} as { q: string; tags: string[] } }), (c) => { return c.json({ message: 'success', queryString: '', }) } ) type AppType = typeof route const server = setupServer( http.get('http://localhost/search', ({ request }) => { const url = new URL(request.url) return HttpResponse.json({ message: 'success', queryString: url.search, }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) // Custom buildSearchParams that uses bracket notation for arrays (key[]=value) const customBuildSearchParams = (query: Record) => { const searchParams = new URLSearchParams() for (const [k, v] of Object.entries(query)) { if (v === undefined) { continue } if (Array.isArray(v)) { v.forEach((item) => searchParams.append(`${k}[]`, item)) } else { searchParams.set(k, v) } } return searchParams } it('Should use custom buildSearchParams for query serialization', async () => { const client = hc('http://localhost', { buildSearchParams: customBuildSearchParams }) const res = await client.search.$get({ query: { q: 'test', tags: ['tag1', 'tag2', 'tag3'] } }) const data = await res.json() expect(res.status).toBe(200) expect(data.queryString).toBe('?q=test&tags%5B%5D=tag1&tags%5B%5D=tag2&tags%5B%5D=tag3') }) it('Should use default buildSearchParams when custom one is not provided', async () => { const client = hc('http://localhost') const res = await client.search.$get({ query: { q: 'test', tags: ['tag1', 'tag2'] } }) const data = await res.json() expect(res.status).toBe(200) expect(data.queryString).toBe('?q=test&tags=tag1&tags=tag2') }) it('Should use custom buildSearchParams in $url() method', () => { const client = hc('http://localhost', { buildSearchParams: customBuildSearchParams }) const url = client.search.$url({ query: { q: 'test', tags: ['tag1', 'tag2'] } }) expect(url.href).toBe('http://localhost/search?q=test&tags%5B%5D=tag1&tags%5B%5D=tag2') }) it('Should use default buildSearchParams in $url() when custom one is not provided', () => { const client = hc('http://localhost') const url = client.search.$url({ query: { q: 'test', tags: ['tag1', 'tag2'] } }) expect(url.href).toBe('http://localhost/search?q=test&tags=tag1&tags=tag2') }) }) describe('ApplyGlobalResponse Type Helper', () => { const server = setupServer( http.get('http://localhost/api/users', () => { return HttpResponse.json({ users: ['alice', 'bob'] }) }), http.get('http://localhost/api/error', () => { return HttpResponse.json( { error: 'Internal Server Error', message: 'Something went wrong' }, { status: 500 } ) }), http.get('http://localhost/api/unauthorized', () => { return HttpResponse.json({ error: 'Unauthorized', message: 'Please login' }, { status: 401 }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) it('Should add global error response types to all routes', () => { // Use explicit status codes for proper type narrowing const app = new Hono().get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) // Apply global error responses with new object syntax type AppWithGlobalErrors = ApplyGlobalResponse< typeof app, { 401: { json: { error: string; message: string } } 500: { json: { error: string; message: string } } } > const client = hc('http://localhost') const req = client.api.users.$get // Type should be a union of normal response and global errors type ResponseType = InferResponseType type Expected = { users: string[] } | { error: string; message: string } type verify = Expect> }) it('Should support multiple global error status codes', async () => { const app = new Hono() .get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) .get('/api/unauthorized', (c) => c.json({ error: 'Unauthorized', message: 'Please login' }, 401) ) .get('/api/error', (c) => c.json({ error: 'Internal Server Error', message: 'Something went wrong' }, 500) ) // Apply multiple global error types in one definition type AppWithGlobalErrors = ApplyGlobalResponse< typeof app, { 401: { json: { error: string; message: string } } 500: { json: { error: string; message: string } } } > const client = hc('http://localhost') // Verify runtime behavior for different status codes const usersRes = await client.api.users.$get() expect(usersRes.status).toBe(200) const unauthorizedRes = await client.api.unauthorized.$get() expect(unauthorizedRes.status).toBe(401) expect(await unauthorizedRes.json()).toEqual({ error: 'Unauthorized', message: 'Please login' }) const errorRes = await client.api.error.$get() expect(errorRes.status).toBe(500) expect(await errorRes.json()).toEqual({ error: 'Internal Server Error', message: 'Something went wrong', }) }) it('Should work with onError handler pattern', () => { // Simulating typical Hono app with onError handler // Use explicit status code for proper type narrowing const app = new Hono().get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) // In real app: app.onError((err, c) => c.json({ error: err.message }, 500)) type AppWithOnError = ApplyGlobalResponse< typeof app, { 500: { json: { error: string } } } > const client = hc('http://localhost') const req = client.api.users.$get // RPC client should know about the error format type ResponseType = InferResponseType type Expected = { users: string[] } | { error: string } type verify = Expect> }) it('Should keep route() paths when global responses are applied', () => { const users = new Hono().get('/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) const app = new Hono().route('/api', users) type AppWithGlobalErrors = ApplyGlobalResponse< typeof app, { 500: { json: { error: string } } } > const client = hc('http://localhost') const req = client.api.users.$get type ResponseType = InferResponseType type Expected = { users: string[] } | { error: string } type verify = Expect> }) }) ================================================ FILE: src/client/client.ts ================================================ import type { Hono } from '../hono' import type { FormValue, ValidationTargets } from '../types' import { serialize } from '../utils/cookie' import type { UnionToIntersection } from '../utils/types' import type { BuildSearchParamsFn, Callback, Client, ClientRequestOptions } from './types' import { buildSearchParams, deepMerge, mergePath, removeIndexString, replaceUrlParam, replaceUrlProtocol, } from './utils' const createProxy = (callback: Callback, path: string[]) => { const proxy: unknown = new Proxy(() => {}, { get(_obj, key) { if (typeof key !== 'string' || key === 'then') { return undefined } return createProxy(callback, [...path, key]) }, apply(_1, _2, args) { return callback({ path, args, }) }, }) return proxy } class ClientRequestImpl { private url: string private method: string private buildSearchParams: BuildSearchParamsFn private queryParams: URLSearchParams | undefined = undefined private pathParams: Record = {} private rBody: BodyInit | undefined private cType: string | undefined = undefined constructor( url: string, method: string, options: { buildSearchParams: BuildSearchParamsFn } ) { this.url = url this.method = method this.buildSearchParams = options.buildSearchParams } fetch = async ( args?: ValidationTargets & { param?: Record }, opt?: ClientRequestOptions ) => { if (args) { if (args.query) { this.queryParams = this.buildSearchParams(args.query) } if (args.form) { const form = new FormData() for (const [k, v] of Object.entries(args.form)) { if (v === undefined) { continue } if (Array.isArray(v)) { for (const v2 of v) { form.append(k, v2) } } else { form.append(k, v) } } this.rBody = form } if (args.json) { this.rBody = JSON.stringify(args.json) this.cType = 'application/json' } if (args.param) { this.pathParams = args.param } } let methodUpperCase = this.method.toUpperCase() const headerValues: Record = { ...args?.header, ...(typeof opt?.headers === 'function' ? await opt.headers() : opt?.headers), } if (args?.cookie) { const cookies: string[] = [] for (const [key, value] of Object.entries(args.cookie)) { cookies.push(serialize(key, value, { path: '/' })) } headerValues['Cookie'] = cookies.join(',') } if (this.cType) { headerValues['Content-Type'] = this.cType } const headers = new Headers(headerValues ?? undefined) let url = this.url url = removeIndexString(url) url = replaceUrlParam(url, this.pathParams) if (this.queryParams) { url = url + '?' + this.queryParams.toString() } methodUpperCase = this.method.toUpperCase() const setBody = !(methodUpperCase === 'GET' || methodUpperCase === 'HEAD') // Pass URL string to 1st arg for testing with MSW and node-fetch return (opt?.fetch || fetch)(url, { body: setBody ? this.rBody : undefined, method: methodUpperCase, headers: headers, ...opt?.init, }) } } // eslint-disable-next-line @typescript-eslint/no-explicit-any export const hc = , Prefix extends string = string>( baseUrl: Prefix, options?: ClientRequestOptions ) => createProxy(function proxyCallback(opts) { const buildSearchParamsOption = options?.buildSearchParams ?? buildSearchParams const parts = [...opts.path] const lastParts = parts.slice(-3).reverse() // allow calling .toString() and .valueOf() on the proxy if (lastParts[0] === 'toString') { if (lastParts[1] === 'name') { // e.g. hc().somePath.name.toString() -> "somePath" return lastParts[2] || '' } // e.g. hc().somePath.toString() return proxyCallback.toString() } if (lastParts[0] === 'valueOf') { if (lastParts[1] === 'name') { // e.g. hc().somePath.name.valueOf() -> "somePath" return lastParts[2] || '' } // e.g. hc().somePath.valueOf() return proxyCallback } let method = '' if (/^\$/.test(lastParts[0] as string)) { const last = parts.pop() if (last) { method = last.replace(/^\$/, '') } } const path = parts.join('/') const url = mergePath(baseUrl, path) if (method === 'url' || method === 'path') { let result = url if (opts.args[0]) { if (opts.args[0].param) { result = replaceUrlParam(url, opts.args[0].param) } if (opts.args[0].query) { result = result + '?' + buildSearchParamsOption(opts.args[0].query).toString() } } result = removeIndexString(result) if (method === 'url') { return new URL(result) } return result.slice(baseUrl.replace(/\/+$/, '').length).replace(/^\/?/, '/') } if (method === 'ws') { const webSocketUrl = replaceUrlProtocol( opts.args[0] && opts.args[0].param ? replaceUrlParam(url, opts.args[0].param) : url, 'ws' ) const targetUrl = new URL(webSocketUrl) const queryParams: Record | undefined = opts.args[0]?.query if (queryParams) { Object.entries(queryParams).forEach(([key, value]) => { if (Array.isArray(value)) { value.forEach((item) => targetUrl.searchParams.append(key, item)) } else { targetUrl.searchParams.set(key, value) } }) } const establishWebSocket = (...args: ConstructorParameters) => { if (options?.webSocket !== undefined && typeof options.webSocket === 'function') { return options.webSocket(...args) } return new WebSocket(...args) } return establishWebSocket(targetUrl.toString()) } const req = new ClientRequestImpl(url, method, { buildSearchParams: buildSearchParamsOption, }) if (method) { options ??= {} const args = deepMerge(options, { ...opts.args[1] }) return req.fetch(opts.args[0], args) } return req }, []) as UnionToIntersection> ================================================ FILE: src/client/fetch-result-please.ts ================================================ /** * @description This file is a modified version of `fetch-result-please` (`ofetch`), minimalized and adapted to Hono's custom needs. * * @link https://www.npmjs.com/package/fetch-result-please */ const nullBodyResponses = new Set([101, 204, 205, 304]) /** * Smartly parses and return the consumable result from a fetch `Response`. * * Throwing a structured error if the response is not `ok`. ({@link DetailedError}) */ export async function fetchRP(fetchRes: Response | Promise): Promise { const _fetchRes = (await fetchRes) as unknown as Response & { _data: any /** * @description BodyInit property from whatwg-fetch polyfill * * @link https://github.com/JakeChampion/fetch/blob/main/fetch.js#L238 */ _bodyInit?: any } const hasBody = (_fetchRes.body || _fetchRes._bodyInit) && !nullBodyResponses.has(_fetchRes.status) if (hasBody) { const responseType = detectResponseType(_fetchRes) _fetchRes._data = await _fetchRes[responseType]() } if (!_fetchRes.ok) { throw new DetailedError(`${_fetchRes.status} ${_fetchRes.statusText}`, { statusCode: _fetchRes?.status, detail: { data: _fetchRes?._data, statusText: _fetchRes?.statusText, }, }) } return _fetchRes._data } export class DetailedError extends Error { /** * Additional `message` that will be logged AND returned to client */ public detail?: any /** * Additional `code` that will be logged AND returned to client */ public code?: any /** * Additional value that will be logged AND NOT returned to client */ public log?: any /** * Optionally set the status code to return, in a web server context */ public statusCode?: any constructor( message: string, options: { detail?: any; code?: any; statusCode?: number; log?: any } = {} ) { super(message) this.name = 'DetailedError' this.log = options.log this.detail = options.detail this.code = options.code this.statusCode = options.statusCode } } // This is used to match the content-type header for 'json' const jsonRegex = /^application\/(?:[\w!#$%&*.^`~-]*\+)?json(?:;.+)?$/i function detectResponseType(response: Response): 'json' | 'text' { const _contentType = response.headers.get('content-type') if (!_contentType) { return 'text' } // `_contentType` might look like: `application/json; charset=utf-8`, `text/plain`, so we get the first part before `;` const contentType = _contentType.split(';').shift()! if (jsonRegex.test(contentType)) { return 'json' } return 'text' } ================================================ FILE: src/client/index.ts ================================================ /** * @module * The HTTP Client for Hono. */ export { hc } from './client' export { parseResponse, DetailedError } from './utils' export type { InferResponseType, InferRequestType, Fetch, ClientRequestOptions, ClientRequest, ClientResponse, ApplyGlobalResponse, } from './types' ================================================ FILE: src/client/types.test.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { expectTypeOf } from 'vitest' import { Hono } from '..' import { upgradeWebSocket } from '../adapter/deno/websocket' import type { TypedURL } from './types' import { hc } from '.' describe('WebSockets', () => { const app = new Hono() .get( '/ws', upgradeWebSocket(() => ({})) ) .get('/', (c) => c.json({})) const client = hc('/') it('WebSocket route', () => { expectTypeOf(client.ws).toMatchTypeOf<{ $ws: () => WebSocket }>() }) it('Not WebSocket Route', () => { expectTypeOf< typeof client.index extends { $ws: () => WebSocket } ? false : true >().toEqualTypeOf(true) }) }) describe('without the leading slash', () => { const app = new Hono() .get('foo', (c) => c.json({})) .get('foo/bar', (c) => c.json({})) .get('foo/:id/baz', (c) => c.json({})) const client = hc('http://localhost') it('`foo` should have `$get`', () => { expectTypeOf(client.foo).toHaveProperty('$get') expectTypeOf(client.foo.$url()).toEqualTypeOf>() expectTypeOf(client.foo.$path()).toEqualTypeOf<'/foo'>() }) it('`foo.bar` should not have `$get`', () => { expectTypeOf(client.foo.bar).toHaveProperty('$get') expectTypeOf(client.foo.bar.$url()).toEqualTypeOf< TypedURL<'http:', 'localhost', '', '/foo/bar', ''> >() expectTypeOf(client.foo.bar.$path()).toEqualTypeOf<'/foo/bar'>() }) it('`foo[":id"].baz` should have `$get`', () => { expectTypeOf(client.foo[':id'].baz).toHaveProperty('$get') expectTypeOf(client.foo[':id'].baz.$url()).toEqualTypeOf< TypedURL<'http:', 'localhost', '', '/foo/:id/baz', ''> >() expectTypeOf( client.foo[':id'].baz.$url({ param: { id: '123' }, }) ).toEqualTypeOf>() expectTypeOf( client.foo[':id'].baz.$url({ param: { id: '123' }, query: { q: 'hono' }, }) ).toEqualTypeOf>() expectTypeOf(client.foo[':id'].baz.$path()).toEqualTypeOf<'/foo/:id/baz'>() expectTypeOf( client.foo[':id'].baz.$path({ param: { id: '123' }, }) ).toEqualTypeOf<'/foo/123/baz'>() expectTypeOf( client.foo[':id'].baz.$path({ param: { id: '123' }, query: { q: 'hono' }, }) ).toEqualTypeOf<`/foo/123/baz?${string}`>() }) }) describe('with the leading slash', () => { const app = new Hono() .get('/foo', (c) => c.json({})) .get('/foo/bar', (c) => c.json({})) .get('/foo/:id/baz', (c) => c.json({})) const client = hc('') it('`foo` should have `$get`', () => { expectTypeOf(client.foo).toHaveProperty('$get') }) it('`foo.bar` should not have `$get`', () => { expectTypeOf(client.foo.bar).toHaveProperty('$get') }) it('`foo[":id"].baz` should have `$get`', () => { expectTypeOf(client.foo[':id'].baz).toHaveProperty('$get') }) }) describe('app.all()', () => { const app = new Hono() .all('/all-route', (c) => c.json({ msg: 'all methods' })) .get('/get-route', (c) => c.json({ msg: 'get only' })) const client = hc('http://localhost', { fetch: app.request }) it('should NOT expose $all on the client', () => { expectTypeOf< (typeof client)['all-route'] extends { $all: unknown } ? true : false >().toEqualTypeOf() }) it('should still expose valid HTTP methods like $get', () => { expectTypeOf(client['get-route']).toHaveProperty('$get') }) it('should expose all standard HTTP methods for routes defined with app.all()', () => { // $all routes should have all standard HTTP methods typed expectTypeOf(client['all-route']).toHaveProperty('$get') expectTypeOf(client['all-route']).toHaveProperty('$post') expectTypeOf(client['all-route']).toHaveProperty('$put') expectTypeOf(client['all-route']).toHaveProperty('$delete') expectTypeOf(client['all-route']).toHaveProperty('$options') expectTypeOf(client['all-route']).toHaveProperty('$patch') }) it('should have correct return type for expanded methods', async () => { // The response type should match the original handler's return type const res = await client['all-route'].$get() expectTypeOf(res.json()).resolves.toEqualTypeOf<{ msg: string }>() }) }) describe('with base URL pathname', () => { const app = new Hono() .get('foo', (c) => c.json({})) .get('foo/bar', (c) => c.json({})) .get('foo/:id/baz', (c) => c.json({})) const client = hc('http://localhost/api') it('$path', () => { expectTypeOf(client.foo.$path()).toEqualTypeOf<'/foo'>() expectTypeOf(client.foo.bar.$path()).toEqualTypeOf<'/foo/bar'>() expectTypeOf( client.foo[':id'].baz.$path({ param: { id: '123' }, query: { q: 'hono' }, }) ).toEqualTypeOf<`/foo/123/baz?${string}`>() }) }) ================================================ FILE: src/client/types.ts ================================================ import type { Hono } from '../hono' import type { HonoBase } from '../hono-base' import type { METHODS, METHOD_NAME_ALL_LOWERCASE } from '../router' import type { Endpoint, ExtractSchema, KnownResponseFormat, ResponseFormat, Schema } from '../types' import type { StatusCode, SuccessStatusCode } from '../utils/http-status' import type { HasRequiredKeys } from '../utils/types' /** * Type representing the '$all' method name */ type MethodNameAll = `$${typeof METHOD_NAME_ALL_LOWERCASE}` /** * Type representing all standard HTTP methods prefixed with '$' * e.g., '$get' | '$post' | '$put' | '$delete' | '$options' | '$patch' */ type StandardMethods = `$${(typeof METHODS)[number]}` /** * Expands '$all' into all standard HTTP methods. * If the schema contains '$all', it creates a type where all standard HTTP methods * point to the same endpoint definition as '$all', while removing '$all' itself. */ type ExpandAllMethod = MethodNameAll extends keyof S ? { [M in StandardMethods]: S[MethodNameAll] } & Omit : S type HonoRequest = (typeof Hono.prototype)['request'] export type BuildSearchParamsFn = (query: Record) => URLSearchParams export type ClientRequestOptions = { fetch?: typeof fetch | HonoRequest webSocket?: (...args: ConstructorParameters) => WebSocket /** * Standard `RequestInit`, caution that this take highest priority * and could be used to overwrite things that Hono sets for you, like `body | method | headers`. * * If you want to add some headers, use in `headers` instead of `init` */ init?: RequestInit /** * Custom function to serialize query parameters into URLSearchParams. * By default, arrays are serialized as multiple parameters with the same key (e.g., `key=a&key=b`). * You can provide a custom function to change this behavior, for example to use bracket notation (e.g., `key[]=a&key[]=b`). * * @example * ```ts * const client = hc('http://localhost', { * buildSearchParams: (query) => { * return new URLSearchParams(qs.stringify(query)) * } * }) * ``` */ buildSearchParams?: BuildSearchParamsFn } & (keyof T extends never ? { headers?: | Record | (() => Record | Promise>) } : { headers: T | (() => T | Promise) }) export type ClientRequest = { [M in keyof ExpandAllMethod]: ExpandAllMethod[M] extends Endpoint & { input: infer R } ? R extends object ? HasRequiredKeys extends true ? ( args: R, options?: ClientRequestOptions ) => Promise[M]>> : ( args?: R, options?: ClientRequestOptions ) => Promise[M]>> : never : never } & { $url: < const Arg extends | (S[keyof S] extends { input: infer R } ? R extends { param: infer P } ? R extends { query: infer Q } ? { param: P; query: Q } : { param: P } : R extends { query: infer Q } ? { query: Q } : {} : {}) | undefined = undefined, >( arg?: Arg ) => HonoURL $path: < const Arg extends | (S[keyof S] extends { input: infer R } ? R extends { param: infer P } ? R extends { query: infer Q } ? { param: P; query: Q } : { param: P } : R extends { query: infer Q } ? { query: Q } : {} : {}) | undefined = undefined, >( arg?: Arg ) => BuildPath } & (S['$get'] extends { outputFormat: 'ws' } ? S['$get'] extends { input: infer I } ? { $ws: (args?: I) => WebSocket } : {} : {}) type ClientResponseOfEndpoint = T extends { output: infer O outputFormat: infer F status: infer S } ? ClientResponse : never export interface ClientResponse< T, U extends number = StatusCode, F extends ResponseFormat = ResponseFormat, > { readonly body: ReadableStream | null readonly bodyUsed: boolean ok: U extends SuccessStatusCode ? true : U extends Exclude ? false : boolean redirected: boolean status: U statusText: string type: 'basic' | 'cors' | 'default' | 'error' | 'opaque' | 'opaqueredirect' headers: Headers url: string redirect(url: string, status: number): Response clone(): Response bytes(): Promise> json(): F extends 'text' ? Promise : F extends 'json' ? Promise : Promise text(): F extends 'text' ? (T extends string ? Promise : Promise) : Promise blob(): Promise formData(): Promise arrayBuffer(): Promise } type BuildSearch = Arg extends { [K in Key]: infer Query } ? IsEmptyObject extends true ? '' : `?${string}` : '' type BuildPathname

= Arg extends { param: infer Param } ? `${ApplyParam, Param>}` : `/${TrimStartSlash

}` type BuildPath

= `${BuildPathname}${BuildSearch}` type BuildTypedURL< Protocol extends string, Host extends string, Port extends string, P extends string, Arg, > = TypedURL<`${Protocol}:`, Host, Port, BuildPathname, BuildSearch> type HonoURL = IsLiteral extends true ? TrimEndSlash extends `${infer Protocol}://${infer Rest}` ? Rest extends `${infer Hostname}/${infer P}` ? ParseHostName extends [infer Host extends string, infer Port extends string] ? BuildTypedURL : never : ParseHostName extends [infer Host extends string, infer Port extends string] ? BuildTypedURL : never : URL : URL type ParseHostName = T extends `${infer Host}:${infer Port}` ? [Host, Port] : [T, ''] type TrimStartSlash = T extends `/${infer R}` ? TrimStartSlash : T type TrimEndSlash = T extends `${infer R}/` ? TrimEndSlash : T type IsLiteral = [T] extends [never] ? false : string extends T ? false : true type ApplyParam< Path extends string, P, Result extends string = '', > = Path extends `${infer Head}/${infer Rest}` ? Head extends `:${infer Param}` ? P extends Record ? IsLiteral extends true ? ApplyParam : ApplyParam : ApplyParam : ApplyParam : Path extends `:${infer Param}` ? P extends Record ? IsLiteral extends true ? `${Result}/${Value & string}` : `${Result}/${Path}` : `${Result}/${Path}` : `${Result}/${Path}` type IsEmptyObject = keyof T extends never ? true : false export interface TypedURL< Protocol extends string, Hostname extends string, Port extends string, Pathname extends string, Search extends string, > extends URL { protocol: Protocol hostname: Hostname port: Port host: Port extends '' ? Hostname : `${Hostname}:${Port}` origin: `${Protocol}//${Hostname}${Port extends '' ? '' : `:${Port}`}` pathname: Pathname search: Search href: `${Protocol}//${Hostname}${Port extends '' ? '' : `:${Port}`}${Pathname}${Search}` } export interface Response extends ClientResponse {} export type Fetch = ( args?: InferRequestType, opt?: ClientRequestOptions ) => Promise>> type InferEndpointType = T extends ( args: infer R, // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any | undefined ) => Promise ? U extends ClientResponse ? { input: NonNullable; output: O; outputFormat: F; status: S } extends Endpoint ? { input: NonNullable; output: O; outputFormat: F; status: S } : never : never : never export type InferResponseType = InferResponseTypeFromEndpoint< InferEndpointType, U > type InferResponseTypeFromEndpoint = T extends { output: infer O status: infer S } ? S extends U ? O : never : never export type InferRequestType = T extends ( args: infer R, // eslint-disable-next-line @typescript-eslint/no-explicit-any options: any | undefined ) => Promise> ? NonNullable : never export type InferRequestOptionsType = T extends ( // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any, options: infer R ) => Promise> ? NonNullable : never /** * Filter a ClientResponse type so it only includes responses of specific status codes. */ export type FilterClientResponseByStatusCode< T extends ClientResponse, U extends number = StatusCode, > = T extends ClientResponse ? RC extends U ? ClientResponse : never : never type PathToChain< Prefix extends string, Path extends string, E extends Schema, Original extends string = Path, > = Path extends `/${infer P}` ? PathToChain : Path extends `${infer P}/${infer R}` ? { [K in P]: PathToChain } : { [K in Path extends '' ? 'index' : Path]: ClientRequest< Prefix, Original, E extends Record ? E[Original] : never > } export type Client = T extends HonoBase ? S extends Record ? K extends string ? PathToChain : never : never : never export type Callback = (opts: CallbackOptions) => unknown interface CallbackOptions { path: string[] // eslint-disable-next-line @typescript-eslint/no-explicit-any args: any[] } export type ObjectType = { [key: string]: T } type GlobalResponseDefinition = { [S in StatusCode]?: { [F in KnownResponseFormat]?: unknown } } type ToEndpoints = { [S in keyof Def & StatusCode]: { [F in keyof Def[S] & KnownResponseFormat]: Omit & { output: Def[S][F] status: S outputFormat: F } }[keyof Def[S] & KnownResponseFormat] }[keyof Def & StatusCode] type ModRoute = R extends Endpoint ? R | ToEndpoints : R type ModSchema = { [K in keyof D]: { [M in keyof D[K]]: ModRoute } } export type ApplyGlobalResponse = App extends HonoBase ? ModSchema, Def> extends infer S extends Schema ? Hono : never : never ================================================ FILE: src/client/utils.test.ts ================================================ import { HttpResponse, http } from 'msw' import { setupServer } from 'msw/node' import { Hono } from '../hono' import type { Expect, Equal } from '../utils/types' import { hc } from './client' import { buildSearchParams, deepMerge, parseResponse, mergePath, removeIndexString, replaceUrlParam, replaceUrlProtocol, } from './utils' describe('mergePath', () => { it('Should merge paths correctly', () => { expect(mergePath('http://localhost', '/api')).toBe('http://localhost/api') expect(mergePath('http://localhost/', '/api')).toBe('http://localhost/api') expect(mergePath('http://localhost', 'api')).toBe('http://localhost/api') expect(mergePath('http://localhost/', 'api')).toBe('http://localhost/api') expect(mergePath('http://localhost/', '/')).toBe('http://localhost/') }) }) describe('replaceUrlParams', () => { it('Should replace correctly', () => { const url = 'http://localhost/posts/:postId/comments/:commentId' const params = { postId: '123', commentId: '456', } const replacedUrl = replaceUrlParam(url, params) expect(replacedUrl).toBe('http://localhost/posts/123/comments/456') }) it('Should replace correctly when there is regex pattern', () => { const url = 'http://localhost/posts/:postId{[abc]+}/comments/:commentId{[0-9]+}' const params = { postId: 'abc', commentId: '456', } const replacedUrl = replaceUrlParam(url, params) expect(replacedUrl).toBe('http://localhost/posts/abc/comments/456') }) it('Should replace correctly when there is regex pattern with length limit', () => { const url = 'http://localhost/year/:year{[1-9]{1}[0-9]{3}}/month/:month{[0-9]{2}}' const params = { year: '2024', month: '2', } const replacedUrl = replaceUrlParam(url, params) expect(replacedUrl).toBe('http://localhost/year/2024/month/2') }) it('Should replace correctly when it has optional parameters', () => { const url = 'http://localhost/something/:firstId/:secondId/:version?' const params = { firstId: '123', secondId: '456', version: undefined, } const replacedUrl = replaceUrlParam(url, params) expect(replacedUrl).toBe('http://localhost/something/123/456') }) }) describe('buildSearchParams', () => { it('Should build URLSearchParams correctly', () => { const query = { id: '123', type: 'test', tag: ['a', 'b'], } const searchParams = buildSearchParams(query) expect(searchParams.toString()).toBe('id=123&type=test&tag=a&tag=b') }) }) describe('replaceUrlProtocol', () => { it('Should replace http to ws', () => { const url = 'http://localhost' const newUrl = replaceUrlProtocol(url, 'ws') expect(newUrl).toBe('ws://localhost') }) it('Should replace https to wss', () => { const url = 'https://localhost' const newUrl = replaceUrlProtocol(url, 'ws') expect(newUrl).toBe('wss://localhost') }) it('Should replace ws to http', () => { const url = 'ws://localhost' const newUrl = replaceUrlProtocol(url, 'http') expect(newUrl).toBe('http://localhost') }) it('Should replace wss to https', () => { const url = 'wss://localhost' const newUrl = replaceUrlProtocol(url, 'http') expect(newUrl).toBe('https://localhost') }) }) describe('removeIndexString', () => { it('Should remove last `/index` string', () => { let url = 'http://localhost/index' let newUrl = removeIndexString(url) expect(newUrl).toBe('http://localhost/') url = '/index' newUrl = removeIndexString(url) expect(newUrl).toBe('') url = '/sub/index' newUrl = removeIndexString(url) expect(newUrl).toBe('/sub') url = '/subindex' newUrl = removeIndexString(url) expect(newUrl).toBe('/subindex') }) it('Should remove `/index` with query parameters', () => { let url = 'http://localhost/index?page=123&limit=20' let newUrl = removeIndexString(url) expect(newUrl).toBe('http://localhost/?page=123&limit=20') url = 'https://example.com/index?q=search' newUrl = removeIndexString(url) expect(newUrl).toBe('https://example.com/?q=search') url = '/api/index?filter=test' newUrl = removeIndexString(url) expect(newUrl).toBe('/api?filter=test') url = '/index?a=1&b=2&c=3' newUrl = removeIndexString(url) expect(newUrl).toBe('?a=1&b=2&c=3') }) }) describe('deepMerge', () => { it('should return the source object if the target object is not an object', () => { const target = null const source = 'not an object' as unknown as Record const result = deepMerge(target, source) expect(result).toEqual(source) }) it('should merge two objects with object properties', () => { expect( deepMerge( { headers: { hono: '1' }, timeout: 2, params: {} }, { headers: { hono: '2', demo: 2 }, params: undefined } ) ).toStrictEqual({ params: undefined, headers: { hono: '2', demo: 2 }, timeout: 2, }) }) }) describe('parseResponse', async () => { const _app = new Hono() .get('/text', (c) => c.text('hi')) .get('/json', (c) => c.json({ message: 'hi' })) .get('/might-error-json', (c) => { if (Math.random() > 0.5) { return c.json({ error: 'error' }, 500) } return c.json({ data: [{ id: 1 }, { id: 2 }] }) }) .get('/might-error-mixed-json-text', (c) => { if (Math.random() > 0.5) { return c.text('500 Internal Server Error', 500) } return c.json({ message: 'Success' }) }) .get('/200-explicit', (c) => c.text('OK', 200)) .get('/404', (c) => c.text('404 Not Found', 404)) .get('/500', (c) => c.text('500 Internal Server Error', 500)) .get('/raw', (c) => { c.header('content-type', '') return c.body('hello') }) .get('/rawUnknown', (c) => { c.header('content-type', 'x/custom-type') return c.body('hello') }) .get('/rawBuffer', (c) => { c.header('content-type', 'x/custom-type') return c.body(new TextEncoder().encode('hono')) }) const client = hc('http://localhost') const server = setupServer( http.get('http://localhost/text', () => { return HttpResponse.text('hi') }), http.get('http://localhost/json', () => { return HttpResponse.json({ message: 'hi' }) }), http.get('http://localhost/might-error-json', () => { if (Math.random() > 0.5) { return HttpResponse.json({ error: 'error' }, { status: 500 }) } return HttpResponse.json({ data: [{ id: 1 }, { id: 2 }] }) }), http.get('http://localhost/might-error-mixed-json-text', () => { if (Math.random() > 0.5) { return HttpResponse.text('500 Internal Server Error', { status: 500 }) } return HttpResponse.json({ message: 'Success' }) }), http.get('http://localhost/200-explicit', () => { return HttpResponse.text('OK', { status: 200 }) }), http.get('http://localhost/404', () => { return HttpResponse.text('404 Not Found', { status: 404 }) }), http.get('http://localhost/500', () => { return HttpResponse.text('500 Internal Server Error', { status: 500 }) }), http.get('http://localhost/raw', () => { return HttpResponse.text('hello', { headers: { 'content-type': '', }, }) }), http.get('http://localhost/rawUnknown', () => { return HttpResponse.text('hello', { headers: { 'content-type': 'x/custom-type', }, }) }), http.get('http://localhost/rawBuffer', () => { return HttpResponse.arrayBuffer(new TextEncoder().encode('hono').buffer, { headers: { 'content-type': 'x/custom-type', }, }) }) ) beforeAll(() => server.listen()) afterEach(() => server.resetHandlers()) afterAll(() => server.close()) await Promise.all([ it('should auto parse the text response - async fetch', async () => { const result = await parseResponse(client.text.$get()) expect(result).toBe('hi') type _verify = Expect> }), it('should auto parse the text response - sync fetch', async () => { const result = await parseResponse(await client.text.$get()) expect(result).toBe('hi') type _verify = Expect> }), it('should auto parse text response - explicit 200', async () => { const result = await parseResponse(client['200-explicit'].$get()) expect(result).toBe('OK') type _verify = Expect> }), it('should auto parse the json response - async fetch', async () => { const result = await parseResponse(client.json.$get()) expect(result).toEqual({ message: 'hi' }) type _verify = Expect> }), it('should auto parse the json response - sync fetch', async () => { const result = await parseResponse(await client.json.$get()) expect(result).toEqual({ message: 'hi' }) type _verify = Expect> }), it('should throw error when the response is not ok', async () => { await expect(parseResponse(client['404'].$get())).rejects.toThrowError('404 Not Found') }), it('should parse as text for raw responses without content-type header', async () => { const result = await parseResponse(client.raw.$get()) expect(result).toBe('hello') type _verify = Expect> }), it('should parse as unknown string for raw buffer responses with unknown content-type header', async () => { const result = await parseResponse(client.rawBuffer.$get()) expect(result).toMatchInlineSnapshot('"hono"') type _verify = Expect> }), it('should throw error matching snapshots', async () => { // Defined 404 route await expect(parseResponse(client['404'].$get())).rejects.toThrowErrorMatchingInlineSnapshot( '[DetailedError: 404 Not Found]' ) // Defined 500 route await expect(parseResponse(client['500'].$get())).rejects.toThrowErrorMatchingInlineSnapshot( '[DetailedError: 500 Internal Server Error]' ) // Not defined route // Note: the error in this test case is thrown at the `fetch` call (.$get()), not during the `parseResponse` call, so I think `parseResponse` should not try to catch and wrap it into a structured error, which could be inconsistent, if the user awaited the fetch. await expect( // @ts-expect-error noRoute is not defined parseResponse(client['noRoute'].$get()) ).rejects.toThrowErrorMatchingInlineSnapshot('[TypeError: fetch failed]') }), it('(type-only) should bypass error responses in the result type inference - simple 404', async () => { type ResultType = Awaited< ReturnType>>> > type _verify = Expect, undefined>> }), it('(type-only) should bypass error responses in the result type inference - conditional - json', async () => { type ResultType = Awaited< ReturnType< typeof parseResponse>> > > type _verify = Expect> }), it('(type-only) should bypass error responses in the result type inference - conditional - mixed json/text', async () => { type ResultType = Awaited< ReturnType< typeof parseResponse< Awaited> > > > type _verify = Expect> }), ]) it('should parse json response with _bodyInit when body is undefined', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, headers: new Headers({ 'content-type': 'application/json' }), body: undefined, _bodyInit: '{"message":"test"}', json: async () => ({ message: 'test' }), }) global.fetch = mockFetch const mockClientResponse = { $get: mockFetch, } const result = await parseResponse(mockClientResponse.$get()) expect(result).toEqual({ message: 'test' }) }) }) ================================================ FILE: src/client/utils.ts ================================================ import type { ClientErrorStatusCode, ContentfulStatusCode, ServerErrorStatusCode, } from '../utils/http-status' import { fetchRP, DetailedError } from './fetch-result-please' import type { ClientResponse, FilterClientResponseByStatusCode, ObjectType } from './types' export { DetailedError } export const mergePath = (base: string, path: string) => { base = base.replace(/\/+$/, '') base = base + '/' path = path.replace(/^\/+/, '') return base + path } export const replaceUrlParam = (urlString: string, params: Record) => { for (const [k, v] of Object.entries(params)) { const reg = new RegExp('/:' + k + '(?:{[^/]+})?\\??') urlString = urlString.replace(reg, v ? `/${v}` : '') } return urlString } export const buildSearchParams = (query: Record) => { const searchParams = new URLSearchParams() for (const [k, v] of Object.entries(query)) { if (v === undefined) { continue } if (Array.isArray(v)) { for (const v2 of v) { searchParams.append(k, v2) } } else { searchParams.set(k, v) } } return searchParams } export const replaceUrlProtocol = (urlString: string, protocol: 'ws' | 'http') => { switch (protocol) { case 'ws': return urlString.replace(/^http/, 'ws') case 'http': return urlString.replace(/^ws/, 'http') } } export const removeIndexString = (urlString: string) => { if (/^https?:\/\/[^\/]+?\/index(?=\?|$)/.test(urlString)) { return urlString.replace(/\/index(?=\?|$)/, '/') } return urlString.replace(/\/index(?=\?|$)/, '') } function isObject(item: unknown): item is ObjectType { return typeof item === 'object' && item !== null && !Array.isArray(item) } export function deepMerge(target: T, source: Record): T { if (!isObject(target) && !isObject(source)) { return source as T } const merged = { ...target } as ObjectType for (const key in source) { const value = source[key] if (isObject(merged[key]) && isObject(value)) { merged[key] = deepMerge(merged[key], value) } else { merged[key] = value as T[keyof T] & T } } return merged as T } /** * Shortcut to get a consumable response from `hc`'s fetch calls (Response), with types inference. * * Smartly parse the response data, throwing a structured error if the response is not `ok`. ({@link DetailedError}) * * @example const result = await parseResponse(client.posts.$get()) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function parseResponse>( fetchRes: T | Promise ): Promise< FilterClientResponseByStatusCode< T, Exclude // Filter out the error responses > extends never ? // Filtered responses does not include any contentful responses, exit with undefined undefined : // Filtered responses includes contentful responses, proceed to infer the type FilterClientResponseByStatusCode< T, Exclude > extends ClientResponse ? RF extends 'json' ? RT : RT extends string ? RT : string : undefined > { return fetchRP(fetchRes) } ================================================ FILE: src/compose.test.ts ================================================ import { compose } from './compose' import { Context } from './context' import type { Params } from './router' import type { Next } from './types' type MiddlewareTuple = [[Function, unknown], Params] class ExpectedError extends Error {} function buildMiddlewareTuple(fn: Function, params?: Params): MiddlewareTuple { return [[fn, undefined], params || {}] } describe('compose', () => { const middleware: MiddlewareTuple[] = [] const a = async (c: Context, next: Next) => { c.set('log', 'log') await next() } const b = async (c: Context, next: Next) => { await next() c.header('x-custom-header', 'custom-header') } const c = async (c: Context, next: Next) => { c.set('xxx', 'yyy') await next() c.set('zzz', 'xxx') } const handler = async (c: Context, next: Next) => { c.set('log', `${c.get('log')} message`) await next() return c.json({ message: 'new response' }) } middleware.push(buildMiddlewareTuple(a)) middleware.push(buildMiddlewareTuple(b)) middleware.push(buildMiddlewareTuple(c)) middleware.push(buildMiddlewareTuple(handler)) it('Request', async () => { const composed = compose(middleware) const context = await composed(new Context(new Request('http://localhost/'))) expect(context.get('log')).not.toBeNull() expect(context.get('log')).toBe('log message') expect(context.get('xxx')).toBe('yyy') }) it('Response', async () => { const composed = compose(middleware) const context = await composed(new Context(new Request('http://localhost/'))) expect(context.res.headers.get('x-custom-header')).not.toBeNull() expect(context.res.headers.get('x-custom-header')).toBe('custom-header') expect((await context.res.json())['message']).toBe('new response') expect(context.get('zzz')).toBe('xxx') }) }) describe('compose with returning a promise, non-async function', () => { const handlers: MiddlewareTuple[] = [ buildMiddlewareTuple(() => { return new Promise((resolve) => setTimeout(() => { resolve( new Response(JSON.stringify({ message: 'new response' }), { headers: { 'Content-Type': 'application/json', }, }) ) }) ) }), ] it('Response', async () => { const composed = compose(handlers) const context = await composed(new Context(new Request('http://localhost/'))) expect((await context.res.json())['message']).toBe('new response') }) }) describe('Handler and middlewares', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const c: Context = new Context(req) const mHandlerFoo = async (c: Context, next: Next) => { c.req.raw.headers.append('x-header-foo', 'foo') await next() } const mHandlerBar = async (c: Context, next: Next) => { await next() c.header('x-header-bar', 'bar') } const handler = (c: Context) => { const foo = c.req.header('x-header-foo') || '' return c.text(foo) } middleware.push(buildMiddlewareTuple(mHandlerFoo)) middleware.push(buildMiddlewareTuple(mHandlerBar)) middleware.push(buildMiddlewareTuple(handler)) it('Should return 200 Response', async () => { const composed = compose(middleware) const context = await composed(c) const res = context.res expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('foo') expect(res.headers.get('x-header-bar')).toBe('bar') }) }) describe('compose with Context - 200 success', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const c: Context = new Context(req) const handler = (c: Context) => { return c.text('Hello') } const mHandler = async (_c: Context, next: Next) => { await next() } middleware.push(buildMiddlewareTuple(handler)) middleware.push(buildMiddlewareTuple(mHandler)) it('Should return 200 Response', async () => { const composed = compose(middleware) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(200) expect(await context.res.text()).toBe('Hello') }) }) describe('compose with Context - 404 not found', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const onNotFound = (c: Context) => { return c.text('onNotFound', 404) } const onNotFoundAsync = async (c: Context) => { return c.text('onNotFoundAsync', 404) } const mHandler = async (_c: Context, next: Next) => { await next() } middleware.push(buildMiddlewareTuple(mHandler)) it('Should return 404 Response', async () => { const c: Context = new Context(req) const composed = compose(middleware, undefined, onNotFound) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(404) expect(await context.res.text()).toBe('onNotFound') expect(context.finalized).toBe(true) }) it('Should return 404 Response - async handler', async () => { const c: Context = new Context(req) const composed = compose(middleware, undefined, onNotFoundAsync) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(404) expect(await context.res.text()).toBe('onNotFoundAsync') expect(context.finalized).toBe(true) }) }) describe('compose with Context - 401 not authorized', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const c: Context = new Context(req) const handler = (c: Context) => { return c.text('Hello') } const mHandler = async (c: Context, next: Next) => { await next() c.res = new Response('Not authorized', { status: 401 }) } middleware.push(buildMiddlewareTuple(mHandler)) middleware.push(buildMiddlewareTuple(handler)) it('Should return 401 Response', async () => { const composed = compose(middleware) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(401) expect(await context.res.text()).toBe('Not authorized') expect(context.finalized).toBe(true) }) }) describe('compose with Context - next() below', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const c: Context = new Context(req) const handler = (c: Context) => { const message = c.req.header('x-custom') || 'blank' return c.text(message) } const mHandler = async (c: Context, next: Next) => { c.req.raw.headers.append('x-custom', 'foo') await next() } middleware.push(buildMiddlewareTuple(mHandler)) middleware.push(buildMiddlewareTuple(handler)) it('Should return 200 Response', async () => { const composed = compose(middleware) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(200) expect(await context.res.text()).toBe('foo') expect(context.finalized).toBe(true) }) }) describe('compose with Context - 500 error', () => { const middleware: MiddlewareTuple[] = [] const req = new Request('http://localhost/') const c: Context = new Context(req) it('Error on handler', async () => { const handler = () => { throw new Error() } const mHandler = async (_c: Context, next: Next) => { await next() } middleware.push(buildMiddlewareTuple(mHandler)) middleware.push(buildMiddlewareTuple(handler)) const onNotFound = (c: Context) => c.text('NotFound', 404) const onError = (_error: Error, c: Context) => c.text('onError', 500) const composed = compose(middleware, onError, onNotFound) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(500) expect(await context.res.text()).toBe('onError') expect(context.finalized).toBe(true) }) it('Error on handler - async', async () => { const handler = () => { throw new Error() } middleware.push(buildMiddlewareTuple(handler)) const onError = async (_error: Error, c: Context) => c.text('onError', 500) const composed = compose(middleware, onError) const context = await composed(c) expect(context.res).not.toBeNull() expect(context.res.status).toBe(500) expect(await context.res.text()).toBe('onError') expect(context.finalized).toBe(true) }) it('Run all the middlewares', async () => { const stack: number[] = [] const middlewares = [ async (_ctx: Context, next: Next) => { stack.push(0) await next() }, async (_ctx: Context, next: Next) => { stack.push(1) await next() }, async (_ctx: Context, next: Next) => { stack.push(2) await next() }, ].map((h) => buildMiddlewareTuple(h)) const composed = compose(middlewares) await composed(new Context(new Request('http://localhost/'))) expect(stack).toEqual([0, 1, 2]) }) }) describe('compose with Context - not finalized', () => { const req = new Request('http://localhost/') const c: Context = new Context(req) const onNotFound = (c: Context) => { return c.text('onNotFound', 404) } it('Should not be finalized - lack `next()`', async () => { const middleware: MiddlewareTuple[] = [] const mHandler = async (_c: Context, next: Next) => { await next() } const mHandler2 = async () => {} middleware.push(buildMiddlewareTuple(mHandler)) middleware.push(buildMiddlewareTuple(mHandler2)) const composed = compose(middleware, undefined, onNotFound) const context = await composed(c) expect(context.finalized).toBe(false) }) it('Should not be finalized - lack `return Response`', async () => { const middleware2: MiddlewareTuple[] = [] const mHandler3 = async (_c: Context, next: Next) => { await next() } const handler = async () => {} middleware2.push(buildMiddlewareTuple(mHandler3)) middleware2.push(buildMiddlewareTuple(handler)) const composed = compose(middleware2, undefined, onNotFound) const context = await composed(c) expect(context.finalized).toBe(false) }) }) describe('compose with Context - next', () => { const req = new Request('http://localhost/') const c: Context = new Context(req) it('Should throw multiple call error', async () => { const middleware: MiddlewareTuple[] = [] const mHandler = async (_c: Context, next: Next) => { await next() } const mHandler2 = async (_c: Context, next: Next) => { await next() await next() } middleware.push(buildMiddlewareTuple(mHandler)) middleware.push(buildMiddlewareTuple(mHandler2)) const composed = compose(middleware) try { await composed(c) } catch (err) { expect(err).toStrictEqual(new Error('next() called multiple times')) } }) }) describe('Compose', function () { it('should get executed order one by one', async () => { const arr: number[] = [] const stack = [] const called: boolean[] = [] stack.push( buildMiddlewareTuple(async (_context: Context, next: Next) => { called.push(true) arr.push(1) await next() arr.push(6) }) ) stack.push( buildMiddlewareTuple(async (_context: Context, next: Next) => { called.push(true) arr.push(2) await next() arr.push(5) }) ) stack.push( buildMiddlewareTuple(async (_context: Context, next: Next) => { called.push(true) arr.push(3) await next() arr.push(4) }) ) await compose(stack)(new Context(new Request('http://localhost/'))) expect(called).toEqual([true, true, true]) expect(arr).toEqual([1, 2, 3, 4, 5, 6]) }) it('should not get executed if previous next() not triggered', async () => { const arr: number[] = [] const stack = [] const called: boolean[] = [] stack.push( buildMiddlewareTuple(async (_context: Context, next: Next) => { called.push(true) arr.push(1) await next() arr.push(6) }) ) stack.push( buildMiddlewareTuple(async () => { called.push(true) arr.push(2) }) ) stack.push( buildMiddlewareTuple(async (_context: Context, next: Next) => { called.push(true) arr.push(3) await next() arr.push(4) }) ) await compose(stack)(new Context(new Request('http://localhost/'))) expect(called).toEqual([true, true]) expect(arr).toEqual([1, 2, 6]) }) it('should be able to be called twice', async () => { const stack = [] stack.push( buildMiddlewareTuple(async (context: Context, next: Next) => { context.get('arr').push(1) await next() context.get('arr').push(6) }) ) stack.push( buildMiddlewareTuple(async (context: Context, next: Next) => { context.get('arr').push(2) await next() context.get('arr').push(5) }) ) stack.push( buildMiddlewareTuple(async (context: Context, next: Next) => { context.get('arr').push(3) await next() context.get('arr').push(4) }) ) const fn = compose(stack) const ctx1 = new Context(new Request('http://localhost/')) ctx1.set('arr', []) const ctx2 = new Context(new Request('http://localhost/')) ctx2.set('arr', []) const out = [1, 2, 3, 4, 5, 6] await fn(ctx1) expect(out).toEqual(ctx1.get('arr')) await fn(ctx2) expect(out).toEqual(ctx2.get('arr')) }) it('should create next functions that return a Promise', async () => { const stack = [] const arr: unknown[] = [] for (let i = 0; i < 5; i++) { stack.push( buildMiddlewareTuple((_context: Context, next: Next) => { arr.push(next()) }) ) } await compose(stack)(new Context(new Request('http://localhost/'))) for (const next of arr) { const isPromise = !!(next as { then?: Function })?.then expect(isPromise).toBe(true) } }) it('should work with 0 middleware', async () => { await compose([])(new Context(new Request('http://localhost/'))) }) it('should work when yielding at the end of the stack', async () => { const stack = [] let called = false stack.push( buildMiddlewareTuple(async (_ctx: Context, next: Next) => { await next() called = true }) ) await compose(stack)(new Context(new Request('http://localhost/'))) expect(called).toBe(true) }) it('should reject on errors in middleware', async () => { const stack = [] stack.push( buildMiddlewareTuple(() => { throw new ExpectedError() }) ) try { await compose(stack)(new Context(new Request('http://localhost/'))) throw new Error('promise was not rejected') } catch (e) { expect(e).toBeInstanceOf(ExpectedError) } }) it('should keep the context', async () => { const ctx = new Context(new Request('http://localhost/')) const stack = [] stack.push( buildMiddlewareTuple(async (ctx2: Context, next: Next) => { await next() expect(ctx2).toEqual(ctx) }) ) stack.push( buildMiddlewareTuple(async (ctx2: Context, next: Next) => { await next() expect(ctx2).toEqual(ctx) }) ) stack.push( buildMiddlewareTuple(async (ctx2: Context, next: Next) => { await next() expect(ctx2).toEqual(ctx) }) ) await compose(stack)(ctx) }) it('should catch downstream errors', async () => { const arr: number[] = [] const stack = [] stack.push( buildMiddlewareTuple(async (_ctx: Context, next: Next) => { arr.push(1) try { arr.push(6) await next() arr.push(7) } catch { arr.push(2) } arr.push(3) }) ) stack.push( buildMiddlewareTuple(async () => { arr.push(4) throw new Error() }) ) await compose(stack)(new Context(new Request('http://localhost/'))) expect(arr).toEqual([1, 6, 4, 2, 3]) }) it('should compose w/ next', async () => { let called = false await compose([])(new Context(new Request('http://localhost/')), async () => { called = true }) expect(called).toBe(true) }) it('should handle errors in wrapped non-async functions', async () => { const stack = [] stack.push( buildMiddlewareTuple(function () { throw new ExpectedError() }) ) try { await compose(stack)(new Context(new Request('http://localhost/'))) throw new Error('promise was not rejected') } catch (e) { expect(e).toBeInstanceOf(ExpectedError) } }) // https://github.com/koajs/compose/pull/27#issuecomment-143109739 it('should compose w/ other compositions', async () => { const called: number[] = [] await compose([ buildMiddlewareTuple( compose([ buildMiddlewareTuple((_ctx: Context, next: Next) => { called.push(1) return next() }), buildMiddlewareTuple((_ctx: Context, next: Next) => { called.push(2) return next() }), ]) ), buildMiddlewareTuple((_ctx: Context, next: Next) => { called.push(3) return next() }), ])(new Context(new Request('http://localhost/'))) expect(called).toEqual([1, 2, 3]) }) it('should throw if next() is called multiple times', async () => { try { await compose([ buildMiddlewareTuple(async (_ctx: Context, next: Next) => { await next() await next() }), ])(new Context(new Request('http://localhost/'))) throw new Error('boom') } catch (err) { expect(err instanceof Error && /multiple times/.test(err.message)).toBe(true) } }) it('should return a valid middleware', async () => { let val = 0 await compose([ buildMiddlewareTuple( compose([ buildMiddlewareTuple((_ctx: Context, next: Next) => { val++ return next() }), buildMiddlewareTuple((_ctx: Context, next: Next) => { val++ return next() }), ]) ), buildMiddlewareTuple((_ctx: Context, next: Next) => { val++ return next() }), ])(new Context(new Request('http://localhost/'))) expect(val).toEqual(3) }) it('should return last return value', async () => { const stack = [] stack.push( buildMiddlewareTuple(async (ctx: Context, next: Next) => { await next() expect(ctx.get('val')).toEqual(2) ctx.set('val', 1) }) ) stack.push( buildMiddlewareTuple(async (ctx: Context, next: Next) => { ctx.set('val', 2) await next() expect(ctx.get('val')).toEqual(2) }) ) const res = await compose(stack)(new Context(new Request('http://localhost/'))) expect(res.get('val')).toEqual(1) }) it('should not affect the original middleware array', () => { const middleware: MiddlewareTuple[] = [] const fn1 = (_ctx: Context, next: Next) => { return next() } middleware.push(buildMiddlewareTuple(fn1)) for (const [[fn]] of middleware) { expect(fn).toEqual(fn1) } compose(middleware) for (const [[fn]] of middleware) { expect(fn).toEqual(fn1) } }) it('should not get stuck on the passed in next', async () => { const middleware = [ buildMiddlewareTuple((ctx: Context, next: Next) => { ctx.set('middleware', ctx.get('middleware') + 1) return next() }), ] const ctx = new Context(new Request('http://localhost/')) ctx.set('middleware', 0) ctx.set('next', 0) await compose(middleware)(ctx, ((ctx: Context, next: Next) => { ctx.set('next', ctx.get('next') + 1) return next() }) as Next) expect(ctx.get('middleware')).toEqual(1) expect(ctx.get('next')).toEqual(1) }) }) ================================================ FILE: src/compose.ts ================================================ import type { Context } from './context' import type { Env, ErrorHandler, Next, NotFoundHandler } from './types' /** * Compose middleware functions into a single function based on `koa-compose` package. * * @template E - The environment type. * * @param {[[Function, unknown], unknown][] | [[Function]][]} middleware - An array of middleware functions and their corresponding parameters. * @param {ErrorHandler} [onError] - An optional error handler function. * @param {NotFoundHandler} [onNotFound] - An optional not-found handler function. * * @returns {(context: Context, next?: Next) => Promise} - A composed middleware function. */ export const compose = ( middleware: [[Function, unknown], unknown][] | [[Function]][], onError?: ErrorHandler, onNotFound?: NotFoundHandler ): ((context: Context, next?: Next) => Promise) => { return (context, next) => { let index = -1 return dispatch(0) /** * Dispatch the middleware functions. * * @param {number} i - The current index in the middleware array. * * @returns {Promise} - A promise that resolves to the context. */ async function dispatch(i: number): Promise { if (i <= index) { throw new Error('next() called multiple times') } index = i let res let isError = false let handler if (middleware[i]) { handler = middleware[i][0][0] context.req.routeIndex = i } else { handler = (i === middleware.length && next) || undefined } if (handler) { try { res = await handler(context, () => dispatch(i + 1)) } catch (err) { if (err instanceof Error && onError) { context.error = err res = await onError(err, context) isError = true } else { throw err } } } else { if (context.finalized === false && onNotFound) { res = await onNotFound(context) } } if (res && (context.finalized === false || isError)) { context.res = res } return context } } } ================================================ FILE: src/context.test.ts ================================================ import { Context } from './context' import { setCookie } from './helper/cookie' const makeResponseHeaderImmutable = (res: Response) => { Object.defineProperty(res, 'headers', { value: new Proxy(res.headers, { set(target, prop, value) { if (prop === 'set') { throw new TypeError('Cannot modify headers: Headers are immutable') } return Reflect.set(target, prop, value) }, get(target, prop, receiver) { if (prop === 'set') { return function () { throw new TypeError('Cannot modify headers: Headers are immutable') } } const value = Reflect.get(target, prop) if (typeof value === 'function') { return Object.defineProperties( function (...args: unknown[]) { // @ts-expect-error: `this` context is intentionally dynamic for proxy method binding return Reflect.apply(value, this === receiver ? target : this, args) }, { name: { value: value.name }, length: { value: value.length }, } ) } return value }, }), writable: false, }) return res } describe('Context', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('c.text()', async () => { const res = c.text('text in c', 201, { 'X-Custom': 'Message' }) expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) expect(await res.text()).toBe('text in c') expect(res.headers.get('X-Custom')).toBe('Message') }) it('c.text() with c.status()', async () => { c.status(404) const res = c.text('not found') expect(res.status).toBe(404) expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) expect(await res.text()).toBe('not found') }) it('c.json()', async () => { const res = c.json({ message: 'Hello' }, 201, { 'X-Custom': 'Message' }) expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch('application/json') const text = await res.text() expect(text).toBe('{"message":"Hello"}') expect(res.headers.get('X-Custom')).toBe('Message') }) it('c.html()', async () => { const res: Response = c.html('

Hello! Hono!

', 201, { 'X-Custom': 'Message' }) expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch('text/html') expect(await res.text()).toBe('

Hello! Hono!

') expect(res.headers.get('X-Custom')).toBe('Message') }) it('c.html() with async', async () => { const resPromise: Promise = c.html( new Promise((resolve) => setTimeout(() => resolve('

Hello! Hono!

'), 0)), 201, { 'X-Custom': 'Message', } ) const res = await resPromise expect(res.status).toBe(201) expect(res.headers.get('Content-Type')).toMatch('text/html') expect(await res.text()).toBe('

Hello! Hono!

') expect(res.headers.get('X-Custom')).toBe('Message') }) it('c.redirect()', async () => { let res = c.redirect('/destination') expect(res.status).toBe(302) expect(res.headers.get('Location')).toBe('/destination') res = c.redirect('https://example.com/destination') expect(res.status).toBe(302) expect(res.headers.get('Location')).toBe('https://example.com/destination') }) it('c.redirect() w/ URL', async () => { const res = c.redirect(new URL('/destination', 'https://example.com')) expect(res.status).toBe(302) expect(res.headers.get('Location')).toBe('https://example.com/destination') }) it('c.redirect() w/ multibytes', async () => { const res = c.redirect('https://example.com/こんにちは') expect(res.headers.get('Location')).toBe( 'https://example.com/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF' ) }) const unchangedURLString = [ 'https://example.com/%hello', // invalid ASCII chars 'https://example.com/%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF?abc', 'https://localhost/api?redirect_uri=https%3A%2F%2Fexample.com', // with :// 'https://localhost/api?redirect_uri=https%3A%2F%2Fexample.com&scope=email%20profile', // with spaces and :// ] unchangedURLString.forEach((urlString) => { it(`c.redirect() w/ ${urlString}`, () => { const res = c.redirect(urlString) expect(res.headers.get('Location')).toBe(urlString) }) }) it('c.header()', async () => { c.header('X-Foo', 'Bar') const res = c.body('Hi') const foo = res.headers.get('X-Foo') expect(foo).toBe('Bar') }) it('c.header() - append', async () => { c.header('X-Foo', 'Bar') c.header('X-Foo', 'Buzz', { append: true }) const res = c.body('Hi') const foo = res.headers.get('X-Foo') expect(foo).toBe('Bar, Buzz') }) it('c.set() and c.get()', async () => { expect(c.get('foo')).toBe(undefined) c.set('foo', 'bar') expect(c.get('foo')).toBe('bar') expect(c.get('foo2')).toBe(undefined) }) it('c.var', async () => { expect(c.var.foo).toBe(undefined) c.set('foo', 'bar') expect(c.var.foo).toBe('bar') expect(c.var.foo2).toBe(undefined) }) it('c.notFound()', async () => { const res = c.notFound() expect(res).instanceOf(Response) }) it('Should set headers if already this.#headers is created by `c.header()`', async () => { c.header('X-Foo', 'Bar') c.header('X-Foo', 'Buzz', { append: true }) const res = c.body('Hi', { headers: { 'X-Message': 'Hi', }, }) expect(res.headers.get('X-Foo')).toBe('Bar, Buzz') expect(res.headers.get('X-Message')).toBe('Hi') }) it('c.header() - append, c.html()', async () => { c.header('X-Foo', 'Bar', { append: true }) const res = await c.html('

This rendered fine

') expect(res.headers.get('content-type')).toMatch(/^text\/html/) }) it('c.header() - clear the header', async () => { c.header('X-Foo', 'Bar') c.header('X-Foo', undefined) c.header('X-Foo2', 'Bar') const res = c.body('Hi') expect(res.headers.get('X-Foo')).toBe(null) c.header('X-Foo2', undefined) const res2 = c.body('Hi') expect(res2.headers.get('X-Foo2')).toBe(null) }) it('c.header() - clear the header when append is true', async () => { c.header('X-Foo', 'Bar', { append: true }) c.header('X-Foo', undefined) expect(c.res.headers.get('X-Foo')).toBe(null) }) it('c.body() - multiple header', async () => { const res = c.body('Hi', 200, { 'X-Foo': ['Bar', 'Buzz'], }) const foo = res.headers.get('X-Foo') expect(foo).toBe('Bar, Buzz') }) it('c.body() - content-type cannot be overridden by the default response when append headers', async () => { c.header('Vary', 'Accept-Encoding', { append: true }) c.res c.header('Content-Type', 'text/html') const res = c.body('

Hi

') expect(res.headers.get('Content-Type')).toMatch('text/html') }) it('c.body() - content-type can set explicitly via c.res.headers', async () => { c.header('Vary', 'Accept-Encoding', { append: true }) c.res.headers.set('Content-Type', 'text/html') const res = c.body('

Hi

') expect(res.headers.get('Content-Type')).toMatch('text/html') }) it('c.body() - Different header settings require ensuring order', async () => { c.header('Vary', 'Accept-Encoding', { append: true }) c.header('Content-Type', 'image/png') c.res.headers.set('Content-Type', 'text/html') const res = c.body('

Hi

') expect(res.headers.get('Content-Type')).toMatch('text/html') }) it('c.status()', async () => { c.status(201) const res = c.body('Hi') expect(res.status).toBe(201) }) it('Complex pattern', async () => { c.status(404) const res = c.json({ hono: 'great app' }) expect(res.status).toBe(404) expect(res.headers.get('Content-Type')).toMatch('application/json') const obj: { [key: string]: string } = await res.json() expect(obj['hono']).toBe('great app') }) it('Has headers and status', async () => { c.header('x-custom1', 'Message1') c.header('x-custom2', 'Message2') c.status(200) const res = c.newResponse('this is body', 201, { 'x-custom3': 'Message3', 'x-custom2': 'Message2-Override', }) expect(res.headers.get('x-Custom1')).toBe('Message1') expect(res.headers.get('x-Custom2')).toBe('Message2-Override') expect(res.headers.get('x-Custom3')).toBe('Message3') expect(res.status).toBe(201) // res is already set. c.res = res c.header('X-Custom4', 'Message4') c.status(202) expect(c.res.headers.get('X-Custom4')).toBe('Message4') expect(c.res.status).toBe(201) expect(await res.text()).toBe('this is body') }) it('Inherit current status if not specified', async () => { c.status(201) const res = c.newResponse('this is body', { headers: { 'x-custom3': 'Message3', 'x-custom2': 'Message2-Override', }, }) expect(res.headers.get('x-Custom2')).toBe('Message2-Override') expect(res.headers.get('x-Custom3')).toBe('Message3') expect(res.status).toBe(201) expect(await res.text()).toBe('this is body') }) it('Should append the previous headers to new Response', () => { c.res.headers.set('x-Custom1', 'Message1') const res2 = new Response('foo2', { headers: { 'Content-Type': 'application/json', }, }) res2.headers.set('x-Custom2', 'Message2') c.res = res2 expect(c.res.headers.get('x-Custom1')).toBe('Message1') expect(c.res.headers.get('Content-Type')).toBe('application/json') }) it('Should return 200 response', async () => { const res = c.text('Text') expect(res.status).toBe(200) }) it('Should return 204 response', async () => { c.status(204) const res = c.body(null) expect(res.status).toBe(204) expect(await res.text()).toBe('') }) it('Should be able read env', async () => { const req = new Request('http://localhost/') const key = 'a-secret-key' const ctx = new Context(req, { env: { API_KEY: key, }, }) expect(ctx.env.API_KEY).toBe(key) }) it('set and set', async () => { const ctx = new Context(req) expect(ctx.get('k-foo')).toEqual(undefined) ctx.set('k-foo', 'v-foo') expect(ctx.get('k-foo')).toEqual('v-foo') expect(ctx.get('k-bar')).toEqual(undefined) ctx.set('k-bar', { k: 'v' }) expect(ctx.get('k-bar')).toEqual({ k: 'v' }) }) it('has res object by default', async () => { c = new Context(req) c.res.headers.append('foo', 'bar') const res = c.text('foo') expect(res.headers.get('foo')).not.toBeNull() expect(res.headers.get('foo')).toBe('bar') }) }) describe('event and executionCtx', () => { const req = new Request('http://localhost/') it('Should return the event if accessing c.event', () => { const respondWith = vi.fn() const c = new Context(req, { // @ts-expect-error the type is not correct executionCtx: { respondWith: respondWith, }, }) expect(() => c.event).not.toThrowError() c.event.respondWith(new Response()) expect(respondWith).toHaveBeenCalled() }) it('Should throw an error if accessing c.event', () => { const c = new Context(req) expect(() => c.event).toThrowError() }) it('Should return the executionCtx if accessing c.executionCtx', () => { const pathThroughOnException = vi.fn() const waitUntil = vi.fn() const c = new Context(req, { executionCtx: { passThroughOnException: pathThroughOnException, waitUntil: waitUntil, props: {}, }, env: {}, }) expect(() => c.executionCtx).not.toThrowError() c.executionCtx.passThroughOnException() expect(pathThroughOnException).toHaveBeenCalled() const asyncFunc = async () => {} c.executionCtx.waitUntil(asyncFunc()) expect(waitUntil).toHaveBeenCalled() }) it('Should throw an error if accessing c.executionCtx', () => { const c = new Context(req) expect(() => c.executionCtx).toThrowError() }) }) describe('Context header', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('Should return only one content-type value', async () => { c.header('Content-Type', 'foo') const res = await c.html('foo') expect(res.headers.get('Content-Type')).toBe('text/html; charset=UTF-8') }) it('Should rewrite header values correctly', async () => { c.res = await c.html('foo') const res = c.text('foo') expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) }) it('Should set header values if the #this.headers is set and the arg is ResponseInit', async () => { c.header('foo', 'bar') const res = c.body('foo', { headers: { 'Content-Type': 'text/plain', }, }) expect(res.headers.get('foo')).toBe('bar') }) it('Should set cookie headers when re-assigning Response to `c.res`', () => { const cookies = ['foo=bar; Path=/', 'foo2=bar2; Path=/'] const res = new Response(null) res.headers.append('set-cookie', cookies[0]) res.headers.append('set-cookie', cookies[1]) c.res = res expect(c.res.headers.getSetCookie().length).toBe(2) // Re-assign const newCookies = ['foo3=bar3; Path=/'] const newResponse = new Response(null) newResponse.headers.append('set-cookie', newCookies[0]) c.res = newResponse expect(c.res.headers.getSetCookie().length).toBe(cookies.length) expect(c.res.headers.getSetCookie()).toEqual(cookies) }) it('Should keep previous cookies in response headers', () => { c.res.headers.append('set-cookie', 'foo=bar; Path=/') setCookie(c, 'foo2', 'bar2', { path: '/' }) const res = c.json({ message: 'Hello' }) const cookies = res.headers.getSetCookie() expect(cookies.includes('foo=bar; Path=/')).toBe(true) expect(cookies.includes('foo2=bar2; Path=/')).toBe(true) }) it('Should set set-cookie header values if c.res is already defined', () => { c.res = new Response(null, { headers: [ ['set-cookie', 'a'], ['set-cookie', 'b'], ['set-cookie', 'c'], ], }) const res = c.text('Hi') expect(res.headers.get('set-cookie')).toBe('a, b, c') }) it('Should be able to overwrite a fetch response with a new response.', async () => { c.res = makeResponseHeaderImmutable(new Response('bar')) c.res = new Response('foo', { headers: { 'X-Custom': 'Message', }, }) expect(await c.res.text()).toBe('foo') expect(c.res.headers.get('X-Custom')).toBe('Message') }) it('Should be able to overwrite a response with a fetch response.', async () => { c.res = new Response('foo', { headers: { 'X-Custom': 'Message', }, }) c.res = makeResponseHeaderImmutable(new Response('bar')) expect(await c.res.text()).toBe('bar') expect(c.res.headers.get('X-Custom')).toBe('Message') }) it('Should be able to set headers if the context is finalized', async () => { c.res = makeResponseHeaderImmutable(new Response('bar')) expect(c.finalized).toBe(true) c.header('X-Custom', 'Message') expect(c.res.headers.get('X-Custom')).toBe('Message') }) it('Should handle headers with array values correctly', async () => { c.header('X-Array', 'value1') const res = c.json({ test: 'data' }, 200, { 'X-Array': ['new1', 'new2'], }) expect(res.headers.get('X-Array')).toBe('new1, new2') }) it('Should remove existing header when new value is empty array', async () => { c.header('X-Test', 'existing') const res = c.json({ test: 'data' }, 200, { 'X-Test': [], }) expect(res.headers.get('X-Test')).toBeNull() }) }) describe('Pass a ResponseInit to respond methods', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('c.json()', async () => { const originalResponse = new Response('Unauthorized', { headers: { 'content-type': 'text/plain', 'x-custom': 'custom message', }, status: 401, }) const res = c.json( { message: 'Unauthorized', }, originalResponse ) expect(res.status).toBe(401) expect(res.headers.get('content-type')).toMatch(/^application\/json/) expect(res.headers.get('x-custom')).toBe('custom message') expect(await res.json()).toEqual({ message: 'Unauthorized', }) }) it('c.body()', async () => { const originalResponse = new Response('

Hello

', { headers: { 'content-type': 'text/html', }, }) const res = c.body('

Hello

', originalResponse) expect(res.headers.get('content-type')).toMatch(/^text\/html/) expect(await res.text()).toBe('

Hello

') }) it('c.body() should retain context cookies from context and original response', async () => { setCookie(c, 'context', '1') setCookie(c, 'context', '2') const originalResponse = new Response('', { headers: { 'set-cookie': 'response=1; Path=/', }, }) const res = c.body('', originalResponse) const cookies = res.headers.getSetCookie() expect(cookies.includes('context=1; Path=/')).toBe(true) expect(cookies.includes('context=2; Path=/')).toBe(true) expect(cookies.includes('response=1; Path=/')).toBe(true) }) it('c.text()', async () => { const originalResponse = new Response(JSON.stringify({ foo: 'bar' })) const res = c.text('foo', originalResponse) expect(res.headers.get('content-type')).toMatch(/^text\/plain/) expect(await res.text()).toBe('foo') }) it('c.html()', async () => { const originalResponse = new Response('foo') const res = await c.html('

foo

', originalResponse) expect(res.headers.get('content-type')).toMatch(/^text\/html/) expect(await res.text()).toBe('

foo

') }) }) declare module './context' { interface ContextRenderer { (content: string | Promise, head: { title: string }): Response | Promise } } describe('c.render', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('Should return a Response from the default renderer', async () => { c.header('foo', 'bar') const res = await c.render('

content

', { title: 'dummy ' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('

content

') }) it('Should return a Response from the custom renderer', async () => { c.setRenderer((content, head) => { return c.html(`${head.title}${content}`) }) c.header('foo', 'bar') const res = await c.render('

content

', { title: 'title' }) expect(res.headers.get('foo')).toBe('bar') expect(await res.text()).toBe('title

content

') }) }) ================================================ FILE: src/context.ts ================================================ import { HonoRequest } from './request' import type { Result } from './router' import type { Env, FetchEventLike, H, Input, NotFoundHandler, RouterRoute, TypedResponse, } from './types' import type { ResponseHeader } from './utils/headers' import { HtmlEscapedCallbackPhase, resolveCallback } from './utils/html' import type { ContentfulStatusCode, RedirectStatusCode, StatusCode } from './utils/http-status' import type { BaseMime } from './utils/mime' import type { InvalidJSONValue, IsAny, JSONParsed, JSONValue } from './utils/types' type HeaderRecord = | Record<'Content-Type', BaseMime> | Record | Record /** * Data type can be a string, ArrayBuffer, Uint8Array (buffer), or ReadableStream. */ export type Data = string | ArrayBuffer | ReadableStream | Uint8Array /** * Interface for the execution context in a web worker or similar environment. */ export interface ExecutionContext { /** * Extends the lifetime of the event callback until the promise is settled. * * @param promise - A promise to wait for. */ waitUntil(promise: Promise): void /** * Allows the event to be passed through to subsequent event listeners. */ passThroughOnException(): void /** * For compatibility with Wrangler 4.x. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any props: any /** * For compatibility with Wrangler 4.x. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any exports?: any } /** * Interface for context variable mapping. */ export interface ContextVariableMap {} /** * Interface for context renderer. */ export interface ContextRenderer {} /** * Interface representing a renderer for content. * * @interface DefaultRenderer * @param {string | Promise} content - The content to be rendered, which can be either a string or a Promise resolving to a string. * @returns {Response | Promise} - The response after rendering the content, which can be either a Response or a Promise resolving to a Response. */ interface DefaultRenderer { (content: string | Promise): Response | Promise } /** * Renderer type which can either be a ContextRenderer or DefaultRenderer. */ export type Renderer = ContextRenderer extends Function ? ContextRenderer : DefaultRenderer /** * Extracts the props for the renderer. */ export type PropsForRenderer = [...Required>] extends [unknown, infer Props] ? Props : unknown // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Layout> = (props: T) => any /** * Interface for getting context variables. * * @template E - Environment type. */ interface Get { (key: Key): E['Variables'][Key] (key: Key): ContextVariableMap[Key] } /** * Interface for setting context variables. * * @template E - Environment type. */ interface Set { (key: Key, value: E['Variables'][Key]): void (key: Key, value: ContextVariableMap[Key]): void } /** * Interface for creating a new response. */ interface NewResponse { (data: Data | null, status?: StatusCode, headers?: HeaderRecord): Response (data: Data | null, init?: ResponseOrInit): Response } /** * Interface for responding with a body. */ interface BodyRespond { // if we return content, only allow the status codes that allow for returning the body ( data: T, status?: U, headers?: HeaderRecord ): Response & TypedResponse ( data: T, init?: ResponseOrInit ): Response & TypedResponse ( data: T, status?: U, headers?: HeaderRecord ): Response & TypedResponse ( data: T, init?: ResponseOrInit ): Response & TypedResponse } /** * Interface for responding with text. * * @interface TextRespond * @template T - The type of the text content. * @template U - The type of the status code. * * @param {T} text - The text content to be included in the response. * @param {U} [status] - An optional status code for the response. * @param {HeaderRecord} [headers] - An optional record of headers to include in the response. * * @returns {Response & TypedResponse} - The response after rendering the text content, typed with the provided text and status code types. */ interface TextRespond { ( text: T, status?: U, headers?: HeaderRecord ): Response & TypedResponse ( text: T, init?: ResponseOrInit ): Response & TypedResponse } /** * Interface for responding with JSON. * * @interface JSONRespond * @template T - The type of the JSON value or simplified unknown type. * @template U - The type of the status code. * * @param {T} object - The JSON object to be included in the response. * @param {U} [status] - An optional status code for the response. * @param {HeaderRecord} [headers] - An optional record of headers to include in the response. * * @returns {JSONRespondReturn} - The response after rendering the JSON object, typed with the provided object and status code types. */ interface JSONRespond { < T extends JSONValue | {} | InvalidJSONValue, U extends ContentfulStatusCode = ContentfulStatusCode, >( object: T, status?: U, headers?: HeaderRecord ): JSONRespondReturn < T extends JSONValue | {} | InvalidJSONValue, U extends ContentfulStatusCode = ContentfulStatusCode, >( object: T, init?: ResponseOrInit ): JSONRespondReturn } /** * @template T - The type of the JSON value or simplified unknown type. * @template U - The type of the status code. * * @returns {Response & TypedResponse, U, 'json'>} - The response after rendering the JSON object, typed with the provided object and status code types. */ type JSONRespondReturn< T extends JSONValue | {} | InvalidJSONValue, U extends ContentfulStatusCode, > = Response & TypedResponse, U, 'json'> /** * Interface representing a function that responds with HTML content. * * @param html - The HTML content to respond with, which can be a string or a Promise that resolves to a string. * @param status - (Optional) The HTTP status code for the response. * @param headers - (Optional) A record of headers to include in the response. * @param init - (Optional) The response initialization object. * * @returns A Response object or a Promise that resolves to a Response object. */ interface HTMLRespond { >( html: T, status?: ContentfulStatusCode, headers?: HeaderRecord ): T extends string ? Response : Promise >( html: T, init?: ResponseOrInit ): T extends string ? Response : Promise } /** * Options for configuring the context. * * @template E - Environment type. */ type ContextOptions = { /** * Bindings for the environment. */ env: E['Bindings'] /** * Execution context for the request. */ executionCtx?: FetchEventLike | ExecutionContext | undefined /** * Handler for not found responses. */ notFoundHandler?: NotFoundHandler matchResult?: Result<[H, RouterRoute]> path?: string } interface SetHeadersOptions { append?: boolean } interface SetHeaders { (name: 'Content-Type', value?: BaseMime, options?: SetHeadersOptions): void (name: ResponseHeader, value?: string, options?: SetHeadersOptions): void (name: string, value?: string, options?: SetHeadersOptions): void } type ResponseHeadersInit = | [string, string][] | Record<'Content-Type', BaseMime> | Record | Record | Headers interface ResponseInit { headers?: ResponseHeadersInit status?: T statusText?: string } type ResponseOrInit = ResponseInit | Response export const TEXT_PLAIN = 'text/plain; charset=UTF-8' const setDefaultContentType = (contentType: string, headers?: HeaderRecord): HeaderRecord => { return { 'Content-Type': contentType, ...headers, } } const createResponseInstance = ( body?: BodyInit | null | undefined, init?: globalThis.ResponseInit ): Response => new Response(body, init) export class Context< // eslint-disable-next-line @typescript-eslint/no-explicit-any E extends Env = any, // eslint-disable-next-line @typescript-eslint/no-explicit-any P extends string = any, I extends Input = {}, > { #rawRequest: Request #req: HonoRequest | undefined /** * `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers. * * @see {@link https://hono.dev/docs/api/context#env} * * @example * ```ts * // Environment object for Cloudflare Workers * app.get('*', async c => { * const counter = c.env.COUNTER * }) * ``` */ env: E['Bindings'] = {} #var: Map | undefined finalized: boolean = false /** * `.error` can get the error object from the middleware if the Handler throws an error. * * @see {@link https://hono.dev/docs/api/context#error} * * @example * ```ts * app.use('*', async (c, next) => { * await next() * if (c.error) { * // do something... * } * }) * ``` */ error: Error | undefined #status: StatusCode | undefined #executionCtx: FetchEventLike | ExecutionContext | undefined #res: Response | undefined #layout: Layout | undefined #renderer: Renderer | undefined #notFoundHandler: NotFoundHandler | undefined #preparedHeaders: Headers | undefined #matchResult: Result<[H, RouterRoute]> | undefined #path: string | undefined /** * Creates an instance of the Context class. * * @param req - The Request object. * @param options - Optional configuration options for the context. */ constructor(req: Request, options?: ContextOptions) { this.#rawRequest = req if (options) { this.#executionCtx = options.executionCtx this.env = options.env this.#notFoundHandler = options.notFoundHandler this.#path = options.path this.#matchResult = options.matchResult } } /** * `.req` is the instance of {@link HonoRequest}. */ get req(): HonoRequest { this.#req ??= new HonoRequest(this.#rawRequest, this.#path, this.#matchResult) return this.#req } /** * @see {@link https://hono.dev/docs/api/context#event} * The FetchEvent associated with the current request. * * @throws Will throw an error if the context does not have a FetchEvent. */ get event(): FetchEventLike { if (this.#executionCtx && 'respondWith' in this.#executionCtx) { return this.#executionCtx } else { throw Error('This context has no FetchEvent') } } /** * @see {@link https://hono.dev/docs/api/context#executionctx} * The ExecutionContext associated with the current request. * * @throws Will throw an error if the context does not have an ExecutionContext. */ get executionCtx(): ExecutionContext { if (this.#executionCtx) { return this.#executionCtx as ExecutionContext } else { throw Error('This context has no ExecutionContext') } } /** * @see {@link https://hono.dev/docs/api/context#res} * The Response object for the current request. */ get res(): Response { return (this.#res ||= createResponseInstance(null, { headers: (this.#preparedHeaders ??= new Headers()), })) } /** * Sets the Response object for the current request. * * @param _res - The Response object to set. */ set res(_res: Response | undefined) { if (this.#res && _res) { _res = createResponseInstance(_res.body, _res) for (const [k, v] of this.#res.headers.entries()) { if (k === 'content-type') { continue } if (k === 'set-cookie') { const cookies = this.#res.headers.getSetCookie() _res.headers.delete('set-cookie') for (const cookie of cookies) { _res.headers.append('set-cookie', cookie) } } else { _res.headers.set(k, v) } } } this.#res = _res this.finalized = true } /** * `.render()` can create a response within a layout. * * @see {@link https://hono.dev/docs/api/context#render-setrenderer} * * @example * ```ts * app.get('/', (c) => { * return c.render('Hello!') * }) * ``` */ render: Renderer = (...args) => { this.#renderer ??= (content: string | Promise) => this.html(content) return this.#renderer(...args) } /** * Sets the layout for the response. * * @param layout - The layout to set. * @returns The layout function. */ setLayout = ( layout: Layout ): Layout< PropsForRenderer & { Layout: Layout } > => (this.#layout = layout) /** * Gets the current layout for the response. * * @returns The current layout function. */ getLayout = (): Layout | undefined => this.#layout /** * `.setRenderer()` can set the layout in the custom middleware. * * @see {@link https://hono.dev/docs/api/context#render-setrenderer} * * @example * ```tsx * app.use('*', async (c, next) => { * c.setRenderer((content) => { * return c.html( * * *

{content}

* * * ) * }) * await next() * }) * ``` */ setRenderer = (renderer: Renderer): void => { this.#renderer = renderer } /** * `.header()` can set headers. * * @see {@link https://hono.dev/docs/api/context#header} * * @example * ```ts * app.get('/welcome', (c) => { * // Set headers * c.header('X-Message', 'Hello!') * c.header('Content-Type', 'text/plain') * * return c.body('Thank you for coming') * }) * ``` */ header: SetHeaders = (name, value, options): void => { if (this.finalized) { this.#res = createResponseInstance((this.#res as Response).body, this.#res) } const headers = this.#res ? this.#res.headers : (this.#preparedHeaders ??= new Headers()) if (value === undefined) { headers.delete(name) } else if (options?.append) { headers.append(name, value) } else { headers.set(name, value) } } status = (status: StatusCode): void => { this.#status = status } /** * `.set()` can set the value specified by the key. * * @see {@link https://hono.dev/docs/api/context#set-get} * * @example * ```ts * app.use('*', async (c, next) => { * c.set('message', 'Hono is hot!!') * await next() * }) * ``` */ set: Set< IsAny extends true ? { // eslint-disable-next-line @typescript-eslint/no-explicit-any Variables: ContextVariableMap & Record } : E > = (key: string, value: unknown) => { this.#var ??= new Map() this.#var.set(key, value) } /** * `.get()` can use the value specified by the key. * * @see {@link https://hono.dev/docs/api/context#set-get} * * @example * ```ts * app.get('/', (c) => { * const message = c.get('message') * return c.text(`The message is "${message}"`) * }) * ``` */ get: Get< IsAny extends true ? { // eslint-disable-next-line @typescript-eslint/no-explicit-any Variables: ContextVariableMap & Record } : E > = (key: string) => { return this.#var ? this.#var.get(key) : undefined } /** * `.var` can access the value of a variable. * * @see {@link https://hono.dev/docs/api/context#var} * * @example * ```ts * const result = c.var.client.oneMethod() * ``` */ // c.var.propName is a read-only get var(): Readonly< // eslint-disable-next-line @typescript-eslint/no-explicit-any ContextVariableMap & (IsAny extends true ? Record : E['Variables']) > { if (!this.#var) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return {} as any } return Object.fromEntries(this.#var) } #newResponse( data: Data | null, arg?: StatusCode | ResponseOrInit, headers?: HeaderRecord ): Response { const responseHeaders = this.#res ? new Headers(this.#res.headers) : (this.#preparedHeaders ?? new Headers()) if (typeof arg === 'object' && 'headers' in arg) { const argHeaders = arg.headers instanceof Headers ? arg.headers : new Headers(arg.headers) for (const [key, value] of argHeaders) { if (key.toLowerCase() === 'set-cookie') { responseHeaders.append(key, value) } else { responseHeaders.set(key, value) } } } if (headers) { for (const [k, v] of Object.entries(headers)) { if (typeof v === 'string') { responseHeaders.set(k, v) } else { responseHeaders.delete(k) for (const v2 of v) { responseHeaders.append(k, v2) } } } } const status = typeof arg === 'number' ? arg : (arg?.status ?? this.#status) return createResponseInstance(data, { status, headers: responseHeaders }) } newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters)) /** * `.body()` can return the HTTP response. * You can set headers with `.header()` and set HTTP status code with `.status`. * This can also be set in `.text()`, `.json()` and so on. * * @see {@link https://hono.dev/docs/api/context#body} * * @example * ```ts * app.get('/welcome', (c) => { * // Set headers * c.header('X-Message', 'Hello!') * c.header('Content-Type', 'text/plain') * // Set HTTP status code * c.status(201) * * // Return the response body * return c.body('Thank you for coming') * }) * ``` */ body: BodyRespond = ( data: Data | null, arg?: StatusCode | RequestInit, headers?: HeaderRecord ): ReturnType => this.#newResponse(data, arg, headers) as ReturnType /** * `.text()` can render text as `Content-Type:text/plain`. * * @see {@link https://hono.dev/docs/api/context#text} * * @example * ```ts * app.get('/say', (c) => { * return c.text('Hello!') * }) * ``` */ text: TextRespond = ( text: string, arg?: ContentfulStatusCode | ResponseOrInit, headers?: HeaderRecord ): ReturnType => { return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized ? (new Response(text) as ReturnType) : (this.#newResponse( text, arg, setDefaultContentType(TEXT_PLAIN, headers) ) as ReturnType) } /** * `.json()` can render JSON as `Content-Type:application/json`. * * @see {@link https://hono.dev/docs/api/context#json} * * @example * ```ts * app.get('/api', (c) => { * return c.json({ message: 'Hello!' }) * }) * ``` */ json: JSONRespond = < T extends JSONValue | {} | InvalidJSONValue, U extends ContentfulStatusCode = ContentfulStatusCode, >( object: T, arg?: U | ResponseOrInit, headers?: HeaderRecord ): JSONRespondReturn => { return this.#newResponse( JSON.stringify(object), arg, setDefaultContentType('application/json', headers) ) /* eslint-disable @typescript-eslint/no-explicit-any */ as any } html: HTMLRespond = ( html: string | Promise, arg?: ContentfulStatusCode | ResponseOrInit, headers?: HeaderRecord ): Response | Promise => { const res = (html: string) => this.#newResponse(html, arg, setDefaultContentType('text/html; charset=UTF-8', headers)) return typeof html === 'object' ? resolveCallback(html, HtmlEscapedCallbackPhase.Stringify, false, {}).then(res) : res(html) } /** * `.redirect()` can Redirect, default status code is 302. * * @see {@link https://hono.dev/docs/api/context#redirect} * * @example * ```ts * app.get('/redirect', (c) => { * return c.redirect('/') * }) * app.get('/redirect-permanently', (c) => { * return c.redirect('/', 301) * }) * ``` */ redirect = ( location: string | URL, status?: T ): Response & TypedResponse => { const locationString = String(location) this.header( 'Location', // Multibyes should be encoded // eslint-disable-next-line no-control-regex !/[^\x00-\xFF]/.test(locationString) ? locationString : encodeURI(locationString) ) return this.newResponse(null, status ?? 302) as any } /** * `.notFound()` can return the Not Found Response. * * @see {@link https://hono.dev/docs/api/context#notfound} * * @example * ```ts * app.get('/notfound', (c) => { * return c.notFound() * }) * ``` */ notFound = (): ReturnType => { this.#notFoundHandler ??= () => createResponseInstance() return this.#notFoundHandler(this) } } ================================================ FILE: src/helper/accepts/accepts.test.ts ================================================ import { Hono } from '../..' import { parseAccept } from '../../utils/accept' import type { Accept, acceptsConfig, acceptsOptions } from './accepts' import { accepts, defaultMatch } from './accepts' describe('parseAccept', () => { test('should parse accept header', () => { const acceptHeader = 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8;level=1;foo=bar' const accepts = parseAccept(acceptHeader) expect(accepts).toEqual([ { type: 'text/html', params: {}, q: 1 }, { type: 'application/xhtml+xml', params: {}, q: 1 }, { type: 'image/webp', params: {}, q: 1 }, { type: 'application/xml', params: { q: '0.9' }, q: 0.9 }, { type: '*/*', params: { q: '0.8', level: '1', foo: 'bar' }, q: 0.8 }, ]) }) }) describe('defaultMatch', () => { test('should return default support', () => { const accepts: Accept[] = [ { type: 'text/html', params: {}, q: 1 }, { type: 'application/xhtml+xml', params: {}, q: 1 }, { type: 'application/xml', params: { q: '0.9' }, q: 0.9 }, { type: 'image/webp', params: {}, q: 1 }, { type: '*/*', params: { q: '0.8' }, q: 0.8 }, ] const config: acceptsConfig = { header: 'Accept', supports: ['text/html'], default: 'text/html', } const result = defaultMatch(accepts, config) expect(result).toBe('text/html') }) }) describe('accepts', () => { test('should return matched support', () => { const c = { req: { header: () => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any const options: acceptsConfig = { header: 'Accept', supports: ['application/xml', 'text/html'], default: 'application/json', } const result = accepts(c, options) expect(result).toBe('text/html') }) test('should return default support if no matched support', () => { const c = { req: { header: () => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any const options: acceptsConfig = { header: 'Accept', supports: ['application/json'], default: 'text/html', } const result = accepts(c, options) expect(result).toBe('text/html') }) test('should return default support if no accept header', () => { const c = { req: { header: () => undefined, }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any const options: acceptsConfig = { header: 'Accept', supports: ['application/json'], default: 'text/html', } const result = accepts(c, options) expect(result).toBe('text/html') }) test('should return matched support with custom match function', () => { const c = { req: { header: () => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', }, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any // this match function will return the least q value const match = (accepts: Accept[], config: acceptsConfig) => { const { supports, default: defaultSupport } = config const accept = accepts .sort((a, b) => a.q - b.q) .find((accept) => supports.includes(accept.type)) return accept ? accept.type : defaultSupport } const options: acceptsOptions = { header: 'Accept', supports: ['application/xml', 'text/html'], default: 'application/json', match, } const result = accepts(c, options) expect(result).toBe('application/xml') }) }) describe('Usage', () => { test('decide compression by Accept-Encoding header', async () => { const app = new Hono() app.get('/compressed', async (c) => { const encoding = accepts(c, { header: 'Accept-Encoding', supports: ['gzip', 'deflate'], default: 'identity', }) const COMPRESS_DATA = 'COMPRESS_DATA' const readable = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode(COMPRESS_DATA)) controller.close() }, }) if (encoding === 'gzip') { c.header('Content-Encoding', 'gzip') return c.body(readable.pipeThrough(new CompressionStream('gzip'))) } if (encoding === 'deflate') { c.header('Content-Encoding', 'deflate') return c.body(readable.pipeThrough(new CompressionStream('deflate'))) } c.body(COMPRESS_DATA) }) const req1 = await app.request('/compressed', { headers: { 'Accept-Encoding': 'deflate' } }) const req2 = await app.request('/compressed', { headers: { 'Accept-Encoding': 'gzip' } }) const req3 = await app.request('/compressed', { headers: { 'Accept-Encoding': 'gzip;q=0.5,deflate' }, }) const req4 = await app.request('/compressed', { headers: { 'Accept-Encoding': 'br' } }) expect(req1.headers.get('Content-Encoding')).toBe('deflate') expect(req2.headers.get('Content-Encoding')).toBe('gzip') expect(req3.headers.get('Content-Encoding')).toBe('deflate') expect(req4.headers.get('Content-Encoding')).toBeNull() }) test('decide language by Accept-Language header', async () => { const app = new Hono() const SUPPORTED_LANGS = ['en', 'ja', 'zh'] app.get('/*', async (c) => { const lang = accepts(c, { header: 'Accept-Language', supports: SUPPORTED_LANGS, default: 'en', }) const isLangedPath = SUPPORTED_LANGS.some((l) => c.req.path.startsWith(`/${l}`)) if (isLangedPath) { return c.body(`lang: ${lang}`) } return c.redirect(`/${lang}${c.req.path}`) }) const req1 = await app.request('/foo', { headers: { 'Accept-Language': 'en=0.8,ja' } }) const req2 = await app.request('/en/foo', { headers: { 'Accept-Language': 'en' } }) expect(req1.status).toBe(302) expect(req1.headers.get('Location')).toBe('/ja/foo') expect(await req2.text()).toBe('lang: en') }) }) ================================================ FILE: src/helper/accepts/accepts.ts ================================================ import type { Context } from '../../context' import { parseAccept } from '../../utils/accept' import type { AcceptHeader } from '../../utils/headers' export interface Accept { type: string params: Record q: number } export interface acceptsConfig { header: AcceptHeader supports: string[] default: string } export interface acceptsOptions extends acceptsConfig { match?: (accepts: Accept[], config: acceptsConfig) => string } export const defaultMatch = (accepts: Accept[], config: acceptsConfig): string => { const { supports, default: defaultSupport } = config const accept = accepts.sort((a, b) => b.q - a.q).find((accept) => supports.includes(accept.type)) return accept ? accept.type : defaultSupport } /** * Match the accept header with the given options. * @example * ```ts * app.get('/users', (c) => { * const lang = accepts(c, { * header: 'Accept-Language', * supports: ['en', 'zh'], * default: 'en', * }) * }) * ``` */ export const accepts = (c: Context, options: acceptsOptions): string => { const acceptHeader = c.req.header(options.header) if (!acceptHeader) { return options.default } const accepts = parseAccept(acceptHeader) const match = options.match || defaultMatch return match(accepts, options) } ================================================ FILE: src/helper/accepts/index.ts ================================================ /** * @module * Accepts Helper for Hono. */ export { accepts } from './accepts' ================================================ FILE: src/helper/adapter/index.test.ts ================================================ import { Hono } from '../../hono' import { env, getRuntimeKey } from '.' describe('getRuntimeKey', () => { it('Should return the current runtime key', () => { // Now, using the `bun run test` command. // But `vitest` depending Node.js will run this test so the RuntimeKey will be `node`. expect(getRuntimeKey()).toBe('node') }) }) describe('env', () => { describe('Types', () => { type Env = { Bindings: { MY_VAR: string } } it('Should not throw type errors with env has generics', () => { const app = new Hono() app.get('/var', (c) => { const { MY_VAR } = env<{ MY_VAR: string }>(c) expectTypeOf(MY_VAR) return c.json({ var: MY_VAR, }) }) }) it('Should not throw type errors with Hono has generics', () => { const app = new Hono() app.get('/var', (c) => { const { MY_VAR } = env(c) expectTypeOf(MY_VAR) return c.json({ var: MY_VAR, }) }) }) it('Should not throw type errors with env and Hono have generics', () => { const app = new Hono() app.get('/var', (c) => { const { MY_VAR } = env<{ MY_VAR: string }>(c) expectTypeOf(MY_VAR) return c.json({ var: MY_VAR, }) }) }) }) }) ================================================ FILE: src/helper/adapter/index.ts ================================================ /** * @module * Adapter Helper for Hono. */ import type { Context } from '../../context' export type Runtime = 'node' | 'deno' | 'bun' | 'workerd' | 'fastly' | 'edge-light' | 'other' export const env = < T extends Record, C extends Context = Context<{ Bindings: T }>, >( c: T extends Record ? Context : C, runtime?: Runtime ): T & C['env'] => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any const globalEnv = global?.process?.env as T runtime ??= getRuntimeKey() const runtimeEnvHandlers: Record T> = { bun: () => globalEnv, node: () => globalEnv, 'edge-light': () => globalEnv, deno: () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return Deno.env.toObject() as T }, workerd: () => c.env, // On Fastly Compute, you can use the ConfigStore to manage user-defined data. fastly: () => ({}) as T, other: () => ({}) as T, } return runtimeEnvHandlers[runtime]() } export const knownUserAgents: Partial> = { deno: 'Deno', bun: 'Bun', workerd: 'Cloudflare-Workers', node: 'Node.js', } export const getRuntimeKey = (): Runtime => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const global = globalThis as any // check if the current runtime supports navigator.userAgent const userAgentSupported = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string' // if supported, check the user agent if (userAgentSupported) { for (const [runtimeKey, userAgent] of Object.entries(knownUserAgents)) { if (checkUserAgentEquals(userAgent)) { return runtimeKey as Runtime } } } // check if running on Edge Runtime if (typeof global?.EdgeRuntime === 'string') { return 'edge-light' } // check if running on Fastly if (global?.fastly !== undefined) { return 'fastly' } // userAgent isn't supported before Node v21.1.0; so fallback to the old way if (global?.process?.release?.name === 'node') { return 'node' } // couldn't detect the runtime return 'other' } export const checkUserAgentEquals = (platform: string): boolean => { const userAgent = navigator.userAgent return userAgent.startsWith(platform) } ================================================ FILE: src/helper/conninfo/index.ts ================================================ /** * @module * ConnInfo Helper for Hono. */ export type { AddressType, NetAddrInfo, ConnInfo, GetConnInfo } from './types' ================================================ FILE: src/helper/conninfo/types.ts ================================================ import type { Context } from '../../context' export type AddressType = 'IPv6' | 'IPv4' | undefined export type NetAddrInfo = { /** * Transport protocol type */ transport?: 'tcp' | 'udp' /** * Transport port number */ port?: number address?: string addressType?: AddressType } & ( | { /** * Host name such as IP Addr */ address: string /** * Host name type */ addressType: AddressType } | {} ) /** * HTTP Connection information */ export interface ConnInfo { /** * Remote information */ remote: NetAddrInfo } /** * Helper type */ export type GetConnInfo = (c: Context) => ConnInfo ================================================ FILE: src/helper/cookie/index.test.ts ================================================ import { Hono } from '../../hono' import { deleteCookie, getCookie, getSignedCookie, setCookie, setSignedCookie, generateCookie, generateSignedCookie, } from '.' describe('Cookie Middleware', () => { describe('Parse cookie', () => { const apps: Record = {} apps['get by name'] = (() => { const app = new Hono() app.get('/cookie', (c) => { const yummyCookie = getCookie(c, 'yummy_cookie') const tastyCookie = getCookie(c, 'tasty_cookie') const res = new Response('Good cookie') if (yummyCookie && tastyCookie) { res.headers.set('Yummy-Cookie', yummyCookie) res.headers.set('Tasty-Cookie', tastyCookie) } return res }) return app })() apps['get all as an object'] = (() => { const app = new Hono() app.get('/cookie', (c) => { const { yummy_cookie: yummyCookie, tasty_cookie: tastyCookie } = getCookie(c) const res = new Response('Good cookie') res.headers.set('Yummy-Cookie', yummyCookie) res.headers.set('Tasty-Cookie', tastyCookie) return res }) return app })() describe.each(Object.keys(apps))('%s', (name) => { const app = apps[name] it('Parse cookie with getCookie()', async () => { const req = new Request('http://localhost/cookie') const cookieString = 'yummy_cookie=choco; tasty_cookie = strawberry' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Yummy-Cookie')).toBe('choco') expect(res.headers.get('Tasty-Cookie')).toBe('strawberry') }) }) const app = new Hono() app.get('/cookie-signed-get-all', async (c) => { const secret = 'secret lucky charm' const { fortune_cookie: fortuneCookie, fruit_cookie: fruitCookie } = await getSignedCookie( c, secret ) const res = new Response('Signed fortune cookie') if (typeof fortuneCookie !== 'undefined' && typeof fruitCookie !== 'undefined') { // just examples for tests sake res.headers.set('Fortune-Cookie', fortuneCookie || 'INVALID') res.headers.set('Fruit-Cookie', fruitCookie || 'INVALID') } return res }) app.get('/cookie-signed-get-one', async (c) => { const secret = 'secret lucky charm' const fortuneCookie = await getSignedCookie(c, secret, 'fortune_cookie') const res = new Response('Signed fortune cookie') if (typeof fortuneCookie !== 'undefined') { // just an example for tests sake res.headers.set('Fortune-Cookie', fortuneCookie || 'INVALID') } return res }) it('Get signed cookies', async () => { const req = new Request('http://localhost/cookie-signed-get-all') const cookieString = 'fortune_cookie=lots-of-money.UO6vMygDM6NCDU4LdvBnzdVb2Xcdj+h+ZTnmS8X7iH8%3D; fruit_cookie=mango.lRwgtW9ooM9%2Fd9ZZA%2FInNRG64CbQsfWGXQyFLPM9520%3D' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Fortune-Cookie')).toBe('lots-of-money') expect(res.headers.get('Fruit-Cookie')).toBe('mango') }) it('Get signed cookies invalid signature', async () => { const req = new Request('http://localhost/cookie-signed-get-all') // fruit_cookie has invalid signature const cookieString = 'fortune_cookie=lots-of-money.UO6vMygDM6NCDU4LdvBnzdVb2Xcdj+h+ZTnmS8X7iH8%3D; fruit_cookie=mango.LAa7RX43t2vCrLNcKmNG65H41OkyV02sraRPuY5RuVg%3D' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Fortune-Cookie')).toBe('lots-of-money') expect(res.headers.get('Fruit-Cookie')).toBe('INVALID') }) it('Get signed cookie', async () => { const req = new Request('http://localhost/cookie-signed-get-one') const cookieString = 'fortune_cookie=lots-of-money.UO6vMygDM6NCDU4LdvBnzdVb2Xcdj+h+ZTnmS8X7iH8%3D; fruit_cookie=mango.lRwgtW9ooM9%2Fd9ZZA%2FInNRG64CbQsfWGXQyFLPM9520%3D' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Fortune-Cookie')).toBe('lots-of-money') }) it('Get signed cookie with invalid signature', async () => { const req = new Request('http://localhost/cookie-signed-get-one') // fortune_cookie has invalid signature const cookieString = 'fortune_cookie=lots-of-money.LAa7RX43t2vCrLNcKmNG65H41OkyV02sraRPuY5RuVg=; fruit_cookie=mango.lRwgtW9ooM9%2Fd9ZZA%2FInNRG64CbQsfWGXQyFLPM9520%3D' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Fortune-Cookie')).toBe('INVALID') }) describe('get null if the value is undefined', () => { const app = new Hono() app.get('/cookie', (c) => { const yummyCookie = getCookie(c, 'yummy_cookie') const res = new Response('Good cookie') if (yummyCookie) { res.headers.set('Yummy-Cookie', yummyCookie) } return res }) it('Should be null', async () => { const req = new Request('http://localhost/cookie') const cookieString = 'yummy_cookie=' req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.headers.get('Yummy-Cookie')).toBe(null) }) }) }) describe('Set cookie', () => { const app = new Hono() app.get('/set-cookie', (c) => { setCookie(c, 'delicious_cookie', 'macha') return c.text('Give cookie') }) it('Set cookie with setCookie()', async () => { const res = await app.request('http://localhost/set-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe('delicious_cookie=macha; Path=/') }) app.get('/a/set-cookie-path', (c) => { setCookie(c, 'delicious_cookie', 'macha', { path: '/a' }) return c.text('Give cookie') }) it('Set cookie with setCookie() and path option', async () => { const res = await app.request('http://localhost/a/set-cookie-path') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe('delicious_cookie=macha; Path=/a') }) app.get('/set-signed-cookie', async (c) => { const secret = 'secret chocolate chips' await setSignedCookie(c, 'delicious_cookie', 'macha', secret) return c.text('Give signed cookie') }) it('Set signed cookie with setSignedCookie()', async () => { const res = await app.request('http://localhost/set-signed-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( 'delicious_cookie=macha.diubJPY8O7hI1pLa42QSfkPiyDWQ0I4DnlACH%2FN2HaA%3D; Path=/' ) }) app.get('/a/set-signed-cookie-path', async (c) => { const secret = 'secret chocolate chips' await setSignedCookie(c, 'delicious_cookie', 'macha', secret, { path: '/a' }) return c.text('Give signed cookie') }) it('Set signed cookie with setSignedCookie() and path option', async () => { const res = await app.request('http://localhost/a/set-signed-cookie-path') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( 'delicious_cookie=macha.diubJPY8O7hI1pLa42QSfkPiyDWQ0I4DnlACH%2FN2HaA%3D; Path=/a' ) }) app.get('/get-secure-prefix-cookie', async (c) => { const cookie = getCookie(c, 'delicious_cookie', 'secure') if (cookie) { return c.text(cookie) } else { return c.notFound() } }) app.get('/get-host-prefix-cookie', async (c) => { const cookie = getCookie(c, 'delicious_cookie', 'host') if (cookie) { return c.text(cookie) } else { return c.notFound() } }) app.get('/set-secure-prefix-cookie', (c) => { setCookie(c, 'delicious_cookie', 'macha', { prefix: 'secure', secure: false, // this will be ignore }) return c.text('Set secure prefix cookie') }) it('Set cookie with secure prefix', async () => { const res = await app.request('http://localhost/set-secure-prefix-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe('__Secure-delicious_cookie=macha; Path=/; Secure') }) it('Get cookie with secure prefix', async () => { const setCookie = await app.request('http://localhost/set-secure-prefix-cookie') const header = setCookie.headers.get('Set-Cookie') if (!header) { assert.fail('invalid header') } const res = await app.request('http://localhost/get-secure-prefix-cookie', { headers: { Cookie: header, }, }) const response = await res.text() expect(res.status).toBe(200) expect(response).toBe('macha') }) app.get('/set-host-prefix-cookie', (c) => { setCookie(c, 'delicious_cookie', 'macha', { prefix: 'host', path: '/foo', // this will be ignored domain: 'example.com', // this will be ignored secure: false, // this will be ignored }) return c.text('Set host prefix cookie') }) it('Set cookie with host prefix', async () => { const res = await app.request('http://localhost/set-host-prefix-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe('__Host-delicious_cookie=macha; Path=/; Secure') }) it('Get cookie with host prefix', async () => { const setCookie = await app.request('http://localhost/set-host-prefix-cookie') const header = setCookie.headers.get('Set-Cookie') if (!header) { assert.fail('invalid header') } const res = await app.request('http://localhost/get-host-prefix-cookie', { headers: { Cookie: header, }, }) const response = await res.text() expect(res.status).toBe(200) expect(response).toBe('macha') }) app.get('/set-signed-secure-prefix-cookie', async (c) => { await setSignedCookie(c, 'delicious_cookie', 'macha', 'secret choco chips', { prefix: 'secure', }) return c.text('Set secure prefix cookie') }) it('Set signed cookie with secure prefix', async () => { const res = await app.request('http://localhost/set-signed-secure-prefix-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( '__Secure-delicious_cookie=macha.i225faTyCrJUY8TvpTuJHI20HBWbQ89B4GV7lT4E%2FB0%3D; Path=/; Secure' ) }) app.get('/set-signed-host-prefix-cookie', async (c) => { await setSignedCookie(c, 'delicious_cookie', 'macha', 'secret choco chips', { prefix: 'host', domain: 'example.com', // this will be ignored path: 'example.com', // thi will be ignored secure: false, // this will be ignored }) return c.text('Set host prefix cookie') }) it('Set signed cookie with host prefix', async () => { const res = await app.request('http://localhost/set-signed-host-prefix-cookie') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( '__Host-delicious_cookie=macha.i225faTyCrJUY8TvpTuJHI20HBWbQ89B4GV7lT4E%2FB0%3D; Path=/; Secure' ) }) app.get('/set-cookie-complex', (c) => { setCookie(c, 'great_cookie', 'banana', { path: '/', secure: true, domain: 'example.com', httpOnly: true, maxAge: 1000, expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)), sameSite: 'Strict', }) return c.text('Give cookie') }) it('Complex pattern', async () => { const res = await app.request('http://localhost/set-cookie-complex') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( 'great_cookie=banana; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict' ) }) app.get('/set-signed-cookie-complex', async (c) => { const secret = 'secret chocolate chips' await setSignedCookie(c, 'great_cookie', 'banana', secret, { path: '/', secure: true, domain: 'example.com', httpOnly: true, maxAge: 1000, expires: new Date(Date.UTC(2000, 11, 24, 10, 30, 59, 900)), sameSite: 'Strict', }) return c.text('Give signed cookie') }) it('Complex pattern (signed)', async () => { const res = await app.request('http://localhost/set-signed-cookie-complex') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe( 'great_cookie=banana.hSo6gB7YT2db0WBiEAakEmh7dtwEL0DSp76G23WvHuQ%3D; Max-Age=1000; Domain=example.com; Path=/; Expires=Sun, 24 Dec 2000 10:30:59 GMT; HttpOnly; Secure; SameSite=Strict' ) }) app.get('/set-cookie-multiple', (c) => { setCookie(c, 'delicious_cookie', 'macha') setCookie(c, 'delicious_cookie', 'choco') return c.text('Give cookie') }) it('Multiple values', async () => { const res = await app.request('http://localhost/set-cookie-multiple') expect(res.status).toBe(200) const header = res.headers.get('Set-Cookie') expect(header).toBe('delicious_cookie=macha; Path=/, delicious_cookie=choco; Path=/') }) }) describe('Delete cookie', () => { const app = new Hono() app.get('/delete-cookie', (c) => { deleteCookie(c, 'delicious_cookie') return c.text('Give cookie') }) it('Delete cookie', async () => { const res2 = await app.request('http://localhost/delete-cookie') expect(res2.status).toBe(200) const header2 = res2.headers.get('Set-Cookie') expect(header2).toBe('delicious_cookie=; Max-Age=0; Path=/') }) app.get('/delete-cookie-multiple', (c) => { deleteCookie(c, 'delicious_cookie') deleteCookie(c, 'delicious_cookie2') return c.text('Give cookie') }) it('Delete multiple cookies', async () => { const res2 = await app.request('http://localhost/delete-cookie-multiple') expect(res2.status).toBe(200) const header2 = res2.headers.get('Set-Cookie') expect(header2).toBe( 'delicious_cookie=; Max-Age=0; Path=/, delicious_cookie2=; Max-Age=0; Path=/' ) }) app.get('/delete-cookie-with-options', (c) => { deleteCookie(c, 'delicious_cookie', { path: '/', secure: true, domain: 'example.com', }) return c.text('Give cookie') }) it('Delete cookie with options', async () => { const res2 = await app.request('http://localhost/delete-cookie-with-options') expect(res2.status).toBe(200) const header2 = res2.headers.get('Set-Cookie') expect(header2).toBe('delicious_cookie=; Max-Age=0; Domain=example.com; Path=/; Secure') }) app.get('/delete-cookie-with-deleted-value', (c) => { const deleted = deleteCookie(c, 'delicious_cookie') return c.text(deleted || '') }) it('Get deleted value', async () => { const cookieString = 'delicious_cookie=choco' const req = new Request('http://localhost/delete-cookie-with-deleted-value') req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('choco') }) app.get('/delete-cookie-with-prefix', (c) => { const deleted = deleteCookie(c, 'delicious_cookie', { prefix: 'secure' }) return c.text(deleted || '') }) it('Get deleted value with prefix', async () => { const cookieString = '__Secure-delicious_cookie=choco' const req = new Request('http://localhost/delete-cookie-with-prefix') req.headers.set('Cookie', cookieString) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('choco') }) }) describe('Generate cookie', () => { it('should generate a cookie', () => { const cookie = generateCookie('delicious_cookie', 'macha') expect(cookie).toBe('delicious_cookie=macha; Path=/') }) it('should generate a cookie with options', () => { const cookie = generateCookie('delicious_cookie', 'macha', { path: '/', secure: true, httpOnly: true, domain: 'example.com', }) expect(cookie).toBe('delicious_cookie=macha; Domain=example.com; Path=/; HttpOnly; Secure') }) it('should generate a signed cookie', async () => { const cookie = await generateSignedCookie( 'delicious_cookie', 'macha', 'secret chocolate chips' ) expect(cookie).toBe( 'delicious_cookie=macha.diubJPY8O7hI1pLa42QSfkPiyDWQ0I4DnlACH%2FN2HaA%3D; Path=/' ) }) it('should generate a signed cookie with options', async () => { const cookie = await generateSignedCookie( 'delicious_cookie', 'macha', 'secret chocolate chips', { path: '/', secure: true, httpOnly: true, domain: 'example.com', } ) expect(cookie).toBe( 'delicious_cookie=macha.diubJPY8O7hI1pLa42QSfkPiyDWQ0I4DnlACH%2FN2HaA%3D; Domain=example.com; Path=/; HttpOnly; Secure' ) }) }) }) ================================================ FILE: src/helper/cookie/index.ts ================================================ /** * @module * Cookie Helper for Hono. */ import type { Context } from '../../context' import { parse, parseSigned, serialize, serializeSigned } from '../../utils/cookie' import type { Cookie, CookieOptions, CookiePrefixOptions, SignedCookie } from '../../utils/cookie' interface GetCookie { (c: Context, key: string): string | undefined (c: Context): Cookie (c: Context, key: string, prefixOptions?: CookiePrefixOptions): string | undefined } interface GetSignedCookie { (c: Context, secret: string | BufferSource, key: string): Promise (c: Context, secret: string | BufferSource): Promise ( c: Context, secret: string | BufferSource, key: string, prefixOptions?: CookiePrefixOptions ): Promise } export const getCookie: GetCookie = (c, key?, prefix?: CookiePrefixOptions) => { const cookie = c.req.raw.headers.get('Cookie') if (typeof key === 'string') { if (!cookie) { return undefined } let finalKey = key if (prefix === 'secure') { finalKey = '__Secure-' + key } else if (prefix === 'host') { finalKey = '__Host-' + key } const obj = parse(cookie, finalKey) return obj[finalKey] } if (!cookie) { return {} } const obj = parse(cookie) // eslint-disable-next-line @typescript-eslint/no-explicit-any return obj as any } export const getSignedCookie: GetSignedCookie = async ( c, secret, key?, prefix?: CookiePrefixOptions ) => { const cookie = c.req.raw.headers.get('Cookie') if (typeof key === 'string') { if (!cookie) { return undefined } let finalKey = key if (prefix === 'secure') { finalKey = '__Secure-' + key } else if (prefix === 'host') { finalKey = '__Host-' + key } const obj = await parseSigned(cookie, secret, finalKey) return obj[finalKey] } if (!cookie) { return {} } const obj = await parseSigned(cookie, secret) // eslint-disable-next-line @typescript-eslint/no-explicit-any return obj as any } export const generateCookie = (name: string, value: string, opt?: CookieOptions): string => { // Cookie names prefixed with __Secure- can be used only if they are set with the secure attribute. // Cookie names prefixed with __Host- can be used only if they are set with the secure attribute, must have a path of / (meaning any path at the host) // and must not have a Domain attribute. // Read more at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#cookie_prefixes' let cookie if (opt?.prefix === 'secure') { cookie = serialize('__Secure-' + name, value, { path: '/', ...opt, secure: true }) } else if (opt?.prefix === 'host') { cookie = serialize('__Host-' + name, value, { ...opt, path: '/', secure: true, domain: undefined, }) } else { cookie = serialize(name, value, { path: '/', ...opt }) } return cookie } export const setCookie = (c: Context, name: string, value: string, opt?: CookieOptions): void => { const cookie = generateCookie(name, value, opt) c.header('Set-Cookie', cookie, { append: true }) } export const generateSignedCookie = async ( name: string, value: string, secret: string | BufferSource, opt?: CookieOptions ): Promise => { let cookie if (opt?.prefix === 'secure') { cookie = await serializeSigned('__Secure-' + name, value, secret, { path: '/', ...opt, secure: true, }) } else if (opt?.prefix === 'host') { cookie = await serializeSigned('__Host-' + name, value, secret, { ...opt, path: '/', secure: true, domain: undefined, }) } else { cookie = await serializeSigned(name, value, secret, { path: '/', ...opt }) } return cookie } export const setSignedCookie = async ( c: Context, name: string, value: string, secret: string | BufferSource, opt?: CookieOptions ): Promise => { const cookie = await generateSignedCookie(name, value, secret, opt) c.header('set-cookie', cookie, { append: true }) } export const deleteCookie = (c: Context, name: string, opt?: CookieOptions): string | undefined => { const deletedCookie = getCookie(c, name, opt?.prefix) setCookie(c, name, '', { ...opt, maxAge: 0 }) return deletedCookie } ================================================ FILE: src/helper/css/common.case.test.tsx ================================================ /** @jsxImportSource ../../jsx */ import type { Style as StyleComponent, css as cssHelper, keyframes as keyframesHelper, rawCssString as rawCssStringHelper, viewTransition as viewTransitionHelper, } from './index' interface Support { nest: boolean } export const renderTest = ( getEnv: () => { css: typeof cssHelper keyframes: typeof keyframesHelper viewTransition: typeof viewTransitionHelper rawCssString: typeof rawCssStringHelper // eslint-disable-next-line @typescript-eslint/no-explicit-any toString: (template: any) => Promise Style: typeof StyleComponent support: Support } ) => { const { support } = getEnv() let css: typeof cssHelper let keyframes: typeof keyframesHelper let viewTransition: typeof viewTransitionHelper let rawCssString: typeof rawCssStringHelper // eslint-disable-next-line @typescript-eslint/no-explicit-any let toString: (template: any) => Promise let Style: typeof StyleComponent beforeEach(() => { ;({ css, keyframes, viewTransition, rawCssString, toString, Style } = getEnv()) }) describe('render css', () => { it('Should render CSS styles with JSX', async () => { const headerClass = css` background-color: blue; ` const template = ( <>

Hello!

' ) }) it('Should render CSS with keyframes', async () => { const animation = keyframes` from { opacity: 0; } to { opacity: 1; } ` const headerClass = css` background-color: blue; animation: ${animation} 1s ease-in-out; ` const template = ( <>

Hello!

' ) }) it('Should not output the same class name multiple times.', async () => { const headerClass = css` background-color: blue; ` const headerClass2 = css` background-color: blue; ` const template = ( <>

Hello!

Hello2!

' ) }) it('Should render CSS with variable', async () => { const headerClass = css` background-color: blue; content: '${"I'm a variable!"}'; ` const template = ( <>

Hello!

' ) }) it('Should escape ', async () => { const headerClass = css` background-color: blue; content: '${''}'; ` const template = ( <>

Hello!

' ) }) it('Should not escape URL', async () => { const headerClass = css` background-color: blue; background: url('${'http://www.example.com/path/to/file.jpg'}'); ` const template = ( <>

Hello!

' ) }) it('Should render CSS with escaped variable', async () => { const headerClass = css` background-color: blue; content: '${rawCssString('say "Hello!"')}'; ` const template = ( <>

Hello!

' ) }) it('Should render CSS with number', async () => { const headerClass = css` background-color: blue; font-size: ${1}rem; ` const template = ( <>

Hello!

' ) }) it('Should render CSS with array', async () => { const animation = keyframes` from { opacity: 0; } to { opacity: 1; } ` const headerClass = css` background-color: blue; animation: ${animation} 1s ease-in-out; ` const extendedHeaderClass = css` ${headerClass} color: red; ` const template = ( <>

Hello!

' ) }) it.runIf(support.nest)( 'Should be used as a class name for syntax `${className} {`', async () => { const headerClass = css` font-weight: bold; ` const containerClass = css` ${headerClass} { h1 { color: red; } } ` const template = ( <>

Hello!

' ) } ) it('Should be inserted to global if style string starts with :-hono-root', async () => { const globalClass = css` :-hono-global { html { color: red; } body { display: flex; } } ` const template = ( <>

Hello!

' ) }) it.runIf(support.nest)( 'Should be inserted to global if style string starts with :-hono-root and extends class name', async () => { const headerClass = css` display: flex; ` const specialHeaderClass = css` :-hono-global { ${headerClass} { h1 { color: red; } } } ` const template = ( <>

Hello!

' ) } ) it('Should be inserted as global css if passed css`` to Style component', async () => { const headerClass = css` font-size: 1rem; ` const template = ( <>

Hello!

) expect(await toString(template)).toBe( '

Hello!

' ) }) it('Should be ignored :-hono-root inside Style component', async () => { const headerClass = css` font-size: 1rem; ` const template = ( <>

Hello!

) expect(await toString(template)).toBe( '

Hello!

' ) }) describe('viewTransition', () => { it('Should render CSS with unique view-transition-name', async () => { const transition = viewTransition() const template = ( <>

Hello!

' ) }) it('Should render CSS with css and keyframes', async () => { const kf = keyframes` from { opacity: 0; } to { opacity: 1; } ` const transition = viewTransition(css` ::view-transition-old() { animation-name: ${kf}; } ::view-transition-new() { animation-name: ${kf}; } `) const headerClass = css` ${transition} background-color: blue; ` const template = ( <>

Hello!

' ) }) it('Should works as a template tag function', async () => { const kf = keyframes` from { opacity: 0; } to { opacity: 1; } ` const transition = viewTransition` ::view-transition-old() { animation-name: ${kf}; } ::view-transition-new() { animation-name: ${kf}; } ` const headerClass = css` ${transition} background-color: blue; ` const template = ( <>

Hello!

' ) }) }) it.runIf(support.nest)('Should render sub CSS with keyframe', async () => { const headerClass = css` background-color: blue; ${[1, 2].map( (i) => css` :nth-child(${i}) { color: red; } ` )} ` const template = ( <>

Hello!

' ) }) it('Should be generated deferent class name for deferent first line comment even if the content is the same', async () => { const headerClassA = css` /* class A */ display: flex; ` const headerClassB = css` /* class B */ display: flex; ` const template = ( <>

Hello!

Hello!

' ) }) describe('Booleans, Null, and Undefined Are Ignored', () => { it.each([true, false, undefined, null])('%s', async (value) => { const headerClass = css` ${value} background-color: blue; ` const template = ( <>

Hello!

' ) }) it('falsy value', async () => { const value = 0 const headerClass = css` padding: ${value}; ` const template = ( <>

Hello!

' ) }) it('Should render CSS styles with CSP nonce', async () => { const headerClass = css` background-color: blue; ` const template = ( <>

Hello!

' ) }) }) }) } ================================================ FILE: src/helper/css/common.ts ================================================ // provide utility functions for css helper both on server and client export const PSEUDO_GLOBAL_SELECTOR = ':-hono-global' export const isPseudoGlobalSelectorRe = new RegExp(`^${PSEUDO_GLOBAL_SELECTOR}{(.*)}$`) export const DEFAULT_STYLE_ID = 'hono-css' export const SELECTOR: unique symbol = Symbol() export const CLASS_NAME: unique symbol = Symbol() export const STYLE_STRING: unique symbol = Symbol() export const SELECTORS: unique symbol = Symbol() export const EXTERNAL_CLASS_NAMES: unique symbol = Symbol() const CSS_ESCAPED: unique symbol = Symbol() export interface CssClassName { [SELECTOR]: string [CLASS_NAME]: string [STYLE_STRING]: string [SELECTORS]: CssClassName[] [EXTERNAL_CLASS_NAMES]: string[] } export const IS_CSS_ESCAPED = Symbol() interface CssEscapedString { [CSS_ESCAPED]: string } /** * @experimental * `rawCssString` is an experimental feature. * The API might be changed. */ export const rawCssString = (value: string): CssEscapedString => { return { [CSS_ESCAPED]: value, } } /** * Used the goober'code as a reference: * https://github.com/cristianbote/goober/blob/master/src/core/to-hash.js * MIT License, Copyright (c) 2019 Cristian Bote */ const toHash = (str: string): string => { let i = 0, out = 11 while (i < str.length) { out = (101 * out + str.charCodeAt(i++)) >>> 0 } return 'css-' + out } const cssStringReStr: string = [ '"(?:(?:\\\\[\\s\\S]|[^"\\\\])*)"', // double quoted string "'(?:(?:\\\\[\\s\\S]|[^'\\\\])*)'", // single quoted string ].join('|') const minifyCssRe: RegExp = new RegExp( [ '(' + cssStringReStr + ')', // $1: quoted string '(?:' + [ '^\\s+', // head whitespace '\\/\\*.*?\\*\\/\\s*', // multi-line comment '\\/\\/.*\\n\\s*', // single-line comment '\\s+$', // tail whitespace ].join('|') + ')', '\\s*;\\s*(}|$)\\s*', // $2: trailing semicolon '\\s*([{};:,])\\s*', // $3: whitespace around { } : , ; '(\\s)\\s+', // $4: 2+ spaces ].join('|'), 'g' ) export const minify = (css: string): string => { return css.replace(minifyCssRe, (_, $1, $2, $3, $4) => $1 || $2 || $3 || $4 || '') } type CssVariableBasicType = | CssClassName | CssEscapedString | string | number | boolean | null | undefined type CssVariableAsyncType = Promise type CssVariableArrayType = (CssVariableBasicType | CssVariableAsyncType)[] export type CssVariableType = CssVariableBasicType | CssVariableAsyncType | CssVariableArrayType export const buildStyleString = ( strings: TemplateStringsArray, values: CssVariableType[] ): [string, string, CssClassName[], string[]] => { const selectors: CssClassName[] = [] const externalClassNames: string[] = [] const label = strings[0].match(/^\s*\/\*(.*?)\*\//)?.[1] || '' let styleString = '' for (let i = 0, len = strings.length; i < len; i++) { styleString += strings[i] let vArray = values[i] if (typeof vArray === 'boolean' || vArray === null || vArray === undefined) { continue } if (!Array.isArray(vArray)) { vArray = [vArray] } for (let j = 0, len = vArray.length; j < len; j++) { let value = vArray[j] if (typeof value === 'boolean' || value === null || value === undefined) { continue } if (typeof value === 'string') { if (/([\\"'\/])/.test(value)) { styleString += value.replace(/([\\"']|(?<=<)\/)/g, '\\$1') } else { styleString += value } } else if (typeof value === 'number') { styleString += value } else if ((value as CssEscapedString)[CSS_ESCAPED]) { styleString += (value as CssEscapedString)[CSS_ESCAPED] } else if ((value as CssClassName)[CLASS_NAME].startsWith('@keyframes ')) { selectors.push(value as CssClassName) styleString += ` ${(value as CssClassName)[CLASS_NAME].substring(11)} ` } else { if (strings[i + 1]?.match(/^\s*{/)) { // assume this value is a class name selectors.push(value as CssClassName) value = `.${(value as CssClassName)[CLASS_NAME]}` } else { selectors.push(...(value as CssClassName)[SELECTORS]) externalClassNames.push(...(value as CssClassName)[EXTERNAL_CLASS_NAMES]) value = (value as CssClassName)[STYLE_STRING] const valueLen = value.length if (valueLen > 0) { const lastChar = value[valueLen - 1] if (lastChar !== ';' && lastChar !== '}') { value += ';' } } } styleString += `${value || ''}` } } } return [label, minify(styleString), selectors, externalClassNames] } export const cssCommon = ( strings: TemplateStringsArray, values: CssVariableType[] ): CssClassName => { let [label, thisStyleString, selectors, externalClassNames] = buildStyleString(strings, values) const isPseudoGlobal = isPseudoGlobalSelectorRe.exec(thisStyleString) if (isPseudoGlobal) { thisStyleString = isPseudoGlobal[1] } const selector = (isPseudoGlobal ? PSEUDO_GLOBAL_SELECTOR : '') + toHash(label + thisStyleString) const className = ( isPseudoGlobal ? selectors.map((s) => s[CLASS_NAME]) : [selector, ...externalClassNames] ).join(' ') return { [SELECTOR]: selector, [CLASS_NAME]: className, [STYLE_STRING]: thisStyleString, [SELECTORS]: selectors, [EXTERNAL_CLASS_NAMES]: externalClassNames, } } export const cxCommon = ( args: (string | boolean | null | undefined | CssClassName)[] ): (string | boolean | null | undefined | CssClassName)[] => { for (let i = 0, len = args.length; i < len; i++) { const arg = args[i] if (typeof arg === 'string') { args[i] = { [SELECTOR]: '', [CLASS_NAME]: '', [STYLE_STRING]: '', [SELECTORS]: [], [EXTERNAL_CLASS_NAMES]: [arg], } } } return args } export const keyframesCommon = ( strings: TemplateStringsArray, ...values: CssVariableType[] ): CssClassName => { const [label, styleString] = buildStyleString(strings, values) return { [SELECTOR]: '', [CLASS_NAME]: `@keyframes ${toHash(label + styleString)}`, [STYLE_STRING]: styleString, [SELECTORS]: [], [EXTERNAL_CLASS_NAMES]: [], } } type ViewTransitionType = { (strings: TemplateStringsArray, values: CssVariableType[]): CssClassName (content: CssClassName): CssClassName (): CssClassName } let viewTransitionNameIndex = 0 export const viewTransitionCommon: ViewTransitionType = (( strings: TemplateStringsArray | CssClassName | undefined, values: CssVariableType[] ): CssClassName => { if (!strings) { // eslint-disable-next-line @typescript-eslint/no-explicit-any strings = [`/* h-v-t ${viewTransitionNameIndex++} */`] as any } const content = Array.isArray(strings) ? cssCommon(strings as TemplateStringsArray, values) : (strings as CssClassName) const transitionName = content[CLASS_NAME] // eslint-disable-next-line @typescript-eslint/no-explicit-any const res = cssCommon(['view-transition-name:', ''] as any, [transitionName]) content[CLASS_NAME] = PSEUDO_GLOBAL_SELECTOR + content[CLASS_NAME] content[STYLE_STRING] = content[STYLE_STRING].replace( /(?<=::view-transition(?:[a-z-]*)\()(?=\))/g, transitionName ) res[CLASS_NAME] = res[SELECTOR] = transitionName res[SELECTORS] = [...content[SELECTORS], content] return res }) as ViewTransitionType ================================================ FILE: src/helper/css/index.test.tsx ================================================ /** @jsxImportSource ../../jsx */ import { Hono } from '../../' import { html } from '../../helper/html' import type { JSXNode } from '../../jsx' import { isValidElement } from '../../jsx' import { Suspense, renderToReadableStream } from '../../jsx/streaming' import type { HtmlEscapedString } from '../../utils/html' import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html' import { renderTest } from './common.case.test' import { Style, createCssContext, css, cx, keyframes, rawCssString, viewTransition } from './index' async function toString( template: JSXNode | Promise | Promise | HtmlEscapedString ) { if (template instanceof Promise) { template = (await template) as HtmlEscapedString } if (isValidElement(template)) { template = template.toString() as Promise } return resolveCallback(await template, HtmlEscapedCallbackPhase.Stringify, false, template) } async function toCSS( template: JSXNode | Promise | Promise | HtmlEscapedString ) { return (await toString(template)) .replace(/.*?=(".*")<\/script.*/, '$1') .replace(/\.css-\d+/g, '.css-123') } describe('CSS Helper', () => { renderTest(() => { return { css, Style, keyframes, viewTransition, rawCssString, createCssContext, toString, toCSS, support: { nest: true, }, } }) describe('with `html` tag function', () => { it('Should render CSS styles with `html` tag function', async () => { const headerClass = css` background-color: blue; ` const template = html`${Style()}

Hello!

` expect(await toString(template)).toBe( `

Hello!

` ) }) it('Should render CSS styles with `html` tag function and CSP nonce', async () => { const headerClass = css` background-color: blue; ` const template = html`${Style({ nonce: '1234' })}

Hello!

` expect(await toString(template)).toBe( `

Hello!

` ) }) }) describe('cx()', () => { it('Should render CSS with cx()', async () => { const btn = css` border-radius: 4px; ` const btnPrimary = css` background-color: blue; color: white; ` const template = ( <>

Hello!

' ) }) it('Should render CSS with cx() includes external class name', async () => { const btn = css` border-radius: 4px; ` const template = ( <>

Hello!

' ) }) it('Should render CSS with cx() includes nested external class name', async () => { const btn = css` border-radius: 4px; ` const btn2 = cx(btn, 'external-class') const btn3 = css` ${btn2} color: white; ` const btn4 = cx(btn3, 'external-class2') const template = ( <>

Hello!

' ) }) }) describe('minify', () => { const data: [string, Promise, string][] = [ [ 'basic CSS styles', css` background-color: blue; color: white; padding: 1rem; `, '.css-123{background-color:blue;color:white;padding:1rem}', ], [ 'remove comments', css` /* background-color: blue; */ color: white; padding: 1rem; // inline comment margin: 1rem; `, '.css-123{color:white;padding:1rem;margin:1rem}', ], [ 'preserve string', css` background-color: blue; color: white; padding: 1rem; content: "Hel \\\n \\' lo!"; content: 'Hel \\\n \\" lo!'; `, '.css-123{background-color:blue;color:white;padding:1rem;content:"Hel \\\n \\\' lo!";content:\'Hel \\\n \\" lo!\'}', ], [ 'preserve nested selectors', css` padding: 1rem; &:hover { padding: 2rem; } `, '.css-123{padding:1rem;&:hover{padding:2rem}}', ], ] data.forEach(([name, str, expected]) => { it(`Should be minified while preserving content accurately: ${name}`, async () => { expect(JSON.parse(await toCSS(str))).toBe(expected) }) }) }) describe('createCssContext()', () => { it('Should create a new CSS context', async () => { const { css: css1, Style: Style1 } = createCssContext({ id: 'context1' }) const { css: css2, Style: Style2 } = createCssContext({ id: 'context2' }) const headerClass1 = css1` background-color: blue; ` const headerClass2 = css2` background-color: red; ` const template = ( <>

Hello!

Hello!

) expect(await toString(template)).toBe( '

Hello!

Hello!

' ) }) }) describe('with application', () => { const app = new Hono() const headerClass = css` background-color: blue; ` app.get('/sync', (c) => c.html( <>

Hello!

' ) }) it('/stream', async () => { const res = await app.request('http://localhost/stream') expect(res).not.toBeNull() expect(await res.text()).toBe( `

Loading...

` ) }) it('/stream-with-nonce', async () => { const res = await app.request('http://localhost/stream-with-nonce') expect(res).not.toBeNull() expect(await res.text()).toBe( `

Loading...

` ) }) }) }) ================================================ FILE: src/helper/css/index.ts ================================================ /** * @module * css Helper for Hono. */ import { raw } from '../../helper/html' import { DOM_RENDERER } from '../../jsx/constants' import { createCssJsxDomObjects } from '../../jsx/dom/css' import type { HtmlEscapedCallback, HtmlEscapedString } from '../../utils/html' import type { CssClassName as CssClassNameCommon, CssVariableType } from './common' import { CLASS_NAME, DEFAULT_STYLE_ID, PSEUDO_GLOBAL_SELECTOR, SELECTOR, SELECTORS, STYLE_STRING, cssCommon, cxCommon, keyframesCommon, viewTransitionCommon, } from './common' export { rawCssString } from './common' type CssClassName = HtmlEscapedString & CssClassNameCommon type usedClassNameData = [ Record, // class name to add Record, // class name already added ] interface CssType { (strings: TemplateStringsArray, ...values: CssVariableType[]): Promise } interface CxType { ( ...args: (CssClassName | Promise | string | boolean | null | undefined)[] ): Promise } interface KeyframesType { (strings: TemplateStringsArray, ...values: CssVariableType[]): CssClassNameCommon } interface ViewTransitionType { (strings: TemplateStringsArray, ...values: CssVariableType[]): Promise (content: Promise): Promise (): Promise } interface StyleType { (args?: { children?: Promise; nonce?: string }): HtmlEscapedString } /** * @experimental * `createCssContext` is an experimental feature. * The API might be changed. */ export const createCssContext = ({ id }: { id: Readonly }): DefaultContextType => { const [cssJsxDomObject, StyleRenderToDom] = createCssJsxDomObjects({ id }) const contextMap: WeakMap = new WeakMap() const nonceMap: WeakMap = new WeakMap() const replaceStyleRe = new RegExp(`()`) const newCssClassNameObject = (cssClassName: CssClassNameCommon): Promise => { const appendStyle: HtmlEscapedCallback = ({ buffer, context }): Promise | undefined => { const [toAdd, added] = contextMap.get(context) as usedClassNameData const names = Object.keys(toAdd) if (!names.length) { return } let stylesStr = '' names.forEach((className) => { added[className] = true stylesStr += className.startsWith(PSEUDO_GLOBAL_SELECTOR) ? toAdd[className] : `${className[0] === '@' ? '' : '.'}${className}{${toAdd[className]}}` }) contextMap.set(context, [{}, added]) if (buffer && replaceStyleRe.test(buffer[0])) { buffer[0] = buffer[0].replace(replaceStyleRe, (_, pre, post) => `${pre}${stylesStr}${post}`) return } const nonce = nonceMap.get(context) const appendStyleScript = `document.querySelector('#${id}').textContent+=${JSON.stringify(stylesStr)}` if (buffer) { buffer[0] = `${appendStyleScript}${buffer[0]}` return } return Promise.resolve(appendStyleScript) } const addClassNameToContext: HtmlEscapedCallback = ({ context }) => { if (!contextMap.has(context)) { contextMap.set(context, [{}, {}]) } const [toAdd, added] = contextMap.get(context) as usedClassNameData let allAdded = true if (!added[cssClassName[SELECTOR]]) { allAdded = false toAdd[cssClassName[SELECTOR]] = cssClassName[STYLE_STRING] } cssClassName[SELECTORS].forEach( ({ [CLASS_NAME]: className, [STYLE_STRING]: styleString }) => { if (!added[className]) { allAdded = false toAdd[className] = styleString } } ) if (allAdded) { return } return Promise.resolve(raw('', [appendStyle])) } const className = new String(cssClassName[CLASS_NAME]) as CssClassName Object.assign(className, cssClassName) ;(className as HtmlEscapedString).isEscaped = true ;(className as HtmlEscapedString).callbacks = [addClassNameToContext] const promise = Promise.resolve(className) Object.assign(promise, cssClassName) promise.toString = cssJsxDomObject.toString return promise } const css: CssType = (strings, ...values) => { return newCssClassNameObject(cssCommon(strings, values)) } const cx: CxType = (...args) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any args = cxCommon(args as any) as any // eslint-disable-next-line @typescript-eslint/no-explicit-any return css(Array(args.length).fill('') as any, ...args) } const keyframes = keyframesCommon const viewTransition: ViewTransitionType = (( strings: TemplateStringsArray | Promise | undefined, ...values: CssVariableType[] ) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return newCssClassNameObject(viewTransitionCommon(strings as any, values)) }) as ViewTransitionType const Style: StyleType = ({ children, nonce } = {}) => raw( ``, [ ({ context }) => { nonceMap.set(context, nonce) return undefined }, ] ) // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(Style as any)[DOM_RENDERER] = StyleRenderToDom return { css, cx, keyframes, viewTransition: viewTransition as ViewTransitionType, Style, } } interface DefaultContextType { css: CssType cx: CxType keyframes: KeyframesType viewTransition: ViewTransitionType Style: StyleType } const defaultContext: DefaultContextType = createCssContext({ id: DEFAULT_STYLE_ID, }) /** * @experimental * `css` is an experimental feature. * The API might be changed. */ export const css = defaultContext.css /** * @experimental * `cx` is an experimental feature. * The API might be changed. */ export const cx = defaultContext.cx /** * @experimental * `keyframes` is an experimental feature. * The API might be changed. */ export const keyframes = defaultContext.keyframes /** * @experimental * `viewTransition` is an experimental feature. * The API might be changed. */ export const viewTransition = defaultContext.viewTransition /** * @experimental * `Style` is an experimental feature. * The API might be changed. */ export const Style = defaultContext.Style ================================================ FILE: src/helper/dev/index.test.ts ================================================ import { Hono } from '../../hono' import { RegExpRouter } from '../../router/reg-exp-router' import type { Handler, MiddlewareHandler } from '../../types' import { getRouterName, inspectRoutes, showRoutes } from '.' const namedMiddleware: MiddlewareHandler = (_, next) => next() const namedHandler: Handler = (c) => c.text('hi') const app = new Hono() .use('*', (_c, next) => next()) .get( '/', (_, next) => next(), (c) => c.text('hi') ) .get('/named', namedMiddleware, namedHandler) .post('/', (c) => c.text('hi')) .put('/', (c) => c.text('hi')) .patch('/', (c) => c.text('hi')) .delete('/', (c) => c.text('hi')) .options('/', (c) => c.text('hi')) .get('/static', () => new Response('hi')) describe('inspectRoutes()', () => { it('should return correct data', async () => { expect(inspectRoutes(app)).toEqual([ { path: '/*', method: 'ALL', name: '[middleware]', isMiddleware: true }, { path: '/', method: 'GET', name: '[middleware]', isMiddleware: true }, { path: '/', method: 'GET', name: '[handler]', isMiddleware: false }, { path: '/named', method: 'GET', name: 'namedMiddleware', isMiddleware: true }, { path: '/named', method: 'GET', name: 'namedHandler', isMiddleware: false }, { path: '/', method: 'POST', name: '[handler]', isMiddleware: false }, { path: '/', method: 'PUT', name: '[handler]', isMiddleware: false }, { path: '/', method: 'PATCH', name: '[handler]', isMiddleware: false }, { path: '/', method: 'DELETE', name: '[handler]', isMiddleware: false }, { path: '/', method: 'OPTIONS', name: '[handler]', isMiddleware: false }, { path: '/static', method: 'GET', name: '[handler]', isMiddleware: false }, ]) }) it('should return [handler] also for sub app', async () => { const subApp = new Hono() subApp.get('/', (c) => c.json(0)) subApp.onError((_, c) => c.json(0)) const mainApp = new Hono() mainApp.route('/', subApp) expect(inspectRoutes(mainApp)).toEqual([ { isMiddleware: false, method: 'GET', name: '[handler]', path: '/', }, ]) }) }) describe('showRoutes()', () => { let logs: string[] = [] let originalLog: typeof console.log beforeAll(() => { originalLog = console.log console.log = (...args) => logs.push(...args) }) afterAll(() => { console.log = originalLog }) beforeEach(() => { logs = [] }) it('should render simple output', async () => { showRoutes(app) expect(logs).toEqual([ '\x1b[32mGET\x1b[0m /', '\x1b[32mGET\x1b[0m /named', '\x1b[32mPOST\x1b[0m /', '\x1b[32mPUT\x1b[0m /', '\x1b[32mPATCH\x1b[0m /', '\x1b[32mDELETE\x1b[0m /', '\x1b[32mOPTIONS\x1b[0m /', '\x1b[32mGET\x1b[0m /static', ]) }) it('should render output includes handlers and middlewares', async () => { showRoutes(app, { verbose: true }) expect(logs).toEqual([ '\x1b[32mALL\x1b[0m /*', ' [middleware]', '\x1b[32mGET\x1b[0m /', ' [middleware]', ' [handler]', '\x1b[32mGET\x1b[0m /named', ' namedMiddleware', ' namedHandler', '\x1b[32mPOST\x1b[0m /', ' [handler]', '\x1b[32mPUT\x1b[0m /', ' [handler]', '\x1b[32mPATCH\x1b[0m /', ' [handler]', '\x1b[32mDELETE\x1b[0m /', ' [handler]', '\x1b[32mOPTIONS\x1b[0m /', ' [handler]', '\x1b[32mGET\x1b[0m /static', ' [handler]', ]) }) it('should render not colorized output', async () => { showRoutes(app, { colorize: false }) expect(logs).toEqual([ 'GET /', 'GET /named', 'POST /', 'PUT /', 'PATCH /', 'DELETE /', 'OPTIONS /', 'GET /static', ]) }) }) describe('showRoutes() in NO_COLOR', () => { let logs: string[] = [] let originalLog: typeof console.log beforeAll(() => { vi.stubEnv('NO_COLOR', '1') originalLog = console.log console.log = (...args) => logs.push(...args) }) afterAll(() => { vi.unstubAllEnvs() console.log = originalLog }) beforeEach(() => { logs = [] }) it('should render not colorized output', async () => { showRoutes(app) expect(logs).toEqual([ 'GET /', 'GET /named', 'POST /', 'PUT /', 'PATCH /', 'DELETE /', 'OPTIONS /', 'GET /static', ]) }) it('should render colorized output if colorize: true', async () => { showRoutes(app, { colorize: true }) expect(logs).toEqual([ '\x1b[32mGET\x1b[0m /', '\x1b[32mGET\x1b[0m /named', '\x1b[32mPOST\x1b[0m /', '\x1b[32mPUT\x1b[0m /', '\x1b[32mPATCH\x1b[0m /', '\x1b[32mDELETE\x1b[0m /', '\x1b[32mOPTIONS\x1b[0m /', '\x1b[32mGET\x1b[0m /static', ]) }) }) describe('getRouterName()', () => { it('Should return the correct router name', async () => { const app = new Hono({ router: new RegExpRouter(), }) expect(getRouterName(app)).toBe('RegExpRouter') }) }) ================================================ FILE: src/helper/dev/index.ts ================================================ /** * @module * Dev Helper for Hono. */ import type { Hono } from '../../hono' import type { Env, RouterRoute } from '../../types' import { getColorEnabled } from '../../utils/color' import { findTargetHandler, isMiddleware } from '../../utils/handler' interface ShowRoutesOptions { verbose?: boolean colorize?: boolean } interface RouteData { path: string method: string name: string isMiddleware: boolean } const handlerName = (handler: Function): string => { return handler.name || (isMiddleware(handler) ? '[middleware]' : '[handler]') } export const inspectRoutes = (hono: Hono): RouteData[] => { return hono.routes.map(({ path, method, handler }: RouterRoute) => { const targetHandler = findTargetHandler(handler) return { path, method, name: handlerName(targetHandler), isMiddleware: isMiddleware(targetHandler), } }) } export const showRoutes = (hono: Hono, opts?: ShowRoutesOptions): void => { const colorEnabled = opts?.colorize ?? getColorEnabled() const routeData: Record = {} let maxMethodLength = 0 let maxPathLength = 0 inspectRoutes(hono) .filter(({ isMiddleware }) => opts?.verbose || !isMiddleware) .map((route) => { const key = `${route.method}-${route.path}` ;(routeData[key] ||= []).push(route) if (routeData[key].length > 1) { return } maxMethodLength = Math.max(maxMethodLength, route.method.length) maxPathLength = Math.max(maxPathLength, route.path.length) return { method: route.method, path: route.path, routes: routeData[key] } }) .forEach((data) => { if (!data) { return } const { method, path, routes } = data const methodStr = colorEnabled ? `\x1b[32m${method}\x1b[0m` : method console.log(`${methodStr} ${' '.repeat(maxMethodLength - method.length)} ${path}`) if (!opts?.verbose) { return } routes.forEach(({ name }) => { console.log(`${' '.repeat(maxMethodLength + 3)} ${name}`) }) }) } export const getRouterName = (app: Hono): string => { app.router.match('GET', '/') return app.router.name } ================================================ FILE: src/helper/factory/index.test.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ import { expectTypeOf } from 'vitest' import { hc } from '../../client' import type { ClientRequest } from '../../client/types' import { Hono } from '../../index' import type { Env, ExtractSchema, H, MiddlewareHandler, ToSchema, TypedResponse } from '../../types' import type { ContentfulStatusCode } from '../../utils/http-status' import type { Equal, Expect } from '../../utils/types' import { validator } from '../../validator' import { createFactory, createMiddleware } from './index' describe('createMiddleware', () => { type Env = { Variables: { foo: string } } const app = new Hono() const mw = (message: string) => createMiddleware(async (c, next) => { expectTypeOf(c.var.foo).toEqualTypeOf() c.set('foo', 'bar') await next() c.header('X-Message', message) }) const route = app.get('/message', mw('Hello Middleware'), (c) => { return c.text(`Hey, ${c.var.foo}`) }) it('Should return the correct header and the content', async () => { const res = await app.request('/message') expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('Hello Middleware') expect(await res.text()).toBe('Hey, bar') }) it('Should provide the correct types', async () => { const client = hc('http://localhost') const url = client.message.$url() expect(url.pathname).toBe('/message') }) it('Should pass generics types to chained handlers', () => { type Bindings = { MY_VAR_IN_BINDINGS: string } type Variables = { MY_VAR: string } const app = new Hono<{ Bindings: Bindings }>() app.get( '/', createMiddleware<{ Variables: Variables }>(async (c, next) => { await next() }), createMiddleware(async (c, next) => { await next() }), async (c) => { const v = c.get('MY_VAR') expectTypeOf(v).toEqualTypeOf() } ) }) it('Should default to MiddlewareHandler', async () => { const middleware = createMiddleware(async (c, next) => { await next() }) type Expected = MiddlewareHandler type _verify = Expect> }) }) describe('createHandler', () => { const mw = (message: string) => createMiddleware(async (c, next) => { await next() c.header('x-message', message) }) describe('Basic', () => { const factory = createFactory() const app = new Hono() const handlersA = factory.createHandlers((c) => { return c.text('A') }) const routesA = app.get('/a', ...handlersA) const handlersB = factory.createHandlers(mw('B'), (c) => { return c.text('B') }) app.get('/b', ...handlersB) it('Should return 200 response - GET /a', async () => { const res = await app.request('/a') expect(res.status).toBe(200) expect(await res.text()).toBe('A') }) it('Should return 200 response with a custom header - GET /b', async () => { const res = await app.request('/b') expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('B') expect(await res.text()).toBe('B') }) it('Should return correct path types - /a', () => { const client = hc('/') expectTypeOf(client).toEqualTypeOf<{ a: ClientRequest< string, '/a', { $get: { input: {} output: 'A' outputFormat: 'text' status: ContentfulStatusCode } } > }>() }) }) describe('Types', () => { type Env = { Variables: { foo: string } } const factory = createFactory() const app = new Hono() const handlers = factory.createHandlers( validator('query', () => { return { page: '1', } }), (c) => { const foo = c.var.foo const { page } = c.req.valid('query') return c.json({ page, foo }) } ) const routes = app.get('/posts', ...handlers) type Expected = Hono< Env, ToSchema< 'get', '/posts', { in: { query: { page: string } } }, TypedResponse<{ page: string foo: string }> >, '/' > it('Should return correct types', () => { expectTypeOf(routes).toEqualTypeOf() }) }) // It's difficult to cover all possible patterns, // so these tests will only cover the minimal cases. describe('Types - Complex', () => { type Env = { Variables: { foo: string } } const factory = createFactory() const app = new Hono() const handlers = factory.createHandlers( validator('header', () => { return { auth: 'token', } }), validator('query', () => { return { page: '1', } }), validator('json', () => { return { id: 123, } }), (c) => { const foo = c.var.foo const { auth } = c.req.valid('header') const { page } = c.req.valid('query') const { id } = c.req.valid('json') return c.json({ auth, page, foo, id }) } ) const routes = app.get('/posts', ...handlers) type Expected = Hono< Env, ToSchema< 'get', '/posts', { in: { header: { auth: string } } & { query: { page: string } } & { json: { id: number } } }, TypedResponse<{ auth: string page: string foo: string id: number }> >, '/' > it('Should return correct types', () => { expectTypeOf(routes).toEqualTypeOf() }) }) describe('Types - Context Env with Multiple Middlewares', () => { const factory = createFactory() const mw1 = createMiddleware< { Variables: { foo1: string } }, string, { out: { query: { bar1: number } } } >(async () => {}) const mw2 = createMiddleware< { Variables: { foo2: string } }, string, { out: { query: { bar2: number } } } >(async () => {}) const mw3 = createMiddleware< { Variables: { foo3: string } }, string, { out: { query: { bar3: number } } } >(async () => {}) const mw4 = createMiddleware< { Variables: { foo4: string } }, string, { out: { query: { bar4: number } } } >(async () => {}) const mw5 = createMiddleware< { Variables: { foo5: string } }, string, { out: { query: { bar5: number } } } >(async () => {}) const mw6 = createMiddleware< { Variables: { foo6: string } }, string, { out: { query: { bar6: number } } } >(async () => {}) const mw7 = createMiddleware< { Variables: { foo7: string } }, string, { out: { query: { bar7: number } } } >(async () => {}) const mw8 = createMiddleware< { Variables: { foo8: string } }, string, { out: { query: { bar8: number } } } >(async () => {}) const mw9 = createMiddleware< { Variables: { foo9: string } }, string, { out: { query: { bar9: number } } } >(async () => {}) it('Should not throw type error', () => { factory.createHandlers( mw1, mw2, mw3, mw4, mw5, mw6, mw7, mw8, async (c) => { expectTypeOf(c.var.foo1).toEqualTypeOf() expectTypeOf(c.var.foo2).toEqualTypeOf() expectTypeOf(c.var.foo3).toEqualTypeOf() expectTypeOf(c.var.foo4).toEqualTypeOf() expectTypeOf(c.var.foo5).toEqualTypeOf() expectTypeOf(c.var.foo6).toEqualTypeOf() expectTypeOf(c.var.foo7).toEqualTypeOf() expectTypeOf(c.var.foo8).toEqualTypeOf() }, (c) => c.json(0) ) factory.createHandlers(mw1, mw2, mw3, mw4, mw5, mw6, mw7, mw8, mw9, (c) => { expectTypeOf(c.var.foo1).toEqualTypeOf() expectTypeOf(c.var.foo2).toEqualTypeOf() expectTypeOf(c.var.foo3).toEqualTypeOf() expectTypeOf(c.var.foo4).toEqualTypeOf() expectTypeOf(c.var.foo5).toEqualTypeOf() expectTypeOf(c.var.foo6).toEqualTypeOf() expectTypeOf(c.var.foo7).toEqualTypeOf() expectTypeOf(c.var.foo8).toEqualTypeOf() expectTypeOf(c.var.foo9).toEqualTypeOf() return c.json({ foo1: c.get('foo1'), foo2: c.get('foo2'), foo3: c.get('foo3'), foo4: c.get('foo4'), foo5: c.get('foo5'), foo6: c.get('foo6'), foo7: c.get('foo7'), foo8: c.get('foo8'), foo9: c.get('foo9'), }) }) }) }) describe('Types - Multiple Handlers', () => { const factory = createFactory() const [handler1, handler2] = factory.createHandlers( (c) => c.json({ first: 1 }), (c) => c.json({ second: 'second' as const }) ) type ExtractOutput = R extends H> ? Inner : never type Handler1Output = ExtractOutput type Handler2Output = ExtractOutput it('Should allow multiple handlers with independent return types', () => { expectTypeOf().toEqualTypeOf<{ first: number }>() expectTypeOf().toEqualTypeOf<{ second: 'second' }>() }) }) }) describe('createFactory', () => { describe('createApp', () => { type Env = { Variables: { foo: string } } const factory = createFactory({ initApp: (app) => { app.use((c, next) => { c.set('foo', 'bar') return next() }) }, }) const app = factory.createApp() it('Should set the correct type and initialize the app', async () => { app.get('/', (c) => { expectTypeOf(c.var.foo).toEqualTypeOf() return c.text(c.var.foo) }) const res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe('bar') }) }) describe('createMiddleware', () => { it('Should omit the `*` wildcard from the generated schema', () => { const factory = createFactory() const middleware = factory.createMiddleware(async (_, next) => { await next() }) const routes = new Hono().use('*', middleware) type Actual = ExtractSchema type Expected = {} type verify = Expect> }) it('Should default to MiddlewareHandler', async () => { const factory = createFactory() const middleware = factory.createMiddleware(async (c, next) => { await next() }) type Expected = MiddlewareHandler type _verify = Expect> }) }) it('Should use the default app options', async () => { const app = createFactory({ defaultAppOptions: { strict: false } }).createApp() app.get('/hello', (c) => c.text('hello')) const res = await app.request('/hello/') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') }) it('Should override the default app options when creating', async () => { const app = createFactory({ defaultAppOptions: { strict: true } }).createApp({ strict: false }) app.get('/hello', (c) => c.text('hello')) const res = await app.request('/hello/') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') }) }) describe('Lint rules', () => { it('Should not throw a eslint `unbound-method` error if destructed', () => { const { createApp, createHandlers, createMiddleware } = createFactory() expect(createApp).toBeDefined() expect(createHandlers).toBeDefined() expect(createMiddleware).toBeDefined() }) }) ================================================ FILE: src/helper/factory/index.ts ================================================ /** * @module * Factory Helper for Hono. */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { Hono } from '../../hono' import type { HonoOptions } from '../../hono-base' import type { Env, H, HandlerResponse, Input, IntersectNonAnyTypes, MiddlewareHandler, } from '../../types' type InitApp = (app: Hono) => void export interface CreateHandlersInterface { = any, E2 extends Env = E>( handler1: H ): [H] // handler x2 < I extends Input = {}, I2 extends Input = I, R extends HandlerResponse = any, R2 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, >( handler1: H, handler2: H ): [H, H] // handler x3 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, >( handler1: H, handler2: H, handler3: H ): [H, H, H] // handler x4 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, >( handler1: H, handler2: H, handler3: H, handler4: H ): [H, H, H, H] // handler x5 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H ): [H, H, H, H, H] // handler x6 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, I6 extends Input = I & I2 & I3 & I4 & I5, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, R6 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H, handler6: H ): [ H, H, H, H, H, H, ] // handler x7 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, I6 extends Input = I & I2 & I3 & I4 & I5, I7 extends Input = I & I2 & I3 & I4 & I5 & I6, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, R6 extends HandlerResponse = any, R7 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H, handler6: H, handler7: H ): [ H, H, H, H, H, H, H, ] // handler x8 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, I6 extends Input = I & I2 & I3 & I4 & I5, I7 extends Input = I & I2 & I3 & I4 & I5 & I6, I8 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, R6 extends HandlerResponse = any, R7 extends HandlerResponse = any, R8 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H, handler6: H, handler7: H, handler8: H ): [ H, H, H, H, H, H, H, H, ] // handler x9 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, I6 extends Input = I & I2 & I3 & I4 & I5, I7 extends Input = I & I2 & I3 & I4 & I5 & I6, I8 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7, I9 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7 & I8, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, R6 extends HandlerResponse = any, R7 extends HandlerResponse = any, R8 extends HandlerResponse = any, R9 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H, handler6: H, handler7: H, handler8: H, handler9: H ): [ H, H, H, H, H, H, H, H, H, ] // handler x10 < I extends Input = {}, I2 extends Input = I, I3 extends Input = I & I2, I4 extends Input = I & I2 & I3, I5 extends Input = I & I2 & I3 & I4, I6 extends Input = I & I2 & I3 & I4 & I5, I7 extends Input = I & I2 & I3 & I4 & I5 & I6, I8 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7, I9 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7 & I8, I10 extends Input = I & I2 & I3 & I4 & I5 & I6 & I7 & I8 & I9, R extends HandlerResponse = any, R2 extends HandlerResponse = any, R3 extends HandlerResponse = any, R4 extends HandlerResponse = any, R5 extends HandlerResponse = any, R6 extends HandlerResponse = any, R7 extends HandlerResponse = any, R8 extends HandlerResponse = any, R9 extends HandlerResponse = any, R10 extends HandlerResponse = any, E2 extends Env = E, E3 extends Env = IntersectNonAnyTypes<[E, E2]>, E4 extends Env = IntersectNonAnyTypes<[E, E2, E3]>, E5 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4]>, E6 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5]>, E7 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6]>, E8 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7]>, E9 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8]>, E10 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9]>, E11 extends Env = IntersectNonAnyTypes<[E, E2, E3, E4, E5, E6, E7, E8, E9, E10]>, >( handler1: H, handler2: H, handler3: H, handler4: H, handler5: H, handler6: H, handler7: H, handler8: H, handler9: H, handler10: H ): [ H, H, H, H, H, H, H, H, H, H, ] } export class Factory { private initApp?: InitApp #defaultAppOptions?: HonoOptions constructor(init?: { initApp?: InitApp; defaultAppOptions?: HonoOptions }) { this.initApp = init?.initApp this.#defaultAppOptions = init?.defaultAppOptions } createApp = (options?: HonoOptions): Hono => { const app = new Hono( options && this.#defaultAppOptions ? { ...this.#defaultAppOptions, ...options } : (options ?? this.#defaultAppOptions) ) if (this.initApp) { this.initApp(app) } return app } createMiddleware = | void = void>( middleware: MiddlewareHandler ): MiddlewareHandler => middleware createHandlers: CreateHandlersInterface = (...handlers: any) => { // @ts-expect-error this should not be typed return handlers.filter((handler) => handler !== undefined) } } export const createFactory = (init?: { initApp?: InitApp defaultAppOptions?: HonoOptions }): Factory => new Factory(init) export const createMiddleware = < E extends Env = any, P extends string = string, I extends Input = {}, R extends HandlerResponse | void = void, >( middleware: MiddlewareHandler ): MiddlewareHandler => middleware ================================================ FILE: src/helper/html/index.test.ts ================================================ import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html' import { html, raw } from '.' describe('Tagged Template Literals', () => { it('Should escape special characters', () => { const name = 'John "Johnny" Smith' expect(html`

I'm ${name}.

`.toString()).toBe("

I'm John "Johnny" Smith.

") }) describe('Booleans, Null, and Undefined Are Ignored', () => { it.each([true, false, undefined, null])('%s', (item) => { expect(html`${item}`.toString()).toBe('') }) it('falsy value', () => { expect(html`${0}`.toString()).toBe('0') }) }) it('Should call $array.flat(Infinity)', () => { const values = [ 'Name:', ['John "Johnny" Smith', undefined, null], ' Contact:', [html`My Website`], ] expect(html`

${values}

`.toString()).toBe( '

Name:John "Johnny" Smith Contact:My Website

' ) }) describe('Promise', () => { it('Should return Promise when some variables contains Promise in variables', async () => { const name = Promise.resolve('John "Johnny" Smith') const res = html`

I'm ${name}.

` expect(res).toBeInstanceOf(Promise) expect((await res).toString()).toBe("

I'm John "Johnny" Smith.

") }) it('Should return raw value when some variables contains Promise in variables', async () => { const name = Promise.resolve(raw('John "Johnny" Smith')) const res = html`

I'm ${name}.

` expect(res).toBeInstanceOf(Promise) expect((await res).toString()).toBe('

I\'m John "Johnny" Smith.

') }) }) describe('HtmlEscapedString', () => { it('Should preserve callbacks', async () => { const name = raw('Hono', [ ({ buffer }) => { if (buffer) { buffer[0] = buffer[0].replace('Hono', 'Hono!') } return undefined }, ]) const res = html`

I'm ${name}.

` expect(res).toBeInstanceOf(Promise) expect((await res).toString()).toBe("

I'm Hono.

") expect(await resolveCallback(await res, HtmlEscapedCallbackPhase.Stringify, false, {})).toBe( "

I'm Hono!.

" ) }) }) }) describe('raw', () => { it('Should be marked as escaped.', () => { const name = 'John "Johnny" Smith' expect(html`

I'm ${raw(name)}.

`.toString()).toBe( "

I'm John "Johnny" Smith.

" ) }) }) ================================================ FILE: src/helper/html/index.ts ================================================ /** * @module * html Helper for Hono. */ import { escapeToBuffer, raw, resolveCallbackSync, stringBufferToString } from '../../utils/html' import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../../utils/html' export { raw } export const html = ( strings: TemplateStringsArray, ...values: unknown[] ): HtmlEscapedString | Promise => { const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks for (let i = 0, len = strings.length - 1; i < len; i++) { buffer[0] += strings[i] const children = Array.isArray(values[i]) ? (values[i] as Array).flat(Infinity) : [values[i]] for (let i = 0, len = children.length; i < len; i++) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const child = children[i] as any if (typeof child === 'string') { escapeToBuffer(child, buffer) } else if (typeof child === 'number') { ;(buffer[0] as string) += child } else if (typeof child === 'boolean' || child === null || child === undefined) { continue } else if (typeof child === 'object' && (child as HtmlEscaped).isEscaped) { if ((child as HtmlEscapedString).callbacks) { buffer.unshift('', child) } else { const tmp = child.toString() if (tmp instanceof Promise) { buffer.unshift('', tmp) } else { buffer[0] += tmp } } } else if (child instanceof Promise) { buffer.unshift('', child) } else { escapeToBuffer(child.toString(), buffer) } } } buffer[0] += strings.at(-1) as string return buffer.length === 1 ? 'callbacks' in buffer ? raw(resolveCallbackSync(raw(buffer[0], buffer.callbacks))) : raw(buffer[0]) : stringBufferToString(buffer, buffer.callbacks) } ================================================ FILE: src/helper/proxy/index.test.ts ================================================ import { Hono } from '../../hono' import { proxy } from '.' describe('Proxy Middleware', () => { describe('proxy', () => { beforeEach(() => { global.fetch = vi.fn().mockImplementation(async (req) => { if (req.url === 'https://example.com/ok') { return Promise.resolve(new Response('ok')) } else if (req.url === 'https://example.com/disconnect') { const reader = req.body.getReader() let response req.signal.addEventListener('abort', () => { response = req.signal.reason reader.cancel() }) await reader.read() return Promise.resolve(new Response(response)) } else if (req.url === 'https://example.com/compressed') { return Promise.resolve( new Response('ok', { headers: { 'Content-Encoding': 'gzip', 'Content-Length': '1', 'Content-Range': 'bytes 0-2/1024', 'X-Response-Id': '456', }, }) ) } else if (req.url === 'https://example.com/uncompressed') { return Promise.resolve( new Response('ok', { headers: { 'Content-Length': '2', 'Content-Range': 'bytes 0-2/1024', 'X-Response-Id': '456', }, }) ) } else if (req.url === 'https://example.com/post' && req.method === 'POST') { return Promise.resolve(new Response(`request body: ${await req.text()}`)) } else if (req.url === 'https://example.com/hop-by-hop') { return Promise.resolve( new Response('ok', { headers: { 'Transfer-Encoding': 'chunked', }, }) ) } else if (req.url === 'https://example.com/set-cookie') { return Promise.resolve( new Response('ok', { headers: { 'Set-Cookie': 'test=123', }, }) ) } return Promise.resolve(new Response('not found', { status: 404 })) }) }) it('compressed', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy( new Request(`https://example.com/${c.req.param('path')}`, { headers: { 'X-Request-Id': '123', 'Accept-Encoding': 'gzip', }, }) ) ) const res = await app.request('/proxy/compressed') const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.url).toBe('https://example.com/compressed') expect(req.headers.get('X-Request-Id')).toBe('123') expect(req.headers.get('Accept-Encoding')).toBeNull() expect(res.status).toBe(200) expect(res.headers.get('X-Response-Id')).toBe('456') expect(res.headers.get('Content-Encoding')).toBeNull() expect(res.headers.get('Content-Length')).toBeNull() expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) it('uncompressed', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy( new Request(`https://example.com/${c.req.param('path')}`, { headers: { 'X-Request-Id': '123', 'Accept-Encoding': 'gzip', }, }) ) ) const res = await app.request('/proxy/uncompressed') const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.url).toBe('https://example.com/uncompressed') expect(req.headers.get('X-Request-Id')).toBe('123') expect(req.headers.get('Accept-Encoding')).toBeNull() expect(res.status).toBe(200) expect(res.headers.get('X-Response-Id')).toBe('456') expect(res.headers.get('Content-Length')).toBe('2') expect(res.headers.get('Content-Range')).toBe('bytes 0-2/1024') }) it('POST request', async () => { const app = new Hono() app.all('/proxy/:path', (c) => { return proxy(`https://example.com/${c.req.param('path')}`, { ...c.req, headers: { ...c.req.header(), 'X-Request-Id': '123', 'Accept-Encoding': 'gzip', }, }) }) const res = await app.request('/proxy/post', { method: 'POST', body: 'test', }) const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.url).toBe('https://example.com/post') expect(res.status).toBe(200) expect(await res.text()).toBe('request body: test') }) it('remove hop-by-hop headers', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, c.req)) const res = await app.request('/proxy/hop-by-hop', { headers: { Host: 'example.com', Connection: 'keep-alive, custom-header', 'Keep-Alive': 'timeout=5, max=1000', 'Proxy-Authorization': 'Basic 123456', 'Custom-Header': 'test', 'Allowed-Custom-Header': 'test', }, }) const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.headers.get('Host')).toBe('example.com') expect(req.headers.get('Connection')).toBeNull() expect(req.headers.get('Keep-Alive')).toBeNull() expect(req.headers.get('Proxy-Authorization')).toBeNull() expect(req.headers.get('Custom-Header')).toBe('test') expect(req.headers.get('Allowed-Custom-Header')).toBe('test') expect(res.headers.get('Transfer-Encoding')).toBeNull() }) it('remove hop-by-hop headers with strictConnectionProcessing', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, { ...c.req, strictConnectionProcessing: true, }) ) const res = await app.request('/proxy/hop-by-hop', { headers: { Host: 'example.com', Connection: 'keep-alive, custom-header', 'Keep-Alive': 'timeout=5, max=1000', 'Proxy-Authorization': 'Basic 123456', 'Custom-Header': 'test', 'Allowed-Custom-Header': 'test', }, }) const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.headers.get('Host')).toBe('example.com') expect(req.headers.get('Connection')).toBeNull() expect(req.headers.get('Keep-Alive')).toBeNull() expect(req.headers.get('Proxy-Authorization')).toBeNull() expect(req.headers.get('Custom-Header')).toBeNull() expect(req.headers.get('Allowed-Custom-Header')).toBe('test') expect(res.headers.get('Transfer-Encoding')).toBeNull() }) it('invalid hop-by-hop headers with strictConnectionProcessing', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, { ...c.req, strictConnectionProcessing: true, }) ) const res = await app.request('/proxy/hop-by-hop', { headers: { Host: 'example.com', Connection: 'keep-alive, invalid-header invalid-header', 'Keep-Alive': 'timeout=5, max=1000', }, }) expect(res.status).toBe(400) }) it('specify hop-by-hop header by options', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, { headers: { 'Proxy-Authorization': 'Basic 123456', }, }) ) const res = await app.request('/proxy/hop-by-hop') const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.headers.get('Proxy-Authorization')).toBe('Basic 123456') expect(res.headers.get('Transfer-Encoding')).toBeNull() }) it('modify header', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, { headers: { 'Set-Cookie': 'test=123', }, }).then((res) => { res.headers.delete('Set-Cookie') res.headers.set('X-Response-Id', '456') return res }) ) const res = await app.request('/proxy/set-cookie') expect(res.headers.get('Set-Cookie')).toBeNull() expect(res.headers.get('X-Response-Id')).toBe('456') }) it('does not propagate undefined request headers', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, { headers: { ...c.req.header(), Authorization: undefined, }, }) ) await app.request('/proxy/ok', { headers: { Authorization: 'Bearer 123', }, }) const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.headers.get('Authorization')).toBeNull() }) it('client disconnect', async () => { const app = new Hono() const controller = new AbortController() app.post('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`, c.req)) const resPromise = app.request('/proxy/disconnect', { method: 'POST', body: 'test', signal: controller.signal, }) controller.abort('client disconnect') const res = await resPromise expect(await res.text()).toBe('client disconnect') }) it('not found', async () => { const app = new Hono() app.get('/proxy/:path', (c) => proxy(`https://example.com/${c.req.param('path')}`)) const res = await app.request('/proxy/404') expect(res.status).toBe(404) }) it('pass a Request object to proxyInit', async () => { const app = new Hono() app.get('/proxy/:path', (c) => { const req = new Request(c.req.raw, { headers: { 'X-Request-Id': '123', 'Accept-Encoding': 'gzip', }, }) return proxy(`https://example.com/${c.req.param('path')}`, req) }) const res = await app.request('/proxy/compressed') const req = (global.fetch as ReturnType).mock.calls[0][0] expect(req.url).toBe('https://example.com/compressed') expect(req.headers.get('X-Request-Id')).toBe('123') expect(req.headers.get('Accept-Encoding')).toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('ok') }) it('Should call the custom fetch method when specified', async () => { const customFetch = vi.fn().mockImplementation(async (req: Request) => { const text = await req.text() return new Response('custom fetch response. message:' + text) }) const app = new Hono() app.post('/', (c) => { return proxy(`https://example.com/`, { customFetch, ...c.req, }) }) const res = await app.request('/', { method: 'POST', body: 'hi', }) expect(res.status).toBe(200) expect(await res.text()).toBe('custom fetch response. message:hi') }) }) }) ================================================ FILE: src/helper/proxy/index.ts ================================================ /** * @module * Proxy Helper for Hono. */ import { HTTPException } from '../../http-exception' import type { RequestHeader } from '../../utils/headers' // https://datatracker.ietf.org/doc/html/rfc2616#section-13.5.1 const hopByHopHeaders = [ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', ] // https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2 const ALLOWED_TOKEN_PATTERN = /^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/ interface ProxyRequestInit extends Omit { raw?: Request headers?: | HeadersInit | [string, string][] | Record | Record customFetch?: (request: Request) => Promise /** * Enable strict RFC 9110 compliance for Connection header processing. * * - `false` (default): Ignores Connection header to prevent potential * Hop-by-Hop Header Injection attacks. Recommended for untrusted clients. * - `true`: Processes Connection header per RFC 9110 and removes listed headers. * Only use in trusted environments. * * @default false * @see https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1 */ strictConnectionProcessing?: boolean } interface ProxyFetch { (input: string | URL | Request, init?: ProxyRequestInit): Promise } const buildRequestInitFromRequest = ( request: Request | undefined, strictConnectionProcessing: boolean ): RequestInit & { duplex?: 'half' } => { if (!request) { return {} } const headers = new Headers(request.headers) if (strictConnectionProcessing) { // https://datatracker.ietf.org/doc/html/rfc9110#section-7.6.1 // Parse Connection header and remove listed headers (MUST per RFC 9110) const connectionValue = headers.get('connection') if (connectionValue) { const headerNames = connectionValue.split(',').map((h) => h.trim()) // Validate header names per RFC 9110 Section 5.6.2 (token syntax) const invalidHeaders = headerNames.filter((h) => !ALLOWED_TOKEN_PATTERN.test(h)) if (invalidHeaders.length > 0) { throw new HTTPException(400, { message: `Invalid Connection header value: ${invalidHeaders.join(', ')}`, }) } headerNames.forEach((headerName) => { headers.delete(headerName) }) } } hopByHopHeaders.forEach((header) => { headers.delete(header) }) return { method: request.method, body: request.body, duplex: request.body ? 'half' : undefined, headers, signal: request.signal, } } const preprocessRequestInit = (requestInit: RequestInit): RequestInit => { if ( !requestInit.headers || Array.isArray(requestInit.headers) || requestInit.headers instanceof Headers ) { return requestInit } const headers = new Headers() for (const [key, value] of Object.entries(requestInit.headers)) { if (value == null) { // delete header if value is null or undefined headers.delete(key) } else { headers.set(key, value) } } requestInit.headers = headers return requestInit } /** * Fetch API wrapper for proxy. * The parameters and return value are the same as for `fetch` (except for the proxy-specific options). * * The “Accept-Encoding” header is replaced with an encoding that the current runtime can handle. * Unnecessary response headers are deleted and a Response object is returned that can be returned * as is as a response from the handler. * * @example * ```ts * app.get('/proxy/:path', (c) => { * return proxy(`http://${originServer}/${c.req.param('path')}`, { * headers: { * ...c.req.header(), // optional, specify only when forwarding all the request data (including credentials) is necessary. * 'X-Forwarded-For': '127.0.0.1', * 'X-Forwarded-Host': c.req.header('host'), * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') * }, * }).then((res) => { * res.headers.delete('Set-Cookie') * return res * }) * }) * * app.all('/proxy/:path', (c) => { * return proxy(`http://${originServer}/${c.req.param('path')}`, { * ...c.req, // optional, specify only when forwarding all the request data (including credentials) is necessary. * headers: { * ...c.req.header(), * 'X-Forwarded-For': '127.0.0.1', * 'X-Forwarded-Host': c.req.header('host'), * Authorization: undefined, // do not propagate request headers contained in c.req.header('Authorization') * }, * }) * }) * * // Strict RFC compliance mode (use only in trusted environments) * app.get('/internal-proxy/:path', (c) => { * return proxy(`http://${internalServer}/${c.req.param('path')}`, { * ...c.req, * strictConnectionProcessing: true, * }) * }) * ``` */ export const proxy: ProxyFetch = async (input, proxyInit) => { const { raw, customFetch, strictConnectionProcessing = false, ...requestInit } = proxyInit instanceof Request ? { raw: proxyInit } : (proxyInit ?? {}) const req = new Request(input, { ...buildRequestInitFromRequest(raw, strictConnectionProcessing), ...preprocessRequestInit(requestInit as RequestInit), }) req.headers.delete('accept-encoding') const res = await (customFetch || fetch)(req) const resHeaders = new Headers(res.headers) hopByHopHeaders.forEach((header) => { resHeaders.delete(header) }) if (resHeaders.has('content-encoding')) { resHeaders.delete('content-encoding') // Content-Length is the size of the compressed content, not the size of the original content resHeaders.delete('content-length') } return new Response(res.body, { status: res.status, statusText: res.statusText, headers: resHeaders, }) } ================================================ FILE: src/helper/route/index.test.ts ================================================ import { Context } from '../../context' import { matchedRoutes, routePath, baseRoutePath, basePath } from '.' const defaultContextOptions = { executionCtx: { waitUntil: () => {}, passThroughOnException: () => {}, props: {}, }, env: {}, } describe('matchedRoutes', () => { it('should return matched routes', () => { const handlerA = () => {} const handlerB = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/123/key', matchResult: [ [ [ [handlerA, { basePath: '/', handler: handlerA, method: 'GET', path: '/:id' }], { id: '123' }, ], [ [handlerA, { basePath: '/', handler: handlerB, method: 'GET', path: '/:id/:name' }], { id: '456', name: 'key' }, ], ], ], ...defaultContextOptions, }) expect(matchedRoutes(c)).toEqual([ { basePath: '/', handler: handlerA, method: 'GET', path: '/:id' }, { basePath: '/', handler: handlerB, method: 'GET', path: '/:id/:name' }, ]) }) }) describe('routePath', () => { it('should return route path', () => { const handlerA = () => {} const handlerB = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/123/key', matchResult: [ [ [ [handlerA, { basePath: '/', handler: handlerA, method: 'GET', path: '/:id' }], { id: '123' }, ], [ [handlerA, { basePath: '/', handler: handlerB, method: 'GET', path: '/:id/:name' }], { id: '456', name: 'key' }, ], ], ], ...defaultContextOptions, }) expect(routePath(c)).toBe('/:id') expect(routePath(c, 0)).toBe('/:id') expect(routePath(c, 1)).toBe('/:id/:name') expect(routePath(c, -1)).toBe('/:id/:name') expect(routePath(c, 2)).toBe('') c.req.routeIndex = 1 expect(routePath(c)).toBe('/:id/:name') }) }) describe('baseRoutePath', () => { it('should return raw basePath', () => { const handlerA = () => {} const handlerB = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/123/key', matchResult: [ [ [ [handlerA, { basePath: '/', handler: handlerA, method: 'GET', path: '/:id' }], { id: '123' }, ], [ [handlerA, { basePath: '/sub', handler: handlerB, method: 'GET', path: '/:id/:name' }], { id: '456', name: 'key' }, ], [ [handlerA, { basePath: '/:sub', handler: handlerB, method: 'GET', path: '/:id/:name' }], { id: '456', name: 'key' }, ], ], ], ...defaultContextOptions, }) expect(baseRoutePath(c)).toBe('/') expect(baseRoutePath(c, 0)).toBe('/') expect(baseRoutePath(c, 1)).toBe('/sub') expect(baseRoutePath(c, 2)).toBe('/:sub') expect(baseRoutePath(c, -1)).toBe('/:sub') expect(baseRoutePath(c, 3)).toBe('') c.req.routeIndex = 1 expect(baseRoutePath(c)).toBe('/sub') c.req.routeIndex = 2 expect(baseRoutePath(c)).toBe('/:sub') }) }) describe('basePath', () => { it('should return basePath without parameters', () => { const handlerA = () => {} const handlerB = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/123/key', matchResult: [ [ [ [handlerA, { basePath: '/', handler: handlerA, method: 'GET', path: '/:id' }], { id: '123' }, ], [ [handlerA, { basePath: '/sub', handler: handlerB, method: 'GET', path: '/:id/:name' }], { id: '456', name: 'key' }, ], ], ], ...defaultContextOptions, }) expect(basePath(c)).toBe('/') expect(basePath(c)).toBe('/') // cached value expect(basePath(c, 0)).toBe('/') expect(basePath(c, 1)).toBe('/sub') expect(basePath(c, -1)).toBe('/sub') expect(basePath(c, 2)).toBe('') c.req.routeIndex = 1 expect(basePath(c)).toBe('/sub') }) it('should return basePath with embedded parameters', () => { const handlerA = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/sub-app-path/123/key', matchResult: [ [ [ [handlerA, { basePath: '/:sub', handler: handlerA, method: 'GET', path: '/:id' }], { id: '123' }, ], ], ], ...defaultContextOptions, }) expect(basePath(c)).toBe('/sub-app-path') }) it('should return basePath with wildcard', () => { const handlerA = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/sub-app-path/foo/app/123/key', matchResult: [ [ [ [ handlerA, { basePath: '/sub-app-path/*/app', handler: handlerA, method: 'GET', path: '/:id' }, ], { id: '123' }, ], ], ], ...defaultContextOptions, }) expect(basePath(c)).toBe('/sub-app-path/foo/app') }) it('should return basePath with custom regex pattern', () => { const handlerA = () => {} const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') const c = new Context(rawRequest, { path: '/sub-app-path/foo/123/key', matchResult: [ [ [ [ handlerA, { basePath: '/sub-app-path/:sub{foo|bar}', handler: handlerA, method: 'GET', path: '/:id', }, ], { sub: 'foo', id: '123' }, ], ], ], ...defaultContextOptions, }) expect(basePath(c)).toBe('/sub-app-path/foo') }) }) ================================================ FILE: src/helper/route/index.ts ================================================ import type { Context } from '../../context' import { GET_MATCH_RESULT } from '../../request/constants' import type { RouterRoute } from '../../types' import { getPattern, splitRoutingPath } from '../../utils/url' /** * Get matched routes in the handler * * @param {Context} c - The context object * @returns An array of matched routes * * @example * ```ts * import { matchedRoutes } from 'hono/route' * * app.use('*', async function logger(c, next) { * await next() * matchedRoutes(c).forEach(({ handler, method, path }, i) => { * const name = handler.name || (handler.length < 2 ? '[handler]' : '[middleware]') * console.log( * method, * ' ', * path, * ' '.repeat(Math.max(10 - path.length, 0)), * name, * i === c.req.routeIndex ? '<- respond from here' : '' * ) * }) * }) * ``` */ export const matchedRoutes = (c: Context): RouterRoute[] => // @ts-expect-error c.req[GET_MATCH_RESULT] is not typed (c.req as unknown)[GET_MATCH_RESULT][0].map(([[, route]]) => route) /** * Get the route path registered within the handler * * @param {Context} c - The context object * @param {number} index - The index of the root from which to retrieve the path, similar to Array.prototype.at(), where a negative number is the index counted from the end of the matching root. Defaults to the current root index. * @returns The route path registered within the handler * * @example * ```ts * import { routePath } from 'hono/route' * * app.use('*', (c, next) => { * console.log(routePath(c)) // '*' * console.log(routePath(c, -1)) // '/posts/:id' * return next() * }) * * app.get('/posts/:id', (c) => { * return c.text(routePath(c)) // '/posts/:id' * }) * ``` */ export const routePath = (c: Context, index?: number): string => matchedRoutes(c).at(index ?? c.req.routeIndex)?.path ?? '' /** * Get the basePath of the as-is route specified by routing. * * @param {Context} c - The context object * @param {number} index - The index of the root from which to retrieve the path, similar to Array.prototype.at(), where a negative number is the index counted from the end of the matching root. Defaults to the current root index. * @returns The basePath of the as-is route specified by routing. * * @example * ```ts * import { baseRoutePath } from 'hono/route' * * const app = new Hono() * * const subApp = new Hono() * subApp.get('/posts/:id', (c) => { * return c.text(baseRoutePath(c)) // '/:sub' * }) * * app.route('/:sub', subApp) * ``` */ export const baseRoutePath = (c: Context, index?: number): string => matchedRoutes(c).at(index ?? c.req.routeIndex)?.basePath ?? '' /** * Get the basePath with embedded parameters * * @param {Context} c - The context object * @param {number} index - The index of the root from which to retrieve the path, similar to Array.prototype.at(), where a negative number is the index counted from the end of the matching root. Defaults to the current root index. * @returns The basePath with embedded parameters. * * @example * ```ts * import { basePath } from 'hono/route' * * const app = new Hono() * * const subApp = new Hono() * subApp.get('/posts/:id', (c) => { * return c.text(basePath(c)) // '/requested-sub-app-path' * }) * * app.route('/:sub', subApp) * ``` */ const basePathCacheMap: WeakMap> = new WeakMap() export const basePath = (c: Context, index?: number): string => { index ??= c.req.routeIndex const cache = basePathCacheMap.get(c) || [] if (typeof cache[index] === 'string') { return cache[index] } let result: string const rp = baseRoutePath(c, index) if (!/[:*]/.test(rp)) { result = rp } else { const paths = splitRoutingPath(rp) const reqPath = c.req.path let basePathLength = 0 for (let i = 0, len = paths.length; i < len; i++) { const pattern = getPattern(paths[i], paths[i + 1]) if (pattern) { const re = pattern[2] === true || pattern === '*' ? /[^\/]+/ : pattern[2] basePathLength += reqPath.substring(basePathLength + 1).match(re)?.[0].length || 0 } else { basePathLength += paths[i].length } basePathLength += 1 // for '/' } result = reqPath.substring(0, basePathLength) } cache[index] = result basePathCacheMap.set(c, cache) return result } ================================================ FILE: src/helper/ssg/index.ts ================================================ /** * @module * SSG Helper for Hono. */ export * from './ssg' export { X_HONO_DISABLE_SSG_HEADER_KEY, ssgParams, isSSGContext, disableSSG, onlySSG, } from './middleware' export { defaultPlugin, redirectPlugin } from './plugins' ================================================ FILE: src/helper/ssg/middleware.ts ================================================ import type { Context } from '../../context' import type { Env, MiddlewareHandler } from '../../types' import { isDynamicRoute } from './utils' export const SSG_CONTEXT = 'HONO_SSG_CONTEXT' export const X_HONO_DISABLE_SSG_HEADER_KEY = 'x-hono-disable-ssg' /** * @deprecated * Use `X_HONO_DISABLE_SSG_HEADER_KEY` instead. * This constant will be removed in the next minor version. */ export const SSG_DISABLED_RESPONSE = (() => { try { return new Response('SSG is disabled', { status: 404, headers: { [X_HONO_DISABLE_SSG_HEADER_KEY]: 'true' }, }) } catch { return null } })() as Response interface SSGParam { [key: string]: string } export type SSGParams = SSGParam[] interface SSGParamsMiddleware { ( generateParams: (c: Context) => SSGParams | Promise ): MiddlewareHandler (params: SSGParams): MiddlewareHandler } export type AddedSSGDataRequest = Request & { ssgParams?: SSGParams } /** * Define SSG Route */ export const ssgParams: SSGParamsMiddleware = (params) => async (c, next) => { if (isDynamicRoute(c.req.path)) { ;(c.req.raw as AddedSSGDataRequest).ssgParams = Array.isArray(params) ? params : await params(c) return c.notFound() // Prevent subsequent handler execution after ssgParams } await next() } /** * @experimental * `isSSGContext` is an experimental feature. * The API might be changed. */ export const isSSGContext = (c: Context): boolean => !!c.env?.[SSG_CONTEXT] /** * @experimental * `disableSSG` is an experimental feature. * The API might be changed. */ export const disableSSG = (): MiddlewareHandler => async function disableSSG(c, next) { if (isSSGContext(c)) { c.header(X_HONO_DISABLE_SSG_HEADER_KEY, 'true') return c.notFound() } await next() } /** * @experimental * `onlySSG` is an experimental feature. * The API might be changed. */ export const onlySSG = (): MiddlewareHandler => async function onlySSG(c, next) { if (!isSSGContext(c)) { return c.notFound() } await next() } ================================================ FILE: src/helper/ssg/plugins.test.tsx ================================================ import { Hono } from '../../hono' import type { RedirectStatusCode, StatusCode } from '../../utils/http-status' import * as plugins from './plugins' import { toSSG } from './ssg' import type { FileSystemModule } from './ssg' const { defaultPlugin, redirectPlugin } = plugins describe('Built-in SSG plugins', () => { let app: Hono let fsMock: FileSystemModule beforeEach(() => { app = new Hono() app.get('/', (c) => c.html('

Home

')) app.get('/about', (c) => c.html('

About

')) app.get('/blog', (c) => c.html('

Blog

')) app.get('/created', (c) => c.text('201 Created', 201)) app.get('/redirect', (c) => c.redirect('/')) app.get('/notfound', (c) => c.notFound()) app.get('/error', (c) => c.text('500 Error', 500)) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) describe('default plugin', () => { it('uses defaultPlugin when plugins option is omitted', async () => { const defaultPluginSpy = vi.spyOn(plugins, 'defaultPlugin') await toSSG(app, fsMock, { dir: './static' }) expect(defaultPluginSpy).toHaveBeenCalled() defaultPluginSpy.mockRestore() }) it('skips non-200 responses with defaultPlugin', async () => { const result = await toSSG(app, fsMock, { plugins: [defaultPlugin()], dir: './static' }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '

Home

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '

About

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/blog.html', '

Blog

') expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/created.txt', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/redirect.txt', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/notfound.txt', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/error.txt', expect.any(String)) expect(result.files.some((f) => f.includes('created'))).toBe(false) expect(result.files.some((f) => f.includes('redirect'))).toBe(false) expect(result.files.some((f) => f.includes('notfound'))).toBe(false) expect(result.files.some((f) => f.includes('error'))).toBe(false) expect(result.success).toBe(true) }) }) describe('redirect plugin', () => { it('generates redirect HTML for status codes requiring Location per HTTP Semantics specification', async () => { const statusCodes = [301, 302, 303, 307, 308] satisfies RedirectStatusCode[] for (const statusCode of statusCodes) { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get('/old', (c) => c.redirect('/new', statusCode)) // Default is 302 redirectApp.get('/new', (c) => c.html('New Page')) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) expect(writtenFiles['static/old.html']).toBeDefined() const content = writtenFiles['static/old.html'] // Should contain meta refresh expect(content).toContain('meta http-equiv="refresh" content="0;url=/new"') // Should contain canonical expect(content).toContain('rel="canonical" href="/new"') // Should contain robots noindex expect(content).toContain('') // Should contain link anchor expect(content).toContain('Redirecting to /new') // Should contain a body element that includes the anchor expect(content).toMatch(/]*>[\s\S]*[\s\S]*<\/body>/) } }) it('skips generating redirect HTML for status codes requiring Location when Location header is missing', async () => { const statusCodes = [301, 302, 303, 307, 308] satisfies RedirectStatusCode[] for (const statusCode of statusCodes) { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get('/bad', () => new Response(null, { status: statusCode })) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) expect(writtenFiles['static/bad.html']).toBeUndefined() } }) it('skips generating redirect HTML for status codes not requiring Location per HTTP Semantics specification', async () => { const statusCodes = [300, 304, 305, 306] satisfies RedirectStatusCode[] for (const statusCode of statusCodes) { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get( '/response', () => new Response(null, { status: statusCode, headers: { Location: '/' } }) ) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) expect(writtenFiles['static/response.html']).toBeUndefined() } }) it('does not apply redirect HTML for non-redirect status codes even with Location header', async () => { const statusCodes = [200, 201, 400, 404, 500] satisfies Exclude< StatusCode, RedirectStatusCode >[] for (const statusCode of statusCodes) { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get( '/response', () => new Response('Response Body', { status: statusCode, headers: { Location: '/' } }) ) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) expect(writtenFiles['static/response.txt']).toBeDefined() expect(writtenFiles['static/response.txt']).toBe('Response Body') } }) it('escapes Location header values when generating redirect HTML', async () => { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const maliciousLocation = '/new"> ' const redirectApp = new Hono() redirectApp.get( '/evil', () => new Response(null, { status: 301, headers: { Location: maliciousLocation } }) ) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) const content = writtenFiles['static/evil.html'] expect(content).toBeDefined() expect(content).not.toContain('') expect(content).toContain('<script>alert(1)</script>') expect(content).toContain('"') }) it('redirectPlugin before defaultPlugin generates redirect HTML', async () => { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get('/old', (c) => c.redirect('/new')) redirectApp.get('/new', (c) => c.html('New Page')) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin(), defaultPlugin()], }) expect(writtenFiles['static/old.html']).toBeDefined() }) it('redirectPlugin after defaultPlugin does not generate redirect HTML', async () => { const writtenFiles: Record = {} const fsMockLocal: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const redirectApp = new Hono() redirectApp.get('/old', (c) => c.redirect('/new')) redirectApp.get('/new', (c) => c.html('New Page')) await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [defaultPlugin(), redirectPlugin()], }) expect(writtenFiles['static/old.html']).toBeUndefined() }) }) }) ================================================ FILE: src/helper/ssg/plugins.ts ================================================ import { html } from '../html' import type { SSGPlugin } from './ssg' /** * The default plugin that defines the recommended behavior. * * @experimental * `defaultPlugin` is an experimental feature. * The API might be changed. */ export const defaultPlugin = (): SSGPlugin => { return { afterResponseHook: (res) => { if (res.status !== 200) { return false } return res }, } } const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]) const generateRedirectHtml = (location: string) => { // prettier-ignore const content = html` Redirecting to: ${location} Redirecting to ${location} ` return content.toString().replace(/\n/g, '') } /** * The redirect plugin that generates HTML redirect pages for HTTP redirect responses for status codes 301, 302, 303, 307 and 308. * * When used with `defaultPlugin`, place `redirectPlugin` before it, because `defaultPlugin` skips non-200 responses. * * ```ts * // ✅ Will work as expected * toSSG(app, fs, { plugins: [redirectPlugin(), defaultPlugin()] }) * * // ❌ Will not work as expected * toSSG(app, fs, { plugins: [defaultPlugin(), redirectPlugin()] }) * ``` * * @experimental * `redirectPlugin` is an experimental feature. * The API might be changed. */ export const redirectPlugin = (): SSGPlugin => { return { afterResponseHook: (res) => { if (REDIRECT_STATUS_CODES.has(res.status)) { const location = res.headers.get('Location') if (!location) { return false } const htmlBody = generateRedirectHtml(location) return new Response(htmlBody, { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' }, }) } return res }, } } ================================================ FILE: src/helper/ssg/ssg.test.tsx ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ /** @jsxImportSource ../../jsx */ import { Hono } from '../../hono' import { poweredBy } from '../../middleware/powered-by' import { X_HONO_DISABLE_SSG_HEADER_KEY, disableSSG, isSSGContext, onlySSG, ssgParams, } from './middleware' import { defaultExtensionMap, fetchRoutesContent, saveContentToFile, toSSG } from './ssg' import type { AfterGenerateHook, AfterResponseHook, BeforeRequestHook, FileSystemModule, ToSSGResult, SSGPlugin, ToSSGOptions, } from './ssg' const resolveRoutesContent = async (res: ReturnType) => { const htmlMap = new Map() for (const getInfoPromise of res) { const getInfo = await getInfoPromise if (!getInfo) { continue } for (const dataPromise of getInfo) { const data = await dataPromise if (!data) { continue } htmlMap.set(data.routePath, { content: data.content, mimeType: data.mimeType, }) } } return htmlMap } describe('toSSG function', () => { let app: Hono let fsMock: FileSystemModule const postParams = [{ post: '1' }, { post: '2' }] beforeEach(() => { app = new Hono() app.all('/', (c) => c.html('Hello, World!')) app.get('/about', (c) => c.html('About Page')) app.get('/about/some', (c) => c.text('About Page 2tier')) app.post('/about/some/thing', (c) => c.text('About Page 3tier')) app.get('/bravo', (c) => c.html('Bravo Page')) app.get('/Charlie', async (c, next) => { c.setRenderer((content, head) => { return c.html( {head.title || ''}

{content}

) }) await next() }) app.get('/Charlie', (c) => { return c.render('Hello!', { title: 'Charlies Page' }) }) // Included params app.get( '/post/:post', ssgParams(() => postParams), (c) => c.html(

{c.req.param('post')}

) ) app.get( '/user/:user_id', ssgParams([{ user_id: '1' }, { user_id: '2' }, { user_id: '3' }]), (c) => c.html(

{c.req.param('user_id')}

) ) type Env = { Bindings: { FOO_DB: string } Variables: { FOO_VAR: string } } app.get( '/env-type-check', ssgParams((c) => { expectTypeOf().toBeString() expectTypeOf().toBeString() return [] }) ) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('Should correctly generate static HTML files for Hono routes', async () => { const writtenFiles: Record = {} const fsMock: FileSystemModule = { writeFile: (path, data) => { writtenFiles[path] = typeof data === 'string' ? data : data.toString() return Promise.resolve() }, mkdir: vi.fn(() => Promise.resolve()), } const result = await toSSG(app, fsMock, { dir: './static' }) for (const postParam of postParams) { const html = writtenFiles[`static/post/${postParam.post}.html`] expect(html).toBe(`

${postParam.post}

`) } for (let i = 1; i <= 3; i++) { const html = writtenFiles[`static/user/${i}.html`] expect(html).toBe(`

${i}

`) } expect(result.files.length).toBe(10) expect(fsMock.mkdir).toHaveBeenCalledWith(expect.any(String), { recursive: true, }) }) it('Should handle file system errors correctly in saveContentToFiles', async () => { const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.reject(new Error('Write error'))), mkdir: vi.fn(() => Promise.resolve()), } const result = await toSSG(app, fsMock, { dir: './static' }) expect(result.success).toBe(false) expect(result.files).toStrictEqual([]) expect(result.error?.message).toBe('Write error') }) it('Should handle overall process errors correctly in toSSG', async () => { const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.reject(new Error('Write error'))), mkdir: vi.fn(() => Promise.resolve()), } const result = await toSSG(app, fsMock, { dir: './static' }) expect(result.success).toBe(false) expect(result.error).toBeDefined() expect(result.files).toStrictEqual([]) }) it('Should correctly generate files with the expected paths', async () => { app.get('/data', (c) => c.text(JSON.stringify({ title: 'hono' }), 200, { 'Content-Type': 'text/x-foo', }) ) await toSSG(app, fsMock, { dir: './static', extensionMap: { ...defaultExtensionMap, 'text/x-foo': 'foo', }, }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/about/some.txt', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith( 'static/about/some/thing.txt', expect.any(String) ) expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/bravo.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/Charlie.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/data.foo', expect.any(String)) }) it('should modify the request if the hook is provided', async () => { const beforeRequestHook: BeforeRequestHook = (req) => { if (req.method === 'GET') { return req } return false } const result = await toSSG(app, fsMock, { beforeRequestHook }) expect(result.files).toHaveLength(10) }) it('should skip the route if the request hook returns false', async () => { const beforeRequest: BeforeRequestHook = () => false const result = await toSSG(app, fsMock, { beforeRequestHook: beforeRequest }) expect(result.success).toBe(true) expect(result.files).toStrictEqual([]) }) it('should modify the response if the hook is provided', async () => { const afterResponseHook: AfterResponseHook = (res) => { if (res.status === 200 || res.status === 500) { return res } return false } const result = await toSSG(app, fsMock, { afterResponseHook }) expect(result.files).toHaveLength(10) }) it('should skip the route if the response hook returns false', async () => { const afterResponse: AfterResponseHook = () => false const result = await toSSG(app, fsMock, { afterResponseHook: afterResponse }) expect(result.success).toBe(true) expect(result.files).toStrictEqual([]) }) it('should execute additional processing using afterGenerateHook', async () => { const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } const afterGenerateHookMock: AfterGenerateHook = vi.fn( (result, fsModule, options) => { if (result.files) { result.files.forEach((file) => console.log(file)) } } ) await toSSG(app, fsMock, { dir: './static', afterGenerateHook: afterGenerateHookMock }) expect(afterGenerateHookMock).toHaveBeenCalled() expect(afterGenerateHookMock).toHaveBeenCalledWith( expect.anything(), // result expect.anything(), // fsModule expect.anything() // options ) }) it('should handle asynchronous beforeRequestHook correctly', async () => { const beforeRequestHook: BeforeRequestHook = async (req) => { await new Promise((resolve) => setTimeout(resolve, 10)) if (req.url.includes('/skip')) { return false } return req } const result = await toSSG(app, fsMock, { beforeRequestHook }) expect(result.files).not.toContain(expect.stringContaining('/skip')) expect(result.success).toBe(true) expect(result.files.length).toBeGreaterThan(0) }) it('should handle asynchronous afterResponseHook correctly', async () => { const afterResponseHook: AfterResponseHook = async (res) => { await new Promise((resolve) => setTimeout(resolve, 10)) if (res.headers.get('X-Skip') === 'true') { return false } return res } const result = await toSSG(app, fsMock, { afterResponseHook }) expect(result.files).not.toContain(expect.stringContaining('/skip')) expect(result.success).toBe(true) expect(result.files.length).toBeGreaterThan(0) }) it('should handle asynchronous afterGenerateHook correctly', async () => { const afterGenerateHook: AfterGenerateHook = async (result, fsModule, options) => { await new Promise((resolve) => setTimeout(resolve, 10)) console.log(`Generated ${result.files.length} files.`) } const result = await toSSG(app, fsMock, { afterGenerateHook }) expect(result.success).toBe(true) expect(result.files.length).toBeGreaterThan(0) }) it('should avoid memory leak from `req.signal.addEventListener()`', async () => { const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } const signalAddEventListener = vi.fn(() => {}) const app = new Hono() app.get('/post/:post', ssgParams([{ post: '1' }, { post: '2' }]), (c) => c.html(

{c.req.param('post')}

) ) await toSSG(app, fsMock, { beforeRequestHook: (req) => { req.signal.addEventListener = signalAddEventListener return req }, }) expect(signalAddEventListener).not.toHaveBeenCalled() }) }) describe('fetchRoutesContent function', () => { let app: Hono beforeEach(() => { app = new Hono() app.get('/text', (c) => c.text('Text Response')) app.get('/text-utf8', (c) => { return c.text('Text Response', 200, { 'Content-Type': 'text/plain;charset=UTF-8' }) }) app.get('/html', (c) => c.html('

HTML Response

')) app.get('/json', (c) => c.json({ message: 'JSON Response' })) app.use('*', poweredBy()) }) it('should fetch the correct content and MIME type for each route', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.get('/text')).toEqual({ content: 'Text Response', mimeType: 'text/plain', }) expect(htmlMap.get('/text-utf8')).toEqual({ content: 'Text Response', mimeType: 'text/plain', }) expect(htmlMap.get('/html')).toEqual({ content: '

HTML Response

', mimeType: 'text/html', }) expect(htmlMap.get('/json')).toEqual({ content: '{"message":"JSON Response"}', mimeType: 'application/json', }) }) it('should skip middleware routes', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.has('*')).toBeFalsy() }) it('should handle errors correctly', async () => { vi.spyOn(app, 'fetch').mockRejectedValue(new Error('Network error')) await expect(resolveRoutesContent(fetchRoutesContent(app))).rejects.toThrow('Network error') vi.restoreAllMocks() }) }) describe('saveContentToFile function', () => { // tar.gz, testdir/test.txt const gzFileBuffer = Buffer.from( 'H4sIAAAAAAAAA+3SQQrCMBSE4aw9RU6gSc3LO0/FLgqukgj29qZgsQgqCEHE/9vMIoEMTMqQy3FMO9OQq1RkTq/i1rkwPkiMUXWvnXG+U/XGSstSi3MufbLWHIZ0mvLYP7v37vxHldv+c27LpbR4Yx44hvBi/3DfX3zdP0j9Eta1KPPoz/ef+mnz7Q4AAAAAAAAAAAAAAAAAPnMFqt1/BQAoAAA=', 'base64' ) const gzFileArrayBuffer = gzFileBuffer.buffer.slice( gzFileBuffer.byteOffset, gzFileBuffer.byteLength + gzFileBuffer.byteOffset ) // PNG, red dot (1x1) const pngFileBuffer = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCCAAAALw', 'base64' ) const pngFileArrayBuffer = pngFileBuffer.buffer.slice( pngFileBuffer.byteOffset, pngFileBuffer.byteLength + pngFileBuffer.byteOffset ) const fileData = [ { routePath: '/', content: 'Home Page', mimeType: 'text/html' }, { routePath: '/index.html', content: 'Home Page2', mimeType: 'text/html' }, { routePath: '/about', content: 'About Page', mimeType: 'text/html' }, { routePath: '/about/', content: 'About Page', mimeType: 'text/html' }, { routePath: '/bravo/index.html', content: 'About Page', mimeType: 'text/html' }, { routePath: '/bravo/release-4.0.0', content: 'Release 4.0.0', mimeType: 'text/html' }, { routePath: '/bravo/2024.02.18-sweet-memories', content: 'Sweet Memories', mimeType: 'text/html', }, { routePath: '/bravo/deep.dive.to.html', content: 'Deep Dive To HTML', mimeType: 'text/html' }, { routePath: '/bravo/alert.js', content: 'alert("evil content")', mimeType: 'text/html' }, { routePath: '/bravo.text/index.html', content: 'About Page', mimeType: 'text/html' }, { routePath: '/bravo.text/', content: 'Bravo Page', mimeType: 'text/html' }, { routePath: '/bravo/index.tar.gz', content: gzFileArrayBuffer, mimeType: 'application/gzip', }, { routePath: '/bravo/dot.png', content: pngFileArrayBuffer, mimeType: 'image/png', }, ] let fsMock: FileSystemModule beforeEach(() => { fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('should correctly create files with the right content and paths', async () => { for (const data of fileData) { await saveContentToFile(Promise.resolve(data), fsMock, './static') } expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', 'Home Page') expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', 'Home Page2') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', 'About Page') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about/index.html', 'About Page') expect(fsMock.writeFile).toHaveBeenCalledWith('static/bravo/index.html', 'About Page') expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/release-4.0.0.html', 'Release 4.0.0' ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/deep.dive.to.html', 'Deep Dive To HTML' ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/2024.02.18-sweet-memories.html', 'Sweet Memories' ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/alert.js.html', 'alert("evil content")' ) expect(fsMock.writeFile).toHaveBeenCalledWith('static/bravo.text/index.html', 'About Page') expect(fsMock.writeFile).toHaveBeenCalledWith('static/bravo.text/index.html', 'Bravo Page') // binary files expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/index.tar.gz', new Uint8Array(gzFileArrayBuffer) ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/bravo/dot.png', new Uint8Array(pngFileArrayBuffer) ) }) it('should correctly create directories if they do not exist', async () => { await saveContentToFile( Promise.resolve({ routePath: '/new-dir/index.html', content: 'New Page', mimeType: 'text/html', }), fsMock, './static' ) expect(fsMock.mkdir).toHaveBeenCalledWith('static/new-dir', { recursive: true }) }) it('should handle file writing or directory creation errors', async () => { const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.reject(new Error('File write error'))), } await expect( saveContentToFile( Promise.resolve({ routePath: '/error-dir/index.html', content: 'New Page', mimeType: 'text/html', }), fsMock, './static' ) ).rejects.toThrow('File write error') }) it('check extensions', async () => { for (const data of fileData) { await saveContentToFile(Promise.resolve(data), fsMock, './static-check-extensions') } expect(fsMock.mkdir).toHaveBeenCalledWith('static-check-extensions', { recursive: true }) }) it('should correctly create .yaml files for YAML content', async () => { const yamlContent = 'title: YAML Example\nvalue: This is a YAML file.' const mimeType = 'application/yaml' const routePath = '/example' const yamlData = { routePath: routePath, content: yamlContent, mimeType: mimeType, } const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } await saveContentToFile(Promise.resolve(yamlData), fsMock, './static') expect(fsMock.writeFile).toHaveBeenCalledWith('static/example.yaml', yamlContent) }) it('should correctly create .yml files for YAML content', async () => { const yamlContent = 'title: YAML Example\nvalue: This is a YAML file.' const yamlMimeType = 'application/yaml' const yamlRoutePath = '/yaml' const yamlData = { routePath: yamlRoutePath, content: yamlContent, mimeType: yamlMimeType, } const yamlMimeType2 = 'x-yaml' const yamlRoutePath2 = '/yaml2' const yamlData2 = { routePath: yamlRoutePath2, content: yamlContent, mimeType: yamlMimeType2, } const htmlMimeType = 'text/html' const htmlRoutePath = '/html' const htmlData = { routePath: htmlRoutePath, content: yamlContent, mimeType: htmlMimeType, } const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } const extensionMap = { 'application/yaml': 'yml', 'x-yaml': 'xyml', } await saveContentToFile(Promise.resolve(yamlData), fsMock, './static', extensionMap) await saveContentToFile(Promise.resolve(yamlData2), fsMock, './static', extensionMap) await saveContentToFile(Promise.resolve(htmlData), fsMock, './static', extensionMap) await saveContentToFile(Promise.resolve(htmlData), fsMock, './static', { ...defaultExtensionMap, ...extensionMap, }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/yaml.yml', yamlContent) expect(fsMock.writeFile).toHaveBeenCalledWith('static/yaml2.xyml', yamlContent) expect(fsMock.writeFile).toHaveBeenCalledWith('static/html.htm', yamlContent) // extensionMap expect(fsMock.writeFile).toHaveBeenCalledWith('static/html.html', yamlContent) // default + extensionMap }) }) describe('Dynamic route handling', () => { let app: Hono beforeEach(() => { app = new Hono() app.get('/shops/:id', (c) => c.html('Shop Page')) app.get('/shops/:id/:comments([0-9]+)', (c) => c.html('Comments Page')) app.get('/foo/*', (c) => c.html('Foo Page')) app.get('/foo:bar', (c) => c.html('Foo Bar Page')) }) it('should skip /shops/:id dynamic route', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.has('/shops/:id')).toBeFalsy() }) it('should skip /shops/:id/:comments([0-9]+) dynamic route', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.has('/shops/:id/:comments([0-9]+)')).toBeFalsy() }) it('should skip /foo/* dynamic route', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.has('/foo/*')).toBeFalsy() }) it('should not skip /foo:bar dynamic route', async () => { const htmlMap = await resolveRoutesContent(fetchRoutesContent(app)) expect(htmlMap.has('/foo:bar')).toBeTruthy() }) }) describe('isSSGContext()', () => { const app = new Hono() app.get('/', (c) => c.html(

{isSSGContext(c) ? 'SSG' : 'noSSG'}

)) const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } it('Should not generate the page if disableSSG is set', async () => { await toSSG(app, fsMock, { dir: './static' }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '

SSG

') }) it('Should return 404 response if onlySSG() is set', async () => { const res = await app.request('/') expect(await res.text()).toBe('

noSSG

') }) }) describe('disableSSG/onlySSG middlewares', () => { const app = new Hono() app.get('/', (c) => c.html(

Hello

)) app.get('/api', disableSSG(), (c) => c.text('an-api')) app.get('/disable-by-response', (c) => c.text('', 404, { [X_HONO_DISABLE_SSG_HEADER_KEY]: 'true' }) ) app.get('/static-page', onlySSG(), (c) => c.html(

Welcome to my site

)) const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } it('Should not generate the page if disableSSG is set', async () => { await toSSG(app, fsMock, { dir: './static' }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', expect.any(String)) expect(fsMock.writeFile).toHaveBeenCalledWith('static/static-page.html', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/api.html', expect.any(String)) expect(fsMock.writeFile).not.toHaveBeenCalledWith( 'static/disable-by-response.html', expect.any(String) ) }) it('Should return 404 response if onlySSG() is set', async () => { const res = await app.request('/static-page') expect(res.status).toBe(404) }) }) describe('Request hooks - filterPathsBeforeRequestHook and denyPathsBeforeRequestHook', () => { let app: Hono let fsMock: FileSystemModule const filterPathsBeforeRequestHook = (allowedPaths: string | string[]): BeforeRequestHook => { const baseURL = 'http://localhost' return async (req: Request): Promise => { const paths = Array.isArray(allowedPaths) ? allowedPaths : [allowedPaths] const pathname = new URL(req.url, baseURL).pathname if (paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { return req } return false } } const denyPathsBeforeRequestHook = (deniedPaths: string | string[]): BeforeRequestHook => { const baseURL = 'http://localhost' return async (req: Request): Promise => { const paths = Array.isArray(deniedPaths) ? deniedPaths : [deniedPaths] const pathname = new URL(req.url, baseURL).pathname if (!paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { return req } return false } } beforeEach(() => { app = new Hono() app.get('/allowed-path', (c) => c.html('Allowed Path Page')) app.get('/denied-path', (c) => c.html('Denied Path Page')) app.get('/other-path', (c) => c.html('Other Path Page')) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('should only process requests for allowed paths with filterPathsBeforeRequestHook', async () => { const allowedPathsHook = filterPathsBeforeRequestHook(['/allowed-path']) const result = await toSSG(app, fsMock, { dir: './static', beforeRequestHook: allowedPathsHook, }) expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) expect(result.files.some((file) => file.includes('other-path.html'))).toBe(false) }) it('should deny requests for specified paths with denyPathsBeforeRequestHook', async () => { const deniedPathsHook = denyPathsBeforeRequestHook(['/denied-path']) const result = await toSSG(app, fsMock, { dir: './static', beforeRequestHook: deniedPathsHook }) expect(result.files.some((file) => file.includes('denied-path.html'))).toBe(false) expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) expect(result.files.some((file) => file.includes('other-path.html'))).toBe(true) }) }) describe('Combined Response hooks - modify response content', () => { let app: Hono let fsMock: FileSystemModule const prependContentAfterResponseHook = (prefix: string): AfterResponseHook => { return async (res: Response): Promise => { const originalText = await res.text() return new Response(`${prefix}${originalText}`, res) } } const appendContentAfterResponseHook = (suffix: string): AfterResponseHook => { return async (res: Response): Promise => { const originalText = await res.text() return new Response(`${originalText}${suffix}`, res) } } beforeEach(() => { app = new Hono() app.get('/content-path', (c) => c.text('Original Content')) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('should modify response content with combined AfterResponseHooks', async () => { const prefixHook = prependContentAfterResponseHook('Prefix-') const suffixHook = appendContentAfterResponseHook('-Suffix') const combinedHook = [prefixHook, suffixHook] await toSSG(app, fsMock, { dir: './static', afterResponseHook: combinedHook, }) // Assert that the response content is modified by both hooks // This assumes you have a way to inspect the content of saved files or you need to mock/stub the Response text method correctly. expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/content-path.txt', 'Prefix-Original Content-Suffix' ) }) }) describe('Combined Generate hooks - AfterGenerateHook', () => { let app: Hono let fsMock: FileSystemModule const logResultAfterGenerateHook = (): AfterGenerateHook => { return async ( result: ToSSGResult, fsModule: FileSystemModule, options?: ToSSGOptions ): Promise => { console.log('Generation completed with status:', result.success) // Log the generation success } } const appendFilesAfterGenerateHook = (additionalFiles: string[]): AfterGenerateHook => { return async ( result: ToSSGResult, fsModule: FileSystemModule, options?: ToSSGOptions ): Promise => { result.files = result.files.concat(additionalFiles) // Append additional files to the result } } beforeEach(() => { app = new Hono() app.get('/path', (c) => c.text('Page Content')) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('should execute combined AfterGenerateHooks affecting the result', async () => { const logHook = logResultAfterGenerateHook() const appendHook = appendFilesAfterGenerateHook(['/extra/file1.html', '/extra/file2.html']) const combinedHook = [logHook, appendHook] const consoleSpy = vi.spyOn(console, 'log') const result = await toSSG(app, fsMock, { dir: './static', afterGenerateHook: combinedHook, }) // Check that the log function was called correctly expect(consoleSpy).toHaveBeenCalledWith('Generation completed with status:', true) // Check that additional files were appended to the result expect(result.files).toContain('/extra/file1.html') expect(result.files).toContain('/extra/file2.html') }) }) describe('SSG Plugin System', () => { let app: Hono let fsMock: FileSystemModule beforeEach(() => { app = new Hono() app.get('/', (c) => c.html('

Home

')) app.get('/about', (c) => c.html('

About

')) app.get('/blog', (c) => c.html('

Blog

')) app.get('/created', (c) => c.text('201 Created', 201)) app.get('/redirect', (c) => c.redirect('/')) app.get('/notfound', (c) => c.notFound()) app.get('/error', (c) => c.text('500 Error', 500)) fsMock = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } }) it('should correctly apply plugins with beforeRequestHook', async () => { const plugin: SSGPlugin = { beforeRequestHook: (req) => { // Skip requests to the blog page const url = new URL(req.url) if (url.pathname === '/blog') { return false } return req }, } const result = await toSSG(app, fsMock, { plugins: [plugin], }) expect(result.files).toHaveLength(6) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '

Home

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '

About

') expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/blog.html', '

Blog

') }) it('should correctly apply plugins with afterResponseHook', async () => { const plugin: SSGPlugin = { afterResponseHook: async (res) => { const text = await res.text() return new Response(text.replace('', ' - Modified'), res) }, } await toSSG(app, fsMock, { plugins: [plugin], }) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '

Home - Modified

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '

About - Modified

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/blog.html', '

Blog - Modified

') }) it('should correctly apply plugins with afterGenerateHook', async () => { const additionalFiles = ['sitemap.xml', 'robots.txt'] const plugin: SSGPlugin = { afterGenerateHook: (result) => { result.files.push(...additionalFiles) }, } const result = await toSSG(app, fsMock, { plugins: [plugin], }) expect(result.files).toContain('sitemap.xml') expect(result.files).toContain('robots.txt') }) it('should correctly combine multiple plugins', async () => { const skipBlogPlugin: SSGPlugin = { beforeRequestHook: (req) => { const url = new URL(req.url) if (url.pathname === '/blog') { return false } return req }, } const prefixPlugin: SSGPlugin = { afterResponseHook: async (res) => { const text = await res.text() return new Response(`[Prefix] ${text}`, res) }, } const sitemapPlugin: SSGPlugin = { afterGenerateHook: (result, fsModule, options) => { result.files.push('sitemap.xml') }, } const result = await toSSG(app, fsMock, { plugins: [skipBlogPlugin, prefixPlugin, sitemapPlugin], }) expect(result.files).toHaveLength(7) expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '[Prefix]

Home

') expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '[Prefix]

About

') expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/blog.html', expect.any(String)) expect(result.files).toContain('sitemap.xml') }) it('should correctly combine plugin hooks with option hooks', async () => { const plugin: SSGPlugin = { afterResponseHook: async (res) => { const text = await res.text() return new Response(`${text} [Plugin]`, res) }, } const afterResponseHook: AfterResponseHook = async (res) => { const text = await res.text() return new Response(`${text} [Option]`, res) } await toSSG(app, fsMock, { plugins: [plugin], afterResponseHook, }) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/index.html', '

Home

[Option] [Plugin]' ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/about.html', '

About

[Option] [Plugin]' ) expect(fsMock.writeFile).toHaveBeenCalledWith( 'static/blog.html', '

Blog

[Option] [Plugin]' ) }) }) describe('ssgParams', () => { it('should invoke callback only once', async () => { const app = new Hono() const cb = vi.fn(() => [{ post: '1' }, { post: '2' }]) app.get('/post/:post', ssgParams(cb), (c) => c.html(

{c.req.param('post')}

)) const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } await toSSG(app, fsMock) expect(cb).toHaveBeenCalledTimes(1) }) it('should not invoke handler after ssgParams for dynamic route request', async () => { const app = new Hono() const log = vi.fn() app.get( '/shops/:id', ssgParams(() => [{ id: 'shop1' }]), async (c) => { const id = c.req.param('id') log(id) return c.html(id) } ) const fsMock: FileSystemModule = { writeFile: vi.fn(() => Promise.resolve()), mkdir: vi.fn(() => Promise.resolve()), } await toSSG(app, fsMock) expect(log).toHaveBeenCalledTimes(1) expect(log).toHaveBeenCalledWith('shop1') expect(fsMock.writeFile).toHaveBeenCalledTimes(1) expect(fsMock.writeFile).toHaveBeenCalledWith('static/shops/shop1.html', 'shop1') }) }) ================================================ FILE: src/helper/ssg/ssg.ts ================================================ import { replaceUrlParam } from '../../client/utils' import type { Hono } from '../../hono' import type { Env, Schema } from '../../types' import { createPool } from '../../utils/concurrent' import { getExtension } from '../../utils/mime' import type { AddedSSGDataRequest, SSGParams } from './middleware' import { SSG_CONTEXT, X_HONO_DISABLE_SSG_HEADER_KEY } from './middleware' import { defaultPlugin } from './plugins' import { dirname, filterStaticGenerateRoutes, isDynamicRoute, joinPaths } from './utils' const DEFAULT_CONCURRENCY = 2 // default concurrency for ssg // 'default_content_type' is designed according to Bun's performance optimization, // which omits Content-Type by default for text responses. // This is based on benchmarks showing performance gains without Content-Type. // In Hono, using `c.text()` without a Content-Type implicitly assumes 'text/plain; charset=UTF-8'. // This approach maintains performance consistency across different environments. // For details, see GitHub issues: oven-sh/bun#8530 and https://github.com/honojs/hono/issues/2284. const DEFAULT_CONTENT_TYPE = 'text/plain' export const DEFAULT_OUTPUT_DIR = './static' /** * @experimental * `FileSystemModule` is an experimental feature. * The API might be changed. */ export interface FileSystemModule { writeFile(path: string, data: string | Uint8Array): Promise mkdir(path: string, options: { recursive: boolean }): Promise } /** * @experimental * `ToSSGResult` is an experimental feature. * The API might be changed. */ export interface ToSSGResult { success: boolean files: string[] error?: Error } const generateFilePath = ( routePath: string, outDir: string, mimeType: string, extensionMap?: Record ): string => { const extension = determineExtension(mimeType, extensionMap) if (routePath.endsWith(`.${extension}`)) { return joinPaths(outDir, routePath) } if (routePath === '/') { return joinPaths(outDir, `index.${extension}`) } if (routePath.endsWith('/')) { return joinPaths(outDir, routePath, `index.${extension}`) } return joinPaths(outDir, `${routePath}.${extension}`) } const parseResponseContent = async (response: Response): Promise => { const contentType = response.headers.get('Content-Type') try { if (contentType?.includes('text') || contentType?.includes('json')) { return await response.text() } else { return await response.arrayBuffer() } } catch (error) { throw new Error( `Error processing response: ${error instanceof Error ? error.message : 'Unknown error'}` ) } } export const defaultExtensionMap: Record = { 'text/html': 'html', 'text/xml': 'xml', 'application/xml': 'xml', 'application/yaml': 'yaml', } const determineExtension = ( mimeType: string, userExtensionMap?: Record ): string => { const extensionMap = userExtensionMap || defaultExtensionMap if (mimeType in extensionMap) { return extensionMap[mimeType] } return getExtension(mimeType) || 'html' } export type BeforeRequestHook = (req: Request) => Request | false | Promise export type AfterResponseHook = (res: Response) => Response | false | Promise export type AfterGenerateHook = ( result: ToSSGResult, fsModule: FileSystemModule, options?: ToSSGOptions ) => void | Promise export const combineBeforeRequestHooks = ( hooks: BeforeRequestHook | BeforeRequestHook[] ): BeforeRequestHook => { if (!Array.isArray(hooks)) { return hooks } return async (req: Request): Promise => { let currentReq = req for (const hook of hooks) { const result = await hook(currentReq) if (result === false) { return false } if (result instanceof Request) { currentReq = result } } return currentReq } } export const combineAfterResponseHooks = ( hooks: AfterResponseHook | AfterResponseHook[] ): AfterResponseHook => { if (!Array.isArray(hooks)) { return hooks } return async (res: Response): Promise => { let currentRes = res for (const hook of hooks) { const result = await hook(currentRes) if (result === false) { return false } if (result instanceof Response) { currentRes = result } } return currentRes } } export const combineAfterGenerateHooks = ( hooks: AfterGenerateHook | AfterGenerateHook[], fsModule: FileSystemModule, options?: ToSSGOptions ): AfterGenerateHook => { if (!Array.isArray(hooks)) { return hooks } return async (result: ToSSGResult): Promise => { for (const hook of hooks) { await hook(result, fsModule, options) } } } export interface SSGPlugin { beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[] afterResponseHook?: AfterResponseHook | AfterResponseHook[] afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[] } export interface ToSSGOptions { dir?: string /** * @deprecated Use plugins[].beforeRequestHook instead. */ beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[] /** * @deprecated Use plugins[].afterResponseHook instead. */ afterResponseHook?: AfterResponseHook | AfterResponseHook[] /** * @deprecated Use plugins[].afterGenerateHook instead. */ afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[] concurrency?: number extensionMap?: Record plugins?: SSGPlugin[] } /** * @experimental * `fetchRoutesContent` is an experimental feature. * The API might be changed. */ export const fetchRoutesContent = function* < E extends Env = Env, S extends Schema = {}, BasePath extends string = '/', >( app: Hono, beforeRequestHook?: BeforeRequestHook, afterResponseHook?: AfterResponseHook, concurrency?: number ): Generator< Promise< | Generator< Promise<{ routePath: string; mimeType: string; content: string | ArrayBuffer } | undefined> > | undefined > > { const baseURL = 'http://localhost' const pool = createPool({ concurrency }) for (const route of filterStaticGenerateRoutes(app)) { // GET Route Info const thisRouteBaseURL = new URL(route.path, baseURL).toString() let forGetInfoURLRequest = new Request(thisRouteBaseURL) as AddedSSGDataRequest // eslint-disable-next-line no-async-promise-executor yield new Promise(async (resolveGetInfo, rejectGetInfo) => { try { if (beforeRequestHook) { const maybeRequest = await beforeRequestHook(forGetInfoURLRequest) if (!maybeRequest) { resolveGetInfo(undefined) return } forGetInfoURLRequest = maybeRequest as unknown as AddedSSGDataRequest } await pool.run(() => app.fetch(forGetInfoURLRequest)) if (!forGetInfoURLRequest.ssgParams) { if (isDynamicRoute(route.path)) { resolveGetInfo(undefined) return } forGetInfoURLRequest.ssgParams = [{}] } const requestInit = { method: forGetInfoURLRequest.method, headers: forGetInfoURLRequest.headers, } resolveGetInfo( (function* () { for (const param of forGetInfoURLRequest.ssgParams as SSGParams) { // eslint-disable-next-line no-async-promise-executor yield new Promise(async (resolveReq, rejectReq) => { try { const replacedUrlParam = replaceUrlParam(route.path, param) let response = await pool.run(() => app.request(replacedUrlParam, requestInit, { [SSG_CONTEXT]: true, }) ) if (response.headers.get(X_HONO_DISABLE_SSG_HEADER_KEY)) { resolveReq(undefined) return } if (afterResponseHook) { const maybeResponse = await afterResponseHook(response) if (!maybeResponse) { resolveReq(undefined) return } response = maybeResponse } const mimeType = response.headers.get('Content-Type')?.split(';')[0] || DEFAULT_CONTENT_TYPE const content = await parseResponseContent(response) resolveReq({ routePath: replacedUrlParam, mimeType, content, }) } catch (error) { rejectReq(error) } }) } })() ) } catch (error) { rejectGetInfo(error) } }) } } /** * @experimental * `saveContentToFile` is an experimental feature. * The API might be changed. */ const createdDirs: Set = new Set() export const saveContentToFile = async ( data: Promise<{ routePath: string; content: string | ArrayBuffer; mimeType: string } | undefined>, fsModule: FileSystemModule, outDir: string, extensionMap?: Record ): Promise => { const awaitedData = await data if (!awaitedData) { return } const { routePath, content, mimeType } = awaitedData const filePath = generateFilePath(routePath, outDir, mimeType, extensionMap) const dirPath = dirname(filePath) if (!createdDirs.has(dirPath)) { await fsModule.mkdir(dirPath, { recursive: true }) createdDirs.add(dirPath) } if (typeof content === 'string') { await fsModule.writeFile(filePath, content) } else if (content instanceof ArrayBuffer) { await fsModule.writeFile(filePath, new Uint8Array(content)) } return filePath } /** * @experimental * `ToSSGInterface` is an experimental feature. * The API might be changed. */ export interface ToSSGInterface { ( // eslint-disable-next-line @typescript-eslint/no-explicit-any app: Hono, fsModule: FileSystemModule, options?: ToSSGOptions ): Promise } /** * @experimental * `ToSSGAdaptorInterface` is an experimental feature. * The API might be changed. */ export interface ToSSGAdaptorInterface< E extends Env = Env, S extends Schema = {}, BasePath extends string = '/', > { (app: Hono, options?: ToSSGOptions): Promise } /** * @experimental * `toSSG` is an experimental feature. * The API might be changed. */ export const toSSG: ToSSGInterface = async (app, fs, options) => { let result: ToSSGResult | undefined const getInfoPromises: Promise[] = [] const savePromises: Promise[] = [] const plugins = options?.plugins || [defaultPlugin()] const beforeRequestHooks: BeforeRequestHook[] = [] const afterResponseHooks: AfterResponseHook[] = [] const afterGenerateHooks: AfterGenerateHook[] = [] if (options?.beforeRequestHook) { beforeRequestHooks.push( ...(Array.isArray(options.beforeRequestHook) ? options.beforeRequestHook : [options.beforeRequestHook]) ) } if (options?.afterResponseHook) { afterResponseHooks.push( ...(Array.isArray(options.afterResponseHook) ? options.afterResponseHook : [options.afterResponseHook]) ) } if (options?.afterGenerateHook) { afterGenerateHooks.push( ...(Array.isArray(options.afterGenerateHook) ? options.afterGenerateHook : [options.afterGenerateHook]) ) } for (const plugin of plugins) { if (plugin.beforeRequestHook) { beforeRequestHooks.push( ...(Array.isArray(plugin.beforeRequestHook) ? plugin.beforeRequestHook : [plugin.beforeRequestHook]) ) } if (plugin.afterResponseHook) { afterResponseHooks.push( ...(Array.isArray(plugin.afterResponseHook) ? plugin.afterResponseHook : [plugin.afterResponseHook]) ) } if (plugin.afterGenerateHook) { afterGenerateHooks.push( ...(Array.isArray(plugin.afterGenerateHook) ? plugin.afterGenerateHook : [plugin.afterGenerateHook]) ) } } try { const outputDir = options?.dir ?? DEFAULT_OUTPUT_DIR const concurrency = options?.concurrency ?? DEFAULT_CONCURRENCY const combinedBeforeRequestHook = combineBeforeRequestHooks( beforeRequestHooks.length > 0 ? beforeRequestHooks : [(req) => req] ) const combinedAfterResponseHook = combineAfterResponseHooks( afterResponseHooks.length > 0 ? afterResponseHooks : [(req) => req] ) const getInfoGen = fetchRoutesContent( app, combinedBeforeRequestHook, combinedAfterResponseHook, concurrency ) for (const getInfo of getInfoGen) { getInfoPromises.push( getInfo.then((getContentGen) => { if (!getContentGen) { return } for (const content of getContentGen) { savePromises.push( saveContentToFile(content, fs, outputDir, options?.extensionMap).catch((e) => e) ) } }) ) } await Promise.all(getInfoPromises) const files: string[] = [] for (const savePromise of savePromises) { const fileOrError = await savePromise if (typeof fileOrError === 'string') { files.push(fileOrError) } else if (fileOrError) { throw fileOrError } } result = { success: true, files } } catch (error) { const errorObj = error instanceof Error ? error : new Error(String(error)) result = { success: false, files: [], error: errorObj } } if (afterGenerateHooks.length > 0) { const combinedAfterGenerateHooks = combineAfterGenerateHooks(afterGenerateHooks, fs, options) await combinedAfterGenerateHooks(result, fs, options) } return result } ================================================ FILE: src/helper/ssg/utils.test.ts ================================================ import { describe, expect, it } from 'vitest' import { dirname, joinPaths } from './utils' describe('joinPath', () => { it('Should joined path is valid.', () => { expect(joinPaths('test')).toBe('test') //single expect(joinPaths('.test')).toBe('.test') //single with dot expect(joinPaths('/.test')).toBe('/.test') //single with dot with root expect(joinPaths('test', 'test2')).toBe('test/test2') // single and single expect(joinPaths('test', 'test2', '../test3')).toBe('test/test3') // single and single and single with parent expect(joinPaths('.', '../')).toBe('..') // dot and parent expect(joinPaths('test/', 'test2/')).toBe('test/test2') // trailing slashes expect(joinPaths('./test', './test2')).toBe('test/test2') // dot and slash expect(joinPaths('', 'test')).toBe('test') // empty path expect(joinPaths('/test', '/test2')).toBe('/test/test2') // root path expect(joinPaths('../', 'test')).toBe('../test') // parent and single expect(joinPaths('test', '..', 'test2')).toBe('test2') // single triple dot and single expect(joinPaths('test', '...', 'test2')).toBe('test/.../test2') // single triple dot and single expect(joinPaths('test', './test2', '.test3.')).toBe('test/test2/.test3.') // single and single with slash and single with dot expect(joinPaths('test', '../', '.test2')).toBe('.test2') // single and parent and single with dot expect(joinPaths('..', '..', 'test')).toBe('../../test') // parent and parent and single expect(joinPaths('..', '..')).toBe('../..') // parent and parent expect(joinPaths('.test../test2/../')).toBe('.test..') //shuffle expect(joinPaths('.test./.test2/../')).toBe('.test.') //shuffle2 }) it('Should windows path is valid.', () => { expect(joinPaths('a\\b\\c', 'd\\e')).toBe('a/b/c/d/e') }) }) describe('dirname', () => { it('Should dirname is valid.', () => { expect(dirname('parent/child')).toBe('parent') expect(dirname('windows\\test.txt')).toBe('windows') }) }) ================================================ FILE: src/helper/ssg/utils.ts ================================================ import type { Hono } from '../../hono' import { METHOD_NAME_ALL } from '../../router' import type { Env, RouterRoute } from '../../types' import { findTargetHandler, isMiddleware } from '../../utils/handler' /** * Get dirname * @param path File Path * @returns Parent dir path */ export const dirname = (path: string): string => { const separatedPath = path.split(/[\/\\]/) return separatedPath.slice(0, -1).join('/') // Windows supports slash path } const normalizePath = (path: string): string => { return path.replace(/(\\)/g, '/').replace(/\/$/g, '') } const handleParent = (resultPaths: string[], beforeParentFlag: boolean): void => { if (resultPaths.length === 0 || beforeParentFlag) { resultPaths.push('..') } else { resultPaths.pop() } } const handleNonDot = (path: string, resultPaths: string[]): void => { path = path.replace(/^\.(?!.)/, '') if (path !== '') { resultPaths.push(path) } } const handleSegments = (paths: string[], resultPaths: string[]): void => { let beforeParentFlag = false for (const path of paths) { // Handle `..` if (path === '..') { handleParent(resultPaths, beforeParentFlag) beforeParentFlag = true } else { // Handle `.` or `abc` handleNonDot(path, resultPaths) beforeParentFlag = false } } } export const joinPaths = (...paths: string[]): string => { paths = paths.map(normalizePath) const resultPaths: string[] = [] handleSegments(paths.join('/').split('/'), resultPaths) return (paths[0][0] === '/' ? '/' : '') + resultPaths.join('/') } interface FilterStaticGenerateRouteData { path: string } export const filterStaticGenerateRoutes = ( hono: Hono ): FilterStaticGenerateRouteData[] => { return hono.routes.reduce((acc, { method, handler, path }: RouterRoute) => { const targetHandler = findTargetHandler(handler) if (['GET', METHOD_NAME_ALL].includes(method) && !isMiddleware(targetHandler)) { acc.push({ path }) } return acc }, [] as FilterStaticGenerateRouteData[]) } export const isDynamicRoute = (path: string): boolean => { return path.split('/').some((segment) => segment.startsWith(':') || segment.includes('*')) } ================================================ FILE: src/helper/streaming/index.ts ================================================ /** * @module * Streaming Helper for Hono. */ export { stream } from './stream' export type { SSEMessage } from './sse' export { streamSSE, SSEStreamingApi } from './sse' export { streamText } from './text' ================================================ FILE: src/helper/streaming/sse.test.tsx ================================================ /** @jsxImportSource ../../jsx */ import { Context } from '../../context' import { ErrorBoundary } from '../../jsx' import { streamSSE } from '.' describe('SSE Streaming helper', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('Check streamSSE Response', async () => { let spy const res = streamSSE(c, async (stream) => { spy = vi.spyOn(stream, 'close').mockImplementation(async () => {}) let id = 0 const maxIterations = 5 while (id < maxIterations) { const message = `Message\nIt is ${id}` await stream.writeSSE({ data: message, event: 'time-update', id: String(id++) }) await stream.sleep(10) } }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.headers.get('Transfer-Encoding')).toEqual('chunked') expect(res.headers.get('Content-Type')).toEqual('text/event-stream') expect(res.headers.get('Cache-Control')).toEqual('no-cache') expect(res.headers.get('Connection')).toEqual('keep-alive') if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() for (let i = 0; i < 5; i++) { const { value } = await reader.read() const decodedValue = decoder.decode(value) // Check the structure and content of the SSE message let expectedValue = 'event: time-update\n' expectedValue += 'data: Message\n' expectedValue += `data: It is ${i}\n` expectedValue += `id: ${i}\n\n` expect(decodedValue).toBe(expectedValue) } await new Promise((resolve) => setTimeout(resolve, 100)) expect(spy).toHaveBeenCalled() }) it('Check streamSSE Response if aborted by client', async () => { let aborted = false const res = streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) for (let i = 0; i < 3; i++) { await stream.writeSSE({ data: `Message ${i}`, }) await stream.sleep(1) } }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const { value } = await reader.read() expect(value).toEqual(new TextEncoder().encode('data: Message 0\n\n')) reader.cancel() expect(aborted).toBeTruthy() }) it('Check streamSSE Response if aborted by abort signal', async () => { // Emulate an old version of Bun (version 1.1.0) for this specific test case // @ts-expect-error Bun is not typed global.Bun = { version: '1.1.0', } const ac = new AbortController() const req = new Request('http://localhost/', { signal: ac.signal }) const c = new Context(req) let aborted = false const res = streamSSE(c, async (stream) => { stream.onAbort(() => { aborted = true }) for (let i = 0; i < 3; i++) { await stream.writeSSE({ data: `Message ${i}`, }) await stream.sleep(1) } }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const { value } = await reader.read() expect(value).toEqual(new TextEncoder().encode('data: Message 0\n\n')) ac.abort() expect(aborted).toBeTruthy() }) it('Should include retry in the SSE message', async () => { const retryTime = 3000 // 3 seconds const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: 'This is a test message', retry: retryTime, }) }) expect(res).not.toBeNull() expect(res.status).toBe(200) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) // Check if the retry parameter is included in the SSE message const expectedRetryValue = `retry: ${retryTime}\n\n` expect(decodedValue).toContain(expectedRetryValue) }) it('Check stream Response if error occurred', async () => { const onError = vi.fn() const res = streamSSE( c, async () => { throw new Error('Test error') }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('event: error\ndata: Test error\n\n') expect(onError).toBeCalledTimes(1) expect(onError).toBeCalledWith(new Error('Test error'), expect.anything()) // 2nd argument is StreamingApi instance }) it('Check streamSSE Response via Promise', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: Promise.resolve('Async Message') }) }) expect(res).not.toBeNull() expect(res.status).toBe(200) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('data: Async Message\n\n') }) it('Check streamSSE Response via JSX.Element', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data:
Hello
}) }) expect(res).not.toBeNull() expect(res.status).toBe(200) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('data:
Hello
\n\n') }) it('Check streamSSE Response via ErrorBoundary in success case', async () => { const AsyncComponent = async () => Promise.resolve(
Async Hello
) const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: ( Error
}> ), }) }) expect(res).not.toBeNull() expect(res.status).toBe(200) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('data:
Async Hello
\n\n') }) it('Check streamSSE Response via ErrorBoundary in error case', async () => { const AsyncComponent = async () => Promise.reject() const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: ( Error
}> ), }) }) expect(res).not.toBeNull() expect(res.status).toBe(200) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('data:
Error
\n\n') }) it('Check streamSSE handles \\r (CR) line ending correctly', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: 'Line1\rLine2', event: 'test-cr', }) }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('event: test-cr\ndata: Line1\ndata: Line2\n\n') }) it('Check streamSSE handles \\r\\n (CRLF) line ending correctly', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: 'Line1\r\nLine2', event: 'test-crlf', }) }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('event: test-crlf\ndata: Line1\ndata: Line2\n\n') }) it('Check streamSSE handles mixed line endings correctly', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: 'A\nB\rC\r\nD', event: 'test-mixed', }) }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toBe('event: test-mixed\ndata: A\ndata: B\ndata: C\ndata: D\n\n') }) it('Should throw error if event contains \\n', async () => { const onError = vi.fn() const res = streamSSE( c, async (stream) => { await stream.writeSSE({ data: 'test', event: 'test\nevent' }) }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toContain('event: error') expect(onError).toBeCalledTimes(1) }) it('Should throw error if event contains \\r', async () => { const onError = vi.fn() const res = streamSSE( c, async (stream) => { await stream.writeSSE({ data: 'test', event: 'test\revent' }) }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toContain('event: error') expect(onError).toBeCalledTimes(1) }) it('Should throw error if id contains \\n', async () => { const onError = vi.fn() const res = streamSSE( c, async (stream) => { await stream.writeSSE({ data: 'test', id: 'test\nid' }) }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toContain('event: error') expect(onError).toBeCalledTimes(1) }) it('Should throw error if id contains \\r', async () => { const onError = vi.fn() const res = streamSSE( c, async (stream) => { await stream.writeSSE({ data: 'test', id: 'test\rid' }) }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) expect(decodedValue).toContain('event: error') expect(onError).toBeCalledTimes(1) }) it('Check streamSSE handles consecutive \\r correctly', async () => { const res = streamSSE(c, async (stream) => { await stream.writeSSE({ data: 'Left\r\rRight', event: 'test-double-cr', }) }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() const { value } = await reader.read() const decodedValue = decoder.decode(value) // Two \r should produce an empty line in between expect(decodedValue).toBe('event: test-double-cr\ndata: Left\ndata: \ndata: Right\n\n') }) }) ================================================ FILE: src/helper/streaming/sse.ts ================================================ import type { Context } from '../../context' import { HtmlEscapedCallbackPhase, resolveCallback } from '../../utils/html' import { StreamingApi } from '../../utils/stream' import { isOldBunVersion } from './utils' export interface SSEMessage { data: string | Promise event?: string id?: string retry?: number } export class SSEStreamingApi extends StreamingApi { constructor(writable: WritableStream, readable: ReadableStream) { super(writable, readable) } async writeSSE(message: SSEMessage) { const data = await resolveCallback(message.data, HtmlEscapedCallbackPhase.Stringify, false, {}) const dataLines = (data as string) .split(/\r\n|\r|\n/) .map((line) => { return `data: ${line}` }) .join('\n') for (const key of ['event', 'id', 'retry'] as (keyof SSEMessage)[]) { if (message[key] && /[\r\n]/.test(message[key] as string)) { throw new Error(`${key} must not contain "\\r" or "\\n"`) } } const sseData = [ message.event && `event: ${message.event}`, dataLines, message.id && `id: ${message.id}`, message.retry && `retry: ${message.retry}`, ] .filter(Boolean) .join('\n') + '\n\n' await this.write(sseData) } } const run = async ( stream: SSEStreamingApi, cb: (stream: SSEStreamingApi) => Promise, onError?: (e: Error, stream: SSEStreamingApi) => Promise ): Promise => { try { await cb(stream) } catch (e) { if (e instanceof Error && onError) { await onError(e, stream) await stream.writeSSE({ event: 'error', data: e.message, }) } else { console.error(e) } } finally { stream.close() } } const contextStash: WeakMap = new WeakMap() export const streamSSE = ( c: Context, cb: (stream: SSEStreamingApi) => Promise, onError?: (e: Error, stream: SSEStreamingApi) => Promise ): Response => { const { readable, writable } = new TransformStream() const stream = new SSEStreamingApi(writable, readable) // Until Bun v1.1.27, Bun didn't call cancel() on the ReadableStream for Response objects from Bun.serve() if (isOldBunVersion()) { c.req.raw.signal.addEventListener('abort', () => { if (!stream.closed) { stream.abort() } }) } // in bun, `c` is destroyed when the request is returned, so hold it until the end of streaming contextStash.set(stream.responseReadable, c) c.header('Transfer-Encoding', 'chunked') c.header('Content-Type', 'text/event-stream') c.header('Cache-Control', 'no-cache') c.header('Connection', 'keep-alive') run(stream, cb, onError) return c.newResponse(stream.responseReadable) } ================================================ FILE: src/helper/streaming/stream.test.ts ================================================ import { Context } from '../../context' import { stream } from '.' describe('Basic Streaming Helper', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('Check stream Response', async () => { const res = stream(c, async (stream) => { for (let i = 0; i < 3; i++) { await stream.write(new Uint8Array([i])) await stream.sleep(1) } }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() for (let i = 0; i < 3; i++) { const { value } = await reader.read() expect(value).toEqual(new Uint8Array([i])) } }) it('Check stream Response if aborted by client', async () => { let aborted = false const res = stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) for (let i = 0; i < 3; i++) { await stream.write(new Uint8Array([i])) await stream.sleep(1) } }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const { value } = await reader.read() expect(value).toEqual(new Uint8Array([0])) reader.cancel() expect(aborted).toBeTruthy() }) it('Check stream Response if aborted by abort signal', async () => { // Emulate an old version of Bun (version 1.1.0) for this specific test case // @ts-expect-error Bun is not typed global.Bun = { version: '1.1.0', } const ac = new AbortController() const req = new Request('http://localhost/', { signal: ac.signal }) const c = new Context(req) let aborted = false const res = stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) for (let i = 0; i < 3; i++) { await stream.write(new Uint8Array([i])) await stream.sleep(1) } }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const { value } = await reader.read() expect(value).toEqual(new Uint8Array([0])) ac.abort() expect(aborted).toBeTruthy() // @ts-expect-error Bun is not typed delete global.Bun }) it('Check stream Response if pipe is aborted by abort signal', async () => { // Emulate an old version of Bun (version 1.1.0) for this specific test case // @ts-expect-error Bun is not typed global.Bun = { version: '1.1.0', } const ac = new AbortController() const req = new Request('http://localhost/', { signal: ac.signal }) const c = new Context(req) let aborted = false const res = stream(c, async (stream) => { stream.onAbort(() => { aborted = true }) await stream.pipe(new ReadableStream()) }) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const pReading = reader.read() ac.abort() await pReading expect(aborted).toBeTruthy() // @ts-expect-error Bun is not typed delete global.Bun }) it('Check stream Response if error occurred', async () => { const onError = vi.fn() const res = stream( c, async () => { throw new Error('error') }, onError ) if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const { value } = await reader.read() expect(value).toBeUndefined() expect(onError).toBeCalledTimes(1) expect(onError).toBeCalledWith(new Error('error'), expect.anything()) // 2nd argument is StreamingApi instance }) }) ================================================ FILE: src/helper/streaming/stream.ts ================================================ import type { Context } from '../../context' import { StreamingApi } from '../../utils/stream' import { isOldBunVersion } from './utils' const contextStash: WeakMap = new WeakMap() export const stream = ( c: Context, cb: (stream: StreamingApi) => Promise, onError?: (e: Error, stream: StreamingApi) => Promise ): Response => { const { readable, writable } = new TransformStream() const stream = new StreamingApi(writable, readable) // Until Bun v1.1.27, Bun didn't call cancel() on the ReadableStream for Response objects from Bun.serve() if (isOldBunVersion()) { c.req.raw.signal.addEventListener('abort', () => { if (!stream.closed) { stream.abort() } }) } // in bun, `c` is destroyed when the request is returned, so hold it until the end of streaming contextStash.set(stream.responseReadable, c) ;(async () => { try { await cb(stream) } catch (e) { if (e === undefined) { // If reading is canceled without a reason value (e.g. by StreamingApi) // then the .pipeTo() promise will reject with undefined. // In this case, do nothing because the stream is already closed. } else if (e instanceof Error && onError) { await onError(e, stream) } else { console.error(e) } } finally { stream.close() } })() return c.newResponse(stream.responseReadable) } ================================================ FILE: src/helper/streaming/text.test.ts ================================================ import { Context } from '../../context' import { streamText } from '.' describe('Text Streaming Helper', () => { const req = new Request('http://localhost/') let c: Context beforeEach(() => { c = new Context(req) }) it('Check streamText Response', async () => { const res = streamText(c, async (stream) => { for (let i = 0; i < 3; i++) { await stream.write(`${i}`) await stream.sleep(1) } }) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toMatch(/^text\/plain/) expect(res.headers.get('x-content-type-options')).toBe('nosniff') expect(res.headers.get('transfer-encoding')).toBe('chunked') if (!res.body) { throw new Error('Body is null') } const reader = res.body.getReader() const decoder = new TextDecoder() for (let i = 0; i < 3; i++) { const { value } = await reader.read() expect(decoder.decode(value)).toEqual(`${i}`) } }) }) ================================================ FILE: src/helper/streaming/text.ts ================================================ import type { Context } from '../../context' import { TEXT_PLAIN } from '../../context' import type { StreamingApi } from '../../utils/stream' import { stream } from './' export const streamText = ( c: Context, cb: (stream: StreamingApi) => Promise, onError?: (e: Error, stream: StreamingApi) => Promise ): Response => { c.header('Content-Type', TEXT_PLAIN) c.header('X-Content-Type-Options', 'nosniff') c.header('Transfer-Encoding', 'chunked') return stream(c, cb, onError) } ================================================ FILE: src/helper/streaming/utils.ts ================================================ export let isOldBunVersion = (): boolean => { // @ts-expect-error @types/bun is not installed const version: string = typeof Bun !== 'undefined' ? Bun.version : undefined if (version === undefined) { return false } const result = version.startsWith('1.1') || version.startsWith('1.0') || version.startsWith('0.') // Avoid running this check on every call isOldBunVersion = () => result return result } ================================================ FILE: src/helper/testing/index.test.ts ================================================ import { Hono } from '../../hono' import { testClient } from '.' describe('hono testClient', () => { it('Should return the correct search result', async () => { const app = new Hono().get('/search', (c) => c.json({ hello: 'world' })) const res = await testClient(app).search.$get() expect(await res.json()).toEqual({ hello: 'world' }) }) it('Should return the correct environment variables value', async () => { type Bindings = { hello: string } const app = new Hono<{ Bindings: Bindings }>().get('/search', (c) => { return c.json({ hello: c.env.hello }) }) const res = await testClient(app, { hello: 'world' }).search.$get() expect(await res.json()).toEqual({ hello: 'world' }) }) it('Should use the passed in headers', async () => { const app = new Hono().get('/search', (c) => { return c.json({ query: c.req.header('x-query') }) }) const res = await testClient(app, undefined, undefined, { headers: { 'x-query': 'abc' }, }).search.$get() expect(await res.json()).toEqual({ query: 'abc' }) }) it('Should return a correct URL with out throwing an error', async () => { const app = new Hono().get('/abc', (c) => c.json(0)) const url = testClient(app).abc.$url() expect(url.pathname).toBe('/abc') }) it('Should not throw an error with $ws()', async () => { vi.stubGlobal('WebSocket', class {}) const app = new Hono().get('/ws', (c) => c.text('Fake response of a WebSocket')) // @ts-expect-error $ws is not typed correctly expect(() => testClient(app).ws.$ws()).not.toThrowError() }) }) ================================================ FILE: src/helper/testing/index.ts ================================================ /** * @module * Testing Helper for Hono. */ import { hc } from '../../client' import type { Client, ClientRequestOptions } from '../../client/types' import type { ExecutionContext } from '../../context' import type { Hono } from '../../hono' import type { Schema } from '../../types' import type { UnionToIntersection } from '../../utils/types' type ExtractEnv = T extends Hono ? E : never // eslint-disable-next-line @typescript-eslint/no-explicit-any export const testClient = >( app: T, Env?: ExtractEnv['Bindings'] | {}, executionCtx?: ExecutionContext, options?: Omit ): UnionToIntersection> => { const customFetch = (input: RequestInfo | URL, init?: RequestInit) => { return app.request(input, init, Env, executionCtx) } return hc('http://localhost', { ...options, fetch: customFetch }) } ================================================ FILE: src/helper/websocket/index.test.ts ================================================ import { Context } from '../../context' import type { WSContextInit } from '.' import { WSContext, createWSMessageEvent, defineWebSocketHelper } from '.' describe('`createWSMessageEvent`', () => { it('Should `createWSMessageEvent` is working for string', () => { const randomString = Math.random().toString() const event = createWSMessageEvent(randomString) expect(event.data).toBe(randomString) }) it('Should `createWSMessageEvent` type is `message`', () => { const event = createWSMessageEvent('') expect(event.type).toBe('message') }) }) describe('defineWebSocketHelper', () => { it('defineWebSocketHelper should work', async () => { const upgradeWebSocket = defineWebSocketHelper(() => { return new Response('Hello World', { status: 200, }) }) const response = await upgradeWebSocket(() => ({}))( new Context(new Request('http://localhost')), () => Promise.resolve() ) expect(response).toBeTruthy() expect((response as Response).status).toBe(200) }) it('When response is undefined, should call next()', async () => { const upgradeWebSocket = defineWebSocketHelper(() => { return }) const next = vi.fn() await upgradeWebSocket(() => ({}))(new Context(new Request('http://localhost')), next) expect(next).toBeCalled() }) it('Use upgradeWebSocket in return', async () => { const upgradeWebSocket = defineWebSocketHelper(() => { return new Response('Hello World', { status: 200, }) }) const c = new Context(new Request('http://localhost')) const res = await upgradeWebSocket(c, {}) expect(res.status).toBe(200) }) it('When upgrading failed and use it in handler, it should throw error', async () => { const upgradeWebSocket = defineWebSocketHelper(() => { return }) const c = new Context(new Request('http://localhost')) expect(() => upgradeWebSocket(c, {})).rejects.toThrow() }) }) describe('WSContext', () => { it('Should close() works', async () => { type Result = [number | undefined, string | undefined] let ws!: WSContext const promise = new Promise((resolve) => { ws = new WSContext({ close(code, reason) { resolve([code, reason]) }, } as WSContextInit) }) ws.close(0, 'reason') const [code, reason] = await promise expect(code).toBe(0) expect(reason).toBe('reason') }) it('Should send() works', async () => { let ws!: WSContext const promise = new Promise>((resolve) => { ws = new WSContext({ send(data: string | ArrayBuffer | Uint8Array, _options) { resolve(data) }, } as WSContextInit) }) ws.send('Hello') expect(await promise).toBe('Hello') }) it('Should readyState works', () => { const ws = new WSContext({ readyState: 0, } as WSContextInit) expect(ws.readyState).toBe(0) }) it('Should normalize URL', () => { const stringURLWS = new WSContext({ url: 'http://localhost', } as WSContextInit) expect(stringURLWS.url).toBeInstanceOf(URL) const urlURLWS = new WSContext({ url: new URL('http://localhost'), } as WSContextInit) expect(urlURLWS.url).toBeInstanceOf(URL) const nullURLWS = new WSContext({ url: undefined, } as WSContextInit) expect(nullURLWS.url).toBeNull() }) it('Should normalize message in send()', () => { let data: string | ArrayBuffer | Uint8Array | null = null const wsContext = new WSContext({ send(received, _options) { data = received }, } as WSContextInit) wsContext.send('string') expect(data).toBe('string') wsContext.send(new ArrayBuffer(16)) expect(data).toBeInstanceOf(ArrayBuffer) wsContext.send(new Uint8Array(16)) expect(data).toBeInstanceOf(Uint8Array) }) }) ================================================ FILE: src/helper/websocket/index.ts ================================================ /** * @module * WebSocket Helper for Hono. */ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { Context } from '../../context' import type { MiddlewareHandler, TypedResponse } from '../../types' import type { StatusCode } from '../../utils/http-status' /** * WebSocket Event Listeners type */ export interface WSEvents { onOpen?: (evt: Event, ws: WSContext) => void onMessage?: (evt: MessageEvent, ws: WSContext) => void onClose?: (evt: CloseEvent, ws: WSContext) => void onError?: (evt: Event, ws: WSContext) => void } /** * Upgrade WebSocket Type */ export interface UpgradeWebSocket> { ( createEvents: (c: Context) => _WSEvents | Promise<_WSEvents>, options?: U ): MiddlewareHandler< any, string, { outputFormat: 'ws' } > ( c: Context, events: _WSEvents, options?: U ): Promise> } /** * ReadyState for WebSocket */ export type WSReadyState = 0 | 1 | 2 | 3 /** * An argument for WSContext class */ export interface WSContextInit { send(data: string | ArrayBuffer | Uint8Array, options: SendOptions): void close(code?: number, reason?: string): void raw?: T readyState: WSReadyState url?: string | URL | null protocol?: string | null } /** * Options for sending message */ export interface SendOptions { compress?: boolean } /** * A context for controlling WebSockets */ export class WSContext { #init: WSContextInit constructor(init: WSContextInit) { this.#init = init this.raw = init.raw this.url = init.url ? new URL(init.url) : null this.protocol = init.protocol ?? null } send(source: string | ArrayBuffer | Uint8Array, options?: SendOptions): void { this.#init.send(source, options ?? {}) } raw?: T binaryType: BinaryType = 'arraybuffer' get readyState(): WSReadyState { return this.#init.readyState } url: URL | null protocol: string | null close(code?: number, reason?: string) { this.#init.close(code, reason) } } export type WSMessageReceive = string | Blob | ArrayBufferLike export const createWSMessageEvent = (source: WSMessageReceive): MessageEvent => { return new MessageEvent('message', { data: source, }) } export interface WebSocketHelperDefineContext {} export type WebSocketHelperDefineHandler = ( c: Context, events: WSEvents, options?: U ) => Promise | Response | void /** * Create a WebSocket adapter/helper */ export const defineWebSocketHelper = ( handler: WebSocketHelperDefineHandler ): UpgradeWebSocket => { return (( ...args: | [createEvents: (c: Context) => WSEvents | Promise>, options?: U] | [c: Context, events: WSEvents, options?: U] ) => { if (typeof args[0] === 'function') { const [createEvents, options] = args return async function upgradeWebSocket(c, next) { const events = await createEvents(c) const result = await handler(c, events, options as U) if (result) { return result } await next() } } else { const [c, events, options] = args as [c: Context, events: WSEvents, options?: U] return (async () => { const upgraded = await handler(c, events, options as U) if (!upgraded) { throw new Error('Failed to upgrade WebSocket') } return upgraded })() } }) as UpgradeWebSocket } ================================================ FILE: src/hono-base.ts ================================================ /** * @module * This module is the base module for the Hono object. */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { compose } from './compose' import { Context } from './context' import type { ExecutionContext } from './context' import type { Router } from './router' import { METHODS, METHOD_NAME_ALL, METHOD_NAME_ALL_LOWERCASE } from './router' import type { Env, ErrorHandler, FetchEventLike, H, HandlerInterface, MergePath, MergeSchemaPath, MiddlewareHandler, MiddlewareHandlerInterface, Next, NotFoundHandler, OnHandlerInterface, RouterRoute, Schema, } from './types' import { COMPOSED_HANDLER } from './utils/constants' import { getPath, getPathNoStrict, mergePath } from './utils/url' const notFoundHandler: NotFoundHandler = (c) => { return c.text('404 Not Found', 404) } const errorHandler: ErrorHandler = (err, c) => { if ('getResponse' in err) { const res = err.getResponse() return c.newResponse(res.body, res) } console.error(err) return c.text('Internal Server Error', 500) } type GetPath = (request: Request, options?: { env?: E['Bindings'] }) => string export type HonoOptions = { /** * `strict` option specifies whether to distinguish whether the last path is a directory or not. * * @see {@link https://hono.dev/docs/api/hono#strict-mode} * * @default true */ strict?: boolean /** * `router` option specifies which router to use. * * @see {@link https://hono.dev/docs/api/hono#router-option} * * @example * ```ts * const app = new Hono({ router: new RegExpRouter() }) * ``` */ router?: Router<[H, RouterRoute]> /** * `getPath` can handle the host header value. * * @see {@link https://hono.dev/docs/api/routing#routing-with-host-header-value} * * @example * ```ts * const app = new Hono({ * getPath: (req) => * '/' + req.headers.get('host') + req.url.replace(/^https?:\/\/[^/]+(\/[^?]*)/, '$1'), * }) * * app.get('/www1.example.com/hello', () => c.text('hello www1')) * * // A following request will match the route: * // new Request('http://www1.example.com/hello', { * // headers: { host: 'www1.example.com' }, * // }) * ``` */ getPath?: GetPath } type MountOptionHandler = (c: Context) => unknown type MountReplaceRequest = (originalRequest: Request) => Request type MountOptions = | MountOptionHandler | { optionHandler?: MountOptionHandler replaceRequest?: MountReplaceRequest | false } class Hono< E extends Env = Env, S extends Schema = {}, BasePath extends string = '/', CurrentPath extends string = BasePath, > { get!: HandlerInterface post!: HandlerInterface put!: HandlerInterface delete!: HandlerInterface options!: HandlerInterface patch!: HandlerInterface all!: HandlerInterface on: OnHandlerInterface use: MiddlewareHandlerInterface /* This class is like an abstract class and does not have a router. To use it, inherit the class and implement router in the constructor. */ router!: Router<[H, RouterRoute]> readonly getPath: GetPath // Cannot use `#` because it requires visibility at JavaScript runtime. private _basePath: string = '/' #path: string = '/' routes: RouterRoute[] = [] constructor(options: HonoOptions = {}) { // Implementation of app.get(...handlers[]) or app.get(path, ...handlers[]) const allMethods = [...METHODS, METHOD_NAME_ALL_LOWERCASE] allMethods.forEach((method) => { this[method] = (args1: string | H, ...args: H[]) => { if (typeof args1 === 'string') { this.#path = args1 } else { this.#addRoute(method, this.#path, args1) } args.forEach((handler) => { this.#addRoute(method, this.#path, handler) }) return this as any } }) // Implementation of app.on(method, path, ...handlers[]) this.on = (method: string | string[], path: string | string[], ...handlers: H[]) => { for (const p of [path].flat()) { this.#path = p for (const m of [method].flat()) { handlers.map((handler) => { this.#addRoute(m.toUpperCase(), this.#path, handler) }) } } return this as any } // Implementation of app.use(...handlers[]) or app.use(path, ...handlers[]) this.use = (arg1: string | MiddlewareHandler, ...handlers: MiddlewareHandler[]) => { if (typeof arg1 === 'string') { this.#path = arg1 } else { this.#path = '*' handlers.unshift(arg1) } handlers.forEach((handler) => { this.#addRoute(METHOD_NAME_ALL, this.#path, handler) }) return this as any } const { strict, ...optionsWithoutStrict } = options Object.assign(this, optionsWithoutStrict) this.getPath = (strict ?? true) ? (options.getPath ?? getPath) : getPathNoStrict } #clone(): Hono { const clone = new Hono({ router: this.router, getPath: this.getPath, }) clone.errorHandler = this.errorHandler clone.#notFoundHandler = this.#notFoundHandler clone.routes = this.routes return clone } #notFoundHandler: NotFoundHandler = notFoundHandler // Cannot use `#` because it requires visibility at JavaScript runtime. private errorHandler: ErrorHandler = errorHandler /** * `.route()` allows grouping other Hono instance in routes. * * @see {@link https://hono.dev/docs/api/routing#grouping} * * @param {string} path - base Path * @param {Hono} app - other Hono instance * @returns {Hono} routed Hono instance * * @example * ```ts * const app = new Hono() * const app2 = new Hono() * * app2.get("/user", (c) => c.text("user")) * app.route("/api", app2) // GET /api/user * ``` */ route< SubPath extends string, SubEnv extends Env, SubSchema extends Schema, SubBasePath extends string, SubCurrentPath extends string, >( path: SubPath, app: Hono ): Hono> | S, BasePath, CurrentPath> { const subApp = this.basePath(path) app.routes.map((r) => { let handler if (app.errorHandler === errorHandler) { handler = r.handler } else { handler = async (c: Context, next: Next) => (await compose([], app.errorHandler)(c, () => r.handler(c, next))).res ;(handler as any)[COMPOSED_HANDLER] = r.handler } subApp.#addRoute(r.method, r.path, handler) }) return this } /** * `.basePath()` allows base paths to be specified. * * @see {@link https://hono.dev/docs/api/routing#base-path} * * @param {string} path - base Path * @returns {Hono} changed Hono instance * * @example * ```ts * const api = new Hono().basePath('/api') * ``` */ basePath( path: SubPath ): Hono, MergePath> { const subApp = this.#clone() subApp._basePath = mergePath(this._basePath, path) return subApp } /** * `.onError()` handles an error and returns a customized Response. * * @see {@link https://hono.dev/docs/api/hono#error-handling} * * @param {ErrorHandler} handler - request Handler for error * @returns {Hono} changed Hono instance * * @example * ```ts * app.onError((err, c) => { * console.error(`${err}`) * return c.text('Custom Error Message', 500) * }) * ``` */ onError = (handler: ErrorHandler): Hono => { this.errorHandler = handler return this } /** * `.notFound()` allows you to customize a Not Found Response. * * @see {@link https://hono.dev/docs/api/hono#not-found} * * @param {NotFoundHandler} handler - request handler for not-found * @returns {Hono} changed Hono instance * * @example * ```ts * app.notFound((c) => { * return c.text('Custom 404 Message', 404) * }) * ``` */ notFound = (handler: NotFoundHandler): Hono => { this.#notFoundHandler = handler return this } /** * `.mount()` allows you to mount applications built with other frameworks into your Hono application. * * @see {@link https://hono.dev/docs/api/hono#mount} * * @param {string} path - base Path * @param {Function} applicationHandler - other Request Handler * @param {MountOptions} [options] - options of `.mount()` * @returns {Hono} mounted Hono instance * * @example * ```ts * import { Router as IttyRouter } from 'itty-router' * import { Hono } from 'hono' * // Create itty-router application * const ittyRouter = IttyRouter() * // GET /itty-router/hello * ittyRouter.get('/hello', () => new Response('Hello from itty-router')) * * const app = new Hono() * app.mount('/itty-router', ittyRouter.handle) * ``` * * @example * ```ts * const app = new Hono() * // Send the request to another application without modification. * app.mount('/app', anotherApp, { * replaceRequest: (req) => req, * }) * ``` */ mount( path: string, applicationHandler: (request: Request, ...args: any) => Response | Promise, options?: MountOptions ): Hono { // handle options let replaceRequest: MountReplaceRequest | undefined let optionHandler: MountOptionHandler | undefined if (options) { if (typeof options === 'function') { optionHandler = options } else { optionHandler = options.optionHandler if (options.replaceRequest === false) { replaceRequest = (request) => request } else { replaceRequest = options.replaceRequest } } } // prepare handlers for request const getOptions: (c: Context) => unknown[] = optionHandler ? (c) => { const options = optionHandler!(c) return Array.isArray(options) ? options : [options] } : (c) => { let executionContext: ExecutionContext | undefined = undefined try { executionContext = c.executionCtx } catch {} // Do nothing return [c.env, executionContext] } replaceRequest ||= (() => { const mergedPath = mergePath(this._basePath, path) const pathPrefixLength = mergedPath === '/' ? 0 : mergedPath.length return (request) => { const url = new URL(request.url) url.pathname = url.pathname.slice(pathPrefixLength) || '/' return new Request(url, request) } })() const handler: MiddlewareHandler = async (c, next) => { const res = await applicationHandler(replaceRequest(c.req.raw), ...getOptions(c)) if (res) { return res } await next() } this.#addRoute(METHOD_NAME_ALL, mergePath(path, '*'), handler) return this } #addRoute(method: string, path: string, handler: H): void { method = method.toUpperCase() path = mergePath(this._basePath, path) const r: RouterRoute = { basePath: this._basePath, path, method, handler } this.router.add(method, path, [handler, r]) this.routes.push(r) } #handleError(err: unknown, c: Context): Response | Promise { if (err instanceof Error) { return this.errorHandler(err, c) } throw err } #dispatch( request: Request, executionCtx: ExecutionContext | FetchEventLike | undefined, env: E['Bindings'], method: string ): Response | Promise { // Handle HEAD method if (method === 'HEAD') { return (async () => new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))() } const path = this.getPath(request, { env }) const matchResult = this.router.match(method, path) const c = new Context(request, { path, matchResult, env, executionCtx, notFoundHandler: this.#notFoundHandler, }) // Do not `compose` if it has only one handler if (matchResult[0].length === 1) { let res: ReturnType try { res = matchResult[0][0][0][0](c, async () => { c.res = await this.#notFoundHandler(c) }) } catch (err) { return this.#handleError(err, c) } return res instanceof Promise ? res .then( (resolved: Response | undefined) => resolved || (c.finalized ? c.res : this.#notFoundHandler(c)) ) .catch((err: Error) => this.#handleError(err, c)) : (res ?? this.#notFoundHandler(c)) } const composed = compose(matchResult[0], this.errorHandler, this.#notFoundHandler) return (async () => { try { const context = await composed(c) if (!context.finalized) { throw new Error( 'Context is not finalized. Did you forget to return a Response object or `await next()`?' ) } return context.res } catch (err) { return this.#handleError(err, c) } })() } /** * `.fetch()` will be entry point of your app. * * @see {@link https://hono.dev/docs/api/hono#fetch} * * @param {Request} request - request Object of request * @param {Env} Env - env Object * @param {ExecutionContext} - context of execution * @returns {Response | Promise} response of request * */ fetch: ( request: Request, Env?: E['Bindings'] | {}, executionCtx?: ExecutionContext ) => Response | Promise = (request, ...rest) => { return this.#dispatch(request, rest[1], rest[0], request.method) } /** * `.request()` is a useful method for testing. * You can pass a URL or pathname to send a GET request. * app will return a Response object. * ```ts * test('GET /hello is ok', async () => { * const res = await app.request('/hello') * expect(res.status).toBe(200) * }) * ``` * @see https://hono.dev/docs/api/hono#request */ request = ( input: Request | string | URL, requestInit?: RequestInit, Env?: E['Bindings'] | {}, executionCtx?: ExecutionContext ): Response | Promise => { if (input instanceof Request) { return this.fetch(requestInit ? new Request(input, requestInit) : input, Env, executionCtx) } input = input.toString() return this.fetch( new Request( /^https?:\/\//.test(input) ? input : `http://localhost${mergePath('/', input)}`, requestInit ), Env, executionCtx ) } /** * `.fire()` automatically adds a global fetch event listener. * This can be useful for environments that adhere to the Service Worker API, such as non-ES module Cloudflare Workers. * @deprecated * Use `fire` from `hono/service-worker` instead. * ```ts * import { Hono } from 'hono' * import { fire } from 'hono/service-worker' * * const app = new Hono() * // ... * fire(app) * ``` * @see https://hono.dev/docs/api/hono#fire * @see https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API * @see https://developers.cloudflare.com/workers/reference/migrate-to-module-workers/ */ fire = (): void => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore addEventListener('fetch', (event: FetchEventLike): void => { event.respondWith(this.#dispatch(event.request, event, undefined, event.request.method)) }) } } export { Hono as HonoBase } ================================================ FILE: src/hono.test.ts ================================================ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/ban-ts-comment */ import { expectTypeOf } from 'vitest' import { hc } from './client' import type { Context, ExecutionContext } from './context' import { Hono } from './hono' import { HTTPException } from './http-exception' import { logger } from './middleware/logger' import { poweredBy } from './middleware/powered-by' import { RegExpRouter } from './router/reg-exp-router' import { SmartRouter } from './router/smart-router' import { TrieRouter } from './router/trie-router' import type { Handler, MiddlewareHandler, Next } from './types' import type { Equal, Expect } from './utils/types' import { getPath } from './utils/url' // https://stackoverflow.com/a/65666402 function throwExpression(errorMessage: string): never { throw new Error(errorMessage) } type Env = { Bindings: { _: string } } const createResponseProxy = (response: Response) => { return new Proxy(response, { get(target, prop, receiver) { const value = target[prop as keyof Response] if (typeof value === 'function') { return Object.defineProperties( function (...args: unknown[]) { // @ts-expect-error: `this` context is intentionally dynamic for proxy method binding return Reflect.apply(value, this === receiver ? target : this, args) }, { name: { value: value.name }, length: { value: value.length }, } ) } return value }, }) } describe('GET Request', () => { describe('without middleware', () => { // In other words, this is a test for cases that do not use `compose()` const app = new Hono() app.get('/hello', async () => { return new Response('hello', { status: 200, statusText: 'Hono is OK', }) }) app.get('/hello-with-shortcuts', (c) => { c.header('X-Custom', 'This is Hono') c.status(201) return c.html('

Hono!!!

') }) app.get('/hello-env', (c) => { return c.json(c.env) }) app.get('/proxy-object', () => createResponseProxy(new Response('proxy'))) app.get('/async-proxy-object', async () => createResponseProxy(new Response('proxy'))) it('GET http://localhost/hello is ok', async () => { const res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET httphello is ng', async () => { const res = await app.request('httphello') expect(res.status).toBe(404) }) it('GET /hello is ok', async () => { const res = await app.request('/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET hello is ok', async () => { const res = await app.request('hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET /hello-with-shortcuts is ok', async () => { const res = await app.request('http://localhost/hello-with-shortcuts') expect(res).not.toBeNull() expect(res.status).toBe(201) expect(res.headers.get('X-Custom')).toBe('This is Hono') expect(res.headers.get('Content-Type')).toMatch(/text\/html/) expect(await res.text()).toBe('

Hono!!!

') }) it('GET / is not found', async () => { const res = await app.request('http://localhost/') expect(res).not.toBeNull() expect(res.status).toBe(404) }) it('GET /hello-env is ok', async () => { const res = await app.request('/hello-env', undefined, { HELLO: 'world' }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ HELLO: 'world' }) }) it('GET /proxy-object is ok', async () => { const res = await app.request('/proxy-object') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('proxy') }) it('GET /async-proxy-object is ok', async () => { const res = await app.request('/proxy-object') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('proxy') }) }) describe('with middleware', () => { // when using `compose()` const app = new Hono() app.use('*', async (ctx, next) => { await next() }) app.get('/hello', async () => { return new Response('hello', { status: 200, statusText: 'Hono is OK', }) }) app.get('/hello-with-shortcuts', (c) => { c.header('X-Custom', 'This is Hono') c.status(201) return c.html('

Hono!!!

') }) app.get('/hello-env', (c) => { return c.json(c.env) }) app.get('/proxy-object', () => createResponseProxy(new Response('proxy'))) app.get('/async-proxy-object', async () => createResponseProxy(new Response('proxy'))) it('GET http://localhost/hello is ok', async () => { const res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET httphello is ng', async () => { const res = await app.request('httphello') expect(res.status).toBe(404) }) it('GET /hello is ok', async () => { const res = await app.request('/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET hello is ok', async () => { const res = await app.request('hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(res.statusText).toBe('Hono is OK') expect(await res.text()).toBe('hello') }) it('GET /hello-with-shortcuts is ok', async () => { const res = await app.request('http://localhost/hello-with-shortcuts') expect(res).not.toBeNull() expect(res.status).toBe(201) expect(res.headers.get('X-Custom')).toBe('This is Hono') expect(res.headers.get('Content-Type')).toMatch(/text\/html/) expect(await res.text()).toBe('

Hono!!!

') }) it('GET / is not found', async () => { const res = await app.request('http://localhost/') expect(res).not.toBeNull() expect(res.status).toBe(404) }) it('GET /hello-env is ok', async () => { const res = await app.request('/hello-env', undefined, { HELLO: 'world' }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ HELLO: 'world' }) }) it('GET /proxy-object is ok', async () => { const res = await app.request('/proxy-object') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('proxy') }) it('GET /async-proxy-object is ok', async () => { const res = await app.request('/proxy-object') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('proxy') }) }) }) describe('Register handlers without a path', () => { describe('No basePath', () => { const app = new Hono() app.get((c) => { return c.text('Hello') }) it('GET http://localhost/ is ok', async () => { const res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello') }) it('GET http://localhost/anything is not found', async () => { const res = await app.request('/anything') expect(res.status).toBe(404) }) }) describe('With specifying basePath', () => { const app = new Hono().basePath('/about') app.get((c) => { return c.text('About') }) it('GET http://localhost/about is ok', async () => { const res = await app.request('/about') expect(res.status).toBe(200) expect(await res.text()).toBe('About') }) it('GET http://localhost/ is not found', async () => { const res = await app.request('/') expect(res.status).toBe(404) }) }) describe('With chaining', () => { const app = new Hono() app.post('/books').get((c) => { return c.text('Books') }) it('GET http://localhost/books is ok', async () => { const res = await app.request('/books') expect(res.status).toBe(200) expect(await res.text()).toBe('Books') }) it('GET http://localhost/ is not found', async () => { const res = await app.request('/') expect(res.status).toBe(404) }) }) }) describe('Options', () => { describe('router option', () => { it('Should be SmartRouter', () => { const app = new Hono() expect(app.router instanceof SmartRouter).toBe(true) }) it('Should be RegExpRouter', () => { const app = new Hono({ router: new RegExpRouter(), }) expect(app.router instanceof RegExpRouter).toBe(true) }) }) describe('strict parameter', () => { describe('strict is true with not slash', () => { const app = new Hono() app.get('/hello', (c) => { return c.text('/hello') }) it('/hello/ is not found', async () => { let res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) res = await app.request('http://localhost/hello/') expect(res).not.toBeNull() expect(res.status).toBe(404) }) }) describe('strict is true with slash', () => { const app = new Hono() app.get('/hello/', (c) => { return c.text('/hello/') }) it('/hello is not found', async () => { let res = await app.request('http://localhost/hello/') expect(res).not.toBeNull() expect(res.status).toBe(200) res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(404) }) }) describe('strict is false', () => { const app = new Hono({ strict: false }) app.get('/hello', (c) => { return c.text('/hello') }) it('/hello and /hello/ are treated as the same', async () => { let res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) res = await app.request('http://localhost/hello/') expect(res).not.toBeNull() expect(res.status).toBe(200) }) }) describe('strict is false with `getPath` option', () => { const app = new Hono({ strict: false, getPath: getPath, }) app.get('/hello', (c) => { return c.text('/hello') }) it('/hello and /hello/ are treated as the same', async () => { let res = await app.request('http://localhost/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) res = await app.request('http://localhost/hello/') expect(res).not.toBeNull() expect(res.status).toBe(200) }) }) }) it('Should not modify the options passed to it', () => { const options = { strict: true } const clone = structuredClone(options) const app = new Hono(clone) expect(clone).toEqual(options) }) }) describe('Destruct functions in context', () => { it('Should return 200 response - text', async () => { const app = new Hono() app.get('/text', ({ text }) => text('foo')) const res = await app.request('http://localhost/text') expect(res.status).toBe(200) }) it('Should return 200 response - json', async () => { const app = new Hono() app.get('/json', ({ json }) => json({ foo: 'bar' })) const res = await app.request('http://localhost/json') expect(res.status).toBe(200) }) }) describe('Routing', () => { it('Return it self', async () => { const app = new Hono() const app2 = app.get('/', () => new Response('get /')) expect(app2).not.toBeUndefined() app2.delete('/', () => new Response('delete /')) let res = await app2.request('http://localhost/', { method: 'GET' }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('get /') res = await app2.request('http://localhost/', { method: 'DELETE' }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('delete /') }) it('Nested route', async () => { const app = new Hono() const book = app.basePath('/book') book.get('/', (c) => c.text('get /book')) book.get('/:id', (c) => { return c.text('get /book/' + c.req.param('id')) }) book.post('/', (c) => c.text('post /book')) const user = app.basePath('/user') user.get('/login', (c) => c.text('get /user/login')) user.post('/register', (c) => c.text('post /user/register')) const appForEachUser = user.basePath(':id') appForEachUser.get('/profile', (c) => c.text('get /user/' + c.req.param('id') + '/profile')) app.get('/add-path-after-route-call', (c) => c.text('get /add-path-after-route-call')) let res = await app.request('http://localhost/book', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /book') res = await app.request('http://localhost/book/123', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /book/123') res = await app.request('http://localhost/book', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('post /book') res = await app.request('http://localhost/book/', { method: 'GET' }) expect(res.status).toBe(404) res = await app.request('http://localhost/user/login', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /user/login') res = await app.request('http://localhost/user/register', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('post /user/register') res = await app.request('http://localhost/user/123/profile', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /user/123/profile') res = await app.request('http://localhost/add-path-after-route-call', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /add-path-after-route-call') }) it('Nested route - subApp with basePath', async () => { const app = new Hono() const book = new Hono().basePath('/book') book.get('/', (c) => c.text('get /book')) app.route('/api', book) const res = await app.request('http://localhost/api/book', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /book') }) it('Multiple route', async () => { const app = new Hono() const book = new Hono() book.get('/hello', (c) => c.text('get /book/hello')) const user = new Hono() user.get('/hello', (c) => c.text('get /user/hello')) app.route('/book', book).route('/user', user) let res = await app.request('http://localhost/book/hello', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /book/hello') res = await app.request('http://localhost/user/hello', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('get /user/hello') }) describe('Nested route with middleware', () => { const api = new Hono() const api2 = api.use('*', async (_c, next) => await next()) it('Should mount routes with no type errors', () => { const app = new Hono().route('/api', api2) }) }) describe('Grouped route', () => { let one: Hono, two: Hono, three: Hono beforeEach(() => { one = new Hono() two = new Hono() three = new Hono() }) it('only works with correct order', async () => { three.get('/hi', (c) => c.text('hi')) two.route('/three', three) one.route('/two', two) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(200) }) it('fails with incorrect order 1', async () => { three.get('/hi', (c) => c.text('hi')) one.route('/two', two) two.route('/three', three) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(404) }) it('fails with incorrect order 2', async () => { two.route('/three', three) three.get('/hi', (c) => c.text('hi')) one.route('/two', two) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(404) }) it('fails with incorrect order 3', async () => { two.route('/three', three) one.route('/two', two) three.get('/hi', (c) => c.text('hi')) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(404) }) it('fails with incorrect order 4', async () => { one.route('/two', two) three.get('/hi', (c) => c.text('hi')) two.route('/three', three) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(404) }) it('fails with incorrect order 5', async () => { one.route('/two', two) two.route('/three', three) three.get('/hi', (c) => c.text('hi')) const { status } = await one.request('http://localhost/two/three/hi', { method: 'GET' }) expect(status).toBe(404) }) }) it('routing with hostname', async () => { const app = new Hono({ getPath: (req) => req.url.replace(/^https?:\/(.+?)$/, '$1'), }) const sub = new Hono() sub.get('/', (c) => c.text('hello sub')) sub.get('/foo', (c) => c.text('hello sub foo')) app.get('/www1.example.com/hello', () => new Response('hello www1')) app.get('/www2.example.com/hello', () => new Response('hello www2')) app.get('/www1.example.com/', (c) => c.text('hello www1 root')) app.route('/www1.example.com/sub', sub) let res = await app.request('http://www1.example.com/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www1') res = await app.request('http://www2.example.com/hello') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www2') res = await app.request('http://www1.example.com/') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www1 root') res = await app.request('http://www1.example.com/sub') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello sub') res = await app.request('http://www1.example.com/sub/foo') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello sub foo') }) it('routing with request header', async () => { const app = new Hono({ getPath: (req) => '/' + req.headers.get('host') + req.url.replace(/^https?:\/\/[^/]+(\/[^?]*)/, '$1'), }) const sub = new Hono() sub.get('/', (c) => c.text('hello sub')) sub.get('/foo', (c) => c.text('hello sub foo')) app.get('/www1.example.com/hello', () => new Response('hello www1')) app.get('/www2.example.com/hello', () => new Response('hello www2')) app.get('/www1.example.com/', (c) => c.text('hello www1 root')) app.route('/www1.example.com/sub', sub) let res = await app.request('http://www1.example.com/hello', { headers: { host: 'www1.example.com', }, }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www1') res = await app.request('http://www2.example.com/hello', { headers: { host: 'www2.example.com', }, }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www2') res = await app.request('http://www1.example.com/', { headers: { host: 'www1.example.com', }, }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello www1 root') res = await app.request('http://www1.example.com/sub', { headers: { host: 'www1.example.com', }, }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello sub') res = await app.request('http://www1.example.com/sub/foo', { headers: { host: 'www1.example.com', }, }) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('hello sub foo') expect(res.status).toBe(200) }) describe('routing with the bindings value', () => { const app = new Hono<{ Bindings: { host: string } }>({ getPath: (req, options) => { const url = new URL(req.url) const host = options?.env?.host const prefix = url.host === host ? '/FOO' : '' return url.pathname === '/' ? prefix : `${prefix}${url.pathname}` }, }) app.get('/about', (c) => c.text('About root')) app.get('/FOO/about', (c) => c.text('About FOO')) it('Should return 200 without specifying a hostname', async () => { const res = await app.request('/about') expect(res.status).toBe(200) expect(await res.text()).toBe('About root') }) it('Should return 200 with specifying the hostname in env', async () => { const req = new Request('http://foo.localhost/about') const res = await app.fetch(req, { host: 'foo.localhost' }) expect(res.status).toBe(200) expect(await res.text()).toBe('About FOO') }) }) describe('Chained route', () => { const app = new Hono() app .get('/chained/:abc', (c) => { const abc = c.req.param('abc') return c.text(`GET for ${abc}`) }) .post((c) => { const abc = c.req.param('abc') return c.text(`POST for ${abc}`) }) it('Should return 200 response from GET request', async () => { const res = await app.request('http://localhost/chained/abc', { method: 'GET' }) expect(res.status).toBe(200) expect(await res.text()).toBe('GET for abc') }) it('Should return 200 response from POST request', async () => { const res = await app.request('http://localhost/chained/abc', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('POST for abc') }) it('Should return 404 response from PUT request', async () => { const res = await app.request('http://localhost/chained/abc', { method: 'PUT' }) expect(res.status).toBe(404) }) }) describe('Encoded path', () => { let app: Hono beforeEach(() => { app = new Hono() }) it('should decode path parameter', async () => { app.get('/users/:id', (c) => c.text(`id is ${c.req.param('id')}`)) const res = await app.request('http://localhost/users/%C3%A7awa%20y%C3%AE%3F') expect(res.status).toBe(200) expect(await res.text()).toBe('id is çawa yî?') }) it('should decode "/"', async () => { app.get('/users/:id', (c) => c.text(`id is ${c.req.param('id')}`)) const res = await app.request('http://localhost/users/hono%2Fposts') // %2F is '/' expect(res.status).toBe(200) expect(await res.text()).toBe('id is hono/posts') }) it('should decode alphabets', async () => { app.get('/users/static', (c) => c.text('static')) const res = await app.request('http://localhost/users/%73tatic') // %73 is 's' expect(res.status).toBe(200) expect(await res.text()).toBe('static') }) it('should decode alphabets with invalid UTF-8 sequence', async () => { app.get('/static/:path', (c) => { return c.text(`by c.req.param: ${c.req.param('path')}`) }) const res = await app.request('http://localhost/%73tatic/%A4%A2') // %73 is 's', %A4%A2 is invalid UTF-8 sequence expect(res.status).toBe(200) expect(await res.text()).toBe('by c.req.param: %A4%A2') }) it('should decode alphabets with invalid percent encoding', async () => { app.get('/static/:path', (c) => { return c.text(`by c.req.param: ${c.req.param('path')}`) }) const res = await app.request('http://localhost/%73tatic/%a') // %73 is 's', %a is invalid percent encoding expect(res.status).toBe(200) expect(await res.text()).toBe('by c.req.param: %a') }) it('should not double decode', async () => { app.get('/users/:id', (c) => c.text(`posts of ${c.req.param('id')}`)) const res = await app.request('http://localhost/users/%2525') // %25 is '%' expect(res.status).toBe(200) expect(await res.text()).toBe('posts of %25') }) }) }) describe('param and query', () => { const apps: Record = {} apps['get by name'] = (() => { const app = new Hono() app.get('/entry/:id', (c) => { const id = c.req.param('id') return c.text(`id is ${id}`) }) app.get('/date/:date{[0-9]+}', (c) => { const date = c.req.param('date') return c.text(`date is ${date}`) }) app.get('/search', (c) => { const name = c.req.query('name') return c.text(`name is ${name}`) }) app.get('/multiple-values', (c) => { const queries = c.req.queries('q') ?? throwExpression('missing query values') const limit = c.req.queries('limit') ?? throwExpression('missing query values') return c.text(`q is ${queries[0]} and ${queries[1]}, limit is ${limit[0]}`) }) app.get('/add-header', (c) => { const bar = c.req.header('X-Foo') return c.text(`foo is ${bar}`) }) return app })() apps['get all as an object'] = (() => { const app = new Hono() app.get('/entry/:id', (c) => { const { id } = c.req.param() return c.text(`id is ${id}`) }) app.get('/date/:date{[0-9]+}', (c) => { const { date } = c.req.param() return c.text(`date is ${date}`) }) app.get('/search', (c) => { const { name } = c.req.query() return c.text(`name is ${name}`) }) app.get('/multiple-values', (c) => { const { q, limit } = c.req.queries() return c.text(`q is ${q[0]} and ${q[1]}, limit is ${limit[0]}`) }) app.get('/add-header', (c) => { const { 'x-foo': bar } = c.req.header() return c.text(`foo is ${bar}`) }) return app })() describe.each(Object.keys(apps))('%s', (name) => { const app = apps[name] it('param of /entry/:id is found', async () => { const res = await app.request('http://localhost/entry/123') expect(res.status).toBe(200) expect(await res.text()).toBe('id is 123') }) it('param of /entry/:id is found, even for Array object method names', async () => { const res = await app.request('http://localhost/entry/key') expect(res.status).toBe(200) expect(await res.text()).toBe('id is key') }) it('param of /entry/:id is decoded', async () => { const res = await app.request('http://localhost/entry/%C3%A7awa%20y%C3%AE%3F') expect(res.status).toBe(200) expect(await res.text()).toBe('id is çawa yî?') }) it('param of /date/:date is found', async () => { const res = await app.request('http://localhost/date/0401') expect(res.status).toBe(200) expect(await res.text()).toBe('date is 0401') }) it('query of /search?name=sam is found', async () => { const res = await app.request('http://localhost/search?name=sam') expect(res.status).toBe(200) expect(await res.text()).toBe('name is sam') }) it('query of /search?name=sam&name=tom is found', async () => { const res = await app.request('http://localhost/search?name=sam&name=tom') expect(res.status).toBe(200) expect(await res.text()).toBe('name is sam') }) it('query of /multiple-values?q=foo&q=bar&limit=10 is found', async () => { const res = await app.request('http://localhost/multiple-values?q=foo&q=bar&limit=10') expect(res.status).toBe(200) expect(await res.text()).toBe('q is foo and bar, limit is 10') }) it('/add-header header - X-Foo is Bar', async () => { const req = new Request('http://localhost/add-header') req.headers.append('X-Foo', 'Bar') const res = await app.request(req) expect(res.status).toBe(200) expect(await res.text()).toBe('foo is Bar') }) }) describe('param with undefined', () => { const app = new Hono() app.get('/foo/:foo', (c) => { const bar = c.req.param('bar') return c.json({ foo: bar }) }) it('param of /foo/foo should return undefined not "undefined"', async () => { const res = await app.request('http://localhost/foo/foo') expect(res.status).toBe(200) expect(await res.json()).toEqual({ foo: undefined }) }) }) }) describe('c.req.path', () => { const app = new Hono() app.get('/', (c) => c.text(c.req.path)) app.get('/search', (c) => c.text(c.req.path)) it('Should get the path `/` correctly', async () => { const res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe('/') }) it('Should get the path `/search` correctly with a query', async () => { const res = await app.request('/search?query=hono') expect(res.status).toBe(200) expect(await res.text()).toBe('/search') }) }) describe('Header', () => { const app = new Hono() app.get('/text', (c) => { return c.text('Hello') }) app.get('/text-with-custom-header', (c) => { c.header('X-Custom', 'Message') return c.text('Hello') }) it('Should return correct headers - /text', async () => { const res = await app.request('/text') expect(res.status).toBe(200) expect(res.headers.get('content-type')).toMatch(/^text\/plain/) expect(await res.text()).toBe('Hello') }) it('Should return correct headers - /text-with-custom-header', async () => { const res = await app.request('/text-with-custom-header') expect(res.status).toBe(200) expect(res.headers.get('x-custom')).toBe('Message') expect(res.headers.get('content-type')).toMatch(/^text\/plain/) expect(await res.text()).toBe('Hello') }) }) describe('Middleware', () => { describe('Basic', () => { const app = new Hono() // Custom Logger app.use('*', async (c, next) => { console.log(`${c.req.method} : ${c.req.url}`) await next() }) // Append Custom Header app.use('*', async (c, next) => { await next() c.res.headers.append('x-custom', 'root') }) app.use('/hello', async (c, next) => { await next() c.res.headers.append('x-message', 'custom-header') }) app.use('/hello/*', async (c, next) => { await next() c.res.headers.append('x-message-2', 'custom-header-2') }) app.get('/hello', (c) => { return c.text('hello') }) app.use('/json/*', async (c, next) => { c.res.headers.append('foo', 'bar') await next() }) app.get('/json', (c) => { // With a raw response return new Response( JSON.stringify({ message: 'hello', }), { headers: { 'content-type': 'application/json', }, } ) }) app.get('/hello/:message', (c) => { const message = c.req.param('message') return c.text(`${message}`) }) app.get('/error', () => { throw new Error('Error!') }) app.notFound((c) => { return c.text('Not Found Foo', 404) }) it('logging and custom header', async () => { const res = await app.request('http://localhost/hello') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') expect(res.headers.get('x-custom')).toBe('root') expect(res.headers.get('x-message')).toBe('custom-header') expect(res.headers.get('x-message-2')).toBe('custom-header-2') }) it('logging and custom header with named param', async () => { const res = await app.request('http://localhost/hello/message') expect(res.status).toBe(200) expect(await res.text()).toBe('message') expect(res.headers.get('x-custom')).toBe('root') expect(res.headers.get('x-message-2')).toBe('custom-header-2') }) it('should return correct the content-type header', async () => { const res = await app.request('http://localhost/json') expect(res.status).toBe(200) expect(res.headers.get('content-type')).toMatch(/^application\/json/) }) it('not found', async () => { const res = await app.request('http://localhost/foo') expect(res.status).toBe(404) expect(await res.text()).toBe('Not Found Foo') }) it('internal server error', async () => { const res = await app.request('http://localhost/error') expect(res.status).toBe(500) console.log(await res.text()) }) }) describe('Chained route', () => { const app = new Hono() app .use('/chained/*', async (c, next) => { c.req.raw.headers.append('x-before', 'abc') await next() }) .use(async (c, next) => { await next() c.header( 'x-after', c.req.header('x-before') ?? throwExpression('missing `x-before` header') ) }) .get('/chained/abc', (c) => { return c.text('GET chained') }) it('GET /chained/abc', async () => { const res = await app.request('http://localhost/chained/abc') expect(res.status).toBe(200) expect(await res.text()).toBe('GET chained') expect(res.headers.get('x-after')).toBe('abc') }) }) describe('Multiple handler', () => { const app = new Hono() app .use( '/multiple/*', async (c, next) => { c.req.raw.headers.append('x-before', 'abc') await next() }, async (c, next) => { await next() c.header( 'x-after', c.req.header('x-before') ?? throwExpression('missing `x-before` header') ) } ) .get('/multiple/abc', (c) => { return c.text('GET multiple') }) it('GET /multiple/abc', async () => { const res = await app.request('http://localhost/multiple/abc') expect(res.status).toBe(200) expect(await res.text()).toBe('GET multiple') expect(res.headers.get('x-after')).toBe('abc') }) }) describe('Overwrite the response from middleware after next()', () => { const app = new Hono() app.use('/normal', async (c, next) => { await next() c.res = new Response('Middleware') }) app.use('/overwrite', async (c, next) => { await next() c.res = undefined c.res = new Response('Middleware') }) app.get('*', (c) => { c.header('x-custom', 'foo') return c.text('Handler') }) it('Should have the custom header', async () => { const res = await app.request('/normal') expect(res.headers.get('x-custom')).toBe('foo') }) it('Should not have the custom header', async () => { const res = await app.request('/overwrite') expect(res.headers.get('x-custom')).toBe(null) }) }) }) describe('Builtin Middleware', () => { const app = new Hono() app.use('/abc', poweredBy()) app.use('/def', async (c, next) => { const middleware = poweredBy() await middleware(c, next) }) app.get('/abc', () => new Response()) app.get('/def', () => new Response()) it('"powered-by" middleware', async () => { const res = await app.request('http://localhost/abc') expect(res.headers.get('x-powered-by')).toBe('Hono') }) it('"powered-by" middleware in a handler', async () => { const res = await app.request('http://localhost/def') expect(res.headers.get('x-powered-by')).toBe('Hono') }) }) describe('Middleware with app.HTTP_METHOD', () => { describe('Basic', () => { const app = new Hono() app.all('*', async (c, next) => { c.header('x-before-dispatch', 'foo') await next() c.header('x-custom-message', 'hello') }) const customHeader = async (c: Context, next: Next) => { c.req.raw.headers.append('x-custom-foo', 'bar') await next() } const customHeader2 = async (c: Context, next: Next) => { await next() c.header('x-custom-foo-2', 'bar-2') } app .get('/abc', customHeader, (c) => { const foo = c.req.header('x-custom-foo') || '' return c.text(foo) }) .post(customHeader2, (c) => { return c.text('POST /abc') }) it('GET /abc', async () => { const res = await app.request('http://localhost/abc') expect(res.status).toBe(200) expect(res.headers.get('x-custom-message')).toBe('hello') expect(res.headers.get('x-before-dispatch')).toBe('foo') expect(await res.text()).toBe('bar') }) it('POST /abc', async () => { const res = await app.request('http://localhost/abc', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('POST /abc') expect(res.headers.get('x-custom-foo-2')).toBe('bar-2') }) }) describe('With builtin middleware', () => { const app = new Hono() app.get('/abc', poweredBy(), (c) => { return c.text('GET /abc') }) it('GET /abc', async () => { const res = await app.request('http://localhost/abc') expect(res.status).toBe(200) expect(await res.text()).toBe('GET /abc') expect(res.headers.get('x-powered-by')).toBe('Hono') }) }) }) describe('Not Found', () => { const app = new Hono() app.notFound((c) => { return c.text('Custom 404 Not Found', 404) }) app.get('/hello', (c) => { return c.text('hello') }) app.get('/notfound', (c) => { return c.notFound() }) it('Custom 404 Not Found', async () => { let res = await app.request('http://localhost/hello') expect(res.status).toBe(200) res = await app.request('http://localhost/notfound') expect(res.status).toBe(404) res = await app.request('http://localhost/foo') expect(res.status).toBe(404) expect(await res.text()).toBe('Custom 404 Not Found') }) describe('Not Found with a middleware', () => { const app = new Hono() app.get('/', (c) => c.text('hello')) app.use('*', async (c, next) => { await next() c.res = new Response((await c.res.text()) + ' + Middleware', c.res) }) it('Custom 404 Not Found', async () => { let res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('http://localhost/foo') expect(res.status).toBe(404) expect(await res.text()).toBe('404 Not Found + Middleware') }) }) describe('Not Found with some middleware', () => { const app = new Hono() app.get('/', (c) => c.text('hello')) app.use('*', async (c, next) => { await next() c.res = new Response((await c.res.text()) + ' + Middleware 1', c.res) }) app.use('*', async (c, next) => { await next() c.res = new Response((await c.res.text()) + ' + Middleware 2', c.res) }) it('Custom 404 Not Found', async () => { let res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('http://localhost/foo') expect(res.status).toBe(404) expect(await res.text()).toBe('404 Not Found + Middleware 2 + Middleware 1') }) }) describe('No response from a handler', () => { const app = new Hono() app.get('/', (c) => c.text('hello')) app.get('/not-found', async (c) => undefined) it('Custom 404 Not Found', async () => { let res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('http://localhost/not-found') expect(res.status).toBe(404) expect(await res.text()).toBe('404 Not Found') }) }) describe('Custom 404 Not Found with a middleware like Compress Middleware', () => { const app = new Hono() // Custom Middleware which creates a new Response object after `next()`. app.use('*', async (c, next) => { await next() c.res = new Response(await c.res.text(), c.res) }) app.notFound((c) => { return c.text('Custom NotFound', 404) }) it('Custom 404 Not Found', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(404) expect(await res.text()).toBe('Custom NotFound') }) }) }) describe('Redirect', () => { const app = new Hono() app.get('/redirect', (c) => { return c.redirect('/') }) it('Absolute URL', async () => { const res = await app.request('https://example.com/redirect') expect(res.status).toBe(302) expect(res.headers.get('Location')).toBe('/') }) }) describe('Error handle', () => { describe('Basic', () => { const app = new Hono() app.get('/error', () => { throw new Error('This is Error') }) app.get('/error-string', () => { throw 'This is Error' }) app.use('/error-middleware', async () => { throw new Error('This is Middleware Error') }) app.onError((err, c) => { c.header('x-debug', err.message) return c.text('Custom Error Message', 500) }) it('Should throw Error if a non-Error object is thrown in a handler', async () => { expect(() => app.request('/error-string')).toThrowError() }) it('Custom Error Message', async () => { let res = await app.request('https://example.com/error') expect(res.status).toBe(500) expect(await res.text()).toBe('Custom Error Message') expect(res.headers.get('x-debug')).toBe('This is Error') res = await app.request('https://example.com/error-middleware') expect(res.status).toBe(500) expect(await res.text()).toBe('Custom Error Message') expect(res.headers.get('x-debug')).toBe('This is Middleware Error') }) }) describe('Async custom handler', () => { const app = new Hono() app.get('/error', () => { throw new Error('This is Error') }) app.use('/error-middleware', async () => { throw new Error('This is Middleware Error') }) app.onError(async (err, c) => { const promise = new Promise((resolve) => setTimeout(() => { resolve('Promised') }, 1) ) const message = (await promise) as string c.header('x-debug', err.message) return c.text(`Custom Error Message with ${message}`, 500) }) it('Custom Error Message', async () => { let res = await app.request('https://example.com/error') expect(res.status).toBe(500) expect(await res.text()).toBe('Custom Error Message with Promised') expect(res.headers.get('x-debug')).toBe('This is Error') res = await app.request('https://example.com/error-middleware') expect(res.status).toBe(500) expect(await res.text()).toBe('Custom Error Message with Promised') expect(res.headers.get('x-debug')).toBe('This is Middleware Error') }) }) describe('Handle HTTPException', () => { const app = new Hono() app.get('/exception', () => { throw new HTTPException(401, { message: 'Unauthorized', }) }) it('Should return 401 response', async () => { const res = await app.request('http://localhost/exception') expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') }) const app2 = new Hono() app2.get('/exception', () => { throw new HTTPException(401) }) app2.onError((err, c) => { if (err instanceof HTTPException && err.status === 401) { return c.text('Custom Error Message', 401) } return c.text('Internal Server Error', 500) }) it('Should return 401 response with a custom message', async () => { const res = await app2.request('http://localhost/exception') expect(res.status).toBe(401) expect(await res.text()).toBe('Custom Error Message') }) }) describe('Handle HTTPException like object', () => { const app = new Hono() class CustomError extends Error { getResponse() { return new Response('Custom Error', { status: 400 }) } } app.get('/exception', () => { throw new CustomError() }) it('Should return 401 response', async () => { const res = await app.request('http://localhost/exception') expect(res.status).toBe(400) expect(await res.text()).toBe('Custom Error') }) }) describe('HTTPException with finally block', () => { const app = new Hono() app.use(async (c) => { try { throw new Error() } catch (cause) { throw new HTTPException(302, { cause, res: c.redirect('/?error=invalid_request', 302), }) } finally { c.header('x-custom', 'custom message') } }) it('Should have the custom header', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(302) expect(res.headers.get('x-custom')).toBe('custom message') }) }) }) describe('Error handling in middleware', () => { const app = new Hono() app.get('/handle-error-in-middleware', async (c, next) => { await next() if (c.error) { const message = c.error.message c.res = c.text(`Handle the error in middleware, original message is ${message}`, 500) } }) app.get('/handle-error-in-middleware-async', async (c, next) => { await next() if (c.error) { const message = c.error.message c.res = c.text( `Handle the error in middleware with async, original message is ${message}`, 500 ) } }) app.get('/handle-error-in-middleware', () => { throw new Error('Error message') }) app.get('/handle-error-in-middleware-async', async () => { throw new Error('Error message') }) it('Should handle the error in middleware', async () => { const res = await app.request('https://example.com/handle-error-in-middleware') expect(res.status).toBe(500) expect(await res.text()).toBe( 'Handle the error in middleware, original message is Error message' ) }) it('Should handle the error in middleware - async', async () => { const res = await app.request('https://example.com/handle-error-in-middleware-async') expect(res.status).toBe(500) expect(await res.text()).toBe( 'Handle the error in middleware with async, original message is Error message' ) }) describe('Default route app.use', () => { const app = new Hono() app .use(async (c, next) => { c.header('x-default-use', 'abc') await next() }) .get('/multiple/abc', (c) => { return c.text('GET multiple') }) it('GET /multiple/abc', async () => { const res = await app.request('http://localhost/multiple/abc') expect(res.status).toBe(200) expect(await res.text()).toBe('GET multiple') expect(res.headers.get('x-default-use')).toBe('abc') }) }) describe('Error in `notFound()`', () => { const app = new Hono() app.use('*', async () => {}) app.notFound(() => { throw new Error('Error in Not Found') }) app.onError((err, c) => { return c.text(err.message, 400) }) it('Should handle the error thrown in `notFound()``', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(400) expect(await res.text()).toBe('Error in Not Found') }) }) }) describe('Request methods with custom middleware', () => { const app = new Hono() app.use('*', async (c, next) => { const query = c.req.query('foo') // @ts-ignore const param = c.req.param('foo') // This will cause a type error. const header = c.req.header('User-Agent') await next() c.header('X-Query-2', query ?? throwExpression('missing `X-Query-2` header')) c.header('X-Param-2', param) c.header('X-Header-2', header ?? throwExpression('missing `X-Header-2` header')) }) app.get('/:foo', (c) => { const query = c.req.query('foo') const param = c.req.param('foo') const header = c.req.header('User-Agent') c.header('X-Query', query ?? throwExpression('missing `X-Query` header')) c.header('X-Param', param) c.header('X-Header', header ?? throwExpression('missing `X-Header` header')) return c.body('Hono') }) it('query', async () => { const url = new URL('http://localhost/bar') url.searchParams.append('foo', 'bar') const req = new Request(url.toString()) req.headers.append('User-Agent', 'bar') const res = await app.request(req) expect(res.status).toBe(200) expect(res.headers.get('X-Query')).toBe('bar') expect(res.headers.get('X-Param')).toBe('bar') expect(res.headers.get('X-Header')).toBe('bar') expect(res.headers.get('X-Query-2')).toBe('bar') expect(res.headers.get('X-Param-2')).toBe(null) expect(res.headers.get('X-Header-2')).toBe('bar') }) }) describe('Middleware + c.json(0, requestInit)', () => { const app = new Hono() app.use('/', async (c, next) => { await next() }) app.get('/', (c) => { return c.json(0, { status: 200, headers: { foo: 'bar', }, }) }) it('Should return a correct headers', async () => { const res = await app.request('/') expect(res.headers.get('content-type')).toMatch(/^application\/json/) expect(res.headers.get('foo')).toBe('bar') }) }) describe('Hono with `app.route`', () => { describe('Basic', () => { const app = new Hono() const api = new Hono() const middleware = new Hono() api.use('*', async (c, next) => { await next() c.res.headers.append('x-custom-a', 'a') }) api.get('/posts', (c) => c.text('List')) api.post('/posts', (c) => c.text('Create')) api.get('/posts/:id', (c) => c.text(`GET ${c.req.param('id')}`)) middleware.use('*', async (c, next) => { await next() c.res.headers.append('x-custom-b', 'b') }) app.route('/api', middleware) app.route('/api', api) app.get('/foo', (c) => c.text('bar')) it('Should return not found response', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(404) }) it('Should return not found response', async () => { const res = await app.request('http://localhost/posts') expect(res.status).toBe(404) }) test('GET /api/posts', async () => { const res = await app.request('http://localhost/api/posts') expect(res.status).toBe(200) expect(await res.text()).toBe('List') }) test('Custom header by middleware', async () => { const res = await app.request('http://localhost/api/posts') expect(res.status).toBe(200) expect(res.headers.get('x-custom-a')).toBe('a') expect(res.headers.get('x-custom-b')).toBe('b') }) test('POST /api/posts', async () => { const res = await app.request('http://localhost/api/posts', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('Create') }) test('GET /api/posts/123', async () => { const res = await app.request('http://localhost/api/posts/123') expect(res.status).toBe(200) expect(await res.text()).toBe('GET 123') }) test('GET /foo', async () => { const res = await app.request('http://localhost/foo') expect(res.status).toBe(200) expect(await res.text()).toBe('bar') }) describe('With app.get(...handler)', () => { const app = new Hono() const about = new Hono() about.get((c) => c.text('me')) const subApp = new Hono() subApp.route('/about', about) app.route('/', subApp) it('Should return 200 response - /about', async () => { const res = await app.request('/about') expect(res.status).toBe(200) expect(await res.text()).toBe('me') }) test('Should return 404 response /about/foo', async () => { const res = await app.request('/about/foo') expect(res.status).toBe(404) }) }) describe('With app.get(...handler) and app.basePath()', () => { const app = new Hono() const about = new Hono().basePath('/about') about.get((c) => c.text('me')) app.route('/', about) it('Should return 200 response - /about', async () => { const res = await app.request('/about') expect(res.status).toBe(200) expect(await res.text()).toBe('me') }) test('Should return 404 response /about/foo', async () => { const res = await app.request('/about/foo') expect(res.status).toBe(404) }) }) }) describe('Chaining', () => { const app = new Hono() const route = new Hono() route.get('/post', (c) => c.text('GET /POST v2')).post((c) => c.text('POST /POST v2')) app.route('/v2', route) it('Should return 200 response - GET /v2/post', async () => { const res = await app.request('http://localhost/v2/post') expect(res.status).toBe(200) expect(await res.text()).toBe('GET /POST v2') }) it('Should return 200 response - POST /v2/post', async () => { const res = await app.request('http://localhost/v2/post', { method: 'POST' }) expect(res.status).toBe(200) expect(await res.text()).toBe('POST /POST v2') }) it('Should return 404 response - DELETE /v2/post', async () => { const res = await app.request('http://localhost/v2/post', { method: 'DELETE' }) expect(res.status).toBe(404) }) }) describe('Nested', () => { const app = new Hono() const api = new Hono() const book = new Hono() book.get('/', (c) => c.text('list books')) book.get('/:id', (c) => c.text(`book ${c.req.param('id')}`)) api.get('/', (c) => c.text('this is API')) api.route('/book', book) app.get('/', (c) => c.text('root')) app.route('/v2', api) it('Should return 200 response - GET /', async () => { const res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('root') }) it('Should return 200 response - GET /v2', async () => { const res = await app.request('http://localhost/v2') expect(res.status).toBe(200) expect(await res.text()).toBe('this is API') }) it('Should return 200 response - GET /v2/book', async () => { const res = await app.request('http://localhost/v2/book') expect(res.status).toBe(200) expect(await res.text()).toBe('list books') }) it('Should return 200 response - GET /v2/book/123', async () => { const res = await app.request('http://localhost/v2/book/123') expect(res.status).toBe(200) expect(await res.text()).toBe('book 123') }) }) describe('onError', () => { const app = new Hono() const sub = new Hono() app.use('*', async (c, next) => { await next() if (c.req.query('app-error')) { throw new Error('This is Error') } }) app.onError((err, c) => { return c.text('onError by app', 500) }) sub.get('/posts/:id', async (c, next) => { c.header('handler-chain', '1') await next() }) sub.get('/posts/:id', (c) => { return c.text(`post: ${c.req.param('id')}`) }) sub.get('/error', () => { throw new Error('This is Error') }) sub.onError((err, c) => { return c.text('onError by sub', 500) }) app.route('/sub', sub) it('GET /posts/123 for sub', async () => { const res = await app.request('https://example.com/sub/posts/123') expect(res.status).toBe(200) expect(res.headers.get('handler-chain')).toBe('1') expect(await res.text()).toBe('post: 123') }) it('should be handled by app', async () => { const res = await app.request('https://example.com/sub/ok?app-error=1') expect(res.status).toBe(500) expect(await res.text()).toBe('onError by app') }) it('should be handled by sub', async () => { const res = await app.request('https://example.com/sub/error') expect(res.status).toBe(500) expect(await res.text()).toBe('onError by sub') }) }) describe('onError for a single handler', () => { const app = new Hono() const sub = new Hono() sub.get('/ok', (c) => c.text('OK')) sub.get('/error', () => { throw new Error('This is Error') }) sub.onError((err, c) => { return c.text('onError by sub', 500) }) app.route('/sub', sub) it('ok', async () => { const res = await app.request('https://example.com/sub/ok') expect(res.status).toBe(200) }) it('error', async () => { const res = await app.request('https://example.com/sub/error') expect(res.status).toBe(500) expect(await res.text()).toBe('onError by sub') }) }) describe('notFound', () => { const app = new Hono() const sub = new Hono() app.get('/explicit-404', async (c) => { c.header('explicit', '1') }) app.notFound((c) => { return c.text('404 Not Found by app', 404) }) sub.get('/ok', (c) => { return c.text('ok') }) sub.get('/explicit-404', async (c) => { c.header('explicit', '1') }) sub.notFound((c) => { return c.text('404 Not Found by sub', 404) }) app.route('/sub', sub) it('/explicit-404 should be handled on app', async () => { const res = await app.request('https://example.com/explicit-404') expect(res.status).toBe(404) expect(res.headers.get('explicit')).toBe('1') expect(await res.text()).toBe('404 Not Found by app') }) it('/sub/explicit-404 should be handled on app', async () => { const res = await app.request('https://example.com/sub/explicit-404') expect(res.status).toBe(404) expect(res.headers.get('explicit')).toBe('1') expect(await res.text()).toBe('404 Not Found by app') }) it('/implicit-404 should be handled by app', async () => { const res = await app.request('https://example.com/implicit-404') expect(res.status).toBe(404) expect(res.headers.get('explicit')).toBe(null) expect(await res.text()).toBe('404 Not Found by app') }) it('/sub/implicit-404 should be handled by sub', async () => { const res = await app.request('https://example.com/sub/implicit-404') expect(res.status).toBe(404) expect(res.headers.get('explicit')).toBe(null) expect(await res.text()).toBe('404 Not Found by app') }) }) }) describe('Using other methods with `app.on`', () => { it('Should handle PURGE method with RegExpRouter', async () => { const app = new Hono({ router: new RegExpRouter() }) app.on('PURGE', '/purge', (c) => c.text('Accepted', 202)) const req = new Request('http://localhost/purge', { method: 'PURGE', }) const res = await app.request(req) expect(res.status).toBe(202) expect(await res.text()).toBe('Accepted') }) it('Should handle PURGE method with TrieRouter', async () => { const app = new Hono({ router: new TrieRouter() }) app.on('PURGE', '/purge', (c) => c.text('Accepted', 202)) const req = new Request('http://localhost/purge', { method: 'PURGE', }) const res = await app.request(req) expect(res.status).toBe(202) expect(await res.text()).toBe('Accepted') }) }) describe('Multiple methods with `app.on`', () => { const app = new Hono() app.on(['PUT', 'DELETE'], '/posts/:id', (c) => { return c.json({ postId: c.req.param('id'), method: c.req.method, }) }) it('Should return 200 with PUT', async () => { const req = new Request('http://localhost/posts/123', { method: 'PUT', }) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.json()).toEqual({ postId: '123', method: 'PUT', }) }) it('Should return 200 with DELETE', async () => { const req = new Request('http://localhost/posts/123', { method: 'DELETE', }) const res = await app.request(req) expect(res.status).toBe(200) expect(await res.json()).toEqual({ postId: '123', method: 'DELETE', }) }) it('Should return 404 with POST', async () => { const req = new Request('http://localhost/posts/123', { method: 'POST', }) const res = await app.request(req) expect(res.status).toBe(404) }) }) describe('Multiple paths with one handler', () => { const app = new Hono() const paths = ['/hello', '/ja/hello', '/en/hello'] app.on('GET', paths, (c) => { return c.json({ path: c.req.path, routePath: c.req.routePath, }) }) it('Should handle multiple paths', async () => { paths.map(async (path) => { const res = await app.request(path) expect(res.status).toBe(200) const data = await res.json() expect(data).toEqual({ path, routePath: path, }) }) }) }) describe('Multiple handler', () => { describe('handler + handler', () => { const app = new Hono() app.get('/posts/:id', (c) => { const id = c.req.param('id') c.header('foo', 'bar') return c.text(`id is ${id}`) }) app.get('/:type/:id', (c) => { c.status(404) c.header('foo2', 'bar2') return c.text('foo') }) it('Should return response from `specialized` route', async () => { const res = await app.request('http://localhost/posts/123') expect(res.status).toBe(200) expect(await res.text()).toBe('id is 123') expect(res.headers.get('foo')).toBe('bar') expect(res.headers.get('foo2')).toBeNull() }) }) describe('Duplicate param name', () => { describe('basic', () => { const app = new Hono() app.get('/:type/:url', (c) => { return c.text(`type: ${c.req.param('type')}, url: ${c.req.param('url')}`) }) app.get('/foo/:type/:url', (c) => { return c.text(`foo type: ${c.req.param('type')}, url: ${c.req.param('url')}`) }) it('Should return a correct param - GET /car/good-car', async () => { const res = await app.request('/car/good-car') expect(res.ok).toBe(true) expect(await res.text()).toBe('type: car, url: good-car') }) it('Should return a correct param - GET /foo/food/good-food', async () => { const res = await app.request('/foo/food/good-food') expect(res.ok).toBe(true) expect(await res.text()).toBe('foo type: food, url: good-food') }) }) describe('self', () => { const app = new Hono() app.get('/:id/:id', (c) => { const id = c.req.param('id') return c.text(`id is ${id}`) }) it('Should return 123 - GET /123/456', async () => { const res = await app.request('/123/456') expect(res.status).toBe(200) expect(await res.text()).toBe('id is 123') }) }) describe('hierarchy', () => { const app = new Hono() app.get('/posts/:id/comments/:comment_id', (c) => { return c.text(`post: ${c.req.param('id')}, comment: ${c.req.param('comment_id')}`) }) app.get('/posts/:id', (c) => { return c.text(`post: ${c.req.param('id')}`) }) it('Should return a correct param - GET /posts/123/comments/456', async () => { const res = await app.request('/posts/123/comments/456') expect(res.status).toBe(200) expect(await res.text()).toBe('post: 123, comment: 456') }) it('Should return a correct param - GET /posts/789', async () => { const res = await app.request('/posts/789') expect(res.status).toBe(200) expect(await res.text()).toBe('post: 789') }) }) describe('different regular expression', () => { const app = new Hono() app.get('/:id/:action{create|update}', (c) => { return c.text(`id: ${c.req.param('id')}, action: ${c.req.param('action')}`) }) app.get('/:id/:action{delete}', (c) => { return c.text(`id: ${c.req.param('id')}, action: ${c.req.param('action')}`) }) it('Should return a correct param - GET /123/create', async () => { const res = await app.request('/123/create') expect(res.status).toBe(200) expect(await res.text()).toBe('id: 123, action: create') }) it('Should return a correct param - GET /456/update', async () => { const res = await app.request('/467/update') expect(res.status).toBe(200) expect(await res.text()).toBe('id: 467, action: update') }) it('Should return a correct param - GET /789/delete', async () => { const res = await app.request('/789/delete') expect(res.status).toBe(200) expect(await res.text()).toBe('id: 789, action: delete') }) }) }) }) describe('Multiple handler - async', () => { describe('handler + handler', () => { const app = new Hono() app.get('/posts/:id', async (c) => { await new Promise((resolve) => setTimeout(resolve, 1)) c.header('foo2', 'bar2') const id = c.req.param('id') return c.text(`id is ${id}`) }) app.get('/:type/:id', async (c) => { await new Promise((resolve) => setTimeout(resolve, 1)) c.header('foo', 'bar') c.status(404) return c.text('foo') }) it('Should return response from `specialized` route', async () => { const res = await app.request('http://localhost/posts/123') expect(res.status).toBe(200) expect(await res.text()).toBe('id is 123') expect(res.headers.get('foo')).toBeNull() expect(res.headers.get('foo2')).toBe('bar2') }) }) }) describe('Lack returning response with a single handler', () => { const app = new Hono() // @ts-expect-error it should return Response to type it app.get('/sync', () => {}) app.get('/async', async () => {}) it('Should return 404 response if lacking returning response', async () => { const res = await app.request('/sync') expect(res.status).toBe(404) }) it('Should return 404 response if lacking returning response in an async handler', async () => { const res = await app.request('/async') expect(res.status).toBe(404) }) }) describe('Context is not finalized', () => { it('should throw error - lack `await next()`', async () => { const app = new Hono() // @ts-ignore app.use('*', () => {}) app.get('/foo', (c) => { return c.text('foo') }) app.onError((err, c) => { return c.text(err.message, 500) }) const res = await app.request('http://localhost/foo') expect(res.status).toBe(500) expect(await res.text()).toMatch(/^Context is not finalized/) }) it('should throw error - lack `returning Response`', async () => { const app = new Hono() app.use('*', async (_c, next) => { await next() }) // @ts-ignore app.get('/foo', () => {}) app.onError((err, c) => { return c.text(err.message, 500) }) const res = await app.request('http://localhost/foo') expect(res.status).toBe(500) expect(await res.text()).toMatch(/^Context is not finalized/) }) }) describe('Parse Body', () => { const app = new Hono() app.post('/json', async (c) => { return c.json<{}, 200>(await c.req.parseBody(), 200) }) app.post('/form', async (c) => { return c.json<{}, 200>(await c.req.parseBody(), 200) }) it('POST with JSON', async () => { const req = new Request('http://localhost/json', { method: 'POST', body: JSON.stringify({ message: 'hello hono' }), headers: new Headers({ 'Content-Type': 'application/json' }), }) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) }) it('POST with `multipart/form-data`', async () => { const formData = new FormData() formData.append('message', 'hello') const req = new Request('https://localhost/form', { method: 'POST', body: formData, }) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'hello' }) }) it('POST with `application/x-www-form-urlencoded`', async () => { const searchParam = new URLSearchParams() searchParam.append('message', 'hello') const req = new Request('https://localhost/form', { method: 'POST', body: searchParam, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, }) const res = await app.request(req) expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'hello' }) }) }) describe('Both two middleware returning response', () => { it('Should return correct Content-Type`', async () => { const app = new Hono() app.use('*', async (c, next) => { await next() return c.html('Foo') }) app.get('/', (c) => { return c.text('Bar') }) const res = await app.request('http://localhost/') expect(res).not.toBeNull() expect(res.status).toBe(200) expect(await res.text()).toBe('Bar') expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) }) }) describe('Count of logger called', () => { // It will be added `2` each time the logger is called once. let count = 0 let log = '' const app = new Hono() const logFn = (str: string) => { count++ log = str } app.use('*', logger(logFn)) app.get('/', (c) => c.text('foo')) it('Should be called two times', async () => { const res = await app.request('http://localhost/not-found') expect(res).not.toBeNull() expect(res.status).toBe(404) expect(await res.text()).toBe('404 Not Found') expect(count).toBe(2) expect(log).toMatch(/404/) }) it('Should be called two times / Custom Not Found', async () => { app.notFound((c) => c.text('Custom Not Found', 404)) const res = await app.request('http://localhost/custom-not-found') expect(res).not.toBeNull() expect(res.status).toBe(404) expect(await res.text()).toBe('Custom Not Found') expect(count).toBe(4) expect(log).toMatch(/404/) }) }) describe('Context set/get variables', () => { type Variables = { id: number title: string } const app = new Hono<{ Variables: Variables }>() it('Should set and get variables with correct types', async () => { app.use('*', async (c, next) => { c.set('id', 123) c.set('title', 'Hello') await next() }) app.get('/', (c) => { const id = c.get('id') const title = c.get('title') // type verifyID = Expect> expectTypeOf(id).toEqualTypeOf() // type verifyTitle = Expect> expectTypeOf(title).toEqualTypeOf() return c.text(`${id} is ${title}`) }) const res = await app.request('http://localhost/') expect(res.status).toBe(200) expect(await res.text()).toBe('123 is Hello') }) }) describe('Context binding variables', () => { type Bindings = { USER_ID: number USER_NAME: string } const app = new Hono<{ Bindings: Bindings }>() it('Should get binding variables with correct types', async () => { app.get('/', (c) => { expectTypeOf(c.env).toEqualTypeOf() return c.text('These are verified') }) const res = await app.request('http://localhost/') expect(res.status).toBe(200) }) }) describe('Handler as variables', () => { const app = new Hono() it('Should be typed correctly', async () => { const handler: Handler = (c) => { const id = c.req.param('id') return c.text(`Post id is ${id}`) } app.get('/posts/:id', handler) const res = await app.request('http://localhost/posts/123') expect(res.status).toBe(200) expect(await res.text()).toBe('Post id is 123') }) }) describe('json', () => { const api = new Hono() api.get('/message', (c) => { return c.json({ message: 'Hello', }) }) api.get('/message-async', async (c) => { return c.json({ message: 'Hello', }) }) describe('Single handler', () => { const app = new Hono() app.route('/api', api) it('Should return 200 response', async () => { const res = await app.request('http://localhost/api/message') expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'Hello', }) }) it('Should return 200 response - with async', async () => { const res = await app.request('http://localhost/api/message-async') expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'Hello', }) }) }) describe('With middleware', () => { const app = new Hono() app.use('*', async (_c, next) => { await next() }) app.route('/api', api) it('Should return 200 response', async () => { const res = await app.request('http://localhost/api/message') expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'Hello', }) }) it('Should return 200 response - with async', async () => { const res = await app.request('http://localhost/api/message-async') expect(res.status).toBe(200) expect(await res.json()).toEqual({ message: 'Hello', }) }) }) }) describe('Optional parameters', () => { const app = new Hono() app.get('/api/:version/animal/:type?', (c) => { const type1 = c.req.param('type') expectTypeOf(type1).toEqualTypeOf() const { type, version } = c.req.param() expectTypeOf(version).toEqualTypeOf() expectTypeOf(type).toEqualTypeOf() return c.json({ type: type, }) }) it('Should match with an optional parameter', async () => { const res = await app.request('http://localhost/api/v1/animal/bird') expect(res.status).toBe(200) expect(await res.json()).toEqual({ type: 'bird', }) }) it('Should match without an optional parameter', async () => { const res = await app.request('http://localhost/api/v1/animal') expect(res.status).toBe(200) expect(await res.json()).toEqual({ type: undefined, }) }) it('Should have a correct type with an optional parameter in a regexp path', async () => { const app = new Hono() app.get('/url/:url{.*}?', (c) => { const url = c.req.param('url') expectTypeOf(url).toEqualTypeOf() return c.json(0) }) }) }) describe('app.mount()', () => { describe('Basic', () => { const anotherApp = (req: Request, ...params: unknown[]) => { const path = getPath(req) if (path === '/') { return new Response('AnotherApp') } if (path === '/hello') { return new Response('Hello from AnotherApp') } if (path === '/header') { const message = req.headers.get('x-message') return new Response(message) } if (path === '/with-query') { const queryStrings = new URL(req.url).searchParams.toString() return new Response(queryStrings) } if (path == '/with-params') { return new Response( JSON.stringify({ params, }), { headers: { 'Content-Type': 'application.json', }, } ) } if (path === '/undefined') { return undefined as unknown as Response } return new Response('Not Found from AnotherApp', { status: 404, }) } const app = new Hono() app.use('*', async (c, next) => { await next() c.header('x-message', 'Foo') }) app.get('/', (c) => c.text('Hono')) app.notFound((c) => { return c.text('Not Found from App', 404) }) app.mount('/another-app', anotherApp, () => { return 'params' }) app.mount('/another-app-with-array-option', anotherApp, () => { return ['param1', 'param2'] }) app.mount('/another-app2/sub-slash/', anotherApp) const api = new Hono().basePath('/api') api.mount('/another-app', anotherApp) it('Should return responses from Hono app', async () => { const res = await app.request('/') expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('Foo') expect(await res.text()).toBe('Hono') }) it('Should return responses from AnotherApp', async () => { let res = await app.request('/another-app') expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('Foo') expect(await res.text()).toBe('AnotherApp') res = await app.request('/another-app/hello') expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('Foo') expect(await res.text()).toBe('Hello from AnotherApp') const req = new Request('http://localhost/another-app/header', { headers: { 'x-message': 'Message Foo!', }, }) res = await app.request(req) expect(res.status).toBe(200) expect(res.headers.get('x-message')).toBe('Foo') expect(await res.text()).toBe('Message Foo!') res = await app.request('/another-app/not-found') expect(res.status).toBe(404) expect(res.headers.get('x-message')).toBe('Foo') expect(await res.text()).toBe('Not Found from AnotherApp') res = await app.request('/another-app/with-query?foo=bar&baz=qux') expect(res.status).toBe(200) expect(await res.text()).toBe('foo=bar&baz=qux') res = await app.request('/another-app/with-params') expect(res.status).toBe(200) expect(await res.json()).toEqual({ params: ['params'], }) res = await app.request('/another-app/undefined') expect(res.status).toBe(404) expect(await res.text()).toBe('Not Found from App') }) it('Should return response from Another app with an array option', async () => { const res = await app.request('/another-app-with-array-option/with-params') expect(res.status).toBe(200) expect(await res.json()).toEqual({ params: ['param1', 'param2'], }) }) it('Should return responses from AnotherApp - sub + slash', async () => { const res = await app.request('/another-app2/sub-slash') expect(res.status).toBe(200) expect(await res.text()).toBe('AnotherApp') }) it('Should return responses from AnotherApp - with `basePath()`', async () => { const res = await api.request('/api/another-app') expect(res.status).toBe(200) expect(await res.text()).toBe('AnotherApp') }) }) describe('With fetch', () => { const anotherApp = async (req: Request, env: {}, executionContext: ExecutionContext) => { const path = getPath(req) if (path === '/') { return new Response( JSON.stringify({ env, executionContext, }), { headers: { 'Content-Type': 'application/json', }, } ) } return new Response('Not Found from AnotherApp', { status: 404, }) } const app = new Hono() app.mount('/another-app', anotherApp) it('Should handle Env and ExecuteContext', async () => { const request = new Request('http://localhost/another-app') const res = await app.fetch( request, { TOKEN: 'foo', }, { // Force mocking! // @ts-ignore waitUntil: 'waitUntil', // @ts-ignore passThroughOnException: 'passThroughOnException', } ) expect(res.status).toBe(200) expect(await res.json()).toEqual({ env: { TOKEN: 'foo', }, executionContext: { waitUntil: 'waitUntil', passThroughOnException: 'passThroughOnException', }, }) }) }) describe('Mount on `/`', () => { const anotherApp = (req: Request, params: unknown) => { const path = getPath(req) if (path === '/') { return new Response('AnotherApp') } if (path === '/hello') { return new Response('Hello from AnotherApp') } if (path === '/good/night') { return new Response('Good Night from AnotherApp') } return new Response('Not Found from AnotherApp', { status: 404, }) } const app = new Hono() app.mount('/', anotherApp) it('Should return responses from AnotherApp - mount on `/`', async () => { let res = await app.request('/') expect(res.status).toBe(200) expect(await res.text()).toBe('AnotherApp') res = await app.request('/hello') expect(res.status).toBe(200) expect(await res.text()).toBe('Hello from AnotherApp') res = await app.request('/good/night') expect(res.status).toBe(200) expect(await res.text()).toBe('Good Night from AnotherApp') res = await app.request('/not-found') expect(res.status).toBe(404) expect(await res.text()).toBe('Not Found from AnotherApp') }) }) describe('With replaceRequest option', () => { const anotherApp = (req: Request) => { const path = getPath(req) if (path === '/app') { return new Response(getPath(req)) } return new Response(null, { status: 404 }) } const app = new Hono() app.mount('/app', anotherApp, { replaceRequest: (req) => req, }) it('Should return 200 response with the correct path', async () => { const res = await app.request('/app') expect(res.status).toBe(200) expect(await res.text()).toBe('/app') }) }) describe('With replaceRequest: false', () => { const anotherApp = (req: Request) => { const path = getPath(req) if (path === '/app') { return new Response(getPath(req)) } return new Response(null, { status: 404 }) } const app = new Hono() app.mount('/app', anotherApp, { replaceRequest: false }) it('Should return 200 response with the correct path', async () => { const res = await app.request('/app') expect(res.status).toBe(200) expect(await res.text()).toBe('/app') }) }) }) describe('HEAD method', () => { const app = new Hono() app.get('/page', (c) => { c.header('X-Message', 'Foo') c.header('X-Method', c.req.method) return c.text('/page') }) it('Should return 200 response with body - GET /page', async () => { const res = await app.request('/page') expect(res.status).toBe(200) expect(res.headers.get('X-Message')).toBe('Foo') expect(res.headers.get('X-Method')).toBe('GET') expect(await res.text()).toBe('/page') }) it('Should return 200 response without body - HEAD /page', async () => { const req = new Request('http://localhost/page', { method: 'HEAD', }) const res = await app.request(req) expect(res.status).toBe(200) expect(res.headers.get('X-Message')).toBe('Foo') expect(res.headers.get('X-Method')).toBe('HEAD') expect(res.body).toBe(null) }) }) declare module './context' { interface ContextRenderer { (content: string | Promise, head: { title: string }): Response | Promise } } describe('app.request()', () => { it('Should return response with Request and RequestInit as args', async () => { const app = new Hono() app.get('/foo', (c) => { return c.json(c.req.header('x-message')) }) const req = new Request('http://localhost/foo') const headers = new Headers() headers.append('x-message', 'hello') const res = await app.request(req, { headers, }) expect(res.status).toBe(200) expect(await res.text()).toBe('"hello"') }) }) describe('app.fire()', () => { it('Should call global.addEventListener', () => { const app = new Hono() const addEventListener = vi.fn() global.addEventListener = addEventListener app.fire() expect(addEventListener).toHaveBeenCalledWith('fetch', expect.any(Function)) const fetchEventListener = addEventListener.mock.calls[0][1] const respondWith = vi.fn() const request = new Request('http://localhost') fetchEventListener({ respondWith, request }) expect(respondWith).toHaveBeenCalledWith(expect.any(Promise)) }) }) describe('Context render and setRenderer', () => { const app = new Hono() app.get('/default', (c) => { return c.render('

content

', { title: 'dummy ' }) }) app.use('/page', async (c, next) => { c.setRenderer((content, head) => { return new Response( `${head.title}

${content}

` ) }) await next() }) app.get('/page', (c) => { return c.render('page content', { title: 'page title', }) }) it('Should return a Response from the default renderer', async () => { const res = await app.request('/default') expect(await res.text()).toBe('

content

') }) it('Should return a Response from the custom renderer', async () => { const res = await app.request('/page') expect(await res.text()).toBe( 'page title

page content

' ) }) }) describe('c.var - with testing types', () => { const app = new Hono<{ Bindings: { Token: string } }>() const mw = (): MiddlewareHandler<{ Variables: { echo: (str: string) => string } }> => async (c, next) => { c.set('echo', (str) => str) await next() } const mw2 = (): MiddlewareHandler<{ Variables: { echo2: (str: string) => string } }> => async (c, next) => { c.set('echo2', (str) => str) await next() } const mw3 = (): MiddlewareHandler<{ Variables: { echo3: (str: string) => string } }> => async (c, next) => { c.set('echo3', (str) => str) await next() } const mw4 = (): MiddlewareHandler<{ Variables: { echo4: (str: string) => string } }> => async (c, next) => { c.set('echo4', (str) => str) await next() } const mw5 = (): MiddlewareHandler<{ Variables: { echo5: (str: string) => string } }> => async (c, next) => { c.set('echo5', (str) => str) await next() } const mw6 = (): MiddlewareHandler<{ Variables: { echo6: (str: string) => string } }> => async (c, next) => { c.set('echo6', (str) => str) await next() } const mw7 = (): MiddlewareHandler<{ Variables: { echo7: (str: string) => string } }> => async (c, next) => { c.set('echo7', (str) => str) await next() } const mw8 = (): MiddlewareHandler<{ Variables: { echo8: (str: string) => string } }> => async (c, next) => { c.set('echo8', (str) => str) await next() } const mw9 = (): MiddlewareHandler<{ Variables: { echo9: (str: string) => string } }> => async (c, next) => { c.set('echo9', (str) => str) await next() } const mw10 = (): MiddlewareHandler<{ Variables: { echo10: (str: string) => string } }> => async (c, next) => { c.set('echo10', (str) => str) await next() } app.use('/no-path/1').get(mw(), (c) => { return c.text(c.var.echo('hello')) }) app.use('/no-path/2').get(mw(), mw2(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2')) }) app.use('/no-path/3').get(mw(), mw2(), mw3(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3')) }) app.use('/no-path/4').get(mw(), mw2(), mw3(), mw4(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') ) }) app.use('/no-path/5').get(mw(), mw2(), mw3(), mw4(), mw5(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') ) }) app.use('/no-path/6').get(mw(), mw2(), mw3(), mw4(), mw5(), mw6(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') ) }) app.use('/no-path/7').get(mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') ) }) app.use('/no-path/8').get(mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') ) }) app.use('/no-path/9').get(mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') + c.var.echo9('hello9') ) }) app.use('/no-path/10').get( // @ts-expect-error The handlers are more than 10 mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), mw10(), (c) => { return c.text( // @ts-expect-error c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') + c.var.echo9('hello9') + c.var.echo10('hello10') ) } ) app.get('*', mw()) app.get('/path/1', mw(), (c) => { return c.text(c.var.echo('hello')) }) app.get('/path/2', mw(), mw2(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2')) }) app.get('/path/3', mw(), mw2(), mw3(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3')) }) app.get('/path/4', mw(), mw2(), mw3(), mw4(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') ) }) app.get('/path/5', mw(), mw2(), mw3(), mw4(), mw5(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') ) }) app.get('/path/6', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') ) }) app.get('/path/7', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') ) }) app.get('/path/8', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') ) }) app.get('/path/9', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') + c.var.echo9('hello9') ) }) // @ts-expect-error app.get('/path/10', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), mw10(), (c) => { return c.text( // @ts-expect-error c.var.echo('hello') + // @ts-expect-error c.var.echo2('hello2') + // @ts-expect-error c.var.echo3('hello3') + // @ts-expect-error c.var.echo4('hello4') + // @ts-expect-error c.var.echo5('hello5') + // @ts-expect-error c.var.echo6('hello6') + // @ts-expect-error c.var.echo7('hello7') + // @ts-expect-error c.var.echo8('hello8') + // @ts-expect-error c.var.echo9('hello9') + // @ts-expect-error c.var.echo10('hello10') ) }) app.on('GET', '/on/1', mw(), (c) => { return c.text(c.var.echo('hello')) }) app.on('GET', '/on/2', mw(), mw2(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2')) }) app.on('GET', '/on/3', mw(), mw2(), mw3(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3')) }) app.on('GET', '/on/4', mw(), mw2(), mw3(), mw4(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') ) }) app.on('GET', '/on/5', mw(), mw2(), mw3(), mw4(), mw5(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') ) }) app.on('GET', '/on/6', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') ) }) app.on('GET', '/on/7', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') ) }) app.on('GET', '/on/8', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') ) }) app.on('GET', '/on/9', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') + c.var.echo9('hello9') ) }) // @ts-expect-error app.on( 'GET', '/on/10', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), mw10(), (c) => { return c.text( // @ts-expect-error c.var.echo('hello') + // @ts-expect-error c.var.echo2('hello2') + // @ts-expect-error c.var.echo3('hello3') + // @ts-expect-error c.var.echo4('hello4') + // @ts-expect-error c.var.echo5('hello5') + // @ts-expect-error c.var.echo6('hello6') + // @ts-expect-error c.var.echo7('hello7') + // @ts-expect-error c.var.echo8('hello8') + // @ts-expect-error c.var.echo9('hello9') + // @ts-expect-error c.var.echo10('hello10') ) } ) app.on(['GET', 'POST'], '/on/1', mw(), (c) => { return c.text(c.var.echo('hello')) }) app.on(['GET', 'POST'], '/on/2', mw(), mw2(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2')) }) app.on(['GET', 'POST'], '/on/3', mw(), mw2(), mw3(), (c) => { return c.text(c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3')) }) app.on(['GET', 'POST'], '/on/4', mw(), mw2(), mw3(), mw4(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') ) }) app.on(['GET', 'POST'], '/on/5', mw(), mw2(), mw3(), mw4(), mw5(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') ) }) app.on(['GET', 'POST'], '/on/6', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') ) }) app.on(['GET', 'POST'], '/on/7', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') ) }) app.on(['GET', 'POST'], '/on/8', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') ) }) app.on( ['GET', 'POST'], '/on/9', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), (c) => { return c.text( c.var.echo('hello') + c.var.echo2('hello2') + c.var.echo3('hello3') + c.var.echo4('hello4') + c.var.echo5('hello5') + c.var.echo6('hello6') + c.var.echo7('hello7') + c.var.echo8('hello8') + c.var.echo9('hello9') ) } ) // @ts-expect-error app.on( ['GET', 'POST'], '/on/10', mw(), mw2(), mw3(), mw4(), mw5(), mw6(), mw7(), mw8(), mw9(), mw10(), (c) => { return c.text( // @ts-expect-error c.var.echo('hello') + // @ts-expect-error c.var.echo2('hello2') + // @ts-expect-error c.var.echo3('hello3') + // @ts-expect-error c.var.echo4('hello4') + // @ts-expect-error c.var.echo5('hello5') + // @ts-expect-error c.var.echo6('hello6') + // @ts-expect-error c.var.echo7('hello7') + // @ts-expect-error c.var.echo8('hello8') + // @ts-expect-error c.var.echo9('hello9') + // @ts-expect-error c.var.echo10('hello10') ) } ) it('Should return the correct response - no-path', async () => { let res = await app.request('/no-path/1') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('/no-path/2') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2') res = await app.request('/no-path/3') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3') res = await app.request('/no-path/4') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4') res = await app.request('/no-path/5') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4hello5') }) it('Should return the correct response - path', async () => { let res = await app.request('/path/1') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('/path/2') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2') res = await app.request('/path/3') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3') res = await app.request('/path/4') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4') res = await app.request('/path/5') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4hello5') }) it('Should return the correct response - on', async () => { let res = await app.request('/on/1') expect(res.status).toBe(200) expect(await res.text()).toBe('hello') res = await app.request('/on/2') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2') res = await app.request('/on/3') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3') res = await app.request('/on/4') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4') res = await app.request('/on/5') expect(res.status).toBe(200) expect(await res.text()).toBe('hellohello2hello3hello4hello5') }) it('Should not throw type errors', () => { const app = new Hono<{ Variables: { hello: () => string } }>() app.get(mw()) app.get(mw(), mw2()) app.get(mw(), mw2(), mw3()) app.get(mw(), mw2(), mw3(), mw4()) app.get(mw(), mw2(), mw3(), mw4(), mw5()) app.get('/', mw()) app.get('/', mw(), mw2()) app.get('/', mw(), mw2(), mw3()) app.get('/', mw(), mw2(), mw3(), mw4()) app.get('/', mw(), mw2(), mw3(), mw4(), mw5()) }) it('Should be a read-only', () => { expect(() => { app.get('/path/1', mw(), (c) => { // @ts-expect-error c.var.echo = 'hello' return c.text(c.var.echo('hello')) }) }).toThrow() }) it('Should not throw a type error', (c) => { const app = new Hono<{ Bindings: { TOKEN: string } }>() app.get('/', poweredBy(), async (c) => { expectTypeOf(c.env.TOKEN).toEqualTypeOf() }) app.get('/', async (c, next) => { expectTypeOf(c.env.TOKEN).toEqualTypeOf() const mw = poweredBy() await mw(c, next) }) app.use(mw()) app.use('*', mw()) const route = app.get('/posts', mw(), (c) => c.json(0)) const client = hc('/') type key = keyof typeof client type verify = Expect> }) it('Should throw type errors', (c) => { try { // @ts-expect-error app.get(['foo', 'bar'], poweredBy()) // @ts-expect-error app.use(['foo', 'bar'], poweredBy()) } catch {} }) }) describe('Compatible with extended Hono classes, such Zod OpenAPI Hono.', () => { class ExtendedHono extends Hono { // @ts-ignore route(path: string, app?: Hono) { // @ts-ignore super.route(path, app) return this } // @ts-ignore basePath(path: string) { return new ExtendedHono(super.basePath(path)) } } const a = new ExtendedHono() const sub = new Hono() sub.get('/foo', (c) => c.text('foo')) a.route('/sub', sub) it('Should return 200 response', async () => { const res = await a.request('/sub/foo') expect(res.status).toBe(200) }) }) describe('Generics for Bindings and Variables', () => { interface CloudflareBindings { MY_VARIABLE: string } it('Should not throw type errors', () => { // @ts-expect-error Bindings should extend object new Hono<{ Bindings: number }>() const appWithInterface = new Hono<{ Bindings: CloudflareBindings }>() appWithInterface.get('/', (c) => { expectTypeOf(c.env.MY_VARIABLE).toMatchTypeOf() return c.text('/') }) const appWithType = new Hono<{ Bindings: { foo: string } }>() appWithType.get('/', (c) => { expectTypeOf(c.env.foo).toMatchTypeOf() return c.text('Hello Hono!') }) }) }) describe('app.basePath() with the internal #clone()', () => { const app = new Hono() .notFound((c) => { return c.text('Custom not found', 404) }) .onError((error, c) => { return c.text(`Custom error "${error.message}"`, 500) }) .basePath('/api') .get('/test', async () => { throw new Error('API Test error') }) it('Should return the custom not found', async () => { const res = await app.request('/api/not-found') expect(res.status).toBe(404) expect(await res.text()).toBe('Custom not found') }) it('Should return the custom error', async () => { const res = await app.request('/api/test') expect(res.status).toBe(500) expect(await res.text()).toBe('Custom error "API Test error"') }) }) describe('Catch-all route with empty segment', () => { it('Should return empty string for empty catch-all param', async () => { const app = new Hono() app.get('/:remaining{.*}', (c) => { const remaining = c.req.param('remaining') return c.json({ type: typeof remaining, value: remaining }) }) const res = await app.request('http://localhost/') expect(res.status).toBe(200) const json = await res.json() expect(json).toEqual({ type: 'string', value: '' }) }) }) ================================================ FILE: src/hono.ts ================================================ import { HonoBase } from './hono-base' import type { HonoOptions } from './hono-base' import { RegExpRouter } from './router/reg-exp-router' import { SmartRouter } from './router/smart-router' import { TrieRouter } from './router/trie-router' import type { BlankEnv, BlankSchema, Env, Schema } from './types' /** * The Hono class extends the functionality of the HonoBase class. * It sets up routing and allows for custom options to be passed. * * @template E - The environment type. * @template S - The schema type. * @template BasePath - The base path type. */ export class Hono< E extends Env = BlankEnv, S extends Schema = BlankSchema, BasePath extends string = '/', > extends HonoBase { /** * Creates an instance of the Hono class. * * @param options - Optional configuration options for the Hono instance. */ constructor(options: HonoOptions = {}) { super(options) this.router = options.router ?? new SmartRouter({ routers: [new RegExpRouter(), new TrieRouter()], }) } } ================================================ FILE: src/http-exception.test.ts ================================================ import { HTTPException } from './http-exception' describe('HTTPException', () => { it('Should be 401 HTTP exception object', async () => { // We should throw an exception if is not authorized // because next handlers should not be fired. const exception = new HTTPException(401, { message: 'Unauthorized', }) const res = exception.getResponse() expect(res.status).toBe(401) expect(await res.text()).toBe('Unauthorized') expect(exception.status).toBe(401) expect(exception.message).toBe('Unauthorized') }) it('Should be accessible to the object causing the exception', async () => { // We should pass the cause of the error to the cause option // because it makes debugging easier. const error = new Error('Server Error') const exception = new HTTPException(500, { message: 'Internal Server Error', cause: error, }) const res = exception.getResponse() expect(res.status).toBe(500) expect(await res.text()).toBe('Internal Server Error') expect(exception.status).toBe(500) expect(exception.message).toBe('Internal Server Error') expect(exception.cause).toBe(error) }) it('Should prioritize the status code over the code in the response', async () => { const exception = new HTTPException(400, { res: new Response('An exception', { status: 200, }), }) const res = exception.getResponse() expect(res.status).toBe(400) expect(await res.text()).toBe('An exception') }) }) ================================================ FILE: src/http-exception.ts ================================================ /** * @module * This module provides the `HTTPException` class. */ import type { ContentfulStatusCode } from './utils/http-status' /** * Options for creating an `HTTPException`. * @property res - Optional response object to use. * @property message - Optional custom error message. * @property cause - Optional cause of the error. */ type HTTPExceptionOptions = { res?: Response message?: string cause?: unknown } /** * `HTTPException` must be used when a fatal error such as authentication failure occurs. * * @see {@link https://hono.dev/docs/api/exception} * * @param {StatusCode} status - status code of HTTPException * @param {HTTPExceptionOptions} options - options of HTTPException * @param {HTTPExceptionOptions["res"]} options.res - response of options of HTTPException * @param {HTTPExceptionOptions["message"]} options.message - message of options of HTTPException * @param {HTTPExceptionOptions["cause"]} options.cause - cause of options of HTTPException * * @example * ```ts * import { HTTPException } from 'hono/http-exception' * * // ... * * app.post('/auth', async (c, next) => { * // authentication * if (authorized === false) { * throw new HTTPException(401, { message: 'Custom error message' }) * } * await next() * }) * ``` */ export class HTTPException extends Error { readonly res?: Response readonly status: ContentfulStatusCode /** * Creates an instance of `HTTPException`. * @param status - HTTP status code for the exception. Defaults to 500. * @param options - Additional options for the exception. */ constructor(status: ContentfulStatusCode = 500, options?: HTTPExceptionOptions) { super(options?.message, { cause: options?.cause }) this.res = options?.res this.status = status } /** * Returns the response object associated with the exception. * If a response object is not provided, a new response is created with the error message and status code. * @returns The response object. */ getResponse(): Response { if (this.res) { const newResponse = new Response(this.res.body, { status: this.status, headers: this.res.headers, }) return newResponse } return new Response(this.message, { status: this.status, }) } } ================================================ FILE: src/index.ts ================================================ /** * @module * * Hono - Web Framework built on Web Standards * * @example * ```ts * import { Hono } from 'hono' * const app = new Hono() * * app.get('/', (c) => c.text('Hono!')) * * export default app * ``` */ import { Hono } from './hono' /** * Types for environment variables, error handlers, handlers, middleware handlers, and more. */ export type { Env, ErrorHandler, Handler, MiddlewareHandler, Next, NotFoundResponse, NotFoundHandler, ValidationTargets, Input, Schema, ToSchema, TypedResponse, } from './types' /** * Types for context, context variable map, context renderer, and execution context. */ export type { Context, ContextVariableMap, ContextRenderer, ExecutionContext } from './context' /** * Type for HonoRequest. */ export type { HonoRequest } from './request' /** * Types for inferring request and response types and client request options. */ export type { InferRequestType, InferResponseType, ClientRequestOptions } from './client' /** * Hono framework for building web applications. */ export { Hono } ================================================ FILE: src/jsx/base.test.tsx ================================================ /** @jsxImportSource ./ */ import type { JSXNode } from './base' // eslint-disable-next-line @typescript-eslint/no-unused-vars import { cloneElement, jsx, Fragment } from './base' describe('cloneElement', () => { it('should clone an element with new props', () => { const element =
Hello
const clonedElement = cloneElement(element, { className: 'cloned' }) expect((clonedElement as unknown as JSXNode).props.className).toBe('cloned') expect((clonedElement as unknown as JSXNode).props.children).toBe('Hello') }) it('should clone a children element', () => { const fnElement = ({ message }: { message: string }) =>
{message}
const element = fnElement({ message: 'Hello' }) const clonedElement = cloneElement(element, {}) expect(element.toString()).toBe('
Hello
') expect(clonedElement.toString()).toBe('
Hello
') }) it('should clone an element with new children', () => { const fnElement = ({ message }: { message: string }) =>
{message}
const element = fnElement({ message: 'Hello' }) const clonedElement = cloneElement(element, {}, 'World') expect(element.toString()).toBe('
Hello
') expect(clonedElement.toString()).toBe('
World
') }) it('should self-close a wrapped empty tag', () => { const Hr = ({ ...props }) =>
const element =
expect(element.toString()).toBe('
') }) }) ================================================ FILE: src/jsx/base.ts ================================================ import { raw } from '../helper/html' import { escapeToBuffer, resolveCallbackSync, stringBufferToString } from '../utils/html' import type { HtmlEscaped, HtmlEscapedString, StringBufferWithCallbacks } from '../utils/html' import { DOM_RENDERER, DOM_MEMO } from './constants' import type { Context } from './context' import { createContext, globalContexts, useContext } from './context' import { domRenderers } from './intrinsic-element/common' import * as intrinsicElementTags from './intrinsic-element/components' import type { JSX as HonoJSX, IntrinsicElements as IntrinsicElementsDefined, } from './intrinsic-elements' import { normalizeIntrinsicElementKey, styleObjectForEach } from './utils' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type Props = Record export type FC

= { (props: P): HtmlEscapedString | Promise | null defaultProps?: Partial

| undefined displayName?: string | undefined } export type DOMAttributes = HonoJSX.HTMLAttributes // eslint-disable-next-line @typescript-eslint/no-namespace export namespace JSX { export type Element = HtmlEscapedString | Promise export interface ElementChildrenAttribute { children: Child } export interface IntrinsicElements extends IntrinsicElementsDefined { [tagName: string]: Props } export interface IntrinsicAttributes { key?: string | number | bigint | null | undefined } } let nameSpaceContext: Context | undefined = undefined export const getNameSpaceContext = () => nameSpaceContext const toSVGAttributeName = (key: string): string => /[A-Z]/.test(key) && // Presentation attributes are findable in style object. "clip-path", "font-size", "stroke-width", etc. // Or other un-deprecated kebab-case attributes. "overline-position", "paint-order", "strikethrough-position", etc. key.match( /^(?:al|basel|clip(?:Path|Rule)$|co|do|fill|fl|fo|gl|let|lig|i|marker[EMS]|o|pai|pointe|sh|st[or]|text[^L]|tr|u|ve|w)/ ) ? key.replace(/([A-Z])/g, '-$1').toLowerCase() : key const emptyTags = [ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'meta', 'param', 'source', 'track', 'wbr', ] export const booleanAttributes = [ 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'download', 'formnovalidate', 'hidden', 'inert', 'ismap', 'itemscope', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected', ] const childrenToStringToBuffer = (children: Child[], buffer: StringBufferWithCallbacks): void => { for (let i = 0, len = children.length; i < len; i++) { const child = children[i] if (typeof child === 'string') { escapeToBuffer(child, buffer) } else if (typeof child === 'boolean' || child === null || child === undefined) { continue } else if (child instanceof JSXNode) { child.toStringToBuffer(buffer) } else if ( typeof child === 'number' || (child as unknown as { isEscaped: boolean }).isEscaped ) { ;(buffer[0] as string) += child } else if (child instanceof Promise) { buffer.unshift('', child) } else { // `child` type is `Child[]`, so stringify recursively childrenToStringToBuffer(child, buffer) } } } type LocalContexts = [Context, unknown][] export type Child = | string | Promise | number | JSXNode | null | undefined | boolean | Child[] export class JSXNode implements HtmlEscaped { tag: string | Function props: Props key?: string children: Child[] isEscaped: true = true as const localContexts?: LocalContexts constructor(tag: string | Function, props: Props, children: Child[]) { this.tag = tag this.props = props this.children = children } get type(): string | Function { return this.tag as string } // Added for compatibility with libraries that rely on React's internal structure // eslint-disable-next-line @typescript-eslint/no-explicit-any get ref(): any { return this.props.ref || null } toString(): string | Promise { const buffer: StringBufferWithCallbacks = [''] as StringBufferWithCallbacks this.localContexts?.forEach(([context, value]) => { context.values.push(value) }) try { this.toStringToBuffer(buffer) } finally { this.localContexts?.forEach(([context]) => { context.values.pop() }) } return buffer.length === 1 ? 'callbacks' in buffer ? resolveCallbackSync(raw(buffer[0], buffer.callbacks)).toString() : buffer[0] : stringBufferToString(buffer, buffer.callbacks) } toStringToBuffer(buffer: StringBufferWithCallbacks): void { const tag = this.tag as string const props = this.props let { children } = this buffer[0] += `<${tag}` const normalizeKey: (key: string) => string = nameSpaceContext && useContext(nameSpaceContext) === 'svg' ? (key) => toSVGAttributeName(normalizeIntrinsicElementKey(key)) : (key) => normalizeIntrinsicElementKey(key) for (let [key, v] of Object.entries(props)) { key = normalizeKey(key) if (key === 'children') { // skip children } else if (key === 'style' && typeof v === 'object') { // object to style strings let styleStr = '' styleObjectForEach(v, (property, value) => { if (value != null) { styleStr += `${styleStr ? ';' : ''}${property}:${value}` } }) buffer[0] += ' style="' escapeToBuffer(styleStr, buffer) buffer[0] += '"' } else if (typeof v === 'string') { buffer[0] += ` ${key}="` escapeToBuffer(v, buffer) buffer[0] += '"' } else if (v === null || v === undefined) { // Do nothing } else if (typeof v === 'number' || (v as HtmlEscaped).isEscaped) { buffer[0] += ` ${key}="${v}"` } else if (typeof v === 'boolean' && booleanAttributes.includes(key)) { if (v) { buffer[0] += ` ${key}=""` } } else if (key === 'dangerouslySetInnerHTML') { if (children.length > 0) { throw new Error('Can only set one of `children` or `props.dangerouslySetInnerHTML`.') } children = [raw(v.__html)] } else if (v instanceof Promise) { buffer[0] += ` ${key}="` buffer.unshift('"', v) } else if (typeof v === 'function') { if (!key.startsWith('on') && key !== 'ref') { throw new Error(`Invalid prop '${key}' of type 'function' supplied to '${tag}'.`) } // maybe event handler for client components, just ignore in server components } else { buffer[0] += ` ${key}="` escapeToBuffer(v.toString(), buffer) buffer[0] += '"' } } if (emptyTags.includes(tag as string) && children.length === 0) { buffer[0] += '/>' return } buffer[0] += '>' childrenToStringToBuffer(children, buffer) buffer[0] += `` } } class JSXFunctionNode extends JSXNode { override toStringToBuffer(buffer: StringBufferWithCallbacks): void { const { children } = this const props = { ...this.props } if (children.length) { props.children = children.length === 1 ? children[0] : children } const res = (this.tag as Function).call(null, props) if (typeof res === 'boolean' || res == null) { // boolean or null or undefined return } else if (res instanceof Promise) { if (globalContexts.length === 0) { buffer.unshift('', res) } else { // save current contexts for resuming const currentContexts: LocalContexts = globalContexts.map((c) => [c, c.values.at(-1)]) buffer.unshift( '', res.then((childRes) => { if (childRes instanceof JSXNode) { childRes.localContexts = currentContexts } return childRes }) ) } } else if (res instanceof JSXNode) { res.toStringToBuffer(buffer) } else if (typeof res === 'number' || (res as HtmlEscaped).isEscaped) { buffer[0] += res if (res.callbacks) { buffer.callbacks ||= [] buffer.callbacks.push(...res.callbacks) } } else { escapeToBuffer(res, buffer) } } } export class JSXFragmentNode extends JSXNode { override toStringToBuffer(buffer: StringBufferWithCallbacks): void { childrenToStringToBuffer(this.children, buffer) } } export const jsx = ( tag: string | Function, props: Props | null, ...children: (string | number | HtmlEscapedString)[] ): JSXNode => { props ??= {} if (children.length) { props.children = children.length === 1 ? children[0] : children } const key = props.key delete props['key'] const node = jsxFn(tag, props, children) node.key = key return node } let initDomRenderer = false export const jsxFn = ( tag: string | Function, props: Props, children: (string | number | HtmlEscapedString)[] ): JSXNode => { if (!initDomRenderer) { for (const k in domRenderers) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(intrinsicElementTags[k as keyof typeof intrinsicElementTags] as any)[DOM_RENDERER] = domRenderers[k] } initDomRenderer = true } if (typeof tag === 'function') { return new JSXFunctionNode(tag, props, children) } else if (intrinsicElementTags[tag as keyof typeof intrinsicElementTags]) { return new JSXFunctionNode( intrinsicElementTags[tag as keyof typeof intrinsicElementTags], props, children ) } else if (tag === 'svg' || tag === 'head') { nameSpaceContext ||= createContext('') return new JSXNode(tag, props, [ new JSXFunctionNode( nameSpaceContext, { value: tag, }, children ), ]) } else { return new JSXNode(tag, props, children) } } export const shallowEqual = (a: Props, b: Props): boolean => { if (a === b) { return true } const aKeys = Object.keys(a).sort() const bKeys = Object.keys(b).sort() if (aKeys.length !== bKeys.length) { return false } for (let i = 0, len = aKeys.length; i < len; i++) { if ( aKeys[i] === 'children' && bKeys[i] === 'children' && !a.children?.length && !b.children?.length ) { continue } else if (a[aKeys[i]] !== b[aKeys[i]]) { return false } } return true } export type MemorableFC = FC & { [DOM_MEMO]: (prevProps: Readonly, nextProps: Readonly) => boolean } export const memo = ( component: FC, propsAreEqual: (prevProps: Readonly, nextProps: Readonly) => boolean = shallowEqual ): FC => { let computed: ReturnType> = null let prevProps: T | undefined = undefined const wrapper: MemorableFC = ((props: T) => { if (prevProps && !propsAreEqual(prevProps, props)) { computed = null } prevProps = props return (computed ||= component(props)) }) as MemorableFC // This function is for toString(), but it can also be used for DOM renderer. // So, set DOM_MEMO and DOM_RENDERER for DOM renderer. wrapper[DOM_MEMO] = propsAreEqual // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(wrapper as any)[DOM_RENDERER] = component return wrapper as FC } export const Fragment = ({ children, }: { key?: string children?: Child | HtmlEscapedString }): HtmlEscapedString => { return new JSXFragmentNode( '', { children, }, Array.isArray(children) ? children : children ? [children] : [] ) as never } export const isValidElement = (element: unknown): element is JSXNode => { return !!(element && typeof element === 'object' && 'tag' in element && 'props' in element) } export const cloneElement = ( element: T, props: Partial, ...children: Child[] ): T => { let childrenToClone if (children.length > 0) { childrenToClone = children } else { const c = (element as JSXNode).props.children childrenToClone = Array.isArray(c) ? c : [c] } return jsx( (element as JSXNode).tag, { ...(element as JSXNode).props, ...props }, ...childrenToClone ) as T } export const reactAPICompatVersion = '19.0.0-hono-jsx' ================================================ FILE: src/jsx/children.test.ts ================================================ import { Children } from './children' import { createElement } from '.' describe('map', () => { it('should map children', () => { const element = createElement('div', null, 1, 2, 3) const result = Children.map(element.children, (child) => (child as number) * 2) expect(result).toEqual([2, 4, 6]) }) }) describe('forEach', () => { it('should iterate children', () => { const element = createElement('div', null, 1, 2, 3) const result: number[] = [] Children.forEach(element.children, (child) => { result.push(child as number) }) expect(result).toEqual([1, 2, 3]) }) }) describe('count', () => { it('should count children', () => { const element = createElement('div', null, 1, 2, 3) const result = Children.count(element.children) expect(result).toBe(3) }) }) describe('only', () => { it('should return the only child', () => { const element = createElement('div', null, 1) const result = Children.only(element.children) expect(result).toBe(1) }) it('should throw an error if there are multiple children', () => { const element = createElement('div', null, 1, 2) expect(() => Children.only(element.children)).toThrowError( 'Children.only() expects only one child' ) }) }) describe('toArray', () => { it('should convert children to an array', () => { const element = createElement('div', null, 1, 2, 3) const result = Children.toArray(element.children) expect(result).toEqual([1, 2, 3]) }) }) ================================================ FILE: src/jsx/children.ts ================================================ import type { Child } from './base' export const toArray = (children: Child): Child[] => Array.isArray(children) ? children : [children] export const Children = { map: (children: Child[], fn: (child: Child, index: number) => Child): Child[] => toArray(children).map(fn), forEach: (children: Child[], fn: (child: Child, index: number) => void): void => { toArray(children).forEach(fn) }, count: (children: Child[]): number => toArray(children).length, only: (_children: Child[]): Child => { const children = toArray(_children) if (children.length !== 1) { throw new Error('Children.only() expects only one child') } return children[0] }, toArray, } ================================================ FILE: src/jsx/components.test.tsx ================================================ /* eslint-disable @typescript-eslint/no-explicit-any */ /** @jsxImportSource ./ */ import { JSDOM } from 'jsdom' import type { HtmlEscapedString } from '../utils/html' import { HtmlEscapedCallbackPhase, resolveCallback as rawResolveCallback } from '../utils/html' import { ErrorBoundary } from './components' import { Suspense, renderToReadableStream, StreamingContext } from './streaming' function resolveCallback(template: string | HtmlEscapedString) { return rawResolveCallback(template, HtmlEscapedCallbackPhase.Stream, false, {}) } function replacementResult(html: string) { const document = new JSDOM(html, { runScripts: 'dangerously' }).window.document document.querySelectorAll('template, script').forEach((e) => e.remove()) return document.body.innerHTML } const Fallback = () =>

Out Of Service
describe('ErrorBoundary', () => { let errorBoundaryCounter = 0 let suspenseCounter = 0 afterEach(() => { errorBoundaryCounter++ suspenseCounter++ }) describe('sync', async () => { const Component = ({ error }: { error?: boolean }) => { if (error) { throw new Error('Error') } return
Hello
} it('no error', async () => { const html = ( }> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('
Hello
') errorBoundaryCounter-- suspenseCounter-- }) it('error', async () => { const html = ( }> ) expect((await resolveCallback(await html.toString())).toString()).toEqual( '
Out Of Service
' ) suspenseCounter-- }) it('nullish', async () => { const html = (
}>{[null, undefined]}
) expect((await resolveCallback(await html.toString())).toString()).toEqual('
') errorBoundaryCounter-- suspenseCounter-- }) it('boolean', async () => { const html = (
}>{[true, false]}
) expect((await resolveCallback(await html.toString())).toString()).toEqual('
') errorBoundaryCounter-- suspenseCounter-- }) it('string content', async () => { const html = }>{'< ok >'} expect((await resolveCallback(await html.toString())).toString()).toEqual('< ok >') errorBoundaryCounter-- suspenseCounter-- }) it('error: string content', async () => { const html = ( '}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') errorBoundaryCounter-- suspenseCounter-- }) it('error: Promise from fallback', async () => { const html = ( ')}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') errorBoundaryCounter-- suspenseCounter-- }) it('error: string content from fallbackRender', async () => { const html = ( '< error >'}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') errorBoundaryCounter-- suspenseCounter-- }) it('error: Promise from fallbackRender', async () => { const html = ( Promise.resolve('< error >')}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') errorBoundaryCounter-- suspenseCounter-- }) }) describe('async', async () => { const Component = async ({ error }: { error?: boolean }) => { await new Promise((resolve) => setTimeout(resolve, 10)) if (error) { throw new Error('Error') } return
Hello
} it('no error', async () => { const html = ( }> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('
Hello
') errorBoundaryCounter-- suspenseCounter-- }) it('error', async () => { const html = ( }> ) expect((await resolveCallback(await html.toString())).toString()).toEqual( '
Out Of Service
' ) suspenseCounter-- }) it('error: string content', async () => { const html = ( '}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') suspenseCounter-- }) it('error: Promise from fallback', async () => { const html = ( ')}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') suspenseCounter-- }) it('error: string content from fallbackRender', async () => { const html = ( '< error >'}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') suspenseCounter-- }) it('error: Promise from fallbackRender', async () => { const html = ( Promise.resolve('< error >')}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('< error >') suspenseCounter-- }) }) describe('async : nested', async () => { const handlers: Record void; reject: () => void }> = {} const Component = async ({ id }: { id: number }) => { await new Promise((resolve, reject) => (handlers[id] = { resolve, reject })) return
{id}
} it('no error', async () => { const html = ( }> }> ).toString() Object.values(handlers).forEach(({ resolve }) => resolve(undefined)) expect((await resolveCallback(await html)).toString()).toEqual('
1
2
') errorBoundaryCounter++ suspenseCounter-- }) it('error in parent', async () => { const html = ( }> }> ).toString() handlers[2].resolve(undefined) handlers[1].reject() expect((await resolveCallback(await html)).toString()).toEqual('
Out Of Service
') errorBoundaryCounter++ suspenseCounter-- }) it('error in child', async () => { const html = ( }> }> ).toString() handlers[1].resolve(undefined) handlers[2].reject() expect((await resolveCallback(await html)).toString()).toEqual( '
1
Out Of Service
' ) errorBoundaryCounter++ suspenseCounter-- }) }) describe('async : setTimeout', async () => { const TimeoutSuccessComponent = async () => { await new Promise((resolve) => setTimeout(resolve, 10)) return
OK
} const TimeoutErrorComponent = async () => { await new Promise((resolve) => setTimeout(resolve, 0)) throw new Error('Error') } it('fallback', async () => { const html = ( <> }> ).toString() expect((await resolveCallback(await html)).toString()).toEqual( '
OK
Out Of Service
' ) suspenseCounter-- }) }) describe('streaming', async () => { const Component = async ({ error }: { error?: boolean }) => { await new Promise((resolve) => setTimeout(resolve, 10)) if (error) { throw new Error('Error') } return
Hello
} it('no error', async () => { const stream = renderToReadableStream( }> Loading...

}>
) const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(chunks).toEqual([ ``, ``, ``, ]) expect(replacementResult(`${chunks.join('')}`)).toEqual( '
Hello
' ) }) it('with StreamingContext', async () => { const stream = renderToReadableStream( }> Loading...

}>
) const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(chunks).toEqual([ ``, ``, ``, ]) expect(replacementResult(`${chunks.join('')}`)).toEqual( '
Hello
' ) }) it('error', async () => { const html = ( }> ) expect((await resolveCallback(await html.toString())).toString()).toEqual( '
Out Of Service
' ) }) }) describe('streaming : contains multiple suspense', async () => { const handlers: Record void; reject: () => void }> = {} const Component = async ({ id }: { id: number }) => { await new Promise((resolve, reject) => (handlers[id] = { resolve, reject })) return
{id}
} it('no error', async () => { const stream = renderToReadableStream( }> Loading...

}>
Loading...

}>
Loading...

}>
) Object.values(handlers).forEach(({ resolve }) => resolve(undefined)) const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(replacementResult(`${chunks.join('')}`)).toEqual( '
1
2
3
' ) }) it('error', async () => { const stream = renderToReadableStream( }> Loading...

}>
Loading...

}>
Loading...

}>
) handlers[1].resolve(undefined) handlers[2].resolve(undefined) handlers[3].reject() const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(replacementResult(`${chunks.join('')}`)).toEqual( '
Out Of Service
' ) }) }) describe('streaming : nested', async () => { const handlers: Record void; reject: () => void }> = {} const Component = async ({ id }: { id: number }) => { await new Promise((resolve, reject) => (handlers[id] = { resolve, reject })) return
{id}
} it('no error', async () => { const stream = renderToReadableStream( }> Loading...

}>
}> Loading...

}>
) Object.values(handlers).forEach(({ resolve }) => resolve(undefined)) const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(replacementResult(`${chunks.join('')}`)).toEqual( '
1
2
' ) }) it('error in parent', async () => { const stream = renderToReadableStream( }> Loading...

}>
}> Loading...

}>
) handlers[2].resolve(undefined) handlers[1].reject() const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(replacementResult(`${chunks.join('')}`)).toEqual( '
Out Of Service
' ) }) it('error in child', async () => { const stream = renderToReadableStream( }> Loading...

}>
}> Loading...

}>
) handlers[1].resolve(undefined) handlers[2].reject() const chunks = [] const textDecoder = new TextDecoder() for await (const chunk of stream as any) { chunks.push(textDecoder.decode(chunk)) } expect(replacementResult(`${chunks.join('')}`)).toEqual( '
1
Out Of Service
' ) }) }) describe('onError', async () => { const Component = ({ error }: { error?: boolean }) => { if (error) { throw new Error('Error') } return
Hello
} it('no error', async () => { const errors: Error[] = [] const html = ( } onError={(err) => errors.push(err)}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual('
Hello
') errorBoundaryCounter-- suspenseCounter-- expect(errors).toEqual([]) }) it('error', async () => { const errors: Error[] = [] const html = ( } onError={(err) => errors.push(err)}> ) expect((await resolveCallback(await html.toString())).toString()).toEqual( '
Out Of Service
' ) suspenseCounter-- expect(errors[0]).toEqual(new Error('Error')) }) }) describe('fallbackRender', async () => { const fallbackRenderer = (error: Error) =>
{error.message}
const Component = ({ error }: { error?: boolean }) => { if (error) { throw new Error('Error') } return
Hello
} it('no error', async () => { const errors: Error[] = [] const html = ( ) expect((await resolveCallback(await html.toString())).toString()).toEqual('
Hello
') errorBoundaryCounter-- suspenseCounter-- expect(errors).toEqual([]) }) it('error', async () => { const html = ( ) expect((await resolveCallback(await html.toString())).toString()).toEqual( '
Error
' ) suspenseCounter-- }) }) }) ================================================ FILE: src/jsx/components.ts ================================================ import { raw } from '../helper/html' import type { HtmlEscapedCallback, HtmlEscapedString } from '../utils/html' import { HtmlEscapedCallbackPhase, resolveCallback } from '../utils/html' import { jsx, Fragment } from './base' import { DOM_RENDERER } from './constants' import { useContext } from './context' import { ErrorBoundary as ErrorBoundaryDomRenderer } from './dom/components' import type { HasRenderToDom } from './dom/render' import { StreamingContext } from './streaming' import type { Child, FC, PropsWithChildren } from './' let errorBoundaryCounter = 0 export const childrenToString = async (children: Child[]): Promise => { try { return children .flat() .map((c) => (c == null || typeof c === 'boolean' ? '' : c.toString())) as HtmlEscapedString[] } catch (e) { if (e instanceof Promise) { await e return childrenToString(children) } else { throw e } } } const resolveChildEarly = (c: Child): HtmlEscapedString | Promise => { if (c == null || typeof c === 'boolean') { return '' as HtmlEscapedString } else if (typeof c === 'string') { return c as HtmlEscapedString } else { const str = c.toString() if (!(str instanceof Promise)) { return raw(str) } else { return str as Promise } } } export type ErrorHandler = (error: Error) => void export type FallbackRender = (error: Error) => Child /** * @experimental * `ErrorBoundary` is an experimental feature. * The API might be changed. */ export const ErrorBoundary: FC< PropsWithChildren<{ fallback?: Child fallbackRender?: FallbackRender onError?: ErrorHandler }> > = async ({ children, fallback, fallbackRender, onError }) => { if (!children) { return raw('') } if (!Array.isArray(children)) { children = [children] } const nonce = useContext(StreamingContext)?.scriptNonce let fallbackStr: string | undefined const resolveFallbackStr = async () => { const awaitedFallback = await fallback if (typeof awaitedFallback === 'string') { fallbackStr = awaitedFallback } else { fallbackStr = await awaitedFallback?.toString() if (typeof fallbackStr === 'string') { // should not apply `raw` if fallbackStr is undefined, null, or boolean fallbackStr = raw(fallbackStr) } } } const fallbackRes = (error: Error): HtmlEscapedString | Promise => { onError?.(error) return (fallbackStr || (fallbackRender && jsx(Fragment, {}, fallbackRender(error) as HtmlEscapedString)) || '') as HtmlEscapedString } let resArray: HtmlEscapedString[] | Promise[] = [] try { resArray = children.map(resolveChildEarly) as unknown as HtmlEscapedString[] } catch (e) { await resolveFallbackStr() if (e instanceof Promise) { resArray = [ e.then(() => childrenToString(children as Child[])).catch((e) => fallbackRes(e)), ] as Promise[] } else { resArray = [fallbackRes(e as Error) as HtmlEscapedString] } } if (resArray.some((res) => (res as {}) instanceof Promise)) { await resolveFallbackStr() const index = errorBoundaryCounter++ const replaceRe = RegExp(`(.*?)(.*?)()`) const caught = false const catchCallback = async ({ error, buffer }: { error: Error; buffer?: [string] }) => { if (caught) { return '' } const fallbackResString = await Fragment({ children: fallbackRes(error), }).toString() if (buffer) { buffer[0] = buffer[0].replace(replaceRe, fallbackResString) } return buffer ? '' : `` } let error: unknown const promiseAll = Promise.all(resArray).catch((e) => (error = e)) return raw(``, [ ({ phase, buffer, context }) => { if (phase === HtmlEscapedCallbackPhase.BeforeStream) { return } return promiseAll .then(async (htmlArray: HtmlEscapedString[]) => { if (error) { throw error } htmlArray = htmlArray.flat() const content = htmlArray.join('') let html = buffer ? '' : ` ((d,c) => { c=d.currentScript.previousSibling d=d.getElementById('E:${index}') if(!d)return d.parentElement.insertBefore(c.content,d.nextSibling) })(document) ` if (htmlArray.every((html) => !(html as HtmlEscapedString).callbacks?.length)) { if (buffer) { buffer[0] = buffer[0].replace(replaceRe, content) } return html } if (buffer) { buffer[0] = buffer[0].replace( replaceRe, (_all, pre, _, post) => `${pre}${content}${post}` ) } const callbacks = htmlArray .map((html) => (html as HtmlEscapedString).callbacks || []) .flat() if (phase === HtmlEscapedCallbackPhase.Stream) { html = await resolveCallback( html, HtmlEscapedCallbackPhase.BeforeStream, true, context ) } let resolvedCount = 0 const promises = callbacks.map( (c) => (...args) => c(...args) ?.then((content) => { resolvedCount++ if (buffer) { if (resolvedCount === callbacks.length) { buffer[0] = buffer[0].replace(replaceRe, (_all, _pre, content) => content) } buffer[0] += content return raw('', (content as HtmlEscapedString).callbacks) } return raw( content + (resolvedCount !== callbacks.length ? '' : ``), (content as HtmlEscapedString).callbacks ) }) .catch((error) => catchCallback({ error, buffer })) ) // eslint-disable-next-line @typescript-eslint/no-explicit-any return raw(html, promises as any) }) .catch((error) => catchCallback({ error, buffer })) }, ]) } else { return Fragment({ children: resArray as Child[] }) } } ;(ErrorBoundary as HasRenderToDom)[DOM_RENDERER] = ErrorBoundaryDomRenderer ================================================ FILE: src/jsx/constants.ts ================================================ export const DOM_RENDERER = Symbol('RENDERER') export const DOM_ERROR_HANDLER = Symbol('ERROR_HANDLER') export const DOM_STASH = Symbol('STASH') export const DOM_INTERNAL_TAG = Symbol('INTERNAL') export const DOM_MEMO = Symbol('MEMO') export const PERMALINK = Symbol('PERMALINK') ================================================ FILE: src/jsx/context.ts ================================================ import { raw } from '../helper/html' import type { HtmlEscapedString } from '../utils/html' import { JSXFragmentNode } from './base' import { DOM_RENDERER } from './constants' import { createContextProviderFunction } from './dom/context' import type { FC, PropsWithChildren } from './' export interface Context extends FC> { values: T[] Provider: FC> } export const globalContexts: Context[] = [] export const createContext = (defaultValue: T): Context => { const values = [defaultValue] const context: Context = ((props): HtmlEscapedString | Promise => { values.push(props.value) let string try { string = props.children ? (Array.isArray(props.children) ? new JSXFragmentNode('', {}, props.children) : props.children ).toString() : '' } catch (e) { values.pop() throw e } if (string instanceof Promise) { return string .finally(() => values.pop()) .then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks)) } else { values.pop() return raw(string) } }) as Context context.values = values context.Provider = context // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(context as any)[DOM_RENDERER] = createContextProviderFunction(values) globalContexts.push(context as Context) return context } export const useContext = (context: Context): T => { return context.values.at(-1) as T } ================================================ FILE: src/jsx/dom/client.test.tsx ================================================ /** @jsxImportSource ../ */ import { JSDOM } from 'jsdom' import DefaultExport, { createRoot, hydrateRoot } from './client' import { useEffect } from '.' describe('createRoot', () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) let dom: JSDOM let rootElement: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text rootElement = document.getElementById('root') as HTMLElement }) it('render / unmount', async () => { const cleanup = vi.fn() const App = () => { useEffect(() => cleanup, []) return

Hello

} const root = createRoot(rootElement) root.render() expect(rootElement.innerHTML).toBe('

Hello

') await new Promise((resolve) => setTimeout(resolve)) root.unmount() await Promise.resolve() expect(rootElement.innerHTML).toBe('') expect(cleanup).toHaveBeenCalled() }) it('call render twice', async () => { const App =

Hello

const App2 =

World

const root = createRoot(rootElement) root.render(App) expect(rootElement.innerHTML).toBe('

Hello

') const createElementSpy = vi.spyOn(dom.window.document, 'createElement') root.render(App2) await Promise.resolve() expect(rootElement.innerHTML).toBe('

World

') expect(createElementSpy).not.toHaveBeenCalled() }) it('call render after unmount', async () => { const App =

Hello

const App2 =

World

const root = createRoot(rootElement) root.render(App) expect(rootElement.innerHTML).toBe('

Hello

') root.unmount() expect(() => root.render(App2)).toThrow('Cannot update an unmounted root') }) }) describe('hydrateRoot', () => { let dom: JSDOM let rootElement: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text rootElement = document.getElementById('root') as HTMLElement }) it('should return root object', async () => { const cleanup = vi.fn() const App = () => { useEffect(() => cleanup, []) return

Hello

} const root = hydrateRoot(rootElement, ) expect(rootElement.innerHTML).toBe('

Hello

') await new Promise((resolve) => setTimeout(resolve)) root.unmount() await Promise.resolve() expect(rootElement.innerHTML).toBe('') expect(cleanup).toHaveBeenCalled() }) it('call render', async () => { const App =

Hello

const App2 =

World

const root = hydrateRoot(rootElement, App) expect(rootElement.innerHTML).toBe('

Hello

') const createElementSpy = vi.spyOn(dom.window.document, 'createElement') root.render(App2) await Promise.resolve() expect(rootElement.innerHTML).toBe('

World

') expect(createElementSpy).not.toHaveBeenCalled() }) it('call render after unmount', async () => { const App =

Hello

const App2 =

World

const root = hydrateRoot(rootElement, App) expect(rootElement.innerHTML).toBe('

Hello

') root.unmount() expect(() => root.render(App2)).toThrow('Cannot update an unmounted root') }) }) describe('default export', () => { ;['createRoot', 'hydrateRoot'].forEach((key) => { it(key, () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((DefaultExport as any)[key]).toBeDefined() }) }) }) ================================================ FILE: src/jsx/dom/client.ts ================================================ /** * @module * This module provides APIs for `hono/jsx/dom/client`, which is compatible with `react-dom/client`. */ import type { Child } from '../base' import { useState } from '../hooks' import { buildNode, renderNode } from './render' import type { NodeObject } from './render' export interface Root { render(children: Child): void unmount(): void } export type RootOptions = Record /** * Create a root object for rendering * @param element Render target * @param options Options for createRoot (not supported yet) * @returns Root object has `render` and `unmount` methods */ export const createRoot = ( element: HTMLElement | DocumentFragment, options: RootOptions = {} ): Root => { let setJsxNode: | undefined // initial state | ((jsxNode: unknown) => void) // rendered | null = // unmounted undefined if (Object.keys(options).length > 0) { console.warn('createRoot options are not supported yet') } return { render(jsxNode: unknown) { if (setJsxNode === null) { // unmounted throw new Error('Cannot update an unmounted root') } if (setJsxNode) { // rendered setJsxNode(jsxNode) } else { renderNode( buildNode({ tag: () => { const [_jsxNode, _setJsxNode] = useState(jsxNode) setJsxNode = _setJsxNode return _jsxNode }, props: {}, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any) as NodeObject, element ) } }, unmount() { setJsxNode?.(null) setJsxNode = null }, } } /** * Create a root object and hydrate app to the target element. * In hono/jsx/dom, hydrate is equivalent to render. * @param element Render target * @param reactNode A JSXNode to render * @param options Options for createRoot (not supported yet) * @returns Root object has `render` and `unmount` methods */ export const hydrateRoot = ( element: HTMLElement | DocumentFragment, reactNode: Child, options: RootOptions = {} ): Root => { const root = createRoot(element, options) root.render(reactNode) return root } export default { createRoot, hydrateRoot, } ================================================ FILE: src/jsx/dom/components.test.tsx ================================================ /** @jsxImportSource ../ */ import { JSDOM } from 'jsdom' import { ErrorBoundary as ErrorBoundaryCommon, Suspense as SuspenseCommon } from '..' // for common // run tests by old style jsx default // hono/jsx/jsx-runtime and hono/jsx/dom/jsx-runtime are tested in their respective settings import { use, useState } from '../hooks' import { ErrorBoundary as ErrorBoundaryDom, Suspense as SuspenseDom, render } from '.' // for dom runner('Common', SuspenseCommon, ErrorBoundaryCommon) runner('DOM', SuspenseDom, ErrorBoundaryDom) function runner( name: string, Suspense: typeof SuspenseDom, ErrorBoundary: typeof ErrorBoundaryDom ) { describe(name, () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) describe('Suspense', () => { let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) it('has no lazy load content', async () => { const App = Loading...
}>Hello render(App, root) expect(root.innerHTML).toBe('Hello') }) it('with use()', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { const num = use(promise) return

{num}

} const Component = () => { return ( Loading...
}> ) } const App = render(App, root) expect(root.innerHTML).toBe('
Loading...
') resolve(1) await new Promise((resolve) => setTimeout(resolve)) expect(root.innerHTML).toBe('

1

') }) it('with use() update', async () => { const counterMap: Record> = {} const getCounter = (count: number) => (counterMap[count] ||= Promise.resolve(count + 1)) const Content = ({ count }: { count: number }) => { const num = use(getCounter(count)) return ( <>
{num}
) } const Component = () => { const [count, setCount] = useState(0) return ( Loading...
}> ) } const App = render(App, root) expect(root.innerHTML).toBe('
Loading...
') await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('
1
') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
Loading...
') await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('
2
') }) it('with use() nested', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { const num = use(promise) return

{num}

} let resolve2: (value: number) => void = () => {} const promise2 = new Promise((_resolve) => (resolve2 = _resolve)) const Content2 = () => { const num = use(promise2) return

{num}

} const Component = () => { return ( Loading...
}> More...
}> ) } const App = render(App, root) expect(root.innerHTML).toBe('
Loading...
') resolve(1) await new Promise((resolve) => setTimeout(resolve)) expect(root.innerHTML).toBe('

1

More...
') resolve2(2) await new Promise((resolve) => setTimeout(resolve)) expect(root.innerHTML).toBe('

1

2

') }) it('race condition', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { const num = use(promise) return

{num}

} const Component = () => { const [show, setShow] = useState(false) return (
{show && ( Loading...
}> )}
) } const App = render(App, root) expect(root.innerHTML).toBe('
') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
Loading...
') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
Loading...
') resolve(2) await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('

2

') }) it('Suspense at child', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { const num = use(promise) return

{num}

} const Component = () => { return ( Loading...
}> ) } const App = () => { const [show, setShow] = useState(false) return (
{show && }
) } render(, root) expect(root.innerHTML).toBe('
') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
Loading...
') resolve(2) await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('

2

') }) it('Suspense at child counter', async () => { const promiseMap: Record> = {} const Counter = () => { const [count, setCount] = useState(0) const promise = (promiseMap[count] ||= Promise.resolve(count)) const value = use(promise) return ( <>

{value}

) } const Component = () => { return ( Loading...
}> ) } const App = () => { return (
) } render(, root) expect(root.innerHTML).toBe('
Loading...
') await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('

0

') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('
Loading...
') await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe('

1

') }) }) describe('ErrorBoundary', () => { let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) it('has no error', async () => { const App = ( Error}>
OK
) render(App, root) expect(root.innerHTML).toBe('
OK
') }) it('has error', async () => { const Component = () => { throw new Error('error') } const App = ( Error}> ) render(App, root) expect(root.innerHTML).toBe('
Error
') }) it('has no error with Suspense', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { const num = use(promise) return

{num}

} const Component = () => { return ( Error}> Loading...}> ) } const App = render(App, root) expect(root.innerHTML).toBe('
Loading...
') resolve(1) await new Promise((resolve) => setTimeout(resolve)) expect(root.innerHTML).toBe('

1

') }) it('has error with Suspense', async () => { let resolve: (value: number) => void = () => {} const promise = new Promise((_resolve) => (resolve = _resolve)) const Content = () => { use(promise) throw new Error('error') } const Component = () => { return ( Error}> Loading...}> ) } const App = render(App, root) expect(root.innerHTML).toBe('
Loading...
') resolve(1) await new Promise((resolve) => setTimeout(resolve)) expect(root.innerHTML).toBe('
Error
') }) }) }) } ================================================ FILE: src/jsx/dom/components.ts ================================================ import type { Child, FC, PropsWithChildren } from '../' import type { ErrorHandler, FallbackRender } from '../components' import { DOM_ERROR_HANDLER } from '../constants' import { Fragment } from './jsx-runtime' /* eslint-disable @typescript-eslint/no-explicit-any */ export const ErrorBoundary: FC< PropsWithChildren<{ fallback?: Child fallbackRender?: FallbackRender onError?: ErrorHandler }> > = (({ children, fallback, fallbackRender, onError }: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any) => { if (err instanceof Promise) { throw err } onError?.(err) return fallbackRender?.(err) || fallback } return res }) as any export const Suspense: FC> = (({ children, fallback, }: any) => { const res = Fragment({ children }) ;(res as any)[DOM_ERROR_HANDLER] = (err: any, retry: () => void) => { if (!(err instanceof Promise)) { throw err } err.finally(retry) return fallback } return res }) as any /* eslint-enable @typescript-eslint/no-explicit-any */ ================================================ FILE: src/jsx/dom/context.test.tsx ================================================ /** @jsxImportSource ../ */ import { JSDOM } from 'jsdom' import { Suspense, createContext as createContextCommon, use, useContext as useContextCommon, } from '..' // for common // run tests by old style jsx default // hono/jsx/jsx-runtime and hono/jsx/dom/jsx-runtime are tested in their respective settings import { createContext as createContextDom, render, useContext as useContextDom, useState } from '.' // for dom runner('Common', createContextCommon, useContextCommon) runner('DOM', createContextDom, useContextDom) function runner( name: string, createContext: typeof createContextCommon, useContext: typeof useContextCommon ) { describe(name, () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) describe('Context', () => { let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) it('simple context', async () => { const Context = createContext(0) const Content = () => { const num = useContext(Context) return

{num}

} const Component = () => { return ( ) } const App = render(App, root) expect(root.innerHTML).toBe('

1

') }) it(' as a provider ', async () => { const Context = createContext(0) const Content = () => { const num = useContext(Context) return

{num}

} const Component = () => { return ( ) } const App = render(App, root) expect(root.innerHTML).toBe('

1

') }) it('simple context with state', async () => { const Context = createContext(0) const Content = () => { const [count, setCount] = useState(0) const num = useContext(Context) return ( <>

{num} - {count}

) } const Component = () => { return ( ) } const App = render(App, root) expect(root.innerHTML).toBe('

1 - 0

') root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe('

1 - 1

') }) it('multiple provider', async () => { const Context = createContext(0) const Content = () => { const num = useContext(Context) return

{num}

} const Component = () => { return ( <> ) } const App = render(App, root) expect(root.innerHTML).toBe('

1

2

') }) it('nested provider', async () => { const Context = createContext(0) const Content = () => { const num = useContext(Context) return

{num}

} const Component = () => { return ( <> ) } const App = render(App, root) expect(root.innerHTML).toBe('

1

3

1

') }) it('inside Suspense', async () => { const promise = Promise.resolve(2) const AsyncComponent = () => { const num = use(promise) return

{num}

} const Context = createContext(0) const Content = () => { const num = useContext(Context) return

{num}

} const Component = () => { return ( <> Loading...}> ) } const App = render(App, root) expect(root.innerHTML).toBe('

1

Loading...

1

') }) }) }) } ================================================ FILE: src/jsx/dom/context.ts ================================================ import type { Child } from '../base' import { DOM_ERROR_HANDLER } from '../constants' import type { Context } from '../context' import { globalContexts } from '../context' import { setInternalTagFlag } from './utils' export const createContextProviderFunction = (values: T[]): Function => ({ value, children }: { value: T; children: Child[] }) => { if (!children) { return undefined } // eslint-disable-next-line @typescript-eslint/no-explicit-any const props: { children: any } = { children: [ { tag: setInternalTagFlag(() => { values.push(value) }), props: {}, }, ], } if (Array.isArray(children)) { props.children.push(...children.flat()) } else { props.children.push(children) } props.children.push({ tag: setInternalTagFlag(() => { values.pop() }), props: {}, }) const res = { tag: '', props, type: '' } // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(res as any)[DOM_ERROR_HANDLER] = (err: unknown) => { values.pop() throw err } return res } export const createContext = (defaultValue: T): Context => { const values = [defaultValue] const context: Context = createContextProviderFunction(values) as Context context.values = values context.Provider = context globalContexts.push(context as Context) return context } ================================================ FILE: src/jsx/dom/css.test.tsx ================================================ /** @jsxImportSource ../ */ import { JSDOM } from 'jsdom' // run tests by old style jsx default // hono/jsx/jsx-runtime and hono/jsx/dom/jsx-runtime are tested in their respective settings import type { JSXNode } from '..' import { Style, createCssContext, css, rawCssString } from '../../helper/css' import { minify } from '../../helper/css/common' import { renderTest } from '../../helper/css/common.case.test' import { render } from '.' describe('Style and css for jsx/dom', () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) it('
red
' ) await Promise.resolve() expect(root.querySelector('style')?.sheet?.cssRules[0].cssText).toBe( '.css-3142110215 {color: red;}' ) }) it('') }) it('', async () => { const App = () => { return (
red
) } render(, root) expect(root.innerHTML).toBe( '
red
' ) }) }) describe('render', () => { renderTest(() => { const cssContext = createCssContext({ id: 'hono-css' }) const dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.Text = dom.window.Text const root = document.getElementById('root') as HTMLElement const toString = async (node: JSXNode) => { render(node, root) await Promise.resolve() const style = root.querySelector('style') if (style) { style.textContent = minify( [...(style.sheet?.cssRules || [])].map((r) => r.cssText).join('') || '' ) } return root.innerHTML } return { toString, rawCssString, ...cssContext, support: { nest: false }, } }) }) ================================================ FILE: src/jsx/dom/css.ts ================================================ /** * @module * This module provides APIs that enable `hono/jsx/dom` to support. */ import type { FC, PropsWithChildren } from '../' import type { CssClassName, CssVariableType } from '../../helper/css/common' import { CLASS_NAME, DEFAULT_STYLE_ID, PSEUDO_GLOBAL_SELECTOR, SELECTOR, SELECTORS, STYLE_STRING, cssCommon, cxCommon, keyframesCommon, viewTransitionCommon, } from '../../helper/css/common' export { rawCssString } from '../../helper/css/common' const splitRule = (rule: string): string[] => { const result: string[] = [] let startPos = 0 let depth = 0 for (let i = 0, len = rule.length; i < len; i++) { const char = rule[i] // consume quote if (char === "'" || char === '"') { const quote = char i++ for (; i < len; i++) { if (rule[i] === '\\') { i++ continue } if (rule[i] === quote) { break } } continue } // comments are removed from the rule in advance if (char === '{') { depth++ continue } if (char === '}') { depth-- if (depth === 0) { result.push(rule.slice(startPos, i + 1)) startPos = i + 1 } continue } } return result } interface CreateCssJsxDomObjectsType { (args: { id: Readonly }): readonly [ { toString(this: CssClassName): string }, FC>, ] } export const createCssJsxDomObjects: CreateCssJsxDomObjectsType = ({ id }) => { let styleSheet: CSSStyleSheet | null | undefined = undefined const findStyleSheet = (): [CSSStyleSheet, Set] | [] => { if (!styleSheet) { styleSheet = document.querySelector(`style#${id}`) ?.sheet as CSSStyleSheet | null if (styleSheet) { // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(styleSheet as any).addedStyles = new Set() } } // eslint-disable-next-line @typescript-eslint/no-explicit-any return styleSheet ? [styleSheet, (styleSheet as any).addedStyles] : [] } const insertRule = (className: string, styleString: string) => { const [sheet, addedStyles] = findStyleSheet() if (!sheet || !addedStyles) { Promise.resolve().then(() => { if (!findStyleSheet()[0]) { throw new Error('style sheet not found') } insertRule(className, styleString) }) return } if (!addedStyles.has(className)) { addedStyles.add(className) ;(className.startsWith(PSEUDO_GLOBAL_SELECTOR) ? splitRule(styleString) : [`${className[0] === '@' ? '' : '.'}${className}{${styleString}}`] ).forEach((rule) => { sheet.insertRule(rule, sheet.cssRules.length) }) } } const cssObject = { toString(this: CssClassName): string { const selector = this[SELECTOR] insertRule(selector, this[STYLE_STRING]) this[SELECTORS].forEach(({ [CLASS_NAME]: className, [STYLE_STRING]: styleString }) => { insertRule(className, styleString) }) return this[CLASS_NAME] }, } const Style: FC> = ({ children, nonce }) => ({ tag: 'style', props: { id, nonce, children: children && (Array.isArray(children) ? children : [children]).map( (c) => (c as unknown as CssClassName)[STYLE_STRING] ), }, // eslint-disable-next-line @typescript-eslint/no-explicit-any }) as any return [cssObject, Style] as const } interface CssType { (strings: TemplateStringsArray, ...values: CssVariableType[]): string } interface CxType { (...args: (string | boolean | null | undefined)[]): string } interface KeyframesType { (strings: TemplateStringsArray, ...values: CssVariableType[]): CssClassName } interface ViewTransitionType { (strings: TemplateStringsArray, ...values: CssVariableType[]): string (content: string): string (): string } interface DefaultContextType { css: CssType cx: CxType keyframes: KeyframesType viewTransition: ViewTransitionType Style: FC> } /** * @experimental * `createCssContext` is an experimental feature. * The API might be changed. */ export const createCssContext = ({ id }: { id: Readonly }): DefaultContextType => { const [cssObject, Style] = createCssJsxDomObjects({ id }) const newCssClassNameObject = (cssClassName: CssClassName): string => { cssClassName.toString = cssObject.toString return cssClassName as unknown as string } const css: CssType = (strings, ...values) => { return newCssClassNameObject(cssCommon(strings, values)) } const cx: CxType = (...args) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any args = cxCommon(args as any) as any // eslint-disable-next-line @typescript-eslint/no-explicit-any return css(Array(args.length).fill('') as any, ...args) } const keyframes: KeyframesType = keyframesCommon const viewTransition: ViewTransitionType = (( strings: TemplateStringsArray | string | undefined, ...values: CssVariableType[] ) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any return newCssClassNameObject(viewTransitionCommon(strings as any, values)) }) as ViewTransitionType return { css, cx, keyframes, viewTransition, Style, } } const defaultContext: DefaultContextType = createCssContext({ id: DEFAULT_STYLE_ID }) /** * @experimental * `css` is an experimental feature. * The API might be changed. */ export const css = defaultContext.css /** * @experimental * `cx` is an experimental feature. * The API might be changed. */ export const cx = defaultContext.cx /** * @experimental * `keyframes` is an experimental feature. * The API might be changed. */ export const keyframes = defaultContext.keyframes /** * @experimental * `viewTransition` is an experimental feature. * The API might be changed. */ export const viewTransition = defaultContext.viewTransition /** * @experimental * `Style` is an experimental feature. * The API might be changed. */ export const Style = defaultContext.Style ================================================ FILE: src/jsx/dom/hooks/index.test.tsx ================================================ /** @jsxImportSource ../../ */ import { JSDOM } from 'jsdom' import { render, useCallback, useState } from '..' import { useActionState, useFormStatus, useOptimistic } from '.' describe('Hooks', () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text global.FormData = dom.window.FormData root = document.getElementById('root') as HTMLElement }) describe('useActionState', () => { it('should return initial state', () => { const [state] = useActionState(() => {}, 'initial') expect(state).toBe('initial') }) it('should return updated state', async () => { const action = vi.fn().mockReturnValue('updated') const App = () => { const [state, formAction] = useActionState(action, 'initial') return ( <>
{state}
) } render(, root) expect(root.innerHTML).toBe( '
initial
' ) root.querySelector('button')?.click() await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe( '
updated
' ) expect(action).toHaveBeenCalledOnce() const [initialState, formData] = action.mock.calls[0] expect(initialState).toBe('initial') expect(formData).toBeInstanceOf(FormData) expect(formData.get('name')).toBe('updated') }) }) describe('useFormStatus', () => { it('should return initial state', () => { const status = useFormStatus() expect(status).toEqual({ pending: false, data: null, method: null, action: null, }) }) it('should return updated state', async () => { let formResolve: () => void = () => {} const formPromise = new Promise((r) => (formResolve = r)) let status: ReturnType | undefined const Status = () => { status = useFormStatus() return null } const App = () => { const [, setCount] = useState(0) const action = useCallback(() => { setCount((count) => count + 1) return formPromise }, []) return ( <>
) } render(, root) expect(root.innerHTML).toBe( '
' ) root.querySelector('button')?.click() await Promise.resolve() await Promise.resolve() await Promise.resolve() await Promise.resolve() expect(status).toEqual({ pending: true, data: expect.any(FormData), method: 'post', action: expect.any(Function), }) formResolve?.() await Promise.resolve() await Promise.resolve() expect(status).toEqual({ pending: false, data: null, method: null, action: null, }) }) }) describe('useOptimistic', () => { it('should return updated state', async () => { let formResolve: () => void = () => {} const formPromise = new Promise((r) => (formResolve = r)) const App = () => { const [count, setCount] = useState(0) const [optimisticCount, setOptimisticCount] = useOptimistic(count, (_c, n: number) => n) const action = useCallback(async () => { setOptimisticCount(count + 1) await formPromise setCount((count) => count + 2) }, []) return ( <>
{optimisticCount}
) } render(, root) expect(root.innerHTML).toBe( '
0
' ) root.querySelector('button')?.click() await Promise.resolve() expect(root.innerHTML).toBe( '
1
' ) formResolve?.() await Promise.resolve() await Promise.resolve() await Promise.resolve() await Promise.resolve() expect(root.innerHTML).toBe( '
2
' ) }) }) }) ================================================ FILE: src/jsx/dom/hooks/index.ts ================================================ /** * Provide hooks used only in jsx/dom */ import { PERMALINK } from '../../constants' import type { Context } from '../../context' import { useContext } from '../../context' import { useCallback, useState } from '../../hooks' import { createContext } from '../context' type FormStatus = | { pending: false data: null method: null action: null } | { pending: true data: FormData method: 'get' | 'post' action: string | ((formData: FormData) => void | Promise) } export const FormContext: Context = createContext({ pending: false, data: null, method: null, action: null, }) const actions: Set> = new Set() export const registerAction = (action: Promise) => { actions.add(action) action.finally(() => actions.delete(action)) } /** * This hook returns the current form status * @returns FormStatus */ export const useFormStatus = (): FormStatus => { return useContext(FormContext) } /** * This hook returns the current state and a function to update the state optimistically * The current state is updated optimistically and then reverted to the original state when all actions are resolved * @param state * @param updateState * @returns [T, (action: N) => void] */ export const useOptimistic = ( state: T, updateState: (currentState: T, action: N) => T ): [T, (action: N) => void] => { const [optimisticState, setOptimisticState] = useState(state) if (actions.size > 0) { Promise.all(actions).finally(() => { setOptimisticState(state) }) } else { setOptimisticState(state) } const cb = useCallback((newData: N) => { setOptimisticState((currentState) => updateState(currentState, newData)) }, []) return [optimisticState, cb] } /** * This hook returns the current state and a function to update the state by form action * @param fn * @param initialState * @param permalink * @returns [T, (data: FormData) => void] */ export const useActionState = ( fn: Function, initialState: T, permalink?: string ): [T, Function] => { const [state, setState] = useState(initialState) const actionState = async (data: FormData) => { setState(await fn(state, data)) } // eslint-disable-next-line @typescript-eslint/no-explicit-any ;(actionState as any)[PERMALINK] = permalink return [state, actionState] } ================================================ FILE: src/jsx/dom/index.test.tsx ================================================ /** @jsxImportSource ../ */ import { JSDOM } from 'jsdom' import type { Child, FC } from '..' // run tests by old style jsx default // hono/jsx/jsx-runtime and hono/jsx/dom/jsx-runtime are tested in their respective settings import { createElement, jsx } from '..' import type { RefObject } from '../hooks' import { createRef, useCallback, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useRef, useState, } from '../hooks' import DefaultExport, { cloneElement, cloneElement as cloneElementForDom, createElement as createElementForDom, createContext, useContext, createPortal, flushSync, isValidElement, memo, render, version, } from '.' describe('Common', () => { ;[createElement, createElementForDom].forEach((createElement) => { describe('createElement', () => { it('simple', () => { const element = createElement('div', { id: 'app' }) expect(element).toEqual(expect.objectContaining({ tag: 'div', props: { id: 'app' } })) }) it('children', () => { const element = createElement('div', { id: 'app' }, 'Hello') expect(element).toEqual( expect.objectContaining({ tag: 'div', props: { id: 'app', children: 'Hello' } }) ) }) it('multiple children', () => { const element = createElement('div', { id: 'app' }, 'Hello', 'World') expect(element).toEqual( expect.objectContaining({ tag: 'div', props: { id: 'app', children: ['Hello', 'World'] }, }) ) }) it('key', () => { const element = createElement('div', { id: 'app', key: 'key' }) expect(element).toEqual( expect.objectContaining({ tag: 'div', props: { id: 'app' }, key: 'key' }) ) }) it('ref', () => { const ref = { current: null } const element = createElement('div', { id: 'app', ref }) expect(element).toEqual(expect.objectContaining({ tag: 'div', props: { id: 'app', ref } })) expect(element.ref).toBe(ref) }) it('type', () => { const element = createElement('div', { id: 'app' }) expect(element.type).toBe('div') }) it('null props', () => { const element = createElement('div', null) expect(element).toEqual(expect.objectContaining({ tag: 'div', props: {} })) }) }) }) }) describe('DOM', () => { beforeAll(() => { global.requestAnimationFrame = (cb) => setTimeout(cb) }) let dom: JSDOM let root: HTMLElement beforeEach(() => { dom = new JSDOM('
', { runScripts: 'dangerously', }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) it('simple App', () => { const App =

Hello

render(App, root) expect(root.innerHTML).toBe('

Hello

') }) it('replace', () => { dom.window.document.body.innerHTML = '
Existing content
' root = document.getElementById('root') as HTMLElement const App =

Hello

render(App, root) expect(root.innerHTML).toBe('

Hello

') }) it('render text directly', () => { const App = () => <>{'Hello'} render(, root) expect(root.innerHTML).toBe('Hello') }) describe('performance', () => { it('should be O(N) for each additional element', () => { const App = () => ( <> {Array.from({ length: 1000 }, (_, i) => (
{i}
))} ) render(, root) expect(root.innerHTML).toBe( Array.from({ length: 1000 }, (_, i) => `
${i}
`).join('') ) }) }) describe('attribute', () => { it('simple', () => { const App = () =>
render(, root) expect(root.innerHTML).toBe('
') }) it('boolean', () => { const App = () =>