Repository: arcjet/arcjet-js
Pull request #4191: feat(arcjet): Segment cache entries by rule
Author: login: blaine-arcjet
Created: 2025-05-21T00:04:31Z | Merged: 2025-05-27T22:29:39Z

This changes the way caching is done in the SDK. Instead of a single top-level block cache for a decision, we will now cache each rule result with a `DENY` conclusion. These are cached on a combination of `ruleId` and (per rule) `fingerprint`. If a decision is made by the Decide service, it will return the `ruleId` and `fingerprint` to store in the cache.

This means that all rules will now have a local component, even if it is just looking up a value in the cache.

Closes #213

Linked issue #213: Create cache package
We have a cache implementation in our arcjet package, but we also need a cache with a similar API in analyze to support local rate limits.

We can pull that cache implementation into a package with tests so it can be reused.

Diff:
diff --git a/.github/.release-please-manifest.json b/.github/.release-please-manifest.json
index 0b8a0f514e..e0125c17d7 100644
--- a/.github/.release-please-manifest.json
+++ b/.github/.release-please-manifest.json
@@ -12,6 +12,7 @@
   "arcjet-remix": "1.0.0-beta.7",
   "arcjet-sveltekit": "1.0.0-beta.7",
   "body": "1.0.0-beta.7",
+  "cache": "1.0.0-beta.7",
   "decorate": "1.0.0-beta.7",
   "duration": "1.0.0-beta.7",
   "env": "1.0.0-beta.7",
diff --git a/.github/release-please-config.json b/.github/release-please-config.json
index 00a82d2698..80993b3101 100644
--- a/.github/release-please-config.json
+++ b/.github/release-please-config.json
@@ -76,6 +76,10 @@
       "component": "@arcjet/body",
       "skip-github-release": true
     },
+    "cache": {
+      "component": "@arcjet/cache",
+      "skip-github-release": true
+    },
     "decorate": {
       "component": "@arcjet/decorate",
       "skip-github-release": true
@@ -186,6 +190,7 @@
         "@arcjet/inspect",
         "@arcjet/ip",
         "@arcjet/body",
+        "@arcjet/cache",
         "@arcjet/logger",
         "nosecone",
         "@nosecone/next",
diff --git a/README.md b/README.md
index 60f8065d53..c25942c972 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,8 @@ See [the docs][nosecone-docs] for details.
 - [`arcjet`](./arcjet/README.md): JS SDK core.
 - [`@arcjet/body`](./body/README.md): utilities for extracting the body from a
   Node.js IncomingMessage.
+- [`@arcjet/cache`](./cache/README.md): Interface and implementations for value
+  caches.
 - [`@arcjet/decorate`](./decorate/README.md): Utilities for decorating responses
   with information.
 - [`@arcjet/duration`](./duration/README.md): Utilities for parsing duration
diff --git a/arcjet/index.ts b/arcjet/index.ts
index ca8a11be57..9096432e22 100644
--- a/arcjet/index.ts
+++ b/arcjet/index.ts
@@ -30,6 +30,7 @@ import {
   ArcjetErrorDecision,
   ArcjetShieldReason,
   ArcjetRateLimitReason,
+  ArcjetConclusion,
 } from "@arcjet/protocol";
 import type { Client } from "@arcjet/protocol/client.js";
 import * as analyze from "@arcjet/analyze";
@@ -43,6 +44,7 @@ import * as duration from "@arcjet/duration";
 import ArcjetHeaders from "@arcjet/headers";
 import { runtime } from "@arcjet/runtime";
 import * as hasher from "@arcjet/stable-hash";
+import { MemoryCache } from "@arcjet/cache";
 
 export * from "@arcjet/protocol";
 
@@ -52,42 +54,6 @@ function assert(condition: boolean, msg: string) {
   }
 }
 
