#!/usr/bin/env bun /** * Off-prod probe of what the snippet edge answers (plugin_runtime/utils/ * snippetEdgeAnswer.ts) rely on, run on dedicated test hostnames of the * capgo.app zone (the only zone with Snippets + http_requests Logpush). * No production host, worker, snippet or Logpush job is touched. * * snippet-edge-probe.capgo.app probe snippet (probe-snippet.js) * snippet-edge-test.capgo.app the real snippet, worker URLs -> test origin * snippet-edge-alias.capgo.app the real snippet too, sharing IP limits with the test host * snippet-edge-probe-origin.capgo.app test origin worker (origin-worker.ts) * * Checks: * 1. subrequest limit, and whether Cache API calls count against it * 2. a snippet Cache API entry is purged by Cache-Tag * 3. snippet and worker of the zone share Cache API entries (same key) * 4. Logpush keeps the X-Capgo-Edge-Stat response header, by size * 5. real snippet end to end: learn, answer without origin, purge, IP limit * 6. Cache API keys on another hostname of the zone (IP limits shared by hosts) * * Env: CLOUDFLARE_API_TOKEN with, on capgo.app: Snippets Edit, DNS Edit, * Cache Purge, Logs Edit, Zone Read. Wrangler (Worker deploy, R2 bucket) * runs on the local wrangler login. Optional CLOUDFLARE_ACCOUNT_ID. * * bun scripts/snippet-edge-probe/run.ts setup [--dry-run] * bun scripts/snippet-edge-probe/run.ts test * bun scripts/snippet-edge-probe/run.ts logs [run] # Logpush part, 1-5 min after test * bun scripts/snippet-edge-probe/run.ts teardown */ import { Buffer } from 'node:buffer' import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import process from 'node:process' const ZONE_NAME = 'capgo.app' const PROBE_HOST = 'snippet-edge-probe.capgo.app' const TEST_HOST = 'snippet-edge-test.capgo.app' const ALIAS_HOST = 'snippet-edge-alias.capgo.app' const ORIGIN_HOST = 'snippet-edge-probe-origin.capgo.app' const PROBE_SNIPPET = 'capgo_edge_probe' const TEST_SNIPPET = 'capgo_edge_probe_real' const LOGPUSH_JOB = 'capgo-snippet-edge-probe' const BUCKET = 'capgo-snippet-edge-probe' const STAT_HEADER = 'x-capgo-edge-stat' const HEADER_SIZES = [512, 2048, 4096, 8192, 12000, 16000, 32000] const DIR = resolve(import.meta.dir) const ROOT = resolve(DIR, '../..') const STATE_PATH = join(tmpdir(), 'capgo-snippet-edge-probe.json') interface State { secret: string, zoneId?: string, dnsIds?: string[], logpushJobId?: number } function loadState(): State { if (existsSync(STATE_PATH)) return JSON.parse(readFileSync(STATE_PATH, 'utf8')) const state = { secret: crypto.randomUUID() } writeFileSync(STATE_PATH, JSON.stringify(state)) return state } function saveState(state: State) { writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)) } function token() { const value = process.env.CLOUDFLARE_API_TOKEN?.trim() if (!value) throw new Error('Missing CLOUDFLARE_API_TOKEN') return value } async function cf(path: string, init: RequestInit = {}): Promise { const response = await fetch(`https://api.cloudflare.com/client/v4${path}`, { ...init, headers: { Authorization: `Bearer ${token()}`, ...(init.body && !(init.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}), ...init.headers }, }) const body = await response.json() as { success: boolean, result: T, errors?: unknown } if (!body.success) throw new Error(`${init.method ?? 'GET'} ${path}: ${JSON.stringify(body.errors)}`) return body.result } function wrangler(args: string[], input?: string) { console.log(`$ wrangler ${args.join(' ')}`) // The zone token has no Workers/R2 rights: wrangler runs on its own login. const { CLOUDFLARE_API_TOKEN: _zoneToken, ...env } = process.env const child = spawnSync('bunx', ['wrangler', ...args], { cwd: DIR, input, encoding: 'utf8', env }) const output = `${child.stdout}${child.stderr}` if (child.status !== 0 && !/already exists|already been taken|10004|not found|does not exist/i.test(output)) throw new Error(output) return output } async function zoneId(state: State) { if (state.zoneId) return state.zoneId const [zone] = await cf<{ id: string }[]>(`/zones?name=${ZONE_NAME}`) if (!zone) throw new Error(`Zone ${ZONE_NAME} not found`) state.zoneId = zone.id saveState(state) return zone.id } /** The real snippet, with every regional plugin worker pointed at the test origin. */ async function buildTestSnippet() { const source = readFileSync(resolve(ROOT, 'cloudflare_workers/snippet/index.js'), 'utf8') .replace(/https:\/\/plugin\.[a-z]+\.capgo\.app/g, `https://${ORIGIN_HOST}`) if (source.includes('plugin.eu.capgo.app')) throw new Error('Test snippet still targets a production worker') const ipLimitHosts = `const IP_LIMIT_HOSTS = { 'api.capgo.app': 'plugin.capgo.app', 'plugin.usecapgo.com': 'plugin.capgo.app' }` if (!source.includes(ipLimitHosts)) throw new Error('IP_LIMIT_HOSTS changed: update the probe') const testSource = source.replace(ipLimitHosts, `const IP_LIMIT_HOSTS = { '${ALIAS_HOST}': '${TEST_HOST}' }`) const path = join(tmpdir(), 'capgo-edge-probe-real.js') writeFileSync(path, testSource) const result = await Bun.build({ entrypoints: [path], minify: true, target: 'browser', format: 'esm' }) if (!result.success) throw new Error(result.logs.join('\n')) return await result.outputs[0]!.text() } async function uploadSnippet(zone: string, name: string, code: string) { const form = new FormData() form.append('files', new File([code], `${name}.js`, { type: 'application/javascript+module' })) form.append('metadata', JSON.stringify({ main_module: `${name}.js` })) await cf(`/zones/${zone}/snippets/${name}`, { method: 'PUT', body: form }) console.log(`Uploaded snippet ${name} (${code.length} bytes)`) } interface SnippetRule { description?: string, enabled: boolean, expression: string, snippet_name: string } /** * PUT snippet_rules replaces every rule of the zone: always resend the * existing (production) rules untouched, and check they survived. */ async function setProbeRules(zone: string, add: boolean, dryRun = false) { const existing = await cf(`/zones/${zone}/snippets/snippet_rules`) ?? [] const others = existing.filter(rule => rule.snippet_name !== PROBE_SNIPPET && rule.snippet_name !== TEST_SNIPPET) .map(({ description, enabled, expression, snippet_name }) => ({ description, enabled, expression, snippet_name })) const ours: SnippetRule[] = add ? [ { description: 'capgo edge probe (test host only)', enabled: true, expression: `(http.host eq "${PROBE_HOST}")`, snippet_name: PROBE_SNIPPET }, { description: 'capgo edge probe real snippet (test hosts only)', enabled: true, expression: `(http.host eq "${TEST_HOST}") or (http.host eq "${ALIAS_HOST}")`, snippet_name: TEST_SNIPPET }, ] : [] console.log(`Snippet rules kept as is: ${others.map(rule => `${rule.snippet_name} [${rule.expression}]`).join(', ') || 'none'}`) if (dryRun) { console.log(`Would set ${ours.length} probe rule(s)`) return } if (others.length === 0 && existing.length === 0 && !add) return if (others.length === 0 && ours.length === 0) { await cf(`/zones/${zone}/snippets/snippet_rules`, { method: 'DELETE' }) return } await cf(`/zones/${zone}/snippets/snippet_rules`, { method: 'PUT', body: JSON.stringify({ rules: [...others, ...ours] }) }) const after = await cf(`/zones/${zone}/snippets/snippet_rules`) ?? [] for (const rule of others) { if (!after.some(next => next.snippet_name === rule.snippet_name && next.expression === rule.expression && next.enabled === rule.enabled)) throw new Error(`Snippet rule ${rule.snippet_name} changed: restore it now! ${JSON.stringify(rule)}`) } } async function ensureDns(zone: string, state: State) { state.dnsIds ??= [] for (const name of [PROBE_HOST, TEST_HOST, ALIAS_HOST]) { const found = await cf<{ id: string }[]>(`/zones/${zone}/dns_records?name=${name}`) if (found.length > 0) continue // Proxied, originless: the snippet answers or calls the test origin itself. const record = await cf<{ id: string }>(`/zones/${zone}/dns_records`, { method: 'POST', body: JSON.stringify({ type: 'AAAA', name, content: '100::', proxied: true, comment: 'capgo snippet edge probe' }) }) state.dnsIds.push(record.id) console.log(`DNS ${name} created`) } saveState(state) } async function ensureCustomField(zone: string) { const path = `/zones/${zone}/rulesets/phases/http_log_custom_fields/entrypoint` let rules: any[] = [] try { rules = (await cf<{ rules?: any[] }>(path)).rules ?? [] } catch {} const rule = rules.find(item => item.action === 'log_custom_field' && item.expression === 'true') const fields: { name: string }[] = rule?.action_parameters?.response_fields ?? [] if (fields.some(field => field.name.toLowerCase() === STAT_HEADER)) return const next = rule ? rules.map(item => item === rule ? { ...item, action_parameters: { ...item.action_parameters, response_fields: [...fields, { name: STAT_HEADER }] } } : item) : [...rules, { action: 'log_custom_field', expression: 'true', description: 'Capgo log fields', action_parameters: { response_fields: [{ name: STAT_HEADER }] } }] await cf(path, { method: 'PUT', body: JSON.stringify({ rules: next }) }) console.log(`Custom log field ${STAT_HEADER} added (additive)`) } async function ensureLogpush(zone: string, state: State) { const jobs = await cf<{ id: number, name: string }[]>(`/zones/${zone}/logpush/jobs`) const found = jobs.find(job => job.name === LOGPUSH_JOB) if (found) { state.logpushJobId = found.id saveState(state) return } const destination = `https://${ORIGIN_HOST}/logpush?header_Authorization=${encodeURIComponent(`Bearer ${state.secret}`)}` const job = await cf<{ id: number }>(`/zones/${zone}/logpush/jobs`, { method: 'POST', body: JSON.stringify({ name: LOGPUSH_JOB, dataset: 'http_requests', enabled: true, destination_conf: destination, max_upload_interval_seconds: 30, output_options: { output_type: 'ndjson', field_names: ['ClientRequestHost', 'ClientRequestURI', 'EdgeResponseStatus', 'ResponseHeaders'] }, filter: JSON.stringify({ where: { or: [PROBE_HOST, TEST_HOST, ALIAS_HOST].map(value => ({ key: 'ClientRequestHost', operator: 'eq', value })) } }), }), }) state.logpushJobId = job.id saveState(state) console.log(`Logpush job ${job.id} created (test hosts only)`) } async function setup(dryRun: boolean) { const state = loadState() const zone = await zoneId(state) if (dryRun) { await setProbeRules(zone, true, true) console.log('Dry run: would create R2 bucket, deploy origin worker, DNS, 2 snippets, custom log field, Logpush job') return } wrangler(['r2', 'bucket', 'create', BUCKET]) wrangler(['deploy']) wrangler(['secret', 'put', 'PROBE_SECRET'], state.secret) await ensureDns(zone, state) await uploadSnippet(zone, PROBE_SNIPPET, readFileSync(join(DIR, 'probe-snippet.js'), 'utf8')) await uploadSnippet(zone, TEST_SNIPPET, await buildTestSnippet()) await setProbeRules(zone, true) await ensureCustomField(zone) await ensureLogpush(zone, state) console.log('Setup done. Give DNS/snippets ~30s, then: bun scripts/snippet-edge-probe/run.ts test') } async function get(url: string, init?: RequestInit) { const response = await fetch(url, init) const text = await response.text() let body: any = text try { body = JSON.parse(text) } catch {} return { status: response.status, headers: response.headers, body } } const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) async function test() { const state = loadState() const zone = await zoneId(state) const run = Date.now().toString(36) const report: Record = {} // 1. Subrequest limit for (const kind of ['match', 'fetch', 'mixed']) { const result = await get(`https://${PROBE_HOST}/__probe__/subreq?n=7&kind=${kind}`) report[`subrequests_${kind}`] = result.status === 200 ? result.body.results.map((r: any) => `${r.i}:${r.op}:${r.ok ? 'ok' : `FAIL(${r.error})`}`) : `HTTP ${result.status} ${String(result.body).slice(0, 200)}` } // 2. Purge by tag of a snippet Cache API entry const tag = `capgo-edge-probe-purge-${run}` const key = `purge-${run}` await get(`https://${PROBE_HOST}/__probe__/put?key=${key}&tag=${tag}`) const before = await get(`https://${PROBE_HOST}/__probe__/get?key=${key}`) await cf(`/zones/${zone}/purge_cache`, { method: 'POST', body: JSON.stringify({ tags: [tag] }) }) const purgeStart = Date.now() let purgedAfterMs: number | null = null while (Date.now() - purgeStart < 60_000) { const after = await get(`https://${PROBE_HOST}/__probe__/get?key=${key}`) if (!after.body.hit) { purgedAfterMs = Date.now() - purgeStart break } await sleep(2000) } report.purge_by_tag = { colo: before.body.colo, hitBeforePurge: before.body.hit, purgedAfterMs } // 3. Snippet <-> worker Cache API sharing (same key, same zone, same data center) const sharedW = `w-${run}` const wPut = await get(`https://${ORIGIN_HOST}/shared-put?key=${sharedW}`) const sGet = await get(`https://${PROBE_HOST}/__probe__/shared-get?key=${sharedW}`) const sharedS = `s-${run}` const sPut = await get(`https://${PROBE_HOST}/__probe__/shared-put?key=${sharedS}`) const wGet = await get(`https://${ORIGIN_HOST}/shared-get?key=${sharedS}`) report.shared_cache = { workerPutColo: wPut.body.colo, snippetSeesWorkerEntry: sGet.body.hit, snippetColo: sGet.body.colo, snippetPutColo: sPut.body.colo, workerSeesSnippetEntry: wGet.body.hit, workerColo: wGet.body.colo, } // 4. Response header sizes (Logpush part checked by `logs`) report.header_sizes = await Promise.all(HEADER_SIZES.map(async (size) => { const result = await get(`https://${PROBE_HOST}/__probe__/header?size=${size}&run=${run}`) return { size, status: result.status, clientHeaderLength: result.headers.get(STAT_HEADER)?.length ?? null } })) // 5. Real snippet end to end on the test host const appId = `com.probe.edge${run}` const body = (deviceId: string, versionName = '1.0.0') => JSON.stringify({ app_id: appId, device_id: deviceId, platform: 'ios', version_name: versionName, version_build: '1.0.0', plugin_version: '8.1.0', is_emulator: false, is_prod: true, defaultChannel: 'production', }) // The run id in the query lets `logs` keep this run's lines only (the snippet matches the path). const post = (path: string, payload: string, host = TEST_HOST) => get(`https://${host}${path}?run=${run}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload }) const served = (result: Awaited>) => result.headers.get('X-Capgo-Edge') === 'answer' ? 'snippet' : result.headers.get('X-Probe-Origin') ? 'origin' : `other(${result.status})` const e2e: Record = {} e2e.first = served(await post('/updates', body('00000000-0000-4000-8000-000000000001'))) e2e.secondOtherDevice = served(await post('/updates', body('00000000-0000-4000-8000-000000000002'))) e2e.oldBundle = served(await post('/updates', body('00000000-0000-4000-8000-000000000003', '0.9.0'))) await cf(`/zones/${zone}/purge_cache`, { method: 'POST', body: JSON.stringify({ tags: [`capgo-edge-probe-${appId}`] }) }) await sleep(5000) e2e.afterPurge = served(await post('/updates', body('00000000-0000-4000-8000-000000000004'))) e2e.afterRefill = served(await post('/updates', body('00000000-0000-4000-8000-000000000005'))) // Answers are stored per host, platform and default channel. e2e.aliasFirst = served(await post('/updates', body('00000000-0000-4000-8000-000000000009'), ALIAS_HOST)) e2e.aliasSecond = served(await post('/updates', body('00000000-0000-4000-8000-00000000000a'), ALIAS_HOST)) e2e.otherChannel = served(await post('/updates', body('00000000-0000-4000-8000-00000000000b').replace('"production"', '"beta"'))) e2e.trailingSlash = served(await post('/updates/', body('00000000-0000-4000-8000-00000000000c'))) e2e.ipLimit = served(await post('/updates', body('ffffffff-0000-4000-8000-000000000006', '0.1.0'))) e2e.afterIpLimit = served(await post('/updates', body('00000000-0000-4000-8000-000000000007'))) // The limit was learned on the test host: the alias host must honour it. e2e.aliasAfterIpLimit = served(await post('/updates', body('00000000-0000-4000-8000-00000000000d'), ALIAS_HOST)) const stats = JSON.stringify([{ app_id: appId, device_id: '00000000-0000-4000-8000-000000000008', platform: 'ios', version_name: '1.0.0', version_os: '18', is_emulator: false, is_prod: true, action: 'app_moved_to_foreground' }]) e2e.statsFirst = served(await post('/stats', stats)) e2e.statsSecond = served(await post('/stats', stats)) report.real_snippet = e2e // 6. Cache API key on another hostname, without the snippet's error handling const xkey = `x-${run}` report.cross_host_cache = { putTestHostKey: (await get(`https://${PROBE_HOST}/__probe__/xhost-put?host=${TEST_HOST}&key=${xkey}`)).body, getTestHostKey: (await get(`https://${PROBE_HOST}/__probe__/xhost-get?host=${TEST_HOST}&key=${xkey}`)).body, getAliasHostKey: (await get(`https://${PROBE_HOST}/__probe__/xhost-get?host=${ALIAS_HOST}&key=${xkey}`)).body, } console.log(JSON.stringify(report, null, 2)) console.log(`\nLogpush ships every ~30s: run "bun scripts/snippet-edge-probe/run.ts logs ${run}" in 1-5 min.`) } async function logs() { const state = loadState() const run = process.argv[3] const result = await get(`https://${ORIGIN_HOST}/logpush-dump`, { headers: { Authorization: `Bearer ${state.secret}` } }) if (result.status !== 200) throw new Error(`logpush-dump: HTTP ${result.status}`) const all = result.body.lines as { host: string, uri: string, statLength: number | null }[] const lines = run ? all.filter(line => line.uri?.includes(`run=${run}`)) : all const headerLines = lines.filter(line => line.uri?.includes('/__probe__/header')) console.log(JSON.stringify({ files: result.body.files, headerSizes: headerLines.map(line => ({ requested: Number(new URL(`https://x${line.uri}`).searchParams.get('size')), logged: line.statLength })), realSnippetAnswersLogged: lines.filter(line => (line.host === TEST_HOST || line.host === ALIAS_HOST) && line.statLength).length, // v2 stat header: no owner org. statKeys: [...new Set(lines.filter(line => (line.host === TEST_HOST || line.host === ALIAS_HOST) && (line as any).stat).map(line => Object.keys(JSON.parse(Buffer.from((line as any).stat, 'base64url').toString())).sort().join(',')))], }, null, 2)) } async function teardown() { const state = loadState() const zone = await zoneId(state) await setProbeRules(zone, false) for (const name of [PROBE_SNIPPET, TEST_SNIPPET]) { try { await cf(`/zones/${zone}/snippets/${name}`, { method: 'DELETE' }) } catch (error) { console.log(String(error)) } } for (const id of state.dnsIds ?? []) { try { await cf(`/zones/${zone}/dns_records/${id}`, { method: 'DELETE' }) } catch (error) { console.log(String(error)) } } if (state.logpushJobId) { try { await cf(`/zones/${zone}/logpush/jobs/${state.logpushJobId}`, { method: 'DELETE' }) } catch (error) { console.log(String(error)) } } wrangler(['delete', '--force']) console.log(`Left in place: custom log field ${STAT_HEADER} (additive, needed by the rollout) and R2 bucket ${BUCKET} (delete with: bunx wrangler r2 bucket delete ${BUCKET} after emptying it).`) saveState({ secret: state.secret }) } const command = process.argv[2] const dryRun = process.argv.includes('--dry-run') const commands: Record Promise> = { setup: () => setup(dryRun), test, logs, teardown } if (!commands[command ?? '']) { console.error('Usage: bun scripts/snippet-edge-probe/run.ts [--dry-run]') process.exit(1) } commands[command!]!().catch((error) => { console.error(error instanceof Error ? error.message : error) process.exit(1) })