-
+
DOWNLOAD FOR MAC
diff --git a/docs/src/app/download/beta/route.test.ts b/docs/src/app/download/beta/route.test.ts
index fc1b2bda..8f357f7a 100644
--- a/docs/src/app/download/beta/route.test.ts
+++ b/docs/src/app/download/beta/route.test.ts
@@ -23,30 +23,56 @@ mock.module('../../../lib/download-links.ts', () => ({
}),
}));
+type CaptureOpts = {
+ event: string;
+ distinctId: string;
+ properties?: Record
;
+};
+let _lastCapture: CaptureOpts | null = null;
+mock.module('../../../lib/track.ts', () => ({
+ captureServerEvent: (opts: CaptureOpts) => {
+ _lastCapture = opts;
+ },
+ resolveDistinctId: () => 'visitor-9',
+ referrerHostname: () => undefined,
+}));
+
const { GET } = await import('./route.ts');
+function call(): Promise {
+ return GET(new Request('https://openknowledge.ai/download/beta'));
+}
+
describe('GET /download/beta', () => {
- test('302 to the fresh beta URL with CDN-cacheable headers', async () => {
+ test('302 to the fresh beta URL and fires dmg_downloaded (beta)', async () => {
_redirect = { kind: 'fresh', url: TEST_DMG_URL };
- const res = await GET();
+ _lastCapture = null;
+ const res = await call();
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe(TEST_DMG_URL);
expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL);
+ expect(_lastCapture?.event).toBe('dmg_downloaded');
+ expect(_lastCapture?.distinctId).toBe('visitor-9');
+ expect(_lastCapture?.properties?.channel).toBe('beta');
});
- test('302 to the stale LKG URL with CDN-cacheable headers', async () => {
+ test('302 to the stale LKG URL and still counts', async () => {
_redirect = { kind: 'stale-lkg', url: TEST_DMG_URL, refreshError: 'network down' };
- const res = await GET();
+ _lastCapture = null;
+ const res = await call();
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe(TEST_DMG_URL);
expect(res.headers.get('cache-control')).toBe(SUCCESS_CACHE_CONTROL);
+ expect(_lastCapture?.properties?.channel).toBe('beta');
});
- test('302 to the releases page uncached on fallback', async () => {
+ test('302 to the releases page on fallback is NOT counted as a download', async () => {
_redirect = { kind: 'fallback', url: RELEASES_PAGE_URL, cause: 'API error' };
- const res = await GET();
+ _lastCapture = null;
+ const res = await call();
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe(RELEASES_PAGE_URL);
expect(res.headers.get('cache-control')).toBe(FALLBACK_CACHE_CONTROL);
+ expect(_lastCapture).toBeNull();
});
});
diff --git a/docs/src/app/download/beta/route.ts b/docs/src/app/download/beta/route.ts
index 7d5b6781..79fd8a53 100644
--- a/docs/src/app/download/beta/route.ts
+++ b/docs/src/app/download/beta/route.ts
@@ -1,10 +1,11 @@
import { createBetaResolver, toRedirectResponse } from '@/lib/download-links';
+import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track';
export const dynamic = 'force-dynamic';
const resolveBetaRedirect = createBetaResolver();
-export async function GET(): Promise {
+export async function GET(request: Request): Promise {
const redirect = await resolveBetaRedirect();
if (redirect.kind === 'stale-lkg') {
console.warn(
@@ -14,5 +15,12 @@ export async function GET(): Promise {
if (redirect.kind === 'fallback') {
console.error(`[download/beta] falling back to releases page: ${redirect.cause}`);
}
+ if (redirect.kind !== 'fallback') {
+ captureServerEvent({
+ event: 'dmg_downloaded',
+ distinctId: resolveDistinctId(request),
+ properties: { channel: 'beta', referrer: referrerHostname(request) },
+ });
+ }
return toRedirectResponse(redirect);
}
diff --git a/docs/src/app/download/stable/route.test.ts b/docs/src/app/download/stable/route.test.ts
index d286c801..d4f49a6a 100644
--- a/docs/src/app/download/stable/route.test.ts
+++ b/docs/src/app/download/stable/route.test.ts
@@ -1,12 +1,33 @@
-import { describe, expect, test } from 'bun:test';
-import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '../../../lib/download-links.ts';
-import { GET } from './route.ts';
+import { describe, expect, mock, test } from 'bun:test';
+import { STABLE_DMG_URL } from '../../../lib/download-links.ts';
+
+type CaptureOpts = {
+ event: string;
+ distinctId: string;
+ properties?: Record;
+};
+
+let _lastCapture: CaptureOpts | null = null;
+mock.module('../../../lib/track.ts', () => ({
+ captureServerEvent: (opts: CaptureOpts) => {
+ _lastCapture = opts;
+ },
+ resolveDistinctId: () => 'visitor-1',
+ referrerHostname: () => 'news.ycombinator.com',
+}));
+
+const { GET } = await import('./route.ts');
describe('GET /download/stable', () => {
- test('302 to the stable DMG URL with CDN-cacheable headers', () => {
- const res = GET();
+ test('302 to the stable DMG URL, uncached, and fires dmg_downloaded', () => {
+ _lastCapture = null;
+ const res = GET(new Request('https://openknowledge.ai/download/stable'));
expect(res.status).toBe(302);
expect(res.headers.get('location')).toBe(STABLE_DMG_URL);
- expect(res.headers.get('cache-control')).toBe(STABLE_CACHE_CONTROL);
+ expect(res.headers.get('cache-control')).toBe('no-store');
+ expect(_lastCapture?.event).toBe('dmg_downloaded');
+ expect(_lastCapture?.distinctId).toBe('visitor-1');
+ expect(_lastCapture?.properties?.channel).toBe('stable');
+ expect(_lastCapture?.properties?.referrer).toBe('news.ycombinator.com');
});
});
diff --git a/docs/src/app/download/stable/route.ts b/docs/src/app/download/stable/route.ts
index 1a31829f..5fd6ed0a 100644
--- a/docs/src/app/download/stable/route.ts
+++ b/docs/src/app/download/stable/route.ts
@@ -1,13 +1,19 @@
-import { STABLE_CACHE_CONTROL, STABLE_DMG_URL } from '@/lib/download-links';
+import { STABLE_DMG_URL } from '@/lib/download-links';
+import { captureServerEvent, referrerHostname, resolveDistinctId } from '@/lib/track';
export const dynamic = 'force-dynamic';
-export function GET(): Response {
+export function GET(request: Request): Response {
+ captureServerEvent({
+ event: 'dmg_downloaded',
+ distinctId: resolveDistinctId(request),
+ properties: { channel: 'stable', referrer: referrerHostname(request) },
+ });
return new Response(null, {
status: 302,
headers: {
location: STABLE_DMG_URL,
- 'cache-control': STABLE_CACHE_CONTROL,
+ 'cache-control': 'no-store',
},
});
}
diff --git a/docs/src/app/updates/[channel]/[...path]/route.test.ts b/docs/src/app/updates/[channel]/[...path]/route.test.ts
new file mode 100644
index 00000000..4d76af53
--- /dev/null
+++ b/docs/src/app/updates/[channel]/[...path]/route.test.ts
@@ -0,0 +1,139 @@
+import { describe, expect, mock, test } from 'bun:test';
+
+type CaptureOpts = {
+ event: string;
+ distinctId: string;
+ properties?: Record;
+};
+let _lastCapture: CaptureOpts | null = null;
+
+mock.module('../../../../lib/track.ts', () => ({
+ captureServerEvent: (opts: CaptureOpts) => {
+ _lastCapture = opts;
+ },
+ resolveDistinctId: () => 'updater-1',
+}));
+
+const BETA_DMG_URL =
+ 'https://github.com/inkeep/open-knowledge/releases/download/v0.20.0-beta.4/OpenKnowledge-arm64.dmg';
+type BetaRedirect = { kind: string; url: string; cause?: string };
+let _betaRedirect: BetaRedirect = { kind: 'fresh', url: BETA_DMG_URL };
+mock.module('../../../../lib/download-links.ts', () => ({
+ createBetaResolver: () => () => Promise.resolve(_betaRedirect),
+}));
+
+const { GET } = await import('./route.ts');
+const REL = 'https://github.com/inkeep/open-knowledge/releases';
+
+function call(
+ channel: string,
+ path: string[],
+ headers: Record = {},
+): Promise {
+ return GET(
+ new Request(`https://openknowledge.ai/updates/${channel}/${path.join('/')}`, { headers }),
+ {
+ params: Promise.resolve({ channel, path }),
+ },
+ );
+}
+
+describe('GET /updates/[channel]/[...path]', () => {
+ test('stable manifest 302s to the latest alias and is NOT counted', async () => {
+ _lastCapture = null;
+ const res = await call('stable', ['latest-mac.yml']);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/latest/download/latest-mac.yml`);
+ expect(_lastCapture).toBeNull();
+ });
+
+ test('beta manifest 302s to the resolved beta tag and is NOT counted', async () => {
+ _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL };
+ _lastCapture = null;
+ const res = await call('beta', ['beta-mac.yml']);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/beta-mac.yml`);
+ expect(_lastCapture).toBeNull();
+ });
+
+ test('stable zip 302s to the tagged release and counts app_update_downloaded', async () => {
+ _lastCapture = null;
+ const file = 'OpenKnowledge-0.20.0-arm64-mac.zip';
+ const res = await call('stable', [file], { 'x-ok-from-version': '0.19.1' });
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`);
+ expect(res.headers.get('cache-control')).toBe('no-store');
+ expect(_lastCapture?.event).toBe('app_update_downloaded');
+ expect(_lastCapture?.properties?.channel).toBe('stable');
+ expect(_lastCapture?.properties?.artifact_type).toBe('zip');
+ expect(_lastCapture?.properties?.to_version).toBe('0.20.0');
+ expect(_lastCapture?.properties?.from_version).toBe('0.19.1');
+ });
+
+ test('beta zip parses the prerelease version and counts (no from_version header)', async () => {
+ _lastCapture = null;
+ const file = 'OpenKnowledge-0.20.0-beta.4-arm64-mac.zip';
+ const res = await call('beta', [file]);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0-beta.4/${file}`);
+ expect(_lastCapture?.properties?.to_version).toBe('0.20.0-beta.4');
+ expect(_lastCapture?.properties?.from_version).toBeUndefined();
+ });
+
+ test('blockmap 302s but is NOT counted', async () => {
+ _lastCapture = null;
+ const file = 'OpenKnowledge-0.20.0-arm64-mac.zip.blockmap';
+ const res = await call('stable', [file]);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`);
+ expect(_lastCapture).toBeNull();
+ });
+
+ test('human dmg 302s (latest alias) but is NOT counted', async () => {
+ _lastCapture = null;
+ const res = await call('stable', ['OpenKnowledge-arm64.dmg']);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/latest/download/OpenKnowledge-arm64.dmg`);
+ expect(_lastCapture).toBeNull();
+ });
+
+ test('dmg blockmap 302s (latest alias) but is NOT counted', async () => {
+ _lastCapture = null;
+ const file = 'OpenKnowledge-arm64.dmg.blockmap';
+ const res = await call('stable', [file]);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/latest/download/${file}`);
+ expect(_lastCapture).toBeNull();
+ });
+
+ test('invalid channel → 404', async () => {
+ expect((await call('canary', ['latest-mac.yml'])).status).toBe(404);
+ });
+
+ test('path traversal / multi-segment → 404', async () => {
+ expect((await call('stable', ['..', 'secret'])).status).toBe(404);
+ });
+
+ test('unknown artifact type → 404', async () => {
+ expect((await call('stable', ['random.txt'])).status).toBe(404);
+ });
+
+ test('x64 zip parses the version and counts', async () => {
+ _lastCapture = null;
+ const file = 'OpenKnowledge-0.20.0-x64-mac.zip';
+ const res = await call('stable', [file]);
+ expect(res.status).toBe(302);
+ expect(res.headers.get('location')).toBe(`${REL}/download/v0.20.0/${file}`);
+ expect(_lastCapture?.properties?.to_version).toBe('0.20.0');
+ });
+
+ test('beta manifest 503s (no-store) on resolver fallback, not counted', async () => {
+ _betaRedirect = { kind: 'fallback', url: REL, cause: 'API error' };
+ _lastCapture = null;
+ const res = await call('beta', ['beta-mac.yml']);
+ expect(res.status).toBe(503);
+ expect(res.headers.get('cache-control')).toBe('no-store');
+ expect(_lastCapture).toBeNull();
+ _betaRedirect = { kind: 'fresh', url: BETA_DMG_URL };
+ });
+});
diff --git a/docs/src/app/updates/[channel]/[...path]/route.ts b/docs/src/app/updates/[channel]/[...path]/route.ts
new file mode 100644
index 00000000..d9758046
--- /dev/null
+++ b/docs/src/app/updates/[channel]/[...path]/route.ts
@@ -0,0 +1,88 @@
+import { createBetaResolver } from '@/lib/download-links';
+import { captureServerEvent, resolveDistinctId } from '@/lib/track';
+
+export const dynamic = 'force-dynamic';
+
+const RELEASES_BASE = 'https://github.com/inkeep/open-knowledge/releases';
+const VALID_CHANNELS = new Set(['stable', 'beta']);
+const SAFE_FILENAME = /^[A-Za-z0-9._-]+$/;
+const ARTIFACT_VERSION =
+ /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)-(?:arm64|x64|universal)-mac\.zip(?:\.blockmap)?$/;
+const BETA_TAG_FROM_URL = /\/releases\/download\/([^/]+)\//;
+const FROM_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/;
+
+const resolveBeta = createBetaResolver();
+
+type ArtifactType = 'manifest' | 'zip' | 'blockmap' | 'dmg' | 'other';
+
+function classify(filename: string): ArtifactType {
+ if (filename.endsWith('-mac.yml')) return 'manifest';
+ if (filename.endsWith('.blockmap')) return 'blockmap';
+ if (filename.endsWith('.zip')) return 'zip';
+ if (filename.endsWith('.dmg')) return 'dmg';
+ return 'other';
+}
+
+function redirect302(location: string): Response {
+ return new Response(null, { status: 302, headers: { location, 'cache-control': 'no-store' } });
+}
+
+function errorResponse(status: number): Response {
+ return new Response(null, { status, headers: { 'cache-control': 'no-store' } });
+}
+
+async function latestBetaTag(): Promise {
+ const redirect = await resolveBeta();
+ if (redirect.kind === 'stale-lkg') {
+ console.warn(
+ `[updates/beta] serving stale LKG tag after refresh failure: ${redirect.refreshError}`,
+ );
+ }
+ if (redirect.kind === 'fallback') return null;
+ return BETA_TAG_FROM_URL.exec(redirect.url)?.[1] ?? null;
+}
+
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ channel: string; path: string[] }> },
+): Promise {
+ const { channel, path } = await params;
+ if (!VALID_CHANNELS.has(channel)) return errorResponse(404);
+
+ const filename = path.join('/');
+ if (!SAFE_FILENAME.test(filename)) return errorResponse(404);
+
+ const type = classify(filename);
+ if (type === 'other') return errorResponse(404);
+
+ const version = ARTIFACT_VERSION.exec(filename)?.[1];
+
+ let target: string;
+ if (version) {
+ target = `${RELEASES_BASE}/download/v${version}/${filename}`;
+ } else if (channel === 'stable') {
+ target = `${RELEASES_BASE}/latest/download/${filename}`;
+ } else {
+ const tag = await latestBetaTag();
+ if (!tag) {
+ return errorResponse(503);
+ }
+ target = `${RELEASES_BASE}/download/${tag}/${filename}`;
+ }
+
+ if (type === 'zip' && version) {
+ const rawFrom = request.headers.get('x-ok-from-version');
+ captureServerEvent({
+ event: 'app_update_downloaded',
+ distinctId: resolveDistinctId(request),
+ properties: {
+ channel,
+ artifact_type: 'zip',
+ to_version: version,
+ from_version: rawFrom && FROM_VERSION.test(rawFrom) ? rawFrom : undefined,
+ },
+ });
+ }
+
+ return redirect302(target);
+}
diff --git a/docs/src/components/download-button.tsx b/docs/src/components/download-button.tsx
index ee5c0272..d45b1d5f 100644
--- a/docs/src/components/download-button.tsx
+++ b/docs/src/components/download-button.tsx
@@ -1,6 +1,5 @@
import { DownloadIcon } from 'lucide-react';
-import Link from 'next/link';
-import { STABLE_DMG_URL } from '@/lib/download-links';
+import { DOWNLOAD_ROUTE } from '@/lib/site';
type DownloadButtonProps = {
href?: string;
@@ -8,11 +7,11 @@ type DownloadButtonProps = {
};
export function DownloadButton({
- href = STABLE_DMG_URL,
+ href = DOWNLOAD_ROUTE,
label = 'DOWNLOAD FOR MAC',
}: DownloadButtonProps) {
return (
-
{label}
-
+
);
}
diff --git a/docs/src/lib/download-links.ts b/docs/src/lib/download-links.ts
index d20cf97d..b597c20b 100644
--- a/docs/src/lib/download-links.ts
+++ b/docs/src/lib/download-links.ts
@@ -113,8 +113,6 @@ export const SUCCESS_CACHE_CONTROL = 'public, max-age=0, s-maxage=300, stale-whi
export const FALLBACK_CACHE_CONTROL = 'no-store';
-export const STABLE_CACHE_CONTROL = 'public, max-age=0, s-maxage=3600';
-
export function toRedirectResponse(redirect: BetaRedirect): Response {
return new Response(null, {
status: 302,
diff --git a/docs/src/lib/site.ts b/docs/src/lib/site.ts
index 6fb31f1f..74caf713 100644
--- a/docs/src/lib/site.ts
+++ b/docs/src/lib/site.ts
@@ -25,5 +25,7 @@ export function metaDescription(
export const DOWNLOAD_URL = STABLE_DMG_URL;
+export const DOWNLOAD_ROUTE = '/download/stable';
+
export const EXAMPLE_KB_SHARE_URL =
'https://openknowledge.ai/d/AWh0dHBzOi8vZ2l0aHViLmNvbS9pbmtlZXAvdGVjaC1pcG9zL2Jsb2IvbWFpbi9SRUFETUUubWQ';
diff --git a/docs/src/lib/track.test.ts b/docs/src/lib/track.test.ts
new file mode 100644
index 00000000..204e07ae
--- /dev/null
+++ b/docs/src/lib/track.test.ts
@@ -0,0 +1,114 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import {
+ buildCapturePayload,
+ captureServerEvent,
+ referrerHostname,
+ resolveDistinctId,
+} from './track.ts';
+
+const KEY = 'phc_test_key';
+const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+
+const prevKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+afterEach(() => {
+ if (prevKey === undefined) delete process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ else process.env.NEXT_PUBLIC_POSTHOG_KEY = prevKey;
+});
+
+function req(headers: Record = {}): Request {
+ return new Request('https://openknowledge.ai/download/stable', { headers });
+}
+
+describe('buildCapturePayload', () => {
+ test('forces the privacy guards and strips undefined props', () => {
+ const p = buildCapturePayload(
+ {
+ event: 'dmg_downloaded',
+ distinctId: 'd1',
+ properties: { channel: 'stable', from_version: undefined },
+ },
+ KEY,
+ );
+ expect(p.api_key).toBe(KEY);
+ expect(p.event).toBe('dmg_downloaded');
+ expect(p.distinct_id).toBe('d1');
+ expect(typeof p.timestamp).toBe('string');
+ expect(p.properties.channel).toBe('stable');
+ expect('from_version' in p.properties).toBe(false);
+ expect(p.properties.$ip).toBeNull();
+ expect(p.properties.$geoip_disable).toBe(true);
+ expect('$useragent' in p.properties).toBe(false);
+ });
+});
+
+describe('resolveDistinctId', () => {
+ beforeEach(() => {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY = KEY;
+ });
+
+ test('reuses the posthog cookie distinct_id when present', () => {
+ const cookie = `ph_${KEY}_posthog=${encodeURIComponent(JSON.stringify({ distinct_id: 'abc-123' }))}`;
+ expect(resolveDistinctId(req({ cookie }))).toBe('abc-123');
+ });
+
+ test('falls back to a random UUID when no cookie', () => {
+ expect(resolveDistinctId(req())).toMatch(UUID_RE);
+ });
+
+ test('falls back to a random UUID on a malformed cookie (no throw)', () => {
+ const cookie = `ph_${KEY}_posthog=not-json`;
+ expect(resolveDistinctId(req({ cookie }))).toMatch(UUID_RE);
+ });
+
+ test('falls back to a random UUID on an empty distinct_id', () => {
+ const cookie = `ph_${KEY}_posthog=${encodeURIComponent(JSON.stringify({ distinct_id: '' }))}`;
+ expect(resolveDistinctId(req({ cookie }))).toMatch(UUID_RE);
+ });
+
+ test('finds the posthog cookie among multiple cookies', () => {
+ const ph = encodeURIComponent(JSON.stringify({ distinct_id: 'abc-456' }));
+ const cookie = `_ga=GA1.1; ph_${KEY}_posthog=${ph}; session=xyz`;
+ expect(resolveDistinctId(req({ cookie }))).toBe('abc-456');
+ });
+
+ test('ignores cookies and returns a UUID when the key is unset', () => {
+ delete process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ const cookie = `ph_${KEY}_posthog=${encodeURIComponent(JSON.stringify({ distinct_id: 'abc-123' }))}`;
+ expect(resolveDistinctId(req({ cookie }))).toMatch(UUID_RE);
+ });
+});
+
+describe('referrerHostname', () => {
+ test('returns hostname only (never the path)', () => {
+ expect(referrerHostname(req({ referer: 'https://news.ycombinator.com/item?id=1' }))).toBe(
+ 'news.ycombinator.com',
+ );
+ });
+ test('undefined when missing or unparseable', () => {
+ expect(referrerHostname(req())).toBeUndefined();
+ expect(referrerHostname(req({ referer: 'not a url' }))).toBeUndefined();
+ });
+});
+
+describe('captureServerEvent', () => {
+ test('no-ops (no fetch) when the key is unset', () => {
+ delete process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ let called = false;
+ const orig = globalThis.fetch;
+ globalThis.fetch = (async () => {
+ called = true;
+ return new Response(null);
+ }) as typeof fetch;
+ try {
+ captureServerEvent({ event: 'dmg_downloaded', distinctId: 'd1' });
+ } finally {
+ globalThis.fetch = orig;
+ }
+ expect(called).toBe(false);
+ });
+
+ test('never throws even when scheduling fails (key set, no request scope)', () => {
+ process.env.NEXT_PUBLIC_POSTHOG_KEY = KEY;
+ expect(() => captureServerEvent({ event: 'dmg_downloaded', distinctId: 'd1' })).not.toThrow();
+ });
+});
diff --git a/docs/src/lib/track.ts b/docs/src/lib/track.ts
new file mode 100644
index 00000000..f541e2cf
--- /dev/null
+++ b/docs/src/lib/track.ts
@@ -0,0 +1,106 @@
+import { after } from 'next/server';
+
+const POSTHOG_CAPTURE_URL = 'https://us.i.posthog.com/capture/';
+const CAPTURE_TIMEOUT_MS = 3_000;
+
+export interface TrackOptions {
+ event: string;
+ distinctId: string;
+ properties?: Record;
+}
+
+export interface CapturePayload {
+ api_key: string;
+ event: string;
+ distinct_id: string;
+ timestamp: string;
+ properties: Record;
+}
+
+export function buildCapturePayload(opts: TrackOptions, key: string): CapturePayload {
+ const properties: Record = {};
+ if (opts.properties) {
+ for (const [k, v] of Object.entries(opts.properties)) {
+ if (v !== undefined) properties[k] = v;
+ }
+ }
+ properties.$ip = null;
+ properties.$geoip_disable = true;
+ return {
+ api_key: key,
+ event: opts.event,
+ distinct_id: opts.distinctId,
+ timestamp: new Date().toISOString(),
+ properties,
+ };
+}
+
+export function captureServerEvent(opts: TrackOptions): void {
+ try {
+ const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ if (!key) return;
+ const payload = buildCapturePayload(opts, key);
+ after(async () => {
+ try {
+ const res = await fetch(POSTHOG_CAPTURE_URL, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(payload),
+ signal: AbortSignal.timeout(CAPTURE_TIMEOUT_MS),
+ });
+ if (!res.ok) {
+ console.warn(`[track] capture HTTP ${res.status} for ${opts.event}`);
+ }
+ } catch (err) {
+ console.warn(
+ `[track] capture failed for ${opts.event}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+ });
+ } catch (err) {
+ console.warn(
+ `[track] capture skipped for ${opts.event}: ${err instanceof Error ? err.message : String(err)}`,
+ );
+ }
+}
+
+export function resolveDistinctId(request: Request): string {
+ const key = process.env.NEXT_PUBLIC_POSTHOG_KEY;
+ if (key) {
+ const fromCookie = readPosthogDistinctId(request, key);
+ if (fromCookie) return fromCookie;
+ }
+ return crypto.randomUUID();
+}
+
+function readPosthogDistinctId(request: Request, key: string): string | null {
+ const cookieHeader = request.headers.get('cookie');
+ if (!cookieHeader) return null;
+ const cookieName = `ph_${key}_posthog`;
+ for (const part of cookieHeader.split(';')) {
+ const eq = part.indexOf('=');
+ if (eq === -1) continue;
+ if (part.slice(0, eq).trim() !== cookieName) continue;
+ try {
+ const parsed = JSON.parse(decodeURIComponent(part.slice(eq + 1).trim())) as {
+ distinct_id?: unknown;
+ };
+ return typeof parsed.distinct_id === 'string' && parsed.distinct_id.length > 0
+ ? parsed.distinct_id
+ : null;
+ } catch {
+ return null;
+ }
+ }
+ return null;
+}
+
+export function referrerHostname(request: Request): string | undefined {
+ const referer = request.headers.get('referer');
+ if (!referer) return undefined;
+ try {
+ return new URL(referer).hostname;
+ } catch {
+ return undefined;
+ }
+}