From eb6fe036cf9cbfc2023ebab3d2f9d5b7c6cdb5f6 Mon Sep 17 00:00:00 2001 From: Nick Chisiu <8492343+nickchisiu@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:59:20 +0300 Subject: [PATCH] feat(static): retain unique badge referrers --- .github/workflows/deploy-demo.yml | 2 +- RELEASING.md | 14 +- packages/demo-api/__tests__/static.test.ts | 87 ++++++++++ packages/demo-api/__tests__/worker.test.ts | 8 +- .../0001_create_badge_referrer_hosts.sql | 4 + packages/demo-api/package.json | 4 +- packages/demo-api/scripts/deploy-static.mjs | 152 ++++++++++++++++++ packages/demo-api/src/static.ts | 39 +++++ packages/demo-api/wrangler.static.jsonc | 11 ++ 9 files changed, 315 insertions(+), 6 deletions(-) create mode 100644 packages/demo-api/__tests__/static.test.ts create mode 100644 packages/demo-api/migrations/0001_create_badge_referrer_hosts.sql create mode 100644 packages/demo-api/scripts/deploy-static.mjs create mode 100644 packages/demo-api/src/static.ts diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml index 3427769..1f3ed3c 100644 --- a/.github/workflows/deploy-demo.yml +++ b/.github/workflows/deploy-demo.yml @@ -54,7 +54,7 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} run: npm run --workspace=@cortex-docs/demo-api deploy - - name: Deploy static.cortexdocs.dev as assets only + - name: Deploy static.cortexdocs.dev and its referrer registry env: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/RELEASING.md b/RELEASING.md index 8248bf3..a89476b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -32,14 +32,17 @@ The demo deployment workflow performs these actions: 1. Build all packages. 2. Make sure that the configured Cloudflare zone is active. 3. Deploy the demo API Worker to `api.demo.cortexdocs.dev`. -4. Deploy the logo to `static.cortexdocs.dev` with Cloudflare Static Assets. +4. Deploy the logo to `static.cortexdocs.dev` with Cloudflare Static Assets and record each + distinct referrer hostname in D1. 5. Build the complete docs UI as static files. 6. Deploy the static files to `demo.cortexdocs.dev`. 7. Make sure that Cloudflare Static Assets serves each demo page. 8. Block unknown paths before they invoke the demo API Worker. 9. Limit valid demo API requests to 30 requests for each IP address during 10 seconds. -Requests to the two static hosts do not use the daily Workers request allowance. Only `Try now` requests invoke the demo API Worker. +Requests to the demo site do not use the daily Workers request allowance. Badge image requests +invoke a small Worker so it can store new referrer hostnames. `Try now` requests invoke the demo +API Worker. The release workflow performs these actions: @@ -137,3 +140,10 @@ Run this command to preview the product docs build: ```bash npm run --workspace=@cortex-docs/docs-ui docs:preview ``` + +Run this command to list the hostnames that have loaded the Built with Cortex badge: + +```bash +npx wrangler d1 execute cortex-badge-referrers --remote \ + --command="SELECT hostname, first_seen_at FROM badge_referrer_hosts ORDER BY first_seen_at DESC" +``` diff --git a/packages/demo-api/__tests__/static.test.ts b/packages/demo-api/__tests__/static.test.ts new file mode 100644 index 0000000..c6cc919 --- /dev/null +++ b/packages/demo-api/__tests__/static.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from 'vitest'; +import staticWorker, { getReferrerHostname } from '../src/static'; + +function createRuntime() { + const rows = new Set(); + const statements: string[] = []; + const pending: Promise[] = []; + let boundHostname = ''; + + const database = { + prepare: vi.fn((statement: string) => { + statements.push(statement); + return { + bind: vi.fn((hostname: string) => { + boundHostname = hostname; + return { + run: vi.fn(async () => { + rows.add(boundHostname); + return { success: true }; + }), + }; + }), + }; + }), + } as unknown as D1Database; + const assets = { + fetch: vi.fn( + async () => new Response('', { headers: { 'Content-Type': 'image/svg+xml' } }), + ), + } as unknown as Fetcher; + const context = { + waitUntil: vi.fn((promise: Promise) => pending.push(promise)), + } as unknown as ExecutionContext; + + return { assets, context, database, pending, rows, statements }; +} + +describe('static asset Worker', () => { + it('normalizes HTTP referrer hostnames', () => { + expect(getReferrerHostname('https://WWW.Example.com./docs/page?query=1')).toBe( + 'www.example.com', + ); + expect(getReferrerHostname('mailto:hello@example.com')).toBeNull(); + expect(getReferrerHostname('not a URL')).toBeNull(); + expect(getReferrerHostname(null)).toBeNull(); + }); + + it('stores each badge referrer only once and still serves the asset', async () => { + const runtime = createRuntime(); + const request = new Request('https://static.cortexdocs.dev/images/built-with-cortex.svg', { + headers: { Referer: 'https://Example.com/docs' }, + }); + const env = { ASSETS: runtime.assets, BADGE_REFERRERS: runtime.database }; + + const first = await staticWorker.fetch(request, env, runtime.context); + const second = await staticWorker.fetch(request, env, runtime.context); + await Promise.all(runtime.pending); + + expect(first.status).toBe(200); + expect(second.headers.get('Content-Type')).toBe('image/svg+xml'); + expect(runtime.rows).toEqual(new Set(['example.com'])); + expect(runtime.statements).toHaveLength(2); + expect(runtime.statements[0]).toContain('INSERT OR IGNORE'); + }); + + it('does not store missing referrers or HEAD requests', async () => { + const runtime = createRuntime(); + const env = { ASSETS: runtime.assets, BADGE_REFERRERS: runtime.database }; + + await staticWorker.fetch( + new Request('https://static.cortexdocs.dev/images/built-with-cortex.svg'), + env, + runtime.context, + ); + await staticWorker.fetch( + new Request('https://static.cortexdocs.dev/images/built-with-cortex.svg', { + method: 'HEAD', + headers: { Referer: 'https://example.com/' }, + }), + env, + runtime.context, + ); + + expect(runtime.pending).toHaveLength(0); + expect(runtime.rows.size).toBe(0); + }); +}); diff --git a/packages/demo-api/__tests__/worker.test.ts b/packages/demo-api/__tests__/worker.test.ts index 628210b..1c1bac0 100644 --- a/packages/demo-api/__tests__/worker.test.ts +++ b/packages/demo-api/__tests__/worker.test.ts @@ -13,13 +13,17 @@ describe('demo API Worker', () => { await expect(response.json()).resolves.toEqual({ status: 'ok', runtime: 'cloudflare-worker' }); }); - it('keeps the Built with Cortex logo in the assets-only deployment', () => { + it('keeps the Built with Cortex logo in the static deployment', () => { const body = readFileSync( new URL('../static/images/built-with-cortex.svg', import.meta.url), 'utf8', ); const headers = readFileSync(new URL('../static/_headers', import.meta.url), 'utf8'); const workerConfig = readFileSync(new URL('../wrangler.jsonc', import.meta.url), 'utf8'); + const staticWorkerConfig = readFileSync( + new URL('../wrangler.static.jsonc', import.meta.url), + 'utf8', + ); expect(body).toContain('Built with Cortex'); expect(body).toContain('font-size="12" font-weight="600">Cortex'); @@ -27,6 +31,8 @@ describe('demo API Worker', () => { expect(headers).toContain('Cache-Control: public,max-age=86400'); expect(headers).toContain('Cross-Origin-Resource-Policy: cross-origin'); expect(workerConfig).toContain('"directory": "static"'); + expect(staticWorkerConfig).toContain('"run_worker_first": ["/images/built-with-cortex.svg"]'); + expect(staticWorkerConfig).toContain('"binding": "BADGE_REFERRERS"'); }); it('returns the Petstore collection with CORS headers', async () => { diff --git a/packages/demo-api/migrations/0001_create_badge_referrer_hosts.sql b/packages/demo-api/migrations/0001_create_badge_referrer_hosts.sql new file mode 100644 index 0000000..f042d58 --- /dev/null +++ b/packages/demo-api/migrations/0001_create_badge_referrer_hosts.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS badge_referrer_hosts ( + hostname TEXT PRIMARY KEY COLLATE NOCASE, + first_seen_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +) WITHOUT ROWID; diff --git a/packages/demo-api/package.json b/packages/demo-api/package.json index ad46cd4..21b3671 100644 --- a/packages/demo-api/package.json +++ b/packages/demo-api/package.json @@ -7,10 +7,10 @@ "build": "tsc", "dev": "wrangler dev --port 4010", "deploy": "wrangler deploy", - "deploy:static": "wrangler deploy --config wrangler.static.jsonc", + "deploy:static": "node scripts/deploy-static.mjs", "test": "vitest run", "test:coverage": "vitest run --coverage", - "clean": "rm -rf .wrangler dist coverage" + "clean": "rm -rf .wrangler dist coverage wrangler.static.production.jsonc" }, "dependencies": { "graphql": "^16.14.0" diff --git a/packages/demo-api/scripts/deploy-static.mjs b/packages/demo-api/scripts/deploy-static.mjs new file mode 100644 index 0000000..0a747c7 --- /dev/null +++ b/packages/demo-api/scripts/deploy-static.mjs @@ -0,0 +1,152 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const databaseName = 'cortex-badge-referrers'; +const databaseBinding = 'BADGE_REFERRERS'; +const databaseIdPlaceholder = '00000000-0000-0000-0000-000000000000'; +const verificationHostname = 'docs.cortexdocs.dev'; +const badgeUrl = 'https://static.cortexdocs.dev/images/built-with-cortex.svg'; +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const packageDir = resolve(scriptDir, '..'); +const sourceConfigPath = join(packageDir, 'wrangler.static.jsonc'); +const productionConfigPath = join(packageDir, 'wrangler.static.production.jsonc'); +const require = createRequire(import.meta.url); +const wranglerPackagePath = require.resolve('wrangler/package.json'); +const wranglerPackage = require(wranglerPackagePath); +const wranglerCli = resolve(dirname(wranglerPackagePath), wranglerPackage.bin.wrangler); + +function run(args, { capture = false } = {}) { + return new Promise((resolveCommand, rejectCommand) => { + let stdout = ''; + let stderr = ''; + const child = spawn(process.execPath, [wranglerCli, ...args], { + cwd: packageDir, + env: { ...process.env, CI: process.env.CI || 'true' }, + stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit', + }); + + if (capture) { + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + } + + child.once('error', rejectCommand); + child.once('exit', (code, signal) => { + if (signal) { + rejectCommand(new Error(`Wrangler stopped with signal ${signal}.`)); + } else if (code !== 0) { + rejectCommand( + new Error( + `Wrangler ${args.join(' ')} failed with code ${code}.${stderr ? `\n${stderr}` : ''}`, + ), + ); + } else { + resolveCommand({ stdout, stderr }); + } + }); + }); +} + +async function listDatabases() { + const { stdout } = await run(['d1', 'list', '--json'], { capture: true }); + const databases = JSON.parse(stdout); + if (!Array.isArray(databases)) throw new Error('Wrangler returned an invalid D1 database list.'); + return databases; +} + +async function findOrCreateDatabase() { + let database = (await listDatabases()).find((candidate) => candidate.name === databaseName); + if (database) return database; + + console.log(`Creating the ${databaseName} production D1 database.`); + await run(['d1', 'create', databaseName, '--location', 'eeur']); + + for (let attempt = 1; attempt <= 5; attempt += 1) { + database = (await listDatabases()).find((candidate) => candidate.name === databaseName); + if (database) return database; + await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 1000)); + } + + throw new Error(`The ${databaseName} database was created but could not be listed.`); +} + +async function writeProductionConfig(databaseId) { + const source = await readFile(sourceConfigPath, 'utf8'); + if (!source.includes(databaseIdPlaceholder)) { + throw new Error('The static Wrangler configuration is missing its D1 database placeholder.'); + } + await writeFile(productionConfigPath, source.replace(databaseIdPlaceholder, databaseId)); +} + +async function countVerificationHostname() { + const query = [ + 'SELECT COUNT(*) AS records', + 'FROM badge_referrer_hosts', + `WHERE hostname = '${verificationHostname}'`, + ].join(' '); + const { stdout } = await run( + [ + 'd1', + 'execute', + databaseBinding, + '--remote', + '--json', + '--config', + productionConfigPath, + '--command', + query, + ], + { capture: true }, + ); + const results = JSON.parse(stdout); + return Number(results?.[0]?.results?.[0]?.records ?? 0); +} + +async function verifyDeployment() { + for (let attempt = 1; attempt <= 10; attempt += 1) { + const first = await fetch(badgeUrl, { + headers: { Referer: `https://${verificationHostname}/` }, + }); + const second = await fetch(badgeUrl, { + headers: { Referer: `https://${verificationHostname}/` }, + }); + if (first.ok && second.ok && (await countVerificationHostname()) === 1) { + console.log('Static badge deployment and referrer deduplication verified.'); + return; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, attempt * 1000)); + } + + throw new Error('The static badge deployment did not record exactly one referrer hostname.'); +} + +try { + await mkdir(dirname(productionConfigPath), { recursive: true }); + const database = await findOrCreateDatabase(); + if (!database.uuid) throw new Error(`The ${databaseName} database does not have an ID.`); + await writeProductionConfig(database.uuid); + await run([ + 'd1', + 'migrations', + 'apply', + databaseBinding, + '--remote', + '--config', + productionConfigPath, + ]); + await run(['deploy', '--config', productionConfigPath]); + await verifyDeployment(); +} finally { + await rm(productionConfigPath, { force: true }); +} diff --git a/packages/demo-api/src/static.ts b/packages/demo-api/src/static.ts new file mode 100644 index 0000000..75f5fc9 --- /dev/null +++ b/packages/demo-api/src/static.ts @@ -0,0 +1,39 @@ +const badgePath = '/images/built-with-cortex.svg'; +const insertReferrer = 'INSERT OR IGNORE INTO badge_referrer_hosts (hostname) VALUES (?)'; + +interface StaticAssetsEnv { + ASSETS: Fetcher; + BADGE_REFERRERS: D1Database; +} + +export function getReferrerHostname(value: string | null): string | null { + if (!value) return null; + + try { + const url = new URL(value); + if (url.protocol !== 'http:' && url.protocol !== 'https:') return null; + return url.hostname.toLowerCase().replace(/\.$/, '') || null; + } catch { + return null; + } +} + +async function storeReferrer(database: D1Database, hostname: string): Promise { + try { + await database.prepare(insertReferrer).bind(hostname).run(); + } catch (error) { + console.error('Unable to store the Built with Cortex referrer hostname.', error); + } +} + +export default { + fetch(request: Request, env: StaticAssetsEnv, context: ExecutionContext): Promise { + const url = new URL(request.url); + if (request.method === 'GET' && url.pathname === badgePath) { + const hostname = getReferrerHostname(request.headers.get('Referer')); + if (hostname) context.waitUntil(storeReferrer(env.BADGE_REFERRERS, hostname)); + } + + return env.ASSETS.fetch(request); + }, +} satisfies ExportedHandler; diff --git a/packages/demo-api/wrangler.static.jsonc b/packages/demo-api/wrangler.static.jsonc index b772f82..b708811 100644 --- a/packages/demo-api/wrangler.static.jsonc +++ b/packages/demo-api/wrangler.static.jsonc @@ -1,6 +1,7 @@ { "$schema": "../../node_modules/wrangler/config-schema.json", "name": "cortex-static-assets", + "main": "src/static.ts", "compatibility_date": "2026-08-24", "workers_dev": false, "routes": [ @@ -9,8 +10,18 @@ "custom_domain": true, }, ], + "d1_databases": [ + { + "binding": "BADGE_REFERRERS", + "database_name": "cortex-badge-referrers", + "database_id": "00000000-0000-0000-0000-000000000000", + "migrations_dir": "migrations", + }, + ], "assets": { "directory": "static", + "binding": "ASSETS", + "run_worker_first": ["/images/built-with-cortex.svg"], "html_handling": "none", "not_found_handling": "none", },