From 69f303e0c3a45c09e7ec56611973751208737857 Mon Sep 17 00:00:00 2001 From: mohanram Date: Thu, 19 Feb 2026 15:43:35 +0530 Subject: [PATCH 01/12] feat(client): Add `ApplyGlobalResponse` type helper for RPC Client (#4556) * feat(client): add ApplyGlobalResponse type helper for global error handling * Applied @yusukebe's patch * ci: apply automated fixes --------- Co-authored-by: Yusuke Wada Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- src/client/client.test.ts | 111 +++++++++++++++++++++++++++++++++++++- src/client/types.ts | 33 +++++++++++- 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/src/client/client.test.ts b/src/client/client.test.ts index ba94a613b..b7f3fec3d 100644 --- a/src/client/client.test.ts +++ b/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) @@ -1639,3 +1644,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('http://localhost') + const req = client.api.users.$get + + // Type should be a union of normal response and global errors + type ResponseType = InferResponseType + type Expected = { users: string[] } | { error: string; message: string } + + type verify = Expect> + }) + + it('Should support multiple global error status codes', async () => { + const app = new Hono() + .get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) + .get('/api/unauthorized', (c) => + c.json({ error: 'Unauthorized', message: 'Please login' }, 401) + ) + .get('/api/error', (c) => + c.json({ error: 'Internal Server Error', message: 'Something went wrong' }, 500) + ) + + // Apply multiple global error types in one definition + type AppWithGlobalErrors = ApplyGlobalResponse< + typeof app, + { + 401: { json: { error: string; message: string } } + 500: { json: { error: string; message: string } } + } + > + + const client = hc('http://localhost') + + // Verify runtime behavior for different status codes + const usersRes = await client.api.users.$get() + expect(usersRes.status).toBe(200) + + const unauthorizedRes = await client.api.unauthorized.$get() + expect(unauthorizedRes.status).toBe(401) + expect(await unauthorizedRes.json()).toEqual({ error: 'Unauthorized', message: 'Please login' }) + + const errorRes = await client.api.error.$get() + expect(errorRes.status).toBe(500) + expect(await errorRes.json()).toEqual({ + error: 'Internal Server Error', + message: 'Something went wrong', + }) + }) + + it('Should work with onError handler pattern', () => { + // Simulating typical Hono app with onError handler + // Use explicit status code for proper type narrowing + const app = new Hono().get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200)) + + // In real app: app.onError((err, c) => c.json({ error: err.message }, 500)) + type AppWithOnError = ApplyGlobalResponse< + typeof app, + { + 500: { json: { error: string } } + } + > + + const client = hc('http://localhost') + const req = client.api.users.$get + + // RPC client should know about the error format + type ResponseType = InferResponseType + type Expected = { users: string[] } | { error: string } + + type verify = Expect> + }) +}) diff --git a/src/client/types.ts b/src/client/types.ts index cd5b2938d..758a2f379 100644 --- a/src/client/types.ts +++ b/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' @@ -309,3 +309,34 @@ interface CallbackOptions { export type ObjectType = { [key: string]: T } + +type GlobalResponseDefinition = { + [S in StatusCode]?: { + [F in KnownResponseFormat]?: unknown + } +} + +type ToEndpoints = { + [S in keyof Def & StatusCode]: { + [F in keyof Def[S] & KnownResponseFormat]: Omit & { + output: Def[S][F] + status: S + outputFormat: F + } + }[keyof Def[S] & KnownResponseFormat] +}[keyof Def & StatusCode] + +type ModRoute = R extends Endpoint + ? R | ToEndpoints + : R + +type ModSchema = { + [K in keyof D]: { + [M in keyof D[K]]: ModRoute + } +} + +export type ApplyGlobalResponse = + App extends HonoBase + ? Hono extends Schema ? ModSchema : never, B> + : never From 00b405a8f840f0f2ce4145e0fe5558cf2beeee78 Mon Sep 17 00:00:00 2001 From: 3w36zj6 <52315048+3w36zj6@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:16:26 +0900 Subject: [PATCH 02/12] feat(ssg): add redirect plugin (#4599) * feat(ssg): add redirect plugin * test(ssg): add check for robots noindex meta tag in redirect HTML * test(ssg): add check for body element in redirect HTML * style: apply curly rule * refactor(ssg): use html helper * docs(ssg): add note about plugin ordering * fix(ssg): use a single location parameter in redirect HTML generation * refactor(ssg): move default plugin definition to plugins * test(ssg): separate built-in plugin tests * refactor(ssg): convert default plugin to function * docs(ssg): standardize TSDoc format * docs(ssg): add usage examples to redirect plugin * refactor(ssg): rename variable to avoid collision * fix(ssg): export default plugin * fix(ssg): support 303, 307, 308 as redirect triggers * test(ssg): consolidate redirect plugin tests and clarify HTTP Semantics --- src/helper/ssg/index.ts | 1 + src/helper/ssg/plugins.test.tsx | 227 ++++++++++++++++++++++++++++++++ src/helper/ssg/plugins.ts | 72 ++++++++++ src/helper/ssg/ssg.test.tsx | 32 +---- src/helper/ssg/ssg.ts | 19 +-- 5 files changed, 303 insertions(+), 48 deletions(-) create mode 100644 src/helper/ssg/plugins.test.tsx create mode 100644 src/helper/ssg/plugins.ts diff --git a/src/helper/ssg/index.ts b/src/helper/ssg/index.ts index f6678e2b7..2a635752f 100644 --- a/src/helper/ssg/index.ts +++ b/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 000000000..f7fee4bf5 --- /dev/null +++ b/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('

Home

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

About

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

Blog

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

Home

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

About

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

Blog

') + expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/created.txt', expect.any(String)) + expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/redirect.txt', expect.any(String)) + expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/notfound.txt', expect.any(String)) + expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/error.txt', expect.any(String)) + expect(result.files.some((f) => f.includes('created'))).toBe(false) + expect(result.files.some((f) => f.includes('redirect'))).toBe(false) + expect(result.files.some((f) => f.includes('notfound'))).toBe(false) + expect(result.files.some((f) => f.includes('error'))).toBe(false) + expect(result.success).toBe(true) + }) + }) + + describe('redirect plugin', () => { + it('generates redirect HTML for status codes requiring Location per HTTP Semantics specification', async () => { + const statusCodes = [301, 302, 303, 307, 308] satisfies RedirectStatusCode[] + for (const statusCode of statusCodes) { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + const redirectApp = new Hono() + redirectApp.get('/old', (c) => c.redirect('/new', statusCode)) // Default is 302 + redirectApp.get('/new', (c) => c.html('New Page')) + + await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) + + expect(writtenFiles['static/old.html']).toBeDefined() + const content = writtenFiles['static/old.html'] + // Should contain meta refresh + expect(content).toContain('meta http-equiv="refresh" content="0;url=/new"') + // Should contain canonical + expect(content).toContain('rel="canonical" href="/new"') + // Should contain robots noindex + expect(content).toContain('') + // Should contain link anchor + expect(content).toContain('Redirecting to /new') + // Should contain a body element that includes the anchor + expect(content).toMatch(/]*>[\s\S]*[\s\S]*<\/body>/) + } + }) + + it('skips generating redirect HTML for status codes requiring Location when Location header is missing', async () => { + const statusCodes = [301, 302, 303, 307, 308] satisfies RedirectStatusCode[] + for (const statusCode of statusCodes) { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + const redirectApp = new Hono() + redirectApp.get('/bad', () => new Response(null, { status: statusCode })) + + await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) + + expect(writtenFiles['static/bad.html']).toBeUndefined() + } + }) + + it('skips generating redirect HTML for status codes not requiring Location per HTTP Semantics specification', async () => { + const statusCodes = [300, 304, 305, 306] satisfies RedirectStatusCode[] + for (const statusCode of statusCodes) { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + const redirectApp = new Hono() + + redirectApp.get( + '/response', + () => new Response(null, { status: statusCode, headers: { Location: '/' } }) + ) + + await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) + + expect(writtenFiles['static/response.html']).toBeUndefined() + } + }) + + it('does not apply redirect HTML for non-redirect status codes even with Location header', async () => { + const statusCodes = [200, 201, 400, 404, 500] satisfies Exclude< + StatusCode, + RedirectStatusCode + >[] + for (const statusCode of statusCodes) { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + const redirectApp = new Hono() + + redirectApp.get( + '/response', + () => new Response('Response Body', { status: statusCode, headers: { Location: '/' } }) + ) + + await toSSG(redirectApp, fsMockLocal, { dir: './static', plugins: [redirectPlugin()] }) + + expect(writtenFiles['static/response.txt']).toBeDefined() + expect(writtenFiles['static/response.txt']).toBe('Response Body') + } + }) + + it('escapes Location header values when generating redirect HTML', async () => { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + + const maliciousLocation = '/new"> ' + const redirectApp = new Hono() + redirectApp.get( + '/evil', + (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('') + expect(content).toContain('<script>alert(1)</script>') + expect(content).toContain('"') + }) + + it('redirectPlugin before defaultPlugin generates redirect HTML', async () => { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + + const redirectApp = new Hono() + redirectApp.get('/old', (c) => c.redirect('/new')) + redirectApp.get('/new', (c) => c.html('New Page')) + + await toSSG(redirectApp, fsMockLocal, { + dir: './static', + plugins: [redirectPlugin(), defaultPlugin()], + }) + expect(writtenFiles['static/old.html']).toBeDefined() + }) + + it('redirectPlugin after defaultPlugin does not generate redirect HTML', async () => { + const writtenFiles: Record = {} + const fsMockLocal: FileSystemModule = { + writeFile: (path, data) => { + writtenFiles[path] = typeof data === 'string' ? data : data.toString() + return Promise.resolve() + }, + mkdir: vi.fn(() => Promise.resolve()), + } + + const redirectApp = new Hono() + redirectApp.get('/old', (c) => c.redirect('/new')) + redirectApp.get('/new', (c) => c.html('New Page')) + + await toSSG(redirectApp, fsMockLocal, { + dir: './static', + plugins: [defaultPlugin(), redirectPlugin()], + }) + expect(writtenFiles['static/old.html']).toBeUndefined() + }) + }) +}) diff --git a/src/helper/ssg/plugins.ts b/src/helper/ssg/plugins.ts new file mode 100644 index 000000000..a25179a44 --- /dev/null +++ b/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` +Redirecting to: ${location} + + + + +Redirecting to ${location} + +` + return content.toString().replace(/\n/g, '') +} + +/** + * The redirect plugin that generates HTML redirect pages for HTTP redirect responses for status codes 301, 302, 303, 307 and 308. + * + * When used with `defaultPlugin`, place `redirectPlugin` before it, because `defaultPlugin` skips non-200 responses. + * + * ```ts + * // ✅ Will work as expected + * toSSG(app, fs, { plugins: [redirectPlugin(), defaultPlugin()] }) + * + * // ❌ Will not work as expected + * toSSG(app, fs, { plugins: [defaultPlugin(), redirectPlugin()] }) + * ``` + * + * @experimental + * `redirectPlugin` is an experimental feature. + * The API might be changed. + */ +export const redirectPlugin = (): SSGPlugin => { + return { + afterResponseHook: (res) => { + if (REDIRECT_STATUS_CODES.has(res.status)) { + const location = res.headers.get('Location') + if (!location) { + return false + } + const htmlBody = generateRedirectHtml(location) + return new Response(htmlBody, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }) + } + return res + }, + } +} diff --git a/src/helper/ssg/ssg.test.tsx b/src/helper/ssg/ssg.test.tsx index f779973e8..52324ee74 100644 --- a/src/helper/ssg/ssg.test.tsx +++ b/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', '

