an app to share curated trails
sidetrail.app
1const Redis = require("ioredis");
2const { LRUCache } = require("lru-cache");
3
4const CACHE_PREFIX = "nextcache:";
5const TAGS_PREFIX = "nextcache:tags:";
6const LRU_MAX_SIZE = parseInt(process.env.LRU_CACHE_MAX_SIZE || "50", 10) * 1024 * 1024;
7
8const memoryCache = new LRUCache({
9 maxSize: LRU_MAX_SIZE,
10 sizeCalculation: (entry) => entry.size,
11});
12
13let redis = null;
14const pendingSets = new Map();
15
16function getRedis() {
17 if (!redis) {
18 redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379", {
19 maxRetriesPerRequest: 3,
20 lazyConnect: true,
21 });
22 redis.on("error", (err) => {
23 console.error("[redis] connection error:", err.message);
24 });
25 }
26 return redis;
27}
28
29async function streamToBuffer(stream) {
30 const reader = stream.getReader();
31 const chunks = [];
32 try {
33 while (true) {
34 const { done, value } = await reader.read();
35 if (done) break;
36 chunks.push(value);
37 }
38 } finally {
39 reader.releaseLock();
40 }
41 return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
42}
43
44function bufferToStream(buffer) {
45 return new ReadableStream({
46 start(controller) {
47 controller.enqueue(buffer);
48 controller.close();
49 },
50 });
51}
52
53async function areTagsExpired(tags, timestamp) {
54 if (!tags?.length) return false;
55 try {
56 const values = await getRedis().mget(tags.map((t) => TAGS_PREFIX + t));
57 return values.some((val) => val && parseInt(val, 10) > timestamp);
58 } catch {
59 return false;
60 }
61}
62
63module.exports = {
64 async get(cacheKey) {
65 const pending = pendingSets.get(cacheKey);
66 if (pending) await pending;
67
68 const now = Date.now();
69
70 // LRU
71 const mem = memoryCache.get(cacheKey);
72 if (mem) {
73 const entry = mem.entry;
74 if (now > entry.timestamp + entry.revalidate * 1000) {
75 memoryCache.delete(cacheKey);
76 } else if (await areTagsExpired(entry.tags, entry.timestamp)) {
77 memoryCache.delete(cacheKey);
78 } else {
79 const [ret, keep] = entry.value.tee();
80 entry.value = keep;
81 return { ...entry, value: ret };
82 }
83 }
84
85 // Redis
86 try {
87 const stored = await getRedis().get(CACHE_PREFIX + cacheKey);
88 if (!stored) {
89 return undefined;
90 }
91
92 const data = JSON.parse(stored);
93
94 if (now > data.timestamp + data.revalidate * 1000) {
95 await getRedis().del(CACHE_PREFIX + cacheKey);
96 return undefined;
97 }
98
99 if (await areTagsExpired(data.tags, data.timestamp)) {
100 await getRedis().del(CACHE_PREFIX + cacheKey);
101 return undefined;
102 }
103
104 const buffer = Buffer.from(data.value, "base64");
105 const entry = {
106 value: bufferToStream(buffer),
107 tags: data.tags,
108 stale: data.stale,
109 timestamp: data.timestamp,
110 expire: data.expire,
111 revalidate: data.revalidate,
112 };
113
114 const [ret, keep] = entry.value.tee();
115 entry.value = keep;
116 memoryCache.set(cacheKey, { entry, size: buffer.byteLength });
117
118 return { ...entry, value: ret };
119 } catch (err) {
120 console.error("[redis] get error:", err.message);
121 return undefined;
122 }
123 },
124
125 async set(cacheKey, pendingEntry) {
126 let resolve;
127 const promise = new Promise((r) => (resolve = r));
128 pendingSets.set(cacheKey, promise);
129
130 try {
131 const entry = await pendingEntry;
132 const buffer = await streamToBuffer(entry.value);
133
134 memoryCache.set(cacheKey, {
135 entry: { ...entry, value: bufferToStream(buffer) },
136 size: buffer.byteLength,
137 });
138
139 const ttl = entry.expire > 0 ? entry.expire : 86400;
140 await getRedis().set(
141 CACHE_PREFIX + cacheKey,
142 JSON.stringify({
143 value: buffer.toString("base64"),
144 tags: entry.tags,
145 stale: entry.stale,
146 timestamp: entry.timestamp,
147 expire: entry.expire,
148 revalidate: entry.revalidate,
149 }),
150 "EX",
151 ttl,
152 );
153 } catch (err) {
154 console.error("[redis] set error:", err.message);
155 memoryCache.delete(cacheKey);
156 } finally {
157 resolve();
158 pendingSets.delete(cacheKey);
159 }
160 },
161
162 async refreshTags() {},
163
164 async getExpiration(tags) {
165 if (!tags?.length) return 0;
166 try {
167 const values = await getRedis().mget(tags.map((t) => TAGS_PREFIX + t));
168 return Math.max(0, ...values.map((v) => (v ? parseInt(v, 10) : 0)));
169 } catch {
170 return 0;
171 }
172 },
173
174 async updateTags(tags) {
175 if (!tags?.length) return;
176 try {
177 const now = Date.now();
178 const pipeline = getRedis().pipeline();
179 for (const tag of tags) {
180 pipeline.set(TAGS_PREFIX + tag, now.toString(), "EX", 604800);
181 }
182 await pipeline.exec();
183
184 for (const [key, cached] of memoryCache.entries()) {
185 if (cached.entry.tags?.some((t) => tags.includes(t))) {
186 memoryCache.delete(key);
187 }
188 }
189 } catch (err) {
190 console.error("[redis] updateTags error:", err.message);
191 }
192 },
193};