@leejpsd/nextjs-cache-handler 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/AGENTS.md +33 -0
  2. package/CHANGELOG.md +43 -0
  3. package/README.md +22 -3
  4. package/bin/cli.cjs +10 -0
  5. package/dist/cache-components/index.cjs +150 -4
  6. package/dist/cache-components/index.cjs.map +1 -1
  7. package/dist/cache-components/index.d.cts +1 -1
  8. package/dist/cache-components/index.d.ts +1 -1
  9. package/dist/cache-components/index.js +150 -4
  10. package/dist/cache-components/index.js.map +1 -1
  11. package/dist/cli/index.cjs +937 -0
  12. package/dist/cli/index.cjs.map +1 -0
  13. package/dist/cli/index.d.cts +28 -0
  14. package/dist/cli/index.d.ts +28 -0
  15. package/dist/cli/index.js +927 -0
  16. package/dist/cli/index.js.map +1 -0
  17. package/dist/incremental/index.cjs +77 -3
  18. package/dist/incremental/index.cjs.map +1 -1
  19. package/dist/incremental/index.d.cts +1 -1
  20. package/dist/incremental/index.d.ts +1 -1
  21. package/dist/incremental/index.js +77 -3
  22. package/dist/incremental/index.js.map +1 -1
  23. package/dist/index.cjs +153 -5
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.d.cts +1 -1
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.js +153 -5
  28. package/dist/index.js.map +1 -1
  29. package/dist/ops/index.d.cts +1 -1
  30. package/dist/ops/index.d.ts +1 -1
  31. package/dist/otel/index.d.cts +1 -1
  32. package/dist/otel/index.d.ts +1 -1
  33. package/dist/seed/index.cjs +560 -0
  34. package/dist/seed/index.cjs.map +1 -0
  35. package/dist/seed/index.d.cts +53 -0
  36. package/dist/seed/index.d.ts +53 -0
  37. package/dist/seed/index.js +549 -0
  38. package/dist/seed/index.js.map +1 -0
  39. package/dist/shared/client/adapter-ioredis.cjs +40 -1
  40. package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
  41. package/dist/shared/client/adapter-ioredis.d.cts +1 -1
  42. package/dist/shared/client/adapter-ioredis.d.ts +1 -1
  43. package/dist/shared/client/adapter-ioredis.js +40 -1
  44. package/dist/shared/client/adapter-ioredis.js.map +1 -1
  45. package/dist/shared/client/adapter-redis.cjs +34 -1
  46. package/dist/shared/client/adapter-redis.cjs.map +1 -1
  47. package/dist/shared/client/adapter-redis.d.cts +1 -1
  48. package/dist/shared/client/adapter-redis.d.ts +1 -1
  49. package/dist/shared/client/adapter-redis.js +34 -1
  50. package/dist/shared/client/adapter-redis.js.map +1 -1
  51. package/dist/{types-Bl-rkLKX.d.cts → types-C1vJ_sED.d.cts} +25 -1
  52. package/dist/{types-Bl-rkLKX.d.ts → types-C1vJ_sED.d.ts} +25 -1
  53. package/llms.txt +27 -0
  54. package/package.json +27 -2
  55. package/rules/nextjs-cache-rules.md +23 -0
  56. package/setup-instructions/setup.md +179 -0
  57. package/skills/nextjs-redis-cache/SKILL.md +182 -0
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { CacheComponentsHandler, createCacheComponentsHandler, partitionEntry, shouldServeStale } from './cache-components/index.cjs';
2
2
  export { IncrementalCtx, IncrementalRedisCacheHandler, createIncrementalCacheHandler } from './incremental/index.cjs';
3
3
  export { getMetricSnapshot } from './ops/index.cjs';
