@leejpsd/nextjs-cache-handler 0.3.4 → 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 (54) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +5 -3
  3. package/bin/cli.cjs +10 -0
  4. package/dist/cache-components/index.cjs +150 -4
  5. package/dist/cache-components/index.cjs.map +1 -1
  6. package/dist/cache-components/index.d.cts +1 -1
  7. package/dist/cache-components/index.d.ts +1 -1
  8. package/dist/cache-components/index.js +150 -4
  9. package/dist/cache-components/index.js.map +1 -1
  10. package/dist/cli/index.cjs +937 -0
  11. package/dist/cli/index.cjs.map +1 -0
  12. package/dist/cli/index.d.cts +28 -0
  13. package/dist/cli/index.d.ts +28 -0
  14. package/dist/cli/index.js +927 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/incremental/index.cjs +77 -3
  17. package/dist/incremental/index.cjs.map +1 -1
  18. package/dist/incremental/index.d.cts +1 -1
  19. package/dist/incremental/index.d.ts +1 -1
  20. package/dist/incremental/index.js +77 -3
  21. package/dist/incremental/index.js.map +1 -1
  22. package/dist/index.cjs +153 -5
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.d.cts +1 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.js +153 -5
  27. package/dist/index.js.map +1 -1
  28. package/dist/ops/index.d.cts +1 -1
  29. package/dist/ops/index.d.ts +1 -1
  30. package/dist/otel/index.d.cts +1 -1
  31. package/dist/otel/index.d.ts +1 -1
  32. package/dist/seed/index.cjs +560 -0
  33. package/dist/seed/index.cjs.map +1 -0
  34. package/dist/seed/index.d.cts +53 -0
  35. package/dist/seed/index.d.ts +53 -0
  36. package/dist/seed/index.js +549 -0
  37. package/dist/seed/index.js.map +1 -0
  38. package/dist/shared/client/adapter-ioredis.cjs +40 -1
  39. package/dist/shared/client/adapter-ioredis.cjs.map +1 -1
  40. package/dist/shared/client/adapter-ioredis.d.cts +1 -1
  41. package/dist/shared/client/adapter-ioredis.d.ts +1 -1
  42. package/dist/shared/client/adapter-ioredis.js +40 -1
  43. package/dist/shared/client/adapter-ioredis.js.map +1 -1
  44. package/dist/shared/client/adapter-redis.cjs +34 -1
  45. package/dist/shared/client/adapter-redis.cjs.map +1 -1
  46. package/dist/shared/client/adapter-redis.d.cts +1 -1
  47. package/dist/shared/client/adapter-redis.d.ts +1 -1
  48. package/dist/shared/client/adapter-redis.js +34 -1
  49. package/dist/shared/client/adapter-redis.js.map +1 -1
  50. package/dist/{types-Bl-rkLKX.d.cts → types-C1vJ_sED.d.cts} +25 -1
  51. package/dist/{types-Bl-rkLKX.d.ts → types-C1vJ_sED.d.ts} +25 -1
  52. package/package.json +22 -2
  53. package/setup-instructions/setup.md +31 -9
  54. package/skills/nextjs-redis-cache/SKILL.md +15 -0
package/dist/index.cjs CHANGED
@@ -53,7 +53,40 @@ __export(adapter_redis_exports, {
53
53
  createRedisV5Client: () => createRedisV5Client
54
54
  });
55
55
  function adaptRedisV5(client) {
56
- return client;
56
+ const like = client;
57
+ like.dispose = () => {
58
+ try {
59
+ client.destroy();
60
+ } catch {
61
+ }
62
+ };
63
+ like.subscribe = async (channel, onMessage, onDown) => {
64
+ const sub = client.duplicate();
65
+ const teardown = () => {
66
+ try {
67
+ sub.destroy();
68
+ } catch {
69
+ }
70
+ };
71
+ let down = false;
72
+ const markDown = () => {
73
+ if (down) return;
74
+ down = true;
75
+ teardown();
76
+ onDown?.();
77
+ };
78
+ sub.on("error", markDown);
79
+ sub.on("end", markDown);
80
+ try {
81
+ await sub.connect();
82
+ await sub.subscribe(channel, (message) => onMessage(message));
83
+ } catch (err) {
84
+ teardown();
85
+ throw err;
86
+ }
87
+ return async () => teardown();
88
+ };
89
+ return like;
57
90
  }
58
91
  function createRedisV5Client(config) {
59
92
  const { createClient } = nodeRequire("redis");
@@ -96,7 +129,12 @@ function adapt(client, isClusterClient = false) {
96
129
  }
97
130
  },
