diff --git a/deno_dist/middleware.ts b/deno_dist/middleware.ts index 5b421668b..6164d61a4 100644 --- a/deno_dist/middleware.ts +++ b/deno_dist/middleware.ts @@ -13,6 +13,7 @@ export { jwt } from './middleware/jwt/index.ts' export * from './middleware/logger/index.ts' export * from './middleware/method-override/index.ts' export * from './middleware/powered-by/index.ts' +export * from './middleware/timeout/index.ts' export * from './middleware/timing/index.ts' export * from './middleware/pretty-json/index.ts' export * from './middleware/secure-headers/index.ts' diff --git a/deno_dist/middleware/timeout/index.ts b/deno_dist/middleware/timeout/index.ts new file mode 100644 index 000000000..076bd80aa --- /dev/null +++ b/deno_dist/middleware/timeout/index.ts @@ -0,0 +1,53 @@ +import type { Context } from '../../context.ts' +import { HTTPException } from '../../http-exception.ts' +import type { MiddlewareHandler } from '../../types.ts' + +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/package.json b/package.json index 043957ff6..7deaa52ed 100644 --- a/package.json +++ b/package.json @@ -179,6 +179,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", @@ -420,6 +425,9 @@ "jwt": [ "./dist/types/middleware/jwt" ], + "timeout": [ + "./dist/types/middleware/timeout" + ], "timing": [ "./dist/types/middleware/timing" ], diff --git a/src/middleware.ts b/src/middleware.ts index 65954a4ed..c6c7a4c72 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -13,6 +13,7 @@ export { jwt } from './middleware/jwt' export * from './middleware/logger' export * from './middleware/method-override' export * from './middleware/powered-by' +export * from './middleware/timeout' export * from './middleware/timing' export * from './middleware/pretty-json' export * from './middleware/secure-headers' 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) + } + } + } +}