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 e9d5d51..d5450b5 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,38 @@ 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. 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 ```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-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 fcb4354..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", @@ -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", @@ -70,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." + } + } +} 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' ],