scripts/set-updates-edge-cache.ts
Rama main · réplica de lectura del repositorio original.
#!/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<string, unknown> }).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 <off|on|N%> [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()