perspectapi-ts-sdk 2.0.0 → 2.1.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.
- package/README.md +82 -0
- package/dist/index.js +14 -0
- package/dist/index.mjs +14 -0
- package/package.json +1 -1
- package/src/cache/cache-manager.ts +17 -0
package/README.md
CHANGED
|
@@ -148,6 +148,88 @@ export default {
|
|
|
148
148
|
```
|
|
149
149
|
|
|
150
150
|
When PerspectAPI sends a webhook—or when your Worker mutates data directly—call `perspect.cache.invalidate({ tags: [...] })` using the tags emitted by the SDK (`products:site:<site>`, `content:slug:<site>:<slug>`, etc.) so stale entries are purged immediately.
|
|
151
|
+
|
|
152
|
+
### Webhook-driven cache invalidation
|
|
153
|
+
|
|
154
|
+
Most deployments rely on PerspectAPI webhooks to bust cache entries whenever content changes. Your app needs an HTTP endpoint that:
|
|
155
|
+
|
|
156
|
+
1. Verifies the webhook signature (or shared secret).
|
|
157
|
+
2. Maps the webhook payload to the cache tags that were used when reading data.
|
|
158
|
+
3. Calls `client.cache.invalidate({ tags })`.
|
|
159
|
+
|
|
160
|
+
Below is a minimal Cloudflare Worker handler; the same flow works in Express, Fastify, etc.—just swap the request parsing.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
// worker.ts
|
|
164
|
+
import { PerspectApiClient } from 'perspectapi-ts-sdk';
|
|
165
|
+
|
|
166
|
+
const kvAdapter = (kv: KVNamespace) => ({
|
|
167
|
+
get: (key: string) => kv.get(key),
|
|
168
|
+
set: (key: string, value: string, options?: { ttlSeconds?: number }) =>
|
|
169
|
+
options?.ttlSeconds
|
|
170
|
+
? kv.put(key, value, { expirationTtl: options.ttlSeconds })
|
|
171
|
+
: kv.put(key, value),
|
|
172
|
+
delete: (key: string) => kv.delete(key),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const perspect = new PerspectApiClient({
|
|
176
|
+
baseUrl: env.PERSPECT_API_URL,
|
|
177
|
+
apiKey: env.PERSPECT_API_KEY,
|
|
178
|
+
cache: { adapter: kvAdapter(env.PERSPECT_CACHE), defaultTtlSeconds: 300 },
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
type WebhookEvent =
|
|
182
|
+
| { type: 'product.updated'; site: string; slug?: string; id?: number }
|
|
183
|
+
| { type: 'content.published'; site: string; slug: string; id: number }
|
|
184
|
+
| { type: 'category.updated'; site: string; slug: string };
|
|
185
|
+
|
|
186
|
+
const tagMap: Record<string, (e: WebhookEvent) => string[]> = {
|
|
187
|
+
'product.updated': event => [
|
|
188
|
+
`products`,
|
|
189
|
+
`products:site:${event.site}`,
|
|
190
|
+
event.slug ? `products:slug:${event.site}:${event.slug}` : '',
|
|
191
|
+
event.id ? `products:id:${event.id}` : '',
|
|
192
|
+
],
|
|
193
|
+
'content.published': event => [
|
|
194
|
+
`content`,
|
|
195
|
+
`content:site:${event.site}`,
|
|
196
|
+
`content:slug:${event.site}:${event.slug}`,
|
|
197
|
+
`content:id:${event.id}`,
|
|
198
|
+
],
|
|
199
|
+
'category.updated': event => [
|
|
200
|
+
`categories`,
|
|
201
|
+
`categories:site:${event.site}`,
|
|
202
|
+
`categories:product:${event.site}:${event.slug}`,
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
export default {
|
|
207
|
+
async fetch(request: Request, env: Env) {
|
|
208
|
+
const url = new URL(request.url);
|
|
209
|
+
|
|
210
|
+
if (url.pathname === '/webhooks/perspect' && request.method === 'POST') {
|
|
211
|
+
const event = (await request.json()) as WebhookEvent;
|
|
212
|
+
|
|
213
|
+
// TODO: validate shared secret / signature before proceeding
|
|
214
|
+
|
|
215
|
+
const buildTags = tagMap[event.type];
|
|
216
|
+
if (buildTags) {
|
|
217
|
+
const tags = buildTags(event).filter(Boolean);
|
|
218
|
+
if (tags.length) {
|
|
219
|
+
await perspect.cache.invalidate({ tags });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return new Response(null, { status: 202 });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ...rest of your app
|
|
227
|
+
return new Response('ok');
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
> 🔁 Adjust the `WebhookEvent` union and `tagMap` to match the actual payloads you receive. PerspectAPI webhooks also carry version IDs and environment metadata that you can use for more granular targeting if needed.
|
|
151
233
|
```
|
|
152
234
|
|
|
153
235
|
## Image Transformations
|
package/dist/index.js
CHANGED
|
@@ -354,6 +354,7 @@ var CacheManager = class {
|
|
|
354
354
|
}
|
|
355
355
|
async getOrSet(key, resolveValue, policy) {
|
|
356
356
|
if (!this.enabled || policy?.skipCache) {
|
|
357
|
+
console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
|
|
357
358
|
const value2 = await resolveValue();
|
|
358
359
|
if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
|
|
359
360
|
await this.set(key, value2, policy);
|
|
@@ -365,12 +366,16 @@ var CacheManager = class {
|
|
|
365
366
|
if (cachedRaw) {
|
|
366
367
|
const entry = this.deserialize(cachedRaw);
|
|
367
368
|
if (!entry.expiresAt || entry.expiresAt > Date.now()) {
|
|
369
|
+
console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
|
|
368
370
|
return entry.value;
|
|
369
371
|
}
|
|
372
|
+
console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
|
|
370
373
|
await this.adapter.delete(namespacedKey);
|
|
371
374
|
if (entry.tags?.length) {
|
|
372
375
|
await this.removeKeyFromTags(namespacedKey, entry.tags);
|
|
373
376
|
}
|
|
377
|
+
} else {
|
|
378
|
+
console.log("[Cache] \u2717 MISS", { key });
|
|
374
379
|
}
|
|
375
380
|
const value = await resolveValue();
|
|
376
381
|
await this.set(key, value, policy);
|
|
@@ -388,6 +393,7 @@ var CacheManager = class {
|
|
|
388
393
|
tags: options?.tags,
|
|
389
394
|
metadata: options?.metadata
|
|
390
395
|
};
|
|
396
|
+
console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
|
|
391
397
|
await this.adapter.set(namespacedKey, this.serialize(entry), {
|
|
392
398
|
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
|
|
393
399
|
});
|
|
@@ -406,34 +412,42 @@ var CacheManager = class {
|
|
|
406
412
|
if (!this.enabled) {
|
|
407
413
|
return;
|
|
408
414
|
}
|
|
415
|
+
let totalInvalidated = 0;
|
|
409
416
|
if (options.keys?.length) {
|
|
410
417
|
const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
|
|
418
|
+
console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
|
|
411
419
|
if (this.adapter.deleteMany) {
|
|
412
420
|
await this.adapter.deleteMany(namespacedKeys);
|
|
413
421
|
} else {
|
|
414
422
|
await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
|
|
415
423
|
}
|
|
424
|
+
totalInvalidated += options.keys.length;
|
|
416
425
|
}
|
|
417
426
|
if (options.tags?.length) {
|
|
427
|
+
console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
|
|
418
428
|
await Promise.all(
|
|
419
429
|
options.tags.map(async (tag) => {
|
|
420
430
|
const tagKey = this.tagKey(tag);
|
|
421
431
|
const payload = await this.adapter.get(tagKey);
|
|
422
432
|
if (!payload) {
|
|
433
|
+
console.log("[Cache] No entries for tag", { tag });
|
|
423
434
|
return;
|
|
424
435
|
}
|
|
425
436
|
const keys = this.deserializeTagSet(payload);
|
|
426
437
|
if (keys.length) {
|
|
438
|
+
console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
|
|
427
439
|
if (this.adapter.deleteMany) {
|
|
428
440
|
await this.adapter.deleteMany(keys);
|
|
429
441
|
} else {
|
|
430
442
|
await Promise.all(keys.map((key) => this.adapter.delete(key)));
|
|
431
443
|
}
|
|
444
|
+
totalInvalidated += keys.length;
|
|
432
445
|
}
|
|
433
446
|
await this.adapter.delete(tagKey);
|
|
434
447
|
})
|
|
435
448
|
);
|
|
436
449
|
}
|
|
450
|
+
console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
|
|
437
451
|
}
|
|
438
452
|
namespacedKey(key) {
|
|
439
453
|
return `${this.keyPrefix}:${key}`;
|
package/dist/index.mjs
CHANGED
|
@@ -293,6 +293,7 @@ var CacheManager = class {
|
|
|
293
293
|
}
|
|
294
294
|
async getOrSet(key, resolveValue, policy) {
|
|
295
295
|
if (!this.enabled || policy?.skipCache) {
|
|
296
|
+
console.log("[Cache] Cache disabled or skipped", { key, enabled: this.enabled, skipCache: policy?.skipCache });
|
|
296
297
|
const value2 = await resolveValue();
|
|
297
298
|
if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
|
|
298
299
|
await this.set(key, value2, policy);
|
|
@@ -304,12 +305,16 @@ var CacheManager = class {
|
|
|
304
305
|
if (cachedRaw) {
|
|
305
306
|
const entry = this.deserialize(cachedRaw);
|
|
306
307
|
if (!entry.expiresAt || entry.expiresAt > Date.now()) {
|
|
308
|
+
console.log("[Cache] \u2713 HIT", { key, tags: entry.tags });
|
|
307
309
|
return entry.value;
|
|
308
310
|
}
|
|
311
|
+
console.log("[Cache] \u2717 EXPIRED", { key, expiresAt: new Date(entry.expiresAt) });
|
|
309
312
|
await this.adapter.delete(namespacedKey);
|
|
310
313
|
if (entry.tags?.length) {
|
|
311
314
|
await this.removeKeyFromTags(namespacedKey, entry.tags);
|
|
312
315
|
}
|
|
316
|
+
} else {
|
|
317
|
+
console.log("[Cache] \u2717 MISS", { key });
|
|
313
318
|
}
|
|
314
319
|
const value = await resolveValue();
|
|
315
320
|
await this.set(key, value, policy);
|
|
@@ -327,6 +332,7 @@ var CacheManager = class {
|
|
|
327
332
|
tags: options?.tags,
|
|
328
333
|
metadata: options?.metadata
|
|
329
334
|
};
|
|
335
|
+
console.log("[Cache] SET", { key, ttlSeconds, tags: options?.tags });
|
|
330
336
|
await this.adapter.set(namespacedKey, this.serialize(entry), {
|
|
331
337
|
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : void 0
|
|
332
338
|
});
|
|
@@ -345,34 +351,42 @@ var CacheManager = class {
|
|
|
345
351
|
if (!this.enabled) {
|
|
346
352
|
return;
|
|
347
353
|
}
|
|
354
|
+
let totalInvalidated = 0;
|
|
348
355
|
if (options.keys?.length) {
|
|
349
356
|
const namespacedKeys = options.keys.map((key) => this.namespacedKey(key));
|
|
357
|
+
console.log("[Cache] INVALIDATE by keys", { count: options.keys.length, keys: options.keys });
|
|
350
358
|
if (this.adapter.deleteMany) {
|
|
351
359
|
await this.adapter.deleteMany(namespacedKeys);
|
|
352
360
|
} else {
|
|
353
361
|
await Promise.all(namespacedKeys.map((key) => this.adapter.delete(key)));
|
|
354
362
|
}
|
|
363
|
+
totalInvalidated += options.keys.length;
|
|
355
364
|
}
|
|
356
365
|
if (options.tags?.length) {
|
|
366
|
+
console.log("[Cache] INVALIDATE by tags", { tags: options.tags });
|
|
357
367
|
await Promise.all(
|
|
358
368
|
options.tags.map(async (tag) => {
|
|
359
369
|
const tagKey = this.tagKey(tag);
|
|
360
370
|
const payload = await this.adapter.get(tagKey);
|
|
361
371
|
if (!payload) {
|
|
372
|
+
console.log("[Cache] No entries for tag", { tag });
|
|
362
373
|
return;
|
|
363
374
|
}
|
|
364
375
|
const keys = this.deserializeTagSet(payload);
|
|
365
376
|
if (keys.length) {
|
|
377
|
+
console.log("[Cache] Invalidating entries for tag", { tag, count: keys.length });
|
|
366
378
|
if (this.adapter.deleteMany) {
|
|
367
379
|
await this.adapter.deleteMany(keys);
|
|
368
380
|
} else {
|
|
369
381
|
await Promise.all(keys.map((key) => this.adapter.delete(key)));
|
|
370
382
|
}
|
|
383
|
+
totalInvalidated += keys.length;
|
|
371
384
|
}
|
|
372
385
|
await this.adapter.delete(tagKey);
|
|
373
386
|
})
|
|
374
387
|
);
|
|
375
388
|
}
|
|
389
|
+
console.log("[Cache] \u2713 INVALIDATED", { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
|
|
376
390
|
}
|
|
377
391
|
namespacedKey(key) {
|
|
378
392
|
return `${this.keyPrefix}:${key}`;
|
package/package.json
CHANGED
|
@@ -75,6 +75,7 @@ export class CacheManager {
|
|
|
75
75
|
policy?: CachePolicy
|
|
76
76
|
): Promise<T> {
|
|
77
77
|
if (!this.enabled || policy?.skipCache) {
|
|
78
|
+
console.log('[Cache] Cache disabled or skipped', { key, enabled: this.enabled, skipCache: policy?.skipCache });
|
|
78
79
|
const value = await resolveValue();
|
|
79
80
|
|
|
80
81
|
if (this.enabled && !policy?.skipCache && policy?.ttlSeconds !== 0) {
|
|
@@ -91,14 +92,18 @@ export class CacheManager {
|
|
|
91
92
|
const entry = this.deserialize<T>(cachedRaw);
|
|
92
93
|
|
|
93
94
|
if (!entry.expiresAt || entry.expiresAt > Date.now()) {
|
|
95
|
+
console.log('[Cache] ✓ HIT', { key, tags: entry.tags });
|
|
94
96
|
return entry.value;
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
// Expired entry
|
|
100
|
+
console.log('[Cache] ✗ EXPIRED', { key, expiresAt: new Date(entry.expiresAt) });
|
|
98
101
|
await this.adapter.delete(namespacedKey);
|
|
99
102
|
if (entry.tags?.length) {
|
|
100
103
|
await this.removeKeyFromTags(namespacedKey, entry.tags);
|
|
101
104
|
}
|
|
105
|
+
} else {
|
|
106
|
+
console.log('[Cache] ✗ MISS', { key });
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
const value = await resolveValue();
|
|
@@ -122,6 +127,8 @@ export class CacheManager {
|
|
|
122
127
|
metadata: options?.metadata,
|
|
123
128
|
};
|
|
124
129
|
|
|
130
|
+
console.log('[Cache] SET', { key, ttlSeconds, tags: options?.tags });
|
|
131
|
+
|
|
125
132
|
await this.adapter.set(namespacedKey, this.serialize(entry), {
|
|
126
133
|
ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
|
|
127
134
|
});
|
|
@@ -144,37 +151,47 @@ export class CacheManager {
|
|
|
144
151
|
return;
|
|
145
152
|
}
|
|
146
153
|
|
|
154
|
+
let totalInvalidated = 0;
|
|
155
|
+
|
|
147
156
|
if (options.keys?.length) {
|
|
148
157
|
const namespacedKeys = options.keys.map(key => this.namespacedKey(key));
|
|
158
|
+
console.log('[Cache] INVALIDATE by keys', { count: options.keys.length, keys: options.keys });
|
|
149
159
|
if (this.adapter.deleteMany) {
|
|
150
160
|
await this.adapter.deleteMany(namespacedKeys);
|
|
151
161
|
} else {
|
|
152
162
|
await Promise.all(namespacedKeys.map(key => this.adapter.delete(key)));
|
|
153
163
|
}
|
|
164
|
+
totalInvalidated += options.keys.length;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
if (options.tags?.length) {
|
|
168
|
+
console.log('[Cache] INVALIDATE by tags', { tags: options.tags });
|
|
157
169
|
await Promise.all(
|
|
158
170
|
options.tags.map(async tag => {
|
|
159
171
|
const tagKey = this.tagKey(tag);
|
|
160
172
|
const payload = await this.adapter.get(tagKey);
|
|
161
173
|
if (!payload) {
|
|
174
|
+
console.log('[Cache] No entries for tag', { tag });
|
|
162
175
|
return;
|
|
163
176
|
}
|
|
164
177
|
|
|
165
178
|
const keys = this.deserializeTagSet(payload);
|
|
166
179
|
if (keys.length) {
|
|
180
|
+
console.log('[Cache] Invalidating entries for tag', { tag, count: keys.length });
|
|
167
181
|
if (this.adapter.deleteMany) {
|
|
168
182
|
await this.adapter.deleteMany(keys);
|
|
169
183
|
} else {
|
|
170
184
|
await Promise.all(keys.map(key => this.adapter.delete(key)));
|
|
171
185
|
}
|
|
186
|
+
totalInvalidated += keys.length;
|
|
172
187
|
}
|
|
173
188
|
|
|
174
189
|
await this.adapter.delete(tagKey);
|
|
175
190
|
})
|
|
176
191
|
);
|
|
177
192
|
}
|
|
193
|
+
|
|
194
|
+
console.log('[Cache] ✓ INVALIDATED', { totalEntries: totalInvalidated, keys: options.keys?.length || 0, tags: options.tags?.length || 0 });
|
|
178
195
|
}
|
|
179
196
|
|
|
180
197
|
private namespacedKey(key: string): string {
|