diff --git a/benchmarks/utils/src/get-path.ts b/benchmarks/utils/src/get-path.ts index f405338ae..48eebe145 100644 --- a/benchmarks/utils/src/get-path.ts +++ b/benchmarks/utils/src/get-path.ts @@ -5,15 +5,61 @@ bench('noop', () => {}) const request = new Request('http://localhost/about/me') group('getPath', () => { - bench('slice + indexOf', () => { + bench('slice + indexOf : w/o decodeURI', () => { const url = request.url const queryIndex = url.indexOf('?', 8) - url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) + return url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) }) - bench('regexp', () => { + bench('regexp : w/o decodeURI', () => { const match = request.url.match(/^https?:\/\/[^/]+(\/[^?]*)/) - match ? match[1] : '' + return match ? match[1] : '' + }) + + bench('slice + indexOf', () => { + const url = request.url + const queryIndex = url.indexOf('?', 8) + const path = url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) + return path.includes('%') ? decodeURIComponent(path) : path + }) + + bench('slice + for-loop + flag', () => { + const url = request.url + let start = url.indexOf('/', 8) + let i = start + let hasPercentEncoding = false + for (; i < url.length; i++) { + const charCode = url.charCodeAt(i) + if (charCode === 37) { + // '%' + hasPercentEncoding = true + } else if (charCode === 63) { + // '?' + break + } + } + return hasPercentEncoding ? decodeURIComponent(url.slice(start, i)) : url.slice(start, i) + }) + + bench('slice + for-loop + immediate return', () => { + const url = request.url + const start = url.indexOf('/', 8) + let i = start + for (; i < url.length; i++) { + const charCode = url.charCodeAt(i) + if (charCode === 37) { + // '%' + // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately. + // Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding. + const queryIndex = url.indexOf('?', i) + const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex) + return decodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path) + } else if (charCode === 63) { + // '?' + break + } + } + return url.slice(start, i) }) }) diff --git a/bun.lockb b/bun.lockb index fda42049f..c1654be2c 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index be5c204c8..17a355261 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hono", - "version": "4.3.9", + "version": "4.3.11", "description": "Ultrafast web framework for the Edges", "main": "dist/cjs/index.js", "type": "module", @@ -178,6 +178,11 @@ "import": "./dist/middleware/jwt/index.js", "require": "./dist/cjs/middleware/jwt/index.js" }, + "./timeout": { + "types": "./dist/types/middleware/timeout/index.d.ts", + "import": "./dist/middleware/timeout/index.js", + "require": "./dist/cjs/middleware/timeout/index.js" + }, "./timing": { "types": "./dist/types/middleware/timing/index.d.ts", "import": "./dist/middleware/timing/index.js", @@ -337,6 +342,11 @@ "types": "./dist/types/helper/websocket/index.d.ts", "import": "./dist/helper/websocket/index.js", "require": "./dist/cjs/helper/websocket/index.js" + }, + "./conninfo": { + "types": "./dist/types/helper/conninfo/index.d.ts", + "import": "./dist/helper/conninfo/index.js", + "require": "./dist/cjs/helper/conninfo/index.js" } }, "typesVersions": { @@ -419,6 +429,9 @@ "jwt": [ "./dist/types/middleware/jwt" ], + "timeout": [ + "./dist/types/middleware/timeout" + ], "timing": [ "./dist/types/middleware/timing" ], @@ -514,6 +527,9 @@ ], "ws": [ "./dist/types/helper/websocket" + ], + "conninfo": [ + "./dist/types/helper/conninfo" ] } }, @@ -545,7 +561,6 @@ "nodejs" ], "devDependencies": { - "@cloudflare/workers-types": "^4.20231121.0", "@hono/eslint-config": "^0.0.4", "@hono/node-server": "^1.8.2", "@types/crypto-js": "^4.1.1", diff --git a/src/adapter/bun/conninfo.test.ts b/src/adapter/bun/conninfo.test.ts new file mode 100644 index 000000000..7a4fdec6c --- /dev/null +++ b/src/adapter/bun/conninfo.test.ts @@ -0,0 +1,57 @@ +import { Context } from '../../context' +import { HonoRequest } from '../../request' +import { getConnInfo } from './conninfo' + +const createRandomBunServer = () => { + const address = Math.random().toString() + const port = Math.floor(Math.random() * (65535 + 1)) + return { + address, + port, + server: { + requestIP() { + return { + address, + family: 'IPv6', + port, + } + }, + }, + } +} +describe('getConnInfo', () => { + it('Should info is valid', () => { + const { port, server, address } = createRandomBunServer() + const c = new Context(new HonoRequest(new Request('http://localhost/')), { env: server }) + const info = getConnInfo(c) + + expect(info.remote.port).toBe(port) + expect(info.remote.address).toBe(address) + expect(info.remote.addressType).toBe('IPv6') + expect(info.remote.transport).toBeUndefined() + }) + it('Should getConnInfo works when env is { server: server }', () => { + const { port, server, address } = createRandomBunServer() + const c = new Context(new HonoRequest(new Request('http://localhost/')), { env: { server } }) + + const info = getConnInfo(c) + + expect(info.remote.port).toBe(port) + expect(info.remote.address).toBe(address) + expect(info.remote.addressType).toBe('IPv6') + expect(info.remote.transport).toBeUndefined() + }) + it('Should throw error when user did not give server', () => { + const c = new Context(new HonoRequest(new Request('http://localhost/')), { env: {} }) + + expect(() => getConnInfo(c)).toThrowError(TypeError) + }) + it('Should throw error when requestIP is not function', () => { + const c = new Context(new HonoRequest(new Request('http://localhost/')), { + env: { + requestIP: 0, + }, + }) + expect(() => getConnInfo(c)).toThrowError(TypeError) + }) +}) diff --git a/src/adapter/bun/conninfo.ts b/src/adapter/bun/conninfo.ts new file mode 100644 index 000000000..b6c031578 --- /dev/null +++ b/src/adapter/bun/conninfo.ts @@ -0,0 +1,35 @@ +import type { Context } from '../..' +import type { GetConnInfo } from '../../helper/conninfo' + +/** + * Get ConnInfo with Bun + * @param c Context + * @returns ConnInfo + */ +export const getConnInfo: GetConnInfo = (c: Context) => { + const server = ('server' in c.env ? c.env.server : c.env) as + | { + requestIP?: (req: Request) => { + address: string + family: string + port: number + } + } + | undefined + + if (!server) { + throw new TypeError('env has to include the 2nd argument of fetch.') + } + if (typeof server.requestIP !== 'function') { + throw new TypeError('server.requestIP is not a function.') + } + const info = server.requestIP(c.req.raw) + + return { + remote: { + address: info.address, + addressType: info.family === 'IPv6' || info.family === 'IPv4' ? info.family : 'unknown', + port: info.port, + }, + } +} diff --git a/src/adapter/bun/index.ts b/src/adapter/bun/index.ts index 815e82b03..1fc6a1946 100644 --- a/src/adapter/bun/index.ts +++ b/src/adapter/bun/index.ts @@ -1,3 +1,4 @@ export { serveStatic } from './serve-static' export { bunFileSystemModule, toSSG } from './ssg' export { createBunWebSocket } from './websocket' +export { getConnInfo } from './conninfo' diff --git a/src/adapter/cloudflare-workers/conninfo.test.ts b/src/adapter/cloudflare-workers/conninfo.test.ts new file mode 100644 index 000000000..3133e6532 --- /dev/null +++ b/src/adapter/cloudflare-workers/conninfo.test.ts @@ -0,0 +1,19 @@ +import { Context } from '../../context' +import { HonoRequest } from '../../request' +import { getConnInfo } from './conninfo' + +describe('getConnInfo', () => { + it('Should getConnInfo works', () => { + const address = Math.random().toString() + const req = new Request('http://localhost/', { + headers: { + 'cf-connecting-ip': address, + }, + }) + const c = new Context(new HonoRequest(req)) + + const info = getConnInfo(c) + + expect(info.remote.address).toBe(address) + }) +}) diff --git a/src/adapter/cloudflare-workers/conninfo.ts b/src/adapter/cloudflare-workers/conninfo.ts new file mode 100644 index 000000000..53d234a19 --- /dev/null +++ b/src/adapter/cloudflare-workers/conninfo.ts @@ -0,0 +1,8 @@ +import type { GetConnInfo } from '../../helper/conninfo' + +export const getConnInfo: GetConnInfo = (c) => ({ + remote: { + address: c.req.header('cf-connecting-ip'), + addressType: 'unknown', + }, +}) diff --git a/src/adapter/cloudflare-workers/serve-static.ts b/src/adapter/cloudflare-workers/serve-static.ts index 6250b04bb..b2f4954e7 100644 --- a/src/adapter/cloudflare-workers/serve-static.ts +++ b/src/adapter/cloudflare-workers/serve-static.ts @@ -1,11 +1,11 @@ -import type { KVNamespace } from '@cloudflare/workers-types' import { serveStatic as baseServeStatic } from '../../middleware/serve-static' import type { ServeStaticOptions as BaseServeStaticOptions } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' import { getContentFromKVAsset } from './utils' export type ServeStaticOptions = BaseServeStaticOptions & { - namespace?: KVNamespace + // namespace is KVNamespace + namespace?: unknown manifest: object | string } diff --git a/src/adapter/cloudflare-workers/utils.ts b/src/adapter/cloudflare-workers/utils.ts index d6624e665..af7cf92a6 100644 --- a/src/adapter/cloudflare-workers/utils.ts +++ b/src/adapter/cloudflare-workers/utils.ts @@ -1,10 +1,11 @@ -import type { KVNamespace } from '@cloudflare/workers-types' -declare const __STATIC_CONTENT: KVNamespace +// __STATIC_CONTENT is KVNamespace +declare const __STATIC_CONTENT: unknown declare const __STATIC_CONTENT_MANIFEST: string export type KVAssetOptions = { manifest?: object | string - namespace?: KVNamespace + // namespace is KVNamespace + namespace?: unknown } export const getContentFromKVAsset = async ( @@ -27,7 +28,8 @@ export const getContentFromKVAsset = async ( } } - let ASSET_NAMESPACE: KVNamespace + // ASSET_NAMESPACE is KVNamespace + let ASSET_NAMESPACE: unknown if (options && options.namespace) { ASSET_NAMESPACE = options.namespace } else { @@ -39,6 +41,7 @@ export const getContentFromKVAsset = async ( return null } + // @ts-expect-error ASSET_NAMESPACE is not typed const content = await ASSET_NAMESPACE.get(key, { type: 'stream' }) if (!content) { return null diff --git a/src/adapter/cloudflare-workers/websocket.ts b/src/adapter/cloudflare-workers/websocket.ts index 7896ead55..39a14dab7 100644 --- a/src/adapter/cloudflare-workers/websocket.ts +++ b/src/adapter/cloudflare-workers/websocket.ts @@ -1,15 +1,7 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -import type { WebSocketPair } from '@cloudflare/workers-types' import type { UpgradeWebSocket, WSContext, WSReadyState } from '../../helper/websocket' // Based on https://github.com/honojs/hono/issues/1153#issuecomment-1767321332 export const upgradeWebSocket: UpgradeWebSocket = (createEvents) => async (c, next) => { - const CFWebSocketPair = ( - globalThis as unknown as { - WebSocketPair: typeof WebSocketPair - } - ).WebSocketPair - const events = await createEvents(c) const upgradeHeader = c.req.header('Upgrade') @@ -17,9 +9,10 @@ export const upgradeWebSocket: UpgradeWebSocket = (createEvents) => async (c, ne return await next() } - const webSocketPair = new CFWebSocketPair() - const client = webSocketPair[0] - const server = webSocketPair[1] + // @ts-expect-error WebSocketPair is not typed + const webSocketPair = new WebSocketPair() + const client: WebSocket = webSocketPair[0] + const server: WebSocket = webSocketPair[1] const wsContext: WSContext = { binaryType: 'arraybuffer', @@ -35,33 +28,31 @@ export const upgradeWebSocket: UpgradeWebSocket = (createEvents) => async (c, ne send: (source) => server.send(source), } if (events.onOpen) { - server.addEventListener( - 'open', - (evt: unknown) => events.onOpen && events.onOpen(evt as Event, wsContext) - ) + server.addEventListener('open', (evt: Event) => events.onOpen && events.onOpen(evt, wsContext)) } if (events.onClose) { server.addEventListener( 'close', - (evt: unknown) => events.onClose && events.onClose(evt as CloseEvent, wsContext) + (evt: CloseEvent) => events.onClose && events.onClose(evt, wsContext) ) } if (events.onMessage) { server.addEventListener( 'message', - (evt: unknown) => events.onMessage && events.onMessage(evt as MessageEvent, wsContext) + (evt: MessageEvent) => events.onMessage && events.onMessage(evt, wsContext) ) } if (events.onError) { server.addEventListener( 'error', - (evt: unknown) => events.onError && events.onError(evt as ErrorEvent as Event, wsContext) + (evt: Event) => events.onError && events.onError(evt, wsContext) ) } + // @ts-expect-error server.accept is not typed server.accept() return new Response(null, { status: 101, - // @ts-ignore + // @ts-expect-error type not typed webSocket: client, }) } diff --git a/src/adapter/deno/conninfo.test.ts b/src/adapter/deno/conninfo.test.ts new file mode 100644 index 000000000..f129c5185 --- /dev/null +++ b/src/adapter/deno/conninfo.test.ts @@ -0,0 +1,25 @@ +import { Context } from '../../context' +import { HonoRequest } from '../../request' +import { getConnInfo } from './conninfo' + +describe('getConnInfo', () => { + it('Should info is valid', () => { + const transport = 'tcp' + const address = Math.random().toString() + const port = Math.floor(Math.random() * (65535 + 1)) + const c = new Context(new HonoRequest(new Request('http://localhost/')), { + env: { + remoteAddr: { + transport, + hostname: address, + port, + }, + }, + }) + const info = getConnInfo(c) + + expect(info.remote.port).toBe(port) + expect(info.remote.address).toBe(address) + expect(info.remote.transport).toBe(transport) + }) +}) diff --git a/src/adapter/deno/conninfo.ts b/src/adapter/deno/conninfo.ts new file mode 100644 index 000000000..3a39eaf2c --- /dev/null +++ b/src/adapter/deno/conninfo.ts @@ -0,0 +1,18 @@ +import type { GetConnInfo } from '../../helper/conninfo' + +/** + * Get conninfo with Deno + * @param c Context + * @returns ConnInfo + */ +export const getConnInfo: GetConnInfo = (c) => { + const { remoteAddr } = c.env + return { + remote: { + address: remoteAddr.hostname, + port: remoteAddr.port, + transport: remoteAddr.transport, + addressType: 'unknown', + }, + } +} diff --git a/src/adapter/deno/index.ts b/src/adapter/deno/index.ts index 6fca11645..4835b7f2f 100644 --- a/src/adapter/deno/index.ts +++ b/src/adapter/deno/index.ts @@ -1,3 +1,4 @@ export { serveStatic } from './serve-static' export { toSSG, denoFileSystemModule } from './ssg' export { upgradeWebSocket } from './websocket' +export { getConnInfo } from './conninfo' diff --git a/src/helper/conninfo/index.ts b/src/helper/conninfo/index.ts new file mode 100644 index 000000000..ce0ef9fed --- /dev/null +++ b/src/helper/conninfo/index.ts @@ -0,0 +1,45 @@ +import type { Context } from '../../context' + +export type AddressType = 'IPv6' | 'IPv4' | 'unknown' + +export type NetAddrInfo = { + /** + * Transport protocol type + */ + transport?: 'tcp' | 'udp' + /** + * Transport port number + */ + port?: number + + address?: string + addressType?: AddressType +} & ( + | { + /** + * Host name such as IP Addr + */ + address: string + + /** + * Host name type + */ + addressType: AddressType + } + | {} +) + +/** + * HTTP Connection infomation + */ +export interface ConnInfo { + /** + * Remote infomation + */ + remote: NetAddrInfo +} + +/** + * Helper type + */ +export type GetConnInfo = (c: Context) => ConnInfo diff --git a/src/helper/ssg/ssg.test.tsx b/src/helper/ssg/ssg.test.tsx index c7a4bf13c..501d2f9c2 100644 --- a/src/helper/ssg/ssg.test.tsx +++ b/src/helper/ssg/ssg.test.tsx @@ -16,6 +16,7 @@ import type { AfterResponseHook, AfterGenerateHook, FileSystemModule, + ToSSGResult, } from './ssg' const resolveRoutesContent = async (res: ReturnType) => { @@ -626,3 +627,165 @@ describe('disableSSG/onlySSG middlewares', () => { expect(res.status).toBe(404) }) }) + +describe('Request hooks - filterPathsBeforeRequestHook and denyPathsBeforeRequestHook', () => { + let app: Hono + let fsMock: FileSystemModule + + const filterPathsBeforeRequestHook = (allowedPaths: string | string[]): BeforeRequestHook => { + const baseURL = 'http://localhost' + return async (req: Request): Promise => { + const paths = Array.isArray(allowedPaths) ? allowedPaths : [allowedPaths] + const pathname = new URL(req.url, baseURL).pathname + + if (paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return req + } + + return false + } + } + + const denyPathsBeforeRequestHook = (deniedPaths: string | string[]): BeforeRequestHook => { + const baseURL = 'http://localhost' + return async (req: Request): Promise => { + const paths = Array.isArray(deniedPaths) ? deniedPaths : [deniedPaths] + const pathname = new URL(req.url, baseURL).pathname + + if (!paths.some((path) => pathname === path || pathname.startsWith(`${path}/`))) { + return req + } + return false + } + } + + beforeEach(() => { + app = new Hono() + app.get('/allowed-path', (c) => c.html('Allowed Path Page')) + app.get('/denied-path', (c) => c.html('Denied Path Page')) + app.get('/other-path', (c) => c.html('Other Path Page')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should only process requests for allowed paths with filterPathsBeforeRequestHook', async () => { + const allowedPathsHook = filterPathsBeforeRequestHook(['/allowed-path']) + + const result = await toSSG(app, fsMock, { + dir: './static', + beforeRequestHook: allowedPathsHook, + }) + + expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) + expect(result.files.some((file) => file.includes('other-path.html'))).toBe(false) + }) + + it('should deny requests for specified paths with denyPathsBeforeRequestHook', async () => { + const deniedPathsHook = denyPathsBeforeRequestHook(['/denied-path']) + + const result = await toSSG(app, fsMock, { dir: './static', beforeRequestHook: deniedPathsHook }) + + expect(result.files.some((file) => file.includes('denied-path.html'))).toBe(false) + + expect(result.files.some((file) => file.includes('allowed-path.html'))).toBe(true) + expect(result.files.some((file) => file.includes('other-path.html'))).toBe(true) + }) +}) + +describe('Combined Response hooks - modify response content', () => { + let app: Hono + let fsMock: FileSystemModule + + const prependContentAfterResponseHook = (prefix: string): AfterResponseHook => { + return async (res: Response): Promise => { + const originalText = await res.text() + return new Response(`${prefix}${originalText}`, { ...res }) + } + } + + const appendContentAfterResponseHook = (suffix: string): AfterResponseHook => { + return async (res: Response): Promise => { + const originalText = await res.text() + return new Response(`${originalText}${suffix}`, { ...res }) + } + } + + beforeEach(() => { + app = new Hono() + app.get('/content-path', (c) => c.text('Original Content')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should modify response content with combined AfterResponseHooks', async () => { + const prefixHook = prependContentAfterResponseHook('Prefix-') + const suffixHook = appendContentAfterResponseHook('-Suffix') + + const combinedHook = [prefixHook, suffixHook] + + await toSSG(app, fsMock, { + dir: './static', + afterResponseHook: combinedHook, + }) + + // Assert that the response content is modified by both hooks + // This assumes you have a way to inspect the content of saved files or you need to mock/stub the Response text method correctly. + expect(fsMock.writeFile).toHaveBeenCalledWith( + 'static/content-path.txt', + 'Prefix-Original Content-Suffix' + ) + }) +}) + +describe('Combined Generate hooks - AfterGenerateHook', () => { + let app: Hono + let fsMock: FileSystemModule + + const logResultAfterGenerateHook = (): AfterGenerateHook => { + return async (result: ToSSGResult): Promise => { + console.log('Generation completed with status:', result.success) // Log the generation success + } + } + + const appendFilesAfterGenerateHook = (additionalFiles: string[]): AfterGenerateHook => { + return async (result: ToSSGResult): Promise => { + result.files = result.files.concat(additionalFiles) // Append additional files to the result + } + } + + beforeEach(() => { + app = new Hono() + app.get('/path', (c) => c.text('Page Content')) + + fsMock = { + writeFile: vi.fn(() => Promise.resolve()), + mkdir: vi.fn(() => Promise.resolve()), + } + }) + + it('should execute combined AfterGenerateHooks affecting the result', async () => { + const logHook = logResultAfterGenerateHook() + const appendHook = appendFilesAfterGenerateHook(['/extra/file1.html', '/extra/file2.html']) + + const combinedHook = [logHook, appendHook] + + const consoleSpy = vi.spyOn(console, 'log') + const result = await toSSG(app, fsMock, { + dir: './static', + afterGenerateHook: combinedHook, + }) + + // Check that the log function was called correctly + expect(consoleSpy).toHaveBeenCalledWith('Generation completed with status:', true) + + // Check that additional files were appended to the result + expect(result.files).toContain('/extra/file1.html') + expect(result.files).toContain('/extra/file2.html') + }) +}) diff --git a/src/helper/ssg/ssg.ts b/src/helper/ssg/ssg.ts index 62a45dd0e..aa98d9c82 100644 --- a/src/helper/ssg/ssg.ts +++ b/src/helper/ssg/ssg.ts @@ -97,11 +97,66 @@ export type BeforeRequestHook = (req: Request) => Request | false | Promise Response | false | Promise export type AfterGenerateHook = (result: ToSSGResult) => void | Promise +export const combineBeforeRequestHooks = ( + hooks: BeforeRequestHook | BeforeRequestHook[] +): BeforeRequestHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (req: Request): Promise => { + let currentReq = req + for (const hook of hooks) { + const result = await hook(currentReq) + if (result === false) { + return false + } + if (result instanceof Request) { + currentReq = result + } + } + return currentReq + } +} + +export const combineAfterResponseHooks = ( + hooks: AfterResponseHook | AfterResponseHook[] +): AfterResponseHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (res: Response): Promise => { + let currentRes = res + for (const hook of hooks) { + const result = await hook(currentRes) + if (result === false) { + return false + } + if (result instanceof Response) { + currentRes = result + } + } + return currentRes + } +} + +export const combineAfterGenerateHooks = ( + hooks: AfterGenerateHook | AfterGenerateHook[] +): AfterGenerateHook => { + if (!Array.isArray(hooks)) { + return hooks + } + return async (result: ToSSGResult): Promise => { + for (const hook of hooks) { + await hook(result) + } + } +} + export interface ToSSGOptions { dir?: string - beforeRequestHook?: BeforeRequestHook - afterResponseHook?: AfterResponseHook - afterGenerateHook?: AfterGenerateHook + beforeRequestHook?: BeforeRequestHook | BeforeRequestHook[] + afterResponseHook?: AfterResponseHook | AfterResponseHook[] + afterGenerateHook?: AfterGenerateHook | AfterGenerateHook[] concurrency?: number extensionMap?: Record } @@ -286,10 +341,16 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const outputDir = options?.dir ?? './static' const concurrency = options?.concurrency ?? DEFAULT_CONCURRENCY + const combinedBeforeRequestHook = combineBeforeRequestHooks( + options?.beforeRequestHook || ((req) => req) + ) + const combinedAfterResponseHook = combineAfterResponseHooks( + options?.afterResponseHook || ((req) => req) + ) const getInfoGen = fetchRoutesContent( app, - options?.beforeRequestHook, - options?.afterResponseHook, + combinedBeforeRequestHook, + combinedAfterResponseHook, concurrency ) for (const getInfo of getInfoGen) { @@ -319,6 +380,9 @@ export const toSSG: ToSSGInterface = async (app, fs, options) => { const errorObj = error instanceof Error ? error : new Error(String(error)) result = { success: false, files: [], error: errorObj } } - await options?.afterGenerateHook?.(result) + if (options?.afterGenerateHook) { + const conbinedAfterGenerateHooks = combineAfterGenerateHooks(options?.afterGenerateHook) + await conbinedAfterGenerateHooks(result) + } return result } diff --git a/src/hono.test.ts b/src/hono.test.ts index b62cd8b86..ca7ff7513 100644 --- a/src/hono.test.ts +++ b/src/hono.test.ts @@ -732,6 +732,89 @@ describe('Routing', () => { expect(res.status).toBe(404) }) }) + + describe('Encoded path', () => { + let app: Hono + beforeEach(() => { + app = new Hono() + }) + + it('should decode path parameter', async () => { + app.get('/users/:id', (c) => c.text(`id is ${c.req.param('id')}`)) + + const res = await app.request('http://localhost/users/%C3%A7awa%20y%C3%AE%3F') + expect(res.status).toBe(200) + expect(await res.text()).toBe('id is çawa yî?') + }) + + it('should decode "/"', async () => { + app.get('/users/:id', (c) => c.text(`id is ${c.req.param('id')}`)) + + const res = await app.request('http://localhost/users/hono%2Fposts') // %2F is '/' + expect(res.status).toBe(200) + expect(await res.text()).toBe('id is hono/posts') + }) + + it('should decode alphabets', async () => { + app.get('/users/static', (c) => c.text('static')) + + const res = await app.request('http://localhost/users/%73tatic') // %73 is 's' + expect(res.status).toBe(200) + expect(await res.text()).toBe('static') + }) + + it('should decode alphabets with invalid UTF-8 sequence', async () => { + app.get('/static/:path', (c) => { + try { + return c.text(`by c.req.param: ${c.req.param('path')}`) // this should throw an error + } catch (e) { + return c.text(`by c.req.url: ${c.req.url.replace(/.*\//, '')}`) + } + }) + + const res = await app.request('http://localhost/%73tatic/%A4%A2') // %73 is 's', %A4%A2 is invalid UTF-8 sequence + expect(res.status).toBe(200) + expect(await res.text()).toBe('by c.req.url: %A4%A2') + }) + + it('should decode alphabets with invalid percent encoding', async () => { + app.get('/static/:path', (c) => { + try { + return c.text(`by c.req.param: ${c.req.param('path')}`) // this should throw an error + } catch (e) { + return c.text(`by c.req.url: ${c.req.url.replace(/.*\//, '')}`) + } + }) + + const res = await app.request('http://localhost/%73tatic/%a') // %73 is 's', %a is invalid percent encoding + expect(res.status).toBe(200) + expect(await res.text()).toBe('by c.req.url: %a') + }) + + it('should be able to catch URIError', async () => { + app.onError((err, c) => { + if (err instanceof URIError) { + return c.text(err.message, 400) + } + throw err + }) + app.get('/static/:path', (c) => { + return c.text(`by c.req.param: ${c.req.param('path')}`) // this should throw an error + }) + + const res = await app.request('http://localhost/%73tatic/%a') // %73 is 's', %a is invalid percent encoding + expect(res.status).toBe(400) + expect(await res.text()).toBe('URI malformed') + }) + + it('should not double decode', async () => { + app.get('/users/:id', (c) => c.text(`posts of ${c.req.param('id')}`)) + + const res = await app.request('http://localhost/users/%2525') // %25 is '%' + expect(res.status).toBe(200) + expect(await res.text()).toBe('posts of %25') + }) + }) }) describe('param and query', () => { diff --git a/src/http-exception.test.ts b/src/http-exception.test.ts index f77ecaf38..75becdba9 100644 --- a/src/http-exception.test.ts +++ b/src/http-exception.test.ts @@ -31,4 +31,15 @@ describe('HTTPException', () => { expect(exception.message).toBe('Internal Server Error') expect(exception.cause).toBe(error) }) + + it('Should prioritize the status code over the code in the response', async () => { + const exception = new HTTPException(400, { + res: new Response('An exception', { + status: 200, + }), + }) + const res = exception.getResponse() + expect(res.status).toBe(400) + expect(await res.text()).toBe('An exception') + }) }) diff --git a/src/http-exception.ts b/src/http-exception.ts index b5761a643..6589ab5c5 100644 --- a/src/http-exception.ts +++ b/src/http-exception.ts @@ -36,7 +36,11 @@ export class HTTPException extends Error { getResponse(): Response { if (this.res) { - return this.res + const newResponse = new Response(this.res.body, { + status: this.status, + headers: this.res.headers, + }) + return newResponse } return new Response(this.message, { status: this.status, diff --git a/src/jsx/dom/css.test.tsx b/src/jsx/dom/css.test.tsx index 0aae152c0..c254ca486 100644 --- a/src/jsx/dom/css.test.tsx +++ b/src/jsx/dom/css.test.tsx @@ -22,6 +22,7 @@ describe('Style and css for jsx/dom', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) diff --git a/src/jsx/dom/index.test.tsx b/src/jsx/dom/index.test.tsx index a4a6d6076..979dc35fa 100644 --- a/src/jsx/dom/index.test.tsx +++ b/src/jsx/dom/index.test.tsx @@ -9,6 +9,7 @@ import { useState, useEffect, useLayoutEffect, + useInsertionEffect, useCallback, useRef, useMemo, @@ -89,6 +90,7 @@ describe('DOM', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) @@ -414,6 +416,42 @@ describe('DOM', () => { }) }) + describe('children', () => { + it('element', async () => { + const Container = ({ children }: { children: Child }) =>
{children}
+ const App = () => ( + + Content + + ) + render(, root) + expect(root.innerHTML).toBe('
Content
') + }) + + it('array', async () => { + const Container = ({ children }: { children: Child }) =>
{children}
+ const App = () => {[1, 2]} + render(, root) + expect(root.innerHTML).toBe('
12
') + }) + + it('use the same children multiple times', async () => { + const MultiChildren = ({ children }: { children: Child }) => ( + <> + {children} +
{children}
+ + ) + const App = () => ( + + Content + + ) + render(, root) + expect(root.innerHTML).toBe('Content
Content
') + }) + }) + describe('update properties', () => { describe('input', () => { it('value', async () => { @@ -1060,6 +1098,45 @@ describe('DOM', () => { ) }) + it('swap deferent type of child component', async () => { + const Even = () =>

Even

+ const Odd = () =>
Odd
+ const Counter = () => { + const [count, setCount] = useState(0) + return ( +
+ {count % 2 === 0 ? ( + <> + + + + ) : ( + <> + + + + )} + +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('

Even

Odd
') + const button = root.querySelector('button') as HTMLButtonElement + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + button.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
Odd

Even

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

Even

Odd
') + + expect(createElementSpy).not.toHaveBeenCalled() + }) + it('setState for unnamed function', async () => { const Input = ({ label, onInput }: { label: string; onInput: (value: string) => void }) => { return ( @@ -1433,6 +1510,120 @@ describe('DOM', () => { }) }) + describe('useInsertionEffect', () => { + it('simple', async () => { + const Counter = () => { + const [count, setCount] = useState(0) + useInsertionEffect(() => { + setCount(count + 1) + }, []) + return
{count}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
1
') + }) + + it('multiple', async () => { + const Counter = () => { + const [count, setCount] = useState(0) + useInsertionEffect(() => { + setCount((c) => c + 1) + }, []) + useInsertionEffect(() => { + setCount((c) => c + 1) + }, []) + return
{count}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
2
') + }) + + it('with useLayoutEffect', async () => { + const Counter = () => { + const [data, setData] = useState([]) + useLayoutEffect(() => { + setData((d) => [...d, 'useLayoutEffect']) + }, []) + useInsertionEffect(() => { + setData((d) => [...d, 'useInsertionEffect']) + }, []) + return
{data.join(',')}
+ } + const app = + render(app, root) + await Promise.resolve() + expect(root.innerHTML).toBe('
useInsertionEffect,useLayoutEffect
') + }) + + it('cleanup', async () => { + const Child = ({ parent }: { parent: RefObject }) => { + useInsertionEffect(() => { + return () => { + parent.current?.setAttribute('data-cleanup', 'true') + } + }, []) + return
Child
+ } + const Parent = () => { + const [show, setShow] = useState(true) + const ref = useRef(null) + return ( +
+ {show && } + +
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('
Child
') + const [button] = root.querySelectorAll('button') + button.click() + await Promise.resolve() + expect(root.innerHTML).toBe('
') + }) + + it('cleanup for deps', async () => { + let effectCount = 0 + let cleanupCount = 0 + + const App = () => { + const [count, setCount] = useState(0) + const [count2, setCount2] = useState(0) + useInsertionEffect(() => { + effectCount++ + return () => { + cleanupCount++ + } + }, [count]) + return ( +
+

{count}

+

{count2}

+ + +
+ ) + } + const app = + render(app, root) + expect(effectCount).toBe(1) + expect(cleanupCount).toBe(0) + root.querySelectorAll('button')[0].click() // count++ + await Promise.resolve() + expect(effectCount).toBe(2) + expect(cleanupCount).toBe(1) + root.querySelectorAll('button')[1].click() // count2++ + await Promise.resolve() + expect(effectCount).toBe(2) + expect(cleanupCount).toBe(1) + }) + }) + describe('useCallback', () => { it('deferent callbacks', async () => { const callbackSet = new Set() @@ -1669,6 +1860,43 @@ describe('DOM', () => { await Promise.resolve() expect(document.body.innerHTML).toBe('
') }) + + it('update', async () => { + const App = () => { + const [count, setCount] = useState(0) + return ( +
+ {createPortal(

{count}

, document.body)} + +
+

{count}

+
+
+ ) + } + const app = + render(app, root) + expect(root.innerHTML).toBe('

0

') + expect(document.body.innerHTML).toBe( + '

0

0

' + ) + + const createElementSpy = vi.spyOn(dom.window.document, 'createElement') + + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(root.innerHTML).toBe('

1

') + expect(document.body.innerHTML).toBe( + '

1

1

' + ) + document.body.querySelector('button')?.click() + await Promise.resolve() + expect(document.body.innerHTML).toBe( + '

2

2

' + ) + + expect(createElementSpy).not.toHaveBeenCalled() + }) }) describe('SVG', () => { @@ -1704,6 +1932,163 @@ describe('DOM', () => { expect(document.querySelector('title')).toBeInstanceOf(dom.window.HTMLTitleElement) expect(document.querySelector('svg title')).toBeInstanceOf(dom.window.SVGTitleElement) }) + + describe('attribute', () => { + describe('camelCase', () => { + test.each` + key + ${'attributeName'} + ${'baseFrequency'} + ${'calcMode'} + ${'clipPathUnits'} + ${'diffuseConstant'} + ${'edgeMode'} + ${'filterUnits'} + ${'gradientTransform'} + ${'gradientUnits'} + ${'kernelMatrix'} + ${'kernelUnitLength'} + ${'keyPoints'} + ${'keySplines'} + ${'keyTimes'} + ${'lengthAdjust'} + ${'limitingConeAngle'} + ${'markerHeight'} + ${'markerUnits'} + ${'markerWidth'} + ${'maskContentUnits'} + ${'maskUnits'} + ${'numOctaves'} + ${'pathLength'} + ${'patternContentUnits'} + ${'patternTransform'} + ${'patternUnits'} + ${'pointsAtX'} + ${'pointsAtY'} + ${'pointsAtZ'} + ${'preserveAlpha'} + ${'preserveAspectRatio'} + ${'primitiveUnits'} + ${'refX'} + ${'refY'} + ${'repeatCount'} + ${'repeatDur'} + ${'specularConstant'} + ${'specularExponent'} + ${'spreadMethod'} + ${'startOffset'} + ${'stdDeviation'} + ${'stitchTiles'} + ${'surfaceScale'} + ${'crossorigin'} + ${'systemLanguage'} + ${'tableValues'} + ${'targetX'} + ${'targetY'} + ${'textLength'} + ${'viewBox'} + ${'xChannelSelector'} + ${'yChannelSelector'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe(``) + }) + }) + + describe('kebab-case', () => { + test.each` + key + ${'alignmentBaseline'} + ${'baselineShift'} + ${'clipPath'} + ${'clipRule'} + ${'colorInterpolation'} + ${'colorInterpolationFilters'} + ${'dominantBaseline'} + ${'fillOpacity'} + ${'fillRule'} + ${'floodColor'} + ${'floodOpacity'} + ${'fontFamily'} + ${'fontSize'} + ${'fontSizeAdjust'} + ${'fontStretch'} + ${'fontStyle'} + ${'fontVariant'} + ${'fontWeight'} + ${'imageRendering'} + ${'letterSpacing'} + ${'lightingColor'} + ${'markerEnd'} + ${'markerMid'} + ${'markerStart'} + ${'overlinePosition'} + ${'overlineThickness'} + ${'paintOrder'} + ${'pointerEvents'} + ${'shapeRendering'} + ${'stopColor'} + ${'stopOpacity'} + ${'strikethroughPosition'} + ${'strikethroughThickness'} + ${'strokeDasharray'} + ${'strokeDashoffset'} + ${'strokeLinecap'} + ${'strokeLinejoin'} + ${'strokeMiterlimit'} + ${'strokeOpacity'} + ${'strokeWidth'} + ${'textAnchor'} + ${'textDecoration'} + ${'textRendering'} + ${'transformOrigin'} + ${'underlinePosition'} + ${'underlineThickness'} + ${'unicodeBidi'} + ${'vectorEffect'} + ${'wordSpacing'} + ${'writingMode'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe( + `` + ) + }) + }) + + describe('data-*', () => { + test.each` + key + ${'data-foo'} + ${'data-foo-bar'} + ${'data-fooBar'} + `('$key', ({ key }) => { + const App = () => { + return ( + + + + ) + } + render(, root) + expect(root.innerHTML).toBe(``) + }) + }) + }) }) describe('MathML', () => { diff --git a/src/jsx/dom/index.ts b/src/jsx/dom/index.ts index 4184ac622..8c4d287cb 100644 --- a/src/jsx/dom/index.ts +++ b/src/jsx/dom/index.ts @@ -16,6 +16,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -80,6 +81,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -115,6 +117,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, diff --git a/src/jsx/dom/jsx-dev-runtime.ts b/src/jsx/dom/jsx-dev-runtime.ts index a4c439b16..d7a6ff29e 100644 --- a/src/jsx/dom/jsx-dev-runtime.ts +++ b/src/jsx/dom/jsx-dev-runtime.ts @@ -1,31 +1,16 @@ import type { Props, JSXNode } from '../base' import { normalizeIntrinsicElementProps } from '../utils' - -const JSXNodeCompatPrototype = { - type: { - get(this: { tag: string | Function }): string | Function { - return this.tag - }, - }, - ref: { - get(this: { props?: { ref: unknown } }): unknown { - return this.props?.ref - }, - }, -} +import { newJSXNode } from './utils' export const jsxDEV = (tag: string | Function, props: Props, key?: string): JSXNode => { if (typeof tag === 'string') { normalizeIntrinsicElementProps(props) } - return Object.defineProperties( - { - tag, - props, - key, - }, - JSXNodeCompatPrototype - ) as JSXNode + return newJSXNode({ + tag, + props, + key, + }) } export const Fragment = (props: Record): JSXNode => jsxDEV('', props, undefined) diff --git a/src/jsx/dom/render.ts b/src/jsx/dom/render.ts index 41191139a..556867a83 100644 --- a/src/jsx/dom/render.ts +++ b/src/jsx/dom/render.ts @@ -8,6 +8,7 @@ import type { EffectData } from '../hooks' import { STASH_EFFECT } from '../hooks' import { styleObjectForEach } from '../utils' import { createContext } from './context' // import dom-specific versions +import { newJSXNode } from './utils' const HONO_PORTAL_ELEMENT = '_hp' @@ -106,6 +107,14 @@ const getEventSpec = (key: string): [string, boolean] | undefined => { return undefined } +const toAttributeName = (element: SupportedElement, key: string): string => + element instanceof SVGElement && + /[A-Z]/.test(key) && + (key in element.style || // Presentation attributes are findable in style object. "clip-path", "font-size", "stroke-width", etc. + key.match(/^(?:o|pai|str|u|ve)/)) // Other un-deprecated kebab-case attributes. "overline-position", "paint-order", "strikethrough-position", etc. + ? key.replace(/([A-Z])/g, '-$1').toLowerCase() + : key + const applyProps = (container: SupportedElement, attributes: Props, oldAttributes?: Props) => { attributes ||= {} for (const [key, value] of Object.entries(attributes)) { @@ -164,14 +173,16 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute ;(container as any)[key] = value } + const k = toAttributeName(container, key) + if (value === null || value === undefined || value === false) { - container.removeAttribute(key) + container.removeAttribute(k) } else if (value === true) { - container.setAttribute(key, '') + container.setAttribute(k, '') } else if (typeof value === 'string' || typeof value === 'number') { - container.setAttribute(key, value as string) + container.setAttribute(k, value as string) } else { - container.setAttribute(key, value.toString()) + container.setAttribute(k, value.toString()) } } } @@ -189,7 +200,7 @@ const applyProps = (container: SupportedElement, attributes: Props, oldAttribute value.current = null } } else { - container.removeAttribute(key) + container.removeAttribute(toAttributeName(container, key)) } } } @@ -248,6 +259,8 @@ const getNextChildren = ( const findInsertBefore = (node: Node | undefined): SupportedElement | Text | null => { if (!node) { return null + } else if (node.tag === HONO_PORTAL_ELEMENT) { + return findInsertBefore(node.nN) } else if (node.e) { return node.e } @@ -324,10 +337,8 @@ const applyNodeObject = (node: NodeObject, container: Container) => { const childNodes = container.childNodes let offset = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - findChildNodeIndex(childNodes, findInsertBefore(node.nN) as any) ?? - // eslint-disable-next-line @typescript-eslint/no-explicit-any - findChildNodeIndex(childNodes, next.find((n) => n.e)?.e as any) ?? + findChildNodeIndex(childNodes, findInsertBefore(node.nN)) ?? + findChildNodeIndex(childNodes, next.find((n) => n.tag !== HONO_PORTAL_ELEMENT && n.e)?.e) ?? childNodes.length for (let i = 0, len = next.length; i < len; i++, offset++) { @@ -347,18 +358,17 @@ const applyNodeObject = (node: NodeObject, container: Container) => { applyProps(el as HTMLElement, child.props, child.pP) applyNode(child, el as HTMLElement) } - if ( - childNodes[offset] !== el && - childNodes[offset - 1] !== child.e && - child.tag !== HONO_PORTAL_ELEMENT - ) { + if (child.tag === HONO_PORTAL_ELEMENT) { + offset-- + } else if (childNodes[offset] !== el && childNodes[offset - 1] !== child.e) { container.insertBefore(el, childNodes[offset] || null) } } remove.forEach(removeNode) - callbacks.forEach(([, cb]) => cb?.()) + callbacks.forEach(([, , , , cb]) => cb?.()) // invoke useInsertionEffect callbacks + callbacks.forEach(([, cb]) => cb?.()) // invoke useLayoutEffect callbacks requestAnimationFrame(() => { - callbacks.forEach(([, , , cb]) => cb?.()) + callbacks.forEach(([, , , cb]) => cb?.()) // invoke useEffect callbacks }) } @@ -382,7 +392,7 @@ export const build = ( } const oldVChildren: Node[] = node.vC ? [...node.vC] : [] const vChildren: Node[] = [] - const vChildrenToRemove: Node[] = [] + node.vR = [] let prevNode: Node | undefined try { children.flat().forEach((c: Child) => { @@ -403,7 +413,13 @@ export const build = ( } let oldChild: Node | undefined - const i = oldVChildren.findIndex((c) => c.key === (child as Node).key) + const i = oldVChildren.findIndex( + isNodeString(child) + ? (c) => isNodeString(c) + : child.key !== undefined + ? (c) => c.key === (child as Node).key + : (c) => c.tag === (child as Node).tag + ) if (i !== -1) { oldChild = oldVChildren[i] oldVChildren.splice(i, 1) @@ -411,17 +427,13 @@ export const build = ( if (oldChild) { if (isNodeString(child)) { - if (!isNodeString(oldChild)) { - vChildrenToRemove.push(oldChild) - } else { - if (oldChild.t !== child.t) { - oldChild.t = child.t // update text content - oldChild.d = true - } - child = oldChild + if ((oldChild as NodeString).t !== child.t) { + ;(oldChild as NodeString).t = child.t // update text content + ;(oldChild as NodeString).d = true } + child = oldChild } else if (oldChild.tag !== child.tag) { - vChildrenToRemove.push(oldChild) + node.vR.push(oldChild) } else { oldChild.pP = oldChild.props oldChild.props = child.props @@ -444,8 +456,7 @@ export const build = ( } }) node.vC = vChildren - vChildrenToRemove.push(...oldVChildren) - node.vR = vChildrenToRemove + node.vR.push(...oldVChildren) } catch (e) { if (errorHandler) { const fallbackUpdateFn = () => @@ -483,10 +494,14 @@ const buildNode = (node: Child): Node | undefined => { } else if (typeof node === 'string' || typeof node === 'number') { return { t: node.toString(), d: true } as NodeString } else { + if ('vR' in node) { + node = newJSXNode({ + tag: (node as NodeObject).tag, + props: (node as NodeObject).props, + key: (node as NodeObject).key, + }) + } if (typeof (node as JSXNode).tag === 'function') { - if ((node as NodeObject)[DOM_STASH]) { - node = { ...node } as NodeObject - } ;(node as NodeObject)[DOM_STASH] = [0, []] } else { const ns = nameSpaceMap[(node as JSXNode).tag as string] diff --git a/src/jsx/dom/utils.ts b/src/jsx/dom/utils.ts index fef0c3c05..77c6b5676 100644 --- a/src/jsx/dom/utils.ts +++ b/src/jsx/dom/utils.ts @@ -1,3 +1,4 @@ +import type { Props, JSXNode } from '../base' import { DOM_INTERNAL_TAG } from '../constants' export const setInternalTagFlag = (fn: Function): Function => { @@ -5,3 +6,19 @@ export const setInternalTagFlag = (fn: Function): Function => { ;(fn as any)[DOM_INTERNAL_TAG] = true return fn } + +const JSXNodeCompatPrototype = { + type: { + get(this: { tag: string | Function }): string | Function { + return this.tag + }, + }, + ref: { + get(this: { props?: { ref: unknown } }): unknown { + return this.props?.ref + }, + }, +} + +export const newJSXNode = (obj: { tag: string | Function; props?: Props; key?: string }): JSXNode => + Object.defineProperties(obj, JSXNodeCompatPrototype) as JSXNode diff --git a/src/jsx/hooks/dom.test.tsx b/src/jsx/hooks/dom.test.tsx index 0ca97b350..93e278313 100644 --- a/src/jsx/hooks/dom.test.tsx +++ b/src/jsx/hooks/dom.test.tsx @@ -34,6 +34,7 @@ describe('Hooks', () => { }) global.document = dom.window.document global.HTMLElement = dom.window.HTMLElement + global.SVGElement = dom.window.SVGElement global.Text = dom.window.Text root = document.getElementById('root') as HTMLElement }) diff --git a/src/jsx/hooks/index.ts b/src/jsx/hooks/index.ts index 874af436d..aea3512d2 100644 --- a/src/jsx/hooks/index.ts +++ b/src/jsx/hooks/index.ts @@ -16,7 +16,8 @@ export type EffectData = [ readonly unknown[] | undefined, // deps (() => void | (() => void)) | undefined, // layout effect (() => void) | undefined, // cleanup - (() => void) | undefined // effect + (() => void) | undefined, // effect + (() => void) | undefined // insertion effect ] const resolvedPromiseValueMap: WeakMap, unknown> = new WeakMap< @@ -252,7 +253,7 @@ const useEffectCommon = ( data[index] = undefined // clear this effect in order to avoid calling effect twice data[2] = effect() as (() => void) | undefined } - const data: EffectData = [deps, undefined, undefined, undefined] + const data: EffectData = [deps, undefined, undefined, undefined, undefined] data[index] = runner effectDepsArray[hookIndex] = data } @@ -263,6 +264,10 @@ export const useLayoutEffect = ( effect: () => void | (() => void), deps?: readonly unknown[] ): void => useEffectCommon(1, effect, deps) +export const useInsertionEffect = ( + effect: () => void | (() => void), + deps?: readonly unknown[] +): void => useEffectCommon(4, effect, deps) export const useCallback = unknown>( callback: T, diff --git a/src/jsx/index.test.tsx b/src/jsx/index.test.tsx index 004802e4b..d871695f0 100644 --- a/src/jsx/index.test.tsx +++ b/src/jsx/index.test.tsx @@ -757,6 +757,7 @@ describe('default export', () => { 'useViewTransition', 'useMemo', 'useLayoutEffect', + 'useInsertionEffect', 'Suspense', ].forEach((key) => { it(key, () => { diff --git a/src/jsx/index.ts b/src/jsx/index.ts index 7ed8c6e52..682740129 100644 --- a/src/jsx/index.ts +++ b/src/jsx/index.ts @@ -16,6 +16,7 @@ import { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, useReducer, useId, useDebugValue, @@ -51,6 +52,7 @@ export { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle, @@ -84,6 +86,7 @@ export default { useViewTransition, useMemo, useLayoutEffect, + useInsertionEffect, createRef, forwardRef, useImperativeHandle, diff --git a/src/middleware/basic-auth/index.ts b/src/middleware/basic-auth/index.ts index a830219c1..6f7eb2b0e 100644 --- a/src/middleware/basic-auth/index.ts +++ b/src/middleware/basic-auth/index.ts @@ -40,6 +40,37 @@ type BasicAuthOptions = hashFunction?: Function } +/** + * Basic authentication middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/basic-auth} + * + * @param {BasicAuthOptions} options - The options for the basic authentication middleware. + * @param {string} options.username - The username for authentication. + * @param {string} options.password - The password for authentication. + * @param {string} [options.realm="Secure Area"] - The realm attribute for the WWW-Authenticate header. + * @param {Function} [options.hashFunction] - The hash function used for secure comparison. + * @param {Function} [options.verifyUser] - The function to verify user credentials. + * @returns {MiddlewareHandler} The middleware handler function. + * @throws {HTTPException} If neither "username and password" nor "verifyUser" options are provided. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use( + * '/auth/*', + * basicAuth({ + * username: 'hono', + * password: 'acoolproject', + * }) + * ) + * + * app.get('/auth/page', (c) => { + * return c.text('You are authorized') + * }) + * ``` + */ export const basicAuth = ( options: BasicAuthOptions, ...users: { username: string; password: string }[] diff --git a/src/middleware/bearer-auth/index.ts b/src/middleware/bearer-auth/index.ts index 17f5ff341..84f8b43c5 100644 --- a/src/middleware/bearer-auth/index.ts +++ b/src/middleware/bearer-auth/index.ts @@ -23,6 +23,35 @@ type BearerAuthOptions = hashFunction?: Function } +/** + * Bearer authentication middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/bearer-auth} + * + * @param {BearerAuthOptions} options - The options for the bearer authentication middleware. + * @param {string | string[]} [options.token] - The string or array of strings to validate the incoming bearer token against. + * @param {Function} [options.verifyToken] - The function to verify the token. + * @param {string} [options.realm=""] - The domain name of the realm, as part of the returned WWW-Authenticate challenge header. + * @param {string} [options.prefix="Bearer"] - The prefix (or known as `schema`) for the Authorization header value. + * @param {string} [options.headerName=Authorization] - The header name. + * @param {Function} [options.hashFunction] - A function to handle hashing for safe comparison of authentication tokens. + * @returns {MiddlewareHandler} The middleware handler function. + * @throws {Error} If neither "token" nor "verifyToken" options are provided. + * @throws {HTTPException} If authentication fails, with 401 status code for missing or invalid token, or 400 status code for invalid request. + * + * @example + * ```ts + * const app = new Hono() + * + * const token = 'honoiscool' + * + * app.use('/api/*', bearerAuth({ token })) + * + * app.get('/api/page', (c) => { + * return c.json({ message: 'You are authorized' }) + * }) + * ``` + */ export const bearerAuth = (options: BearerAuthOptions): MiddlewareHandler => { if (!('token' in options || 'verifyToken' in options)) { throw new Error('bearer auth middleware requires options for "token"') diff --git a/src/middleware/body-limit/index.ts b/src/middleware/body-limit/index.ts index 67dd8c8ef..1c399c0fd 100644 --- a/src/middleware/body-limit/index.ts +++ b/src/middleware/body-limit/index.ts @@ -18,21 +18,34 @@ class BodyLimitError extends Error { } /** - * Body Limit Middleware + * Body limit middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/body-limit} + * + * @param {BodyLimitOptions} options - The options for the body limit middleware. + * @param {number} options.maxSize - The maximum body size allowed. + * @param {OnError} [options.onError] - The error handler to be invoked if the specified body size is exceeded. + * @returns {MiddlewareHandler} The middleware handler function. * * @example * ```ts + * const app = new Hono() + * * app.post( - * '/hello', - * bodyLimit({ - * maxSize: 100 * 1024, // 100kb - * onError: (c) => { - * return c.text('overflow :(', 413) - * } - * }), - * (c) => { - * return c.text('pass :)') - * } + * '/upload', + * bodyLimit({ + * maxSize: 50 * 1024, // 50kb + * onError: (c) => { + * return c.text('overflow :(', 413) + * }, + * }), + * async (c) => { + * const body = await c.req.parseBody() + * if (body['file'] instanceof File) { + * console.log(`Got file sized: ${body['file'].size}`) + * } + * return c.text('pass :)') + * } * ) * ``` */ diff --git a/src/middleware/cache/index.ts b/src/middleware/cache/index.ts index 64779d920..9e6fc718e 100644 --- a/src/middleware/cache/index.ts +++ b/src/middleware/cache/index.ts @@ -1,6 +1,31 @@ import type { Context } from '../../context' import type { MiddlewareHandler } from '../../types' +/** + * cache middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/cache} + * + * @param {Object} options - The options for the cache middleware. + * @param {string | Function} options.cacheName - The name of the cache. Can be used to store multiple caches with different identifiers. + * @param {boolean} [options.wait=false] - A boolean indicating if Hono should wait for the Promise of the `cache.put` function to resolve before continuing with the request. Required to be true for the Deno environment. + * @param {string} [options.cacheControl] - A string of directives for the `Cache-Control` header. + * @param {string | string[]} [options.vary] - Sets the `Vary` header in the response. If the original response header already contains a `Vary` header, the values are merged, removing any duplicates. + * @param {Function} [options.keyGenerator] - Generates keys for every request in the `cacheName` store. This can be used to cache data based on request parameters or context parameters. + * @returns {MiddlewareHandler} The middleware handler function. + * @throws {Error} If the `vary` option includes "*". + * + * @example + * ```ts + * app.get( + * '*', + * cache({ + * cacheName: 'my-app', + * cacheControl: 'max-age=3600', + * }) + * ) + * ``` + */ export const cache = (options: { cacheName: string | ((c: Context) => Promise | string) wait?: boolean diff --git a/src/middleware/compress/index.ts b/src/middleware/compress/index.ts index 05700d9a3..2bf7f01f7 100644 --- a/src/middleware/compress/index.ts +++ b/src/middleware/compress/index.ts @@ -6,6 +6,22 @@ interface CompressionOptions { encoding?: (typeof ENCODING_TYPES)[number] } +/** + * Compress middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/compress} + * + * @param {CompressionOptions} [options] - The options for the compress middleware. + * @param {'gzip' | 'deflate'} [options.encoding] - The compression scheme to allow for response compression. Either 'gzip' or 'deflate'. If not defined, both are allowed and will be used based on the Accept-Encoding header. 'gzip' is prioritized if this option is not provided and the client provides both in the Accept-Encoding header. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(compress()) + * ``` + */ export const compress = (options?: CompressionOptions): MiddlewareHandler => { return async function compress(ctx, next) { await next() diff --git a/src/middleware/cors/index.ts b/src/middleware/cors/index.ts index 415f69d03..dc9e779ad 100644 --- a/src/middleware/cors/index.ts +++ b/src/middleware/cors/index.ts @@ -10,6 +10,45 @@ type CORSOptions = { exposeHeaders?: string[] } +/** + * CORS middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/cors} + * + * @param {CORSOptions} [options] - The options for the CORS middleware. + * @param {string | string[] | ((origin: string, c: Context) => string | undefined | null)} [options.origin='*'] - The value of "Access-Control-Allow-Origin" CORS header. + * @param {string[]} [options.allowMethods=['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']] - The value of "Access-Control-Allow-Methods" CORS header. + * @param {string[]} [options.allowHeaders=[]] - The value of "Access-Control-Allow-Headers" CORS header. + * @param {number} [options.maxAge] - The value of "Access-Control-Max-Age" CORS header. + * @param {boolean} [options.credentials] - The value of "Access-Control-Allow-Credentials" CORS header. + * @param {string[]} [options.exposeHeaders=[]] - The value of "Access-Control-Expose-Headers" CORS header. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use('/api/*', cors()) + * app.use( + * '/api2/*', + * cors({ + * origin: 'http://example.com', + * allowHeaders: ['X-Custom-Header', 'Upgrade-Insecure-Requests'], + * allowMethods: ['POST', 'GET', 'OPTIONS'], + * exposeHeaders: ['Content-Length', 'X-Kuma-Revision'], + * maxAge: 600, + * credentials: true, + * }) + * ) + * + * app.all('/api/abc', (c) => { + * return c.json({ success: true }) + * }) + * app.all('/api2/abc', (c) => { + * return c.json({ success: true }) + * }) + * ``` + */ export const cors = (options?: CORSOptions): MiddlewareHandler => { const defaults: CORSOptions = { origin: '*', diff --git a/src/middleware/csrf/index.ts b/src/middleware/csrf/index.ts index a09184824..67efd1949 100644 --- a/src/middleware/csrf/index.ts +++ b/src/middleware/csrf/index.ts @@ -11,6 +11,43 @@ const isSafeMethodRe = /^(GET|HEAD)$/ const isRequestedByFormElementRe = /^\b(application\/x-www-form-urlencoded|multipart\/form-data|text\/plain)\b/ +/** + * CSRF protection middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/csrf} + * + * @param {CSRFOptions} [options] - The options for the CSRF protection middleware. + * @param {string|string[]|(origin: string, context: Context) => boolean} [options.origin] - Specify origins. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(csrf()) + * + * // Specifying origins with using `origin` option + * // string + * app.use(csrf({ origin: 'myapp.example.com' })) + * + * // string[] + * app.use( + * csrf({ + * origin: ['myapp.example.com', 'development.myapp.example.com'], + * }) + * ) + * + * // Function + * // It is strongly recommended that the protocol be verified to ensure a match to `$`. + * // You should *never* do a forward match. + * app.use( + * '*', + * csrf({ + * origin: (origin) => /https:\/\/(\w+\.)?myapp\.example\.com$/.test(origin), + * }) + * ) + * ``` + */ export const csrf = (options?: CSRFOptions): MiddlewareHandler => { const handler: IsAllowedOriginHandler = ((optsOrigin) => { if (!optsOrigin) { diff --git a/src/middleware/etag/index.test.ts b/src/middleware/etag/index.test.ts index 18d3660c3..d70ce2abc 100644 --- a/src/middleware/etag/index.test.ts +++ b/src/middleware/etag/index.test.ts @@ -1,11 +1,9 @@ import { Hono } from '../../hono' -import { etag } from '.' +import { etag, RETAINED_304_HEADERS } from '.' describe('Etag Middleware', () => { - let app: Hono - - beforeEach(() => { - app = new Hono() + it('Should return etag header', async () => { + const app = new Hono() app.use('/etag/*', etag()) app.get('/etag/abc', (c) => { return c.text('Hono is cool') @@ -13,9 +11,6 @@ describe('Etag Middleware', () => { app.get('/etag/def', (c) => { return c.json({ message: 'Hono is cool' }) }) - }) - - it('Should return etag header', async () => { let res = await app.request('http://localhost/etag/abc') expect(res.headers.get('ETag')).not.toBeFalsy() expect(res.headers.get('ETag')).toBe('"4e32298b1cb4edc595237405e5b696e105c2399a"') @@ -26,18 +21,21 @@ describe('Etag Middleware', () => { }) it('Should return etag header - binary', async () => { - app.use('/etag-binary/*', etag()) - app.get('/etag-binary', async (c) => { + const app = new Hono() + app.use('/etag/*', etag()) + app.get('/etag', async (c) => { return c.body(new Uint8Array(1)) }) - const res = await app.request('http://localhost/etag-binary') + const res = await app.request('http://localhost/etag') expect(res.headers.get('ETag')).not.toBeFalsy() const etagHeader = res.headers.get('ETag') expect(etagHeader).toBe('"5ba93c9db0cff93f52b521d7420e43f6eda2784f"') }) it('Should not be the same etag - arrayBuffer', async () => { + const app = new Hono() + app.use('/etag/*', etag()) app.get('/etag/ab1', (c) => { return c.body(new ArrayBuffer(1)) }) @@ -52,6 +50,8 @@ describe('Etag Middleware', () => { }) it('Should not be the same etag - Uint8Array', async () => { + const app = new Hono() + app.use('/etag/*', etag()) app.get('/etag/ui1', (c) => { return c.body(new Uint8Array([1, 2, 3])) }) @@ -66,17 +66,20 @@ describe('Etag Middleware', () => { }) it('Should return etag header - weak', async () => { - app.use('/etag-weak/*', etag({ weak: true })) - app.get('/etag-weak/abc', (c) => { + const app = new Hono() + app.use('/etag/*', etag({ weak: true })) + app.get('/etag/abc', (c) => { return c.text('Hono is cool') }) - const res = await app.request('http://localhost/etag-weak/abc') + const res = await app.request('http://localhost/etag/abc') expect(res.headers.get('ETag')).not.toBeFalsy() expect(res.headers.get('ETag')).toBe('W/"4e32298b1cb4edc595237405e5b696e105c2399a"') }) it('Should handle conditional GETs', async () => { + const app = new Hono() + app.use('/etag/*', etag()) app.get('/etag/ghi', (c) => c.text('Hono is great', 200, { 'cache-control': 'public, max-age=120', @@ -91,7 +94,7 @@ describe('Etag Middleware', () => { let res = await app.request('http://localhost/etag/ghi') expect(res.status).toBe(200) expect(res.headers.get('ETag')).not.toBeFalsy() - const etag = res.headers.get('ETag') || '' + const etagHeaderValue = res.headers.get('ETag') || '' // conditional GET with the wrong ETag: res = await app.request('http://localhost/etag/ghi', { @@ -104,11 +107,11 @@ describe('Etag Middleware', () => { // conditional GET with matching ETag: res = await app.request('http://localhost/etag/ghi', { headers: { - 'If-None-Match': etag, + 'If-None-Match': etagHeaderValue, }, }) expect(res.status).toBe(304) - expect(res.headers.get('Etag')).toBe(etag) + expect(res.headers.get('Etag')).toBe(etagHeaderValue) expect(await res.text()).toBe('') expect(res.headers.get('cache-control')).toBe('public, max-age=120') expect(res.headers.get('date')).toBe('Mon, Feb 27 2023 12:08:36 GMT') @@ -119,23 +122,26 @@ describe('Etag Middleware', () => { // conditional GET with matching ETag among list: res = await app.request('http://localhost/etag/ghi', { headers: { - 'If-None-Match': `"mismatch 1", ${etag}, "mismatch 2"`, + 'If-None-Match': `"mismatch 1", ${etagHeaderValue}, "mismatch 2"`, }, }) expect(res.status).toBe(304) }) it('Should not return duplicate etag header values', async () => { - app.use('/etag2/*', etag()) - app.use('/etag2/*', etag()) - app.get('/etag2/abc', (c) => c.text('Hono is cool')) + const app = new Hono() + app.use('/etag/*', etag()) + app.use('/etag/*', etag()) + app.get('/etag/abc', (c) => c.text('Hono is cool')) - const res = await app.request('http://localhost/etag2/abc') + const res = await app.request('http://localhost/etag/abc') expect(res.headers.get('ETag')).not.toBeFalsy() expect(res.headers.get('ETag')).toBe('"4e32298b1cb4edc595237405e5b696e105c2399a"') }) it('Should not override ETag headers from upstream', async () => { + const app = new Hono() + app.use('/etag/*', etag()) app.get('/etag/predefined', (c) => c.text('This response has an ETag', 200, { ETag: '"f-0194-d"' }) ) @@ -143,4 +149,34 @@ describe('Etag Middleware', () => { const res = await app.request('http://localhost/etag/predefined') expect(res.headers.get('ETag')).toBe('"f-0194-d"') }) + + it('Should retain the default and the specified headers', async () => { + const cacheControl = 'public, max-age=120' + const message = 'Hello!' + const app = new Hono() + app.use( + '/etag/*', + etag({ + retainedHeaders: ['x-message-retain', ...RETAINED_304_HEADERS], + }) + ) + app.get('/etag', (c) => { + return c.text('Hono is cool', 200, { + 'cache-control': cacheControl, + 'x-message-retain': message, + 'x-message': message, + }) + }) + const res = await app.request('/etag', { + headers: { + 'If-None-Match': '"4e32298b1cb4edc595237405e5b696e105c2399a"', + }, + }) + expect(res.status).toBe(304) + expect(res.headers.get('ETag')).not.toBeFalsy() + expect(res.headers.get('ETag')).toBe('"4e32298b1cb4edc595237405e5b696e105c2399a"') + expect(res.headers.get('Cache-Control')).toBe(cacheControl) + expect(res.headers.get('x-message-retain')).toBe(message) + expect(res.headers.get('x-message')).toBeFalsy() + }) }) diff --git a/src/middleware/etag/index.ts b/src/middleware/etag/index.ts index ba3d86c6f..e89adba35 100644 --- a/src/middleware/etag/index.ts +++ b/src/middleware/etag/index.ts @@ -12,7 +12,7 @@ type ETagOptions = { * > would have been sent in an equivalent 200 OK response: Cache-Control, * > Content-Location, Date, ETag, Expires, and Vary. */ -const RETAINED_304_HEADERS = [ +export const RETAINED_304_HEADERS = [ 'cache-control', 'content-location', 'date', @@ -25,6 +25,26 @@ function etagMatches(etag: string, ifNoneMatch: string | null) { return ifNoneMatch != null && ifNoneMatch.split(/,\s*/).indexOf(etag) > -1 } +/** + * ETag middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/etag} + * + * @param {ETagOptions} [options] - The options for the ETag middleware. + * @param {boolean} [options.weak=false] - Define using or not using a weak validation. If true is set, then `W/` is added to the prefix of the value. + * @param {string[]} [options.retainedHeaders=RETAINED_304_HEADERS] - The headers that you want to retain in the 304 Response. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use('/etag/*', etag()) + * app.get('/etag/abc', (c) => { + * return c.text('Hono is cool') + * }) + * ``` + */ export const etag = (options?: ETagOptions): MiddlewareHandler => { const retainedHeaders = options?.retainedHeaders ?? RETAINED_304_HEADERS const weak = options?.weak ?? false diff --git a/src/middleware/jsx-renderer/index.ts b/src/middleware/jsx-renderer/index.ts index e42b9868a..34eb0223c 100644 --- a/src/middleware/jsx-renderer/index.ts +++ b/src/middleware/jsx-renderer/index.ts @@ -67,6 +67,40 @@ const createRenderer = } } +/** + * JSX renderer middleware for hono. + * + * @see {@link{https://hono.dev/middleware/builtin/jsx-renderer}} + * + * @param {ComponentWithChildren} [component] - The component to render, which can accept children and props. + * @param {RendererOptions} [options] - The options for the JSX renderer middleware. + * @param {boolean | string} [options.docType=true] - The DOCTYPE to be added at the beginning of the HTML. If set to false, no DOCTYPE will be added. + * @param {boolean | Record} [options.stream=false] - If set to true, enables streaming response with default headers. If a record is provided, custom headers will be used. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.get( + * '/page/*', + * jsxRenderer(({ children }) => { + * return ( + * + * + *
Menu
+ *
{children}
+ * + * + * ) + * }) + * ) + * + * app.get('/page/about', (c) => { + * return c.render(

About me!

) + * }) + * ``` + */ export const jsxRenderer = ( component?: ComponentWithChildren, options?: RendererOptions @@ -82,6 +116,30 @@ export const jsxRenderer = ( return next() } +/** + * useRequestContext for Hono. + * + * @template E - The environment type. + * @template P - The parameter type. + * @template I - The input type. + * @returns {Context} An instance of Context. + * + * @example + * ```ts + * const RequestUrlBadge: FC = () => { + * const c = useRequestContext() + * return {c.req.url} + * } + * + * app.get('/page/info', (c) => { + * return c.render( + *
+ * You are accessing: + *
+ * ) + * }) + * ``` + */ export const useRequestContext = < E extends Env = any, P extends string = any, diff --git a/src/middleware/jwt/jwt.ts b/src/middleware/jwt/jwt.ts index 26418c9b1..b303c6d34 100644 --- a/src/middleware/jwt/jwt.ts +++ b/src/middleware/jwt/jwt.ts @@ -11,12 +11,39 @@ export type JwtVariables = { jwtPayload: any } +/** + * JWT Auth middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/jwt} + * + * @param {object} options - The options for the JWT middleware. + * @param {string} [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=HS256] - An algorithm type that is used for verifying. Available types are `HS256` | `HS384` | `HS512` | `RS256` | `RS384` | `RS512` | `PS256` | `PS384` | `PS512` | `ES256` | `ES384` | `ES512` | `EdDSA`. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use( + * '/auth/*', + * jwt({ + * secret: 'it-is-very-secret', + * }) + * ) + * + * app.get('/auth/page', (c) => { + * return c.text('You are authorized') + * }) + * ``` + */ export const jwt = (options: { secret: string cookie?: string alg?: SignatureAlgorithm }): MiddlewareHandler => { - if (!options) { + if (!options || !options.secret) { throw new Error('JWT auth middleware requires options for "secret') } diff --git a/src/middleware/logger/index.ts b/src/middleware/logger/index.ts index 28e0c9b19..20dd9c9bd 100644 --- a/src/middleware/logger/index.ts +++ b/src/middleware/logger/index.ts @@ -55,6 +55,22 @@ function log( fn(out) } +/** + * Logger middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/logger} + * + * @param {PrintFunc} [fn=console.log] - Optional function for customized logging behavior. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(logger()) + * app.get('/', (c) => c.text('Hello Hono!')) + * ``` + */ export const logger = (fn: PrintFunc = console.log): MiddlewareHandler => { return async function logger(c, next) { const { method } = c.req diff --git a/src/middleware/method-override/index.ts b/src/middleware/method-override/index.ts index 028fd985b..eca086504 100644 --- a/src/middleware/method-override/index.ts +++ b/src/middleware/method-override/index.ts @@ -28,21 +28,29 @@ type MethodOverrideOptions = { const DEFAULT_METHOD_FORM_NAME = '_method' /** - * Method Override Middleware + * Method Override Middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/method-override} + * + * @param {MethodOverrideOptions} options - The options for the method override middleware. + * @param {Hono} options.app - The instance of Hono is used in your application. + * @param {string} [options.form=_method] - Form key with a value containing the method name. + * @param {string} [options.header] - Header name with a value containing the method name. + * @param {string} [options.query] - Query parameter key with a value containing the method name. + * @returns {MiddlewareHandler} The middleware handler function. * * @example - * // with form input method + * ```ts * const app = new Hono() - * app.use('/books/*', methodOverride({ app })) // the default `form` value is `_method` - * app.use('/authors/*', methodOverride({ app, form: 'method' })) * - * @example - * // with custom header - * app.use('/books/*', methodOverride({ app, header: 'X-HTTP-METHOD-OVERRIDE' })) + * // If no options are specified, the value of `_method` in the form, + * // e.g. DELETE, is used as the method. + * app.use('/posts', methodOverride({ app })) * - * @example - * // with query parameter - * app.use('/books/*', methodOverride({ app, query: '_method' })) + * app.delete('/posts', (c) => { + * // .... + * }) + * ``` */ export const methodOverride = (options: MethodOverrideOptions): MiddlewareHandler => async function methodOverride(c, next) { diff --git a/src/middleware/pretty-json/index.ts b/src/middleware/pretty-json/index.ts index dbf824298..17a54d8f7 100644 --- a/src/middleware/pretty-json/index.ts +++ b/src/middleware/pretty-json/index.ts @@ -4,6 +4,25 @@ type prettyOptions = { space: number } +/** + * Pretty JSON middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/pretty-json} + * + * @param {prettyOptions} [options] - The options for the pretty JSON middleware. + * @param {number} [options.space=2] - Number of spaces for indentation. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(prettyJSON()) // With options: prettyJSON({ space: 4 }) + * app.get('/', (c) => { + * return c.json({ message: 'Hono!' }) + * }) + * ``` + */ export const prettyJSON = (options: prettyOptions = { space: 2 }): MiddlewareHandler => { return async function prettyJSON(c, next) { const pretty = c.req.query('pretty') || c.req.query('pretty') === '' ? true : false diff --git a/src/middleware/secure-headers/secure-headers.ts b/src/middleware/secure-headers/secure-headers.ts index a274fd296..a701efaac 100644 --- a/src/middleware/secure-headers/secure-headers.ts +++ b/src/middleware/secure-headers/secure-headers.ts @@ -113,6 +113,7 @@ const generateNonce = () => { crypto.getRandomValues(buffer) return encodeBase64(buffer) } + export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => { const nonce = ctx.get('secureHeadersNonce') || @@ -124,6 +125,35 @@ export const NONCE: ContentSecurityPolicyOptionHandler = (ctx) => { return `'nonce-${nonce}'` } +/** + * Secure Headers Middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/secure-headers} + * + * @param {Partial} [customOptions] - The options for the secure headers middleware. + * @param {ContentSecurityPolicyOptions} [customOptions.contentSecurityPolicy] - Settings for the Content-Security-Policy header. + * @param {overridableHeader} [customOptions.crossOriginEmbedderPolicy=false] - Settings for the Cross-Origin-Embedder-Policy header. + * @param {overridableHeader} [customOptions.crossOriginResourcePolicy=true] - Settings for the Cross-Origin-Resource-Policy header. + * @param {overridableHeader} [customOptions.crossOriginOpenerPolicy=true] - Settings for the Cross-Origin-Opener-Policy header. + * @param {overridableHeader} [customOptions.originAgentCluster=true] - Settings for the Origin-Agent-Cluster header. + * @param {overridableHeader} [customOptions.referrerPolicy=true] - Settings for the Referrer-Policy header. + * @param {ReportingEndpointOptions[]} [customOptions.reportingEndpoints] - Settings for the Reporting-Endpoints header. + * @param {ReportToOptions[]} [customOptions.reportTo] - Settings for the Report-To header. + * @param {overridableHeader} [customOptions.strictTransportSecurity=true] - Settings for the Strict-Transport-Security header. + * @param {overridableHeader} [customOptions.xContentTypeOptions=true] - Settings for the X-Content-Type-Options header. + * @param {overridableHeader} [customOptions.xDnsPrefetchControl=true] - Settings for the X-DNS-Prefetch-Control header. + * @param {overridableHeader} [customOptions.xDownloadOptions=true] - Settings for the X-Download-Options header. + * @param {overridableHeader} [customOptions.xFrameOptions=true] - Settings for the X-Frame-Options header. + * @param {overridableHeader} [customOptions.xPermittedCrossDomainPolicies=true] - Settings for the X-Permitted-Cross-Domain-Policies header. + * @param {overridableHeader} [customOptions.xXssProtection=true] - Settings for the X-XSS-Protection header. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * app.use(secureHeaders()) + * ``` + */ export const secureHeaders = (customOptions?: SecureHeadersOptions): MiddlewareHandler => { const options = { ...DEFAULT_OPTIONS, ...customOptions } const headersToSet = getFilteredHeaders(options) diff --git a/src/middleware/timeout/index.test.ts b/src/middleware/timeout/index.test.ts new file mode 100644 index 000000000..6d24a5257 --- /dev/null +++ b/src/middleware/timeout/index.test.ts @@ -0,0 +1,70 @@ +import type { Context } from '../../context' +import { Hono } from '../../hono' +import { HTTPException } from '../../http-exception' +import type { HTTPExceptionFunction } from '.' +import { timeout } from '.' + +describe('Timeout API', () => { + const app = new Hono() + + app.use('/slow-endpoint', timeout(1000)) + app.use( + '/slow-endpoint/custom', + timeout( + 1100, + () => new HTTPException(408, { message: 'Request timeout. Please try again later.' }) + ) + ) + const exception500: HTTPExceptionFunction = (context: Context) => + new HTTPException(500, { message: `Internal Server Error at ${context.req.path}` }) + app.use('/slow-endpoint/error', timeout(1200, exception500)) + app.use('/normal-endpoint', timeout(1000)) + + app.get('/slow-endpoint', async (c) => { + await new Promise((resolve) => setTimeout(resolve, 1100)) + return c.text('This should not show up') + }) + + app.get('/slow-endpoint/custom', async (c) => { + await new Promise((resolve) => setTimeout(resolve, 1200)) + return c.text('This should not show up') + }) + + app.get('/slow-endpoint/error', async (c) => { + await new Promise((resolve) => setTimeout(resolve, 1300)) + return c.text('This should not show up') + }) + + app.get('/normal-endpoint', async (c) => { + await new Promise((resolve) => setTimeout(resolve, 900)) + return c.text('This should not show up') + }) + + it('Should trigger default timeout exception', async () => { + const res = await app.request('http://localhost/slow-endpoint') + expect(res).not.toBeNull() + expect(res.status).toBe(504) + expect(await res.text()).toContain('Gateway Timeout') + }) + + it('Should apply custom exception with function', async () => { + const res = await app.request('http://localhost/slow-endpoint/custom') + expect(res).not.toBeNull() + expect(res.status).toBe(408) + expect(await res.text()).toContain('Request timeout. Please try again later.') + }) + + it('Error timeout with custom status code and message', async () => { + const res = await app.request('http://localhost/slow-endpoint/error') + expect(res).not.toBeNull() + expect(res.status).toBe(500) + expect(await res.text()).toContain('Internal Server Error at /slow-endpoint/error') + }) + + it('No Timeout should pass', async () => { + const res = await app.request('http://localhost/normal-endpoint') + expect(res).not.toBeNull() + expect(res.status).toBe(200) + expect(await res.text()).toContain('This should not show up') + }) +}) diff --git a/src/middleware/timeout/index.ts b/src/middleware/timeout/index.ts new file mode 100644 index 000000000..e7102e18b --- /dev/null +++ b/src/middleware/timeout/index.ts @@ -0,0 +1,53 @@ +import type { Context } from '../../context' +import { HTTPException } from '../../http-exception' +import type { MiddlewareHandler } from '../../types' + +export type HTTPExceptionFunction = (context: Context) => HTTPException + +const defaultTimeoutException = new HTTPException(504, { + message: 'Gateway Timeout', +}) + +/** + * Timeout middleware for Hono. + * + * @param {number} duration - The timeout duration in milliseconds. + * @param {HTTPExceptionFunction | HTTPException} [exception=defaultTimeoutException] - The exception to throw when the timeout occurs. Can be a function that returns an HTTPException or an HTTPException object. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use( + * '/long-request', + * timeout(5000) // Set timeout to 5 seconds + * ) + * + * app.get('/long-request', async (c) => { + * await someLongRunningFunction() + * return c.text('Completed within time limit') + * }) + * ``` + */ +export const timeout = ( + duration: number, + exception: HTTPExceptionFunction | HTTPException = defaultTimeoutException +): MiddlewareHandler => { + return async function timeout(context, next) { + let timer: number | undefined + const timeoutPromise = new Promise((_, reject) => { + timer = setTimeout(() => { + reject(typeof exception === 'function' ? exception(context) : exception) + }, duration) as unknown as number + }) + + try { + await Promise.race([next(), timeoutPromise]) + } finally { + if (timer !== undefined) { + clearTimeout(timer) + } + } + } +} diff --git a/src/middleware/timing/timing.ts b/src/middleware/timing/timing.ts index c07925cd7..6449bd102 100644 --- a/src/middleware/timing/timing.ts +++ b/src/middleware/timing/timing.ts @@ -29,6 +29,45 @@ const getTime = (): number => { return Date.now() } +/** + * Server-Timing Middleware middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/timing} + * + * @param {TimingOptions} [config] - The options for the timing middleware. + * @param {boolean} [config.total=true] - Show the total response time. + * @param {boolean | ((c: Context) => boolean)} [config.enabled=true] - Whether timings should be added to the headers or not. + * @param {string} [config.totalDescription=Total Response Time] - Description for the total response time. + * @param {boolean} [config.autoEnd=true] - If `startTime()` should end automatically at the end of the request. + * @param {boolean | string | ((c: Context) => boolean | string)} [config.crossOrigin=false] - The origin this timings header should be readable. + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * // add the middleware to your router + * app.use(timing()); + * + * app.get('/', async (c) => { + * // add custom metrics + * setMetric(c, 'region', 'europe-west3') + * + * // add custom metrics with timing, must be in milliseconds + * setMetric(c, 'custom', 23.8, 'My custom Metric') + * + * // start a new timer + * startTime(c, 'db'); + * + * const data = await db.findMany(...); + * + * // end the timer + * endTime(c, 'db'); + * + * return c.json({ response: data }); + * }); + * ``` + */ export const timing = (config?: TimingOptions): MiddlewareHandler => { const options: TimingOptions = { ...{ @@ -82,6 +121,21 @@ interface SetMetric { (c: Context, name: string, description?: string): void } +/** + * Set a metric for the timing middleware. + * + * @param {Context} c - The context of the request. + * @param {string} name - The name of the metric. + * @param {number | string} [valueDescription] - The value or description of the metric. + * @param {string} [description] - The description of the metric. + * @param {number} [precision] - The precision of the metric value. + * + * @example + * ```ts + * setMetric(c, 'region', 'europe-west3') + * setMetric(c, 'custom', 23.8, 'My custom Metric') + * ``` + */ export const setMetric: SetMetric = ( c: Context, name: string, @@ -108,6 +162,18 @@ export const setMetric: SetMetric = ( } } +/** + * Start a timer for the timing middleware. + * + * @param {Context} c - The context of the request. + * @param {string} name - The name of the timer. + * @param {string} [description] - The description of the timer. + * + * @example + * ```ts + * startTime(c, 'db') + * ``` + */ export const startTime = (c: Context, name: string, description?: string) => { const metrics = c.get('metric') if (!metrics) { @@ -117,6 +183,18 @@ export const startTime = (c: Context, name: string, description?: string) => { metrics.timers.set(name, { description, start: getTime() }) } +/** + * End a timer for the timing middleware. + * + * @param {Context} c - The context of the request. + * @param {string} name - The name of the timer. + * @param {number} [precision] - The precision of the timer value. + * + * @example + * ```ts + * endTime(c, 'db') + * ``` + */ export const endTime = (c: Context, name: string, precision?: number) => { const metrics = c.get('metric') if (!metrics) { diff --git a/src/middleware/trailing-slash/index.ts b/src/middleware/trailing-slash/index.ts index ead3975b3..13962dc1f 100644 --- a/src/middleware/trailing-slash/index.ts +++ b/src/middleware/trailing-slash/index.ts @@ -1,9 +1,19 @@ import type { MiddlewareHandler } from '../../types' /** - * Trim the trailing slash from the URL if it does have one. For example, `/path/to/page/` will be redirected to `/path/to/page`. - * @access public - * @example app.use(trimTrailingSlash()) + * Trailing slash middleware for Hono. + * + * @see {@link https://hono.dev/middleware/builtin/trailing-slash} + * + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(trimTrailingSlash()) + * app.get('/about/me/', (c) => c.text('With Trailing Slash')) + * ``` */ export const trimTrailingSlash = (): MiddlewareHandler => { return async function trimTrailingSlash(c, next) { @@ -24,9 +34,19 @@ export const trimTrailingSlash = (): MiddlewareHandler => { } /** + * 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/`. - * @access public - * @example app.use(appendTrailingSlash()) + * + * @see {@link https://hono.dev/middleware/builtin/trailing-slash} + * + * @returns {MiddlewareHandler} The middleware handler function. + * + * @example + * ```ts + * const app = new Hono() + * + * app.use(appendTrailingSlash()) + * ``` */ export const appendTrailingSlash = (): MiddlewareHandler => { return async function appendTrailingSlash(c, next) { diff --git a/src/request.test.ts b/src/request.test.ts index 9163f2a38..d73d38aae 100644 --- a/src/request.test.ts +++ b/src/request.test.ts @@ -1,6 +1,10 @@ import { HonoRequest } from './request' import type { RouterRoute } from './types' +type RecursiveRecord = { + [key in K]: T | RecursiveRecord +} + describe('Query', () => { test('req.query() and req.queries()', () => { const rawRequest = new Request('http://localhost?page=2&tag=A&tag=B') @@ -251,4 +255,67 @@ describe('Body methods with caching', () => { expect(async () => await req.arrayBuffer()).not.toThrow() expect(async () => await req.blob()).not.toThrow() }) + + describe('req.parseBody()', async () => { + it('should parse form data', async () => { + const data = new FormData() + data.append('foo', 'bar') + const req = new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: data, + }) + ) + expect((await req.parseBody())['foo']).toBe('bar') + expect((await req.parseBody())['foo']).toBe('bar') + expect(async () => await req.text()).not.toThrow() + expect(async () => await req.arrayBuffer()).not.toThrow() + expect(async () => await req.blob()).not.toThrow() + }) + + describe('Return type', () => { + let req: HonoRequest + beforeEach(() => { + const data = new FormData() + data.append('foo', 'bar') + req = new HonoRequest( + new Request('http://localhost', { + method: 'POST', + body: data, + }) + ) + }) + + it('without options', async () => { + expectTypeOf((await req.parseBody())['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf((await req.parseBody({ all: true }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf((await req.parseBody({ dot: true }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf((await req.parseBody({ all: true, dot: true }))['key']).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('specify return type explicitly', async () => { + expectTypeOf( + await req.parseBody<{ key1: string; key2: string }>({ all: true, dot: true }) + ).toEqualTypeOf<{ key1: string; key2: string }>() + }) + }) + }) }) diff --git a/src/request.ts b/src/request.ts index 7c028aee5..291e7e233 100644 --- a/src/request.ts +++ b/src/request.ts @@ -183,11 +183,15 @@ export class HonoRequest

{ * ``` * @see https://hono.dev/api/request#parsebody */ - async parseBody(options?: ParseBodyOptions): Promise { + async parseBody, T extends BodyData>( + options?: Options + ): Promise + async parseBody(options?: Partial): Promise + async parseBody(options?: Partial) { if (this.bodyCache.parsedBody) { - return this.bodyCache.parsedBody as T + return this.bodyCache.parsedBody } - const parsedBody = await parseBody(this, options) + const parsedBody = await parseBody(this, options) this.bodyCache.parsedBody = parsedBody return parsedBody } diff --git a/src/utils/body.test.ts b/src/utils/body.test.ts index 80443466b..6e049802d 100644 --- a/src/utils/body.test.ts +++ b/src/utils/body.test.ts @@ -1,4 +1,8 @@ -import { parseBody } from './body' +import { parseBody, type BodyData } from './body' + +type RecursiveRecord = { + [key in K]: T | RecursiveRecord +} describe('Parse Body Util', () => { const FORM_URL = 'https://localhost/form' @@ -50,6 +54,46 @@ describe('Parse Body Util', () => { }) }) + it('should not update file object properties', async () => { + const file = new File(['foo'], 'file1', { + type: 'application/octet-stream', + }) + const data = new FormData() + + const req = createRequest(FORM_URL, 'POST', data) + vi.spyOn(req, 'formData').mockImplementation( + async () => + ({ + forEach: (cb) => { + cb(file, 'file', data) + cb('hoo', 'file.hoo', data) + }, + } as FormData) + ) + + const parsedData = await parseBody(req, { dot: true }) + expect(parsedData.file).not.instanceOf(File) + expect(parsedData).toEqual({ + file: { + hoo: 'hoo', + }, + }) + }) + + it('should override value if `all` option is false', async () => { + const data = new FormData() + data.append('file', 'aaa') + data.append('file', 'bbb') + data.append('message', 'hello') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req)).toEqual({ + file: 'bbb', + message: 'hello', + }) + }) + it('should parse multiple values if `all` option is true', async () => { const data = new FormData() data.append('file', 'aaa') @@ -64,6 +108,101 @@ describe('Parse Body Util', () => { }) }) + it('should not parse nested values in default', async () => { + const data = new FormData() + data.append('obj.key1', 'value1') + data.append('obj.key2', 'value2') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: false })).toEqual({ + 'obj.key1': 'value1', + 'obj.key2': 'value2', + }) + }) + + it('should not parse nested values in default for non-nested keys', async () => { + const data = new FormData() + data.append('key1', 'value1') + data.append('key2', 'value2') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: false })).toEqual({ + key1: 'value1', + key2: 'value2', + }) + }) + + it('should handle nested values and non-nested values together with dot option true', async () => { + const data = new FormData() + data.append('obj.key1', 'value1') + data.append('obj.key2', 'value2') + data.append('key3', 'value3') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: true })).toEqual({ + obj: { key1: 'value1', key2: 'value2' }, + key3: 'value3', + }) + }) + + it('should handle deeply nested objects with dot option true', async () => { + const data = new FormData() + data.append('a.b.c.d', 'value') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: true })).toEqual({ + a: { b: { c: { d: 'value' } } }, + }) + }) + + it('should parse nested values if `dot` option is true', async () => { + const data = new FormData() + data.append('obj.key1', 'value1') + data.append('obj.key2', 'value2') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: true })).toEqual({ + obj: { key1: 'value1', key2: 'value2' }, + }) + }) + + it('should parse data if both `all` and `dot` are set', async () => { + const data = new FormData() + data.append('obj.sub.foo', 'value1') + data.append('obj.sub.foo', 'value2') + data.append('key', 'value3') + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { dot: true, all: true })).toEqual({ + obj: { sub: { foo: ['value1', 'value2'] } }, + key: 'value3', + }) + }) + + it('should parse nested values if values are `File`', async () => { + const file1 = new File(['foo'], 'file1', { + type: 'application/octet-stream', + }) + const file2 = new File(['bar'], 'file2', { + type: 'application/octet-stream', + }) + const data = new FormData() + data.append('file.file1', file1) + data.append('file.file2', file2) + + const req = createRequest(FORM_URL, 'POST', data) + + expect(await parseBody(req, { all: true, dot: true })).toEqual({ + file: { file1, file2 }, + }) + }) + it('should parse multiple values if values are `File`', async () => { const file1 = new File(['foo'], 'file1', { type: 'application/octet-stream', @@ -105,4 +244,110 @@ describe('Parse Body Util', () => { expect(await parseBody(req)).toEqual({}) }) + + describe('Return type', () => { + let req: Request + beforeEach(() => { + req = createRequest(FORM_URL, 'POST', new FormData()) + }) + + it('without options', async () => { + expectTypeOf((await parseBody(req))['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf((await parseBody(req, { all: true }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{all: boolean}', async () => { + expectTypeOf((await parseBody(req, { all: !!Math.random() }))['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf((await parseBody(req, { dot: true }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{dot: boolean}', async () => { + expectTypeOf((await parseBody(req, { dot: !!Math.random() }))['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf((await parseBody(req, { all: true, dot: true }))['key']).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('{all: boolean, dot: boolean}', async () => { + expectTypeOf( + (await parseBody(req, { all: !!Math.random(), dot: !!Math.random() }))['key'] + ).toEqualTypeOf< + | string + | File + | (string | File)[] + | RecursiveRecord + >() + }) + + it('specify return type explicitly', async () => { + expectTypeOf( + await parseBody<{ key1: string; key2: string }>(req, { + all: !!Math.random(), + dot: !!Math.random(), + }) + ).toEqualTypeOf<{ key1: string; key2: string }>() + }) + }) +}) + +describe('BodyData', () => { + it('without options', async () => { + expectTypeOf(({} as BodyData)['key']).toEqualTypeOf() + }) + + it('{all: true}', async () => { + expectTypeOf(({} as BodyData<{ all: true }>)['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{all: boolean}', async () => { + expectTypeOf(({} as BodyData<{ all: boolean }>)['key']).toEqualTypeOf< + string | File | (string | File)[] + >() + }) + + it('{dot: true}', async () => { + expectTypeOf(({} as BodyData<{ dot: true }>)['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{dot: boolean}', async () => { + expectTypeOf(({} as BodyData<{ dot: boolean }>)['key']).toEqualTypeOf< + string | File | RecursiveRecord + >() + }) + + it('{all: true, dot: true}', async () => { + expectTypeOf(({} as BodyData<{ all: true; dot: true }>)['key']).toEqualTypeOf< + string | File | (string | File)[] | RecursiveRecord + >() + }) + + it('{all: boolean, dot: boolean}', async () => { + expectTypeOf(({} as BodyData<{ all: boolean; dot: boolean }>)['key']).toEqualTypeOf< + string | File | (string | File)[] | RecursiveRecord + >() + }) }) diff --git a/src/utils/body.ts b/src/utils/body.ts index 295b807d4..4f08ccdef 100644 --- a/src/utils/body.ts +++ b/src/utils/body.ts @@ -1,6 +1,39 @@ import { HonoRequest } from '../request' -export type BodyData = Record +type BodyDataValueDot = { [x: string]: string | File | BodyDataValueDot } & {} +type BodyDataValueDotAll = { + [x: string]: string | File | (string | File)[] | BodyDataValueDotAll +} & {} +type SimplifyBodyData = { + [K in keyof T]: string | File | (string | File)[] | BodyDataValueDotAll extends T[K] + ? string | File | (string | File)[] | BodyDataValueDotAll + : string | File | BodyDataValueDot extends T[K] + ? string | File | BodyDataValueDot + : string | File | (string | File)[] extends T[K] + ? string | File | (string | File)[] + : string | File +} & {} + +type BodyDataValueComponent = + | string + | File + | (T extends { all: false } + ? never // explicitly set to false + : T extends { all: true } | { all: boolean } + ? (string | File)[] // use all option + : never) // without options +type BodyDataValueObject = { [key: string]: BodyDataValueComponent | BodyDataValueObject } +type BodyDataValue = + | BodyDataValueComponent + | (T extends { dot: false } + ? never // explicitly set to false + : T extends { dot: true } | { dot: boolean } + ? BodyDataValueObject // use dot option + : never) // without options +export type BodyData = {}> = SimplifyBodyData< + Record> +> + export type ParseBodyOptions = { /** * Determines whether all fields with multiple values should be parsed as arrays. @@ -17,13 +50,48 @@ export type ParseBodyOptions = { * If all is true: * parseBody should return { file: ['aaa', 'bbb'], message: 'hello' } */ - all?: boolean + all: boolean + /** + * Determines whether all fields with dot notation should be parsed as nested objects. + * @default false + * @example + * const data = new FormData() + * data.append('obj.key1', 'value1') + * data.append('obj.key2', 'value2') + * + * If dot is false: + * parseBody should return { 'obj.key1': 'value1', 'obj.key2': 'value2' } + * + * If dot is true: + * parseBody should return { obj: { key1: 'value1', key2: 'value2' } } + */ + dot: boolean } -export const parseBody = async ( +/** + * Parses the body of a request based on the provided options. + * + * @template T - The type of the parsed body data. + * @param {HonoRequest | Request} request - The request object to parse. + * @param {Partial} [options] - Options for parsing the body. + * @returns {Promise} The parsed body data. + */ +interface ParseBody { + , T extends BodyData>( + request: HonoRequest | Request, + options?: Options + ): Promise + ( + request: HonoRequest | Request, + options?: Partial + ): Promise +} +export const parseBody: ParseBody = async ( request: HonoRequest | Request, - options: ParseBodyOptions = { all: false } -): Promise => { + options = Object.create(null) +) => { + const { all = false, dot = false } = options + const headers = request instanceof HonoRequest ? request.raw.headers : request.headers const contentType = headers.get('Content-Type') @@ -31,13 +99,21 @@ export const parseBody = async ( (contentType !== null && contentType.startsWith('multipart/form-data')) || (contentType !== null && contentType.startsWith('application/x-www-form-urlencoded')) ) { - return parseFormData(request, options) + return parseFormData(request, { all, dot }) } - return {} as T + return {} } -async function parseFormData( +/** + * Parses form data from a request. + * + * @template T - The type of the parsed body data. + * @param {HonoRequest | Request} request - The request object containing form data. + * @param {ParseBodyOptions} options - Options for parsing the form data. + * @returns {Promise} The parsed body data. + */ +async function parseFormData( request: HonoRequest | Request, options: ParseBodyOptions ): Promise { @@ -50,33 +126,100 @@ async function parseFormData( return {} as T } -function convertFormDataToBodyData( +/** + * Converts form data to body data based on the provided options. + * + * @template T - The type of the parsed body data. + * @param {FormData} formData - The form data to convert. + * @param {ParseBodyOptions} options - Options for parsing the form data. + * @returns {T} The converted body data. + */ +function convertFormDataToBodyData( formData: FormData, options: ParseBodyOptions ): T { - const form: BodyData = {} + const form: BodyData = Object.create(null) formData.forEach((value, key) => { const shouldParseAllValues = options.all || key.endsWith('[]') - if (!shouldParseAllValues) { - form[key] = value - } else { + if (shouldParseAllValues) { handleParsingAllValues(form, key, value) + } else { + form[key] = value } }) + if (options.dot) { + const nestedForm: BodyData = Object.create(null) + + Object.entries(form).forEach(([key, value]) => { + const shouldParseDotValues = key.includes('.') + + if (shouldParseDotValues) { + handleParsingNestedValues(nestedForm, key, value) + } else { + nestedForm[key] = value + } + }) + + return nestedForm as T + } + return form as T } -const handleParsingAllValues = (form: BodyData, key: string, value: FormDataEntryValue): void => { - const formKey = form[key] - - if (formKey && Array.isArray(formKey)) { - ;(form[key] as (string | File)[]).push(value) - } else if (formKey) { - form[key] = [formKey, value] +/** + * Handles parsing all values for a given key, supporting multiple values as arrays. + * + * @param {BodyData} form - The form data object. + * @param {string} key - The key to parse. + * @param {FormDataEntryValue} value - The value to assign. + */ +const handleParsingAllValues = ( + form: BodyData<{ all: true }>, + key: string, + value: FormDataEntryValue +): void => { + if (form[key] !== undefined) { + if (Array.isArray(form[key])) { + ;(form[key] as (string | File)[]).push(value) + } else { + form[key] = [form[key] as string | File, value] + } } else { form[key] = value } } + +/** + * Handles parsing nested values using dot notation keys. + * + * @param {BodyData} form - The form data object. + * @param {string} key - The dot notation key. + * @param {BodyDataValue} value - The value to assign. + */ +const handleParsingNestedValues = ( + form: BodyData, + key: string, + value: BodyDataValue> +): void => { + let nestedForm = form + const keys = key.split('.') + + keys.forEach((key, index) => { + if (index === keys.length - 1) { + nestedForm[key] = value + } else { + if ( + !nestedForm[key] || + typeof nestedForm[key] !== 'object' || + Array.isArray(nestedForm[key]) || + nestedForm[key] instanceof File + ) { + nestedForm[key] = Object.create(null) + } + nestedForm = nestedForm[key] as unknown as BodyData + } + }) +} diff --git a/src/utils/url.ts b/src/utils/url.ts index b37a5539a..58349064e 100644 --- a/src/utils/url.ts +++ b/src/utils/url.ts @@ -69,11 +69,48 @@ export const getPattern = (label: string): Pattern | null => { return null } +/** + * Try to apply decodeURI() to given string. + * If it fails, skip invalid percent encoding or invalid UTF-8 sequences, and apply decodeURI() to the rest as much as possible. + * @param str The string to decode. + * @returns The decoded string that sometimes contains undecodable percent encoding. + * @example + * tryDecodeURI('Hello%20World') // 'Hello World' + * tryDecodeURI('Hello%20World/%A4%A2') // 'Hello World/%A4%A2' + */ +const tryDecodeURI = (str: string): string => { + try { + return decodeURI(str) + } catch { + return str.replace(/(?:%[0-9A-Fa-f]{2})+/g, (match) => { + try { + return decodeURI(match) + } catch { + return match + } + }) + } +} + export const getPath = (request: Request): string => { - // Optimized: indexOf() + slice() is faster than RegExp const url = request.url - const queryIndex = url.indexOf('?', 8) - return url.slice(url.indexOf('/', 8), queryIndex === -1 ? undefined : queryIndex) + const start = url.indexOf('/', 8) + let i = start + for (; i < url.length; i++) { + const charCode = url.charCodeAt(i) + if (charCode === 37) { + // '%' + // If the path contains percent encoding, use `indexOf()` to find '?' and return the result immediately. + // Although this is a performance disadvantage, it is acceptable since we prefer cases that do not include percent encoding. + const queryIndex = url.indexOf('?', i) + const path = url.slice(start, queryIndex === -1 ? undefined : queryIndex) + return tryDecodeURI(path.includes('%25') ? path.replace(/%25/g, '%2525') : path) + } else if (charCode === 63) { + // '?' + break + } + } + return url.slice(start, i) } export const getQueryStrings = (url: string): string => { diff --git a/src/validator/validator.ts b/src/validator/validator.ts index 4b4f1b871..cf66d0e35 100644 --- a/src/validator/validator.ts +++ b/src/validator/validator.ts @@ -91,7 +91,7 @@ export const validator = < try { const arrayBuffer = await c.req.arrayBuffer() const formData = await bufferToFormData(arrayBuffer, contentType) - const form: BodyData = {} + const form: BodyData<{ all: true }> = {} formData.forEach((value, key) => { if (key.endsWith('[]')) { if (form[key] === undefined) { diff --git a/yarn.lock b/yarn.lock index e282fd073..17b7cfcec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1,6 +1,6 @@ # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. # yarn lockfile v1 -# bun ./bun.lockb --hash: B0F80BF47311212B-e1ead61e28c636b5-7295CFAA56CB98E9-44e94466d8a0cab6 +# bun ./bun.lockb --hash: 5CA1136C3F2F5DB7-1b68ff373acdefeb-7DBBBCD34BFD0EF1-d2b230862a4c1954 "@aashutoshrathi/word-wrap@^1.2.3": @@ -159,11 +159,6 @@ resolved "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20231030.0.tgz" integrity sha512-fb/Jgj8Yqy3PO1jLhk7mTrHMkR8jklpbQFud6rL/aMAn5d6MQbaSrYOCjzkKGp0Zng8D2LIzSl+Fc0C9Sggxjg== -"@cloudflare/workers-types@^4.20231121.0": - version "4.20240117.0" - resolved "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20240117.0.tgz" - integrity sha512-HQU8lJhaJVh8gQXFtVA7lZwd0hK1ckIFjRuxOXkVN2Z9t7DtzNbA2YTwBry5thKNgF5EwjN4THjHg5NUZzj05A== - "@esbuild-plugins/node-globals-polyfill@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@esbuild-plugins/node-globals-polyfill/-/node-globals-polyfill-0.2.3.tgz"