98
131
  get: (k) => client.get(k),
99
- set: (k, v, opts) => opts?.EX !== void 0 ? client.set(k, v, "EX", opts.EX) : client.set(k, v),
132
+ set: (k, v, opts) => {
133
+ const args = [];
134
+ if (opts?.EX !== void 0) args.push("EX", opts.EX);
135
+ if (opts?.NX) args.push("NX");
136
+ return client.set(k, v, ...args);
137
+ },
100
138
  del: async (keys) => {
101
139
  const arr = Array.isArray(keys) ? keys : [keys];
102
140
  if (arr.length === 0) return 0;
@@ -167,6 +205,40 @@ function adapt(client, isClusterClient = false) {
167
205
  client.on(event, listener);
168
206
  return client;
169
207
  },
208
+ publish: (channel, message) => client.publish(channel, message),
209
+ ...isClusterClient ? {} : {
210
+ subscribe: async (channel, onMessage, onDown) => {
211
+ const sub = client.duplicate();
212
+ let down = false;
213
+ const markDown = () => {
214
+ if (down) return;
215
+ down = true;
216
+ try {
217
+ sub.disconnect();
218
+ } catch {
219
+ }
220
+ onDown?.();
221
+ };
222
+ sub.on("error", markDown);
223
+ sub.on("end", markDown);
224
+ try {
225
+ if (sub.status !== "ready" && sub.status !== "connecting") {
226
+ await sub.connect();
227
+ }
228
+ await sub.subscribe(channel);
229
+ } catch (err) {
230
+ markDown();
231
+ throw err;
232
+ }
233
+ sub.on("message", (ch, message) => {
234
+ if (ch === channel) onMessage(message);
235
+ });
236
+ return async () => {
237
+ down = true;
238
+ sub.disconnect();
239
+ };
240
+ }
241
+ },
170
242
  dispose: () => {
171
243
  client.disconnect();
172
244
  }
@@ -831,6 +903,7 @@ function init(opts) {
831
903
  hashTag: opts.hashTag ?? false,
832
904
  keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX,
833
905
  singleFlight: opts.singleFlight ?? false,
906
+ tagPubSub: opts.tagPubSub ?? false,
834
907
  singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC,
835
908
  rest: opts
836
909
  },
@@ -841,6 +914,7 @@ function init(opts) {
841
914
  memTags: new MemorySetStore(opts.memoryMaxEntries),
842
915
  memTagExp: /* @__PURE__ */ new Map(),
843
916
  localTagTimestamps: /* @__PURE__ */ new Map(),
917
+ pubsub: { active: false, disabled: false, attempting: false, lastAttempt: 0 },
844
918
  resolveNs: () => resolveBuildNamespace(opts.buildNamespace),
845
919
  instanceId: process.env.HOSTNAME || process.env.ECS_TASK_ID || `pid-${process.pid}`
846
920
  };
@@ -918,6 +992,7 @@ function resolveTtlSeconds(entry) {
918
992
  async function getImpl(state, cacheKey, softTags) {
919
993
  const start = Date.now();
920
994
  const useRedis = gateRedis(state);
995
+ if (useRedis) void ensureTagSubscription(state);
921
996
  if (!useRedis && state.opts.fallback === "never") {
922
997
  state.emit({ type: "cache.miss", meta: { reason: "no-redis" } });
923
998
  return void 0;
@@ -994,8 +1069,11 @@ async function getImpl(state, cacheKey, softTags) {
994
1069
  if (freshnessTags.length > 0) {
995
1070
  let mostRecent = 0;
996
1071
  for (const t of freshnessTags) {
997
- const ts = state.localTagTimestamps.get(t);
998
- if (ts !== void 0 && ts > mostRecent) mostRecent = ts;
1072
+ const ts = Math.max(
1073
+ state.localTagTimestamps.get(t) ?? 0,
1074
+ state.memTagExp.get(t) ?? 0
1075
+ );
1076
+ if (ts > mostRecent) mostRecent = ts;
999
1077
  }
1000
1078
  if (mostRecent > envelope.timestamp) {
1001
1079
  if (state.opts.staleWhileRevalidate && partition.freshness !== "expired") {
@@ -1128,10 +1206,68 @@ async function luaSetWithTags(client, state, eKey, payload, ttl, tags) {
1128
1206
  [payload, String(ttl), String(ttl), String(tags.length)]
1129
1207
  );
1130
1208
  }
1209
+ function invalChannel(state) {
1210
+ const ns = state.opts.hashTag ? `{${state.resolveNs()}}` : state.resolveNs();
1211
+ return `${state.opts.keyPrefix}inval:${ns}`;
1212
+ }
1213
+ var SUBSCRIBE_RETRY_MS = 5e3;
1214
+ async function ensureTagSubscription(state) {
1215
+ if (!state.opts.tagPubSub || state.pubsub.active || state.pubsub.disabled || state.pubsub.attempting) {
1216
+ return;
1217
+ }
1218
+ const now = Date.now();
1219
+ if (now - state.pubsub.lastAttempt < SUBSCRIBE_RETRY_MS) return;
1220
+ state.pubsub.lastAttempt = now;
1221
+ state.pubsub.attempting = true;
1222
+ try {
1223
+ const client = await state.conn.getOrConnect();
1224
+ if (!client) return;
1225
+ if (typeof client.subscribe !== "function") {
1226
+ state.pubsub.disabled = true;
1227
+ state.logger.warn(
1228
+ "tagPubSub unavailable on this client (no subscribe support, e.g. Cluster) \u2014 falling back to scan-based propagation"
1229
+ );
1230
+ return;
1231
+ }
1232
+ const stop = await client.subscribe(
1233
+ invalChannel(state),
1234
+ (message) => {
1235
+ try {
1236
+ const parsed = JSON.parse(message);
1237
+ if (!Array.isArray(parsed.t) || typeof parsed.ts !== "number") return;
1238
+ for (const tag of parsed.t) {
1239
+ const prev = state.localTagTimestamps.get(tag) ?? 0;
1240
+ if (parsed.ts > prev) state.localTagTimestamps.set(tag, parsed.ts);
1241
+ const prevMem = state.memTagExp.get(tag) ?? 0;
1242
+ if (parsed.ts > prevMem) state.memTagExp.set(tag, parsed.ts);
1243
+ }
1244
+ pruneOldestByTimestamp(state.localTagTimestamps, MAX_LOCAL_TAG_ENTRIES);
1245
+ pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES);
1246
+ } catch {
1247
+ }
1248
+ },
1249
+ () => {
1250
+ state.pubsub.active = false;
1251
+ }
1252
+ );
1253
+ state.pubsub.active = true;
1254
+ state.pubsub.stop = async () => {
1255
+ state.pubsub.active = false;
1256
+ await stop();
1257
+ };
1258
+ } catch (err) {
1259
+ state.logger.warn("tagPubSub subscribe failed \u2014 will retry", {
1260
+ message: err.message
1261
+ });
1262
+ } finally {
1263
+ state.pubsub.attempting = false;
1264
+ }
1265
+ }
1131
1266
  async function refreshTagsImpl(state) {
1132
1267
  if (!gateRedis(state)) {
1133
1268
  return;
1134
1269
  }
1270
+ void ensureTagSubscription(state);
1135
1271
  try {
1136
1272
  await withAbortSignal(
1137
1273
  "cacheHandlers.refreshTags",
@@ -1289,6 +1425,16 @@ async function updateTagsImpl(state, tags, durations) {
1289
1425
  type: isHardExpire ? "tag.invalidate.hard" : "tag.invalidate.soft",
1290
1426
  meta: { count: tags.length, backend: "redis" }
1291
1427
  });
1428
+ if (state.opts.tagPubSub) {
1429
+ try {
1430
+ const client = await state.conn.getOrConnect();
1431
+ await client?.publish?.(
1432
+ invalChannel(state),
1433
+ JSON.stringify({ t: tags, ts: now })
1434
+ );
1435
+ } catch {
1436
+ }
1437
+ }
1292
1438
  } catch (err) {
1293
1439
  if (err instanceof CacheTimeoutError) {
1294
1440
  state.emit({ type: "redis.timeout", meta: { op: "updateTags" } });
@@ -1480,7 +1626,9 @@ async function readTagStates(state, tags) {
1480
1626
  async () => {
1481
1627
  const client = await state.conn.getOrConnect();
1482
1628
  if (!client) return tags.map((t) => state.memTagStates.get(t) ?? null);
1483
- const values = await client.mGet(tags.map((t) => tagMetaKey(state, t)));
1629
+ const values = await Promise.all(
1630
+ tags.map((t) => client.get(tagMetaKey(state, t)))
1631
+ );
1484
1632
  return values.map((v) => {
1485
1633
  if (!v) return null;
1486
1634
  try {