@revealui/cache 0.2.0 → 0.2.1
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.
- package/LICENSE.commercial +63 -85
- package/README.md +88 -0
- package/dist/adapters/index.d.ts +126 -0
- package/dist/adapters/index.js +144 -0
- package/dist/browser-7BTPENLH.js +6 -0
- package/dist/chunk-EPAGOXMX.js +123 -0
- package/dist/index.d.ts +134 -18
- package/dist/index.js +184 -19
- package/dist/types-CmU1eRbl.d.ts +34 -0
- package/package.json +34 -17
- package/dist/index.js.map +0 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { C as CacheStore } from './types-CmU1eRbl.js';
|
|
2
|
+
export { a as CacheEntry } from './types-CmU1eRbl.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* CDN Configuration and Cache Management
|
|
@@ -158,14 +159,47 @@ declare function getCacheTTL(headers: Headers): number;
|
|
|
158
159
|
/**
|
|
159
160
|
* Edge Caching and ISR (Incremental Static Regeneration)
|
|
160
161
|
*
|
|
161
|
-
*
|
|
162
|
+
* Framework-agnostic helpers for edge caching, ISR-style revalidation, edge
|
|
163
|
+
* rate limiting, geolocation, A/B testing, personalization, and CDN cache
|
|
164
|
+
* headers. Works in any runtime that exposes Web-standard `Request` /
|
|
165
|
+
* `Response` — NextRequest/NextResponse pass via structural typing, Hono's
|
|
166
|
+
* `c.req.raw` / `c.res` pass directly, Cloudflare Workers Request/Response
|
|
167
|
+
* pass directly, etc. The package no longer carries a `next` peer dep.
|
|
168
|
+
*/
|
|
169
|
+
/**
|
|
170
|
+
* Framework-agnostic request shape for edge-cache helpers — compatible with
|
|
171
|
+
* NextRequest, Hono `c.req.raw`, Cloudflare Workers Request, and any other
|
|
172
|
+
* Web-standard `Request` subclass that exposes a NextRequest-style `cookies`
|
|
173
|
+
* map. Narrowed to the read-only subset we actually consume.
|
|
174
|
+
*
|
|
175
|
+
* Consumers using a bare Web `Request` (no `cookies` field — e.g., plain
|
|
176
|
+
* `fetch` requests) must wrap it before calling helpers that read cookies
|
|
177
|
+
* (`getABTestVariant`, `getPersonalizationConfig`). Helpers that only read
|
|
178
|
+
* headers (`getGeoLocation`, `EdgeRateLimiter.check` with default key) work
|
|
179
|
+
* with bare `Request` directly via subtype compatibility.
|
|
180
|
+
*/
|
|
181
|
+
interface CacheRequest extends Request {
|
|
182
|
+
readonly cookies: {
|
|
183
|
+
get(name: string): {
|
|
184
|
+
readonly value: string;
|
|
185
|
+
} | undefined;
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Framework-agnostic response shape for edge-cache helpers — compatible with
|
|
190
|
+
* NextResponse, Hono `c.res`, Cloudflare Workers Response, and any other
|
|
191
|
+
* Web-standard `Response` subclass. Helpers in this file only consume the
|
|
192
|
+
* standard `headers.set()` surface.
|
|
162
193
|
*/
|
|
163
|
-
|
|
194
|
+
type CacheResponse = Response;
|
|
164
195
|
/**
|
|
165
|
-
*
|
|
166
|
-
*
|
|
196
|
+
* Framework-agnostic ISR-style fetch options. Extends Web-standard
|
|
197
|
+
* `RequestInit` with a `next` property that mirrors Next.js's ISR
|
|
198
|
+
* revalidation + cache-tag shape. Runtimes that don't honor the `next`
|
|
199
|
+
* field (anything outside Next.js) ignore it silently — the helper still
|
|
200
|
+
* works as a plain `fetch` wrapper.
|
|
167
201
|
*/
|
|
168
|
-
interface
|
|
202
|
+
interface CachedFetchRequestInit extends RequestInit {
|
|
169
203
|
next?: {
|
|
170
204
|
revalidate?: number | false;
|
|
171
205
|
tags?: string[];
|
|
@@ -252,9 +286,11 @@ interface EdgeCacheConfig {
|
|
|
252
286
|
/**
|
|
253
287
|
* Create edge cached fetch
|
|
254
288
|
*/
|
|
255
|
-
declare function createEdgeCachedFetch(config?: EdgeCacheConfig): <T>(url: string, options?:
|
|
289
|
+
declare function createEdgeCachedFetch(config?: EdgeCacheConfig): <T>(url: string, options?: CachedFetchRequestInit) => Promise<T>;
|
|
256
290
|
/**
|
|
257
|
-
*
|
|
291
|
+
* Memoizing cache wrapper for any async function. TTL-based eviction.
|
|
292
|
+
* Framework-agnostic — does NOT use Next.js's `unstable_cache`; this is
|
|
293
|
+
* a plain in-memory wrapper that works in any runtime.
|
|
258
294
|
*/
|
|
259
295
|
declare function createCachedFunction<TArgs extends unknown[], TReturn>(fn: (...args: TArgs) => Promise<TReturn>, options?: {
|
|
260
296
|
tags?: string[];
|
|
@@ -266,7 +302,7 @@ declare function createCachedFunction<TArgs extends unknown[], TReturn>(fn: (...
|
|
|
266
302
|
interface EdgeRateLimitConfig {
|
|
267
303
|
limit: number;
|
|
268
304
|
window: number;
|
|
269
|
-
key?: (request:
|
|
305
|
+
key?: (request: CacheRequest) => string;
|
|
270
306
|
}
|
|
271
307
|
declare class EdgeRateLimiter {
|
|
272
308
|
private config;
|
|
@@ -275,7 +311,7 @@ declare class EdgeRateLimiter {
|
|
|
275
311
|
/**
|
|
276
312
|
* Check rate limit
|
|
277
313
|
*/
|
|
278
|
-
check(request:
|
|
314
|
+
check(request: CacheRequest): {
|
|
279
315
|
allowed: boolean;
|
|
280
316
|
limit: number;
|
|
281
317
|
remaining: number;
|
|
@@ -296,11 +332,11 @@ interface GeoLocation {
|
|
|
296
332
|
latitude?: number;
|
|
297
333
|
longitude?: number;
|
|
298
334
|
}
|
|
299
|
-
declare function getGeoLocation(request:
|
|
335
|
+
declare function getGeoLocation(request: CacheRequest): GeoLocation | null;
|
|
300
336
|
/**
|
|
301
337
|
* Edge A/B testing with cache
|
|
302
338
|
*/
|
|
303
|
-
declare function getABTestVariant(request:
|
|
339
|
+
declare function getABTestVariant(request: CacheRequest, testName: string, variants: string[]): string;
|
|
304
340
|
/**
|
|
305
341
|
* Edge personalization cache
|
|
306
342
|
*/
|
|
@@ -311,25 +347,25 @@ interface PersonalizationConfig {
|
|
|
311
347
|
device?: 'mobile' | 'tablet' | 'desktop';
|
|
312
348
|
variant?: string;
|
|
313
349
|
}
|
|
314
|
-
declare function getPersonalizationConfig(request:
|
|
350
|
+
declare function getPersonalizationConfig(request: CacheRequest): PersonalizationConfig;
|
|
315
351
|
/**
|
|
316
352
|
* Edge cache headers helper
|
|
317
353
|
*/
|
|
318
|
-
declare function setEdgeCacheHeaders(response:
|
|
354
|
+
declare function setEdgeCacheHeaders(response: CacheResponse, config: {
|
|
319
355
|
maxAge?: number;
|
|
320
356
|
sMaxAge?: number;
|
|
321
357
|
staleWhileRevalidate?: number;
|
|
322
358
|
tags?: string[];
|
|
323
|
-
}):
|
|
359
|
+
}): CacheResponse;
|
|
324
360
|
/**
|
|
325
361
|
* Preload links for critical resources
|
|
326
362
|
*/
|
|
327
|
-
declare function addPreloadLinks(response:
|
|
363
|
+
declare function addPreloadLinks(response: CacheResponse, resources: Array<{
|
|
328
364
|
href: string;
|
|
329
365
|
as: string;
|
|
330
366
|
type?: string;
|
|
331
367
|
crossorigin?: boolean;
|
|
332
|
-
}>):
|
|
368
|
+
}>): CacheResponse;
|
|
333
369
|
/**
|
|
334
370
|
* Cache warming for ISR pages
|
|
335
371
|
*/
|
|
@@ -342,6 +378,86 @@ declare function warmISRCache(paths: string[], baseURL?: string): Promise<{
|
|
|
342
378
|
}>;
|
|
343
379
|
}>;
|
|
344
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Cache Invalidation Channel
|
|
383
|
+
*
|
|
384
|
+
* Coordinates cache invalidation across instances using a shared database table.
|
|
385
|
+
* Events are written to `_cache_invalidation_events` and consumed by polling.
|
|
386
|
+
*
|
|
387
|
+
* Architecture:
|
|
388
|
+
* - Publisher: writes invalidation event to shared PGlite/PostgreSQL table
|
|
389
|
+
* - Subscriber: polls the table for new events and forwards to local CacheStore
|
|
390
|
+
* - Events auto-expire after TTL to prevent unbounded table growth
|
|
391
|
+
*
|
|
392
|
+
* Future: Replace polling with ElectricSQL shape subscriptions or LISTEN/NOTIFY
|
|
393
|
+
* for real-time push-based invalidation (Phase 5.10C/E).
|
|
394
|
+
*/
|
|
395
|
+
|
|
396
|
+
type InvalidationEventType = 'delete' | 'delete-prefix' | 'delete-tags' | 'clear';
|
|
397
|
+
interface InvalidationEvent {
|
|
398
|
+
id: string;
|
|
399
|
+
type: InvalidationEventType;
|
|
400
|
+
/** Cache keys to delete (for 'delete' type). */
|
|
401
|
+
keys?: string[];
|
|
402
|
+
/** Prefix to match (for 'delete-prefix' type). */
|
|
403
|
+
prefix?: string;
|
|
404
|
+
/** Tags to match (for 'delete-tags' type). */
|
|
405
|
+
tags?: string[];
|
|
406
|
+
/** Instance ID that published the event (for deduplication). */
|
|
407
|
+
sourceInstance: string;
|
|
408
|
+
/** Timestamp when the event was created. */
|
|
409
|
+
createdAt: number;
|
|
410
|
+
}
|
|
411
|
+
interface InvalidationChannelOptions {
|
|
412
|
+
/** Unique instance identifier (used to skip self-published events). */
|
|
413
|
+
instanceId: string;
|
|
414
|
+
/** Poll interval in milliseconds (default: 5000). */
|
|
415
|
+
pollIntervalMs?: number;
|
|
416
|
+
/** Event TTL in seconds - events older than this are pruned (default: 60). */
|
|
417
|
+
eventTtlSeconds?: number;
|
|
418
|
+
}
|
|
419
|
+
interface PGliteInstance {
|
|
420
|
+
exec(query: string): Promise<unknown>;
|
|
421
|
+
query<T = Record<string, unknown>>(query: string, params?: unknown[]): Promise<{
|
|
422
|
+
rows: T[];
|
|
423
|
+
}>;
|
|
424
|
+
close(): Promise<void>;
|
|
425
|
+
}
|
|
426
|
+
declare class CacheInvalidationChannel {
|
|
427
|
+
private db;
|
|
428
|
+
private store;
|
|
429
|
+
private instanceId;
|
|
430
|
+
private pollIntervalMs;
|
|
431
|
+
private eventTtlSeconds;
|
|
432
|
+
private lastSeenTimestamp;
|
|
433
|
+
/** IDs processed at exactly lastSeenTimestamp (prevents re-processing on >= query). */
|
|
434
|
+
private processedAtBoundary;
|
|
435
|
+
private pollTimer;
|
|
436
|
+
private ready;
|
|
437
|
+
constructor(db: PGliteInstance, store: CacheStore, options: InvalidationChannelOptions);
|
|
438
|
+
private init;
|
|
439
|
+
/** Start polling for invalidation events. */
|
|
440
|
+
start(): Promise<void>;
|
|
441
|
+
/** Stop polling. */
|
|
442
|
+
stop(): void;
|
|
443
|
+
/** Publish a key deletion event. */
|
|
444
|
+
publishDelete(...keys: string[]): Promise<void>;
|
|
445
|
+
/** Publish a prefix deletion event. */
|
|
446
|
+
publishDeletePrefix(prefix: string): Promise<void>;
|
|
447
|
+
/** Publish a tag-based deletion event. */
|
|
448
|
+
publishDeleteTags(tags: string[]): Promise<void>;
|
|
449
|
+
/** Publish a clear-all event. */
|
|
450
|
+
publishClear(): Promise<void>;
|
|
451
|
+
private publish;
|
|
452
|
+
/** Poll for new events and apply them to the local cache store. */
|
|
453
|
+
poll(): Promise<number>;
|
|
454
|
+
private applyEvent;
|
|
455
|
+
/** Remove events older than the TTL. */
|
|
456
|
+
private prune;
|
|
457
|
+
/** Release resources. */
|
|
458
|
+
close(): Promise<void>;
|
|
459
|
+
}
|
|
460
|
+
|
|
345
461
|
/**
|
|
346
462
|
* Internal logger for @revealui/cache.
|
|
347
463
|
*
|
|
@@ -363,4 +479,4 @@ declare function configureCacheLogger(logger: CacheLogger): void;
|
|
|
363
479
|
*/
|
|
364
480
|
declare function getCacheLogger(): CacheLogger;
|
|
365
481
|
|
|
366
|
-
export { type CDNCacheConfig, type CDNPurgeConfig, CDN_CACHE_PRESETS, type CacheLogger, DEFAULT_CDN_CONFIG, type EdgeCacheConfig, type EdgeRateLimitConfig, EdgeRateLimiter, type GeoLocation, type ISRConfig, ISR_PRESETS, type PersonalizationConfig, addPreloadLinks, configureCacheLogger, createCachedFunction, createEdgeCachedFetch, generateCacheControl, generateCacheTags, generateCloudflareConfig, generateStaticParams, generateVercelCacheConfig, getABTestVariant, getCacheLogger, getCacheTTL, getGeoLocation, getPersonalizationConfig, purgeAllCache, purgeCDNCache, purgeCacheByTag, revalidatePath, revalidatePaths, revalidateTag, revalidateTags, setEdgeCacheHeaders, shouldCacheResponse, warmCDNCache, warmISRCache };
|
|
482
|
+
export { type CDNCacheConfig, type CDNPurgeConfig, CDN_CACHE_PRESETS, CacheInvalidationChannel, type CacheLogger, type CacheRequest, type CacheResponse, CacheStore, DEFAULT_CDN_CONFIG, type EdgeCacheConfig, type EdgeRateLimitConfig, EdgeRateLimiter, type GeoLocation, type ISRConfig, ISR_PRESETS, type InvalidationChannelOptions, type InvalidationEvent, type InvalidationEventType, type PersonalizationConfig, addPreloadLinks, configureCacheLogger, createCachedFunction, createEdgeCachedFetch, generateCacheControl, generateCacheTags, generateCloudflareConfig, generateStaticParams, generateVercelCacheConfig, getABTestVariant, getCacheLogger, getCacheTTL, getGeoLocation, getPersonalizationConfig, purgeAllCache, purgeCDNCache, purgeCacheByTag, revalidatePath, revalidatePaths, revalidateTag, revalidateTags, setEdgeCacheHeaders, shouldCacheResponse, warmCDNCache, warmISRCache };
|
package/dist/index.js
CHANGED
|
@@ -120,7 +120,6 @@ async function purgeCloudflare(urls, config) {
|
|
|
120
120
|
{
|
|
121
121
|
method: "POST",
|
|
122
122
|
headers: {
|
|
123
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header convention
|
|
124
123
|
Authorization: `Bearer ${apiKey}`,
|
|
125
124
|
"Content-Type": "application/json"
|
|
126
125
|
},
|
|
@@ -150,7 +149,6 @@ async function purgeVercel(urls, config) {
|
|
|
150
149
|
const response = await fetch("https://api.vercel.com/v1/purge", {
|
|
151
150
|
method: "POST",
|
|
152
151
|
headers: {
|
|
153
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header convention
|
|
154
152
|
Authorization: `Bearer ${apiKey}`,
|
|
155
153
|
"Content-Type": "application/json"
|
|
156
154
|
},
|
|
@@ -212,7 +210,6 @@ async function purgeCacheByTag(tags, config) {
|
|
|
212
210
|
{
|
|
213
211
|
method: "POST",
|
|
214
212
|
headers: {
|
|
215
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header convention
|
|
216
213
|
Authorization: `Bearer ${apiKey}`,
|
|
217
214
|
"Content-Type": "application/json"
|
|
218
215
|
},
|
|
@@ -247,7 +244,6 @@ async function purgeAllCache(config) {
|
|
|
247
244
|
{
|
|
248
245
|
method: "POST",
|
|
249
246
|
headers: {
|
|
250
|
-
// biome-ignore lint/style/useNamingConvention: HTTP header convention
|
|
251
247
|
Authorization: `Bearer ${apiKey}`,
|
|
252
248
|
"Content-Type": "application/json"
|
|
253
249
|
},
|
|
@@ -347,13 +343,21 @@ function shouldCacheResponse(status, headers) {
|
|
|
347
343
|
}
|
|
348
344
|
function getCacheTTL(headers) {
|
|
349
345
|
const cacheControl = headers.get("cache-control") || "";
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
346
|
+
for (const directive of cacheControl.split(",")) {
|
|
347
|
+
const trimmed = directive.trim();
|
|
348
|
+
if (trimmed.startsWith("s-maxage=")) {
|
|
349
|
+
const val = trimmed.slice("s-maxage=".length);
|
|
350
|
+
const num = Number.parseInt(val, 10);
|
|
351
|
+
if (!Number.isNaN(num)) return num;
|
|
352
|
+
}
|
|
353
353
|
}
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
354
|
+
for (const directive of cacheControl.split(",")) {
|
|
355
|
+
const trimmed = directive.trim();
|
|
356
|
+
if (trimmed.startsWith("max-age=")) {
|
|
357
|
+
const val = trimmed.slice("max-age=".length);
|
|
358
|
+
const num = Number.parseInt(val, 10);
|
|
359
|
+
if (!Number.isNaN(num)) return num;
|
|
360
|
+
}
|
|
357
361
|
}
|
|
358
362
|
const expires = headers.get("expires");
|
|
359
363
|
if (expires) {
|
|
@@ -364,6 +368,9 @@ function getCacheTTL(headers) {
|
|
|
364
368
|
return 0;
|
|
365
369
|
}
|
|
366
370
|
|
|
371
|
+
// src/edge-cache.ts
|
|
372
|
+
import { getClientIp } from "@revealui/security";
|
|
373
|
+
|
|
367
374
|
// src/logger.ts
|
|
368
375
|
var cacheLogger = console;
|
|
369
376
|
function configureCacheLogger(logger) {
|
|
@@ -560,12 +567,13 @@ var EdgeRateLimiter = class {
|
|
|
560
567
|
constructor(config) {
|
|
561
568
|
this.config = config;
|
|
562
569
|
}
|
|
570
|
+
config;
|
|
563
571
|
cache = /* @__PURE__ */ new Map();
|
|
564
572
|
/**
|
|
565
573
|
* Check rate limit
|
|
566
574
|
*/
|
|
567
575
|
check(request) {
|
|
568
|
-
const key = this.config.key ? this.config.key(request) : request
|
|
576
|
+
const key = this.config.key ? this.config.key(request) : getClientIp(request);
|
|
569
577
|
const now = Date.now();
|
|
570
578
|
let entry = this.cache.get(key);
|
|
571
579
|
if (!entry || now > entry.resetTime) {
|
|
@@ -626,7 +634,7 @@ function getABTestVariant(request, testName, variants) {
|
|
|
626
634
|
if (cookieVariant && variants.includes(cookieVariant)) {
|
|
627
635
|
return cookieVariant;
|
|
628
636
|
}
|
|
629
|
-
const ip = request
|
|
637
|
+
const ip = getClientIp(request);
|
|
630
638
|
const hash = simpleHash(ip + testName);
|
|
631
639
|
const variantIndex = hash % variants.length;
|
|
632
640
|
const variant = variants[variantIndex];
|
|
@@ -655,12 +663,10 @@ function getPersonalizationConfig(request) {
|
|
|
655
663
|
};
|
|
656
664
|
}
|
|
657
665
|
function getDeviceType(userAgent) {
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
if (
|
|
662
|
-
return "tablet";
|
|
663
|
-
}
|
|
666
|
+
const ua = userAgent.toLowerCase();
|
|
667
|
+
const isTablet = ua.includes("tablet") || ua.includes("ipad");
|
|
668
|
+
if (isTablet) return "tablet";
|
|
669
|
+
if (ua.includes("mobile")) return "mobile";
|
|
664
670
|
return "desktop";
|
|
665
671
|
}
|
|
666
672
|
function setEdgeCacheHeaders(response, config) {
|
|
@@ -730,8 +736,168 @@ async function warmISRCache(paths, baseURL = process.env.NEXT_PUBLIC_URL || "htt
|
|
|
730
736
|
}
|
|
731
737
|
return { warmed, failed, errors };
|
|
732
738
|
}
|
|
739
|
+
|
|
740
|
+
// src/invalidation-channel.ts
|
|
741
|
+
var CREATE_EVENTS_TABLE_SQL = `
|
|
742
|
+
CREATE TABLE IF NOT EXISTS _cache_invalidation_events (
|
|
743
|
+
id TEXT PRIMARY KEY,
|
|
744
|
+
type TEXT NOT NULL,
|
|
745
|
+
keys TEXT[],
|
|
746
|
+
prefix TEXT,
|
|
747
|
+
tags TEXT[],
|
|
748
|
+
source_instance TEXT NOT NULL,
|
|
749
|
+
created_at BIGINT NOT NULL
|
|
750
|
+
);
|
|
751
|
+
CREATE INDEX IF NOT EXISTS _cache_inv_created_idx ON _cache_invalidation_events (created_at);
|
|
752
|
+
`;
|
|
753
|
+
var CacheInvalidationChannel = class {
|
|
754
|
+
db;
|
|
755
|
+
store;
|
|
756
|
+
instanceId;
|
|
757
|
+
pollIntervalMs;
|
|
758
|
+
eventTtlSeconds;
|
|
759
|
+
lastSeenTimestamp;
|
|
760
|
+
/** IDs processed at exactly lastSeenTimestamp (prevents re-processing on >= query). */
|
|
761
|
+
processedAtBoundary = /* @__PURE__ */ new Set();
|
|
762
|
+
pollTimer = null;
|
|
763
|
+
ready;
|
|
764
|
+
constructor(db, store, options) {
|
|
765
|
+
this.db = db;
|
|
766
|
+
this.store = store;
|
|
767
|
+
this.instanceId = options.instanceId;
|
|
768
|
+
this.pollIntervalMs = options.pollIntervalMs ?? 5e3;
|
|
769
|
+
this.eventTtlSeconds = options.eventTtlSeconds ?? 60;
|
|
770
|
+
this.lastSeenTimestamp = Date.now() - 1;
|
|
771
|
+
this.ready = this.init();
|
|
772
|
+
}
|
|
773
|
+
async init() {
|
|
774
|
+
await this.db.exec(CREATE_EVENTS_TABLE_SQL);
|
|
775
|
+
}
|
|
776
|
+
/** Start polling for invalidation events. */
|
|
777
|
+
async start() {
|
|
778
|
+
await this.ready;
|
|
779
|
+
if (this.pollTimer) return;
|
|
780
|
+
this.pollTimer = setInterval(() => {
|
|
781
|
+
void this.poll();
|
|
782
|
+
}, this.pollIntervalMs);
|
|
783
|
+
if (this.pollTimer.unref) this.pollTimer.unref();
|
|
784
|
+
}
|
|
785
|
+
/** Stop polling. */
|
|
786
|
+
stop() {
|
|
787
|
+
if (this.pollTimer) {
|
|
788
|
+
clearInterval(this.pollTimer);
|
|
789
|
+
this.pollTimer = null;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
// ─── Publishing ─────────────────────────────────────────────────────
|
|
793
|
+
/** Publish a key deletion event. */
|
|
794
|
+
async publishDelete(...keys) {
|
|
795
|
+
await this.publish({ type: "delete", keys });
|
|
796
|
+
}
|
|
797
|
+
/** Publish a prefix deletion event. */
|
|
798
|
+
async publishDeletePrefix(prefix) {
|
|
799
|
+
await this.publish({ type: "delete-prefix", prefix });
|
|
800
|
+
}
|
|
801
|
+
/** Publish a tag-based deletion event. */
|
|
802
|
+
async publishDeleteTags(tags) {
|
|
803
|
+
await this.publish({ type: "delete-tags", tags });
|
|
804
|
+
}
|
|
805
|
+
/** Publish a clear-all event. */
|
|
806
|
+
async publishClear() {
|
|
807
|
+
await this.publish({ type: "clear" });
|
|
808
|
+
}
|
|
809
|
+
async publish(event) {
|
|
810
|
+
await this.ready;
|
|
811
|
+
const id = crypto.randomUUID();
|
|
812
|
+
const now = Date.now();
|
|
813
|
+
await this.db.query(
|
|
814
|
+
`INSERT INTO _cache_invalidation_events (id, type, keys, prefix, tags, source_instance, created_at)
|
|
815
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
816
|
+
[
|
|
817
|
+
id,
|
|
818
|
+
event.type,
|
|
819
|
+
event.keys ?? null,
|
|
820
|
+
event.prefix ?? null,
|
|
821
|
+
event.tags ?? null,
|
|
822
|
+
this.instanceId,
|
|
823
|
+
now
|
|
824
|
+
]
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
// ─── Polling ────────────────────────────────────────────────────────
|
|
828
|
+
/** Poll for new events and apply them to the local cache store. */
|
|
829
|
+
async poll() {
|
|
830
|
+
await this.ready;
|
|
831
|
+
const logger = getCacheLogger();
|
|
832
|
+
const result = await this.db.query(
|
|
833
|
+
`SELECT id, type, keys, prefix, tags, source_instance, created_at
|
|
834
|
+
FROM _cache_invalidation_events
|
|
835
|
+
WHERE created_at >= $1 AND source_instance != $2
|
|
836
|
+
ORDER BY created_at ASC`,
|
|
837
|
+
[this.lastSeenTimestamp, this.instanceId]
|
|
838
|
+
);
|
|
839
|
+
let applied = 0;
|
|
840
|
+
for (const row of result.rows) {
|
|
841
|
+
if (this.processedAtBoundary.has(row.id)) continue;
|
|
842
|
+
const createdAt = Number(row.created_at);
|
|
843
|
+
if (createdAt > this.lastSeenTimestamp) {
|
|
844
|
+
this.lastSeenTimestamp = createdAt;
|
|
845
|
+
this.processedAtBoundary.clear();
|
|
846
|
+
}
|
|
847
|
+
this.processedAtBoundary.add(row.id);
|
|
848
|
+
try {
|
|
849
|
+
await this.applyEvent(row.type, row);
|
|
850
|
+
applied++;
|
|
851
|
+
} catch (error) {
|
|
852
|
+
logger.error(
|
|
853
|
+
"Failed to apply invalidation event",
|
|
854
|
+
error instanceof Error ? error : new Error(String(error))
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
await this.prune();
|
|
859
|
+
return applied;
|
|
860
|
+
}
|
|
861
|
+
async applyEvent(type, row) {
|
|
862
|
+
switch (type) {
|
|
863
|
+
case "delete":
|
|
864
|
+
if (row.keys && row.keys.length > 0) {
|
|
865
|
+
await this.store.delete(...row.keys);
|
|
866
|
+
}
|
|
867
|
+
break;
|
|
868
|
+
case "delete-prefix":
|
|
869
|
+
if (row.prefix) {
|
|
870
|
+
await this.store.deleteByPrefix(row.prefix);
|
|
871
|
+
}
|
|
872
|
+
break;
|
|
873
|
+
case "delete-tags":
|
|
874
|
+
if (row.tags && row.tags.length > 0) {
|
|
875
|
+
await this.store.deleteByTags(row.tags);
|
|
876
|
+
}
|
|
877
|
+
break;
|
|
878
|
+
case "clear":
|
|
879
|
+
await this.store.clear();
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
/** Remove events older than the TTL. */
|
|
884
|
+
async prune() {
|
|
885
|
+
const cutoff = Date.now() - this.eventTtlSeconds * 1e3;
|
|
886
|
+
const result = await this.db.query(
|
|
887
|
+
`WITH deleted AS (DELETE FROM _cache_invalidation_events WHERE created_at < $1 RETURNING 1)
|
|
888
|
+
SELECT count(*)::text AS count FROM deleted`,
|
|
889
|
+
[cutoff]
|
|
890
|
+
);
|
|
891
|
+
return Number.parseInt(result.rows[0]?.count ?? "0", 10);
|
|
892
|
+
}
|
|
893
|
+
/** Release resources. */
|
|
894
|
+
async close() {
|
|
895
|
+
this.stop();
|
|
896
|
+
}
|
|
897
|
+
};
|
|
733
898
|
export {
|
|
734
899
|
CDN_CACHE_PRESETS,
|
|
900
|
+
CacheInvalidationChannel,
|
|
735
901
|
DEFAULT_CDN_CONFIG,
|
|
736
902
|
EdgeRateLimiter,
|
|
737
903
|
ISR_PRESETS,
|
|
@@ -761,4 +927,3 @@ export {
|
|
|
761
927
|
warmCDNCache,
|
|
762
928
|
warmISRCache
|
|
763
929
|
};
|
|
764
|
-
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache Store Adapter Interface
|
|
3
|
+
*
|
|
4
|
+
* Unified interface for pluggable cache backends.
|
|
5
|
+
* Implementations: InMemoryCacheStore (Map), PGliteCacheStore (PostgreSQL-compatible).
|
|
6
|
+
*/
|
|
7
|
+
interface CacheEntry<T = unknown> {
|
|
8
|
+
key: string;
|
|
9
|
+
value: T;
|
|
10
|
+
expiresAt: number;
|
|
11
|
+
tags?: string[];
|
|
12
|
+
}
|
|
13
|
+
interface CacheStore {
|
|
14
|
+
/** Get a cached value by key. Returns null if missing or expired. */
|
|
15
|
+
get<T = unknown>(key: string): Promise<T | null>;
|
|
16
|
+
/** Set a value with TTL in seconds. Overwrites existing entries. */
|
|
17
|
+
set<T = unknown>(key: string, value: T, ttlSeconds: number, tags?: string[]): Promise<void>;
|
|
18
|
+
/** Delete one or more keys. Returns count of deleted entries. */
|
|
19
|
+
delete(...keys: string[]): Promise<number>;
|
|
20
|
+
/** Delete all entries whose key starts with the given prefix. */
|
|
21
|
+
deleteByPrefix(prefix: string): Promise<number>;
|
|
22
|
+
/** Delete all entries tagged with any of the given tags. */
|
|
23
|
+
deleteByTags(tags: string[]): Promise<number>;
|
|
24
|
+
/** Remove all entries from the store. */
|
|
25
|
+
clear(): Promise<void>;
|
|
26
|
+
/** Return approximate number of live (non-expired) entries. */
|
|
27
|
+
size(): Promise<number>;
|
|
28
|
+
/** Clean up expired entries. Called periodically or on demand. */
|
|
29
|
+
prune(): Promise<number>;
|
|
30
|
+
/** Tear down the store (close connections, free resources). */
|
|
31
|
+
close(): Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type { CacheStore as C, CacheEntry as a };
|
package/package.json
CHANGED
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revealui/cache",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Framework-agnostic CDN config, edge cache, ISR-style presets, and revalidation helpers. Compatible with NextRequest/NextResponse, Hono, and Cloudflare Workers via structural typing — no `next` peer dep.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"dependencies": {
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@revealui/security": "0.4.0"
|
|
8
|
+
},
|
|
7
9
|
"devDependencies": {
|
|
8
|
-
"@
|
|
10
|
+
"@electric-sql/pglite": "^0.4.5",
|
|
11
|
+
"@types/node": "^25.6.0",
|
|
9
12
|
"tsup": "^8.5.1",
|
|
10
|
-
"typescript": "^
|
|
11
|
-
"vitest": "^4.
|
|
12
|
-
"dev": "0.0
|
|
13
|
-
},
|
|
14
|
-
"peerDependencies": {
|
|
15
|
-
"next": "^14.0.0 || ^15.0.0 || ^16.0.0"
|
|
16
|
-
},
|
|
17
|
-
"peerDependenciesMeta": {
|
|
18
|
-
"next": {
|
|
19
|
-
"optional": true
|
|
20
|
-
}
|
|
13
|
+
"typescript": "^6.0.3",
|
|
14
|
+
"vitest": "^4.1.5",
|
|
15
|
+
"@revealui/dev": "0.1.0"
|
|
21
16
|
},
|
|
22
17
|
"engines": {
|
|
23
18
|
"node": ">=24.13.0"
|
|
@@ -26,6 +21,10 @@
|
|
|
26
21
|
".": {
|
|
27
22
|
"types": "./dist/index.d.ts",
|
|
28
23
|
"import": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./adapters": {
|
|
26
|
+
"types": "./dist/adapters/index.d.ts",
|
|
27
|
+
"import": "./dist/adapters/index.js"
|
|
29
28
|
}
|
|
30
29
|
},
|
|
31
30
|
"files": [
|
|
@@ -38,14 +37,32 @@
|
|
|
38
37
|
},
|
|
39
38
|
"type": "module",
|
|
40
39
|
"types": "./dist/index.d.ts",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/RevealUIStudio/revealui.git",
|
|
43
|
+
"directory": "packages/cache"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://revealui.com",
|
|
46
|
+
"author": "RevealUI Studio <founder@revealui.com>",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/RevealUIStudio/revealui/issues"
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"revealui",
|
|
52
|
+
"cache",
|
|
53
|
+
"cdn",
|
|
54
|
+
"edge-cache",
|
|
55
|
+
"isr",
|
|
56
|
+
"revalidation"
|
|
57
|
+
],
|
|
41
58
|
"scripts": {
|
|
42
59
|
"build": "tsup",
|
|
43
60
|
"clean": "rm -rf dist",
|
|
44
61
|
"dev": "tsup --watch",
|
|
45
62
|
"lint": "biome check .",
|
|
46
63
|
"lint:fix": "biome check --write .",
|
|
47
|
-
"test": "vitest run
|
|
48
|
-
"test:coverage": "vitest run --coverage --coverage.reporter=json-summary --coverage.reporter=html --coverage.reporter=text
|
|
64
|
+
"test": "vitest run",
|
|
65
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=json-summary --coverage.reporter=html --coverage.reporter=text",
|
|
49
66
|
"test:watch": "vitest",
|
|
50
67
|
"typecheck": "tsc --noEmit"
|
|
51
68
|
}
|