#!/usr/bin/env bun /** * Sets UPDATES_EDGE_CACHE on the plugin workers in one go. * * One switch for the whole plugin edge cache: /updates, /stats and * /channel_self all read it, and a device sampled in by a percentage is * sampled in on every endpoint (stable hash of app_id:device_id). * * It is a worker secret (not a wrangler var), so a change applies right away, * without a code deploy. Values: off | on | a share of devices like 1%, 0.5, 25. * * bun run updates-edge-cache:set 1% # every prod_* plugin env * bun run updates-edge-cache:set on prod_eu prod_na # only these envs * bun run updates-edge-cache:set 10% preprod * bun run updates-edge-cache:set off --dry-run * * Envs come from cloudflare_workers/plugin/wrangler.jsonc. * * The snippet edge answers switch (SNIPPET_EDGE_ANSWER, see * plugin_runtime/utils/snippetEdgeAnswer.ts) takes the same values: * * bun run snippet-edge-answer:set 1% * * The secret only stops new snippet fills, so this also purges the stored * ones (Cache-Tag SNIPPET_EDGE_ANSWERS_TAG). Run the purge alone after a * LIMITED_APPS change: * * bun run snippet-edge-answer:purge * * Purge env: CLOUDFLARE_API_TOKEN (Cache Purge) and CF_CACHE_PURGE_ZONE_IDS * (comma separated, every zone the snippet runs on). */ import { spawnSync } from 'node:child_process' import { readFileSync } from 'node:fs' import process from 'node:process' import { parseJsonc, WRANGLER_PATH as WRANGLER_CONFIG } from './generate-plugin-route-hosts.ts' const SECRET_NAMES = ['UPDATES_EDGE_CACHE', 'SNIPPET_EDGE_ANSWER'] as const /** SNIPPET_EDGE_ANSWERS_TAG in plugin_runtime/utils/snippetEdgeAnswer.ts. */ export const SNIPPET_EDGE_ANSWERS_TAG = 'capgo-snippet-edge-answers' export function resolveSecretName(args: string[]) { const flag = args.find(arg => arg.startsWith('--secret=')) const name = flag ? flag.slice('--secret='.length) : SECRET_NAMES[0] if (!(SECRET_NAMES as readonly string[]).includes(name)) throw new Error(`Unknown secret ${name}. Allowed: ${SECRET_NAMES.join(', ')}`) return name } export function normalizeEdgeCacheValue(raw: string): string | null { const value = raw.trim().toLowerCase() if (value === 'on' || value === 'off') return value const percent = Number.parseFloat(value.replace(/%$/, '')) if (!/^\d+(?:\.\d+)?%?$/.test(value) || !Number.isFinite(percent) || percent < 0 || percent > 100) return null // The worker works in basis points (0.01%): refuse what would round to 0. const bps = Math.round(percent * 100) if (percent > 0 && bps === 0) return null if (bps === 0) return 'off' if (bps === 10_000) return 'on' return `${bps / 100}%` } export function pluginEnvs(config: unknown) { return Object.keys((config as { env?: Record }).env ?? {}) } export function resolveTargetEnvs(requested: string[], available: string[]) { if (requested.length === 0) return available.filter(env => env.startsWith('prod_')) const unknown = requested.filter(env => !available.includes(env)) if (unknown.length > 0) throw new Error(`Unknown plugin env(s): ${unknown.join(', ')}. Available: ${available.join(', ')}`) return requested } /** Drops every stored snippet answer in the given zones. */ export async function purgeSnippetEdgeAnswers(token: string, zoneIds: string[], fetchImpl: typeof fetch = fetch) { const failed: string[] = [] for (const zoneId of zoneIds) { const response = await fetchImpl(`https://api.cloudflare.com/client/v4/zones/${encodeURIComponent(zoneId)}/purge_cache`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ tags: [SNIPPET_EDGE_ANSWERS_TAG] }), }) const json = await response.json().catch(() => null) as { success?: boolean } | null if (!json?.success) failed.push(zoneId) } return failed } async function purgeFromEnv(dryRun: boolean) { const token = process.env.CLOUDFLARE_API_TOKEN?.trim() const zoneIds = (process.env.CF_CACHE_PURGE_ZONE_IDS ?? '').split(',').map(id => id.trim()).filter(Boolean) if (!token || zoneIds.length === 0) { console.error(`Stored snippet answers were NOT purged (set CLOUDFLARE_API_TOKEN and CF_CACHE_PURGE_ZONE_IDS, then run bun run snippet-edge-answer:purge). Until then they keep answering until their TTL.`) return false } console.log(`Purge Cache-Tag ${SNIPPET_EDGE_ANSWERS_TAG} -> ${zoneIds.join(', ')}${dryRun ? ' (dry run)' : ''}`) if (dryRun) return true const failed = await purgeSnippetEdgeAnswers(token, zoneIds) if (failed.length > 0) { console.error(`Purge failed for zone(s): ${failed.join(', ')}`) return false } return true } async function main() { const args = process.argv.slice(2) const dryRun = args.includes('--dry-run') const SECRET_NAME = resolveSecretName(args) if (args.includes('--purge-only')) { if (!await purgeFromEnv(dryRun)) process.exit(1) return } const [rawValue, ...requestedEnvs] = args.filter(arg => arg !== '--dry-run' && !arg.startsWith('--secret=')) const value = rawValue ? normalizeEdgeCacheValue(rawValue) : null if (!value) { console.error('Usage: bun run updates-edge-cache:set [env ...] [--dry-run] [--secret=SNIPPET_EDGE_ANSWER]') process.exit(1) } const envs = resolveTargetEnvs(requestedEnvs, pluginEnvs(parseJsonc(readFileSync(WRANGLER_CONFIG, 'utf8')))) console.log(`${SECRET_NAME}=${value} -> ${envs.join(', ')}${dryRun ? ' (dry run)' : ''}`) if (dryRun) { if (SECRET_NAME === 'SNIPPET_EDGE_ANSWER') await purgeFromEnv(true) return } const failed: string[] = [] for (const env of envs) { // The value goes through stdin, as with an interactive `wrangler secret put`. const child = spawnSync('bunx', ['wrangler', 'secret', 'put', SECRET_NAME, '--config', WRANGLER_CONFIG, '--env', env], { input: value, stdio: ['pipe', 'inherit', 'inherit'], }) if (child.status !== 0) failed.push(env) } if (failed.length > 0) { console.error(`Failed for: ${failed.join(', ')}`) process.exit(1) } console.log(`Done: ${envs.length} plugin worker(s) now use ${SECRET_NAME}=${value}`) if (SECRET_NAME === 'SNIPPET_EDGE_ANSWER' && !await purgeFromEnv(false)) process.exit(1) } if (import.meta.main) void main()