Skip to content

Add onCache hook fired once per cache write#4

Draft
harper-joseph wants to merge 1 commit into
mainfrom
feat/on-cache-hook
Draft

Add onCache hook fired once per cache write#4
harper-joseph wants to merge 1 commit into
mainfrom
feat/on-cache-hook

Conversation

@harper-joseph
Copy link
Copy Markdown
Contributor

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" — transformResponseHeaders only fires on cached-HIT paths, and tagsForResponse runs 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

onCache(request, response, { cacheKey, tags });
Argument Shape Notes
request the original request Same shape other hooks receive.
response { statusCode, headers, content } The exact data being persisted. headers is the undici plain object (lower-cased keys); content is the response body as a Blob.
context.cacheKey string The key the entry will be stored under.
context.tags string[] | null Tags computed for the entry (tagsForResponse result or Surrogate-Key-derived default).

Canonical use:

export const onCache = async (req, response, { cacheKey, tags }) => {
	if (response.statusCode \!== 200) return;
	const related = relatedKeyFor(req);
	if (await isCached(related)) return;
	await convertAndCache(related, await response.content.text(), tags);
};

Hook is fire-and-forget. Errors caught and console.warn'd (HttpObjectSource has no scope.logger); they never affect the primary response.

When it fires (and when it doesn't)

  • ✅ MISS, cacheable response (entry actually being written).
  • ❌ HIT — no new entry.
  • ❌ REVALIDATED 304 — existing entry kept; no new persist.
  • context.noCacheStore true — Cache-Control: no-store, isCacheableResponse returning falsy, Vary-incompatible response, etc. Pass-through-only responses don't qualify as a "cached" event.

Diff

  • src/hooks.js — register onCache in the hook registry + HOOK_NAMES.
  • src/plugin.js — add onCache: () => null to policy defaults so missing exports fall through cleanly.
  • src/resources/HttpObjectSource.js — after computing tags and creating the content blob, gate on \!context.noCacheStore and fire the hook via Promise.resolve().then(...). The hook runs detached; the primary return path is unaffected.
  • test/onCache.test.js — 7 node:test cases mirroring the orchestration in isolation: default no-op, HOOK_NAMES membership, argument shape, sync + async error containment, the noCacheStore gate, and tags null-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 --check and npx eslint clean on the diff.
  • ⚠️ Couldn't run the full devDep install in-sandbox (chmod block on harper bin); CI is the real signal.

🤖 Generated with Claude Code (Opus 4.7).

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>
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

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 }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

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 }

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.

1 participant