From 7cddfe5ecb0ed50f176d201fc424f21daeb4f396 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 15:39:53 -0600 Subject: [PATCH 01/10] fix: dashboard reads from configured Analytics Engine dataset --- packages/cli/src/commands/install.ts | 5 ++ packages/cli/src/lib/__tests__/config.test.ts | 11 +++-- packages/cli/src/lib/config.ts | 15 ++++++ packages/server/.dev.vars.example | 1 + .../app/analytics/__tests__/query.test.ts | 47 +++++++++++++++++++ packages/server/app/analytics/query.ts | 31 ++++++++---- packages/server/app/load-context.ts | 1 + packages/server/worker-configuration.d.ts | 1 + packages/server/workers/app.ts | 1 + packages/server/workers/lib/arrow.ts | 14 ++++-- packages/server/wrangler.json | 3 ++ 11 files changed, 115 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/commands/install.ts b/packages/cli/src/commands/install.ts index a98bc3eb..d4754a9a 100644 --- a/packages/cli/src/commands/install.ts +++ b/packages/cli/src/commands/install.ts @@ -218,6 +218,11 @@ export async function install( // If --advanced is true, prompt the user for worker name and analytics dataset name. // Otherwise, stick to the default values read from the server package. if (opts.advanced) { + log.warn( + "If you previously installed with a custom worker name or analytics dataset, " + + "re-enter the same values below — accepting the defaults will repoint your " + + "deployment at a fresh dataset and your existing analytics will appear empty.", + ); ({ workerName, analyticsDataset } = await promptProjectConfig( workerName, analyticsDataset, diff --git a/packages/cli/src/lib/__tests__/config.test.ts b/packages/cli/src/lib/__tests__/config.test.ts index 647cbe0f..af152617 100644 --- a/packages/cli/src/lib/__tests__/config.test.ts +++ b/packages/cli/src/lib/__tests__/config.test.ts @@ -254,7 +254,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", ); // Verify writeFileSync was called with the correct arguments @@ -271,8 +271,11 @@ describe("CLI Functions", () => { // Verify worker name and dataset were updated expect(writtenConfig.name).toBe("new-worker"); expect(writtenConfig.analytics_engine_datasets[0].dataset).toBe( - "new-dataset", + "newDataset", ); + // Verify CF_DATASET_NAME var was set so the dashboard's SQL + // read path uses the same dataset as the AE binding. + expect(writtenConfig.vars.CF_DATASET_NAME).toBe("newDataset"); // Verify paths were made absolute expect(writtenConfig.build.cwd).toMatch(/^\//); // Should start with / @@ -294,7 +297,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", accountId, ); @@ -319,7 +322,7 @@ describe("CLI Functions", () => { "/target/wrangler.json", initialConfig, "new-worker", - "new-dataset", + "newDataset", ); const writtenConfig = JSON.parse( diff --git a/packages/cli/src/lib/config.ts b/packages/cli/src/lib/config.ts index 61e04866..728cfb1e 100644 --- a/packages/cli/src/lib/config.ts +++ b/packages/cli/src/lib/config.ts @@ -123,6 +123,11 @@ export function readInitialServerConfig() { * converted to be absolute. This makes it so that the `wrangler deploy` command can be * run from any directory. */ +// Mirrors the validation in AnalyticsEngineAPI's constructor — the dataset +// name is interpolated into raw SQL on the read path, and only matching names +// will pass the server-side guard. +export const DATASET_NAME_PATTERN = /^[A-Za-z0-9_]+$/; + export async function stageDeployConfig( targetPath: string, initialDeployConfig: ReturnType, @@ -130,6 +135,12 @@ export async function stageDeployConfig( analyticsDataset: string, accountId?: string, ): Promise { + if (!DATASET_NAME_PATTERN.test(analyticsDataset)) { + throw new Error( + `Invalid Analytics Engine dataset name: ${analyticsDataset}. Only letters, digits, and underscores are allowed.`, + ); + } + const serverPkgDir = getServerPkgDir(); const outDeployConfig = makePathsAbsolute( @@ -138,6 +149,10 @@ export async function stageDeployConfig( ); outDeployConfig.name = workerName; outDeployConfig.analytics_engine_datasets[0].dataset = analyticsDataset; + outDeployConfig.vars = { + ...(outDeployConfig.vars ?? {}), + CF_DATASET_NAME: analyticsDataset, + }; if (accountId) { outDeployConfig.account_id = accountId; diff --git a/packages/server/.dev.vars.example b/packages/server/.dev.vars.example index 8946785f..91fd650d 100644 --- a/packages/server/.dev.vars.example +++ b/packages/server/.dev.vars.example @@ -6,3 +6,4 @@ CF_PASSWORD_HASH='' CF_JWT_SECRET='' CF_AUTH_ENABLED='' CF_STORAGE_ENABLED='' +CF_DATASET_NAME='' diff --git a/packages/server/app/analytics/__tests__/query.test.ts b/packages/server/app/analytics/__tests__/query.test.ts index c21d8d6b..c56bda8c 100644 --- a/packages/server/app/analytics/__tests__/query.test.ts +++ b/packages/server/app/analytics/__tests__/query.test.ts @@ -682,3 +682,50 @@ describe("intervalToSql", () => { }); }); }); + +describe("AnalyticsEngineAPI dataset name", () => { + let fetch: Mock; + beforeEach(() => { + fetch = global.fetch = vi.fn(); + fetch.mockResolvedValue(createFetchResponse({ data: [] })); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("defaults to metricsDataset when no dataset arg is provided", () => { + const api = new AnalyticsEngineAPI("acct", "tok"); + expect(api.dataset).toBe("metricsDataset"); + }); + + test("defaults to metricsDataset when empty string is provided", () => { + const api = new AnalyticsEngineAPI("acct", "tok", ""); + expect(api.dataset).toBe("metricsDataset"); + }); + + test("uses the provided custom dataset name", () => { + const api = new AnalyticsEngineAPI("acct", "tok", "counterscaleMetrics"); + expect(api.dataset).toBe("counterscaleMetrics"); + }); + + test("rejects invalid dataset names", () => { + expect( + () => new AnalyticsEngineAPI("acct", "tok", "bad name; DROP"), + ).toThrow(/Invalid Analytics Engine dataset name/); + }); + + test("getCounts emits SQL referencing the custom dataset", async () => { + const api = new AnalyticsEngineAPI( + "acct", + "tok", + "counterscaleMetrics", + ); + await api.getCounts("site1", "7d"); + expect(fetch).toHaveBeenCalled(); + const body = fetch.mock.calls[0][1].body as string; + expect(body).toContain("FROM counterscaleMetrics"); + expect(body).not.toContain("FROM metricsDataset"); + }); +}); diff --git a/packages/server/app/analytics/query.ts b/packages/server/app/analytics/query.ts index 5810f7eb..d66482af 100644 --- a/packages/server/app/analytics/query.ts +++ b/packages/server/app/analytics/query.ts @@ -166,9 +166,12 @@ function filtersToSql(filters: SearchFilters) { * See: https://developers.cloudflare.com/analytics/analytics-engine/sql-reference/ */ +export const DEFAULT_DATASET_NAME = "metricsDataset"; + export class AnalyticsEngineAPI { cfApiToken: string; cfAccountId: string; + dataset: string; defaultHeaders: { "content-type": string; "X-Source": string; @@ -176,10 +179,22 @@ export class AnalyticsEngineAPI { }; defaultUrl: string; - constructor(cfAccountId: string, cfApiToken: string) { + constructor( + cfAccountId: string, + cfApiToken: string, + dataset?: string, + ) { this.cfAccountId = cfAccountId; this.cfApiToken = cfApiToken; + const resolved = dataset || DEFAULT_DATASET_NAME; + if (!/^[A-Za-z0-9_]+$/.test(resolved)) { + throw new Error( + `Invalid Analytics Engine dataset name: ${resolved}`, + ); + } + this.dataset = resolved; + this.defaultUrl = `https://api.cloudflare.com/client/v4/accounts/${this.cfAccountId}/analytics_engine/sql`; this.defaultHeaders = { "content-type": "application/json;charset=UTF-8", @@ -246,7 +261,7 @@ export class AnalyticsEngineAPI { /* output as UTC */ toDateTime(_bucket, 'Etc/UTC') as bucket - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= toDateTime('${localStartTime.format("YYYY-MM-DD HH:mm:ss")}') AND timestamp < toDateTime('${localEndTime.format("YYYY-MM-DD HH:mm:ss")}') AND ${ColumnMappings.siteId} = '${siteId}' @@ -352,7 +367,7 @@ export class AnalyticsEngineAPI { SELECT SUM(_sample_interval) as count, ${ColumnMappings.newVisitor} as isVisitor, ${ColumnMappings.bounce} as isBounce - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} ${filterStr} AND ${siteIdColumn} = '${siteId}' @@ -416,7 +431,7 @@ export class AnalyticsEngineAPI { const _column = ColumnMappings[column]; const query = ` SELECT ${_column}, SUM(_sample_interval) as count - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} AND ${ColumnMappings.newVisitor} = 1 AND ${ColumnMappings.siteId} = '${siteId}' @@ -492,7 +507,7 @@ export class AnalyticsEngineAPI { ${ColumnMappings.newVisitor} as isVisitor, ${ColumnMappings.bounce} as isBounce, ${columnsStrWithAliases} - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= toDateTime('${startDateTimeSql}') AND timestamp < toDateTime('${endDateTimeSql}') GROUP BY timestamp, ${ColumnMappings.siteId}, @@ -584,7 +599,7 @@ export class AnalyticsEngineAPI { SELECT ${_column}, ${ColumnMappings.newVisitor} as isVisitor, SUM(_sample_interval) as count - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} AND ${ColumnMappings.newVisitor} = 0 AND ${ColumnMappings.siteId} = '${siteId}' @@ -889,7 +904,7 @@ export class AnalyticsEngineAPI { const query = ` SELECT SUM(_sample_interval) as count, ${ColumnMappings.siteId} as siteId - FROM metricsDataset + FROM ${this.dataset} WHERE timestamp >= ${startIntervalSql} AND timestamp < ${endIntervalSql} GROUP BY siteId ORDER BY count DESC @@ -936,7 +951,7 @@ export class AnalyticsEngineAPI { SELECT MIN(timestamp) as earliestEvent, ${ColumnMappings.bounce} as isBounce - FROM metricsDataset + FROM ${this.dataset} WHERE ${ColumnMappings.siteId} = '${siteId}' GROUP by isBounce `; diff --git a/packages/server/app/load-context.ts b/packages/server/app/load-context.ts index 6e7fa07a..90d4cdc5 100644 --- a/packages/server/app/load-context.ts +++ b/packages/server/app/load-context.ts @@ -25,6 +25,7 @@ export const getLoadContext: GetLoadContext = ({ context }) => { const analyticsEngine = new AnalyticsEngineAPI( context.cloudflare.env.CF_ACCOUNT_ID, context.cloudflare.env.CF_BEARER_TOKEN, + context.cloudflare.env.CF_DATASET_NAME, ); return { diff --git a/packages/server/worker-configuration.d.ts b/packages/server/worker-configuration.d.ts index 3d59e313..db83894f 100644 --- a/packages/server/worker-configuration.d.ts +++ b/packages/server/worker-configuration.d.ts @@ -10,6 +10,7 @@ declare namespace Cloudflare { CF_AUTH_ENABLED: string; CF_TRACKER_SCRIPT_NAME: string; CF_STORAGE_ENABLED: string; + CF_DATASET_NAME: string; DAILY_ROLLUPS: R2Bucket; WEB_COUNTER_AE: AnalyticsEngineDataset; ASSETS: Fetcher; diff --git a/packages/server/workers/app.ts b/packages/server/workers/app.ts index 76cb782d..54e0d937 100644 --- a/packages/server/workers/app.ts +++ b/packages/server/workers/app.ts @@ -29,6 +29,7 @@ export default { { accountId: env.CF_ACCOUNT_ID, bearerToken: env.CF_BEARER_TOKEN, + dataset: env.CF_DATASET_NAME, }, env.DAILY_ROLLUPS, ), diff --git a/packages/server/workers/lib/arrow.ts b/packages/server/workers/lib/arrow.ts index 746ca262..f6c92552 100644 --- a/packages/server/workers/lib/arrow.ts +++ b/packages/server/workers/lib/arrow.ts @@ -4,10 +4,14 @@ import { tableFromJSON, tableToIPC } from "apache-arrow"; import dayjs from "dayjs"; export async function extractAsArrow( - { accountId, bearerToken }: { accountId: string; bearerToken: string }, + { + accountId, + bearerToken, + dataset, + }: { accountId: string; bearerToken: string; dataset?: string }, bucket: R2Bucket, ) { - const api = new AnalyticsEngineAPI(accountId, bearerToken); + const api = new AnalyticsEngineAPI(accountId, bearerToken, dataset); // Get yesterday's date range const yesterday = dayjs().subtract(1, "day"); @@ -141,7 +145,11 @@ if (import.meta.url === `file://${process.argv[1]}`) { try { const result = await extractAsArrow( - { accountId, bearerToken }, + { + accountId, + bearerToken, + dataset: process.env.CF_DATASET_NAME, + }, mockBucket, ); console.log("Success:", result); diff --git a/packages/server/wrangler.json b/packages/server/wrangler.json index 435959b1..2b208d5a 100644 --- a/packages/server/wrangler.json +++ b/packages/server/wrangler.json @@ -7,6 +7,9 @@ "binding": "ASSETS", "directory": "./build/client" }, + "vars": { + "CF_DATASET_NAME": "metricsDataset" + }, "analytics_engine_datasets": [ { "binding": "WEB_COUNTER_AE", From 8af6c7acea6566843c33d0b60aee0892684b0e97 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 16:48:38 -0600 Subject: [PATCH 02/10] feat(server): add TRACKER_ALLOWED_ORIGINS with subdomain matching for /tracker.js CORS --- packages/server/app/routes/$script.ts | 65 +++++++- .../app/routes/__tests__/$script.test.tsx | 144 +++++++++++++++++- packages/server/worker-configuration.d.ts | 1 + packages/server/wrangler.json | 6 +- 4 files changed, 208 insertions(+), 8 deletions(-) diff --git a/packages/server/app/routes/$script.ts b/packages/server/app/routes/$script.ts index 997e24c5..ba00d0d0 100644 --- a/packages/server/app/routes/$script.ts +++ b/packages/server/app/routes/$script.ts @@ -25,9 +25,72 @@ export async function loader({ params, context, request }: LoaderFunctionArgs) { try { const url = new URL(request.url); const trackerUrl = `${url.protocol}//${url.host}/tracker.js`; - return await context.cloudflare.env.ASSETS.fetch(trackerUrl) + const assetResponse = + await context.cloudflare.env.ASSETS.fetch(trackerUrl); + + const allowedOrigin = resolveAllowedOrigin( + context.cloudflare.env.TRACKER_ALLOWED_ORIGINS, + request.headers?.get("Origin") ?? null, + ); + + const response = new Response(assetResponse.body, assetResponse); + response.headers.set("Access-Control-Allow-Origin", allowedOrigin); + if (allowedOrigin !== "*") { + const vary = response.headers.get("Vary"); + response.headers.set( + "Vary", + vary ? `${vary}, Origin` : "Origin", + ); + } + return response; } catch (error) { console.error("Error serving tracker script:", error); return new Response("Error serving script", { status: 500 }); } } + +function resolveAllowedOrigin( + allowedOriginsVar: string | undefined, + requestOrigin: string | null, +): string { + const list = (allowedOriginsVar ?? "") + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + + if (list.length === 0) { + return "*"; + } + + if (requestOrigin && originMatchesList(requestOrigin, list)) { + return requestOrigin; + } + + return list[0]; +} + +function originMatchesList(origin: string, list: string[]): boolean { + let originHost: string; + try { + originHost = new URL(origin).hostname.toLowerCase(); + } catch { + return false; + } + + return list.some((entry) => { + const entryHost = extractHost(entry).toLowerCase(); + if (!entryHost) return false; + return ( + originHost === entryHost || + originHost.endsWith(`.${entryHost}`) + ); + }); +} + +function extractHost(value: string): string { + try { + return new URL(value).hostname; + } catch { + return value.replace(/^\*\./, ""); + } +} diff --git a/packages/server/app/routes/__tests__/$script.test.tsx b/packages/server/app/routes/__tests__/$script.test.tsx index 6876c114..81186231 100644 --- a/packages/server/app/routes/__tests__/$script.test.tsx +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -2,16 +2,24 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { loader } from "../$script"; describe("Dynamic script route", () => { - const mockRequest = { - url: "https://example.com/analytics.js", - } as Request; + const buildMockRequest = (origin?: string): Request => + ({ + url: "https://example.com/analytics.js", + headers: new Headers(origin ? { Origin: origin } : {}), + }) as Request; + + const mockRequest = buildMockRequest(); const mockAssetsFetch = vi.fn(); - const createMockContext = (customScriptName?: string) => ({ + const createMockContext = ( + customScriptName?: string, + allowedOrigins?: string, + ) => ({ cloudflare: { env: { CF_TRACKER_SCRIPT_NAME: customScriptName, + TRACKER_ALLOWED_ORIGINS: allowedOrigins, ASSETS: { fetch: mockAssetsFetch, }, @@ -148,10 +156,136 @@ describe("Dynamic script route", () => { request: mockRequest, } as any); - expect(response).toBe(mockResponse); + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe( + "public, max-age=3600", + ); + expect(await response.text()).toBe( + "console.log('tracker script');", + ); expect(mockAssetsFetch).toHaveBeenCalledWith( "https://example.com/tracker.js", ); }); }); + + describe("Access-Control-Allow-Origin header", () => { + const buildAssetResponse = () => + new Response("console.log('tracker');", { + status: 200, + headers: { "Content-Type": "application/javascript" }, + }); + + it("defaults to '*' when TRACKER_ALLOWED_ORIGINS is missing", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(), + request: buildMockRequest("https://anything.example"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "*", + ); + expect(response.headers.get("Vary")).toBeNull(); + }); + + it("defaults to '*' when TRACKER_ALLOWED_ORIGINS is empty", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(undefined, " "), + request: buildMockRequest("https://anything.example"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "*", + ); + }); + + it("echoes a matching Origin and sets Vary: Origin", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext( + undefined, + "https://foo.com, https://bar.com", + ), + request: buildMockRequest("https://bar.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://bar.com", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("matches subdomains of listed bare hosts", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(undefined, "shiftinbits.com"), + request: buildMockRequest("https://test.shiftinbits.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://test.shiftinbits.com", + ); + }); + + it("matches subdomains of listed origins", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext( + undefined, + "https://shiftinbits.com", + ), + request: buildMockRequest("https://a.b.shiftinbits.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://a.b.shiftinbits.com", + ); + }); + + it("does not treat sibling domains as subdomains", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext( + undefined, + "https://foo.com, https://bar.com", + ), + request: buildMockRequest("https://evil-foo.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://foo.com", + ); + }); + + it("falls back to first allowed origin when Origin is missing", async () => { + mockAssetsFetch.mockResolvedValue(buildAssetResponse()); + + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext( + undefined, + "https://foo.com, https://bar.com", + ), + request: buildMockRequest(), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://foo.com", + ); + }); + }); }); diff --git a/packages/server/worker-configuration.d.ts b/packages/server/worker-configuration.d.ts index db83894f..deabde8d 100644 --- a/packages/server/worker-configuration.d.ts +++ b/packages/server/worker-configuration.d.ts @@ -11,6 +11,7 @@ declare namespace Cloudflare { CF_TRACKER_SCRIPT_NAME: string; CF_STORAGE_ENABLED: string; CF_DATASET_NAME: string; + TRACKER_ALLOWED_ORIGINS: string; DAILY_ROLLUPS: R2Bucket; WEB_COUNTER_AE: AnalyticsEngineDataset; ASSETS: Fetcher; diff --git a/packages/server/wrangler.json b/packages/server/wrangler.json index 2b208d5a..12b9cf04 100644 --- a/packages/server/wrangler.json +++ b/packages/server/wrangler.json @@ -5,10 +5,12 @@ "compatibility_date": "2024-12-13", "assets": { "binding": "ASSETS", - "directory": "./build/client" + "directory": "./build/client", + "run_worker_first": ["/tracker.js"] }, "vars": { - "CF_DATASET_NAME": "metricsDataset" + "CF_DATASET_NAME": "metricsDataset", + "TRACKER_ALLOWED_ORIGINS": "" }, "analytics_engine_datasets": [ { From 75cf0034d1fd7521b49a2991dd357be1156b6da8 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 16:49:42 -0600 Subject: [PATCH 03/10] chore(server): add Vite client type reference for ?raw imports --- packages/server/app/vite-env.d.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 packages/server/app/vite-env.d.ts diff --git a/packages/server/app/vite-env.d.ts b/packages/server/app/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/server/app/vite-env.d.ts @@ -0,0 +1 @@ +/// From 7d828bcf0c5a9b882e549279a17e487175997620 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 16:51:51 -0600 Subject: [PATCH 04/10] chore(server): relocate tracker.js source under app/tracker --- .gitignore | 5 +- packages/server/app/tracker/.gitkeep | 0 packages/server/package.json | 190 ++++++++++++++------------- 3 files changed, 100 insertions(+), 95 deletions(-) create mode 100644 packages/server/app/tracker/.gitkeep diff --git a/.gitignore b/.gitignore index c36c3871..1f15d22c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,7 @@ dist coverage playwright-report -__screenshots__ \ No newline at end of file +__screenshots__ + +# Copied from ../tracker/dist (see packages/server/package.json copytracker script) +packages/server/app/tracker/tracker.js \ No newline at end of file diff --git a/packages/server/app/tracker/.gitkeep b/packages/server/app/tracker/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/package.json b/packages/server/package.json index f790946d..1118430f 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,97 +1,99 @@ { - "name": "@counterscale/server", - "version": "3.4.1", - "description": "Counterscale Server", - "homepage": "https://counterscale.dev", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/benvinegar/counterscale", - "directory": "packages/server" - }, - "engines": { - "node": ">=20.0.0" - }, - "files": [ - "build", - "app", - "workers", - "wrangler.json" - ], - "scripts": { - "dev": "react-router dev", - "build": "react-router build", - "preview": "wrangler dev --var VERSION:`git rev-parse HEAD`", - "deploy": "wrangler deploy --var VERSION:`git rev-parse HEAD`", - "lint": "eslint .", - "test": "TZ=EST vitest run", - "test-ci": "TZ=EST vitest run --coverage", - "typecheck": "react-router typegen && tsc", - "prepare": "husky", - "types:gen": "wrangler types", - "copytracker": "cp ../tracker/dist/loader/tracker.js ./public/tracker.js" - }, - "dependencies": { - "@cloudflare/kv-asset-handler": "^0.3.3", - "@counterscale/tracker": "*", - "@radix-ui/react-select": "^2.1.0", - "@radix-ui/react-slot": "^1.1.0", - "@react-router/cloudflare": "7.1.1", - "@types/jsonwebtoken": "^9.0.10", - "apache-arrow": "^21.1.0", - "bcryptjs": "^3.0.2", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "dayjs": "^1.11.11", - "isbot": "^5.1.9", - "jsonwebtoken": "^9.0.2", - "lucide-react": "^0.395.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-router": "7.7.1", - "recharts": "2.13", - "tailwind-merge": "^2.3.0", - "tailwindcss-animate": "^1.0.7", - "ua-parser-js": "^1.0.38" - }, - "devDependencies": { - "@cloudflare/workers-types": "^4.20250803.0", - "@counterscale/eslint-config": "workspace:*", - "@react-router/dev": "7.7.1", - "@react-router/fs-routes": "7.7.1", - "@testing-library/react": "^16.2.0", - "@types/node": "^22.10.2", - "@types/react": "^18.3.3", - "@types/react-dom": "^18.3.0", - "@types/recharts": "^1.8.29", - "@types/ua-parser-js": "^0.7.39", - "@typescript-eslint/eslint-plugin": "^8.26.1", - "@typescript-eslint/parser": "^8.26.1", - "@vitest/coverage-istanbul": "~4.0.8", - "@vitest/coverage-v8": "~4.0.8", - "autoprefixer": "^10.4.19", - "globals": "^16.0.0", - "husky": "^9.0.11", - "jsdom": "^23.2.0", - "node-mocks-http": "^1.14.1", - "postcss": "^8.4.38", - "resize-observer-polyfill": "^1.5.1", - "tailwindcss": "^3.4.4", - "typescript": "^5.7.2", - "vite": "^7.2.2", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "~4.0.8", - "vitest-dom": "^0.1.1", - "wrangler": "^4.23.0" - }, - "postcss": { - "plugins": { - "tailwindcss": {}, - "autoprefixer": {} - } - }, - "publishConfig": { - "access": "public", - "registry": "https://registry.npmjs.org/" + "name": "@counterscale/server", + "version": "3.4.1", + "description": "Counterscale Server", + "homepage": "https://counterscale.dev", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/benvinegar/counterscale", + "directory": "packages/server" + }, + "engines": { + "node": ">=20.0.0" + }, + "files": [ + "build", + "app", + "workers", + "wrangler.json" + ], + "scripts": { + "predev": "pnpm run copytracker", + "dev": "react-router dev", + "prebuild": "pnpm run copytracker", + "build": "react-router build", + "preview": "wrangler dev --var VERSION:`git rev-parse HEAD`", + "deploy": "wrangler deploy --var VERSION:`git rev-parse HEAD`", + "lint": "eslint .", + "test": "TZ=EST vitest run", + "test-ci": "TZ=EST vitest run --coverage", + "typecheck": "react-router typegen && tsc", + "prepare": "husky", + "types:gen": "wrangler types", + "copytracker": "mkdir -p ./app/tracker && cp ../tracker/dist/loader/tracker.js ./app/tracker/tracker.js" + }, + "dependencies": { + "@cloudflare/kv-asset-handler": "^0.3.3", + "@counterscale/tracker": "*", + "@radix-ui/react-select": "^2.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@react-router/cloudflare": "7.1.1", + "@types/jsonwebtoken": "^9.0.10", + "apache-arrow": "^21.1.0", + "bcryptjs": "^3.0.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "isbot": "^5.1.9", + "jsonwebtoken": "^9.0.2", + "lucide-react": "^0.395.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router": "7.7.1", + "recharts": "2.13", + "tailwind-merge": "^2.3.0", + "tailwindcss-animate": "^1.0.7", + "ua-parser-js": "^1.0.38" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250803.0", + "@counterscale/eslint-config": "workspace:*", + "@react-router/dev": "7.7.1", + "@react-router/fs-routes": "7.7.1", + "@testing-library/react": "^16.2.0", + "@types/node": "^22.10.2", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@types/recharts": "^1.8.29", + "@types/ua-parser-js": "^0.7.39", + "@typescript-eslint/eslint-plugin": "^8.26.1", + "@typescript-eslint/parser": "^8.26.1", + "@vitest/coverage-istanbul": "~4.0.8", + "@vitest/coverage-v8": "~4.0.8", + "autoprefixer": "^10.4.19", + "globals": "^16.0.0", + "husky": "^9.0.11", + "jsdom": "^23.2.0", + "node-mocks-http": "^1.14.1", + "postcss": "^8.4.38", + "resize-observer-polyfill": "^1.5.1", + "tailwindcss": "^3.4.4", + "typescript": "^5.7.2", + "vite": "^7.2.2", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "~4.0.8", + "vitest-dom": "^0.1.1", + "wrangler": "^4.23.0" + }, + "postcss": { + "plugins": { + "tailwindcss": {}, + "autoprefixer": {} } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } } From 6060479c0a74e7d0071149606a74b8b07d4332e0 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 16:54:08 -0600 Subject: [PATCH 05/10] refactor(server): inline tracker source via ?raw import --- packages/server/app/routes/$script.ts | 50 ++++++++++----------------- 1 file changed, 19 insertions(+), 31 deletions(-) diff --git a/packages/server/app/routes/$script.ts b/packages/server/app/routes/$script.ts index ba00d0d0..7edda20d 100644 --- a/packages/server/app/routes/$script.ts +++ b/packages/server/app/routes/$script.ts @@ -1,19 +1,18 @@ import type { LoaderFunctionArgs } from "react-router"; +// Bundled at build time via Vite's ?raw suffix. The source file lives at +// app/tracker/tracker.js and is populated by the `copytracker` npm script. +import trackerSource from "../tracker/tracker.js?raw"; + export async function loader({ params, context, request }: LoaderFunctionArgs) { const requestedScript = params.script; - if (!requestedScript || !requestedScript.endsWith(".js")) { return new Response("Not Found", { status: 404 }); } const customScriptName = context.cloudflare.env.CF_TRACKER_SCRIPT_NAME; const defaultScriptName = "tracker"; - - // Extract the base name without extension for comparison const requestedBaseName = requestedScript.replace(".js", ""); - - // Check if requested script matches either default or custom name const isDefaultScript = requestedBaseName === defaultScriptName; const isCustomScript = customScriptName && requestedBaseName === customScriptName; @@ -22,31 +21,21 @@ export async function loader({ params, context, request }: LoaderFunctionArgs) { return new Response("Script not found", { status: 404 }); } - try { - const url = new URL(request.url); - const trackerUrl = `${url.protocol}//${url.host}/tracker.js`; - const assetResponse = - await context.cloudflare.env.ASSETS.fetch(trackerUrl); - - const allowedOrigin = resolveAllowedOrigin( - context.cloudflare.env.TRACKER_ALLOWED_ORIGINS, - request.headers?.get("Origin") ?? null, - ); - - const response = new Response(assetResponse.body, assetResponse); - response.headers.set("Access-Control-Allow-Origin", allowedOrigin); - if (allowedOrigin !== "*") { - const vary = response.headers.get("Vary"); - response.headers.set( - "Vary", - vary ? `${vary}, Origin` : "Origin", - ); - } - return response; - } catch (error) { - console.error("Error serving tracker script:", error); - return new Response("Error serving script", { status: 500 }); + const allowedOrigin = resolveAllowedOrigin( + context.cloudflare.env.TRACKER_ALLOWED_ORIGINS, + request.headers?.get("Origin") ?? null, + ); + + const headers: Record = { + "Content-Type": "application/javascript; charset=utf-8", + "Cache-Control": "public, max-age=3600", + "Access-Control-Allow-Origin": allowedOrigin, + }; + if (allowedOrigin !== "*") { + headers.Vary = "Origin"; } + + return new Response(trackerSource, { status: 200, headers }); } function resolveAllowedOrigin( @@ -81,8 +70,7 @@ function originMatchesList(origin: string, list: string[]): boolean { const entryHost = extractHost(entry).toLowerCase(); if (!entryHost) return false; return ( - originHost === entryHost || - originHost.endsWith(`.${entryHost}`) + originHost === entryHost || originHost.endsWith(`.${entryHost}`) ); }); } From f76f9c1ec5e9361682309d7a60a79fc7892a3298 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 16:59:38 -0600 Subject: [PATCH 06/10] test(server): cover inlined tracker source in loader tests --- .../app/routes/__tests__/$script.test.tsx | 129 +++--------------- 1 file changed, 19 insertions(+), 110 deletions(-) diff --git a/packages/server/app/routes/__tests__/$script.test.tsx b/packages/server/app/routes/__tests__/$script.test.tsx index 81186231..f7dcb3c9 100644 --- a/packages/server/app/routes/__tests__/$script.test.tsx +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -1,4 +1,9 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; + +vi.mock("../../tracker/tracker.js?raw", () => ({ + default: "console.log('tracker script');", +})); + import { loader } from "../$script"; describe("Dynamic script route", () => { @@ -10,8 +15,6 @@ describe("Dynamic script route", () => { const mockRequest = buildMockRequest(); - const mockAssetsFetch = vi.fn(); - const createMockContext = ( customScriptName?: string, allowedOrigins?: string, @@ -20,16 +23,12 @@ describe("Dynamic script route", () => { env: { CF_TRACKER_SCRIPT_NAME: customScriptName, TRACKER_ALLOWED_ORIGINS: allowedOrigins, - ASSETS: { - fetch: mockAssetsFetch, - }, }, }, }); beforeEach(() => { vi.clearAllMocks(); - mockAssetsFetch.mockClear(); }); describe("loader", () => { @@ -55,51 +54,6 @@ describe("Dynamic script route", () => { expect(await response.text()).toBe("Not Found"); }); - it("should serve default tracker.js", async () => { - const mockResponse = new Response( - "console.log('tracker script');", - { - status: 200, - headers: { "Content-Type": "application/javascript" }, - }, - ); - mockAssetsFetch.mockResolvedValue(mockResponse); - - const response = await loader({ - params: { script: "tracker.js" }, - context: createMockContext(), - request: mockRequest, - } as any); - - expect(response.status).toBe(200); - expect(await response.text()).toBe( - "console.log('tracker script');", - ); - expect(mockAssetsFetch).toHaveBeenCalledWith( - "https://example.com/tracker.js", - ); - }); - - it("should serve custom script name when env variable is set", async () => { - const mockResponse = new Response("console.log('custom script');", { - status: 200, - headers: { "Content-Type": "application/javascript" }, - }); - mockAssetsFetch.mockResolvedValue(mockResponse); - - const response = await loader({ - params: { script: "analytics.js" }, - context: createMockContext("analytics"), - request: mockRequest, - } as any); - - expect(response.status).toBe(200); - expect(await response.text()).toBe("console.log('custom script');"); - expect(mockAssetsFetch).toHaveBeenCalledWith( - "https://example.com/tracker.js", - ); - }); - it("should return 404 for unmatched script names", async () => { const response = await loader({ params: { script: "unknown.js" }, @@ -111,74 +65,41 @@ describe("Dynamic script route", () => { expect(await response.text()).toBe("Script not found"); }); - it("should handle fetch errors gracefully", async () => { - mockAssetsFetch.mockRejectedValue(new Error("Fetch failed")); - + it("should serve the bundled tracker source for tracker.js", async () => { const response = await loader({ params: { script: "tracker.js" }, context: createMockContext(), request: mockRequest, } as any); - expect(response.status).toBe(500); - expect(await response.text()).toBe("Error serving script"); - }); - - it("should handle network errors", async () => { - mockAssetsFetch.mockRejectedValue(new Error("Network error")); - - const response = await loader({ - params: { script: "tracker.js" }, - context: createMockContext(), - request: mockRequest, - } as any); - - expect(response.status).toBe(500); - expect(await response.text()).toBe("Error serving script"); - }); - - it("should return response from ASSETS fetch", async () => { - const mockResponse = new Response( + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe( + "application/javascript; charset=utf-8", + ); + expect(response.headers.get("Cache-Control")).toBe( + "public, max-age=3600", + ); + expect(await response.text()).toBe( "console.log('tracker script');", - { - status: 200, - headers: { - "Content-Type": "application/javascript", - "Cache-Control": "public, max-age=3600", - }, - }, ); - mockAssetsFetch.mockResolvedValue(mockResponse); + }); + it("should serve the bundled source for a renamed tracker", async () => { const response = await loader({ - params: { script: "tracker.js" }, - context: createMockContext(), + params: { script: "analytics.js" }, + context: createMockContext("analytics"), request: mockRequest, } as any); expect(response.status).toBe(200); - expect(response.headers.get("Cache-Control")).toBe( - "public, max-age=3600", - ); expect(await response.text()).toBe( "console.log('tracker script');", ); - expect(mockAssetsFetch).toHaveBeenCalledWith( - "https://example.com/tracker.js", - ); }); }); describe("Access-Control-Allow-Origin header", () => { - const buildAssetResponse = () => - new Response("console.log('tracker');", { - status: 200, - headers: { "Content-Type": "application/javascript" }, - }); - it("defaults to '*' when TRACKER_ALLOWED_ORIGINS is missing", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext(), @@ -192,8 +113,6 @@ describe("Dynamic script route", () => { }); it("defaults to '*' when TRACKER_ALLOWED_ORIGINS is empty", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext(undefined, " "), @@ -206,8 +125,6 @@ describe("Dynamic script route", () => { }); it("echoes a matching Origin and sets Vary: Origin", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext( @@ -224,8 +141,6 @@ describe("Dynamic script route", () => { }); it("matches subdomains of listed bare hosts", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext(undefined, "shiftinbits.com"), @@ -238,8 +153,6 @@ describe("Dynamic script route", () => { }); it("matches subdomains of listed origins", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext( @@ -255,8 +168,6 @@ describe("Dynamic script route", () => { }); it("does not treat sibling domains as subdomains", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext( @@ -272,8 +183,6 @@ describe("Dynamic script route", () => { }); it("falls back to first allowed origin when Origin is missing", async () => { - mockAssetsFetch.mockResolvedValue(buildAssetResponse()); - const response = await loader({ params: { script: "tracker.js" }, context: createMockContext( From e4049638c4824b882104dedc0d314a54f91a87bc Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 17:01:30 -0600 Subject: [PATCH 07/10] chore(server): drop run_worker_first; tracker is inlined now --- packages/server/wrangler.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/server/wrangler.json b/packages/server/wrangler.json index 12b9cf04..b49e44ca 100644 --- a/packages/server/wrangler.json +++ b/packages/server/wrangler.json @@ -5,8 +5,7 @@ "compatibility_date": "2024-12-13", "assets": { "binding": "ASSETS", - "directory": "./build/client", - "run_worker_first": ["/tracker.js"] + "directory": "./build/client" }, "vars": { "CF_DATASET_NAME": "metricsDataset", From b494c6dd88bfc5edf3e3b1a0aed3de8b71e8acb4 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 17:13:34 -0600 Subject: [PATCH 08/10] fix(server): normalize TRACKER_ALLOWED_ORIGINS entries; enforce scheme match --- packages/server/app/routes/$script.ts | 37 ++++++++++++------- .../app/routes/__tests__/$script.test.tsx | 28 ++++++++++++++ turbo.json | 2 +- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/server/app/routes/$script.ts b/packages/server/app/routes/$script.ts index 7edda20d..4fa0cf2b 100644 --- a/packages/server/app/routes/$script.ts +++ b/packages/server/app/routes/$script.ts @@ -45,7 +45,8 @@ function resolveAllowedOrigin( const list = (allowedOriginsVar ?? "") .split(",") .map((o) => o.trim()) - .filter((o) => o.length > 0); + .filter((o) => o.length > 0) + .map(normalizeOriginEntry); if (list.length === 0) { return "*"; @@ -58,27 +59,37 @@ function resolveAllowedOrigin( return list[0]; } +function normalizeOriginEntry(entry: string): string { + const lower = entry.toLowerCase(); + if (lower.startsWith("http://") || lower.startsWith("https://")) { + return entry; + } + return `https://${entry.replace(/^\*\./, "")}`; +} + function originMatchesList(origin: string, list: string[]): boolean { - let originHost: string; + let originUrl: URL; try { - originHost = new URL(origin).hostname.toLowerCase(); + originUrl = new URL(origin); } catch { return false; } + const originHost = originUrl.hostname.toLowerCase(); + const originScheme = originUrl.protocol; return list.some((entry) => { - const entryHost = extractHost(entry).toLowerCase(); - if (!entryHost) return false; + let entryUrl: URL; + try { + entryUrl = new URL(entry); + } catch { + return false; + } + if (entryUrl.protocol !== originScheme) { + return false; + } + const entryHost = entryUrl.hostname.toLowerCase(); return ( originHost === entryHost || originHost.endsWith(`.${entryHost}`) ); }); } - -function extractHost(value: string): string { - try { - return new URL(value).hostname; - } catch { - return value.replace(/^\*\./, ""); - } -} diff --git a/packages/server/app/routes/__tests__/$script.test.tsx b/packages/server/app/routes/__tests__/$script.test.tsx index f7dcb3c9..db804a94 100644 --- a/packages/server/app/routes/__tests__/$script.test.tsx +++ b/packages/server/app/routes/__tests__/$script.test.tsx @@ -196,5 +196,33 @@ describe("Dynamic script route", () => { "https://foo.com", ); }); + + it("normalizes bare-host fallback to https:// in the ACAO header", async () => { + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext(undefined, "shiftinbits.com"), + request: buildMockRequest("https://evil.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://shiftinbits.com", + ); + }); + + it("does not let http:// origins match https:// list entries", async () => { + const response = await loader({ + params: { script: "tracker.js" }, + context: createMockContext( + undefined, + "https://foo.com, https://bar.com", + ), + request: buildMockRequest("http://foo.com"), + } as any); + + expect(response.headers.get("Access-Control-Allow-Origin")).toBe( + "https://foo.com", + ); + expect(response.headers.get("Vary")).toBe("Origin"); + }); }); }); diff --git a/turbo.json b/turbo.json index 75568623..89d94ab9 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,7 @@ }, "@counterscale/server#copytracker": { "dependsOn": ["^@counterscale/tracker#build"], - "outputs": ["public/tracker.js"] + "outputs": ["app/tracker/tracker.js"] }, "dev": { "dependsOn": ["^@counterscale/server#copytracker"], From 95d6da18486ad09b4d89b67c290b4f10450c92b0 Mon Sep 17 00:00:00 2001 From: Bobby Bonestell Date: Thu, 28 May 2026 19:49:30 -0600 Subject: [PATCH 09/10] feat(server): enforce TRACKER_ALLOWED_ORIGINS at /collect; serve tracker.js as ACAO * CORS cannot gate who loads a