diff --git a/.changeset/khaki-tires-roll.md b/.changeset/khaki-tires-roll.md new file mode 100644 index 000000000..e5531b6ad --- /dev/null +++ b/.changeset/khaki-tires-roll.md @@ -0,0 +1,5 @@ +--- +'@hono/response-cache': minor +--- + +Initial Response Cache middleware implementation diff --git a/packages/response-cache/README.md b/packages/response-cache/README.md new file mode 100644 index 000000000..50a542f3c --- /dev/null +++ b/packages/response-cache/README.md @@ -0,0 +1,85 @@ +# Response Cache for Hono + +Response cache for [Hono](https://honojs.dev) with `Bring Your Own` cache store. + +## Usage + +### Basic with `in-memory` cache: + +```ts +import { Hono } from 'hono' +import { cacheMiddleware } from '@hono/response-cache' + +const cacheStorage = new Map() +const cache = cacheMiddleware({ + store: { + get: (key) => cacheStorage.get(key), + set: (key, value) => { + cacheStorage.set(key, value) + }, + delete: (key) => { + cacheStorage.delete(key) + }, + }, +}) + +const app = new Hono() +app.use('*', cache) +``` + +### Redis (and custom key function) + +```ts +import { Hono } from 'hono' +import { cacheMiddleware } from '@hono/response-cache' +import { createClient } from '@redis/client' + +const redisClient = createClient({ + url: `redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`, +}) + +const store = { + get: async (key: string) => { + return (await redisClient.get(key)) ?? null + }, + set: async (key: string, value: string) => { + await redisClient.set(key, value) + return + }, + invalidate: async (key: string) => { + await redisClient.del(key) + return + }, +} + +const cache = cacheMiddleware({ + store, + keyFn: (req, c) => `hono_res_cache_${c.req.path}`, +}) + +app.use('*', cache) +``` + +### Add logging + +```ts +import { cacheMiddleware } from '@hono/response-cache' + +const cache = cacheMiddleware({ + store, + logging: { + enabled: true, + onHit: (key, c) => console.log(`Cache hit for ${key}`), + onMiss: (key, c) => console.log(`Cache miss for ${key}`), + onError: (key, c) => console.log(`Cache error for ${key}, error:`, error), + }, +}) +``` + +## Author + +Rokas Muningis + +## License + +MIT diff --git a/packages/response-cache/deno.json b/packages/response-cache/deno.json new file mode 100644 index 000000000..2f1922545 --- /dev/null +++ b/packages/response-cache/deno.json @@ -0,0 +1,15 @@ +{ + "name": "@hono/response-cache", + "version": "0.0.0", + "license": "MIT", + "exports": { + ".": "./src/index.ts" + }, + "imports": { + "hono": "jsr:@hono/hono@^4.8.3" + }, + "publish": { + "include": ["deno.json", "README.md", "src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] + } +} diff --git a/packages/response-cache/package.json b/packages/response-cache/package.json new file mode 100644 index 000000000..793446d98 --- /dev/null +++ b/packages/response-cache/package.json @@ -0,0 +1,56 @@ +{ + "name": "@hono/response-cache", + "version": "0.0.0", + "description": "Response cache for Hono", + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsdown", + "format": "prettier --check . --ignore-path ../../.gitignore", + "lint": "eslint", + "typecheck": "tsc -b tsconfig.json", + "test": "vitest", + "version:jsr": "yarn version:set $npm_package_version" + }, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public", + "provenance": true + }, + "repository": { + "type": "git", + "url": "https://github.com/honojs/middleware.git", + "directory": "packages/response-cache" + }, + "homepage": "https://github.com/honojs/middleware", + "peerDependencies": { + "hono": ">=4.0.0" + }, + "devDependencies": { + "hono": "^4.11.1", + "tsdown": "^0.15.9", + "typescript": "^5.8.2", + "vitest": "^4.0.16" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/response-cache/src/index.test.ts b/packages/response-cache/src/index.test.ts new file mode 100644 index 000000000..1aa79f7ce --- /dev/null +++ b/packages/response-cache/src/index.test.ts @@ -0,0 +1,401 @@ +import { Hono } from 'hono' +import { vi } from 'vitest' +import { responseCache } from '.' + +const createMockStore = () => { + const storage = new Map() + return { + get: vi.fn((key: string) => storage.get(key) ?? null), + set: vi.fn((key: string, value: string) => { + storage.set(key, value) + }), + invalidate: vi.fn((key: string) => { + storage.delete(key) + }), + storage, + } +} + +describe('Response Cache Middleware', () => { + describe('Cache Miss', () => { + it('Should call handler on cache miss', async () => { + const store = createMockStore() + const app = new Hono() + const handlerSpy = vi.fn((c) => c.text('response')) + + app.use('*', responseCache({ store })) + app.get('/test', handlerSpy) + + await app.request(new Request('http://localhost/test')) + + expect(handlerSpy).toHaveBeenCalledTimes(1) + }) + + it('Should return complete response on cache miss', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.json({ data: 'test' }, 201, { 'X-Custom': 'header' })) + + const res = await app.request(new Request('http://localhost/test')) + + expect(res.status).toBe(201) + expect(res.headers.get('X-Custom')).toBe('header') + expect(res.headers.get('Content-Type')).toContain('application/json') + + const data = await res.json() + expect(data).toEqual({ data: 'test' }) + }) + }) + + describe('Cache Hit', () => { + it('Should return cached response on second call', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => + c.json({ data: 'test' }, 200, { + 'X-Request-Id': '12345', + 'Cache-Control': 'max-age=3600', + }) + ) + + const res1 = await app.request(new Request('http://localhost/test')) + expect(res1.status).toBe(200) + expect(res1.headers.get('X-Request-Id')).toBe('12345') + expect(await res1.json()).toEqual({ data: 'test' }) + + store.get.mockClear() + store.set.mockClear() + + const res2 = await app.request(new Request('http://localhost/test')) + + expect(res2.status).toBe(200) + expect(res2.headers.get('X-Request-Id')).toBe('12345') + expect(res2.headers.get('Cache-Control')).toBe('max-age=3600') + expect(await res2.json()).toEqual({ data: 'test' }) + expect(store.get).toHaveBeenCalledWith('/test') + expect(store.set).not.toHaveBeenCalled() + }) + + it('Should filter sensitive headers from cache', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => { + return c.json({ data: 'test' }, 200, { + 'Content-Type': 'application/json', + 'X-Custom': 'safe-header', + 'Set-Cookie': 'session=abc123', + 'WWW-Authenticate': 'Bearer token', + }) + }) + + await app.request(new Request('http://localhost/test')) + + const cachedValue = store.storage.get('/test') + const snapshot = JSON.parse(cachedValue!) + + expect(snapshot.headers['x-custom']).toBe('safe-header') + expect(snapshot.headers['content-type']).toContain('application/json') + expect(snapshot.headers['set-cookie']).toBeUndefined() + expect(snapshot.headers['www-authenticate']).toBeUndefined() + }) + }) + + describe('Response Types', () => { + it('Should cache JSON responses correctly', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.json({ message: 'hello', count: 42 })) + + const res1 = await app.request(new Request('http://localhost/test')) + const data1 = await res1.json() + expect(data1).toEqual({ message: 'hello', count: 42 }) + + const res2 = await app.request(new Request('http://localhost/test')) + const data2 = await res2.json() + expect(data2).toEqual({ message: 'hello', count: 42 }) + }) + + it('Should cache HTML responses correctly', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.html('
Test
')) + + const res1 = await app.request(new Request('http://localhost/test')) + expect(await res1.text()).toBe('
Test
') + + const res2 = await app.request(new Request('http://localhost/test')) + expect(await res2.text()).toBe('
Test
') + }) + + it('Should cache text responses correctly', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.text('Plain text response')) + + const res1 = await app.request(new Request('http://localhost/test')) + expect(await res1.text()).toBe('Plain text response') + + const res2 = await app.request(new Request('http://localhost/test')) + expect(await res2.text()).toBe('Plain text response') + }) + + it('Should cache body responses correctly', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.body('Body response')) + + const res1 = await app.request(new Request('http://localhost/test')) + expect(await res1.text()).toBe('Body response') + + const res2 = await app.request(new Request('http://localhost/test')) + expect(await res2.text()).toBe('Body response') + }) + }) + + describe('Custom Key Function', () => { + it('Should use request path as key by default', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + + expect(store.get).toHaveBeenCalledWith('/test') + expect(store.set).toHaveBeenCalledWith('/test', expect.any(String)) + }) + + it('Should use custom keyFn when provided', async () => { + const store = createMockStore() + const app = new Hono() + const customKeyFn = vi.fn((c) => `custom_${c.req.path}`) + + app.use('*', responseCache({ store, keyFn: customKeyFn })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + + expect(customKeyFn).toHaveBeenCalled() + expect(store.get).toHaveBeenCalledWith('custom_/test') + expect(store.set).toHaveBeenCalledWith('custom_/test', expect.any(String)) + }) + + it('Should maintain separate cache entries for different keys', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/page1', (c) => c.text('Page 1')) + app.get('/page2', (c) => c.text('Page 2')) + + const res1 = await app.request(new Request('http://localhost/page1')) + expect(await res1.text()).toBe('Page 1') + + const res2 = await app.request(new Request('http://localhost/page2')) + expect(await res2.text()).toBe('Page 2') + + expect(store.storage.has('/page1')).toBe(true) + expect(store.storage.has('/page2')).toBe(true) + expect(store.storage.get('/page1')).not.toBe(store.storage.get('/page2')) + }) + }) + + describe('Logging', () => { + it('Should call onHit callback on cache hit', async () => { + const store = createMockStore() + const onHit = vi.fn() + const app = new Hono() + + app.use('*', responseCache({ store, logging: { enabled: true, onHit } })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + expect(onHit).not.toHaveBeenCalled() + + await app.request(new Request('http://localhost/test')) + expect(onHit).toHaveBeenCalledWith('/test', expect.anything()) + }) + + it('Should call onMiss callback on cache miss', async () => { + const store = createMockStore() + const onMiss = vi.fn() + const app = new Hono() + + app.use('*', responseCache({ store, logging: { enabled: true, onMiss } })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + + expect(onMiss).toHaveBeenCalledWith('/test', expect.anything()) + }) + + it('Should not call logging callbacks when logging is disabled', async () => { + const store = createMockStore() + const onHit = vi.fn() + const onMiss = vi.fn() + const app = new Hono() + + app.use('*', responseCache({ store, logging: { enabled: false, onHit, onMiss } })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + expect(onMiss).not.toHaveBeenCalled() + + await app.request(new Request('http://localhost/test')) + expect(onHit).not.toHaveBeenCalled() + }) + + it('Should not call logging callbacks when logging is not provided', async () => { + const store = createMockStore() + const app = new Hono() + + app.use('*', responseCache({ store })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + await app.request(new Request('http://localhost/test')) + }) + + it('Should pass context to logging callbacks', async () => { + const store = createMockStore() + const onHit = vi.fn() + const app = new Hono() + + app.use('*', responseCache({ store, logging: { enabled: true, onHit } })) + app.get('/test', (c) => c.text('response')) + + await app.request(new Request('http://localhost/test')) + await app.request(new Request('http://localhost/test')) + + expect(onHit).toHaveBeenCalledWith( + '/test', + expect.objectContaining({ + req: expect.any(Object), + }) + ) + }) + }) + + describe('Error Handling', () => { + let consoleErrorSpy: ReturnType + + beforeEach(() => { + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + consoleErrorSpy.mockRestore() + }) + + it('Should call onError callback when store.get throws', async () => { + const store = createMockStore() + const onError = vi.fn() + store.get.mockImplementation(() => { + throw new Error('Store get error') + }) + + const app = new Hono() + app.use('*', responseCache({ store, logging: { enabled: true, onError } })) + app.get('/test', (c) => c.text('response')) + + const res = await app.request(new Request('http://localhost/test')) + + expect(res.status).toBe(500) + expect(onError).toHaveBeenCalledWith( + '/test', + expect.anything(), + expect.objectContaining({ message: 'Store get error' }) + ) + }) + + it('Should call onError callback when store.set throws', async () => { + const store = createMockStore() + const onError = vi.fn() + store.set.mockImplementation(() => { + throw new Error('Store set error') + }) + + const app = new Hono() + app.use('*', responseCache({ store, logging: { enabled: true, onError } })) + app.get('/test', (c) => c.text('response')) + + const res = await app.request(new Request('http://localhost/test')) + + expect(res.status).toBe(500) + expect(onError).toHaveBeenCalledWith( + '/test', + expect.anything(), + expect.objectContaining({ message: 'Store set error' }) + ) + }) + }) + + describe('Integration Tests', () => { + it('Should work with async store operations', async () => { + const storage = new Map() + const asyncStore = { + get: vi.fn(async (key: string) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + return storage.get(key) ?? null + }), + set: vi.fn(async (key: string, value: string) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + storage.set(key, value) + }), + invalidate: vi.fn(async (key: string) => { + await new Promise((resolve) => setTimeout(resolve, 10)) + storage.delete(key) + }), + } + + const app = new Hono() + app.use('*', responseCache({ store: asyncStore })) + app.get('/test', (c) => c.text('async response')) + + const res1 = await app.request(new Request('http://localhost/test')) + expect(await res1.text()).toBe('async response') + + const res2 = await app.request(new Request('http://localhost/test')) + expect(await res2.text()).toBe('async response') + + expect(asyncStore.get).toHaveBeenCalledTimes(2) + expect(asyncStore.set).toHaveBeenCalledTimes(1) + }) + + it('Should work with query parameters in custom keyFn', async () => { + const store = createMockStore() + const app = new Hono() + const keyFn = (c: any) => `${c.req.path}?${c.req.query('id')}` + + app.use('*', responseCache({ store, keyFn })) + app.get('/item', (c) => { + const id = c.req.query('id') + return c.text(`Item ${id}`) + }) + + const res1 = await app.request(new Request('http://localhost/item?id=1')) + const res2 = await app.request(new Request('http://localhost/item?id=2')) + + expect(await res1.text()).toBe('Item 1') + expect(await res2.text()).toBe('Item 2') + + expect(store.storage.has('/item?1')).toBe(true) + expect(store.storage.has('/item?2')).toBe(true) + }) + }) +}) diff --git a/packages/response-cache/src/index.ts b/packages/response-cache/src/index.ts new file mode 100644 index 000000000..cc6cbbaef --- /dev/null +++ b/packages/response-cache/src/index.ts @@ -0,0 +1,99 @@ +import type { Context, MiddlewareHandler } from 'hono' +import { createMiddleware } from 'hono/factory' +import { encodeBase64, decodeBase64 } from 'hono/utils/encode' + +type TOrPromise = T | Promise + +interface CacheStore { + get(key: string): TOrPromise + set(key: string, value: string): TOrPromise + invalidate(key: string): TOrPromise +} + +const EXCLUDED_RESPONSE_HEADERS = new Set([ + 'set-cookie', + 'www-authenticate', + 'proxy-authenticate', + 'authentication-info', + 'connection', + 'keep-alive', + 'upgrade', + 'transfer-encoding', + 'te', + 'trailer', + 'via', + 'age', + 'warning', + 'date', + 'vary', +]) + +function filterHeaders(headers: Headers): Record { + const filtered: Record = {} + headers.forEach((value, key) => { + if (!EXCLUDED_RESPONSE_HEADERS.has(key.toLowerCase())) { + filtered[key] = value + } + }) + return filtered +} + +interface CacheMiddlewareOptions { + store: CacheStore + /** + * Function to generate a cache key from the request and context. If not provided, the request path will be used. + * @param req - The request object. + * @param c - The context object. + * @returns A string key for the cache. + */ + keyFn?: (c: Context) => string + logging?: { + enabled?: boolean + onHit?: (key: string, c: Context) => void + onMiss?: (key: string, c: Context) => void + onError?: (key: string, c: Context, error: unknown) => void + } +} +const responseCache = ({ store, keyFn, logging }: CacheMiddlewareOptions): MiddlewareHandler => { + return createMiddleware(async (c, next) => { + const key = keyFn ? keyFn(c) : c.req.path + try { + const cached = await store.get(key) + if (cached) { + if (logging?.enabled) { + logging.onHit?.(key, c) + } + + const snapshot = JSON.parse(cached) + const { body, status, headers } = snapshot + + return new Response(decodeBase64(body), { + status, + headers, + }) + } else { + if (logging?.enabled) { + logging.onMiss?.(key, c) + } + await next() + + const bodyBuffer = await c.res.clone().arrayBuffer() + const body = encodeBase64(bodyBuffer) + const status = c.res.status + const headers = filterHeaders(c.res.headers) + + const snapshot = JSON.stringify({ body, status, headers }) + await store.set(key, snapshot) + + return c.res + } + } catch (error) { + if (logging?.enabled) { + logging.onError?.(key, c, error) + } + throw error + } + }) +} + +export { responseCache } diff --git a/packages/response-cache/tsconfig.build.json b/packages/response-cache/tsconfig.build.json new file mode 100644 index 000000000..4a1f19acc --- /dev/null +++ b/packages/response-cache/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": {}, + "references": [] +} diff --git a/packages/response-cache/tsconfig.json b/packages/response-cache/tsconfig.json new file mode 100644 index 000000000..d4ad6cfa3 --- /dev/null +++ b/packages/response-cache/tsconfig.json @@ -0,0 +1,12 @@ +{ + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.build.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/response-cache/tsconfig.spec.json b/packages/response-cache/tsconfig.spec.json new file mode 100644 index 000000000..9e3aa44d6 --- /dev/null +++ b/packages/response-cache/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "../../dist/packages/response-cache", + "types": ["vitest/globals"] + }, + "references": [] +} diff --git a/packages/response-cache/tsdown.config.ts b/packages/response-cache/tsdown.config.ts new file mode 100644 index 000000000..4baf13a49 --- /dev/null +++ b/packages/response-cache/tsdown.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'tsdown' + +export default defineConfig({ + attw: true, + clean: true, + dts: true, + entry: 'src/index.ts', + format: ['cjs', 'esm'], + publint: true, + tsconfig: 'tsconfig.build.json', +}) diff --git a/packages/response-cache/vitest.config.ts b/packages/response-cache/vitest.config.ts new file mode 100644 index 000000000..6f5da90b4 --- /dev/null +++ b/packages/response-cache/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineProject } from 'vitest/config' + +export default defineProject({ + test: { + globals: true, + include: ['src/**/*.test.ts'], + }, +}) diff --git a/tsconfig.json b/tsconfig.json index 3d23a958d..41b998ab3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,6 +32,7 @@ { "path": "packages/qwik-city" }, { "path": "packages/react-compat" }, { "path": "packages/react-renderer" }, + { "path": "packages/response-cache" }, { "path": "packages/sentry" }, { "path": "packages/session" }, { "path": "packages/ssg-plugins-essential" }, diff --git a/yarn.lock b/yarn.lock index 5a0a0ebd3..7171d30cb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2448,6 +2448,19 @@ __metadata: languageName: unknown linkType: soft +"@hono/response-cache@workspace:packages/response-cache": + version: 0.0.0-use.local + resolution: "@hono/response-cache@workspace:packages/response-cache" + dependencies: + hono: "npm:^4.11.1" + tsdown: "npm:^0.15.9" + typescript: "npm:^5.8.2" + vitest: "npm:^4.0.16" + peerDependencies: + hono: ">=4.0.0" + languageName: unknown + linkType: soft + "@hono/sentry@workspace:packages/sentry": version: 0.0.0-use.local resolution: "@hono/sentry@workspace:packages/sentry"