From 5d7a71e74fc69fc3f7d651f16a9f8565989b19db Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 19:23:25 +0200 Subject: [PATCH 1/3] refactor: move payments and the firewall recommender behind subpaths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root bundle had grown from 9,643 to 22,497 bytes — undoing most of the 65% reduction in #21 — because payments, gateway, entitlement and firewall all exported from the package root. Every consumer paid for them whether or not they charged anyone, and the firewall recommender in particular is an offline analysis tool that will never execute in middleware. dist/index.js 22,497 -> 11,608 B (8,695 -> 4,546 gzipped) dist/pay.js 10,861 B opt-in dist/firewall.js 6,821 B opt-in Entry points now: @apideck/agent-analytics detection, classification, agentPolicy, trackVisit @apideck/agent-analytics/verify Web Bot Auth + published IP range tables @apideck/agent-analytics/payments 402 challenges, gateways, entitlements @apideck/agent-analytics/firewall WAF recommendations (offline) @apideck/agent-analytics/markdown Markdown-twin negotiation agentPolicy stays in the root: classification without a policy to apply it to is half a library, and it costs ~2 kB. That is why the root lands at 11.6 kB rather than back at 9.6 kB. README gains an entry-point table with the real numbers, and every import example in the README, the testing guide and the site now points at the right subpath. Verified from a clean install of the packed package that all five entry points resolve. Found while pulling accurate figures for the site's install section, which was still advertising "216 tests" and "CI on Node 18, 20 and 22" — Node 18 was dropped in 0.12 for lacking globalThis.crypto, so the site was claiming support for a runtime the library refuses to run on. Both corrected. --- README.md | 24 +++++++++++++++++++++--- docs/TESTING-PAYMENTS.md | 8 ++++---- package.json | 12 +++++++++++- src/index.ts | 34 ++++++++++------------------------ src/pay.ts | 14 ++++++++++++++ tsup.config.ts | 2 ++ 6 files changed, 62 insertions(+), 32 deletions(-) create mode 100644 src/pay.ts diff --git a/README.md b/README.md index e9d5d51..c7c4bc7 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ extra steps. So start by counting: ```ts -import { paymentGate } from '@apideck/agent-analytics' +import { paymentGate } from '@apideck/agent-analytics/payments' import { combinedVerifier } from '@apideck/agent-analytics/verify' const gate = await paymentGate(req, { @@ -125,7 +125,7 @@ When you know the number, switch to an entitlement: one 402 advertising a bulk offer, one settlement, a reusable credential. ```ts -import { entitlementGateway } from '@apideck/agent-analytics' +import { entitlementGateway } from '@apideck/agent-analytics/payments' const gate = await paymentGate(req, { onTraining: 'charge', @@ -258,7 +258,7 @@ every rule comes out in `log` mode and Vercel stages rule changes as drafts, so nothing is live until you run `vercel firewall publish` yourself. ```ts -import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics' +import { recommendFirewallRules, firewallScript } from '@apideck/agent-analytics/firewall' const rules = recommendFirewallRules(observations) // aggregate from your warehouse console.log(firewallScript(rules)) // runnable, commented bash @@ -282,6 +282,24 @@ privacy relays egress from hosting networks. See [`docs/TESTING-PAYMENTS.md`](./docs/TESTING-PAYMENTS.md) for testing the payment path end to end. +## Entry points + +The root carries detection, classification, policy and capture — what every +consumer needs. Everything optional lives behind a subpath, so it only reaches +your bundle if you import it. + +| Import | Contains | Root bundle cost | +| --- | --- | ---: | +| `@apideck/agent-analytics` | detection, classification, `agentPolicy`, `trackVisit` | 11.6 kB / **4.5 kB gz** | +| `…/verify` | Web Bot Auth + published IP range tables | 19.0 kB | +| `…/payments` | 402 challenges, gateways, entitlements | 10.9 kB | +| `…/firewall` | WAF rule recommendations (offline tool) | 6.8 kB | +| `…/markdown` | Markdown-twin content negotiation | 2.0 kB | + +This split is load-bearing rather than tidy-minded. Exporting the payment and +firewall surfaces from the root once pushed it from 9.6 kB to 22.5 kB — every +site paid for a firewall recommender that will never run in middleware. + ## Install ```bash diff --git a/docs/TESTING-PAYMENTS.md b/docs/TESTING-PAYMENTS.md index 0361c4c..6e7928a 100644 --- a/docs/TESTING-PAYMENTS.md +++ b/docs/TESTING-PAYMENTS.md @@ -21,7 +21,7 @@ npm i @apideck/agent-analytics ```js // pay.mjs -import { paymentGate, entitlementGateway, memoryEntitlementStore } from '@apideck/agent-analytics' +import { paymentGate, entitlementGateway, memoryEntitlementStore } from '@apideck/agent-analytics/payments' import { combinedVerifier } from '@apideck/agent-analytics/verify' const store = memoryEntitlementStore({ lic_abc: { id: 'lic_abc', remaining: 3 } }) @@ -72,7 +72,7 @@ prove:** that any real agent understands the challenge. ### Inspect the challenge ```js -import { paymentRequired } from '@apideck/agent-analytics' +import { paymentRequired } from '@apideck/agent-analytics/payments' const res = paymentRequired({ challenges: [ @@ -101,7 +101,7 @@ Wire the gate into middleware, then drive it with user agents. ```ts // middleware.ts import { NextResponse, type NextRequest } from 'next/server' -import { paymentGate, entitlementGateway } from '@apideck/agent-analytics' +import { paymentGate, entitlementGateway } from '@apideck/agent-analytics/payments' import { combinedVerifier } from '@apideck/agent-analytics/verify' const gateway = entitlementGateway({ @@ -206,7 +206,7 @@ Stripe's SDK generates challenges and settles; wrap it rather than reimplementing. ```ts -import { mppxGateway } from '@apideck/agent-analytics' +import { mppxGateway } from '@apideck/agent-analytics/payments' const mppx = Mppx.create({ methods: [...], secretKey }) const handler = Mppx.compose( diff --git a/package.json b/package.json index fcb4354..32ff6f8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@apideck/agent-analytics", "version": "0.15.0", - "description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", + "description": "Track AI agent and bot traffic to your Next.js / Vercel app \u2014 PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", "keywords": [ "ai", "agents", @@ -43,6 +43,16 @@ "import": "./dist/verify.js", "require": "./dist/verify.cjs" }, + "./payments": { + "types": "./dist/pay.d.ts", + "import": "./dist/pay.js", + "require": "./dist/pay.cjs" + }, + "./firewall": { + "types": "./dist/firewall.d.ts", + "import": "./dist/firewall.js", + "require": "./dist/firewall.cjs" + }, "./posthog": { "types": "./dist/adapters/posthog.d.ts", "import": "./dist/adapters/posthog.js", diff --git a/src/index.ts b/src/index.ts index 8796cac..f2ef6f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,13 @@ +/** + * Package root: detection, classification, policy and capture. + * + * Deliberately excludes the paid-access surface and the firewall recommender. + * Both are opt-in and neither belongs in an edge bundle by default: + * + * @apideck/agent-analytics/verify identity verification + IP ranges + * @apideck/agent-analytics/payments 402 challenges, gateways, entitlements + * @apideck/agent-analytics/firewall WAF rule recommendations (offline) + */ export { trackVisit } from './track.js' export { AI_BOT_PATTERN, @@ -14,23 +24,6 @@ export type { AgentClassification, AgentKind, HeadlessDetection } from './bots.j export { hashId, randomSecret, HashSecretError } from './hash.js' export { CaptureTransportError } from './errors.js' export { agentIntent, agentPolicy } from './policy.js' -export { mppxGateway, paymentGate, x402Gateway } from './gateway.js' -export { entitlementGateway, memoryEntitlementStore } from './entitlement.js' -export { firewallScript, recommendFirewallRules } from './firewall.js' -export type { - FirewallAction, - FirewallCondition, - FirewallRecommendation, - RateLimitSpec, - RecommendOptions, - TrafficObservation -} from './firewall.js' -export type { - BulkOffer, - Entitlement, - EntitlementGatewayOptions, - EntitlementStore -} from './entitlement.js' export type { GatewayResult, Meter, @@ -40,13 +33,6 @@ export type { PaymentGateway, X402GatewayOptions } from './gateway.js' -export { - hasPaymentPayload, - paymentPayload, - paymentRequired, - respondToDecision, - withSettlement -} from './payments.js' export type { MppChallenge, PaymentChallenge, diff --git a/src/pay.ts b/src/pay.ts new file mode 100644 index 0000000..89b740b --- /dev/null +++ b/src/pay.ts @@ -0,0 +1,14 @@ +/** + * Paid-access entry point. **EXPERIMENTAL** — see `payments.ts`. + * + * Kept out of the package root deliberately. Charging is opt-in and rare; + * classification is what every consumer needs. Exporting these from the root + * put the challenge builders, the gateway and the entitlement store into every + * edge bundle whether or not the site ever charged anyone — the root grew from + * 9.6 kB to 22.5 kB before anyone noticed. + * + * import { paymentGate } from '@apideck/agent-analytics/payments' + */ +export * from './payments.js' +export * from './gateway.js' +export * from './entitlement.js' diff --git a/tsup.config.ts b/tsup.config.ts index 1219d18..1080ca4 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -5,6 +5,8 @@ export default defineConfig({ 'src/index.ts', 'src/markdown.ts', 'src/verify.ts', + 'src/pay.ts', + 'src/firewall.ts', 'src/adapters/posthog.ts', 'src/adapters/webhook.ts' ], From 80f52a58726e6213a745057ebdce8bc7918eb873 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 19:31:22 +0200 Subject: [PATCH 2/3] ci: enforce a bundle budget per entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root entry ships in every consumer's edge middleware and runs on every request, so its size is a feature rather than a vanity metric. It silently doubled anyway: #21 cut it from 27.7 kB to 9.6 kB, then payments, gateway, entitlement and firewall were each exported from the root by reflex and it climbed back to 22.5 kB. Nothing failed. Every test passed. It surfaced weeks later only because someone needed an accurate figure for a marketing page. Same shape as the unpublished-0.10.0 bug and the agentIntent divergence: the mechanism existed, nothing asserted the invariant, and it stayed broken until something unrelated exposed it. So assert it. scripts/check-size.mjs measures each entry gzipped against size-budget.json and fails the build over budget. Zero dependencies, in keeping with the package — node:zlib does the work. entry gzipped budget used dist/index.js 4.44 kB 4.88 kB 91% dist/verify.js 6.25 kB 7.42 kB 84% dist/pay.js 4.05 kB 4.49 kB 90% dist/firewall.js 2.80 kB 3.13 kB 90% dist/markdown.js 1.03 kB 1.27 kB 81% Verified by reintroducing the exact regression — re-exporting pay and firewall from the root put index.js at 174% of budget and exited 1, with a message naming the likely cause rather than just a number. Budgets sit ~10% above current. verify.js gets more headroom because the published IP range tables grow whenever the weekly refresh adds prefixes, and a routine range update should not fail CI. Raising a budget is deliberate: `npm run size -- --update`, and justify it in the commit. The table prints on every run, not only on failure, so the trend is visible in CI logs. --- .github/workflows/ci.yml | 7 +++ README.md | 16 ++++++- package.json | 5 ++- scripts/check-size.mjs | 94 ++++++++++++++++++++++++++++++++++++++++ size-budget.json | 25 +++++++++++ 5 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 scripts/check-size.mjs create mode 100644 size-budget.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ec8a77..e99321c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,13 @@ jobs: - run: npm install - run: npm run typecheck - run: npm run build + + # The root entry ships in every consumer's edge middleware. It silently + # doubled once — 9.6 kB to 22.5 kB — because optional surfaces were + # exported from the root by reflex, and nothing failed for weeks. + - name: Bundle budget + run: npm run size + - run: npm run test:unit # Run separately so a composition failure is legible as one. Every bug diff --git a/README.md b/README.md index c7c4bc7..d5450b5 100644 --- a/README.md +++ b/README.md @@ -298,7 +298,21 @@ your bundle if you import it. This split is load-bearing rather than tidy-minded. Exporting the payment and firewall surfaces from the root once pushed it from 9.6 kB to 22.5 kB — every -site paid for a firewall recommender that will never run in middleware. +site paid for a firewall recommender that will never run in middleware. Nothing +failed; the number just drifted for weeks until someone looked. + +So CI now enforces it. `npm run size` checks each entry against +[`size-budget.json`](./size-budget.json) and fails the build on a regression: + +``` +entry gzipped budget used +dist/index.js 4.44 kB 4.88 kB 91% +dist/verify.js 6.25 kB 7.42 kB 84% +dist/pay.js 4.05 kB 4.49 kB 90% +``` + +Raising a budget is deliberate — `npm run size -- --update`, and say why in the +commit. ## Install diff --git a/package.json b/package.json index 32ff6f8..c5cd21f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@apideck/agent-analytics", "version": "0.15.0", - "description": "Track AI agent and bot traffic to your Next.js / Vercel app \u2014 PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", + "description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", "keywords": [ "ai", "agents", @@ -80,7 +80,8 @@ "test:integration": "vitest run test/integration.test.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", - "prepublishOnly": "npm run build" + "prepublishOnly": "npm run build", + "size": "node scripts/check-size.mjs" }, "devDependencies": { "@types/node": "^20.14.0", diff --git a/scripts/check-size.mjs b/scripts/check-size.mjs new file mode 100644 index 0000000..11bf8f6 --- /dev/null +++ b/scripts/check-size.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * Bundle budget check. + * + * The root entry is edge middleware code — it runs on every request of every + * consumer, so its size is a feature, not a vanity metric. + * + * This exists because the number silently doubled. #21 cut the root from 27.7 kB + * to 9.6 kB; over the following commits payments, gateway, entitlement and + * firewall were each exported from the root by reflex, and it climbed back to + * 22.5 kB. Nothing failed. Every test passed. It was caught weeks later while + * fetching a figure for a marketing page. + * + * So: assert the invariant rather than trusting anyone to remember. Zero + * dependencies, in keeping with the package. + * + * node scripts/check-size.mjs # check + * node scripts/check-size.mjs --update # rewrite budgets to current + headroom + */ +import { readFileSync, writeFileSync, existsSync } from 'node:fs' +import { gzipSync } from 'node:zlib' +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' + +const root = join(dirname(fileURLToPath(import.meta.url)), '..') +const BUDGET_FILE = join(root, 'size-budget.json') + +/** Headroom applied by --update. Tight enough that a real regression trips it. */ +const HEADROOM = 1.1 + +function gzipped(path) { + return gzipSync(readFileSync(path), { level: 9 }).length +} + +function fmt(n) { + return `${(n / 1024).toFixed(2)} kB` +} + +if (!existsSync(BUDGET_FILE)) { + console.error(`No ${BUDGET_FILE}. Run with --update to create one.`) + process.exit(1) +} + +const budgets = JSON.parse(readFileSync(BUDGET_FILE, 'utf8')) +const update = process.argv.includes('--update') + +const rows = [] +let failed = false + +for (const [file, entry] of Object.entries(budgets.entries)) { + const path = join(root, file) + if (!existsSync(path)) { + console.error(`missing build output: ${file} — run \`npm run build\` first`) + process.exit(1) + } + const actual = gzipped(path) + const limit = entry.gzipBudget + const pct = Math.round((actual / limit) * 100) + const over = actual > limit + if (over) failed = true + rows.push({ file, actual, limit, pct, over, note: entry.note }) + if (update) entry.gzipBudget = Math.ceil((actual * HEADROOM) / 10) * 10 +} + +const width = Math.max(...rows.map((r) => r.file.length)) +console.log('') +console.log(`${'entry'.padEnd(width)} ${'gzipped'.padStart(9)} ${'budget'.padStart(9)} used`) +console.log('-'.repeat(width + 32)) +for (const r of rows) { + const flag = r.over ? ' OVER' : '' + console.log( + `${r.file.padEnd(width)} ${fmt(r.actual).padStart(9)} ${fmt(r.limit).padStart(9)} ${String(r.pct).padStart(3)}%${flag}` + ) +} +console.log('') + +if (update) { + writeFileSync(BUDGET_FILE, JSON.stringify(budgets, null, 2) + '\n') + console.log(`Budgets rewritten to current + ${Math.round((HEADROOM - 1) * 100)}% headroom.`) + process.exit(0) +} + +if (failed) { + console.error('Bundle budget exceeded.\n') + console.error('This is usually one of two things:') + console.error(' 1. Something optional got exported from the root entry. Check src/index.ts —') + console.error(' payments, firewall and verify belong behind subpaths, not in every') + console.error(' consumer\'s edge bundle.') + console.error(' 2. The growth is genuinely warranted. Then raise the budget deliberately:') + console.error(' node scripts/check-size.mjs --update, and say why in the commit.\n') + process.exit(1) +} + +console.log('All entries within budget.') diff --git a/size-budget.json b/size-budget.json new file mode 100644 index 0000000..f29c36e --- /dev/null +++ b/size-budget.json @@ -0,0 +1,25 @@ +{ + "_comment": "Gzipped byte budgets per entry point. Enforced by scripts/check-size.mjs in CI. Raise deliberately with `node scripts/check-size.mjs --update` and justify it in the commit — the root entry runs in every consumer's edge middleware on every request.", + "entries": { + "dist/index.js": { + "gzipBudget": 5000, + "note": "Root: detection, classification, agentPolicy, trackVisit. The one that matters — keep it tight." + }, + "dist/verify.js": { + "gzipBudget": 7600, + "note": "Carries the published IP range tables, which grow when the weekly refresh adds prefixes. Extra headroom for that." + }, + "dist/pay.js": { + "gzipBudget": 4600, + "note": "Opt-in payment surface." + }, + "dist/firewall.js": { + "gzipBudget": 3200, + "note": "Offline analysis tool; never runs in middleware." + }, + "dist/markdown.js": { + "gzipBudget": 1300, + "note": "Markdown-twin negotiation." + } + } +} From 0ca0bb8ebb3fabd604347a7a0432de65102648d3 Mon Sep 17 00:00:00 2001 From: GJ Date: Sun, 2 Aug 2026 19:36:23 +0200 Subject: [PATCH 3/3] chore: 0.16.0 --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4de0aa..b36dd7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apideck/agent-analytics", - "version": "0.1.0", + "version": "0.16.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apideck/agent-analytics", - "version": "0.1.0", + "version": "0.16.0", "license": "MIT", "devDependencies": { "@types/node": "^20.14.0", @@ -15,7 +15,7 @@ "vitest": "^2.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@esbuild/aix-ppc64": { diff --git a/package.json b/package.json index c5cd21f..7c55180 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@apideck/agent-analytics", - "version": "0.15.0", + "version": "0.16.0", "description": "Track AI agent and bot traffic to your Next.js / Vercel app — PostHog, webhooks, or any custom analytics backend. Detects Claude, ChatGPT, Perplexity, Google-Extended, and more.", "keywords": [ "ai",