Add onCache hook fired once per cache write#4
Conversation
Cache consumers regularly need body-dependent side effects that run
after a miss has produced a cacheable response — populating a related
cache entry, recording per-miss metrics, POSTing the body to another
service. Up until now there was no clean extension point for this;
`transformResponseHeaders` only fires on the cached-HIT path and runs
after the response is determined.
Add a `onCache(request, response, { cacheKey, tags })` hook that fires
from `HttpObjectSource.get` once per cache write, with the exact
{ statusCode, headers, content } rp-cache is about to persist:
- Fires only on MISS where the response is actually being persisted.
Skipped when `context.noCacheStore` is true (Cache-Control: no-store,
`isCacheableResponse` returning falsy, Vary-incompatible responses,
etc.) and on cached HIT / REVALIDATED paths.
- `response.headers` is the undici plain object (lower-cased keys);
`response.content` is the same Blob rp-cache stores.
- `context.tags` is the tag list rp-cache computed (hook result or
Surrogate-Key default), so consumers can stamp related entries with
the same tags.
Hook is fire-and-forget. Errors caught + logged via console.warn (no
scope.logger here); they never reach the primary response. Default is
`() => null`, so existing consumers see no behavior change.
`node:test` coverage (7 cases) exercises the orchestration contract in
isolation: default no-op, HOOK_NAMES membership, argument shape, sync
and async error containment, the noCacheStore gate, and tags-null vs
tags-array surfacing.
README documents the hook in the policy-hooks table and adds a section
with a canonical usage example.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a new fire-and-forget onCache hook that is executed asynchronously when a response is persisted to the cache. It includes documentation, registration of the hook, implementation in HttpObjectSource, and comprehensive unit tests. The feedback suggests passing a shallow copy of the tags array to the hook to prevent potential race conditions or side effects from shared mutable state.
| hooks.onCache( | ||
| context.requestContext, | ||
| { statusCode: upstreamRes.statusCode, headers: upstreamRes.headers, content }, | ||
| { cacheKey, tags } |
There was a problem hiding this comment.
Passing the tags array reference directly to the detached onCache hook introduces a potential race condition. Since the hook runs asynchronously in a detached promise, any mutation of the tags array within the hook could execute before the database layer serializes and persists the returned object, leading to unintended side effects (e.g., storing mutated tags in the database). Passing a shallow copy of the tags array avoids this shared mutable state.
\t\t\t\t\t\t{ cacheKey, tags: tags ? [...tags] : null }
Summary
Adds a single new policy hook,
onCache(request, response, { cacheKey, tags }), that fires once per cache write with the exact body about to be persisted.Existing hooks didn't cover "I want to do something with the cached object, after it was cached" —
transformResponseHeadersonly fires on cached-HIT paths, andtagsForResponseruns before the body is available. This unblocks body-dependent side effects: populating a related cache entry, recording per-miss metrics, POSTing the body to another service, etc.(Supersedes #3, which proposed a more complex
populateRelated(req, { cacheKey, primaryResponse })hook with parallel-with-primary timing. Per discussion, simpler hook fits the actual use cases.)API
requestresponse{ statusCode, headers, content }headersis the undici plain object (lower-cased keys);contentis the response body as aBlob.context.cacheKeystringcontext.tagsstring[] | nulltagsForResponseresult orSurrogate-Key-derived default).Canonical use:
Hook is fire-and-forget. Errors caught and
console.warn'd (HttpObjectSource has noscope.logger); they never affect the primary response.When it fires (and when it doesn't)
context.noCacheStoretrue —Cache-Control: no-store,isCacheableResponsereturning falsy, Vary-incompatible response, etc. Pass-through-only responses don't qualify as a "cached" event.Diff
src/hooks.js— registeronCachein the hook registry +HOOK_NAMES.src/plugin.js— addonCache: () => nullto policy defaults so missing exports fall through cleanly.src/resources/HttpObjectSource.js— after computing tags and creating the content blob, gate on\!context.noCacheStoreand fire the hook viaPromise.resolve().then(...). The hook runs detached; the primary return path is unaffected.test/onCache.test.js— 7node:testcases mirroring the orchestration in isolation: default no-op,HOOK_NAMESmembership, argument shape, sync + async error containment, thenoCacheStoregate, andtagsnull-vs-array surfacing.README.md— adds the new row to the policy-hooks table and a section with a canonical usage example.Verification
npm test→ 17/17 pass (10 existing + 7 new).npx prettier --checkandnpx eslintclean on the diff.harperbin); CI is the real signal.🤖 Generated with Claude Code (Opus 4.7).