-function nowInSeconds(): number {
-  return Math.floor(Date.now() / 1000);
-}
-
-class Cache<T> {
-  expires: Map<string, number>;
-  data: Map<string, T>;
-
-  constructor() {
-    this.expires = new Map();
-    this.data = new Map();
-  }
-
-  get(key: string) {
-    const ttl = this.ttl(key);
-    if (ttl > 0) {
-      return this.data.get(key);
-    } else {
-      // Cleanup if expired
-      this.expires.delete(key);
-      this.data.delete(key);
-    }
-  }
-
-  set(key: string, value: T, expiresAt: number) {
-    this.expires.set(key, expiresAt);
-    this.data.set(key, value);
-  }
-
-  ttl(key: string): number {
-    const now = nowInSeconds();
-    const expiresAt = this.expires.get(key) ?? now;
-    return expiresAt - now;
-  }
-}
-
 function errorMessage(err: unknown): string {
   if (err) {
     if (typeof err === "string") {
@@ -691,6 +657,11 @@ export type ArcjetRequest<Props extends PlainObject> = Simplify<
   } & Props
 >;
 
+type CachedResult = {
+  conclusion: ArcjetConclusion;
+  reason: ArcjetReason;
+};
+
 function isRateLimitRule<Props extends PlainObject>(
   rule: ArcjetRule<Props>,
 ): rule is ArcjetRateLimitRule<Props> {
@@ -793,7 +764,7 @@ export function tokenBucket<
     interval,
     capacity,
     validate() {},
-    async protect(context: ArcjetContext, details) {
+    async protect(context: ArcjetContext<CachedResult>, details) {
       const localCharacteristics = characteristics ?? context.characteristics;
 
       const ruleId = await hasher.hash(
@@ -819,6 +790,26 @@ export function tokenBucket<
         toAnalyzeRequest(details),
       );
 
+      const [cached, ttl] = await context.cache.get(ruleId, fingerprint);
+      if (cached && cached.reason.isRateLimit()) {
+        return new ArcjetRuleResult({
+          ruleId,
+          fingerprint,
+          ttl,
+          state: "CACHED",
+          conclusion: cached.conclusion,
+          // We rebuild the `ArcjetRateLimitReason` because we need to adjust
+          // the `reset` based on the current time-to-live
+          reason: new ArcjetRateLimitReason({
+            max: cached.reason.max,
+            remaining: cached.reason.remaining,
+            reset: ttl,
+            window: cached.reason.window,
+            resetTime: cached.reason.resetTime,
+          }),
+        });
+      }
+
       return new ArcjetRuleResult({
         ruleId,
         fingerprint,
@@ -830,6 +821,7 @@ export function tokenBucket<
           remaining: 0,
           reset: 0,
           window: 0,
+          resetTime: new Date(),
         }),
       });
     },
@@ -920,7 +912,7 @@ export function fixedWindow<
     max,
     window,
     validate() {},
-    async protect(context: ArcjetContext, details) {
+    async protect(context: ArcjetContext<CachedResult>, details) {
       const localCharacteristics = characteristics ?? context.characteristics;
 
       const ruleId = await hasher.hash(
@@ -945,6 +937,26 @@ export function fixedWindow<
         toAnalyzeRequest(details),
       );
 
+      const [cached, ttl] = await context.cache.get(ruleId, fingerprint);
+      if (cached && cached.reason.isRateLimit()) {
+        return new ArcjetRuleResult({
+          ruleId,
+          fingerprint,
+          ttl,
+          state: "CACHED",
+          conclusion: cached.conclusion,
+          // We rebuild the `ArcjetRateLimitReason` because we need to adjust
+          // the `reset` based on the current time-to-live
+          reason: new ArcjetRateLimitReason({
+            max: cached.reason.max,
+            remaining: cached.reason.remaining,
+            reset: ttl,
+            window: cached.reason.window,
+            resetTime: cached.reason.resetTime,
+          }),
+        });
+      }
+
       return new ArcjetRuleResult({
         ruleId,
         fingerprint,
@@ -1041,7 +1053,7 @@ export function slidingWindow<
     max,
     interval,
     validate() {},
-    async protect(context: ArcjetContext, details) {
+    async protect(context: ArcjetContext<CachedResult>, details) {
       const localCharacteristics = characteristics ?? context.characteristics;
 
       const ruleId = await hasher.hash(
@@ -1066,6 +1078,26 @@ export function slidingWindow<
         toAnalyzeRequest(details),
       );
 
+      const [cached, ttl] = await context.cache.get(ruleId, fingerprint);
+      if (cached && cached.reason.isRateLimit()) {
+        return new ArcjetRuleResult({
+          ruleId,
+          fingerprint,
+          ttl,
+          state: "CACHED",
+          conclusion: cached.conclusion,
+          // We rebuild the `ArcjetRateLimitReason` because we need to adjust
+          // the `reset` based on the current time-to-live
+          reason: new ArcjetRateLimitReason({
+            max: cached.reason.max,
+            remaining: cached.reason.remaining,
+            reset: ttl,
+            window: cached.reason.window,
+            resetTime: cached.reason.resetTime,
+          }),
+        });
+      }
+
       return new ArcjetRuleResult({
         ruleId,
         fingerprint,
@@ -1276,7 +1308,7 @@ export function sensitiveInfo<
     ): asserts details is ArcjetRequestDetails {},
 
     async protect(
-      context: ArcjetContext,
+      context: ArcjetContext<CachedResult>,
       details: ArcjetRequestDetails,
     ): Promise<ArcjetRuleResult> {
       const ruleId = await hasher.hash(
@@ -1289,6 +1321,10 @@ export function sensitiveInfo<
 
       const { fingerprint } = context;
 
+      // No cache is implemented here because the fingerprint can be the same
+      // while the request body changes. This is also why the `sensitiveInfo`
+      // rule results always have a `ttl` of 0.
+
       const body = await context.getBody();
       if (typeof body === "undefined") {
         return new ArcjetRuleResult({
@@ -1555,6 +1591,10 @@ export function validateEmail(
 
       const { fingerprint } = context;
 
+      // No cache is implemented here because the fingerprint can be the same
+      // while the email changes. This is also why the `email` rule results
+      // always have a `ttl` of 0.
+
       const result = await analyze.isValidEmail(context, email, config);
       const state = mode === "LIVE" ? "RUN" : "DRY_RUN";
       if (result.validity === "valid") {
@@ -1743,7 +1783,7 @@ export function detectBot(options: BotOptions): Primitive<{}> {
      * Attempts to call the bot detection on the headers.
      */
     async protect(
-      context: ArcjetContext,
+      context: ArcjetContext<CachedResult>,
       request: ArcjetRequestDetails,
     ): Promise<ArcjetRuleResult> {
       const ruleId = await hasher.hash(
@@ -1756,6 +1796,18 @@ export function detectBot(options: BotOptions): Primitive<{}> {
 
       const { fingerprint } = context;
 
+      const [cached, ttl] = await context.cache.get(ruleId, fingerprint);
+      if (cached) {
+        return new ArcjetRuleResult({
+          ruleId,
+          fingerprint,
+          ttl,
+          state: "CACHED",
+          conclusion: cached.conclusion,
+          reason: cached.reason,
+        });
+      }
+
       const result = await analyze.detectBot(
         context,
         toAnalyzeRequest(request),
@@ -1847,7 +1899,7 @@ export function shield(options: ShieldOptions): Primitive<{}> {
     priority: Priority.Shield,
     mode,
     validate() {},
-    async protect(context: ArcjetContext, details) {
+    async protect(context: ArcjetContext<CachedResult>, details) {
       // TODO(#1989): Prefer characteristics defined on rule once available
       const localCharacteristics = context.characteristics;
 
@@ -1868,6 +1920,18 @@ export function shield(options: ShieldOptions): Primitive<{}> {
         toAnalyzeRequest(details),
       );
 
+      const [cached, ttl] = await context.cache.get(ruleId, fingerprint);
+      if (cached) {
+        return new ArcjetRuleResult({
+          ruleId,
+          fingerprint,
+          ttl,
+          state: "CACHED",
+          conclusion: cached.conclusion,
+          reason: cached.reason,
+        });
+      }
+
       return new ArcjetRuleResult({
         ruleId,
         fingerprint,
@@ -2105,7 +2169,7 @@ export default function arcjet<
   // A local cache of block decisions. Might be emphemeral per request,
   // depending on the way the runtime works, but it's worth a try.
   // TODO(#132): Support configurable caching
-  const blockCache = new Cache<ArcjetReason>();
+  const cache = new MemoryCache<CachedResult>();
 
   const rootRules: ArcjetRule[] = rules
     .flat(1)
@@ -2184,6 +2248,7 @@ export default function arcjet<
 
     const context: ArcjetContext = Object.freeze({
       ...baseContext,
+      cache,
       fingerprint,
       runtime: rt,
     });
@@ -2243,40 +2308,6 @@ export default function arcjet<
 
     const logLocalPerf = perf.measure("local");
     try {
-      // We have our own local cache which we check first. This doesn't work in
-      // serverless environments where every request is isolated, but there may be
-      // some instances where the instance is not recycled immediately. If so, we
-      // can take advantage of that.
-      const logCachePerf = perf.measure("cache");
-      const existingBlockReason = blockCache.get(fingerprint);
-      logCachePerf();
-
-      // If already blocked then we can async log to the API and return the
-      // decision immediately.
-      if (existingBlockReason) {
-        const decision = new ArcjetDenyDecision({
-          ttl: blockCache.ttl(fingerprint),
-          reason: existingBlockReason,
-          // All results will be NOT_RUN because we used a cached decision
-          results,
-        });
-
-        client.report(context, details, decision, rules);
-
-        log.debug(
-          {
-            id: decision.id,
-            conclusion: decision.conclusion,
-            fingerprint,
-            reason: existingBlockReason,
-            runtime: rt,
-          },
-          "decide: already blocked",
-        );
-
-        return decision;
-      }
-
       for (const [idx, rule] of rules.entries()) {
         // This re-assignment is a workaround to a TypeScript error with
         // assertions where the name was introduced via a destructure
@@ -2343,13 +2374,15 @@ export default function arcjet<
           logRulePerf();
         }
 
-        if (results[idx].isDenied()) {
+        const result = results[idx];
+
+        if (result.isDenied()) {
           // If the rule is not a DRY_RUN, we want to cache non-zero TTL results
           // and return a DENY decision.
-          if (results[idx].state !== "DRY_RUN") {
+          if (result.state !== "DRY_RUN") {
             const decision = new ArcjetDenyDecision({
-              ttl: results[idx].ttl,
-              reason: results[idx].reason,
+              ttl: result.ttl,
+              reason: result.reason,
               results,
             });
 
@@ -2358,21 +2391,25 @@ export default function arcjet<
             // the request.
             client.report(context, details, decision, rules);
 
-            if (results[idx].ttl > 0) {
+            if (result.ttl > 0) {
               log.debug(
                 {
-                  fingerprint,
-                  conclusion: decision.conclusion,
-                  reason: decision.reason,
+                  fingerprint: result.fingerprint,
+                  conclusion: result.conclusion,
+                  reason: result.reason,
                 },
                 "Caching decision for %d seconds",
                 decision.ttl,
               );
 
-              blockCache.set(
-                fingerprint,
-                decision.reason,
-                nowInSeconds() + decision.ttl,
+              cache.set(
+                result.ruleId,
+                result.fingerprint,
+                {
+                  conclusion: result.conclusion,
+                  reason: result.reason,
+                },
+                result.ttl,
               );
             }
 
@@ -2405,11 +2442,20 @@ export default function arcjet<
       if (decision.isDenied() && decision.ttl > 0) {
         log.debug("decide: Caching block locally for %d seconds", decision.ttl);
 
-        blockCache.set(
-          fingerprint,
-          decision.reason,
-          nowInSeconds() + decision.ttl,
-        );
+        for (const result of decision.results) {
+          // Cache all DENY results for local cache lookups
+          if (result.conclusion === "DENY") {
+            cache.set(
+              result.ruleId,
+              result.fingerprint,
+              {
+                conclusion: result.conclusion,
+                reason: result.reason,
+              },
+              result.ttl,
+            );
+          }
+        }
       }
 
       return decision;
diff --git a/arcjet/package.json b/arcjet/package.json
index 7bda5ff909..61c4febaae 100644
--- a/arcjet/package.json
+++ b/arcjet/package.json
@@ -40,6 +40,7 @@
   },
   "dependencies": {
     "@arcjet/analyze": "1.0.0-beta.7",
+    "@arcjet/cache": "1.0.0-beta.7",
     "@arcjet/duration": "1.0.0-beta.7",
     "@arcjet/headers": "1.0.0-beta.7",
     "@arcjet/protocol": "1.0.0-beta.7",
diff --git a/arcjet/test/arcjet.test.ts b/arcjet/test/arcjet.test.ts
index 16d31d45b3..1effc4020b 100644
--- a/arcjet/test/arcjet.test.ts
+++ b/arcjet/test/arcjet.test.ts
@@ -22,6 +22,7 @@ import arcjet, {
   shield,
   sensitiveInfo,
   ArcjetSensitiveInfoReason,
+  ArcjetShieldReason,
 } from "../index";
 
 // Type helpers from https://github.com/sindresorhus/type-fest but adjusted for
@@ -89,6 +90,11 @@ expect.addEqualityTesters([areHeadersEqual]);
 
 class ArcjetTestReason extends ArcjetReason {}
 
+class TestCache {
+  get = mock.fn<() => Promise<[unknown, number]>>(async () => [undefined, 0]);
+  set = mock.fn();
+}
+
 function mockLogger() {
   return {
     time: mock.fn(),
@@ -403,6 +409,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -423,6 +430,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -447,6 +455,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -468,6 +477,67 @@ describe("Primitive > detectBot", () => {
     }).toThrow();
   });
 
+  test("uses cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetBotReason({
+          allowed: [],
+          denied: ["CURL"],
+          verified: false,
+          spoofed: false,
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = detectBot({
+      mode: "LIVE",
+      allow: [],
+    });
+    expect(rule.type).toEqual("BOT");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(1);
+    expect(cache.get.mock.calls[0].arguments).toEqual([
+      "84d7c3e132098fafcd8076e0d70154224336f5de91e23c1e538f203e5e46735f",
+      "test-fingerprint",
+    ]);
+    expect(result).toMatchObject({
+      ttl: 10,
+      state: "CACHED",
+      conclusion: "DENY",
+      reason: new ArcjetBotReason({
+        allowed: [],
+        denied: ["CURL"],
+        verified: false,
+        spoofed: false,
+      }),
+    });
+  });
+
   test("denies curl", async () => {
     const context = {
       key: "test-key",
@@ -475,6 +545,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -516,6 +587,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -557,6 +629,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const curlDetails = {
@@ -622,6 +695,7 @@ describe("Primitive > detectBot", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -880,6 +954,71 @@ describe("Primitive > tokenBucket", () => {
     expect(rule.type).toEqual("RATE_LIMIT");
     expect(rule).toHaveProperty("characteristics", undefined);
   });
+
+  test("uses cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetRateLimitReason({
+          max: 0,
+          remaining: 0,
+          // This will be updated by the rule based on TTL
+          reset: 100,
+          window: 1,
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+      requested: 1,
+    };
+
+    const [rule] = tokenBucket({
+      refillRate: 1,
+      interval: 1,
+      capacity: 1,
+    });
+    expect(rule.type).toEqual("RATE_LIMIT");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(1);
+    expect(cache.get.mock.calls[0].arguments).toEqual([
+      "da610f3767d3b939fe2769b489fba4a91da2ab7413184dbbbe6d12519abc1e7b",
+      "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
+    ]);
+    expect(result).toMatchObject({
+      ttl: 10,
+      state: "CACHED",
+      conclusion: "DENY",
+      reason: new ArcjetRateLimitReason({
+        max: 0,
+        remaining: 0,
+        // Updated by the rule based on TTL
+        reset: 10,
+        window: 1,
+      }),
+    });
+  });
 });
 
 describe("Primitive > fixedWindow", () => {
@@ -1034,6 +1173,69 @@ describe("Primitive > fixedWindow", () => {
     expect(rule.type).toEqual("RATE_LIMIT");
     expect(rule).toHaveProperty("characteristics", undefined);
   });
+
+  test("uses cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetRateLimitReason({
+          max: 0,
+          remaining: 0,
+          // This will be updated by the rule based on TTL
+          reset: 100,
+          window: 1,
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = fixedWindow({
+      max: 1,
+      window: 1,
+    });
+    expect(rule.type).toEqual("RATE_LIMIT");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(1);
+    expect(cache.get.mock.calls[0].arguments).toEqual([
+      "c60466a160b56b4cc129995377ef6fbbcacc67f90218920416ae8431a6fd499c",
+      "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
+    ]);
+    expect(result).toMatchObject({
+      ttl: 10,
+      state: "CACHED",
+      conclusion: "DENY",
+      reason: new ArcjetRateLimitReason({
+        max: 0,
+        remaining: 0,
+        // Updated by the rule based on TTL
+        reset: 10,
+        window: 1,
+      }),
+    });
+  });
 });
 
 describe("Primitive > slidingWindow", () => {
@@ -1188,6 +1390,69 @@ describe("Primitive > slidingWindow", () => {
     expect(rule.type).toEqual("RATE_LIMIT");
     expect(rule).toHaveProperty("characteristics", undefined);
   });
+
+  test("uses cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetRateLimitReason({
+          max: 0,
+          remaining: 0,
+          // This will be updated by the rule based on TTL
+          reset: 100,
+          window: 1,
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = slidingWindow({
+      max: 1,
+      interval: 1,
+    });
+    expect(rule.type).toEqual("RATE_LIMIT");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(1);
+    expect(cache.get.mock.calls[0].arguments).toEqual([
+      "64653030723222b3227a642239fbfae3bc53369d858b5ed1a31a2bb0438dacb1",
+      "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
+    ]);
+    expect(result).toMatchObject({
+      ttl: 10,
+      state: "CACHED",
+      conclusion: "DENY",
+      reason: new ArcjetRateLimitReason({
+        max: 0,
+        remaining: 0,
+        // Updated by the rule based on TTL
+        reset: 10,
+        window: 1,
+      }),
+    });
+  });
 });
 
 describe("Primitive > validateEmail", () => {
@@ -1375,6 +1640,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1395,6 +1661,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1415,6 +1682,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1449,6 +1717,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1483,6 +1752,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1517,6 +1787,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1551,6 +1822,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1588,6 +1860,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1622,6 +1895,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1656,6 +1930,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1694,6 +1969,7 @@ describe("Primitive > validateEmail", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1724,6 +2000,58 @@ describe("Primitive > validateEmail", () => {
       }),
     });
   });
+
+  // Email validation is dynamic so all TTL are zero
+  test("does not use cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetEmailReason({
+          emailTypes: ["INVALID"],
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+      email: "test@example.com",
+    };
+
+    const [rule] = validateEmail({
+      mode: "LIVE",
+      allow: [],
+    });
+    expect(rule.type).toEqual("EMAIL");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(0);
+    expect(result).toMatchObject({
+      state: "RUN",
+      conclusion: "ALLOW",
+      reason: new ArcjetEmailReason({
+        emailTypes: [],
+      }),
+    });
+  });
 });
 
 describe("Primitive > shield", () => {
@@ -1751,6 +2079,100 @@ describe("Primitive > shield", () => {
     expect(rule.type).toEqual("SHIELD");
     expect(rule).toHaveProperty("mode", "DRY_RUN");
   });
+
+  test("uses cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetShieldReason({
+          shieldTriggered: true,
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = shield({
+      mode: "LIVE",
+    });
+    expect(rule.type).toEqual("SHIELD");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(1);
+    expect(cache.get.mock.calls[0].arguments).toEqual([
+      "1a506ff95a8c2017894fcb6cc3be55053b144bd15666631945a5c453c477bd16",
+      "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
+    ]);
+    expect(result).toMatchObject({
+      ttl: 10,
+      state: "CACHED",
+      conclusion: "DENY",
+      reason: new ArcjetShieldReason({
+        shieldTriggered: true,
+      }),
+    });
+  });
+
+  test("does not run rule locally", async () => {
+    const cache = new TestCache();
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve(undefined),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = shield({
+      mode: "LIVE",
+    });
+    expect(rule.type).toEqual("SHIELD");
+    const result = await rule.protect(context, details);
+    expect(result).toMatchObject({
+      ttl: 0,
+      state: "NOT_RUN",
+      conclusion: "ALLOW",
+      reason: new ArcjetShieldReason({
+        shieldTriggered: false,
+      }),
+    });
+  });
 });
 
 describe("Primitive > sensitiveInfo", () => {
@@ -1866,6 +2288,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1893,6 +2316,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () =>
         Promise.resolve(
           "127.0.0.1 test@example.com 4242424242424242 +353 87 123 4567",
@@ -1954,6 +2378,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("none of this is sensitive"),
     };
     const details = {
@@ -1991,6 +2416,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () =>
         Promise.resolve(
           "127.0.0.1 test@example.com 4242424242424242 +353 87 123 4567",
@@ -2052,6 +2478,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () =>
         Promise.resolve(
           "127.0.0.1 test@example.com 4242424242424242 +353 87 123 4567",
@@ -2114,6 +2541,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("test@example.com +353 87 123 4567"),
     };
     const details = {
@@ -2162,6 +2590,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () =>
         Promise.resolve("127.0.0.1 test@example.com +353 87 123 4567"),
     };
@@ -2217,6 +2646,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("test@example.com +353 87 123 4567"),
     };
     const details = {
@@ -2266,6 +2696,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("this is bad"),
     };
     const details = {
@@ -2319,6 +2750,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("this is bad"),
     };
     const details = {
@@ -2361,6 +2793,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("my email is test@example.com"),
     };
     const details = {
@@ -2414,6 +2847,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve("my email is test@example.com"),
     };
     const details = {
@@ -2450,6 +2884,7 @@ describe("Primitive > sensitiveInfo", () => {
       runtime: "test",
       log: mockLogger(),
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -2475,6 +2910,59 @@ describe("Primitive > sensitiveInfo", () => {
     expect(decision.state).toEqual("NOT_RUN");
     expect(decision.conclusion).toEqual("ERROR");
   });
+
+  // Sensitive info detection is dynamic so all TTL are zero
+  test("does not use cache", async () => {
+    const cache = new TestCache();
+    mock.method(cache, "get", async () => [
+      {
+        conclusion: "DENY",
+        reason: new ArcjetSensitiveInfoReason({
+          allowed: [],
+          denied: [],
+        }),
+      },
+      10,
+    ]);
+    const context = {
+      key: "test-key",
+      fingerprint: "test-fingerprint",
+      runtime: "test",
+      log: mockLogger(),
+      characteristics: [],
+      cache,
+      getBody: () => Promise.resolve("nothing to detect"),
+    };
+    const details = {
+      ip: "172.100.1.1",
+      method: "GET",
+      protocol: "http",
+      host: "example.com",
+      path: "/",
+      headers: new Headers([["User-Agent", "curl/8.1.2"]]),
+      cookies: "",
+      query: "",
+      extra: {
+        "extra-test": "extra-test-value",
+      },
+    };
+
+    const [rule] = sensitiveInfo({
+      mode: "LIVE",
+      allow: [],
+    });
+    expect(rule.type).toEqual("SENSITIVE_INFO");
+    const result = await rule.protect(context, details);
+    expect(cache.get.mock.callCount()).toEqual(0);
+    expect(result).toMatchObject({
+      state: "RUN",
+      conclusion: "ALLOW",
+      reason: new ArcjetSensitiveInfoReason({
+        allowed: [],
+        denied: [],
+      }),
+    });
+  });
 });
 
 describe("Products > protectSignup", () => {
@@ -2540,6 +3028,40 @@ describe("SDK", () => {
       ),
     } as const;
   }
+  function testRuleLocalCached() {
+    const ruleId = "test-rule-id";
+    const fingerprint =
+      "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e";
+    return {
+      version: 0,
+      mode: "LIVE",
+      type: "TEST_RULE_LOCAL_CACHED",
+      priority: 1,
+      validate: mock.fn(),
+      protect: mock.fn(async (ctx) => {
+        const [result, ttl] = await ctx.cache.get(ruleId, ctx.fingerprint);
+        if (result) {
+          return new ArcjetRuleResult({
+            ruleId,
+            fingerprint,
+            ttl,
+            state: "CACHED",
+            conclusion: "DENY",
+            reason: new ArcjetTestReason(),
+          });
+        } else {
+          return new ArcjetRuleResult({
+            ruleId,
+            fingerprint,
+            ttl: 0,
+            state: "RUN",
+            conclusion: "ALLOW",
+            reason: new ArcjetTestReason(),
+          });
+        }
+      }),
+    } as const;
+  }
   function testRuleLocalIncorrect() {
     return {
       version: 0,
@@ -3812,9 +4334,19 @@ describe("SDK", () => {
     const client = {
       decide: mock.fn(async () => {
         return new ArcjetDenyDecision({
-          ttl: 5000,
+          ttl: 10,
           reason: new ArcjetTestReason(),
-          results: [],
+          results: [
+            new ArcjetRuleResult({
+              ruleId: "test-rule-id",
+              fingerprint:
+                "fp::2::516289fae7993d35ffb6e76883e09b475bbc7a622a378f3b430f35e8c657687e",
+              ttl: 10,
+              state: "RUN",
+              conclusion: "DENY",
+              reason: new ArcjetTestReason(),
+            }),
+          ],
         });
       }),
       report: mock.fn(),
@@ -3832,7 +4364,7 @@ describe("SDK", () => {
 
     const aj = arcjet({
       key: "test-key",
-      rules: [],
+      rules: [[testRuleLocalCached()]],
       client,
       log: mockLogger(),
     });
diff --git a/cache/.gitignore b/cache/.gitignore
new file mode 100644
index 0000000000..35b162da36
--- /dev/null
+++ b/cache/.gitignore
@@ -0,0 +1,135 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+.cache
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+# Generated files
+index.js
+index.d.ts
+test/*.js
diff --git a/cache/LICENSE b/cache/LICENSE
new file mode 100644
index 0000000000..261eeb9e9f
--- /dev/null
+++ b/cache/LICENSE
@@ -0,0 +1,201 @@
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
+
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/cache/README.md b/cache/README.md
new file mode 100644
index 0000000000..de2e02103b
--- /dev/null
+++ b/cache/README.md
@@ -0,0 +1,104 @@
+<a href="https://arcjet.com" target="_arcjet-home">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
+    <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
+  </picture>
+</a>
+
+# `@arcjet/cache`
+
+<p>
+  <a href="https://www.npmjs.com/package/@arcjet/cache">
+    <picture>
+      <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=000000&color=5C5866">
+      <img alt="npm badge" src="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=ECE6F0&color=ECE6F0">
+    </picture>
+  </a>
+</p>
+
+[Arcjet][arcjet] cache interface and implementations.
+
+## Installation
+
+```shell
+npm install -S @arcjet/cache
+```
+
+## API
+
+Caches implement the `Cache` interface over a specific type:
+
+```ts
+interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+```
+
+One such implementation is provided as `MemoryCache`:
+
+### `MemoryCache#constructor()`
+
+Instantiate the `MemoryCache` using `new MemoryCache()` without any arguments.
+
+### `MemoryCache#get(namespace: string, key: string): Promise<[T | undefined, number]>`
+
+Attempts to retrieve a value from the cache. If a value exists, it will be
+returned with the remaining time-to-live (in seconds).
+
+Non-string arguments will cause the returned promise to reject.
+
+### `MemoryCache#set(namespace: string, key: string, value: T, ttl: number): void`
+
+Makes a best attempt at storing the value provided until the time-to-live
+specified.
+
+Non-string arguments will cause the function to throw.
+
+## Example
+
+```ts
+import { MemoryCache } from "@arcjet/cache";
+
+const cache = new MemoryCache();
+
+const namespace = "myNamespace";
+const key = "myKey";
+
+const ttlSeconds = 60; // seconds
+
+const valueToCache = 1;
+
+cache.set(namespace, key, valueToCache, ttlSeconds);
+
+const [cachedValue, ttl] = await cache.get(namespace, key);
+if (cachedValue && ttl > 0) {
+  // do something with cached value
+}
+```
+
+## License
+
+Licensed under the [Apache License, Version 2.0][apache-license].
+
+[arcjet]: https://arcjet.com
+[apache-license]: http://www.apache.org/licenses/LICENSE-2.0
diff --git a/cache/eslint.config.js b/cache/eslint.config.js
new file mode 100644
index 0000000000..62fed470fc
--- /dev/null
+++ b/cache/eslint.config.js
@@ -0,0 +1,15 @@
+import base from "@arcjet/eslint-config";
+
+export default [
+  ...base,
+  {
+    ignores: [
+      ".turbo/",
+      "coverage/",
+      "node_modules/",
+      "**/*.d.ts",
+      "**/*.js",
+      "!*.config.js",
+    ],
+  },
+];
diff --git a/cache/index.ts b/cache/index.ts
new file mode 100644
index 0000000000..38b6db376d
--- /dev/null
+++ b/cache/index.ts
@@ -0,0 +1,99 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
+      return [this.data.get(key), ttl];
+    } else {
+      // Cleanup if expired
+      this.expires.delete(key);
+      this.data.delete(key);
+
+      return [undefined, 0];
+    }
+  }
+
+  set(key: string, value: T, ttl: number) {
+    const expiresAt = nowInSeconds() + ttl;
+    this.expires.set(key, expiresAt);
+    this.data.set(key, value);
+  }
+}
+
+export class MemoryCache<T> implements Cache<T> {
+  namespaces: Map<string, Bucket<T>>;
+
+  constructor() {
+    this.namespaces = new Map();
+  }
+
+  async get(namespace: string, key: string): Promise<[T | undefined, number]> {
+    if (typeof namespace !== "string") {
+      throw new Error("`namespace` must be a string");
+    }
+
+    if (typeof key !== "string") {
+      throw new Error("`key` must be a string");
+    }
+
+    const namespaceCache = this.namespaces.get(namespace);
+
+    if (typeof namespaceCache === "undefined") {
+      return [undefined, 0];
+    }
+
+    return namespaceCache.get(key);
+  }
+
+  set(namespace: string, key: string, value: T, ttl: number) {
+    if (typeof namespace !== "string") {
+      throw new Error("`namespace` must be a string");
+    }
+
+    if (typeof key !== "string") {
+      throw new Error("`key` must be a string");
+    }
+
+    const namespaceCache = this.namespaces.get(namespace) ?? new Bucket();
+    namespaceCache.set(key, value, ttl);
+    this.namespaces.set(namespace, namespaceCache);
+  }
+}
diff --git a/cache/package.json b/cache/package.json
new file mode 100644
index 0000000000..34d3342fb0
--- /dev/null
+++ b/cache/package.json
@@ -0,0 +1,55 @@
+{
+  "name": "@arcjet/cache",
+  "version": "1.0.0-beta.7",
+  "description": "Arcjet cache interface and implementations",
+  "license": "Apache-2.0",
+  "homepage": "https://arcjet.com",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/arcjet/arcjet-js.git",
+    "directory": "cache"
+  },
+  "bugs": {
+    "url": "https://github.com/arcjet/arcjet-js/issues",
+    "email": "support@arcjet.com"
+  },
+  "author": {
+    "name": "Arcjet",
+    "email": "support@arcjet.com",
+    "url": "https://arcjet.com"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "type": "module",
+  "main": "./index.js",
+  "types": "./index.d.ts",
+  "files": [
+    "LICENSE",
+    "README.md",
+    "*.js",
+    "*.d.ts",
+    "!*.config.js"
+  ],
+  "scripts": {
+    "prepublishOnly": "npm run build",
+    "build": "rollup --config rollup.config.js",
+    "lint": "eslint .",
+    "pretest": "npm run build",
+    "test": "node --test --experimental-test-coverage"
+  },
+  "dependencies": {},
+  "devDependencies": {
+    "@arcjet/eslint-config": "1.0.0-beta.7",
+    "@arcjet/rollup-config": "1.0.0-beta.7",
+    "@arcjet/tsconfig": "1.0.0-beta.7",
+    "@rollup/wasm-node": "4.40.1",
+    "eslint": "9.26.0",
+    "expect": "29.7.0",
+    "typescript": "5.8.3"
+  },
+  "publishConfig": {
+    "access": "public",
+    "tag": "latest"
+  }
+}
diff --git a/cache/rollup.config.js b/cache/rollup.config.js
new file mode 100644
index 0000000000..79177f236d
--- /dev/null
+++ b/cache/rollup.config.js
@@ -0,0 +1,3 @@
+import { createConfig } from "@arcjet/rollup-config";
+
+export default createConfig(import.meta.url);
diff --git a/cache/test/memory.test.ts b/cache/test/memory.test.ts
new file mode 100644
index 0000000000..4d6de8539c
--- /dev/null
+++ b/cache/test/memory.test.ts
@@ -0,0 +1,95 @@
+import { describe, test } from "node:test";
+import { expect } from "expect";
+import { MemoryCache } from "../index.js";
+
+describe("MemoryCache", () => {
+  const cache = new MemoryCache();
+
+  test("MemoryCache#set", () => {
+    // No non-string namespaces
+    expect(() => {
+      cache.set(
+        // @ts-expect-error
+        1234,
+        "test-key",
+        "test-value",
+        10,
+      );
+    }).toThrow();
+    // No non-string keys
+    expect(() => {
+      cache.set(
+        "test-namespace",
+        // @ts-expect-error
+        1234,
+        "test-value",
+        10,
+      );
+    }).toThrow();
+
+    // Empty string namespace
+    cache.set("", "test-key", "test-value", 10);
+    expect(cache.namespaces.size).toEqual(1);
+    // Empty string key
+    cache.set("test-namespace", "", "test-value", 10);
+    expect(cache.namespaces.size).toEqual(2);
+
+    // New namespace
+    cache.set("test-namespace", "test-key", "test-value", 10);
+    expect(cache.namespaces.size).toEqual(2);
+    // Re-used namespace
+    cache.set("test-namespace", "test-key-2", "test-value-2", 10);
+    expect(cache.namespaces.size).toEqual(2);
+    // Another new namespace
+    cache.set("test-namespace-2", "test-key", "test-value", 10);
+    expect(cache.namespaces.size).toEqual(3);
+    // Zero TTL (for get tests)
+    cache.set("test-namespace-2", "test-key-2", "test-value-2", 0);
+    expect(cache.namespaces.size).toEqual(3);
+  });
+
+  test("MemoryCache#get", async () => {
+    // No non-string namespaces
+    await expect(
+      cache.get(
+        // @ts-expect-error
+        1234,
+        "test-key",
+      ),
+    ).rejects.toBeInstanceOf(Error);
+    // No non-string keys
+    await expect(
+      cache.get(
+        "test-namespace",
+        // @ts-expect-error
+        1234,
+      ),
+    ).rejects.toBeInstanceOf(Error);
+
+    // Empty string namespace
+    expect(await cache.get("", "test-key")).toEqual(["test-value", 10]);
+    // Empty string key
+    expect(await cache.get("test-namespace", "")).toEqual(["test-value", 10]);
+
+    // Nothing for missing namespaces
+    expect(await cache.get("test-namespace-3", "test-key")).toEqual([
+      undefined,
+      0,
+    ]);
+    // Nothing for missing key
+    expect(await cache.get("test-namespace", "test-key-3")).toEqual([
+      undefined,
+      0,
+    ]);
+    // Value if exists and alive
+    expect(await cache.get("test-namespace", "test-key")).toEqual([
+      "test-value",
+      10,
+    ]);
+    // Nothing if value is expired
+    expect(await cache.get("test-namespace-2", "test-key-2")).toEqual([
+      undefined,
+      0,
+    ]);
+  });
+});
diff --git a/cache/tsconfig.json b/cache/tsconfig.json
new file mode 100644
index 0000000000..7150d9377f
--- /dev/null
+++ b/cache/tsconfig.json
@@ -0,0 +1,4 @@
+{
+  "extends": "@arcjet/tsconfig/base",
+  "include": ["index.ts"]
+}
diff --git a/package-lock.json b/package-lock.json
index 71a3defd2c..26398c1dcf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -64,6 +64,7 @@
       "license": "Apache-2.0",
       "dependencies": {
         "@arcjet/analyze": "1.0.0-beta.7",
+        "@arcjet/cache": "1.0.0-beta.7",
         "@arcjet/duration": "1.0.0-beta.7",
         "@arcjet/headers": "1.0.0-beta.7",
         "@arcjet/protocol": "1.0.0-beta.7",
@@ -332,6 +333,23 @@
         "node": ">=18"
       }
     },
+    "cache": {
+      "name": "@arcjet/cache",
+      "version": "1.0.0-beta.7",
+      "license": "Apache-2.0",
+      "devDependencies": {
+        "@arcjet/eslint-config": "1.0.0-beta.7",
+        "@arcjet/rollup-config": "1.0.0-beta.7",
+        "@arcjet/tsconfig": "1.0.0-beta.7",
+        "@rollup/wasm-node": "4.40.1",
+        "eslint": "9.26.0",
+        "expect": "29.7.0",
+        "typescript": "5.8.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
     "decorate": {
       "name": "@arcjet/decorate",
       "version": "1.0.0-beta.7",
@@ -529,6 +547,10 @@
       "resolved": "arcjet-bun",
       "link": true
     },
+    "node_modules/@arcjet/cache": {
+      "resolved": "cache",
+      "link": true
+    },
     "node_modules/@arcjet/decorate": {
       "resolved": "decorate",
       "link": true
@@ -10268,6 +10290,7 @@
       "version": "1.0.0-beta.7",
       "license": "Apache-2.0",
       "dependencies": {
+        "@arcjet/cache": "1.0.0-beta.7",
         "@bufbuild/protobuf": "1.10.1",
         "@connectrpc/connect": "1.6.1",
         "typeid-js": "1.2.0"
diff --git a/protocol/index.ts b/protocol/index.ts
index 47b5061c3e..cd14fc5f79 100644
--- a/protocol/index.ts
+++ b/protocol/index.ts
@@ -1,3 +1,4 @@
+import type { Cache } from "@arcjet/cache";
 import { typeid } from "typeid-js";
 import { Reason } from "./proto/decide/v1alpha1/decide_pb.js";
 
@@ -889,13 +890,14 @@ export interface ArcjetLogger {
   error(obj: Record<string, unknown>, msg?: string, ...args: unknown[]): void;
 }
 
-export type ArcjetContext = {
+export type ArcjetContext<T = unknown> = {
   [key: string]: unknown;
   key: string;
   fingerprint: string;
   runtime: string;
   log: ArcjetLogger;
   characteristics: string[];
+  cache: Cache<T>;
   getBody: () => Promise<string | undefined>;
   waitUntil?: (promise: Promise<unknown>) => void;
 };
diff --git a/protocol/package.json b/protocol/package.json
index d84538e4d2..248f519c3c 100644
--- a/protocol/package.json
+++ b/protocol/package.json
@@ -40,6 +40,7 @@
     "test": "node --test --experimental-test-coverage"
   },
   "dependencies": {
+    "@arcjet/cache": "1.0.0-beta.7",
     "@bufbuild/protobuf": "1.10.1",
     "@connectrpc/connect": "1.6.1",
     "typeid-js": "1.2.0"
diff --git a/protocol/test/client.test.ts b/protocol/test/client.test.ts
index b24e6b2f33..84a6b5fa55 100644
--- a/protocol/test/client.test.ts
+++ b/protocol/test/client.test.ts
@@ -1,5 +1,6 @@
 import { describe, mock, test } from "node:test";
 import { expect } from "expect";
+import type { Cache } from "@arcjet/cache";
 import { createClient } from "../client.js";
 import { createRouterTransport } from "@connectrpc/connect";
 import { DecideService } from "../proto/decide/v1alpha1/decide_connect.js";
@@ -53,6 +54,13 @@ class ArcjetInvalidDecision extends ArcjetDecision {
   }
 }
 
+class TestCache implements Cache {
+  async get(): Promise<[unknown, number]> {
+    return [undefined, 0];
+  }
+  set() {}
+}
+
 describe("createClient", () => {
   const log = {
     debug() {},
@@ -100,6 +108,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -158,6 +167,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -217,6 +227,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -274,6 +285,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -332,6 +344,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -398,6 +411,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -444,6 +458,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -489,6 +504,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -534,6 +550,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -582,6 +599,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -636,6 +654,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -684,6 +703,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
       waitUntil: mock.fn((promise: Promise<unknown>) => {
         promise.then(() => resolve());
@@ -736,6 +756,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -805,6 +826,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -873,6 +895,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -948,6 +971,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1016,6 +1040,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1080,6 +1105,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1176,6 +1202,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: [],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1224,6 +1251,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: ["src.ip"],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {
@@ -1283,6 +1311,7 @@ describe("createClient", () => {
       runtime: "test",
       log,
       characteristics: ["ip.src"],
+      cache: new TestCache(),
       getBody: () => Promise.resolve(undefined),
     };
     const details = {


Reviews:
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-23T23:43:14Z
user: login: davidmytton
body: Confirmed functional - working as expected.

Tagging @arcjet-rei to take a look at the code.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T14:37:31Z
user: login: qw-in
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-27T19:32:49Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T19:54:35Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:00:06Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:01:04Z
user: login: arcjet-rei
body: The changed caching logic makes sense to me and, as far as I understand how queries and results work, the approach and code look sound, as do the new tests. That all LGTM.

I'd like to see the README for `cache` get that Usage section added; all the other questions are nonblocking, although I would like to hear your thoughts on the name of the component because changing it later will be a moderate pain.
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:02:27Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:13:29Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:15:28Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:22:01Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:22:09Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:30:13Z
user: login: arcjet-rei
body: I agree that an end user should never be getting an error they can't do anything about, which would imply that SDK code using the cache would need to put any calls that could throw in `try / catch` blocks. This can have performarnce implications, but unless I'm missing something, the SDK is not doing enough volume of calls to the API that the relatively minor deoptimization / performance cost of adding `try / catch` would be a significant impact.

To expand a little on my initial comment, to me it seems a best practice to raise an exception when the API contract is violated, because that is a circumstance that should never happen in the ordinary use of the code. Especially if the interface of the cache is not accessible to end users of the SDK, I prefer to have invariants enforced by clear signals that the contract has been violated and that some part of the runtime may be in an error or undefined state.

In this case, that could also be achieved by defining a type guard on the get / set methods to ensure that the namespaces and other values that must be set are so marked in the types.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:36:11Z
user: login: arcjet-rei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:39:27Z
user: login: arcjet-rei
body: That makes sense to me. Given that this is existing logic, it's running in an unthreaded / non-concurrent access context, and the cache is not the ultimate source of truth on decisions, I'm fine with us using our own implementation, but I reserve the right to raise this issue again if any of those conditions change.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:42:41Z
user: login: arcjet-rei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T20:58:44Z
user: login: arcjet-rei
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T21:01:19Z
user: login: blaine-arcjet
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T21:08:28Z
user: login: arcjet-rei
body: Perfect! Expect the conversation about errors and exceptions to come up a few more times, because my experiences with writing instrumentation (and dealing with error handling and error tracing within the Node runtime) have left me with strong preferences for how errors and exceptions are classified and handled in embedded libraries.

For now, though, LGTM! ⚓ 🆙 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T22:12:42Z

Review comments:
path: arcjet/test/arcjet.test.ts
user: login: blaine-arcjet
body: I *think* I'd be able to write some tests for rate limit rule caching by mocking an RPC response but I need to double check.
created_at: 2025-05-23T23:43:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+{
+  "name": "@arcjet/cache",
+  "version": "1.0.0-beta.7",
+  "description": "Arcjet cache interface and implementations",
+  "license": "Apache-2.0",
+  "homepage": "https://arcjet.com",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/arcjet/arcjet-js.git",
+    "directory": "cache"
+  },
+  "bugs": {
+    "url": "https://github.com/arcjet/arcjet-js/issues",
+    "email": "support@arcjet.com"
+  },
+  "author": {
+    "name": "Arcjet",
+    "email": "support@arcjet.com",
+    "url": "https://arcjet.com"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "type": "module",
+  "main": "./index.js",
+  "types": "./index.d.ts",
path: cache/package.json
user: login: qw-in
body: Is it worth adding ["exports"](https://docs.npmjs.com/cli/v11/configuring-npm/package-json#exports)? Especially given the non-root import `@arcjet/cache/memory`?
created_at: 2025-05-26T14:49:09Z
author_association: MEMBER
diff_hunk: @@ -40,6 +40,7 @@
     "test": "node --test --experimental-test-coverage"
   },
   "dependencies": {
+    "@arcjet/cache": "1.0.0-beta.7",
path: protocol/package.json
user: login: qw-in
body: This feels odd, it seems as though otherwise types flow from `protocol` up

If `protocol` is just for rpc bits then maybe it doesn't need to care about caching?
created_at: 2025-05-26T14:54:50Z
author_association: MEMBER
diff_hunk: @@ -12,6 +12,7 @@
   "arcjet-remix": "1.0.0-beta.7",
   "arcjet-sveltekit": "1.0.0-beta.7",
   "body": "1.0.0-beta.7",
+  "cache": "1.0.0-beta.7",
path: .github/.release-please-manifest.json
user: login: arcjet-rei
body: Asking in all ignorance: is it a good idea to have even an internal package with a name as generic as `cache`? Put another way, are we only ever going to have one kind of cache, and / or are we going to have the same semantics for caching everywhere in the code base?
created_at: 2025-05-27T19:11:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
path: cache/index.ts
user: login: arcjet-rei
body: Did you evaluate any of the existing TTL cache implementations? There are a few interesting edge cases around TTL expiration that I've run into over the years, and while this looks sensible to me, if we don't have to cover / test all the edge cases ourselves, that's a win for us.
created_at: 2025-05-27T19:48:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
+      return [this.data.get(key), ttl];
+    } else {
+      // Cleanup if expired
+      this.expires.delete(key);
+      this.data.delete(key);
+
+      return [undefined, 0];
+    }
+  }
+
+  set(key: string, value: T, ttl: number) {
+    const expiresAt = nowInSeconds() + ttl;
+    this.expires.set(key, expiresAt);
+    this.data.set(key, value);
+  }
+}
+
+export class MemoryCache<T> implements Cache<T> {
+  namespaces: Map<string, Bucket<T>>;
+
+  constructor() {
+    this.namespaces = new Map();
+  }
+
+  async get(namespace: string, key: string): Promise<[T | undefined, number]> {
+    // Empty namespaces are not supported
path: cache/index.ts
user: login: arcjet-rei
body: If empty namespaces and keys are "not supported" – which I am taking to mean that is not part of the supported interface of the component – should we not raise an exception in this case? It would simplify writing tests downstream if we can disinguish between the absence of a value and an incorrect use of the component's interface.
created_at: 2025-05-27T19:51:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+<a href="https://arcjet.com" target="_arcjet-home">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
+    <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
+  </picture>
+</a>
+
+# `@arcjet/cache`
+
+<p>
+  <a href="https://www.npmjs.com/package/@arcjet/cache">
+    <picture>
+      <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=000000&color=5C5866">
+      <img alt="npm badge" src="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=ECE6F0&color=ECE6F0">
+    </picture>
+  </a>
+</p>
+
+[Arcjet][arcjet] cache interface and implementations.
+
+## Installation
+
+```shell
+npm install -S @arcjet/cache
+```
+
path: cache/README.md
user: login: arcjet-rei
body: Please add a basic usage section here, even if it's just a straight copy of the documentation comments from `index.ts`?
created_at: 2025-05-27T19:53:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+{
+  "name": "@arcjet/cache",
+  "version": "1.0.0-beta.7",
+  "description": "Arcjet cache interface and implementations",
+  "license": "Apache-2.0",
+  "homepage": "https://arcjet.com",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/arcjet/arcjet-js.git",
+    "directory": "cache"
+  },
+  "bugs": {
+    "url": "https://github.com/arcjet/arcjet-js/issues",
+    "email": "support@arcjet.com"
+  },
+  "author": {
+    "name": "Arcjet",
+    "email": "support@arcjet.com",
+    "url": "https://arcjet.com"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "type": "module",
+  "main": "./index.js",
+  "types": "./index.d.ts",
path: cache/package.json
user: login: blaine-arcjet
body: >  Especially given the non-root import `@arcjet/cache/memory`?

I think I forgot to change this somewhere because I decided to remove it. Just sticking to the `index.js` file right now so we don't need exports.
created_at: 2025-05-27T19:54:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -40,6 +40,7 @@
     "test": "node --test --experimental-test-coverage"
   },
   "dependencies": {
+    "@arcjet/cache": "1.0.0-beta.7",
path: protocol/package.json
user: login: blaine-arcjet
body: Unfortunately there's an interdependency of sorts in that Rules must be sent on the RPC which means their definition + translation must be defined in `@arcjet/protocol`. Since the `validate` and `protect` functions are defined as taking context, we need to pull in the cache layer for types. We also can't specify it as a devDep otherwise the Cache type would become an `any` in production installs.

We also don't want to make Context generic over all of Cache because then anything could be provided for the generic and TypeScript wouldn't catch it.

A final option would be to use peerDeps; however, we don't want to use peerDeps for Arcjet packages because we plan to bundle all dependencies into 1 file once we reach "zero deps" so that installing Arcjet adds exactly 1 entry to users' lockfile.
created_at: 2025-05-27T20:00:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,55 @@
+{
+  "name": "@arcjet/cache",
+  "version": "1.0.0-beta.7",
+  "description": "Arcjet cache interface and implementations",
+  "license": "Apache-2.0",
+  "homepage": "https://arcjet.com",
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/arcjet/arcjet-js.git",
+    "directory": "cache"
+  },
+  "bugs": {
+    "url": "https://github.com/arcjet/arcjet-js/issues",
+    "email": "support@arcjet.com"
+  },
+  "author": {
+    "name": "Arcjet",
+    "email": "support@arcjet.com",
+    "url": "https://arcjet.com"
+  },
+  "engines": {
+    "node": ">=18"
+  },
+  "type": "module",
+  "main": "./index.js",
+  "types": "./index.d.ts",
path: cache/package.json
user: login: blaine-arcjet
body: Found it in the README and resolved. Good catch.
created_at: 2025-05-27T20:01:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,7 @@
   "arcjet-remix": "1.0.0-beta.7",
   "arcjet-sveltekit": "1.0.0-beta.7",
   "body": "1.0.0-beta.7",
+  "cache": "1.0.0-beta.7",
path: .github/.release-please-manifest.json
user: login: blaine-arcjet
body: We've gone through a few iterations of key-value interfaces for caching, with this being informed by every iteration. 

Putting key-value pairs behind a namespace gives us the flexibility to segment the cache for different functionality if needed.

Potential problems would be:
- `get` returns a promise so it forces consumers to be async
- `set` doesn't allow acknowledgement, since it is "best attempt"

If we need to support some of the above in the future, the interface can be tweaked or we could export a different interface.
created_at: 2025-05-27T20:13:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
path: cache/index.ts
user: login: blaine-arcjet
body: This is the same TTL stuff we've been using. We specify the TTL via our rules and caching is just an optimization to lower response times. A lot of user's don't actually get to use the cache because they are in serverless environments where the client/cache are discarded.
created_at: 2025-05-27T20:15:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+<a href="https://arcjet.com" target="_arcjet-home">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
+    <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
+  </picture>
+</a>
+
+# `@arcjet/cache`
+
+<p>
+  <a href="https://www.npmjs.com/package/@arcjet/cache">
+    <picture>
+      <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=000000&color=5C5866">
+      <img alt="npm badge" src="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=ECE6F0&color=ECE6F0">
+    </picture>
+  </a>
+</p>
+
+[Arcjet][arcjet] cache interface and implementations.
+
+## Installation
+
+```shell
+npm install -S @arcjet/cache
+```
+
path: cache/README.md
user: login: blaine-arcjet
body: I think you mean an `API` section, is that correct? I usually think of the `Usage` section the same as the `Example` section here.
created_at: 2025-05-27T20:22:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
+      return [this.data.get(key), ttl];
+    } else {
+      // Cleanup if expired
+      this.expires.delete(key);
+      this.data.delete(key);
+
+      return [undefined, 0];
+    }
+  }
+
+  set(key: string, value: T, ttl: number) {
+    const expiresAt = nowInSeconds() + ttl;
+    this.expires.set(key, expiresAt);
+    this.data.set(key, value);
+  }
+}
+
+export class MemoryCache<T> implements Cache<T> {
+  namespaces: Map<string, Bucket<T>>;
+
+  constructor() {
+    this.namespaces = new Map();
+  }
+
+  async get(namespace: string, key: string): Promise<[T | undefined, number]> {
+    // Empty namespaces are not supported
path: cache/index.ts
user: login: blaine-arcjet
body: I'm not set on *not* throwing here, but let me explain my thinking so we can decide.

Generally, we don't want to throw inside the Arcjet SDK because those get converted into "Errored" results, which users *should* check, but usually don't.

The cache mechanism is passed to all rules, which can be written by end-users. So if an end-user tries to use the `cache` incorrectly by passing an empty string and we throw an error, that doesn't proceed and always produces an "Errored" result. If instead, we just return "no value, ttl 0" (as I did here), then they will just never get a cache value, but their rule logic will still run.

Given the above, do you think we should throw?
created_at: 2025-05-27T20:22:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+<a href="https://arcjet.com" target="_arcjet-home">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
+    <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
+  </picture>
+</a>
+
+# `@arcjet/cache`
+
+<p>
+  <a href="https://www.npmjs.com/package/@arcjet/cache">
+    <picture>
+      <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=000000&color=5C5866">
+      <img alt="npm badge" src="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=ECE6F0&color=ECE6F0">
+    </picture>
+  </a>
+</p>
+
+[Arcjet][arcjet] cache interface and implementations.
+
+## Installation
+
+```shell
+npm install -S @arcjet/cache
+```
+
path: cache/README.md
user: login: blaine-arcjet
body: Added an API section. Please give is a review 🙏 
created_at: 2025-05-27T20:30:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,54 @@
+<a href="https://arcjet.com" target="_arcjet-home">
+  <picture>
+    <source media="(prefers-color-scheme: dark)" srcset="https://arcjet.com/logo/arcjet-dark-lockup-voyage-horizontal.svg">
+    <img src="https://arcjet.com/logo/arcjet-light-lockup-voyage-horizontal.svg" alt="Arcjet Logo" height="128" width="auto">
+  </picture>
+</a>
+
+# `@arcjet/cache`
+
+<p>
+  <a href="https://www.npmjs.com/package/@arcjet/cache">
+    <picture>
+      <source media="(prefers-color-scheme: dark)" srcset="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=000000&color=5C5866">
+      <img alt="npm badge" src="https://img.shields.io/npm/v/%40arcjet%2Fcache?style=flat-square&label=%E2%9C%A6Aj&labelColor=ECE6F0&color=ECE6F0">
+    </picture>
+  </a>
+</p>
+
+[Arcjet][arcjet] cache interface and implementations.
+
+## Installation
+
+```shell
+npm install -S @arcjet/cache
+```
+
path: cache/README.md
user: login: arcjet-rei
body: Perfect! LGTM
created_at: 2025-05-27T20:39:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,103 @@
+export interface Cache<T = unknown> {
+  /**
+   * Attempts to retrieve a value from the cache. If a value exists, it will be
+   * returned with the remaining time-to-live (in seconds).
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to retrieve the value.
+   * @returns A promise for a 2-element tuple containing the value and TTL in
+   * seconds. If no value is retrieved, the value will be `undefined` and the
+   * TTL will be `0`.
+   */
+  get(namespace: string, key: string): Promise<[T | undefined, number]>;
+  /**
+   * If the cache implementation supports storing values, `set` makes a best
+   * attempt at storing the value provided until the time-to-live specified.
+   *
+   * @param namespace A isolated segement of the cache where keys are tracked.
+   * @param key The identifier used to store the value.
+   * @param value The value to be stored under the key.
+   * @param ttl The amount of seconds the value stays valid in the cache.
+   */
+  set(namespace: string, key: string, value: T, ttl: number): void;
+}
+
+function nowInSeconds(): number {
+  return Math.floor(Date.now() / 1000);
+}
+
+class Bucket<T> {
+  expires: Map<string, number>;
+  data: Map<string, T>;
+
+  constructor() {
+    this.expires = new Map();
+    this.data = new Map();
+  }
+
+  get(key: string): [T | undefined, number] {
+    const now = nowInSeconds();
+    const expiresAt = this.expires.get(key) ?? now;
+    const ttl = expiresAt - now;
+
+    if (ttl > 0) {
+      return [this.data.get(key), ttl];
+    } else {
+      // Cleanup if expired
+      this.expires.delete(key);
+      this.data.delete(key);
+
+      return [undefined, 0];
+    }
+  }
+
+  set(key: string, value: T, ttl: number) {
+    const expiresAt = nowInSeconds() + ttl;
+    this.expires.set(key, expiresAt);
+    this.data.set(key, value);
+  }
+}
+
+export class MemoryCache<T> implements Cache<T> {
+  namespaces: Map<string, Bucket<T>>;
+
+  constructor() {
+    this.namespaces = new Map();
+  }
+
+  async get(namespace: string, key: string): Promise<[T | undefined, number]> {
+    // Empty namespaces are not supported
path: cache/index.ts
user: login: arcjet-rei
body: BTW, my reply to this showed up as a standalone concept because I'm still getting my head around how the GitHub PR extension for VSCode works. Sorry for breaking threading!
created_at: 2025-05-27T20:58:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,7 @@
   "arcjet-remix": "1.0.0-beta.7",
   "arcjet-sveltekit": "1.0.0-beta.7",
   "body": "1.0.0-beta.7",
+  "cache": "1.0.0-beta.7",
path: .github/.release-please-manifest.json
user: login: arcjet-rei
body: I guess my question is, do we want to name this TTLCache or do something else to indicate what _kind_ of cache it is? This is not a blocking question, but I've seen how abstractions proliferate as code bases evolve, and we could be setting ourselves up to have a bunch of different abstractions clustered in a single package or having to rename things later on. This is not a blocker and we shouldn't spend much more time discussing it, but naming things _and_ caching are two of the hardest problems in computer science, so having them combined into a single issue made it worth asking the question.
created_at: 2025-05-27T21:01:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -12,6 +12,7 @@
   "arcjet-remix": "1.0.0-beta.7",
   "arcjet-sveltekit": "1.0.0-beta.7",
   "body": "1.0.0-beta.7",
+  "cache": "1.0.0-beta.7",
path: .github/.release-please-manifest.json
user: login: blaine-arcjet
body: Good question. My current thinking is that this package is a central location for all cache stuff. This just includes the `Cache` interface and the `MemoryCache` right now, but it'll also include a caches backed by Vercel Edge Config or Redis in the future.

That could make the package large, but I think having a central place to look for caching strategies makes sense.
created_at: 2025-05-27T21:08:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-05-21T00:04:34Z
body: 😎 Merged successfully - [details](https://app.trunk.io/gh/arcjet/arcjet-js/merge-queue/main/4191).
author_association: NONE
user: login: blaine-arcjet
created_at: 2025-05-22T23:26:26Z
body: This will get rebased on top of #4204 
author_association: CONTRIBUTOR
user: login: socket-security[bot]
created_at: 2025-05-23T23:43:19Z
body: **Review the following changes in direct dependencies.** Learn more about [Socket for GitHub](https://socket.dev?utm_medium=gh).

<table>
<thead>
<tr>
<th>Diff</th>
<th width="200px">Package</th>
<th align="center" width="100px">Supply Chain<br/>Security</th>
<th align="center" width="100px">Vulnerability</th>
<th align="center" width="100px">Quality</th>
<th align="center" width="100px">Maintenance</th>
<th align="center" width="100px">License</th>
</tr>
</thead>
<tbody>
<tr><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/diff-added.svg" title="Added" alt="Added" width="20" height="20"></a></td><td><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172">@​arcjet/​cache@​1.0.0-beta.7</a></td><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Supply Chain Security" width="40" height="40" alt="100"></a></td><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Vulnerability" width="40" height="40" alt="100"></a></td><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Quality" width="40" height="40" alt="100"></a></td><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/score-100.svg" title="Maintenance" width="40" height="40" alt="100"></a></td><td align="center"><a href="https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies&dependency_item_key=33742709172"><img src="https://github-app-statics.socket.dev/score-100.svg" title="License" width="40" height="40" alt="100"></a></td></tr>
</tbody>
</table>

[View full report](https://socket.dev/dashboard/org/arcjet/diff/cfb493e4-81e8-4e7d-b36b-86ec47c36c44/688160fb-69e9-4270-99ae-746175442938?tab=dependencies)

<!-- overview-comment -->
author_association: NONE
user: login: developedbynick
created_at: 2025-05-25T16:15:58Z
body: Been waiting on this pr to be merged. Any updates??
author_association: NONE
user: login: davidmytton
created_at: 2025-05-25T16:30:16Z
body: We’re almost there @developedbynick ! We’re aiming for the next week or so.
author_association: CONTRIBUTOR
user: login: blaine-arcjet
created_at: 2025-05-27T20:45:07Z
body: > using the cache would need to put any calls that could throw in try / catch blocks

We already try/catch around rule execution, which is where my explanation of "Errored" results comes from. We need to do everything in our power to not produce errors for users (see below).

>  prefer to have invariants enforced by clear signals that the contract has been violated and that some part of the runtime may be in an error or undefined state.

I think I'd rather just accept empty strings than calling these invariants and throwing. This is a footgun for users but I really don't want to throw in this code.

> In this case, that could also be achieved by defining a type guard on the get / set methods to ensure that the namespaces and other values that must be set are so marked in the types.

As I've documented in internal documentation, we need to treat usage of TypeScript as raw JavaScript so we can't rely exclusively on a TypeScript type to ensure anything here.
author_association: CONTRIBUTOR
user: login: arcjet-rei
created_at: 2025-05-27T20:57:38Z
body: > > In this case, that could also be achieved by defining a type guard on the get / set methods to ensure that the namespaces and other values that must be set are so marked in the types.
> 
> As I've documented in internal documentation, we need to treat usage of TypeScript as raw JavaScript so we can't rely exclusively on a TypeScript type to ensure anything here.

This is a good requirement for code that is user-facing, but users do not access the `MemoryCache`  instances directly themselves, correct?

The distinction I'm drawing here is between actual exceptions and runtime errors. For runtime errors, as embedded code writers, we should be following [the Robustness Principle](https://deviq.com/laws/postels-law) (which is more or less what the existing documents stipulate).  I'm arguing that the the "must nots" in the code comments should be treated as preconditions / invariants in the design-by-contract sense, and that violations of those invariants should be treated as actual exceptions, which is to say situations in which the system is in an inconsistent state. I want to ensure that we catch all possible instances of this during development, using whatever means are available to us to enforce our API contracts. I don't want this to bubble up to end users. As long as we're confident in the strategies we're using to achieve that goal, I am agnostic as to what strategies we choose to use.

To me, putting type guards on components that are used internally is one way to do that, because, as per the guidelines you've written, end users are running transpiled "raw" JS.
author_association: CONTRIBUTOR
user: login: blaine-arcjet
created_at: 2025-05-27T21:02:27Z
body: > This is a good requirement for code that is user-facing, but users do not access the MemoryCache instances directly themselves, correct?

They can, if they [Write a Custom Rule](https://docs.arcjet.com/blueprints/defining-custom-rules).

> The distinction I'm drawing here is between actual exceptions and runtime errors.

This is a good distinction. With the implementation I ended up using for the namespaced MemoryCache, there's nothing actually stopping users from using empty namespaces or keys (it's just another value in a `Map`) so I've removed that restriction and instead just throw if non-strings are provided, which are also encoded in TypeScript.
author_association: CONTRIBUTOR
user: login: developedbynick
created_at: 2025-05-27T22:31:41Z
body: Thanks Guys. I screamed out "yes" when I saw the merge😂😂
author_association: NONE
user: login: davidmytton
created_at: 2025-05-28T06:36:59Z
body: @developedbynick expect an SDK release by the end of the week 🚀
author_association: CONTRIBUTOR
user: login: developedbynick
created_at: 2025-05-29T03:44:04Z
body: Thanks David!

author_association: NONE