Home

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

About

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

Blog

') - expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/created.txt', expect.any(String)) - expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/redirect.txt', expect.any(String)) - expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/notfound.txt', expect.any(String)) - expect(fsMock.writeFile).not.toHaveBeenCalledWith('static/error.txt', expect.any(String)) - expect(result.files.some((f) => f.includes('created'))).toBe(false) - expect(result.files.some((f) => f.includes('redirect'))).toBe(false) - expect(result.files.some((f) => f.includes('notfound'))).toBe(false) - expect(result.files.some((f) => f.includes('error'))).toBe(false) - expect(result.success).toBe(true) - }) - it('should correctly apply plugins with beforeRequestHook', async () => { const plugin: SSGPlugin = { beforeRequestHook: (req) => { diff --git a/src/helper/ssg/ssg.ts b/src/helper/ssg/ssg.ts index 37e23aedb..4520f7dd0 100644 --- a/src/helper/ssg/ssg.ts +++ b/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, options?: ToSSGOptions): Promise } -/** - * 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[] = [] const savePromises: Promise[] = [] - const plugins = options?.plugins || [defaultPlugin] + const plugins = options?.plugins || [defaultPlugin()] const beforeRequestHooks: BeforeRequestHook[] = [] const afterResponseHooks: AfterResponseHook[] = [] const afterGenerateHooks: AfterGenerateHook[] = [] From 9524923b485145148d6395b7531f3f504dc85933 Mon Sep 17 00:00:00 2001 From: Shachar <34343793+ShaMan123@users.noreply.github.com> Date: Thu, 19 Feb 2026 12:18:25 +0200 Subject: [PATCH 03/12] feat(client): $path (#4636) * init * tests * fix leading slash * refactor(): reuse mergePath * ci: apply automated fixes * apply suggested patch * support an edge case --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yusuke Wada --- src/client/client.test.ts | 93 +++++++++++++++++++++++++++++++++++---- src/client/client.ts | 7 ++- src/client/types.test.ts | 33 ++++++++++++++ src/client/types.ts | 17 +++++++ 4 files changed, 139 insertions(+), 11 deletions(-) diff --git a/src/client/client.test.ts b/src/client/client.test.ts index b7f3fec3d..e42afb265 100644 --- a/src/client/client.test.ts +++ b/src/client/client.test.ts @@ -424,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(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', () => { @@ -760,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', () => { @@ -780,6 +816,35 @@ describe('Merge path with `app.route()`', () => { const url = client.api.v1.me.$url() expectTypeOf(url) expect(url.href).toBe('http://localhost/api/v1/me') + + const path = client.api.v1.me.$path() + expectTypeOf<'/api/v1/me'>(path) + expect(path).toBe('/api/v1/me') + }) + }) + + describe('With endpoint pathname', () => { + const app = new Hono().basePath('/api/v1') + const routes = app.route( + '/me', + new Hono().route( + '', + new Hono().get('', async (c) => { + return c.json({ name: 'hono' }) + }) + ) + ) + const client = hc('http://localhost/proxy') + + it('Should infer paths correctly', async () => { + // Should not a throw type error + const url = client.api.v1.me.$url() + expectTypeOf(url) + expect(url.href).toBe('http://localhost/proxy/api/v1/me') + + const path = client.api.v1.me.$path() + expectTypeOf<'/api/v1/me'>(path) + expect(path).toBe('/api/v1/me') }) }) }) @@ -1055,40 +1120,43 @@ describe('Infer the response types from middlewares', () => { }) }) -describe('$url() with a param option', () => { +const pathname = (value: T): string => + value instanceof URL ? value.pathname : value + +describe.each(['$path', '$url'] as const)('%s() with a param option', (cmd) => { const app = new Hono() .get('/posts/:id/comments', (c) => c.json({ ok: true })) .get('/something/:firstId/:secondId/:version?', (c) => c.json({ ok: true })) type AppType = typeof app const client = hc('http://localhost') - it('Should return the correct 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', () => { @@ -1106,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') }) }) diff --git a/src/client/client.ts b/src/client/client.ts index 9716972b5..72b968439 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -168,7 +168,7 @@ export const hc = , 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) { @@ -179,7 +179,10 @@ export const hc = , 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 a/src/client/types.test.ts b/src/client/types.test.ts index 59310551a..f13a6fcf9 100644 --- a/src/client/types.test.ts +++ b/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>() + 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>() + + 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('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 a/src/client/types.ts b/src/client/types.ts index 758a2f379..2fc15f58e 100644 --- a/src/client/types.ts +++ b/src/client/types.ts @@ -94,6 +94,21 @@ export type ClientRequest( arg?: Arg ) => HonoURL + $path: < + const Arg extends + | (S[keyof S] extends { input: infer R } + ? R extends { param: infer P } + ? R extends { query: infer Q } + ? { param: P; query: Q } + : { param: P } + : R extends { query: infer Q } + ? { query: Q } + : {} + : {}) + | undefined = undefined, + >( + arg?: Arg + ) => BuildPath } & (S['$get'] extends { outputFormat: 'ws' } ? S['$get'] extends { input: infer I } ? { @@ -146,6 +161,8 @@ type BuildPathname

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

}` +type BuildPath

= `${BuildPathname}${BuildSearch}` + type BuildTypedURL< Protocol extends string, Host extends string, From bf37828d6df56618bb90649c65c1c4deb2f9bcd6 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Thu, 19 Feb 2026 18:20:20 +0800 Subject: [PATCH 04/12] feat(basic-auth): add context key and callback options (#4645) * feat(basic-auth): add usernameContextKey and onAuthSuccess options Add two new options to basicAuth middleware: - `usernameContextKey`: stores authenticated username in context - `true`: uses default key 'basicAuthUsername' - `string`: uses custom key name - `onAuthSuccess`: callback invoked after successful authentication This allows route handlers to access the authenticated username without re-parsing the Authorization header. * test(basic-auth): add tests for usernameContextKey and onAuthSuccess Add test cases for: - usernameContextKey with default key 'basicAuthUsername' - usernameContextKey with custom key - usernameContextKey not set (should not store username) - usernameContextKey with verifyUser mode - onAuthSuccess callback execution - onAuthSuccess async callback support - onAuthSuccess not called on failed auth - onAuthSuccess with verifyUser mode - Both options used together * remove `usernameContextKey` * remove unnecessary comments * remove unnecessary comments --------- Co-authored-by: Yusuke Wada --- src/middleware/basic-auth/index.test.ts | 113 ++++++++++++++++++++++++ src/middleware/basic-auth/index.ts | 25 ++++++ 2 files changed, 138 insertions(+) diff --git a/src/middleware/basic-auth/index.test.ts b/src/middleware/basic-auth/index.test.ts index c4065e60e..ed2ac653e 100644 --- a/src/middleware/basic-auth/index.test.ts +++ b/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() + 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() + + 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() + 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 a/src/middleware/basic-auth/index.ts b/src/middleware/basic-auth/index.ts index b27b38c3f..5b2011eaa 100644 --- a/src/middleware/basic-auth/index.ts +++ b/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 } | { verifyUser: (username: string, password: string, c: Context) => boolean | Promise realm?: string hashFunction?: Function invalidUserMessage?: string | object | MessageFunction + onAuthSuccess?: (c: Context, username: string) => void | Promise } /** @@ -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 } From 16321afd47e1bf8f48d06d9d8a2eae6b607c73ef Mon Sep 17 00:00:00 2001 From: Bedirhan Celayir <44666921+rokasta12@users.noreply.github.com> Date: Thu, 19 Feb 2026 13:22:25 +0300 Subject: [PATCH 05/12] feat(adapter): add getConnInfo for AWS Lambda, Cloudflare Pages, and Netlify (#4649) * feat(adapter): add getConnInfo for AWS Lambda, Cloudflare Pages, and Netlify * fix(netlify): add explicit return type to getGeo for JSR validation * remove `getGeo` --------- Co-authored-by: Yusuke Wada --- src/adapter/aws-lambda/conninfo.test.ts | 112 ++++++++++++++++++ src/adapter/aws-lambda/conninfo.ts | 72 +++++++++++ src/adapter/aws-lambda/index.ts | 1 + src/adapter/cloudflare-pages/conninfo.test.ts | 27 +++++ src/adapter/cloudflare-pages/conninfo.ts | 26 ++++ src/adapter/cloudflare-pages/index.ts | 1 + src/adapter/netlify/conninfo.test.ts | 41 +++++++ src/adapter/netlify/conninfo.ts | 57 +++++++++ src/adapter/netlify/mod.ts | 1 + 9 files changed, 338 insertions(+) create mode 100644 src/adapter/aws-lambda/conninfo.test.ts create mode 100644 src/adapter/aws-lambda/conninfo.ts create mode 100644 src/adapter/cloudflare-pages/conninfo.test.ts create mode 100644 src/adapter/cloudflare-pages/conninfo.ts create mode 100644 src/adapter/netlify/conninfo.test.ts create mode 100644 src/adapter/netlify/conninfo.ts diff --git a/src/adapter/aws-lambda/conninfo.test.ts b/src/adapter/aws-lambda/conninfo.test.ts new file mode 100644 index 000000000..3dab1a8a9 --- /dev/null +++ b/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 000000000..26328ab36 --- /dev/null +++ b/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) => { + 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 a/src/adapter/aws-lambda/index.ts b/src/adapter/aws-lambda/index.ts index edbb7c8b1..3c086337b 100644 --- a/src/adapter/aws-lambda/index.ts +++ b/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 000000000..61b1573f9 --- /dev/null +++ b/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 000000000..90098dfed --- /dev/null +++ b/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 a/src/adapter/cloudflare-pages/index.ts b/src/adapter/cloudflare-pages/index.ts index 0bbeb2a37..fb0be1b01 100644 --- a/src/adapter/cloudflare-pages/index.ts +++ b/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 000000000..a8b585ce8 --- /dev/null +++ b/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 000000000..8b71034e1 --- /dev/null +++ b/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) => ({ + remote: { + address: c.env.context?.ip, + }, +}) diff --git a/src/adapter/netlify/mod.ts b/src/adapter/netlify/mod.ts index 015118284..662042084 100644 --- a/src/adapter/netlify/mod.ts +++ b/src/adapter/netlify/mod.ts @@ -1 +1,2 @@ export { handle } from './handler' +export { getConnInfo } from './conninfo' From 034223f1bf8db3c98e4bf2d11d597c94362729d7 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 19 Feb 2026 19:25:21 +0900 Subject: [PATCH 06/12] feat(trailing-slash): add `alwaysRedirect` option to support wildcard routes (#4658) * feat(trailing-slash): add `strict` option to support wildcard routes * use `eager` instead of `strict` * replace `eager` to `alwaysRedirect` --- src/middleware/trailing-slash/index.test.ts | 134 ++++++++++++++++++++ src/middleware/trailing-slash/index.ts | 70 +++++++++- 2 files changed, 202 insertions(+), 2 deletions(-) diff --git a/src/middleware/trailing-slash/index.test.ts b/src/middleware/trailing-slash/index.test.ts index 435ef15ae..e5e7ba7d1 100644 --- a/src/middleware/trailing-slash/index.test.ts +++ b/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 a/src/middleware/trailing-slash/index.ts b/src/middleware/trailing-slash/index.ts index 1a26b7e3f..683d17bf7 100644 --- a/src/middleware/trailing-slash/index.ts +++ b/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) !== '/' From 7438ab93553ce61773e2a74376972777602f08ff Mon Sep 17 00:00:00 2001 From: Olivier Louvignes Date: Thu, 19 Feb 2026 11:27:56 +0100 Subject: [PATCH 07/12] perf(context): add fast path to c.json() matching c.text() optimization (#4707) * perf: add fast path to c.json() matching c.text() optimization Skip #newResponse() and Headers allocation when no status, headers, or finalized state exists. Creates Response directly with inline Content-Type header, matching the existing c.text() fast path pattern. * ci: apply automated fixes * use `Response.json()` * don't use `any` * refactor with `useFastPath` --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Yusuke Wada --- src/context.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/context.ts b/src/context.ts index caaac4307..da6ecddab 100644 --- a/src/context.ts +++ b/src/context.ts @@ -657,6 +657,10 @@ export class Context< headers?: HeaderRecord ): ReturnType => this.#newResponse(data, arg, headers) as ReturnType + #useFastPath(): boolean { + return !this.#preparedHeaders && !this.#status && !this.finalized + } + /** * `.text()` can render text as `Content-Type:text/plain`. * @@ -674,7 +678,7 @@ export class Context< arg?: ContentfulStatusCode | ResponseOrInit, headers?: HeaderRecord ): ReturnType => { - return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized + return this.#useFastPath() && !arg && !headers ? (new Response(text) as ReturnType) : (this.#newResponse( text, @@ -703,11 +707,15 @@ export class Context< arg?: U | ResponseOrInit, headers?: HeaderRecord ): JSONRespondReturn => { - return this.#newResponse( - JSON.stringify(object), - arg, - setDefaultContentType('application/json', headers) - ) /* eslint-disable @typescript-eslint/no-explicit-any */ as any + return ( + this.#useFastPath() && !arg && !headers + ? Response.json(object) + : this.#newResponse( + JSON.stringify(object), + arg, + setDefaultContentType('application/json', headers) + ) + ) as JSONRespondReturn } html: HTMLRespond = ( From 02346c6d945a10c98f54ae51622e8c7afbe3bad4 Mon Sep 17 00:00:00 2001 From: fujitani sora Date: Thu, 19 Feb 2026 19:31:19 +0900 Subject: [PATCH 08/12] feat(language): add progressive locale code truncation to normalizeLanguage (#4717) * feat: lang locate code non all match trancation * fix: locate code trancate use lastIndexOf('-') * fix: back use for let --- src/middleware/language/index.test.ts | 76 +++++++++++++++++++++++++++ src/middleware/language/language.ts | 19 ++++++- 2 files changed, 93 insertions(+), 2 deletions(-) diff --git a/src/middleware/language/index.test.ts b/src/middleware/language/index.test.ts index dc35d1b6e..8bd7d2cb6 100644 --- a/src/middleware/language/index.test.ts +++ b/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 a/src/middleware/language/language.ts b/src/middleware/language/language.ts index d959b8259..df3b0a761 100644 --- a/src/middleware/language/language.ts +++ b/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 } From b85c1e032864322c581f4d04652d37ef59130eee Mon Sep 17 00:00:00 2001 From: Shosei Yoshikawa <65478744+toreis-up@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:38:48 +0900 Subject: [PATCH 09/12] feat(types): Add exports field to ExecutionContext (#4719) --- src/context.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/context.ts b/src/context.ts index da6ecddab..19af3573f 100644 --- a/src/context.ts +++ b/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 } /** From bd26c3129f8e159864d3f96522f44e900516e847 Mon Sep 17 00:00:00 2001 From: EdamAmex <121654029+EdamAme-x@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:46:23 +0900 Subject: [PATCH 10/12] perf(trie-router): improve performance (1.5x ~ 2.0x) (#4724) * Update node.ts * ci: apply automated fixes * ci: apply automated fixes (attempt 2/3) * test(trie-router): add test for Node constructor with method and handler Covers the constructor branch where method and handler are provided, improving patch coverage to 100% line/statement. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- src/router/trie-router/node.test.ts | 10 ++++++ src/router/trie-router/node.ts | 55 ++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/router/trie-router/node.test.ts b/src/router/trie-router/node.test.ts index 126417d16..fa22b01c5 100644 --- a/src/router/trie-router/node.test.ts +++ b/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 a/src/router/trie-router/node.ts b/src/router/trie-router/node.ts index 1cb27e88a..42c03b48c 100644 --- a/src/router/trie-router/node.ts +++ b/src/router/trie-router/node.ts @@ -15,6 +15,13 @@ type HandlerParamsSet = HandlerSet & { const emptyParams = Object.create(null) +const hasChildren = (children: Record): boolean => { + for (const _ in children) { + return true + } + return false +} + export class Node { #methods: Record>[] @@ -77,13 +84,13 @@ export class Node { return curNode } - #getHandlerSets( + #pushHandlerSets( + handlerSets: HandlerParamsSet[], node: Node, method: string, nodeParams: Record, params?: Record - ): HandlerParamsSet[] { - const handlerSets: HandlerParamsSet[] = [] + ): 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 @@ -102,7 +109,6 @@ export class Node { } } } - return handlerSets } search(method: string, path: string): [[T, Params][]] { @@ -115,7 +121,10 @@ export class Node { const parts = splitPath(path) const curNodesQueue: Node[][] = [] - 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[] = [] @@ -129,11 +138,9 @@ export class Node { 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 { 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 { 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 { 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 { } } - curNodes = tempNodes.concat(curNodesQueue.shift() ?? []) + const shifted = curNodesQueue.shift() + curNodes = shifted ? tempNodes.concat(shifted) : tempNodes } if (handlerSets.length > 1) { From a340a25fc6065f41328a20068c495f8a32410401 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 19 Feb 2026 19:59:54 +0900 Subject: [PATCH 11/12] perf(context): use `createResponseInstance` for new Response (#4733) --- src/context.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/context.ts b/src/context.ts index 19af3573f..521fbc7a0 100644 --- a/src/context.ts +++ b/src/context.ts @@ -285,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, @@ -396,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()), })) } @@ -408,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 @@ -509,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) { @@ -630,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)) @@ -684,7 +689,7 @@ export class Context< headers?: HeaderRecord ): ReturnType => { return this.#useFastPath() && !arg && !headers - ? (new Response(text) as ReturnType) + ? (createResponseInstance(text) as ReturnType) : (this.#newResponse( text, arg, @@ -777,7 +782,7 @@ export class Context< * ``` */ notFound = (): ReturnType => { - this.#notFoundHandler ??= () => new Response() + this.#notFoundHandler ??= () => createResponseInstance() return this.#notFoundHandler(this) } } From d2ed2e9c966d82e2369bd74bdae4acd4e8f57807 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Thu, 19 Feb 2026 20:45:05 +0900 Subject: [PATCH 12/12] 4.12.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0c773d048..fcd926a75 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hono", - "version": "4.11.10", + "version": "4.12.0", "description": "Web framework built on Web Standards", "main": "dist/cjs/index.js", "type": "module",