Skip to content

feat(universal-cache): add universal cache middleware package#1764

Open
lord007tn wants to merge 1 commit intohonojs:mainfrom
lord007tn:feat/universal-cache
Open

feat(universal-cache): add universal cache middleware package#1764
lord007tn wants to merge 1 commit intohonojs:mainfrom
lord007tn:feat/universal-cache

Conversation

@lord007tn
Copy link

@lord007tn lord007tn commented Feb 24, 2026

Summary

This PR introduces @hono/universal-cache as a new third-party middleware package for Hono.

It follows the direction discussed in honojs/hono#3857:
honojs/hono#3857

This middleware has been running internally in our production systems for a while. After validating it under real traffic and edge cases, we felt it was in a good place to open-source and get broader feedback on it.

Dependency Model

@hono/universal-cache depends on unstorage as the storage abstraction.

That gives us:

  • in-memory cache by default
  • adapter-based backends through unstorage drivers (Redis, KV, filesystem, and others)
  • one consistent cache API across runtimes

Major Features

  • response caching for Hono handlers via cacheMiddleware()
  • function-level caching via cacheFunction()
  • request-scoped defaults via cacheDefaults()
  • stale-while-revalidate (SWR)
  • in-flight deduplication for refresh paths
  • configurable cache keys (getKey) and integrity (integrity / hash)
  • custom serialize / deserialize hooks for response and function entries
  • cache control hooks: shouldBypassCache, shouldInvalidateCache, shouldRevalidate, validate
  • configurable HTTP methods and varies support for cache key variation
  • opt-in manual revalidation via revalidateHeader
  • keepPreviousOn5xx behavior for safer refresh failures
  • global helpers for cache defaults and storage instance management

Runtime Notes

  • default maxAge is 60
  • manual revalidation is disabled by default
  • on workerd, stale middleware entries refresh synchronously instead of relying on background self-fetch

Test Coverage

Added coverage for:

  • middleware caching behavior across methods
  • bypass / invalidation / manual revalidation flows
  • shouldRevalidate gating
  • SWR behavior in standard runtimes and workerd
  • vary-based keying
  • custom serialization / deserialization
  • function cache deduplication and validation
  • global defaults / storage helpers

Validation

  • corepack yarn eslint packages/universal-cache/src packages/universal-cache/vitest.config.ts packages/universal-cache/vitest.workerd.config.ts --cache --cache-location .cache/.eslintcache
  • corepack yarn workspace @hono/universal-cache typecheck
  • corepack yarn workspace @hono/universal-cache test --run
  • corepack yarn workspace @hono/universal-cache test:workerd --run
  • cd packages/universal-cache && bunx vitest run src/index.test.ts src/utils.test.ts

Related

Merge Note

Please merge this together with the docs PR above.

The author should do the following, if applicable

  • Add tests
  • Run tests
  • yarn changeset at the top of this repo and push the changeset
  • Follow the contribution guide

@changeset-bot
Copy link

changeset-bot bot commented Feb 24, 2026

🦋 Changeset detected

Latest commit: 89ea559

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@hono/universal-cache Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov
Copy link

codecov bot commented Feb 26, 2026

Codecov Report

❌ Patch coverage is 93.78238% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.90%. Comparing base (8a0d4ea) to head (d79c864).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
packages/universal-cache/src/cache.ts 93.16% 21 Missing and 3 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1764      +/-   ##
==========================================
+ Coverage   91.71%   91.90%   +0.19%     
==========================================
  Files         113      115       +2     
  Lines        3778     4164     +386     
  Branches      954     1067     +113     
==========================================
+ Hits         3465     3827     +362     
- Misses        281      302      +21     
- Partials       32       35       +3     
Flag Coverage Δ
universal-cache 93.78% <93.78%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@lord007tn
Copy link
Author

Addressed the Codecov coverage gaps with additional targeted tests.

What I added

  • New utility test suite:
    • packages/universal-cache/src/utils.test.ts
    • Covers all utils.ts branches and lines (normalizePathToName, stableStringify, TTL logic, expiry helpers).
  • Expanded index.test.ts with cache edge-case scenarios:
    • malformed path decoding fallback key path
    • cache entry rejection on integrity mismatch
    • cache entry rejection via validate()
    • rejection of cached headers containing etag: "undefined" / last-modified: "undefined"
    • invalidation behavior for non-cacheable no-store responses
    • function cache default key hashing path (without getKey)
    • removal of malformed function cache entries

Validation run

  • corepack yarn eslint packages/universal-cache/src --cache --cache-location .cache/.eslintcache
  • corepack yarn workspace @hono/universal-cache typecheck
  • corepack yarn workspace @hono/universal-cache test --run

All passed locally.

The new commit is: d79c864

throw new Error('Base64 encoding is not available in this runtime')
}

const base64ToUint8Array = (base64: string) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return null
}

const arrayBufferToBase64 = (buffer: ArrayBuffer) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/** Default storage group for cached functions. */
export const DEFAULT_FUNCTION_GROUP = 'hono/functions'
/** Default cache max age in seconds. */
export const DEFAULT_MAX_AGE = 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 is really proper? Isn't it too short for real users?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By default, for our needs, we kind of bypass it by default. Can I change it to 60, maybe?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. 60 and writing about it in the docs seem good.

return next()
}

const isRevalidateRequest = ctx.req.header(revalidateHeader) === '1'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it good design that any user agent can revalidate if it sends the request with the revalidate header like x-cache-revalidate: 1?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently we use a custom header ourselves, for that case

Maybe I need to mention that in the docs, that its recommanded to change the header name

or make the shouldRevalidate required so they can implement whatever gates they want here

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding shouldRevalidate and allowing users to define the logic is good for DX. And, I think "revalidate" should be optional. It makes safety-side.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think SWR works on Workers. The behaviors of the following code on Bun and Workers are different:

import { Hono } from 'hono'
import { cacheMiddleware } from '@hono/universal-cache'

const app = new Hono()

let handlerCallCount = 0

app.get(
  '/api/data',
  cacheMiddleware({
    maxAge: 2,
    swr: true,
    staleMaxAge: 10,
  }),
  (c) => {
    handlerCallCount++
    console.log(`[handler] called #${handlerCallCount} at ${new Date().toISOString()}`)
    return c.json({
      count: handlerCallCount,
      time: Date.now(),
    })
  }
)

app.get('/api/stats', (c) => {
  return c.json({ handlerCallCount })
})

export default app

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i will check that

@lord007tn lord007tn force-pushed the feat/universal-cache branch from d79c864 to 89ea559 Compare March 12, 2026 10:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants