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
162 changes: 92 additions & 70 deletions bun.lock

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions packages/cache/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# `@lowerdeck/cache`

Multi-layer caching with Redis and in-memory LRU cache. Supports tag-based invalidation, dynamic TTL, and local-only caching for optimal performance.

## Installation

```bash
npm install @lowerdeck/cache
yarn add @lowerdeck/cache
bun add @lowerdeck/cache
pnpm add @lowerdeck/cache
```

## Usage

### Redis-backed cache

```typescript
import { createCachedFunction } from '@lowerdeck/cache';

const getUserProfile = createCachedFunction({
name: 'user-profile',
redisUrl: 'redis://localhost:6379',
getHash: (userId: string) => userId,
provider: async (userId, { setTTL }) => {
const user = await database.getUser(userId);

// Dynamically adjust TTL based on result
if (user.isPremium) {
setTTL(3600); // 1 hour for premium users
}

return user;
},
ttlSeconds: 300, // default 5 minutes
getTags: (user, userId) => [`user:${userId}`, `role:${user.role}`]
});

// Fetch user (checks in-memory cache -> Redis -> provider)
const user = await getUserProfile('123');

// Clear specific user cache
await getUserProfile.clear('123');

// Clear all users with a specific role
await getUserProfile.clearByTag('role:admin');

// Wait for cache clear to complete
await getUserProfile.clearAndWait('123');
await getUserProfile.clearByTagAndWait('role:admin');
```

### Local-only cache

```typescript
import { createLocallyCachedFunction } from '@lowerdeck/cache';

const computeHash = createLocallyCachedFunction({
getHash: (input: string) => input,
provider: async (input) => {
return expensiveHashComputation(input);
},
ttlSeconds: 60
});

// First call computes the result
const hash1 = await computeHash('data');

// Second call returns cached result
const hash2 = await computeHash('data');

// Clear cache for specific input
await computeHash.clear('data');
await computeHash.clearAndWait('data');
```

## License

This project is licensed under the Apache License 2.0.

<div align="center">
<sub>Built with ❤️ by <a href="https://metorial.com">Metorial</a></sub>
</div>
44 changes: 44 additions & 0 deletions packages/cache/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@lowerdeck/cache",
"version": "1.0.0",
"publishConfig": {
"access": "public"
},
"files": [
"src/**",
"dist/**",
"README.md",
"package.json"
],
"author": "Tobias Herber",
"license": "Apache 2",
"type": "module",
"source": "src/index.ts",
"exports": {
"types": "./dist/index.d.ts",
"require": "./dist/index.cjs",
"import": "./dist/index.module.js",
"default": "./dist/index.module.js"
},
"main": "./dist/index.cjs",
"module": "./dist/index.module.js",
"types": "dist/index.d.ts",
"unpkg": "./dist/index.umd.js",
"scripts": {
"test": "vitest run --passWithNoTests",
"lint": "prettier src/**/*.ts --check",
"build": "rm -rf ./dist && microbundle"
},
"dependencies": {
"@lowerdeck/redis": "^1.0.3",
"@lowerdeck/sentry": "^1.0.2",
"lru-cache": "^11.2.4",
"superjson": "^2.2.6"
},
"devDependencies": {
"@lowerdeck/tsconfig": "^1.0.1",
"typescript": "5.8.2",
"microbundle": "^0.15.1",
"vitest": "^3.1.2"
}
}
165 changes: 165 additions & 0 deletions packages/cache/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { createRedisClient } from '@lowerdeck/redis';
import { getSentry } from '@lowerdeck/sentry';
import { LRUCache } from 'lru-cache';

// @ts-ignore
import SuperJson from 'superjson';

let Sentry = getSentry();

let cache = new LRUCache<string, any>({ max: 500, ttl: 15 });

let version = 0;

