Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 52 additions & 52 deletions packages/cachekit-core-ts/index.js

Large diffs are not rendered by default.

76 changes: 73 additions & 3 deletions packages/cachekit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,9 +136,58 @@ const cache = createCache({
baseDelay: 100,
},
},

// Serializer DoS-protection limits — see "Value size limits" below.
serializer: {
maxEncodedSize: 1024 * 1024, // 1 MiB default
maxDecodedSize: 10 * 1024 * 1024, // 10 MiB default
},

// ByteStorage envelope (LZ4 + integrity). Defaults to true, unless the
// backend advertises compression off because its store already
// compresses at rest (the Workers Cache API backend does).
compression: true,
});
```

### Value size limits — the 1 MiB default is a cache-off switch, not a suggestion

`set()` (and every `wrap()` store) rejects values whose encoded size exceeds
`serializer.maxEncodedSize` — **1 MiB by default** — with `ValueTooLargeError`.
Two things make this rejection easy to miss in production:

- **Graceful degradation is on by default**, and it treats a failed `set()`
as "skip silently" — the call resolves normally.
- Consumers that guard `set()` with try/catch (correctly — cache failures
shouldn't fail requests) absorb the error the same way.

Either way the result is a cache that quietly never stores exactly its
largest — often hottest — values, while small values keep caching fine. If
your responses can exceed 1 MiB (large API payloads, reports, aggregates),
raise both limits up front:

```typescript
const cache = createCache.minimal({
backend: workersCacheAPI(),
serializer: {
maxEncodedSize: 64 * 1024 * 1024,
maxDecodedSize: 64 * 1024 * 1024, // must cover reads of what you write
},
});
```

The SDK also reports every size rejection through its
[pluggable logger](#observability) as a rate-limited, greppable
`[cachekit] set rejected, value NOT cached (keyHash=...)` line — watch for it
after deploying a new cache. The line carries a non-reversible blake2b digest
of the cache key rather than the key itself (keys are caller-controlled and
may embed sensitive data); to match a digest to a suspect key, hash the key
with blake2b (16-byte output, hex). This is the same digest the File backend
uses as its on-disk filename, so on that backend a logged `keyHash` names the
entry's cache file directly. (Backends have their own hard ceilings too:
Workers KV values cap at 25 MiB, Memcached items at 1 MiB server-side,
CachekitIO per plan.)

## Stampede Protection

A cold cache key hit by N concurrent callers would normally execute the wrapped
Expand Down Expand Up @@ -196,6 +245,17 @@ Four backends implement the same `Backend` interface (raw bytes in/out) and plug

The Memcached and File backends are **Node-runtime only** and live behind subpath exports, so browser/edge bundles that import the package root never pull in `memjs` or `node:fs`.

**L1 freshness across processes.** When a plain `get()` hits L2, the value is
re-populated into this process's L1. On backends that surface the entry's
remaining TTL in the same read (`Backend.getWithTtl` — Redis via a pipelined
`GET`+`TTL`, the Workers Cache API via response freshness headers), that L1
copy is capped at the entry's **remaining lifetime**, so it can never outlive
the L2 entry it came from. On backends without the capability (KV, CachekitIO,
Memcached, File, custom backends that only implement `get`), the L1 copy is
bounded by `defaultTtl` — if you rely on TTLs for correctness across processes
there, set a small `defaultTtl`, disable L1 (`l1: { enabled: false }`), or
implement `getWithTtl` on your custom backend.

### Memcached

```typescript
Expand Down Expand Up @@ -394,9 +454,17 @@ export default {

Beyond CachekitIO, two Cloudflare-native backends keep cache state in the
edge itself — no round-trip to api.cachekit.io. Both store the same opaque
ByteStorage payloads as every other backend, so encryption and the wire
envelope are unchanged (secure caches store only ciphertext), and both plug
into `createCache` or any intent via `backend:`:
payloads as every other backend, so encryption is unchanged (secure caches
store only ciphertext), and both plug into `createCache` or any intent via
`backend:`. One default differs: the Cache API backend advertises the
ByteStorage compression envelope **off** — Cloudflare already stores
`Response` bodies compressed at rest, so the wasm envelope would spend
isolate CPU compressing twice. Pass `compression: true` to re-enable it
(e.g. to shrink bodies below a size limit before storage). Reads are
envelope-tolerant either way: a compression-off cache detects, verifies,
and unwraps entries that an earlier version (or a compression-on peer in a
mixed fleet) stored with the envelope, so upgrades and gradual rollouts
never serve envelope bytes as values:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```typescript
import { createCache, workersKV, workersCacheAPI } from '@cachekit-io/cachekit/workers';
Expand Down Expand Up @@ -425,6 +493,8 @@ TTL and consistency semantics differ from Redis/CachekitIO — pick by workload:
| TTL | Native `expirationTtl`; **60s minimum** — shorter TTLs are clamped up, never down | `Cache-Control: max-age`, honored to the second, no floor |
| `ttl <= 0` ("no expiry") | Stored without expiration | Capped at 1-year max-age (the Cache API has no unbounded storage) |
| Eviction | Durable until expiry | Best-effort — entries may be dropped under cache pressure |
| Compression default | On (ByteStorage envelope) | **Off** — Cloudflare stores bodies compressed at rest |
| L1 freshness on read | Bounded by `defaultTtl` (KV reads don't surface remaining TTL) | Capped at the entry's **remaining TTL** (from `max-age` − `Age`) |
| Best for | Shared config, sessions, rarely-written hot reads | Request-local acceleration in front of a shared source |

The Cache API is request-keyed under the hood; the backend maps each cache
Expand Down
84 changes: 84 additions & 0 deletions packages/cachekit/scripts/check-workers-bundle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,87 @@ if (violations.length > 0) {
console.log(
'bundle guard OK: workers entry graph is free of node:*, .node, NAPI, ioredis, prom-client'
);

// ── Type-closure guard (LAB-1388) ──────────────────────────────────────────
// Same invariant at the type level: build a program over the workers entry's
// published declaration closure and fail if any Node-typed declarations get
// pulled in (ioredis, prom-client, @types/node). On 0.1.5, a nominal
// `import type { Redis } from 'ioredis'` in the shared type module forced
// every Workers consumer without @types/node into skipLibCheck over dozens
// of `Cannot find name 'Buffer'` errors inside ioredis/built/**.
//
// The detection is a file scan of the program's resolved sources, NOT just
// diagnostics: in THIS repo @types/node is installed, so a leaked ioredis
// reference typechecks clean here while still breaking consumers that don't
// have it. Diagnostics are checked too (with skipLibCheck off) so the
// closure is also proven self-consistent against ES+DOM libs alone.
let ts;
try {
ts = (await import('typescript')).default;
} catch (error) {
console.error('type-closure guard: failed to load the typescript compiler (devDependency)');
console.error(error?.message ?? error);
process.exit(1);
}

const typesEntry = join(pkgDir, 'dist', 'workers', 'index.d.ts');
const compilerOptions = {
noEmit: true,
strict: true,
skipLibCheck: false,
types: [],
// ES lib only, plus DOM for the fetch/Response/caches types the Workers
// backends reference structurally. Deliberately NO Node lib/types.
lib: ['lib.es2022.d.ts', 'lib.dom.d.ts', 'lib.dom.iterable.d.ts'],
module: ts.ModuleKind.NodeNext,
moduleResolution: ts.ModuleResolutionKind.NodeNext,
};

const program = ts.createProgram([typesEntry], compilerOptions);

const FORBIDDEN_TYPE_SOURCES = [
{ name: 'ioredis declarations', filter: /node_modules\/ioredis\// },
{ name: 'prom-client declarations', filter: /node_modules\/prom-client\// },
{ name: '@types/node', filter: /node_modules\/@types\/node\// },
];

const typeViolations = [];
for (const sourceFile of program.getSourceFiles()) {
const fileName = sourceFile.fileName.replace(/\\/g, '/');
for (const { name, filter } of FORBIDDEN_TYPE_SOURCES) {
if (filter.test(fileName)) {
typeViolations.push(`${name}: ${fileName}`);
}
}
}

if (typeViolations.length > 0) {
console.error('type-closure guard: Node-typed declarations reached the workers .d.ts closure:');
for (const violation of typeViolations.slice(0, 10)) {
console.error(` - ${violation}`);
}
if (typeViolations.length > 10) {
console.error(` ... and ${typeViolations.length - 10} more`);
}
process.exit(1);
}

const diagnostics = ts.getPreEmitDiagnostics(program);
if (diagnostics.length > 0) {
console.error('type-closure guard: workers .d.ts closure fails without Node types:');
console.error(
ts.formatDiagnosticsWithColorAndContext(diagnostics.slice(0, 20), {
getCanonicalFileName: (f) => f,
getCurrentDirectory: () => pkgDir,
getNewLine: () => '\n',
})
);
if (diagnostics.length > 20) {
console.error(` ... and ${diagnostics.length - 20} more`);
}
process.exit(1);
}

console.log(
'type-closure guard OK: workers .d.ts closure is free of ioredis / prom-client / @types/node'
);
6 changes: 2 additions & 4 deletions packages/cachekit/src/backends/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@ import { constants as fsConstants } from 'node:fs';
import fs, { type FileHandle } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { blake2b } from '@noble/hashes/blake2.js';
import { bytesToHex } from '@noble/hashes/utils.js';
import { blake2b16Hex } from '../serialization/key-generator.js';
import { Backend, FileBackendConfig, TTLBackend } from './types.js';
import { BackendError } from '../errors.js';

Expand Down Expand Up @@ -262,8 +261,7 @@ export class FileBackend implements Backend, TTLBackend {
* unset: key identity is preserved, nothing is prefixed on the wire).
*/
private keyToPath(key: string): string {
const hash = bytesToHex(blake2b(new TextEncoder().encode(key), { dkLen: 16 }));
return path.join(this.config.cacheDir, hash);
return path.join(this.config.cacheDir, blake2b16Hex(key));
}

/**
Expand Down
66 changes: 65 additions & 1 deletion packages/cachekit/src/backends/redis.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto';
import { Redis as IoRedis, type RedisOptions } from 'ioredis';
import { LockableBackend, RedisBackendConfig, TTLBackend } from './types.js';
import { GetWithTtlResult, LockableBackend, RedisBackendConfig, TTLBackend } from './types.js';
import type { RedisPubSubLike } from '../types/cache.js';
import { BackendError, TimeoutError } from '../errors.js';
import { logError } from '../logger.js';
import {
Expand All @@ -23,6 +24,18 @@ else
return 0
end`;

// Compile-time proof that a real ioredis client satisfies the structural
// RedisPubSubLike that replaced the nominal ioredis type in
// InvalidationConfig (LAB-1388). Anchored here because this is the one
// type-checked src module that already imports ioredis (tests are excluded
// from `tsc --noEmit`; this module is Node-closure-only, so the ioredis
// types never reach the workers type surface). Type-only — erased at
// runtime; if the structural type ever drifts incompatible, `pnpm
// type-check` fails here instead of in every consumer's build.
type AssertPubSubCompatible<T extends RedisPubSubLike> = T;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _IoRedisIsPubSubCompatible = AssertPubSubCompatible<IoRedis>;

/**
* Redis backend implementation using ioredis.
*
Expand Down Expand Up @@ -115,6 +128,57 @@ export class RedisBackend implements LockableBackend, TTLBackend {
}
}

/** One-shot latch for reporting a failing TTL pipeline leg (LAB-1388). */
private ttlLegFailureLogged = false;

/**
* See {@link Backend.getWithTtl}: GET + TTL pipelined onto one round trip
* (LAB-1388), so CacheImpl can cap L1 re-population at the entry's
* remaining lifetime without a second network hop.
*
* TTL result mapping: -1 (no expiry) → null; 0 (sub-second remainder) and
* -2 (expired between the pipelined GET and TTL — pipelines aren't atomic)
* → 0, which the caller's `> 0` gate turns into "don't re-populate L1".
* Collapsing those to null instead would hand a dying entry the full
* default-TTL L1 lifetime — the exact bug this capability fixes.
*
* A TTL-command failure downgrades to "unknown" rather than failing a
* read whose GET succeeded — but it is reported once through the library
* logger, because a persistently failing TTL leg (e.g. a Redis-compatible
* proxy without TTL) silently reverts L1 bounding to defaultTtl.
*/
async getWithTtl(key: string): Promise<GetWithTtlResult | null> {
this.ensureNotClosed();

try {
const results = await this.client.pipeline().getBuffer(key).ttl(key).exec();
if (!results || results.length !== 2) {
throw new Error('pipeline returned no results');
}
const [getErr, buf] = results[0] as [Error | null, Buffer | null];
if (getErr) throw getErr;
if (buf === null) return null;
const [ttlErr, ttl] = results[1] as [Error | null, number];
let ttlSeconds: number | null;
if (ttlErr || typeof ttl !== 'number') {
ttlSeconds = null;
if (!this.ttlLegFailureLogged) {
this.ttlLegFailureLogged = true;
logError(
'[cachekit] Redis getWithTtl: TTL pipeline leg failed — L1 re-population ' +
'falls back to the defaultTtl bound (reported once)',
ttlErr ?? undefined
);
}
} else {
ttlSeconds = ttl === -1 ? null : Math.max(0, ttl);
}
return { value: new Uint8Array(buf), ttlSeconds };
} catch (error) {
throw this.wrapError('get', error);
}
}

async set(key: string, value: Uint8Array, ttl?: number): Promise<void> {
this.ensureNotClosed();

Expand Down
43 changes: 43 additions & 0 deletions packages/cachekit/src/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,26 @@ export interface Backend {
*/
get(key: string): Promise<Uint8Array | null>;

/**
* Retrieve a value together with its remaining TTL — in the SAME storage
* round trip as a plain get (LAB-1388). Optional capability: implement it
* only when the store surfaces the expiry on read for free (Cache API
* response headers, Redis GET+TTL pipeline). Do NOT implement it as
* get + a second TTL request — CacheImpl calls this on every L2 hit when
* L1 is enabled, and a second round trip there doubles read latency (and
* on metered backends, cost).
*
* CacheImpl uses the returned `ttlSeconds` to cap L1 re-population at the
* entry's remaining lifetime, so an L1 copy never outlives the L2 entry it
* was read from. Backends without this capability fall back to `get()`,
* where L1 re-population is bounded only by the cache's default TTL.
*
* @returns The stored bytes plus remaining TTL in seconds (`null` TTL =
* unknown or no expiry), or `null` when the key is missing
* @throws {BackendError} if the operation fails
*/
getWithTtl?(key: string): Promise<GetWithTtlResult | null>;

/**
* Store a value with optional TTL.
*
Expand Down Expand Up @@ -98,6 +118,29 @@ export interface Backend {
* Like `keyPrefix`, the value MUST be constant from construction onward.
*/
readonly transformsKeys?: boolean;

/**
* The backend's preferred default for the ByteStorage compression
* envelope (LAB-1388). Left unset, the cache-level default stays `true`.
* Backends whose store already compresses values at rest (the Cloudflare
* Cache API stores `Response` bodies compressed) advertise `false` here so
* the default configuration doesn't spend CPU compressing twice. An
* explicit `compression:` option on the cache always wins.
*
* Like `keyPrefix`, the value MUST be constant from construction onward.
*/
readonly compressionDefault?: boolean;
}

/** Result of {@link Backend.getWithTtl}: the bytes plus remaining lifetime. */
export interface GetWithTtlResult {
/** The stored bytes. */
value: Uint8Array;
/**
* Remaining TTL in seconds, or `null` when unknown / no expiry (matches
* {@link TTLBackend.getTTL}'s collapse of Redis's -1).
*/
ttlSeconds: number | null;
}

/**
Expand Down
Loading
Loading