@ultimat3/cache 1.0.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/src/lru.ts ADDED
@@ -0,0 +1,255 @@
1
+ // Tier 1: in-process LRU bounded by BYTES, not entry count — an entry count budget is a
2
+ // memory leak with extra steps once payload sizes vary. Doubly-linked list + Map for O(1)
3
+ // touch/evict, plus a tag -> keys index so `invalidateTags` never scans the whole cache.
4
+ // Zero dependencies: this must work in the `x dev` process with nothing installed.
5
+
6
+ import type { Clock } from '@ultimat3/core';
7
+ import { systemClock } from '@ultimat3/core';
8
+ import { CacheTooLargeError } from './errors';
9
+ import type { CacheTag } from './tags';
10
+ import { serializeTag } from './tags';
11
+ import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
12
+ import { nowMs } from './tiers';
13
+
14
+ export interface LruOptions {
15
+ /** Byte budget for the whole tier. Default 64 MiB. */
16
+ readonly maxBytes?: number;
17
+ /** Applied when a `set` omits `ttlMs`. Default 60s — stale-by-default is safer here. */
18
+ readonly defaultTtlMs?: number;
19
+ readonly clock?: Clock;
20
+ }
21
+
22
+ interface LruNode {
23
+ key: string;
24
+ value: unknown;
25
+ bytes: number;
26
+ /** Epoch ms, or `Number.POSITIVE_INFINITY` for no expiry. */
27
+ expiresAt: number;
28
+ tags: readonly CacheTag[];
29
+ prev: LruNode | undefined;
30
+ next: LruNode | undefined;
31
+ }
32
+
33
+ const encoder = new TextEncoder();
34
+
35
+ /** Cheap, deterministic size estimate. Exact heap cost is unknowable; consistency matters. */
36
+ export function estimateBytes(value: unknown): number {
37
+ if (value === undefined) return 0;
38
+ if (typeof value === 'string') return encoder.encode(value).byteLength;
39
+ if (value instanceof ArrayBuffer) return value.byteLength;
40
+ if (ArrayBuffer.isView(value)) return value.byteLength;
41
+ try {
42
+ return encoder.encode(JSON.stringify(value) ?? '').byteLength;
43
+ } catch {
44
+ return 1024;
45
+ }
46
+ }
47
+
48
+ function index(into: Map<string, Set<string>>, bucket: string, key: string): void {
49
+ let set = into.get(bucket);
50
+ if (set === undefined) {
51
+ set = new Set();
52
+ into.set(bucket, set);
53
+ }
54
+ set.add(key);
55
+ }
56
+
57
+ function deindex(from: Map<string, Set<string>>, bucket: string, key: string): void {
58
+ const set = from.get(bucket);
59
+ if (set === undefined) return;
60
+ set.delete(key);
61
+ if (set.size === 0) from.delete(bucket);
62
+ }
63
+
64
+ export interface LruStats {
65
+ readonly entries: number;
66
+ readonly bytes: number;
67
+ readonly maxBytes: number;
68
+ readonly hits: number;
69
+ readonly misses: number;
70
+ readonly evictions: number;
71
+ }
72
+
73
+ export class LruCache {
74
+ private readonly map = new Map<string, LruNode>();
75
+ private readonly tagIndex = new Map<string, Set<string>>();
76
+ private readonly entityIndex = new Map<string, Set<string>>();
77
+ private readonly maxBytes: number;
78
+ private readonly defaultTtlMs: number;
79
+ private readonly clock: Clock;
80
+ private head: LruNode | undefined;
81
+ private tail: LruNode | undefined;
82
+ private bytes = 0;
83
+ private hits = 0;
84
+ private misses = 0;
85
+ private evictions = 0;
86
+
87
+ constructor(options: LruOptions = {}) {
88
+ this.maxBytes = options.maxBytes ?? 64 * 1024 * 1024;
89
+ this.defaultTtlMs = options.defaultTtlMs ?? 60_000;
90
+ this.clock = options.clock ?? systemClock;
91
+ }
92
+
93
+ get<T>(key: string): CacheEntry<T> | undefined {
94
+ const node = this.map.get(key);
95
+ if (node === undefined) {
96
+ this.misses += 1;
97
+ return undefined;
98
+ }
99
+ if (node.expiresAt <= nowMs(this.clock)) {
100
+ this.unlink(node);
101
+ this.misses += 1;
102
+ return undefined;
103
+ }
104
+ this.touch(node);
105
+ this.hits += 1;
106
+ return {
107
+ value: node.value as T,
108
+ tags: node.tags,
109
+ ...(node.expiresAt === Number.POSITIVE_INFINITY ? {} : { expiresAt: node.expiresAt }),
110
+ };
111
+ }
112
+
113
+ set<T>(key: string, value: T, options: CacheSetOptions = {}): void {
114
+ const bytes = estimateBytes(value) + encoder.encode(key).byteLength;
115
+ if (bytes > this.maxBytes) {
116
+ throw new CacheTooLargeError({ key, bytes, maxBytes: this.maxBytes, tier: 'lru' });
117
+ }
118
+
119
+ const existing = this.map.get(key);
120
+ if (existing !== undefined) this.unlink(existing);
121
+
122
+ const ttl = options.ttlMs ?? this.defaultTtlMs;
123
+ const node: LruNode = {
124
+ key,
125
+ value,
126
+ bytes,
127
+ expiresAt: ttl <= 0 ? Number.POSITIVE_INFINITY : nowMs(this.clock) + ttl,
128
+ tags: options.tags ?? [],
129
+ prev: undefined,
130
+ next: undefined,
131
+ };
132
+
133
+ this.map.set(key, node);
134
+ this.bytes += bytes;
135
+ this.pushFront(node);
136
+ for (const owned of node.tags) {
137
+ index(this.tagIndex, serializeTag(owned), key);
138
+ index(this.entityIndex, owned.entity, key);
139
+ }
140
+
141
+ while (this.bytes > this.maxBytes && this.tail !== undefined) {
142
+ this.unlink(this.tail);
143
+ this.evictions += 1;
144
+ }
145
+ }
146
+
147
+ del(key: string): boolean {
148
+ const node = this.map.get(key);
149
+ if (node === undefined) return false;
150
+ this.unlink(node);
151
+ return true;
152
+ }
153
+
154
+ /** Only entries carrying a matching tag are dropped; untagged neighbours survive. */
155
+ invalidateTags(tags: readonly CacheTag[]): readonly string[] {
156
+ const candidates = new Set<string>();
157
+ for (const requested of tags) {
158
+ if (requested.id === undefined) {
159
+ // Collection bust: every row of that entity goes too.
160
+ for (const key of this.entityIndex.get(requested.entity) ?? []) candidates.add(key);
161
+ continue;
162
+ }
163
+ // Row bust: the row itself plus anything tagged with the bare collection.
164
+ for (const key of this.tagIndex.get(serializeTag(requested)) ?? []) candidates.add(key);
165
+ for (const key of this.tagIndex.get(requested.entity) ?? []) candidates.add(key);
166
+ }
167
+
168
+ const removed: string[] = [];
169
+ for (const key of candidates) {
170
+ if (this.del(key)) removed.push(key);
171
+ }
172
+ return removed;
173
+ }
174
+
175
+ keys(): readonly string[] {
176
+ const out: string[] = [];
177
+ for (let node = this.head; node !== undefined; node = node.next) out.push(node.key);
178
+ return out;
179
+ }
180
+
181
+ clear(): void {
182
+ this.map.clear();
183
+ this.tagIndex.clear();
184
+ this.entityIndex.clear();
185
+ this.head = undefined;
186
+ this.tail = undefined;
187
+ this.bytes = 0;
188
+ }
189
+
190
+ stats(): LruStats {
191
+ return {
192
+ entries: this.map.size,
193
+ bytes: this.bytes,
194
+ maxBytes: this.maxBytes,
195
+ hits: this.hits,
196
+ misses: this.misses,
197
+ evictions: this.evictions,
198
+ };
199
+ }
200
+
201
+ private pushFront(node: LruNode): void {
202
+ node.prev = undefined;
203
+ node.next = this.head;
204
+ if (this.head !== undefined) this.head.prev = node;
205
+ this.head = node;
206
+ if (this.tail === undefined) this.tail = node;
207
+ }
208
+
209
+ private touch(node: LruNode): void {
210
+ if (this.head === node) return;
211
+ this.detach(node);
212
+ this.pushFront(node);
213
+ }
214
+
215
+ private detach(node: LruNode): void {
216
+ if (node.prev !== undefined) node.prev.next = node.next;
217
+ else if (this.head === node) this.head = node.next;
218
+ if (node.next !== undefined) node.next.prev = node.prev;
219
+ else if (this.tail === node) this.tail = node.prev;
220
+ node.prev = undefined;
221
+ node.next = undefined;
222
+ }
223
+
224
+ private unlink(node: LruNode): void {
225
+ this.detach(node);
226
+ this.map.delete(node.key);
227
+ this.bytes -= node.bytes;
228
+ for (const owned of node.tags) {
229
+ deindex(this.tagIndex, serializeTag(owned), node.key);
230
+ deindex(this.entityIndex, owned.entity, node.key);
231
+ }
232
+ }
233
+ }
234
+
235
+ export function createLruTier(options: LruOptions = {}): CacheTier & { readonly cache: LruCache } {
236
+ const cache = new LruCache(options);
237
+ return {
238
+ name: 'lru',
239
+ cache,
240
+ get<T>(key: string) {
241
+ return Promise.resolve(cache.get<T>(key));
242
+ },
243
+ set<T>(key: string, value: T, setOptions?: CacheSetOptions) {
244
+ cache.set(key, value, setOptions ?? {});
245
+ return Promise.resolve();
246
+ },
247
+ del(key: string) {
248
+ cache.del(key);
249
+ return Promise.resolve();
250
+ },
251
+ invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
252
+ return Promise.resolve({ tier: 'lru', keys: cache.invalidateTags(tags) });
253
+ },
254
+ };
255
+ }
package/src/memo.ts ADDED
@@ -0,0 +1,83 @@
1
+ // Tier 0: per-request memoization. Keyed off the ALS context object itself via a WeakMap,
2
+ // so the store dies with the request — there is no `clear()` to forget, no cross-request
3
+ // leak, and no lifecycle for an agent to get wrong. Outside a request (worker boot, a
4
+ // script) the tier degrades to a no-op rather than throwing: memoization is never required
5
+ // for correctness.
6
+
7
+ import type { Ctx } from '@ultimat3/core';
8
+ import { useContext } from '@ultimat3/core';
9
+ import type { CacheTag } from './tags';
10
+ import { tagsIntersect } from './tags';
11
+ import type { CacheEntry, CacheSetOptions, CacheTier, TierInvalidation } from './tiers';
12
+
13
+ type MemoStore = Map<string, CacheEntry<unknown>>;
14
+
15
+ const stores = new WeakMap<object, MemoStore>();
16
+
17
+ function currentCtx(): object | undefined {
18
+ try {
19
+ const ctx: Ctx = useContext();
20
+ return typeof ctx === 'object' && ctx !== null ? (ctx as object) : undefined;
21
+ } catch {
22
+ // No ambient context: not a request. Nothing to memoize against.
23
+ return undefined;
24
+ }
25
+ }
26
+
27
+ function storeFor(create: boolean): MemoStore | undefined {
28
+ const ctx = currentCtx();
29
+ if (ctx === undefined) return undefined;
30
+ const existing = stores.get(ctx);
31
+ if (existing !== undefined) return existing;
32
+ if (!create) return undefined;
33
+ const fresh: MemoStore = new Map();
34
+ stores.set(ctx, fresh);
35
+ return fresh;
36
+ }
37
+
38
+ /** Escape hatch for `x dev`'s long-lived contexts; a normal request never calls this. */
39
+ export function clearMemo(): void {
40
+ const ctx = currentCtx();
41
+ if (ctx !== undefined) stores.delete(ctx);
42
+ }
43
+
44
+ export function memoSize(): number {
45
+ return storeFor(false)?.size ?? 0;
46
+ }
47
+
48
+ export function createMemoTier(): CacheTier {
49
+ return {
50
+ name: 'request-memo',
51
+
52
+ get<T>(key: string): Promise<CacheEntry<T> | undefined> {
53
+ const entry = storeFor(false)?.get(key);
54
+ // No TTL check: a request is shorter than any meaningful TTL.
55
+ return Promise.resolve(entry as CacheEntry<T> | undefined);
56
+ },
57
+
58
+ set<T>(key: string, value: T, options?: CacheSetOptions): Promise<void> {
59
+ storeFor(true)?.set(key, { value, tags: options?.tags ?? [] });
60
+ return Promise.resolve();
61
+ },
62
+
63
+ del(key: string): Promise<void> {
64
+ storeFor(false)?.delete(key);
65
+ return Promise.resolve();
66
+ },
67
+
68
+ invalidateTags(tags: readonly CacheTag[]): Promise<TierInvalidation> {
69
+ const store = storeFor(false);
70
+ if (store === undefined) {
71
+ return Promise.resolve({ tier: 'request-memo', keys: [], skipped: 'no request context' });
72
+ }
73
+ const keys: string[] = [];
74
+ for (const [key, entry] of store) {
75
+ if (tagsIntersect(tags, entry.tags)) {
76
+ store.delete(key);
77
+ keys.push(key);
78
+ }
79
+ }
80
+ return Promise.resolve({ tier: 'request-memo', keys });
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,138 @@
1
+ // Single responsibility: Cloudflare's cache-tag purge. One `POST /zones/<id>/purge_cache` per
2
+ // batch of tags, the same call with `purge_everything` for the whole zone. Cloudflare's cache
3
+ // tags ARE Ultimate's wire tags, so a `Cache-Tag` response header and an `invalidates: [tag.post]`
4
+ // name the same string — the alternative, purging by URL, would need a route list nobody keeps.
5
+
6
+ import type { PurgeDriver } from './cdn';
7
+ import { CachePurgeFailedError } from './errors';
8
+ import type { PurgeFetch } from './purge-http';
9
+ import {
10
+ assertPurgeableKeys,
11
+ chunked,
12
+ DEFAULT_PURGE_TIMEOUT_MS,
13
+ defaultPurgeFetch,
14
+ detailFrom,
15
+ isRecord,
16
+ isRetryableStatus,
17
+ purgeBody,
18
+ purgePost,
19
+ requireCredential,
20
+ } from './purge-http';
21
+
22
+ export const CLOUDFLARE_API_URL = 'https://api.cloudflare.com/client/v4';
23
+
24
+ /** Cloudflare takes 30 cache tags per purge call; a longer list is more requests, not a refusal. */
25
+ export const CLOUDFLARE_MAX_TAGS_PER_REQUEST = 30;
26
+
27
+ export interface CloudflarePurgeOptions {
28
+ /** Read from `CLOUDFLARE_API_TOKEN`; needs the zone "Cache Purge" permission. */
29
+ readonly apiToken: string;
30
+ /** Read from `CLOUDFLARE_ZONE_ID` — the zone this deployment is served from. */
31
+ readonly zoneId: string;
32
+ /** Override for a proxy or a test double. Defaults to `CLOUDFLARE_API_URL`. */
33
+ readonly baseUrl?: string | undefined;
34
+ readonly timeoutMs?: number | undefined;
35
+ /** Injected in tests; production uses the global. */
36
+ readonly fetch?: PurgeFetch | undefined;
37
+ }
38
+
39
+ // Every branch names the env key to edit, the call to narrow, or a command to run. The 429 named
40
+ // none of them: the ceiling is per zone and not raisable from here, so the only lever is the
41
+ // `invalidates` list that decides how many 30-tag requests one write sends. `retryable` already
42
+ // says the same purge can land — the fix is what stops the next write hitting the wall again.
43
+ const fixFor = (status: number): string => {
44
+ if (status === 401 || status === 403) {
45
+ return 'set CLOUDFLARE_API_TOKEN in .env.production to a token holding the zone "Cache Purge" permission';
46
+ }
47
+ if (status === 400) {
48
+ return 'unset CLOUDFLARE_API_TOKEN in .env.production to purge nothing — purge by cache tag needs an Enterprise zone';
49
+ }
50
+ if (status === 404) {
51
+ return 'set CLOUDFLARE_ZONE_ID in .env.production to the zone id on the Cloudflare dashboard overview page';
52
+ }
53
+ if (status === 429) {
54
+ return "narrow the action's cache.invalidates to fewer tag(...) entries — the zone allows 1000 purge calls per minute";
55
+ }
56
+ return 'curl -sS -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" https://api.cloudflare.com/client/v4/zones/$CLOUDFLARE_ZONE_ID';
57
+ };
58
+
59
+ /** Cloudflare's own errors, when it sent any: `{"errors":[{"code":1122,"message":"…"}]}`. */
60
+ function messagesFrom(payload: unknown): string | undefined {
61
+ if (!isRecord(payload)) return undefined;
62
+ const errors = payload['errors'];
63
+ if (!Array.isArray(errors) || errors.length === 0) return undefined;
64
+ const messages = errors
65
+ .map((entry) =>
66
+ isRecord(entry) && typeof entry['message'] === 'string' ? entry['message'] : undefined,
67
+ )
68
+ .filter((message): message is string => message !== undefined);
69
+ return messages.length > 0 ? messages.join('; ') : undefined;
70
+ }
71
+
72
+ export function cloudflarePurgeDriver(options: CloudflarePurgeOptions): PurgeDriver {
73
+ const apiToken = requireCredential(options.apiToken, 'CLOUDFLARE_API_TOKEN', 'cloudflare');
74
+ const zoneId = requireCredential(options.zoneId, 'CLOUDFLARE_ZONE_ID', 'cloudflare');
75
+ const baseUrl = options.baseUrl ?? CLOUDFLARE_API_URL;
76
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PURGE_TIMEOUT_MS;
77
+ const doFetch = options.fetch ?? defaultPurgeFetch;
78
+ const headers = { Authorization: `Bearer ${apiToken}` };
79
+
80
+ const post = (body: unknown): Promise<Response> =>
81
+ purgePost({
82
+ driver: 'cloudflare',
83
+ url: `${baseUrl}/zones/${zoneId}/purge_cache`,
84
+ headers,
85
+ body,
86
+ fetch: doFetch,
87
+ timeoutMs,
88
+ });
89
+
90
+ /**
91
+ * Cloudflare answers a refusal with HTTP 200 and `"success": false`, so `response.ok` alone
92
+ * would read a rejected purge as a completed one and leave the edge stale with no failure
93
+ * anywhere. Both halves are checked, and only here.
94
+ */
95
+ const settle = async (response: Response): Promise<void> => {
96
+ const body = await purgeBody(response);
97
+ if (!response.ok) {
98
+ throw new CachePurgeFailedError({
99
+ driver: 'cloudflare',
100
+ detail: messagesFrom(body.json) ?? detailFrom(body),
101
+ status: response.status,
102
+ retryable: isRetryableStatus(response.status),
103
+ fix: fixFor(response.status),
104
+ });
105
+ }
106
+ if (isRecord(body.json) && body.json['success'] === false) {
107
+ throw new CachePurgeFailedError({
108
+ driver: 'cloudflare',
109
+ detail:
110
+ messagesFrom(body.json) ?? 'the api answered 200 with success: false and no message',
111
+ status: response.status,
112
+ retryable: false,
113
+ fix: fixFor(400),
114
+ });
115
+ }
116
+ };
117
+
118
+ return {
119
+ name: 'cloudflare',
120
+
121
+ async purge(keys: readonly string[]): Promise<readonly string[]> {
122
+ if (keys.length === 0) return [];
123
+ assertPurgeableKeys('cloudflare', keys);
124
+ const accepted: string[] = [];
125
+ // Sequential on purpose: the zone rate-limits purges, and nothing downstream reads a
126
+ // purge before it lands, so parallelism would buy latency the caller never waits on.
127
+ for (const batch of chunked('cloudflare', keys, CLOUDFLARE_MAX_TAGS_PER_REQUEST)) {
128
+ await settle(await post({ tags: batch }));
129
+ accepted.push(...batch);
130
+ }
131
+ return accepted;
132
+ },
133
+
134
+ async purgeAll(): Promise<void> {
135
+ await settle(await post({ purge_everything: true }));
136
+ },
137
+ };
138
+ }
@@ -0,0 +1,109 @@
1
+ // Single responsibility: environment → purge driver. The one place that decides which CDN a boot
2
+ // purges against, so `x dev`, a worker container and any custom host resolve it identically. Keyed
3
+ // on env rather than an `app.config.ts` field because nothing loads that file's contents at
4
+ // runtime — a `cache.cdn` block would be a setting no boot could read.
5
+
6
+ import { ConfigInvalidError } from '@ultimat3/core';
7
+ import type { PurgeDriver } from './cdn';
8
+ import { noopPurgeDriver } from './cdn';
9
+ import { cloudflarePurgeDriver } from './purge-cloudflare';
10
+ import { fastlyPurgeDriver } from './purge-fastly';
11
+
12
+ /** The keys read here, and nothing else. Named once so docs and tests cannot drift from the code. */
13
+ export const CDN_PURGE_ENV_KEYS = [
14
+ 'FASTLY_API_TOKEN',
15
+ 'FASTLY_SERVICE_ID',
16
+ 'CLOUDFLARE_API_TOKEN',
17
+ 'CLOUDFLARE_ZONE_ID',
18
+ ] as const;
19
+
20
+ export type PurgeEnvironment = Readonly<Record<string, string | undefined>>;
21
+
22
+ export interface PurgeSelection {
23
+ readonly driver: PurgeDriver;
24
+ /**
25
+ * Why this driver, in one line: the env key that selected it, or what to set to change it.
26
+ * A boot prints it, so "does this replica purge anything" is never a guess. The key's name
27
+ * only — `FASTLY_API_TOKEN` holds a credential, and this string reaches a log.
28
+ */
29
+ readonly detail: string;
30
+ }
31
+
32
+ const nonEmpty = (value: string | undefined): string | undefined =>
33
+ value === undefined || value.trim().length === 0 ? undefined : value.trim();
34
+
35
+ /**
36
+ * The keys this environment actually set, in declaration order. Read rather than assumed, because
37
+ * the refusal below reaches a JSON diagnostic: a hardcoded token pair sends an operator who set
38
+ * only `FASTLY_SERVICE_ID` and `CLOUDFLARE_ZONE_ID` to look at two variables they never set.
39
+ * Names only — every one of these four keys may hold a credential.
40
+ */
41
+ const configuredKeys = (env: PurgeEnvironment): readonly string[] =>
42
+ CDN_PURGE_ENV_KEYS.filter((key) => nonEmpty(env[key]) !== undefined);
43
+
44
+ /** A driver that reaches no CDN, so a caller can report "purges nothing" without a name match. */
45
+ export const isNoopPurgeDriver = (driver: PurgeDriver): boolean => driver.name === 'noop';
46
+
47
+ /**
48
+ * Either key selects its provider, and the other is then required: a `FASTLY_SERVICE_ID` with no
49
+ * token is a half-finished deploy, and treating it as "no CDN" is how an environment ships
50
+ * believing it purges. The pair is named in the cause, so the missing half is the fix.
51
+ */
52
+ function requirePair(env: PurgeEnvironment, selectedBy: string, missingKey: string): string {
53
+ const value = nonEmpty(env[missingKey]);
54
+ if (value === undefined) {
55
+ throw new ConfigInvalidError({
56
+ cause: `${selectedBy} selects a CDN purge driver, but ${missingKey} is unset — the pair is incomplete`,
57
+ fix: `set ${missingKey} in .env.production, or unset ${selectedBy} to purge nothing`,
58
+ meta: { selectedBy, missing: missingKey },
59
+ });
60
+ }
61
+ return value;
62
+ }
63
+
64
+ /**
65
+ * A credential selects its CDN; no credential purges nothing, which is the honest default for a
66
+ * process with no edge in front of it. Two CDNs at once is refused rather than resolved: whichever
67
+ * this picked would be the one an operator did not mean half the time, and the other edge would
68
+ * serve a stale page nobody can explain.
69
+ */
70
+ export function selectPurgeDriver(env: PurgeEnvironment): PurgeSelection {
71
+ const fastlyKey = nonEmpty(env['FASTLY_API_TOKEN']) ?? nonEmpty(env['FASTLY_SERVICE_ID']);
72
+ const cloudflareKey =
73
+ nonEmpty(env['CLOUDFLARE_API_TOKEN']) ?? nonEmpty(env['CLOUDFLARE_ZONE_ID']);
74
+
75
+ if (fastlyKey !== undefined && cloudflareKey !== undefined) {
76
+ const configured = configuredKeys(env);
77
+ throw new ConfigInvalidError({
78
+ cause: `two CDNs claim the same purge: ${configured.join(', ')} are set`,
79
+ fix: 'unset one pair in .env.production: a process purges exactly one edge',
80
+ meta: { configured },
81
+ });
82
+ }
83
+
84
+ if (fastlyKey !== undefined) {
85
+ return {
86
+ driver: fastlyPurgeDriver({
87
+ apiToken: requirePair(env, 'FASTLY_SERVICE_ID', 'FASTLY_API_TOKEN'),
88
+ serviceId: requirePair(env, 'FASTLY_API_TOKEN', 'FASTLY_SERVICE_ID'),
89
+ }),
90
+ detail: 'FASTLY_API_TOKEN',
91
+ };
92
+ }
93
+
94
+ if (cloudflareKey !== undefined) {
95
+ return {
96
+ driver: cloudflarePurgeDriver({
97
+ apiToken: requirePair(env, 'CLOUDFLARE_ZONE_ID', 'CLOUDFLARE_API_TOKEN'),
98
+ zoneId: requirePair(env, 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ZONE_ID'),
99
+ }),
100
+ detail: 'CLOUDFLARE_API_TOKEN',
101
+ };
102
+ }
103
+
104
+ return {
105
+ driver: noopPurgeDriver(),
106
+ detail:
107
+ 'no edge in front of this process — set FASTLY_API_TOKEN + FASTLY_SERVICE_ID, or CLOUDFLARE_API_TOKEN + CLOUDFLARE_ZONE_ID',
108
+ };
109
+ }