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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"*.{ts,tsx,js,jsx}": ["eslint"]
},
"resolutions": {
"@nimpl/cache-adapter>@nimpl/cache": "workspace:*"
"@nimpl/cache-adapter>@nimpl/cache": "workspace:*",
"@nimpl/cache-server>@nimpl/cache": "workspace:*"
},
"license": "MIT",
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a"
Expand Down
8 changes: 8 additions & 0 deletions packages/cache-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,14 @@ Stores a cache entry.
- `400 Bad Request`: Missing key or metadata
- `500 Internal Server Error`: Server error

### `PUT /?key=<key>`

Updates cache lifetimes for a single key using the handler’s `updateKey` method.

- **Query Parameters:**
- `key` (required): The cache key to update
- **Body:** JSON object with optional `durations` object (e.g. `{ "durations": { "expire": 60 } }`)

### `PUT /`

Updates tags for cache entries.
Expand Down
9 changes: 5 additions & 4 deletions packages/cache-server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export const init = (cacheHandler: CacheHandlerRoot, options?: Pick<CacheServerO
revalidate: entry.revalidate,
};

res.statusCode = 200;
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("x-cache-metadata", JSON.stringify(metadata));
const [cacheStream, responseStream] = entry.value.tee();
Expand Down Expand Up @@ -113,7 +112,6 @@ export const init = (cacheHandler: CacheHandlerRoot, options?: Pick<CacheServerO

await cacheHandler.set(key, entry);

res.statusCode = 200;
return res.end();
} catch (error) {
res.statusCode = 500;
Expand All @@ -130,14 +128,18 @@ export const init = (cacheHandler: CacheHandlerRoot, options?: Pick<CacheServerO
}
const { tags, durations } = JSON.parse(body);

if (key) {
await cacheHandler.updateKey(key, durations);
return res.end();
}

if (!Array.isArray(tags)) {
res.statusCode = 400;
return res.end();
}

await cacheHandler.updateTags(tags, durations);

res.statusCode = 200;
return res.end();
} catch (error) {
res.statusCode = 500;
Expand All @@ -154,7 +156,6 @@ export const init = (cacheHandler: CacheHandlerRoot, options?: Pick<CacheServerO

try {
await cacheHandler.delete(key);
res.statusCode = 200;
return res.end();
} catch (error) {
res.statusCode = 500;
Expand Down
25 changes: 23 additions & 2 deletions packages/cache-tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,15 @@ pnpm add @nimpl/cache-tools

## Usage

### Init a cache tools
### Init cache tools

```ts
// cache-tools.ts
import { cacheHandler } from "@/cache-handler";
import { createCache, createHelpers } from "@nimpl/cache-tools";

export const { cache } = createCache(cacheHandler);
export const { getKeys, getKeyDetails, getCacheData } =
export const { getKeys, getKeyDetails, getCacheData, updateTags, updateKey } =
createHelpers(cacheHandler);
```

Expand Down Expand Up @@ -84,6 +85,26 @@ export const GET = async (

Use `getCacheData` as the single entry point for the [widget](https://www.npmjs.com/package/@nimpl/cache-widget).

### Invalidate cache entries

`createHelpers` also exposes helpers for updating cache lifetimes through your own tooling or admin routes:

- `updateTags(tags: string[], duration: number)` – updates all entries that contain **any** of the provided tags. Internally it calls `cacheHandler.updateTags(tags, { expire: duration })`, resetting `stale` and `revalidate` and extending `expire` based on your handler implementation.
- `updateKey(key: string, duration: number)` – updates a **single cache key** via `cacheHandler.updateKey(key, { expire: duration })`. This is useful when you know the exact key you want to revalidate/extend without touching other entries.

Example Next.js route that exposes both helpers:

```ts
// app/api/cache-admin/route.ts
import { updateTags, updateKey } from "@/cache-tools";

export async function POST(req: Request) {
const body = await req.json();
if (body.tags) await updateTags(body.tags, body.duration ?? 0);
return new Response(null, { status: 204 });
}
```

## Examples

- **[Base Example](https://github.com/alexdln/nimpl-cache/tree/main/examples/base-handler)** - Minimal Next.js example demonstrating filesystem cache handler and cache widget
Expand Down
10 changes: 10 additions & 0 deletions packages/cache-tools/src/create-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,21 @@ export const getCacheData = (cacheHandler: CacheHandler, segments?: string[]) =>
return getKeyDetails(cacheHandler, type, segments[1]);
};

export const updateTags = async (cacheHandler: CacheHandler, tags: string[], duration: number) => {
return cacheHandler.updateTags(tags, { expire: duration });
};

export const updateKey = async (cacheHandler: CacheHandler, key: string, duration: number) => {
return cacheHandler.updateKey(key, { expire: duration });
};

export const createHelpers = (cacheHandler: CacheHandler) => {
return {
getKeys: (type: "main" | "persistent" | "ephemeral") => getKeys(cacheHandler, type),
getKeyDetails: (type: "main" | "persistent" | "ephemeral", key: string) =>
getKeyDetails(cacheHandler, type, key),
getCacheData: (segments?: string[]) => getCacheData(cacheHandler, segments),
updateTags: (tags: string[], duration: number) => updateTags(cacheHandler, tags, duration),
updateKey: (key: string, duration: number) => updateKey(cacheHandler, key, duration),
};
};
2 changes: 2 additions & 0 deletions packages/cache-tools/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type CacheHandler = {
get: (key: string) => Promise<Entry | undefined | null>;
set: (key: string, value: Promise<Entry>) => Promise<void>;
keys: () => Promise<KeysData>;
updateTags: (tags: string[], durations?: { expire?: number }) => Promise<void>;
updateKey: (key: string, durations?: { expire?: number }) => Promise<void>;
ephemeralLayer: {
getEntry: (key: string) => Promise<CacheEntry | undefined | null>;
get: (key: string) => Promise<Entry | undefined | null>;
Expand Down
6 changes: 4 additions & 2 deletions packages/cache/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ The fetch layer communicates with a cache server that implements the following H

- `GET /?key=...` - Retrieve cache entry (returns stream with `x-cache-metadata` header)
- `POST /?key=...` - Store cache entry (expects stream body and `x-cache-metadata` header)
- `PUT /` - Update tags (expects JSON body with `tags` and `durations`)
- `PUT /` - Update tags for matching entries (expects JSON body with `tags` and optional `durations`)
- `PUT /?key=...` - Update a single cache key (`updateKey`, expects JSON body with optional `durations`)
- `DELETE /?key=...` - Delete cache entry
- `GET /keys` - Get all cache keys (returns JSON array)
- `GET /readiness` - Health check (returns ok status)
Expand Down Expand Up @@ -263,7 +264,7 @@ These methods are improved versions of the default Next.js `cacheHandler` for be

- `get(key: string)`: Promise<Entry | undefined | null> - Retrieves a cache entry. Returns `undefined` if not found, `null` if expired.
- `set(key: string, pendingEntry: Promise<Entry> | Entry)`: Promise<void> - Stores a cache entry.
- `updateTags(tags: string[], durations?: Durations)`: Promise<void> - Updates tags for cache entries, used for cache invalidation.
- `updateTags(tags: string[], durations?: Durations)`: Promise<void> - Updates cache entries by tags, used for cache invalidation.
- `refreshTags()`: Promise<void> - Refreshes tags from persistent storage.
- `getExpiration()`: Promise<number> - Returns the expiration time for cache entries.

Expand All @@ -278,6 +279,7 @@ These methods are critically necessary and can be reused from any other implemen
- `checkIsReady()`: Promise<boolean> - Checks if the layer is ready to serve requests. Useful for health checks and infrastructure tools.
- `keys()`: Promise<string[]> - Returns all cache keys. Useful for tools and different infrastructures.
- `delete(key: string)`: Promise<void> - Deletes a cache entry. Useful for tools and manual cache management.
- `updateKey(key: string, durations?: Durations)`: Promise<void> - Updates cache entry by key, used for cache invalidation.

#### Core Handlers

Expand Down
9 changes: 8 additions & 1 deletion packages/cache/src/cache-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class CacheHandler implements CacheHandlerRoot {
}

private logOperation(
type: "GET" | "SET" | "UPDATE_TAGS",
type: "GET" | "SET" | "UPDATE_TAGS" | "UPDATE_KEY",
status: LogData["status"],
source: LogData["source"],
key: string,
Expand Down Expand Up @@ -156,6 +156,13 @@ export class CacheHandler implements CacheHandlerRoot {
return Infinity;
}

async updateKey(key: string, durations?: Durations) {
this.logOperation("UPDATE_KEY", "REVALIDATING", "EPHEMERAL", key);
await this.ephemeralLayer.updateKey(key, durations);
this.logOperation("UPDATE_KEY", "REVALIDATING", "PERSISTENT", key);
await this.persistentLayer.updateKey(key, durations);
}

async updateTags(tags: string[], durations?: Durations) {
const tagsKey = tags.join(",");
if (!tags.length) {
Expand Down
17 changes: 17 additions & 0 deletions packages/cache/src/layers/fetch-layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,23 @@ export class FetchLayer implements CacheHandlerLayer {
}
}

async updateKey(key: string, durations?: Durations) {
const result = await this.fetchFn(`${this.baseUrl}/?key=${encodeURIComponent(key)}`, {
method: "PUT",
body: JSON.stringify({ durations }),
});

if (!result.ok) {
this.logger({
type: "UPDATE_KEY",
status: "ERROR",
source: "FETCH",
key,
message: result.statusText || "Failed to update key",
});
}
}

async updateTags(tags: string[], durations?: Durations) {
const result = await this.fetchFn(`${this.baseUrl}/`, {
method: "PUT",
Expand Down
27 changes: 27 additions & 0 deletions packages/cache/src/layers/fs-layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,33 @@ export class FsLayer implements CacheHandlerLayer {
}
}

async updateKey(key: string, durations?: Durations) {
await this.ensureBaseDir();
const metaPath = this.getFilePath(getCacheKeys(key, "").metaKey);
try {
const content = await readFile(metaPath, "utf8");
const metadata: Metadata = JSON.parse(content);
const updated = getUpdatedMetadata(
metadata,
metadata.tags,
durations,
performance.timeOrigin + performance.now(),
);

if (updated === metadata) return;

await writeFile(metaPath, JSON.stringify(updated), "utf8");
} catch (error) {
this.logger({
type: "UPDATE_KEY",
status: "ERROR",
source: "FS",
key,
message: error instanceof Error ? error.message : "Failed to update key in filesystem cache",
});
}
}

async updateTags(tags: string[], durations?: Durations) {
await this.ensureBaseDir();
const metaPrefix = encodeURIComponent(PREFIX_META);
Expand Down
15 changes: 15 additions & 0 deletions packages/cache/src/layers/lru-layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ export class LruLayer implements CacheHandlerLayer {
this.lruClient.delete(key);
}

async updateKey(key: string, durations?: Durations) {
const now = performance.timeOrigin + performance.now();
const cacheEntry = this.lruClient.get(key);

if (!cacheEntry) return;

const { entry, size, status } = cacheEntry;
const updatedMetadata = getUpdatedMetadata(entry, entry.tags, durations, now);

if (updatedMetadata === entry) return;

const updatedEntry: Entry = { ...entry, ...updatedMetadata };
this.lruClient.set(key, { entry: updatedEntry, size, status });
}

async updateTags(tags: string[], durations?: Durations) {
const now = performance.timeOrigin + performance.now();
this.lruClient.forEach((value, key) => {
Expand Down
18 changes: 18 additions & 0 deletions packages/cache/src/layers/redis-layer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,24 @@ export class RedisLayer implements CacheHandlerLayer {
}
}

async updateKey(key: string, durations?: Durations) {
const connected = await this.connect();
if (!connected) return;

const { metaKey } = getCacheKeys(key, this.keyPrefix);
const metaEntry = await this.redisClient.get(metaKey);
if (!metaEntry) return;
const metadata: Metadata = JSON.parse(metaEntry);
const updated = getUpdatedMetadata(
metadata,
metadata.tags,
durations,
performance.timeOrigin + performance.now(),
);
if (updated === metadata) return;
await this.redisClient.set(metaKey, JSON.stringify(updated), "EX", updated.expire);
}

async updateTags(tags: string[], durations?: Durations) {
const connected = await this.connect();
if (!connected) return;
Expand Down
3 changes: 2 additions & 1 deletion packages/cache/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export type CacheEntry = {
};

export type LogData = {
type: "GET" | "SET" | "UPDATE_TAGS" | "CONNECTION";
type: "GET" | "SET" | "UPDATE_TAGS" | "UPDATE_KEY" | "CONNECTION";
status:
| "HIT"
| "MISS"
Expand Down Expand Up @@ -56,6 +56,7 @@ export interface CacheHandlerLayer {
get(key: string): Promise<Entry | undefined | null>;
set(key: string, pendingEntry: Promise<Entry> | Entry): Promise<void>;
delete(key: string): Promise<void>;
updateKey(key: string, durations?: Durations): Promise<void>;
updateTags(tags: string[], durations?: Durations): Promise<void>;
checkIsReady(): Promise<boolean>;
keys(): Promise<string[]>;
Expand Down
16 changes: 3 additions & 13 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions tests/cache/unit/cache-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,30 @@ describe("CacheHandler", () => {
});
});

describe("updateKey", () => {
it("should delegate updateKey to both layers", async () => {
const now = performance.timeOrigin + performance.now();
const stream = Readable.toWeb(Readable.from("test"));
const entry: Entry = {
tags: ["tag1"],
timestamp: now,
stale: 0,
expire: 10,
revalidate: 5,
value: stream,
};

const spyEphemeral = jest.spyOn(handler.ephemeralLayer, "updateKey");
const spyPersistent = jest.spyOn(handler.persistentLayer, "updateKey");

await handler.set("test-key", Promise.resolve(entry));
await handler.updateKey("test-key", { expire: 20 });

expect(spyEphemeral).toHaveBeenCalledWith("test-key", { expire: 20 });
expect(spyPersistent).toHaveBeenCalledWith("test-key", { expire: 20 });
});
});

describe("getExpiration", () => {
it("should return Infinity", async () => {
const expiration = await handler.getExpiration();
Expand Down
Loading