4
- export { C as CacheComponentsEntry, b as CacheHandlerOptions, F as FallbackStrategy, I as IncrementalCacheData, L as Logger, M as MetricEmitter, c as MetricEvent, d as MetricEventType, a as RedisClientConfig, e as RedisClientFactory, R as RedisClientLike } from './types-Bl-rkLKX.cjs';
4
+ export { C as CacheComponentsEntry, c as CacheHandlerOptions, F as FallbackStrategy, I as IncrementalCacheData, L as Logger, M as MetricEmitter, d as MetricEvent, e as MetricEventType, a as RedisClientConfig, b as RedisClientFactory, R as RedisClientLike } from './types-C1vJ_sED.cjs';
5
5
 
6
6
  /**
7
7
  * Timeout-bounded operation wrapper. Surrounds every Redis call so a hung
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { CacheComponentsHandler, createCacheComponentsHandler, partitionEntry, shouldServeStale } from './cache-components/index.js';
2
2
  export { IncrementalCtx, IncrementalRedisCacheHandler, createIncrementalCacheHandler } from './incremental/index.js';
3
3
  export { getMetricSnapshot } from './ops/index.js';
4
- export { C as CacheComponentsEntry, b as CacheHandlerOptions, F as FallbackStrategy, I as IncrementalCacheData, L as Logger, M as MetricEmitter, c as MetricEvent, d as MetricEventType, a as RedisClientConfig, e as RedisClientFactory, R as RedisClientLike } from './types-Bl-rkLKX.js';
4
+ export { C as CacheComponentsEntry, c as CacheHandlerOptions, F as FallbackStrategy, I as IncrementalCacheData, L as Logger, M as MetricEmitter, d as MetricEvent, e as MetricEventType, a as RedisClientConfig, b as RedisClientFactory, R as RedisClientLike } from './types-C1vJ_sED.js';
5
5
 
6
6
  /**
7
7
  * Timeout-bounded operation wrapper. Surrounds every Redis call so a hung
package/dist/index.js CHANGED
@@ -43,7 +43,40 @@ __export(adapter_redis_exports, {
43
43
  createRedisV5Client: () => createRedisV5Client
44
44
  });
45
45
  function adaptRedisV5(client) {
46
- return client;
46
+ const like = client;
47
+ like.dispose = () => {
48
+ try {
49
+ client.destroy();
50
+ } catch {
51
+ }
52
+ };
53
+ like.subscribe = async (channel, onMessage, onDown) => {
54
+ const sub = client.duplicate();
55
+ const teardown = () => {
56
+ try {
57
+ sub.destroy();
58
+ } catch {
59
+ }
60
+ };
61
+ let down = false;
62
+ const markDown = () => {
63
+ if (down) return;
64
+ down = true;
65
+ teardown();
66
+ onDown?.();
67
+ };
68
+ sub.on("error", markDown);
69
+ sub.on("end", markDown);
70
+ try {
71
+ await sub.connect();
72
+ await sub.subscribe(channel, (message) => onMessage(message));
73
+ } catch (err) {
74
+ teardown();
75
+ throw err;
76
+ }
77
+ return async () => teardown();
78
+ };
79
+ return like;
47
80
  }
48
81
  function createRedisV5Client(config) {
49
82
  const { createClient } = nodeRequire("redis");
@@ -86,7 +119,12 @@ function adapt(client, isClusterClient = false) {
86
119
  }
87
120
  },
88
121
  get: (k) => client.get(k),
89
- set: (k, v, opts) => opts?.EX !== void 0 ? client.set(k, v, "EX", opts.EX) : client.set(k, v),
122
+ set: (k, v, opts) => {
123
+ const args = [];
124
+ if (opts?.EX !== void 0) args.push("EX", opts.EX);
125
+ if (opts?.NX) args.push("NX");
126
+ return client.set(k, v, ...args);
127
+ },
90
128
  del: async (keys) => {
91
129
  const arr = Array.isArray(keys) ? keys : [keys];
92
130
  if (arr.length === 0) return 0;
@@ -157,6 +195,40 @@ function adapt(client, isClusterClient = false) {
157
195
  client.on(event, listener);
158
196
  return client;
159
197
  },
198
+ publish: (channel, message) => client.publish(channel, message),
199
+ ...isClusterClient ? {} : {
200
+ subscribe: async (channel, onMessage, onDown) => {
201
+ const sub = client.duplicate();
202
+ let down = false;
203
+ const markDown = () => {
204
+ if (down) return;
205
+ down = true;
206
+ try {
207
+ sub.disconnect();
208
+ } catch {
209
+ }
210
+ onDown?.();
211
+ };
212
+ sub.on("error", markDown);
213
+ sub.on("end", markDown);
214
+ try {
215
+ if (sub.status !== "ready" && sub.status !== "connecting") {
216
+ await sub.connect();
217
+ }
218
+ await sub.subscribe(channel);
219
+ } catch (err) {
220
+ markDown();
221
+ throw err;
222
+ }
223
+ sub.on("message", (ch, message) => {
224
+ if (ch === channel) onMessage(message);
225
+ });
226
+ return async () => {
227
+ down = true;
228
+ sub.disconnect();
229
+ };
230
+ }
231
+ },
160
232
  dispose: () => {
161
233
  client.disconnect();
162
234
  }
@@ -821,6 +893,7 @@ function init(opts) {
821
893
  hashTag: opts.hashTag ?? false,
822
894
  keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
823
895
  singleFlight: opts.singleFlight ?? false,
896
+ tagPubSub: opts.tagPubSub ?? false,
824
897
  singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
825
898
  rest: opts
826
899
  },
@@ -831,6 +904,7 @@ function init(opts) {
831
904
  memTags: new MemorySetStore(opts.memoryMaxEntries),
832
905
  memTagExp: /* @__PURE__ */ new Map(),
