Merged
Conversation
Bumps [hono](https://github.com/honojs/hono) from 4.11.7 to 4.12.0. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](honojs/hono@v4.11.7...v4.12.0) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com>
Contributor
|
[puLL-Merge] - honojs/hono@v4.11.7..v4.12.0 Diffdiff --git bun.lock bun.lock
index 13d51e8d3a..8a881160d8 100644
--- bun.lock
+++ bun.lock
@@ -10,7 +10,7 @@
"@types/glob": "^9.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
- "@typescript/native-preview": "7.0.0-dev.20251220.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",
@@ -474,21 +474,21 @@
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.38.0", "", { "dependencies": { "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g=="],
- "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20251220.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251220.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251220.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251220.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251220.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251220.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251220.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251220.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-PmKa/JV9oVC+34VDVDj8fCnjtJKbcFXzPOOUtebsQhudnJN2L7cUvSUAvsPA36W3MwHA030rNUHaelcKG9bY3w=="],
+ "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260210.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260210.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260210.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260210.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260210.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260210.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260210.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260210.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-vy52DLNMYVTizp02/Uu8TrHQrt3BU0b7foE7qqxPAZF63zXpwvGg1g4EAgFtu7ZDJlYrAlUqSdZg6INb/3iY6w=="],
- "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20251220.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kFdUHBL0f6tzZfgviBJm7SpX7NBMUIJvS7Gp0SsFbV72Lc/W5k7aFYG5cJScpdlNzG64dC0A5GBl3C/WkPe9Rg=="],
+ "@typescript/native-preview-darwin-arm64": ["@typescript/native-preview-darwin-arm64@7.0.0-dev.20260210.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-taEYpsrCbdcyHkqNMBiVcqKR7ZHMC1jwTBM9kn3eUgOjXn68ASRrmyzYBdrujluBJMO7rl+Gm5QRT68onYt53A=="],
- "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20251220.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-i2RNLjZaiskvqeNt9XBN/FdssB+i/PURqLkDP6mY6cLSOVClygBtha0qqBAmj+huTvpa64Nwb740a7uFMpVudw=="],
+ "@typescript/native-preview-darwin-x64": ["@typescript/native-preview-darwin-x64@7.0.0-dev.20260210.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-TSgIk2osa3UpivKybsyglBx7KBL+vTNayagmpzYvxBXbPvBnbgGOgzE/5iHkzFJYVUFxqmuj1gopmDT9X/obaQ=="],
- "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20251220.1", "", { "os": "linux", "cpu": "arm" }, "sha512-KRLhiLNEjWfWX9cu8/iXtsebQdfH43QVSmkwcnQJCD2lVodw9bAJRL6o7jVXJM4tofDP3i8dCk85SAiwaNiC+A=="],
+ "@typescript/native-preview-linux-arm": ["@typescript/native-preview-linux-arm@7.0.0-dev.20260210.1", "", { "os": "linux", "cpu": "arm" }, "sha512-2matUA2ZU/1Zdv/pWLsdNwdzkOxBPeLa1581wgnaANrzZD3IJm4eCMfidRFTh9fVPN/eMsthYOeSnuVJa/mPmg=="],
- "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20251220.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-iiRl8pG4tfImt0LM+M4sYnsdf39eFMGdK2ThgBhVWRUSKZfrtvkqM5odwwVuw9xPKF5hFbx3k9lx2s4mTSM6Gg=="],
+ "@typescript/native-preview-linux-arm64": ["@typescript/native-preview-linux-arm64@7.0.0-dev.20260210.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-aSdY/1Uh+4hOpQT1jHvM16cNqXv6lihe3oZmGTV6DmgkeH9soGXRumbu+oA73E3w0Hm6PjD/aIzbvK53yjvN1Q=="],
- "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20251220.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Gq+YxQWFV5+gBuGv9J939Vw5vYB/ux+q2DLyTGXrgLcXrSCiNGAhf9j2F4DGs0aJOJZIsZN+emp2GTRCUXqdXg=="],
+ "@typescript/native-preview-linux-x64": ["@typescript/native-preview-linux-x64@7.0.0-dev.20260210.1", "", { "os": "linux", "cpu": "x64" }, "sha512-7C5mhiOFzWB+hdoCuog9roQuNFFHALw1jz0zrA9ikH18DOgnnGJpGLuekQJdXG1yQSdrALZROXLidTmVxFYSgg=="],
- "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20251220.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-7oBfrT5DalZPhmm4SMS0DzUxw5VEG+cq3Qh6Zgr09+QrAuKBHcuwyZNvbcWhHN7ERMY5xNAIMPILmXOpiarTKQ=="],
+ "@typescript/native-preview-win32-arm64": ["@typescript/native-preview-win32-arm64@7.0.0-dev.20260210.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-n8/tI1rOrqy+kFqrNc4xBYaVc1eGn5SYS9HHDZOPZ8E2b3Oq7RAPSZdNi+YYwMcOx3MFon0Iu6mZ1N6lqer9Dw=="],
- "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20251220.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Jvg2hAotYaRTp4z/6gJWDfvTZXAPOHQ4/81PsZC68asms8mUBrZT/xBy3rxTpWTKmebsGGRg4cUKHMZCEKNq1Q=="],
+ "@typescript/native-preview-win32-x64": ["@typescript/native-preview-win32-x64@7.0.0-dev.20260210.1", "", { "os": "win32", "cpu": "x64" }, "sha512-wC/Aoxf/5/m/7alzb7RxLivGuYwZw3/Iq7RO73egG70LL2RLUuP306MDg1sj2TyeAe+S3zZX3rU1L6qMOW439A=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="],
diff --git docs/MIGRATION.md docs/MIGRATION.md
index cf0c643eee..9435037c2e 100644
--- docs/MIGRATION.md
+++ docs/MIGRATION.md
@@ -129,7 +129,7 @@ const app = new Hono<{ Bindings: Bindings }>()
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 { z } from 'zod'
+import * as z from 'zod'
//...
diff --git package.json package.json
index a02feccaf3..fcd926a75d 100644
--- package.json
+++ package.json
@@ -1,6 +1,6 @@
{
"name": "hono",
- "version": "4.11.7",
+ "version": "4.12.0",
"description": "Web framework built on Web Standards",
"main": "dist/cjs/index.js",
"type": "module",
@@ -661,7 +661,7 @@
"@types/glob": "^9.0.0",
"@types/jsdom": "^21.1.7",
"@types/node": "^24.3.0",
- "@typescript/native-preview": "7.0.0-dev.20251220.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",
diff --git a/src/adapter/aws-lambda/conninfo.test.ts b/src/adapter/aws-lambda/conninfo.test.ts
new file mode 100644
index 0000000000..3dab1a8a9b
--- /dev/null
+++ src/adapter/aws-lambda/conninfo.test.ts
@@ -0,0 +1,112 @@
+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('Should return the client IP from x-forwarded-for header', () => {
+ const ip = '192.0.2.50'
+ const req = new Request('http://localhost/', {
+ headers: {
+ 'x-forwarded-for': `${ip}, 10.0.0.1`,
+ },
+ })
+ const c = new Context(req, {
+ env: {
+ requestContext: {
+ elb: {
+ targetGroupArn: 'arn:aws:elasticloadbalancing:...',
+ },
+ },
+ },
+ })
+
+ const info = getConnInfo(c)
+
+ expect(info.remote.address).toBe(ip)
+ })
+
+ 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()
+ })
+ })
+})
diff --git a/src/adapter/aws-lambda/conninfo.ts b/src/adapter/aws-lambda/conninfo.ts
new file mode 100644
index 0000000000..26328ab366
--- /dev/null
+++ src/adapter/aws-lambda/conninfo.ts
@@ -0,0 +1,72 @@
+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<Env>) => {
+ 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) {
+ // First IP is the client
+ address = xff.split(',')[0].trim()
+ }
+ }
+
+ return {
+ remote: {
+ address,
+ },
+ }
+}
diff --git src/adapter/aws-lambda/index.ts src/adapter/aws-lambda/index.ts
index edbb7c8b19..3c086337bd 100644
--- src/adapter/aws-lambda/index.ts
+++ src/adapter/aws-lambda/index.ts
@@ -4,6 +4,7 @@
*/
export { handle, streamHandle, defaultIsContentTypeBinary } from './handler'
+export { getConnInfo } from './conninfo'
export type { APIGatewayProxyResult, LambdaEvent } from './handler'
export type {
ApiGatewayRequestContext,
diff --git a/src/adapter/cloudflare-pages/conninfo.test.ts b/src/adapter/cloudflare-pages/conninfo.test.ts
new file mode 100644
index 0000000000..61b1573f9f
--- /dev/null
+++ src/adapter/cloudflare-pages/conninfo.test.ts
@@ -0,0 +1,27 @@
+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()
+ })
+})
diff --git a/src/adapter/cloudflare-pages/conninfo.ts b/src/adapter/cloudflare-pages/conninfo.ts
new file mode 100644
index 0000000000..90098dfedf
--- /dev/null
+++ src/adapter/cloudflare-pages/conninfo.ts
@@ -0,0 +1,26 @@
+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'),
+ },
+})
diff --git src/adapter/cloudflare-pages/index.ts src/adapter/cloudflare-pages/index.ts
index 0bbeb2a377..fb0be1b01f 100644
--- src/adapter/cloudflare-pages/index.ts
+++ src/adapter/cloudflare-pages/index.ts
@@ -4,4 +4,5 @@
*/
export { handle, handleMiddleware, serveStatic } from './handler'
+export { getConnInfo } from './conninfo'
export type { EventContext } from './handler'
diff --git a/src/adapter/netlify/conninfo.test.ts b/src/adapter/netlify/conninfo.test.ts
new file mode 100644
index 0000000000..a8b585ce8a
--- /dev/null
+++ src/adapter/netlify/conninfo.test.ts
@@ -0,0 +1,41 @@
+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()
+ })
+})
diff --git a/src/adapter/netlify/conninfo.ts b/src/adapter/netlify/conninfo.ts
new file mode 100644
index 0000000000..8b71034e16
--- /dev/null
+++ src/adapter/netlify/conninfo.ts
@@ -0,0 +1,57 @@
+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<Env>) => ({
+ remote: {
+ address: c.env.context?.ip,
+ },
+})
diff --git src/adapter/netlify/mod.ts src/adapter/netlify/mod.ts
index 0151182848..662042084a 100644
--- src/adapter/netlify/mod.ts
+++ src/adapter/netlify/mod.ts
@@ -1 +1,2 @@
export { handle } from './handler'
+export { getConnInfo } from './conninfo'
diff --git src/client/client.test.ts src/client/client.test.ts
index ba1a94f25a..e42afb2654 100644
--- src/client/client.test.ts
+++ src/client/client.test.ts
@@ -10,7 +10,12 @@ 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 } from './types'
+import type {
+ ClientResponse,
+ InferRequestType,
+ InferResponseType,
+ ApplyGlobalResponse,
+} from './types'
class SafeBigInt {
unsafe = BigInt(42)
@@ -419,6 +424,38 @@ describe('Basic - $url()', () => {
}).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<typeof app>(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', () => {
@@ -447,6 +484,42 @@ describe('Form - Multiple Values', () => {
})
})
+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(
@@ -719,6 +792,10 @@ describe('Merge path with `app.route()`', () => {
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', () => {
@@ -739,6 +816,35 @@ describe('Merge path with `app.route()`', () => {
const url = client.api.v1.me.$url()
expectTypeOf<URL>(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<typeof routes>('http://localhost/proxy')
+
+ it('Should infer paths correctly', async () => {
+ // Should not a throw type error
+ const url = client.api.v1.me.$url()
+ expectTypeOf<URL>(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')
})
})
})
@@ -1014,40 +1120,43 @@ describe('Infer the response types from middlewares', () => {
})
})
-describe('$url() with a param option', () => {
+const pathname = <T extends URL | string>(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<AppType>('http://localhost')
- it('Should return the correct path - /posts/123/comments', async () => {
- const url = client.posts[':id'].comments.$url({
+ it('Should return the correct url path - /posts/123/comments', async () => {
+ const value = client.posts[':id'].comments[cmd]({
param: {
id: '123',
},
})
- expect(url.pathname).toBe('/posts/123/comments')
+ expect(pathname(value)).toBe('/posts/123/comments')
})
it('Should return the correct path - /posts/:id/comments', async () => {
- const url = client.posts[':id'].comments.$url()
- expect(url.pathname).toBe('/posts/:id/comments')
+ 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 url = client.something[':firstId'][':secondId'][':version?'].$url({
+ const value = client.something[':firstId'][':secondId'][':version?'][cmd]({
param: {
firstId: '123',
secondId: '456',
version: undefined,
},
})
- expect(url.pathname).toBe('/something/123/456')
+ expect(pathname(value)).toBe('/something/123/456')
})
})
-describe('$url() with a query option', () => {
+describe('$url() / $path() with a query option', () => {
const app = new Hono().get(
'/posts',
validator('query', () => {
@@ -1065,6 +1174,13 @@ describe('$url() with a query option', () => {
},
})
expect(url.search).toBe('?filter=test')
+
+ const path = client.posts.$path({
+ query: {
+ filter: 'test',
+ },
+ })
+ expect(path).toBe('/posts?filter=test')
})
})
@@ -1603,3 +1719,107 @@ describe('Custom buildSearchParams', () => {
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<AppWithGlobalErrors>('http://localhost')
+ const req = client.api.users.$get
+
+ // Type should be a union of normal response and global errors
+ type ResponseType = InferResponseType<typeof req>
+ type Expected = { users: string[] } | { error: string; message: string }
+
+ type verify = Expect<Equal<ResponseType, Expected>>
+ })
+
+ 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<AppWithGlobalErrors>('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<AppWithOnError>('http://localhost')
+ const req = client.api.users.$get
+
+ // RPC client should know about the error format
+ type ResponseType = InferResponseType<typeof req>
+ type Expected = { users: string[] } | { error: string }
+
+ type verify = Expect<Equal<ResponseType, Expected>>
+ })
+})
diff --git src/client/client.ts src/client/client.ts
index 73e0c2bab4..72b9684399 100644
--- src/client/client.ts
+++ src/client/client.ts
@@ -64,6 +64,9 @@ class ClientRequestImpl {
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)
@@ -165,7 +168,7 @@ export const hc = <T extends Hono<any, any, any>, Prefix extends string = string
const path = parts.join('/')
const url = mergePath(baseUrl, path)
- if (method === 'url') {
+ if (method === 'url' || method === 'path') {
let result = url
if (opts.args[0]) {
if (opts.args[0].param) {
@@ -176,7 +179,10 @@ export const hc = <T extends Hono<any, any, any>, Prefix extends string = string
}
}
result = removeIndexString(result)
- return new URL(result)
+ if (method === 'url') {
+ return new URL(result)
+ }
+ return result.slice(baseUrl.replace(/\/+$/, '').length).replace(/^\/?/, '/')
}
if (method === 'ws') {
const webSocketUrl = replaceUrlProtocol(
diff --git src/client/types.test.ts src/client/types.test.ts
index 59310551a0..f13a6fcf9a 100644
--- src/client/types.test.ts
+++ src/client/types.test.ts
@@ -35,12 +35,14 @@ describe('without the leading slash', () => {
it('`foo` should have `$get`', () => {
expectTypeOf(client.foo).toHaveProperty('$get')
expectTypeOf(client.foo.$url()).toEqualTypeOf<TypedURL<'http:', 'localhost', '', '/foo', ''>>()
+ 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')
@@ -58,6 +60,19 @@ describe('without the leading slash', () => {
query: { q: 'hono' },
})
).toEqualTypeOf<TypedURL<'http:', 'localhost', '', '/foo/123/baz', `?${string}`>>()
+
+ 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}`>()
})
})
@@ -110,3 +125,21 @@ describe('app.all()', () => {
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<typeof app, 'http://localhost/api'>('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}`>()
+ })
+})
diff --git src/client/types.ts src/client/types.ts
index cd5b2938db..2fc15f58e3 100644
--- src/client/types.ts
+++ src/client/types.ts
@@ -1,7 +1,7 @@
import type { Hono } from '../hono'
import type { HonoBase } from '../hono-base'
import type { METHODS, METHOD_NAME_ALL_LOWERCASE } from '../router'
-import type { Endpoint, ResponseFormat, Schema } from '../types'
+import type { Endpoint, KnownResponseFormat, ResponseFormat, Schema } from '../types'
import type { StatusCode, SuccessStatusCode } from '../utils/http-status'
import type { HasRequiredKeys } from '../utils/types'
@@ -94,6 +94,21 @@ export type ClientRequest<Prefix extends string, Path extends string, S extends
>(
arg?: Arg
) => HonoURL<Prefix, Path, Arg>
+ $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<Path, Arg>
} & (S['$get'] extends { outputFormat: 'ws' }
? S['$get'] extends { input: infer I }
? {
@@ -146,6 +161,8 @@ type BuildPathname<P extends string, Arg> = Arg extends { param: infer Param }
? `${ApplyParam<TrimStartSlash<P>, Param>}`
: `/${TrimStartSlash<P>}`
+type BuildPath<P extends string, Arg> = `${BuildPathname<P, Arg>}${BuildSearch<Arg, 'query'>}`
+
type BuildTypedURL<
Protocol extends string,
Host extends string,
@@ -309,3 +326,34 @@ interface CallbackOptions {
export type ObjectType<T = unknown> = {
[key: string]: T
}
+
+type GlobalResponseDefinition = {
+ [S in StatusCode]?: {
+ [F in KnownResponseFormat]?: unknown
+ }
+}
+
+type ToEndpoints<Def extends GlobalResponseDefinition, R> = {
+ [S in keyof Def & StatusCode]: {
+ [F in keyof Def[S] & KnownResponseFormat]: Omit<R, 'output' | 'status' | 'outputFormat'> & {
+ output: Def[S][F]
+ status: S
+ outputFormat: F
+ }
+ }[keyof Def[S] & KnownResponseFormat]
+}[keyof Def & StatusCode]
+
+type ModRoute<R, Def extends GlobalResponseDefinition> = R extends Endpoint
+ ? R | ToEndpoints<Def, R>
+ : R
+
+type ModSchema<D, Def extends GlobalResponseDefinition> = {
+ [K in keyof D]: {
+ [M in keyof D[K]]: ModRoute<D[K][M], Def>
+ }
+}
+
+export type ApplyGlobalResponse<App, Def extends GlobalResponseDefinition> =
+ App extends HonoBase<infer E, infer D extends Schema, infer B>
+ ? Hono<E, ModSchema<D, Def> extends Schema ? ModSchema<D, Def> : never, B>
+ : never
diff --git src/context.ts src/context.ts
index caaac4307a..521fbc7a09 100644
--- src/context.ts
+++ src/context.ts
@@ -44,6 +44,11 @@ export interface ExecutionContext {
*/
// 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
}
/**
@@ -280,6 +285,11 @@ const setDefaultContentType = (contentType: string, headers?: HeaderRecord): Hea
}
}
+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,
@@ -391,7 +401,7 @@ export class Context<
* The Response object for the current request.
*/
get res(): Response {
- return (this.#res ||= new Response(null, {
+ return (this.#res ||= createResponseInstance(null, {
headers: (this.#preparedHeaders ??= new Headers()),
}))
}
@@ -403,7 +413,7 @@ export class Context<
*/
set res(_res: Response | undefined) {
if (this.#res && _res) {
- _res = new Response(_res.body, _res)
+ _res = createResponseInstance(_res.body, _res)
for (const [k, v] of this.#res.headers.entries()) {
if (k === 'content-type') {
continue
@@ -504,7 +514,7 @@ export class Context<
*/
header: SetHeaders = (name, value, options): void => {
if (this.finalized) {
- this.#res = new Response((this.#res as Response).body, this.#res)
+ this.#res = createResponseInstance((this.#res as Response).body, this.#res)
}
const headers = this.#res ? this.#res.headers : (this.#preparedHeaders ??= new Headers())
if (value === undefined) {
@@ -625,7 +635,7 @@ export class Context<
}
const status = typeof arg === 'number' ? arg : (arg?.status ?? this.#status)
- return new Response(data, { status, headers: responseHeaders })
+ return createResponseInstance(data, { status, headers: responseHeaders })
}
newResponse: NewResponse = (...args) => this.#newResponse(...(args as Parameters<NewResponse>))
@@ -657,6 +667,10 @@ export class Context<
headers?: HeaderRecord
): ReturnType<BodyRespond> => this.#newResponse(data, arg, headers) as ReturnType<BodyRespond>
+ #useFastPath(): boolean {
+ return !this.#preparedHeaders && !this.#status && !this.finalized
+ }
+
/**
* `.text()` can render text as `Content-Type:text/plain`.
*
@@ -674,8 +688,8 @@ export class Context<
arg?: ContentfulStatusCode | ResponseOrInit,
headers?: HeaderRecord
): ReturnType<TextRespond> => {
- return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized
- ? (new Response(text) as ReturnType<TextRespond>)
+ return this.#useFastPath() && !arg && !headers
+ ? (createResponseInstance(text) as ReturnType<TextRespond>)
: (this.#newResponse(
text,
arg,
@@ -703,11 +717,15 @@ export class Context<
arg?: U | ResponseOrInit<U>,
headers?: HeaderRecord
): JSONRespondReturn<T, U> => {
- return this.#newResponse(
- JSON.stringify(object),
- arg,
- setDefaultContentType('application/json', headers)
- ) /* eslint-disable @typescript-eslint/no-explicit-any */ as any
+ return (
+ this.#useFastPath() && !arg && !headers
+ ? Response.json(object)
+ : this.#newResponse(
+ JSON.stringify(object),
+ arg,
+ setDefaultContentType('application/json', headers)
+ )
+ ) as JSONRespondReturn<T, U>
}
html: HTMLRespond = (
@@ -764,7 +782,7 @@ export class Context<
* ```
*/
notFound = (): ReturnType<NotFoundHandler> => {
- this.#notFoundHandler ??= () => new Response()
+ this.#notFoundHandler ??= () => createResponseInstance()
return this.#notFoundHandler(this)
}
}
diff --git src/helper/ssg/index.ts src/helper/ssg/index.ts
index f6678e2b70..2a635752f6 100644
--- src/helper/ssg/index.ts
+++ src/helper/ssg/index.ts
@@ -11,3 +11,4 @@ export {
disableSSG,
onlySSG,
} from './middleware'
+export { defaultPlugin, redirectPlugin } from './plugins'
diff --git a/src/helper/ssg/plugins.test.tsx b/src/helper/ssg/plugins.test.tsx
new file mode 100644
index 0000000000..f7fee4bf5f
--- /dev/null
+++ src/helper/ssg/plugins.test.tsx
@@ -0,0 +1,227 @@
+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('<h1>Home</h1>'))
+ app.get('/about', (c) => c.html('<h1>About</h1>'))
+ app.get('/blog', (c) => c.html('<h1>Blog</h1>'))
+ 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', '<h1>Home</h1>')
+ expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '<h1>About</h1>')
+ expect(fsMock.writeFile).toHaveBeenCalledWith('static/blog.html', '<h1>Blog</h1>')
+ 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<string, string> = {}
+ 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('<meta name="robots" content="noindex" />')
+ // Should contain link anchor
+ expect(content).toContain('<a href="/new">Redirecting to <code>/new</code></a>')
+ // Should contain a body element that includes the anchor
+ expect(content).toMatch(/<body[^>]*>[\s\S]*<a href=\"\/new\">[\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<string, string> = {}
+ 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<string, string> = {}
+ 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<string, string> = {}
+ 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<string, string> = {}
+ 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"> <script>alert(1)</script>'
+ const redirectApp = new Hono()
+ redirectApp.get(
+ '/evil',
+ (c) => 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('<script>alert(1)</script>')
+ expect(content).toContain('<script>alert(1)</script>')
+ expect(content).toContain('"')
+ })
+
+ it('redirectPlugin before defaultPlugin generates redirect HTML', async () => {
+ const writtenFiles: Record<string, string> = {}
+ 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<string, string> = {}
+ 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()
+ })
+ })
+})
diff --git a/src/helper/ssg/plugins.ts b/src/helper/ssg/plugins.ts
new file mode 100644
index 0000000000..a25179a44a
--- /dev/null
+++ src/helper/ssg/plugins.ts
@@ -0,0 +1,72 @@
+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`<!DOCTYPE html>
+<title>Redirecting to: ${location}</title>
+<meta http-equiv="refresh" content="0;url=${location}" />
+<meta name="robots" content="noindex" />
+<link rel="canonical" href="${location}" />
+<body>
+<a href="${location}">Redirecting to <code>${location}</code></a>
+</body>
+`
+ 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
+ },
+ }
+}
diff --git src/helper/ssg/ssg.test.tsx src/helper/ssg/ssg.test.tsx
index f779973e85..52324ee749 100644
--- src/helper/ssg/ssg.test.tsx
+++ src/helper/ssg/ssg.test.tsx
@@ -9,13 +9,7 @@ import {
onlySSG,
ssgParams,
} from './middleware'
-import {
- defaultExtensionMap,
- fetchRoutesContent,
- saveContentToFile,
- toSSG,
- defaultPlugin,
-} from './ssg'
+import { defaultExtensionMap, fetchRoutesContent, saveContentToFile, toSSG } from './ssg'
import type {
AfterGenerateHook,
AfterResponseHook,
@@ -843,30 +837,6 @@ describe('SSG Plugin System', () => {
}
})
- it('should use defaultPlugin when plugins option is omitted', async () => {
- // @ts-expect-error defaultPlugin has afterResponseHook
- const defaultPluginSpy = vi.spyOn(defaultPlugin, 'afterResponseHook')
- await toSSG(app, fsMock, { dir: './static' })
- expect(defaultPluginSpy).toHaveBeenCalled()
- defaultPluginSpy.mockRestore()
- })
-
- it('should skip non-200 responses with defaultPlugin', async () => {
- const result = await toSSG(app, fsMock, { plugins: [defaultPlugin], dir: './static' })
- expect(fsMock.writeFile).toHaveBeenCalledWith('static/index.html', '<h1>Home</h1>')
- expect(fsMock.writeFile).toHaveBeenCalledWith('static/about.html', '<h1>About</h1>')
- expect(fsMock.writeFile).toHaveBeenCalledWith('static/blog.html', '<h1>Blog</h1>')
- 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)
- })
-
it('should correctly apply plugins with beforeRequestHook', async () => {
const plugin: SSGPlugin = {
beforeRequestHook: (req) => {
diff --git src/helper/ssg/ssg.ts src/helper/ssg/ssg.ts
index 37e23aedba..4520f7dd0a 100644
--- src/helper/ssg/ssg.ts
+++ src/helper/ssg/ssg.ts
@@ -5,6 +5,7 @@ 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
@@ -348,22 +349,6 @@ export interface ToSSGAdaptorInterface<
(app: Hono<E, S, BasePath>, options?: ToSSGOptions): Promise<ToSSGResult>
}
-/**
- * The default plugin that defines the recommended behavior.
- *
- * @experimental
- * `defaultPlugin` is an experimental feature.
- * The API might be changed.
- */
-export const defaultPlugin: SSGPlugin = {
- afterResponseHook: (res) => {
- if (res.status !== 200) {
- return false
- }
- return res
- },
-}
-
/**
* @experimental
* `toSSG` is an experimental feature.
@@ -373,7 +358,7 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => {
let result: ToSSGResult | undefined
const getInfoPromises: Promise<unknown>[] = []
const savePromises: Promise<string | undefined>[] = []
- const plugins = options?.plugins || [defaultPlugin]
+ const plugins = options?.plugins || [defaultPlugin()]
const beforeRequestHooks: BeforeRequestHook[] = []
const afterResponseHooks: AfterResponseHook[] = []
const afterGenerateHooks: AfterGenerateHook[] = []
diff --git src/jsx/context.ts src/jsx/context.ts
index 882beb7970..eed1388c87 100644
--- src/jsx/context.ts
+++ src/jsx/context.ts
@@ -24,13 +24,17 @@ export const createContext = <T>(defaultValue: T): Context<T> => {
: props.children
).toString()
: ''
- } finally {
+ } catch (e) {
values.pop()
+ throw e
}
if (string instanceof Promise) {
- return string.then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks))
+ return string
+ .finally(() => values.pop())
+ .then((resString) => raw(resString, (resString as HtmlEscapedString).callbacks))
} else {
+ values.pop()
return raw(string)
}
}) as Context<T>
diff --git src/jsx/dom/index.test.tsx src/jsx/dom/index.test.tsx
index 23a4f82eb7..ca3b86553a 100644
--- src/jsx/dom/index.test.tsx
+++ src/jsx/dom/index.test.tsx
@@ -309,6 +309,157 @@ describe('DOM', () => {
expect(root.innerHTML).toBe('<div>2</div><div>1</div><button>+</button>')
expect(Child).toBeCalledTimes(3)
})
+
+ it('multiple children', async () => {
+ const Child = ({ name }: { name: string }) => {
+ const [count, setCount] = useState(0)
+ return (
+ <div>
+ <div>
+ {name} {count}
+ </div>
+ <button onClick={() => setCount(count + 1)}>+</button>
+ </div>
+ )
+ }
+ const App = () => {
+ const [count, setCount] = useState(0)
+ return (
+ <div>
+ <div>parent {count}</div>
+ <button onClick={() => setCount(count + 1)}>+</button>
+ <div>
+ <Child name='child 1' />
+ <Child name='child 2' />
+ <Child name='child 3' />
+ </div>
+ </div>
+ )
+ }
+ render(<App />, root)
+ expect(root.innerHTML).toBe(
+ '<div><div>parent 0</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
+ )
+ const [parentButton, child1Button, child2Button, child3Button] =
+ root.querySelectorAll('button')
+ parentButton?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div>parent 1</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
+ )
+ child2Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div>parent 1</div><button>+</button><div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
+ )
+ child1Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div>parent 1</div><button>+</button><div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div></div></div>'
+ )
+ child3Button?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div>parent 1</div><button>+</button><div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 1</div><button>+</button></div></div></div>'
+ )
+ })
+
+ it('keeps sibling order when a null sibling exists after parent update', async () => {
+ const Empty = () => null
+ const Child = () => {
+ const [count, setCount] = useState(0)
+ return count === 0 ? (
+ <>
+ <div>A0</div>
+ <button id='child' onClick={() => setCount(1)}>
+ child+
+ </button>
+ </>
+ ) : (
+ <>
+ <span>A1</span>
+ <span>A2</span>
+ </>
+ )
+ }
+ const App = () => {
+ const [count, setCount] = useState(0)
+ return (
+ <>
+ <Child />
+ <Empty />
+ <div id='tail'>T{count}</div>
+ <button id='parent' onClick={() => setCount(count + 1)}>
+ parent+
+ </button>
+ </>
+ )
+ }
+ render(<App />, root)
+ expect(root.innerHTML).toBe(
+ '<div>A0</div><button id="child">child+</button><div id="tail">T0</div><button id="parent">parent+</button>'
+ )
+ root.querySelector<HTMLButtonElement>('#parent')?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div>A0</div><button id="child">child+</button><div id="tail">T1</div><button id="parent">parent+</button>'
+ )
+ root.querySelector<HTMLButtonElement>('#child')?.click()
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<span>A1</span><span>A2</span><div id="tail">T1</div><button id="parent">parent+</button>'
+ )
+ })
+
+ it('multiple children with dynamic addition and rerender', async () => {
+ const Child = ({ name }: { name: string }) => {
+ const [count, setCount] = useState(0)
+ return (
+ <div>
+ <div>
+ {name} {count}
+ </div>
+ <button onClick={() => setCount(count + 1)}>+</button>
+ </div>
+ )
+ }
+ const App = () => {
+ const [showThird, setShowThird] = useState(false)
+ return (
+ <div>
+ <Child name='child 1' />
+ <Child name='child 2' />
+ {showThird && <Child name='child 3' />}
+ <button onClick={() => setShowThird(true)}>add</button>
+ </div>
+ )
+ }
+ render(<App />, root)
+ expect(root.innerHTML).toBe(
+ '<div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><button>add</button></div>'
+ )
+ // add child 3
+ let buttons = root.querySelectorAll('button')
+ buttons[2]?.click() // add
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div><div>child 1 0</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
+ )
+ // click child 1
+ buttons = root.querySelectorAll('button')
+ buttons[0]?.click() // child 1
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 0</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
+ )
+ // click child 2 - verify child 2 and child 3 do not swap positions
+ buttons = root.querySelectorAll('button')
+ buttons[1]?.click() // child 2
+ await Promise.resolve()
+ expect(root.innerHTML).toBe(
+ '<div><div><div>child 1 1</div><button>+</button></div><div><div>child 2 1</div><button>+</button></div><div><div>child 3 0</div><button>+</button></div><button>add</button></div>'
+ )
+ })
})
describe('defaultProps', () => {
@@ -506,6 +657,79 @@ describe('DOM', () => {
expect(root.innerHTML).toBe('<div><span>1</span><span>2</span></div>')
})
+ it('empty array and non-empty array', async () => {
+ const App = () => (
+ <div>
+ {[]}
+ {[<span>1</span>]}
+ </div>
+ )
+ render(<App />, root)
+ expect(root.innerHTML).toBe('<div><span>1</span></div>')
+ })
+
+ it('nested array', async () => {
+ const nestedChildren: Child = [[[<span>1</span>], <span>2</span>]]
+ const App = () => <div>{nestedChildren}</div>
+ render(<App />, root)
+ expect(root.innerHTML).toBe('<div><span>1</span><span>2</span></div>')
+ })
+
+ it('sparse array with nested child', async () => {
+ const sparseChildren: Child[] = []
+ sparseChildren[1] = [<span>1</span>]
+ const App = () => <div>{sparseChildren}</div>
+ render(<App />, root)
+ expect(root.innerHTML).toBe('<div><span>1</span></div>')
+ })
+
+ it('toggle empty array and non-empty array on update', async () => {
+ let setVisible: (value: boolean) => void = () => {}
+ const App = () => {
+ const [visible, _setVisible] = useState(false)
+ setVisible = _setVisible
+ return (
+ <div>
+ {visible ? [] : [<span key='a'>A</span>]}
+ {visible ? [<span key='b'>B</span>] : []}
+ </div>
+ )
+ }
+ render(<App />, root)
+ expect(root.innerHTML).toBe('<div><span>A</span></div>')
+
+ setVisible(true)
+ await Promise.resolve()
+ expect(root.innerHTML).toBe('<div><span>B</span></div>')
+
+ setVisible(false)
+ await Promise.resolve()
+ expect(root.innerHTML).toBe('<div><span>A</span></div>')
+ })
+
+ it('reshape nested array on update', async () => {
+ let setPattern: (value: number) => void = () => {}
+ const App = () => {
+ const [pattern, _setPattern] = useState(0)
+ setPattern = _setPattern
+ const children: Child =
+ pattern === 0
+ ? [[<span key='a'>A</span>], <span key='b'>B</span>]
+ : [<span key='a'>A</span>, [<span key='b'>B</span>]]
+ return <div>{children}</div>
+ }
+ render(<App />, root)
+ expect(root.innerHTML).toBe('<div><span>A</span><span>B</span></div>')
+
+ setPattern(1)
+ await Promise.resolve()
+ expect(root.innerHTML).toBe('<div><span>A</span><span>B</span></div>')
+
+ setPattern(0)
+ await Promise.resolve()
+ expect(root.innerHTML).toBe('<div><span>A</span><span>B</span></div>')
+ })
+
it('use the same children multiple times', async () => {
const MultiChildren = ({ children }: { children: Child }) => (
<>
diff --git src/jsx/dom/render.ts src/jsx/dom/render.ts
index 26aeb17086..5d2eae62e8 100644
--- src/jsx/dom/render.ts
+++ src/jsx/dom/render.ts
@@ -302,15 +302,11 @@ const getNextChildren = (
})
}
-const findInsertBefore = (node: Node | undefined): SupportedElement | Text | null => {
- for (; ; node = node.tag === HONO_PORTAL_ELEMENT || !node.vC || !node.pP ? node.nN : node.vC[0]) {
- if (!node) {
- return null
- }
- if (node.tag !== HONO_PORTAL_ELEMENT && node.e) {
- return node.e
- }
+const findInsertBefore = (node: Node | undefined): SupportedElement | Text | undefined => {
+ while (node && (node.tag === HONO_PORTAL_ELEMENT || !node.e)) {
+ node = node.tag === HONO_PORTAL_ELEMENT || !node.vC?.[0] ? node.nN : node.vC[0]
}
+ return node?.e
}
const removeNode = (node: Node): void => {
@@ -343,7 +339,7 @@ const apply = (node: NodeObject, container: Container, isNew: boolean): void =>
const findChildNodeIndex = (
childNodes: NodeListOf<ChildNode>,
- child: ChildNode | null | undefined
+ child: ChildNode | undefined
): number | undefined => {
if (!child) {
return
@@ -428,7 +424,7 @@ const applyNodeObject = (node: NodeObject, container: Container, isNew: boolean)
}
}
if (node.pP) {
- delete node.pP
+ node.pP = undefined
}
if (callbacks.length) {
const useLayoutEffectCbs: Array<() => void> = []
@@ -490,7 +486,9 @@ export const build = (context: Context, node: NodeObject, children?: Child[]): v
let prevNode: Node | undefined
for (let i = 0; i < children.length; i++) {
if (Array.isArray(children[i])) {
- children.splice(i, 1, ...(children[i] as Child[]).flat())
+ children.splice(i, 1, ...((children[i] as unknown[]).flat(Infinity) as Child[]))
+ i--
+ continue
}
let child = buildNode(children[i])
if (child) {
diff --git src/jsx/index.test.tsx src/jsx/index.test.tsx
index bb72e8bb9b..a830cd1827 100644
--- src/jsx/index.test.tsx
+++ src/jsx/index.test.tsx
@@ -1022,6 +1022,44 @@ d.replaceWith(c.content)
expect(nextRequest.toString()).toBe('<span>light</span>')
})
})
+
+ describe('async with html helper', () => {
+ it('should preserve context when using await before html helper', async () => {
+ // Regression test for https://github.com/honojs/hono/issues/4582
+ // Context was being popped before async children resolved
+ const AsyncParentWithHtml = async (props: { children?: any }) => {
+ await new Promise((r) => setTimeout(r, 10))
+ return html`<div>${props.children}</div>`
+ }
+
+ const template = (
+ <ThemeContext.Provider value='dark'>
+ <AsyncParentWithHtml>
+ <Consumer />
+ </AsyncParentWithHtml>
+ </ThemeContext.Provider>
+ )
+ expect((await template.toString()).toString()).toBe('<div><span>dark</span></div>')
+ })
+
+ it('should preserve nested context when using await before html helper', async () => {
+ const AsyncParentWithHtml = async (props: { children?: any }) => {
+ await new Promise((r) => setTimeout(r, 10))
+ return html`<div>${props.children}</div>`
+ }
+
+ const template = (
+ <ThemeContext.Provider value='dark'>
+ <AsyncParentWithHtml>
+ <ThemeContext.Provider value='black'>
+ <Consumer />
+ </ThemeContext.Provider>
+ </AsyncParentWithHtml>
+ </ThemeContext.Provider>
+ )
+ expect((await template.toString()).toString()).toBe('<div><span>black</span></div>')
+ })
+ })
})
describe('version', () => {
diff --git src/middleware/basic-auth/index.test.ts src/middleware/basic-auth/index.test.ts
index c4065e60e7..ed2ac653e9 100644
--- src/middleware/basic-auth/index.test.ts
+++ src/middleware/basic-auth/index.test.ts
@@ -319,3 +319,116 @@ describe('Basic Auth by Middleware', () => {
expect(await res.text()).toBe('{"message":"Custom unauthorized message as function object"}')
})
})
+
+describe('Basic Auth with onAuthSuccess', () => {
+ const username = 'callback-user'
+ const password = 'callback-pass'
+
+ it('should call onAuthSuccess callback on successful auth', async () => {
+ type Env = { Variables: { custom: string } }
+ const app = new Hono<Env>()
+ let callbackCalled = false
+ let callbackUsername = ''
+
+ app.use(
+ '/*',
+ basicAuth({
+ username,
+ password,
+ onAuthSuccess: (c, u) => {
+ callbackCalled = true
+ callbackUsername = u
+ c.set('custom', 'value')
+ },
+ })
+ )
+ app.get('/', (c) => c.text(c.get('custom') || 'no-custom'))
+
+ const credential = Buffer.from(`${username}:${password}`).toString('base64')
+ const res = await app.request('/', {
+ headers: { Authorization: `Basic ${credential}` },
+ })
+
+ expect(callbackCalled).toBe(true)
+ expect(callbackUsername).toBe(username)
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('value')
+ })
+
+ it('should support async onAuthSuccess callback', async () => {
+ type Env = { Variables: { asyncValue: string } }
+ const app = new Hono<Env>()
+
+ app.use(
+ '/*',
+ basicAuth({
+ username,
+ password,
+ onAuthSuccess: async (c) => {
+ await new Promise((resolve) => setTimeout(resolve, 10))
+ c.set('asyncValue', 'done')
+ },
+ })
+ )
+ app.get('/', (c) => c.text(c.get('asyncValue') || 'not-done'))
+
+ const credential = Buffer.from(`${username}:${password}`).toString('base64')
+ const res = await app.request('/', {
+ headers: { Authorization: `Basic ${credential}` },
+ })
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('done')
+ })
+
+ it('should not call onAuthSuccess on failed auth', async () => {
+ const app = new Hono()
+ let callbackCalled = false
+
+ app.use(
+ '/*',
+ basicAuth({
+ username,
+ password,
+ onAuthSuccess: () => {
+ callbackCalled = true
+ },
+ })
+ )
+ app.get('/', (c) => c.text('ok'))
+
+ const credential = Buffer.from('wrong:wrong').toString('base64')
+ const res = await app.request('/', {
+ headers: { Authorization: `Basic ${credential}` },
+ })
+
+ expect(callbackCalled).toBe(false)
+ expect(res.status).toBe(401)
+ })
+
+ it('should work with verifyUser mode', async () => {
+ type Env = { Variables: { verified: string } }
+ const app = new Hono<Env>()
+ let callbackUsername = ''
+
+ app.use(
+ '/*',
+ basicAuth({
+ verifyUser: (u, p) => u === username && p === password,
+ onAuthSuccess: (c, u) => {
+ callbackUsername = u
+ c.set('verified', 'yes')
+ },
+ })
+ )
+ app.get('/', (c) => c.text(c.get('verified') || 'no'))
+
+ const credential = Buffer.from(`${username}:${password}`).toString('base64')
+ const res = await app.request('/', {
+ headers: { Authorization: `Basic ${credential}` },
+ })
+
+ expect(callbackUsername).toBe(username)
+ expect(res.status).toBe(200)
+ expect(await res.text()).toBe('yes')
+ })
+})
diff --git src/middleware/basic-auth/index.ts src/middleware/basic-auth/index.ts
index b27b38c3fe..5b2011eaa7 100644
--- src/middleware/basic-auth/index.ts
+++ src/middleware/basic-auth/index.ts
@@ -18,12 +18,14 @@ type BasicAuthOptions =
realm?: string
hashFunction?: Function
invalidUserMessage?: string | object | MessageFunction
+ onAuthSuccess?: (c: Context, username: string) => void | Promise<void>
}
| {
verifyUser: (username: string, password: string, c: Context) => boolean | Promise<boolean>
realm?: string
hashFunction?: Function
invalidUserMessage?: string | object | MessageFunction
+ onAuthSuccess?: (c: Context, username: string) => void | Promise<void>
}
/**
@@ -38,6 +40,7 @@ type BasicAuthOptions =
* @param {Function} [options.hashFunction] - The hash function used for secure comparison.
* @param {Function} [options.verifyUser] - The function to verify user credentials.
* @param {string | object | MessageFunction} [options.invalidUserMessage="Unauthorized"] - The invalid user message.
+ * @param {Function} [options.onAuthSuccess] - Callback function called on successful authentication.
* @returns {MiddlewareHandler} The middleware handler function.
* @throws {HTTPException} If neither "username and password" nor "verifyUser" options are provided.
*
@@ -57,6 +60,22 @@ type BasicAuthOptions =
* return c.text('You are authorized')
* })
* ```
+ *
+ * @example
+ * ```ts
+ * // With onAuthSuccess callback
+ * app.use(
+ * '/auth/*',
+ * basicAuth({
+ * username: 'hono',
+ * password: 'ahotproject',
+ * onAuthSuccess: (c, username) => {
+ * c.set('user', { name: username, role: 'admin' })
+ * console.log(`User ${username} authenticated`)
+ * },
+ * })
+ * )
+ * ```
*/
export const basicAuth = (
options: BasicAuthOptions,
@@ -88,6 +107,9 @@ export const basicAuth = (
if (requestUser) {
if (verifyUserInOptions) {
if (await options.verifyUser(requestUser.username, requestUser.password, ctx)) {
+ if (options.onAuthSuccess) {
+ await options.onAuthSuccess(ctx, requestUser.username)
+ }
await next()
return
}
@@ -98,6 +120,9 @@ export const basicAuth = (
timingSafeEqual(user.password, requestUser.password, options.hashFunction),
])
if (usernameEqual && passwordEqual) {
+ if (options.onAuthSuccess) {
+ await options.onAuthSuccess(ctx, requestUser.username)
+ }
await next()
return
}
diff --git src/middleware/bearer-auth/index.test.ts src/middleware/bearer-auth/index.test.ts
index c9f579c999..2dfa97e9c0 100644
--- src/middleware/bearer-auth/index.test.ts
+++ src/middleware/bearer-auth/index.test.ts
@@ -450,6 +450,18 @@ describe('Bearer Auth by Middleware', () => {
expect(res.headers.get('x-custom')).toBe('foo')
})
+ it.each([['bearer'], ['BEARER'], ['BeArEr']])(
+ 'Should authorize - prefix is case-insensitive: %s',
+ async (prefix) => {
+ const req = new Request('http://localhost/auth/a')
+ req.headers.set('Authorization', `${prefix} ${token}`)
+ const res = await app.request(req)
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(200)
+ expect(handlerExecuted).toBeTruthy()
+ }
+ )
+
it('Should not authorize - no authorization header', async () => {
const req = new Request('http://localhost/auth/a')
const res = await app.request(req)
@@ -481,6 +493,15 @@ describe('Bearer Auth by Middleware', () => {
expect(res.headers.get('x-custom')).toBeNull()
})
+ it('Should not authorize - token is case-sensitive', async () => {
+ const req = new Request('http://localhost/auth/a')
+ req.headers.set('Authorization', `Bearer ${token.toUpperCase()}`)
+ const res = await app.request(req)
+ expect(res).not.toBeNull()
+ expect(res.status).toBe(401)
+ expect(await res.text()).toBe('Unauthorized')
+ })
+
it('Should authorize', async () => {
const req = new Request('http://localhost/authBot/a')
req.headers.set('Authorization', 'Bot abcdefg12345-._~+/=')
diff --git src/middleware/bearer-auth/index.ts src/middleware/bearer-auth/index.ts
index 3ecd2e3be2..122ccc55ef 100644
--- src/middleware/bearer-auth/index.ts
+++ src/middleware/bearer-auth/index.ts
@@ -113,7 +113,7 @@ export const bearerAuth = (options: BearerAuthOptions): MiddlewareHandler => {
const realm = options.realm?.replace(/"/g, '\\"')
const prefixRegexStr = options.prefix === '' ? '' : `${options.prefix} +`
- const regexp = new RegExp(`^${prefixRegexStr}(${TOKEN_STRINGS}) *$`)
+ const regexp = new RegExp(`^${prefixRegexStr}(${TOKEN_STRINGS}) *$`, 'i')
const wwwAuthenticatePrefix = options.prefix === '' ? '' : `${options.prefix} `
const throwHTTPException = async (
diff --git src/middleware/jwt/jwt.ts src/middleware/jwt/jwt.ts
index 1a479dde40..c7d88da52e 100644
--- src/middleware/jwt/jwt.ts
+++ src/middleware/jwt/jwt.ts
@@ -25,7 +25,7 @@ export type JwtVariables<T = any> = {
* @see {@link https://hono.dev/docs/middleware/builtin/jwt}
*
* @param {object} options - The options for the JWT middleware.
- * @param {SignatureKey} [options.secret] - A value of your secret key.
+ * @param {SignatureKey} options.secret - A value of your secret key.
* @param {string} [options.cookie] - If this value is set, then the value is retrieved from the cookie header using that value as a key, which is then validated as a token.
* @param {SignatureAlgorithm} options.alg - An algorithm type that is used for verifying (required). Available types are `HS256` | `HS384` | `HS512` | `RS256` | `RS384` | `RS512` | `PS256` | `PS384` | `PS512` | `ES256` | `ES384` | `ES512` | `EdDSA`.
* @param {string} [options.headerName='Authorization'] - The name of the header to look for the JWT token. Default is 'Authorization'.
diff --git src/middleware/language/index.test.ts src/middleware/language/index.test.ts
index dc35d1b6ee..8bd7d2cb63 100644
--- src/middleware/language/index.test.ts
+++ src/middleware/language/index.test.ts
@@ -77,6 +77,82 @@ describe('languageDetector', () => {
expect(await res.text()).toBe('fr')
})
+ it('should fallback to language code when locale code is not in supportedLanguages', async () => {
+ const app = createTestApp({
+ supportedLanguages: ['en', 'ja'],
+ fallbackLanguage: 'en',
+ order: ['header'],
+ })
+
+ const res = await app.request('/', {
+ headers: {
+ 'accept-language': 'ja-JP',
+ },
+ })
+ expect(await res.text()).toBe('ja')
+ })
+
+ it('should match after multiple truncations', async () => {
+ const app = createTestApp({
+ supportedLanguages: ['zh-Hant', 'en'],
+ fallbackLanguage: 'en',
+ order: ['header'],
+ })
+
+ const res = await app.request('/', {
+ headers: {
+ 'accept-language': 'zh-Hant-CN',
+ },
+ })
+ expect(await res.text()).toBe('zh-Hant')
+ })
+
+ it('should fallback when truncation does not match any supported language', async () => {
+ const app = createTestApp({
+ supportedLanguages: ['en', 'ja'],
+ fallbackLanguage: 'en',
+ order: ['header'],
+ })
+
+ const res = await app.request('/', {
+ headers: {
+ 'accept-language': 'ko-KR',
+ },
+ })
+ expect(await res.text()).toBe('en')
+ })
+
+ it('should prefer exact match over truncated match', async () => {
+ const app = createTestApp({
+ supportedLanguages: ['fr', 'fr-CA'],
+ fallbackLanguage: 'fr',
+ order: ['header'],
+ })
+
+ const res = await app.request('/', {
+ headers: {
+ 'accept-language': 'fr-CA',
+ },
+ })
+ expect(await res.text()).toBe('fr-CA')
+ })
+
+ it('should handle case-insensitive truncation matching', async () => {
+ const app = createTestApp({
+ supportedLanguages: ['en', 'ja'],
+ fallbackLanguage: 'en',
+ order: ['header'],
+ ignoreCase: true,
+ })
+
+ const res = await app.request('/', {
+ headers: {
+ 'accept-language': 'JA-JP',
+ },
+ })
+ expect(await res.text()).toBe('ja')
+ })
+
it('should handle malformed Accept-Language headers', async () => {
const app = createTestApp({
supportedLanguages: ['en', 'fr'],
diff --git src/middleware/language/language.ts src/middleware/language/language.ts
index d959b8259d..df3b0a7611 100644
--- src/middleware/language/language.ts
+++ src/middleware/language/language.ts
@@ -100,8 +100,23 @@ export const normalizeLanguage = (
options.ignoreCase ? l.toLowerCase() : l
)
- const matchedLang = compSupported.find((l) => l === compLang)
- return matchedLang ? options.supportedLanguages[compSupported.indexOf(matchedLang)] : undefined
+ // Exact match
+ const exactIndex = compSupported.indexOf(compLang)
+ if (exactIndex !== -1) {
+ return options.supportedLanguages[exactIndex]
+ }
+
+ // Progressive truncation (RFC 4647 Lookup)
+ const parts = compLang.split('-')
+ for (let i = parts.length - 1; i > 0; i--) {
+ const candidate = parts.slice(0, i).join('-')
+ const prefixIndex = compSupported.indexOf(candidate)
+ if (prefixIndex !== -1) {
+ return options.supportedLanguages[prefixIndex]
+ }
+ }
+
+ return undefined
} catch {
return undefined
}
diff --git src/middleware/trailing-slash/index.test.ts src/middleware/trailing-slash/index.test.ts
index 435ef15aee..e5e7ba7d1a 100644
--- src/middleware/trailing-slash/index.test.ts
+++ src/middleware/trailing-slash/index.test.ts
@@ -87,6 +87,73 @@ describe('Resolve trailing slash', () => {
})
})
+ describe('trimTrailingSlash middleware with alwaysRedirect option', () => {
+ const app = new Hono()
+ app.use('*', trimTrailingSlash({ alwaysRedirect: true }))
+
+ app.get('/', async (c) => {
+ return c.text('ok')
+ })
+ app.get('/my-path/*', async (c) => {
+ return c.text('wildcard')
+ })
+ app.get('/exact-path', async (c) => {
+ return c.text('exact')
+ })
+
+ it('should handle GET request for root path correctly', async () => {
+ const resp = await app.request('/')
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(200)
+ })
+
+ it('should redirect wildcard route with trailing slash', async () => {
+ const resp = await app.request('/my-path/something/else/')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something/else')
+ })
+
+ it('should not redirect wildcard route without trailing slash', async () => {
+ const resp = await app.request('/my-path/something/else')
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(200)
+ expect(await resp.text()).toBe('wildcard')
+ })
+
+ it('should redirect exact route with trailing slash', async () => {
+ const resp = await app.request('/exact-path/')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/exact-path')
+ })
+
+ it('should preserve query parameters when redirecting', async () => {
+ const resp = await app.request('/my-path/something/?param=1')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something')
+ expect(loc.searchParams.get('param')).toBe('1')
+ })
+
+ it('should handle HEAD request for wildcard route with trailing slash', async () => {
+ const resp = await app.request('/my-path/something/', { method: 'HEAD' })
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something')
+ })
+ })
+
describe('appendTrailingSlash middleware', () => {
const app = new Hono({ strict: true })
app.use('*', appendTrailingSlash())
@@ -187,4 +254,71 @@ describe('Resolve trailing slash', () => {
expect(loc.searchParams.get('exampleParam')).toBe('1')
})
})
+
+ describe('appendTrailingSlash middleware with alwaysRedirect option', () => {
+ const app = new Hono()
+ app.use('*', appendTrailingSlash({ alwaysRedirect: true }))
+
+ app.get('/', async (c) => {
+ return c.text('ok')
+ })
+ app.get('/my-path/*', async (c) => {
+ return c.text('wildcard')
+ })
+ app.get('/exact-path/', async (c) => {
+ return c.text('exact')
+ })
+
+ it('should handle GET request for root path correctly', async () => {
+ const resp = await app.request('/')
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(200)
+ })
+
+ it('should redirect wildcard route without trailing slash', async () => {
+ const resp = await app.request('/my-path/something/else')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something/else/')
+ })
+
+ it('should not redirect wildcard route with trailing slash', async () => {
+ const resp = await app.request('/my-path/something/else/')
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(200)
+ expect(await resp.text()).toBe('wildcard')
+ })
+
+ it('should redirect exact route without trailing slash', async () => {
+ const resp = await app.request('/exact-path')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/exact-path/')
+ })
+
+ it('should preserve query parameters when redirecting', async () => {
+ const resp = await app.request('/my-path/something?param=1')
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something/')
+ expect(loc.searchParams.get('param')).toBe('1')
+ })
+
+ it('should handle HEAD request for wildcard route without trailing slash', async () => {
+ const resp = await app.request('/my-path/something', { method: 'HEAD' })
+ const loc = new URL(resp.headers.get('location')!)
+
+ expect(resp).not.toBeNull()
+ expect(resp.status).toBe(301)
+ expect(loc.pathname).toBe('/my-path/something/')
+ })
+ })
})
diff --git src/middleware/trailing-slash/index.ts src/middleware/trailing-slash/index.ts
index 1a26b7e3fc..683d17bf78 100644
--- src/middleware/trailing-slash/index.ts
+++ src/middleware/trailing-slash/index.ts
@@ -5,11 +5,23 @@
import type { MiddlewareHandler } from '../../types'
+type TrimTrailingSlashOptions = {
+ /**
+ * If `true`, the middleware will always redirect requests with a trailing slash
+ * before executing handlers.
+ * This is useful for routes with wildcards (`*`).
+ * If `false` (default), it will only redirect when the route is not found (404).
+ * @default false
+ */
+ alwaysRedirect?: boolean
+}
+
/**
* Trailing Slash Middleware for Hono.
*
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
*
+ * @param {TrimTrailingSlashOptions} options - The options for the middleware.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
@@ -19,12 +31,35 @@ import type { MiddlewareHandler } from '../../types'
* app.use(trimTrailingSlash())
* app.get('/about/me/', (c) => c.text('With Trailing Slash'))
* ```
+ *
+ * @example
+ * ```ts
+ * // With alwaysRedirect option for wildcard routes
+ * const app = new Hono()
+ *
+ * app.use(trimTrailingSlash({ alwaysRedirect: true }))
+ * app.get('/my-path/*', (c) => c.text('Wildcard route'))
+ * ```
*/
-export const trimTrailingSlash = (): MiddlewareHandler => {
+export const trimTrailingSlash = (options?: TrimTrailingSlashOptions): MiddlewareHandler => {
return async function trimTrailingSlash(c, next) {
+ if (options?.alwaysRedirect) {
+ if (
+ (c.req.method === 'GET' || c.req.method === 'HEAD') &&
+ c.req.path !== '/' &&
+ c.req.path.at(-1) === '/'
+ ) {
+ const url = new URL(c.req.url)
+ url.pathname = url.pathname.substring(0, url.pathname.length - 1)
+
+ return c.redirect(url.toString(), 301)
+ }
+ }
+
await next()
if (
+ !options?.alwaysRedirect &&
c.res.status === 404 &&
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
c.req.path !== '/' &&
@@ -38,12 +73,24 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
}
}
+type AppendTrailingSlashOptions = {
+ /**
+ * If `true`, the middleware will always redirect requests without a trailing slash
+ * before executing handlers.
+ * This is useful for routes with wildcards (`*`).
+ * If `false` (default), it will only redirect when the route is not found (404).
+ * @default false
+ */
+ alwaysRedirect?: boolean
+}
+
/**
* Append trailing slash middleware for Hono.
* Append a trailing slash to the URL if it doesn't have one. For example, `/path/to/page` will be redirected to `/path/to/page/`.
*
* @see {@link https://hono.dev/docs/middleware/builtin/trailing-slash}
*
+ * @param {AppendTrailingSlashOptions} options - The options for the middleware.
* @returns {MiddlewareHandler} The middleware handler function.
*
* @example
@@ -52,12 +99,31 @@ export const trimTrailingSlash = (): MiddlewareHandler => {
*
* app.use(appendTrailingSlash())
* ```
+ *
+ * @example
+ * ```ts
+ * // With alwaysRedirect option for wildcard routes
+ * const app = new Hono()
+ *
+ * app.use(appendTrailingSlash({ alwaysRedirect: true }))
+ * app.get('/my-path/*', (c) => c.text('Wildcard route'))
+ * ```
*/
-export const appendTrailingSlash = (): MiddlewareHandler => {
+export const appendTrailingSlash = (options?: AppendTrailingSlashOptions): MiddlewareHandler => {
return async function appendTrailingSlash(c, next) {
+ if (options?.alwaysRedirect) {
+ if ((c.req.method === 'GET' || c.req.method === 'HEAD') && c.req.path.at(-1) !== '/') {
+ const url = new URL(c.req.url)
+ url.pathname += '/'
+
+ return c.redirect(url.toString(), 301)
+ }
+ }
+
await next()
if (
+ !options?.alwaysRedirect &&
c.res.status === 404 &&
(c.req.method === 'GET' || c.req.method === 'HEAD') &&
c.req.path.at(-1) !== '/'
diff --git src/router/trie-router/node.test.ts src/router/trie-router/node.test.ts
index 126417d16b..fa22b01c5e 100644
--- src/router/trie-router/node.test.ts
+++ src/router/trie-router/node.test.ts
@@ -804,3 +804,13 @@ describe('The same name is used for path params', () => {
})
})
})
+
+describe('Node with initial method and handler', () => {
+ it('should create a node with method and handler via constructor', () => {
+ const node = new Node('get', 'initial handler')
+ node.insert('get', '/hello', 'hello handler')
+ const [res] = node.search('get', '/hello')
+ expect(res.length).toBe(1)
+ expect(res[0][0]).toEqual('hello handler')
+ })
+})
diff --git src/router/trie-router/node.ts src/router/trie-router/node.ts
index 1cb27e88a7..42c03b48ce 100644
--- src/router/trie-router/node.ts
+++ src/router/trie-router/node.ts
@@ -15,6 +15,13 @@ type HandlerParamsSet<T> = HandlerSet<T> & {
const emptyParams = Object.create(null)
+const hasChildren = (children: Record<string, unknown>): boolean => {
+ for (const _ in children) {
+ return true
+ }
+ return false
+}
+
export class Node<T> {
#methods: Record<string, HandlerSet<T>>[]
@@ -77,13 +84,13 @@ export class Node<T> {
return curNode
}
- #getHandlerSets(
+ #pushHandlerSets(
+ handlerSets: HandlerParamsSet<T>[],
node: Node<T>,
method: string,
nodeParams: Record<string, string>,
params?: Record<string, string>
- ): HandlerParamsSet<T>[] {
- const handlerSets: HandlerParamsSet<T>[] = []
+ ): void {
for (let i = 0, len = node.#methods.length; i < len; i++) {
const m = node.#methods[i]
const handlerSet = (m[method] || m[METHOD_NAME_ALL]) as HandlerParamsSet<T>
@@ -102,7 +109,6 @@ export class Node<T> {
}
}
}
- return handlerSets
}
search(method: string, path: string): [[T, Params][]] {
@@ -115,7 +121,10 @@ export class Node<T> {
const parts = splitPath(path)
const curNodesQueue: Node<T>[][] = []
- for (let i = 0, len = parts.length; i < len; i++) {
+ const len = parts.length
+ let partOffsets: number[] | null = null
+
+ for (let i = 0; i < len; i++) {
const part: string = parts[i]
const isLast = i === len - 1
const tempNodes: Node<T>[] = []
@@ -129,11 +138,9 @@ export class Node<T> {
if (isLast) {
// '/hello/*' => match '/hello'
if (nextNode.#children['*']) {
- handlerSets.push(
- ...this.#getHandlerSets(nextNode.#children['*'], method, node.#params)
- )
+ this.#pushHandlerSets(handlerSets, nextNode.#children['*'], method, node.#params)
}
- handlerSets.push(...this.#getHandlerSets(nextNode, method, node.#params))
+ this.#pushHandlerSets(handlerSets, nextNode, method, node.#params)
} else {
tempNodes.push(nextNode)
}
@@ -148,7 +155,7 @@ export class Node<T> {
if (pattern === '*') {
const astNode = node.#children['*']
if (astNode) {
- handlerSets.push(...this.#getHandlerSets(astNode, method, node.#params))
+ this.#pushHandlerSets(handlerSets, astNode, method, node.#params)
astNode.#params = params
tempNodes.push(astNode)
}
@@ -164,14 +171,23 @@ export class Node<T> {
const child = node.#children[key]
// `/js/:filename{[a-z]+.js}` => match /js/chunk/123.js
- const restPathString = parts.slice(i).join('/')
if (matcher instanceof RegExp) {
+ if (partOffsets === null) {
+ partOffsets = new Array(len)
+ let offset = path[0] === '/' ? 1 : 0
+ for (let p = 0; p < len; p++) {
+ partOffsets[p] = offset
+ offset += parts[p].length + 1
+ }
+ }
+ const restPathString = path.substring(partOffsets[i])
+
const m = matcher.exec(restPathString)
if (m) {
params[name] = m[0]
- handlerSets.push(...this.#getHandlerSets(child, method, node.#params, params))
+ this.#pushHandlerSets(handlerSets, child, method, node.#params, params)
- if (Object.keys(child.#children).length) {
+ if (hasChildren(child.#children)) {
child.#params = params
const componentCount = m[0].match(/\//)?.length ?? 0
const targetCurNodes = (curNodesQueue[componentCount] ||= [])
@@ -185,10 +201,14 @@ export class Node<T> {
if (matcher === true || matcher.test(part)) {
params[name] = part
if (isLast) {
- handlerSets.push(...this.#getHandlerSets(child, method, params, node.#params))
+ this.#pushHandlerSets(handlerSets, child, method, params, node.#params)
if (child.#children['*']) {
- handlerSets.push(
- ...this.#getHandlerSets(child.#children['*'], method, params, node.#params)
+ this.#pushHandlerSets(
+ handlerSets,
+ child.#children['*'],
+ method,
+ params,
+ node.#params
)
}
} else {
@@ -199,7 +219,8 @@ export class Node<T> {
}
}
- curNodes = tempNodes.concat(curNodesQueue.shift() ?? [])
+ const shifted = curNodesQueue.shift()
+ curNodes = shifted ? tempNodes.concat(shifted) : tempNodes
}
if (handlerSets.length > 1) {
diff --git src/types.test.ts src/types.test.ts
index 65d840cd8b..548dcb6236 100644
--- src/types.test.ts
+++ src/types.test.ts
@@ -3682,3 +3682,110 @@ describe('Handlers returning Promise<void>', () => {
type verify = Expect<Equal<Expected, Actual>>
})
})
+
+// Regression tests for #4388: routes before .use() with explicit paths should not be dropped
+describe('Routes before .use() with explicit paths (#4388)', () => {
+ it('should preserve explicit-path .get() before .use() with path', () => {
+ const app = new Hono()
+ .get('/', (c) => c.text('Hello from /'))
+ .use('/noop', async (c, next) => {
+ await next()
+ })
+
+ type Actual = ExtractSchema<typeof app>
+ type Expected = {
+ '/': {
+ $get: {
+ input: {}
+ output: 'Hello from /'
+ outputFormat: 'text'
+ status: ContentfulStatusCode
+ }
+ }
+ }
+ type verify = Expect<Equal<Expected, Actual>>
+ })
+
+ it('should preserve .get() route and infer .post() under .use() path', () => {
+ const app = new Hono()
+ .get('/', (c) => c.text('Hello from /'))
+ .use('/:slug', async (c, next) => {
+ await next()
+ })
+ .post((c) => c.text('posted'))
+
+ type Actual = ExtractSchema<typeof app>
+ type Expected = {
+ '/': {
+ $get: {
+ input: {}
+ output: 'Hello from /'
+ outputFormat: 'text'
+ status: ContentfulStatusCode
+ }
+ }
+ } & {
+ '/:slug': {
+ $post: {
+ input: {
+ param: {
+ slug: string
+ }
+ }
+ output: 'posted'
+ outputFormat: 'text'
+ status: ContentfulStatusCode
+ }
+ }
+ }
+ type verify = Expect<Equal<Expected, Actual>>
+ })
+
+ it('should preserve routes through .route() wrapping', () => {
+ const inner = new Hono()
+ .get('/', (c) => c.text('index'))
+ .use('/:slug', async (c, next) => {
+ await next()
+ })
+ .post((c) => c.text('posted'))
+
+ const app = new Hono().route('/api', inner)
+
+ const client = hc<typeof app>('http://localhost')
+ // '/api' $get should exist (from inner .get('/'))
+ expectTypeOf(client.api.$get).toBeFunction()
+ // '/api/:slug' $post should exist (from inner .post() after .use('/:slug'))
+ expectTypeOf(client.api[':slug'].$post).toBeFunction()
+ })
+
+ it('should preserve multiple explicit-path routes before .use()', () => {
+ const app = new Hono()
+ .get('/', (c) => c.text('home'))
+ .get('/about', (c) => c.text('about'))
+ .use('/mw', async (c, next) => {
+ await next()
+ })
+
+ type Actual = ExtractSchema<typeof app>
+ type Expected = {
+ '/': {
+ $get: {
+ input: {}
+ output: 'home'
+ outputFormat: 'text'
+ status: ContentfulStatusCode
+ }
+ }
+ } & {
+ '/about': {
+ $get: {
+ input: {}
+ output: 'about'
+ outputFormat: 'text'
+ status: ContentfulStatusCode
+ }
+ }
+ }
+ type verify = Expect<Equal<Expected, Actual>>
+ })
+})
diff --git src/utils/buffer.test.ts src/utils/buffer.test.ts
index 62f805815e..56db598df1 100644
--- src/utils/buffer.test.ts
+++ src/utils/buffer.test.ts
@@ -40,13 +40,6 @@ describe('buffer', () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(await timingSafeEqual(undefined, undefined)).toBe(true)
- expect(await timingSafeEqual(true, true)).toBe(true)
- expect(await timingSafeEqual(false, false)).toBe(true)
- expect(
- await timingSafeEqual(true, true, (d: boolean) =>
- createHash('sha256').update(d.toString()).digest('hex')
- )
- )
})
it('negative', async () => {
@@ -58,10 +51,30 @@ describe('buffer', () => {
await timingSafeEqual('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'a')
).toBe(false)
expect(await timingSafeEqual('alpha', 'beta')).toBe(false)
- expect(await timingSafeEqual(false, true)).toBe(false)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
expect(await timingSafeEqual(false, undefined)).toBe(false)
+
+ expect(
+ await timingSafeEqual(
+ // well known md5 hash collision
+ // https://marc-stevens.nl/research/md5-1block-collision/
+ 'TEXTCOLLBYfGiJUETHQ4hAcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak',
+ 'TEXTCOLLBYfGiJUETHQ4hEcKSMd5zYpgqf1YRDhkmxHkhPWptrkoyz28wnI9V0aHeAuaKnak',
+ (input) => createHash('md5').update(input).digest('hex')
+ )
+ ).toBe(false)
+ })
+
+ it.skip('comparing variables except string are deprecated', async () => {
+ expect(await timingSafeEqual(true, true)).toBe(true)
+ expect(await timingSafeEqual(false, false)).toBe(true)
+ expect(
+ await timingSafeEqual(true, true, (d: boolean) =>
+ createHash('sha256').update(d.toString()).digest('hex')
+ )
+ )
+ expect(await timingSafeEqual(false, true)).toBe(false)
expect(
await timingSafeEqual(
() => {},
diff --git src/utils/buffer.ts src/utils/buffer.ts
index b8ead4eedc..6634050955 100644
--- src/utils/buffer.ts
+++ src/utils/buffer.ts
@@ -26,22 +26,73 @@ export const equal = (a: ArrayBuffer, b: ArrayBuffer): boolean => {
return true
}
-export const timingSafeEqual = async (
- a: string | object | boolean,
- b: string | object | boolean,
+const constantTimeEqualString = (a: string, b: string): boolean => {
+ const aLen = a.length
+ const bLen = b.length
+ const maxLen = Math.max(aLen, bLen)
+ let out = aLen ^ bLen
+ for (let i = 0; i < maxLen; i++) {
+ const aChar = i < aLen ? a.charCodeAt(i) : 0
+ const bChar = i < bLen ? b.charCodeAt(i) : 0
+ out |= aChar ^ bChar
+ }
+ return out === 0
+}
+
+type StringHashFunction = (input: string) => string | null | Promise<string | null>
+
+const timingSafeEqualString = async (
+ a: string,
+ b: string,
+ hashFunction?: StringHashFunction
+): Promise<boolean> => {
+ if (!hashFunction) {
+ hashFunction = sha256
+ }
+
+ const [sa, sb] = await Promise.all([hashFunction(a), hashFunction(b)])
+
+ if (sa == null || sb == null || typeof sa !== 'string' || typeof sb !== 'string') {
+ return false
+ }
+
+ const hashEqual = constantTimeEqualString(sa, sb)
+ const originalEqual = constantTimeEqualString(a, b)
+
+ return hashEqual && originalEqual
+}
+
+type TimingSafeEqual = {
+ (a: string, b: string, hashFunction?: StringHashFunction): Promise<boolean>
+ /**
+ * @deprecated object and boolean signatures that take boolean as first and second arguments, and functions with signatures that take non-string arguments have been deprecated
+ */
+ (
+ a: string | object | boolean,
+ b: string | object | boolean,
+ hashFunction?: Function
+ ): Promise<boolean>
+}
+export const timingSafeEqual: TimingSafeEqual = async (
+ a,
+ b,
hashFunction?: Function
): Promise<boolean> => {
+ if (typeof a === 'string' && typeof b === 'string') {
+ return timingSafeEqualString(a, b, hashFunction as StringHashFunction)
+ }
+
if (!hashFunction) {
hashFunction = sha256
}
const [sa, sb] = await Promise.all([hashFunction(a), hashFunction(b)])
- if (!sa || !sb) {
+ if (!sa || !sb || typeof sa !== 'string' || typeof sb !== 'string') {
return false
}
- return sa === sb && a === b
+ return timingSafeEqualString(sa, sb)
}
export const bufferToString = (buffer: ArrayBuffer): string => {
diff --git src/utils/url.test.ts src/utils/url.test.ts
index e23ed346ae..c30213c079 100644
--- src/utils/url.test.ts
+++ src/utils/url.test.ts
@@ -125,6 +125,52 @@ describe('url', () => {
])('getPath - %s', (url) => {
expect(getPath(new Request(url))).toBe(new URL(url).pathname)
})
+
+ it('getPath - with fragment', () => {
+ let path = getPath(new Request('https://example.com/users/#user-list'))
+ expect(path).toBe('/users/')
+ path = getPath(new Request('https://example.com/users/1#profile-section'))
+ expect(path).toBe('/users/1')
+ path = getPath(new Request('https://example.com/hello#section'))
+ expect(path).toBe('/hello')
+ path = getPath(new Request('https://example.com/#top'))
+ expect(path).toBe('/')
+ })
+
+ it('getPath - with query and fragment', () => {
+ let path = getPath(new Request('https://example.com/hello?name=foo#section'))
+ expect(path).toBe('/hello')
+ path = getPath(new Request('https://example.com/search?q=test#results'))
+ expect(path).toBe('/search')
+ })
+
+ it('getPath - with percent encoding only (no query or fragment)', () => {
+ const path = getPath(new Request('https://example.com/hello%20world'))
+ expect(path).toBe('/hello world')
+ })
+
+ it('getPath - with percent encoding and fragment', () => {
+ let path = getPath(new Request('https://example.com/hello%20world#section'))
+ expect(path).toBe('/hello world')
+ path = getPath(new Request('https://example.com/%E7%82%8E#top'))
+ expect(path).toBe('/炎')
+ })
+
+ it('getPath - with percent encoding and fragment containing query-like chars', () => {
+ const path = getPath(new Request('https://example.com/hello%20world#section?foo=bar'))
+ expect(path).toBe('/hello world')
+ })
+
+ it('getPath - with encoded hash (%23) in path and real fragment', () => {
+ // %23 is encoded '#' - decodeURI preserves reserved characters, so %23 stays as %23
+ let path = getPath(new Request('https://example.com/path%23test#real-fragment'))
+ expect(path).toBe('/path%23test')
+ path = getPath(new Request('https://example.com/foo%23bar%23baz#section'))
+ expect(path).toBe('/foo%23bar%23baz')
+ // Only encoded hash, no real fragment
+ path = getPath(new Request('https://example.com/issue%23123'))
+ expect(path).toBe('/issue%23123')
+ })
})
describe('getQueryStrings', () => {
diff --git src/utils/url.ts src/utils/url.ts
index 82350aa9c3..6f39eaed41 100644
--- src/utils/url.ts
+++ src/utils/url.ts
@@ -111,13 +111,22 @@ export const getPath = (request: Request): string => {
const charCode = url.charCodeAt(i)
if (charCode === 37) {
// '%'
- // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately.
+ // If the path contains percent encoding, use `indexOf()` to find '?' or '#' 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)
+ const hashIndex = url.indexOf('#', i)
+ const end =
+ queryIndex === -1
+ ? hashIndex === -1
+ ? undefined
+ : hashIndex
+ : hashIndex === -1
+ ? queryIndex
+ : Math.min(queryIndex, hashIndex)
+ const path = url.slice(start, end)
return tryDecodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path)
- } else if (charCode === 63) {
- // '?'
+ } else if (charCode === 63 || charCode === 35) {
+ // '?' or '#'
break
}
}
diff --git src/validator/validator.test.ts src/validator/validator.test.ts
index 8bf482e561..917b36df15 100644
--- src/validator/validator.test.ts
+++ src/validator/validator.test.ts
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
-import type { ZodSchema } from 'zod'
-import { z } from 'zod'
+import * as z from 'zod'
import type { Context } from '../context'
import { Hono } from '../hono'
import { HTTPException } from '../http-exception'
@@ -35,7 +34,7 @@ type InferValidatorResponse<VF> = VF extends (value: any, c: any) => infer R
// Reference implementation for only testing
const zodValidator = <
- T extends ZodSchema,
+ T extends z.ZodSchema,
E extends {},
P extends string,
Target extends keyof ValidationTargets,
DescriptionThis pull request adds various enhancements and bug fixes, updates dependencies, and introduces new features. Here is a summary of the changes:
Possible Issues
Security Hotspots
Privacy Hotspots
ChangesChangesbun.lock
docs/MIGRATION.md
package.json
src/adapter/aws-lambda/*
src/adapter/cloudflare-pages/*
src/adapter/netlify/*
src/client/client.ts and src/client/client.test.ts
src/helper/ssg/*
src/jsx/*
src/middleware/basic-auth/*
src/middleware/bearer-auth/*
src/middleware/language/*
src/middleware/trailing-slash/*
src/router/trie-router/*
src/types.test.ts
src/utils/*
Overall, this PR introduces several beneficial features and enhancements that improve the functionality and security of the |
mihaiplesa
approved these changes
Feb 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bumps hono from 4.11.7 to 4.12.0.
Release notes
Sourced from hono's releases.
... (truncated)
Commits
d2ed2e94.12.001e78adMerge pull request #4735 from honojs/nexta340a25perf(context): usecreateResponseInstancefor new Response (#4733)bd26c31perf(trie-router): improve performance (1.5x ~ 2.0x) (#4724)b85c1e0feat(types): Add exports field to ExecutionContext (#4719)02346c6feat(language): add progressive locale code truncation to normalizeLanguage (...7438ab9perf(context): add fast path to c.json() matching c.text() optimization (#4707)034223ffeat(trailing-slash): addalwaysRedirectoption to support wildcard routes ...16321affeat(adapter): add getConnInfo for AWS Lambda, Cloudflare Pages, and Netlify ...bf37828feat(basic-auth): add context key and callback options (#4645)Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting
@dependabot rebase.Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR:
@dependabot rebasewill rebase this PR@dependabot recreatewill recreate this PR, overwriting any edits that have been made to it@dependabot show <dependency name> ignore conditionswill show all of the ignore conditions of the specified dependency@dependabot ignore this major versionwill close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this minor versionwill close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)@dependabot ignore this dependencywill close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)You can disable automated security fix PRs for this repo from the Security Alerts page.