export let createCachedFunction = <I, O>(opts: {
name: string;
redisUrl: string;
getHash: (i: I) => string;
provider: (i: I, opts: { setTTL: (ttl: number) => void }) => Promise<O>;
ttlSeconds: number;
getTags?: (o: O, i: I) => string[];
}) => {
let active = new Map<string, Promise<O>>();

let useRedisClient = createRedisClient({ redisUrl: opts.redisUrl }).lazy();

let getHash = (i: I) => `cache:${version}:${opts.name}:val:${opts.getHash(i)}`;
let getTagKeys = (tags: string[]) => tags.map(tag => `cache:${version}:tag:${tag}`);

let run = async (i: I): Promise<O> => {
let hash = getHash(i);
if (active.has(hash)) return await active.get(hash)!;
if (cache.has(hash)) return cache.get(hash)!;

let promise = (async () => {
let redis = await useRedisClient();
let value = await redis.get(hash);
if (value) return SuperJson.parse(value);

let ttl = { current: opts.ttlSeconds };
let setTTL = (newTTL: number) => (ttl.current = newTTL);

let result = await opts.provider(i, { setTTL });
await redis.set(hash, SuperJson.stringify(result), {
EX: ttl.current
});

if (opts.getTags) {
let tags = opts.getTags(result, i);
let tagKeys = getTagKeys(tags);
Promise.all(
tagKeys.map(async tagKey => {
await redis.sAdd(tagKey, hash);
await redis.expire(tagKey, ttl.current);
})
).catch(console.error);
}

cache.set(hash, result);

return result as any;
})();

active.set(hash, promise);
promise.finally(() => active.delete(hash));

return await promise;
};

let clearAndWait = async (i: I) => {
let hash = getHash(i);
let redis = await useRedisClient();

let data = await redis.get(hash);
if (!data) return;

await redis.del(hash);
cache.delete(hash);

if (opts.getTags) {
let tags = opts.getTags(SuperJson.parse(data), i);
let tagKeys = getTagKeys(tags);
for (let tagKey of tagKeys) {
try {
await redis.sRem(tagKey, hash);
} catch (e) {
console.error('Error removing tag:', e);
Sentry.captureException(e);
}
}
}
};

let clearByTagAndWait = async (tag: string) => {
let redis = await useRedisClient();
let tagKey = getTagKeys([tag])[0];

let hashes = await redis.sMembers(tagKey);

for (let hash of hashes) {
await redis.del(hash);
cache.delete(hash);
}

await redis.del(tagKey);
};

let clear = async (i: I) => {
clearAndWait(i).catch(err => {
console.error(err);
Sentry.captureException(err);
});
};

let clearByTag = async (tag: string) => {
clearByTagAndWait(tag).catch(err => {
console.error(err);
Sentry.captureException(err);
});
};

return Object.assign(run, { clear, clearByTag, clearAndWait, clearByTagAndWait });
};

export let createLocallyCachedFunction = <I, O>(opts: {
getHash: (i: I) => string;
provider: (i: I) => Promise<O>;
ttlSeconds: number;
}) => {
let cache = new LRUCache<string, any>({ max: 500, ttl: opts.ttlSeconds });

let active = new Map<string, Promise<O>>();

let getHash = (i: I) => opts.getHash(i);

let run = async (i: I): Promise<O> => {
let hash = getHash(i);
if (active.has(hash)) return await active.get(hash)!;
if (cache.has(hash)) return cache.get(hash)!;

let promise = (async () => {
let result = await opts.provider(i);
cache.set(hash, result);
return result;
})();

active.set(hash, promise);
promise.finally(() => active.delete(hash));

return await promise;
};

let clearAndWait = async (i: I) => {
let hash = getHash(i);
cache.delete(hash);
};

let clear = async (i: I) => {
clearAndWait(i).catch(err => {
console.error(err);
Sentry.captureException(err);
});
};

return Object.assign(run, { clear, clearAndWait });
};
11 changes: 11 additions & 0 deletions packages/cache/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@lowerdeck/tsconfig/base.json",
"exclude": ["dist"],
"include": ["src"],
"compilerOptions": {
"outDir": "dist",
"lib": ["es2021"],
"target": "ES2019"
}
}
6 changes: 6 additions & 0 deletions packages/hono/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @lowerdeck/hono

## 1.0.6

### Patch Changes

- Add cache

## 1.0.5

### Patch Changes
Expand Down
8 changes: 0 additions & 8 deletions packages/hono/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,6 @@ app.use('/api/*', cors({
export default app;
```

### Features

- Automatic ServiceError handling with proper HTTP status codes
- 404 handling for undefined routes
- X-Powered-By header set to Metorial
- Optional base path prefix
- CORS middleware included

## License

This project is licensed under the Apache License 2.0.
Expand Down
2 changes: 1 addition & 1 deletion packages/hono/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@lowerdeck/hono",
"version": "1.0.5",
"version": "1.0.6",
"publishConfig": {
"access": "public"
},
Expand Down
Loading