833
906
  localTagTimestamps: /* @__PURE__ */ new Map(),
907
+ pubsub: { active: false, disabled: false, attempting: false, lastAttempt: 0 },
834
908
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
835
909
  instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
836
910
  };
@@ -908,6 +982,7 @@ function resolveTtlSeconds(entry) {
908
982
  async function getImpl(state, cacheKey, softTags) {
909
983
  const start = Date.now();
910
984
  const useRedis = gateRedis(state);
985
+ if (useRedis) void ensureTagSubscription(state);
911
986
  if (!useRedis && state.opts.fallback === "never") {
912
987
  state.emit({ type: "cache.miss", meta: { reason: "no-redis" } });
913
988
  return void 0;
@@ -984,8 +1059,11 @@ async function getImpl(state, cacheKey, softTags) {
984
1059
  if (freshnessTags.length > 0) {
985
1060
  let mostRecent = 0;
986
1061
  for (const t of freshnessTags) {
987
- const ts = state.localTagTimestamps.get(t);
988
- if (ts !== void 0 && ts > mostRecent) mostRecent = ts;
1062
+ const ts = Math.max(
1063
+ state.localTagTimestamps.get(t) ?? 0,
1064
+ state.memTagExp.get(t) ?? 0
1065
+ );
1066
+ if (ts > mostRecent) mostRecent = ts;
989
1067
  }
990
1068
  if (mostRecent > envelope.timestamp) {
991
1069
  if (state.opts.staleWhileRevalidate && partition.freshness !== "expired") {
@@ -1118,10 +1196,68 @@ async function luaSetWithTags(client, state, eKey, payload, ttl, tags) {
1118
1196
  [payload, String(ttl), String(ttl), String(tags.length)]
1119
1197
  );
1120
1198
  }
1199
+ function invalChannel(state) {
1200
+ const ns = state.opts.hashTag ? `{${state.resolveNs()}}` : state.resolveNs();
1201
+ return `${state.opts.keyPrefix}inval:${ns}`;
1202
+ }
1203
+ var SUBSCRIBE_RETRY_MS = 5e3;
1204
+ async function ensureTagSubscription(state) {
1205
+ if (!state.opts.tagPubSub || state.pubsub.active || state.pubsub.disabled || state.pubsub.attempting) {
1206
+ return;
1207
+ }
1208
+ const now = Date.now();
1209
+ if (now - state.pubsub.lastAttempt < SUBSCRIBE_RETRY_MS) return;
1210
+ state.pubsub.lastAttempt = now;
1211
+ state.pubsub.attempting = true;
1212
+ try {
1213
+ const client = await state.conn.getOrConnect();
1214
+ if (!client) return;
1215
+ if (typeof client.subscribe !== "function") {
1216
+ state.pubsub.disabled = true;
1217
+ state.logger.warn(
1218
+ "tagPubSub unavailable on this client (no subscribe support, e.g. Cluster) \u2014 falling back to scan-based propagation"
1219
+ );
1220
+ return;
1221
+ }
1222
+ const stop = await client.subscribe(
1223
+ invalChannel(state),
1224
+ (message) => {
1225
+ try {
1226
+ const parsed = JSON.parse(message);
1227
+ if (!Array.isArray(parsed.t) || typeof parsed.ts !== "number") return;
1228
+ for (const tag of parsed.t) {
1229
+ const prev = state.localTagTimestamps.get(tag) ?? 0;
1230
+ if (parsed.ts > prev) state.localTagTimestamps.set(tag, parsed.ts);
1231
+ const prevMem = state.memTagExp.get(tag) ?? 0;
1232
+ if (parsed.ts > prevMem) state.memTagExp.set(tag, parsed.ts);
1233
+ }
1234
+ pruneOldestByTimestamp(state.localTagTimestamps, MAX_LOCAL_TAG_ENTRIES);
1235
+ pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
1236
+ } catch {
1237
+ }
1238
+ },
1239
+ () => {
1240
+ state.pubsub.active = false;
1241
+ }
1242
+ );
1243
+ state.pubsub.active = true;
1244
+ state.pubsub.stop = async () => {
1245
+ state.pubsub.active = false;
1246
+ await stop();
1247
+ };
1248
+ } catch (err) {
1249
+ state.logger.warn("tagPubSub subscribe failed \u2014 will retry", {
1250
+ message: err.message
1251
+ });
1252
+ } finally {
1253
+ state.pubsub.attempting = false;
1254
+ }
1255
+ }
1121
1256
  async function refreshTagsImpl(state) {
1122
1257
  if (!gateRedis(state)) {
1123
1258
  return;
1124
1259
  }
1260
+ void ensureTagSubscription(state);
1125
1261
  try {
1126
1262
  await withAbortSignal(
1127
1263
  "cacheHandlers.refreshTags",
@@ -1279,6 +1415,16 @@ async function updateTagsImpl(state, tags, durations) {
1279
1415
  type: isHardExpire ? "tag.invalidate.hard" : "tag.invalidate.soft",
1280
1416
  meta: { count: tags.length, backend: "redis" }
1281
1417
  });
1418
+ if (state.opts.tagPubSub) {
1419
+ try {
1420
+ const client = await state.conn.getOrConnect();
1421
+ await client?.publish?.(
1422
+ invalChannel(state),
1423
+ JSON.stringify({ t: tags, ts: now })
1424
+ );
1425
+ } catch {
1426
+ }
1427
+ }
1282
1428
  } catch (err) {
1283
1429
  if (err instanceof CacheTimeoutError) {
1284
1430
  state.emit({ type: "redis.timeout", meta: { op: "updateTags" } });
@@ -1470,7 +1616,9 @@ async function readTagStates(state, tags) {
1470
1616
  async () => {
1471
1617
  const client = await state.conn.getOrConnect();
1472
1618
  if (!client) return tags.map((t) => state.memTagStates.get(t) ?? null);
1473
- const values = await client.mGet(tags.map((t) => tagMetaKey(state, t)));
1619
+ const values = await Promise.all(
1620
+ tags.map((t) => client.get(tagMetaKey(state, t)))
1621
+ );
1474
1622
  return values.map((v) => {
1475
1623
  if (!v) return null;
1476
1624
  try {