// Single responsibility: the HTTP half both remote purge drivers share — one POST with a // deadline, the status → retryable table, and the batching a provider's per-request key cap // forces. Kept apart from the drivers because "which failure can succeed unchanged" is one // judgement, and two copies of it would drift into two answers for the same 429. import { renderFixShellArg, renderThrowable } from '@ultimat3/core'; import { CacheDriverUnavailableError, CachePurgeFailedError } from './errors'; /** Just the call. `typeof fetch` also carries `preconnect`, which no test double should have to. */ export type PurgeFetch = (input: string, init: RequestInit) => Promise; /** A purge is behind the write, not in front of it: a slow CDN must not hold the fan-out open. */ export const DEFAULT_PURGE_TIMEOUT_MS = 10_000; const MAX_DETAIL_LENGTH = 200; // A 4xx here means the same request, unchanged, might land: a throttle or a momentary conflict. // Every other 4xx is a credential or a plan, which no retry fixes. The table itself is // `@ultimat3/core`'s — this line and `packages/mail/src/driver-resend.ts`'s were byte-identical in // two packages that cannot import each other, so one of them was always going to be edited alone. // Both purge drivers import it from core; it is not re-exported here (`X_HELPER_COPY`). /** * A bare reference to `globalThis.fetch` risks "Illegal invocation" on some hosts; closing over * the call keeps it detached from any receiver, in production and in tests alike. */ export const defaultPurgeFetch: PurgeFetch = (input, init) => globalThis.fetch(input, init); /** * Providers cap keys per request. A bust of 300 tags is still one purge — several requests. * * The size is refused before the loop, not trusted: a `0` or a negative never advances `index`, so * the fan-out hangs holding the write's invalidation open, and a `NaN` ends the loop after one pass * that slices to nothing — an empty key list posted to a CDN that answers 200 and clears nothing. * `X_CACHE_DRIVER_UNAVAILABLE` rather than `X_CACHE_PURGE_FAILED`: the only sizes this ever sees * are the drivers' own caps, so a bad one is this package miswired, and no CDN refused anything. */ export function chunked( driver: string, values: readonly T[], size: number, ): readonly (readonly T[])[] { if (!Number.isSafeInteger(size) || size < 1) { throw new CacheDriverUnavailableError({ driver, cause: `batch size ${String(size)} is not a positive integer, so ${values.length} keys cannot be split into requests`, fix: 'pass a positive integer batch size to chunked(), as FASTLY_MAX_KEYS_PER_REQUEST does', }); } const batches: T[][] = []; for (let index = 0; index < values.length; index += size) { batches.push(values.slice(index, index + size)); } return batches; } /** * A credential the driver cannot run without, refused at construction — where the env key is * still nameable — rather than on the first purge nobody watches. "no CDN token" is what * `X_CACHE_DRIVER_UNAVAILABLE` already means, so this is that code and not a second one. */ export function requireCredential(value: string, envKey: string, driver: string): string { if (value.trim() !== '') return value.trim(); throw new CacheDriverUnavailableError({ driver, cause: `${envKey} is unset, so the ${driver} purge driver has no credential`, fix: `set ${envKey} in .env.production, or use noopPurgeDriver() to purge nothing`, }); } // Every CDN splits a key list on whitespace or a comma, so a key carrying either purges two // things that do not exist instead of the one that does — silently, since the request succeeds. const UNSAFE_KEY = /[\s,]/; const MAX_KEY_BYTES = 1024; // The CDN's limit is on the HEADER, so it is bytes — and `String.length` is UTF-16 code units, // which for a non-ASCII tag is a third of the answer: 900 CJK characters measured 900 against a // 1024 limit and put 2,700 bytes on the wire, where the provider refuses or truncates the key. // That is the accepted purge that cleared nothing, arriving through the guard meant to stop it. const keyBytes = (key: string): number => new TextEncoder().encode(key).byteLength; /** * The problem AND the repair, together — because there are three ways a key is unpurgeable and only * one of them is fixed by renaming away a separator. A 900-character CJK tag carries no whitespace * and no comma, so "rename it so it carries no space or comma" left the next purge failing exactly * as the last one did: an instruction that does not repair the stated cause is the shape axiom 4 * exists to forbid. */ interface KeyProblem { readonly problem: string; readonly fix: string; } const keyProblem = (key: string): KeyProblem | undefined => { if (key === '') return { problem: 'is empty', fix: 'give the tag a name in its declareTags(...) call — an empty surrogate key purges nothing', }; if (UNSAFE_KEY.test(key)) return { problem: 'contains whitespace or a comma, which a CDN reads as a separator', fix: 'rename the tag in its declareTags(...) call so the key carries no space or comma', }; const bytes = keyBytes(key); if (bytes > MAX_KEY_BYTES) return { problem: `is ${bytes} bytes, over the ${MAX_KEY_BYTES}-byte key limit`, fix: `shorten the tag in its declareTags(...) call to at most ${MAX_KEY_BYTES} UTF-8 bytes — the limit is on the header, so it is BYTES, and a non-ASCII character costs two or three of them each`, }; return undefined; }; /** * Refused before the request, not after: a malformed key comes back as an accepted purge that * cleared nothing, which is the one CDN failure no later read can catch. */ export function assertPurgeableKeys(driver: string, keys: readonly string[]): void { for (const key of keys) { const problem = keyProblem(key); if (problem === undefined) continue; throw new CachePurgeFailedError({ driver, detail: `surrogate key ${JSON.stringify(key)} ${problem.problem}`, retryable: false, fix: problem.fix, }); } } export interface PurgeBody { readonly text: string; /** `undefined` when the provider sent something that is not JSON — an html error page, or nothing. */ readonly json: unknown; } /** * The body, read exactly once. A `Response` streams: `json()` followed by `text()` throws "Body * already used", so the failure path would lose the very message it exists to report. */ export async function purgeBody(response: Response): Promise { const text = await response.text().catch(() => ''); try { return { text, json: JSON.parse(text) as unknown }; } catch { return { text, json: undefined }; } } /** Raw text, capped so a provider's error page cannot flood a log line. */ export function detailFrom(body: PurgeBody): string { if (body.text === '') return 'the response body was empty'; return body.text.length > MAX_DETAIL_LENGTH ? `${body.text.slice(0, MAX_DETAIL_LENGTH)}…` : body.text; } export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } export interface PurgePostInput { readonly driver: string; readonly url: string; readonly headers: Readonly>; readonly body: unknown; readonly fetch: PurgeFetch; readonly timeoutMs: number; } /** * One POST, with the transport failure already translated. A request that never got a status — * DNS, TLS, a reset, the deadline — is retryable by definition: nothing at the edge has seen it. */ export async function purgePost(input: PurgePostInput): Promise { try { return await input.fetch(input.url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...input.headers }, body: JSON.stringify(input.body), signal: AbortSignal.timeout(input.timeoutMs), }); } catch (error) { // `renderThrowable`, never `error.message` behind an `instanceof`: `fetch` is INJECTED here, // so the rejection is whatever a driver or a test double threw — and `instanceof` itself // throws on a `Proxy` whose `getPrototypeOf` does, which would replace the coded refusal this // catch exists to raise with a bare `TypeError` from inside it. Same rule as `invalidate.ts`. const reason = renderThrowable(error); throw new CachePurgeFailedError({ driver: input.driver, detail: `${reason} — nothing left this host for ${input.url} (egress, DNS or TLS)`, retryable: true, // The endpoint comes from `.env.production` (`purge-env.ts`), so it is operator data in a // COMMAND position: `$(…)` and a backtick substitute before `curl` is reached. The URL is // already in the `detail` above, which is read rather than pasted. fix: `curl -sS -m 5 -o /dev/null ${renderFixShellArg(input.url, '')}`, }); } }