cmskite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ 'use strict';
2
+
3
+ // src/transport.ts
4
+ var DEFAULT_BASE_URL = "https://api.cmskite.com";
5
+
6
+ // src/analytics.ts
7
+ var MAX_BATCH = 50;
8
+ var STORAGE_PREFIX = "cmskite:v:";
9
+ var CMSKiteAnalytics = class {
10
+ endpoint;
11
+ apiKey;
12
+ enabled;
13
+ flushIntervalMs;
14
+ fetchImpl;
15
+ queue = [];
16
+ timer = null;
17
+ listening = false;
18
+ constructor(options) {
19
+ this.apiKey = options.apiKey;
20
+ this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "")}/v1/blog/events`;
21
+ this.enabled = options.enabled !== false;
22
+ this.flushIntervalMs = options.flushIntervalMs ?? 1e3;
23
+ this.fetchImpl = options.fetch;
24
+ this.listenForUnload();
25
+ }
26
+ /**
27
+ * One reader seeing one post.
28
+ *
29
+ * Call it when the post is rendered. Calling it again for the same post in
30
+ * the same tab does nothing, which is what makes it safe to put in a React
31
+ * effect that runs on every render.
32
+ */
33
+ trackView(postId, path) {
34
+ if (!this.enabled || !postId) return;
35
+ if (this.alreadySeen(postId)) return;
36
+ this.remember(postId);
37
+ this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() } });
38
+ }
39
+ /**
40
+ * A link press.
41
+ *
42
+ * Not deduplicated: pressing the same link twice is two clicks, and the nonce
43
+ * is what tells the server so.
44
+ */
45
+ trackClick(postId, target, path) {
46
+ if (!this.enabled || !postId) return;
47
+ this.push({
48
+ type: "click",
49
+ postId,
50
+ ...target ? { target: target.slice(0, 300) } : {},
51
+ ...path ? { path } : { path: currentPath() },
52
+ nonce: nonce()
53
+ });
54
+ }
55
+ /** Sends whatever is queued now. Called for you on page hide. */
56
+ flush() {
57
+ if (this.queue.length === 0) return;
58
+ const batch = this.queue.splice(0, MAX_BATCH);
59
+ this.clearTimer();
60
+ this.send(batch);
61
+ }
62
+ /** Stops the timer and the listeners. For a test, or a single-page teardown. */
63
+ destroy() {
64
+ this.flush();
65
+ this.clearTimer();
66
+ }
67
+ // --- internals ------------------------------------------------------------
68
+ push(event) {
69
+ this.queue.push(event);
70
+ if (this.queue.length >= MAX_BATCH) return this.flush();
71
+ if (!this.timer) {
72
+ this.timer = setTimeout(() => this.flush(), this.flushIntervalMs);
73
+ this.timer.unref?.();
74
+ }
75
+ }
76
+ send(events) {
77
+ const body = JSON.stringify({ events });
78
+ if (!this.fetchImpl) {
79
+ try {
80
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
81
+ const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
82
+ const blob = new Blob([body], { type: "application/json" });
83
+ if (navigator.sendBeacon(url, blob)) return;
84
+ }
85
+ } catch {
86
+ }
87
+ }
88
+ try {
89
+ void (this.fetchImpl ?? fetch)(this.endpoint, {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
92
+ body,
93
+ // Survives the navigation that triggered it, like sendBeacon does.
94
+ keepalive: true
95
+ }).catch(() => {
96
+ });
97
+ } catch {
98
+ }
99
+ }
100
+ /**
101
+ * What this tab has already reported.
102
+ *
103
+ * `sessionStorage`, not `localStorage`: a view should be counted again
104
+ * tomorrow, and a session is the unit the server deduplicates on too. A
105
+ * browser that refuses storage -- private mode, blocked site data -- falls
106
+ * back to counting the view, which is the right way to be wrong.
107
+ */
108
+ alreadySeen(postId) {
109
+ try {
110
+ return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null;
111
+ } catch {
112
+ return false;
113
+ }
114
+ }
115
+ remember(postId) {
116
+ try {
117
+ sessionStorage.setItem(STORAGE_PREFIX + postId, "1");
118
+ } catch {
119
+ }
120
+ }
121
+ listenForUnload() {
122
+ if (this.listening || typeof document === "undefined") return;
123
+ this.listening = true;
124
+ document.addEventListener("visibilitychange", () => {
125
+ if (document.visibilityState === "hidden") this.flush();
126
+ });
127
+ }
128
+ clearTimer() {
129
+ if (this.timer) clearTimeout(this.timer);
130
+ this.timer = null;
131
+ }
132
+ };
133
+ function currentPath() {
134
+ try {
135
+ return window.location.pathname;
136
+ } catch {
137
+ return "/";
138
+ }
139
+ }
140
+ function nonce() {
141
+ return Math.random().toString(36).slice(2, 10);
142
+ }
143
+
144
+ // src/browser.ts
145
+ function createTracker(options) {
146
+ return new CMSKiteAnalytics(options);
147
+ }
148
+
149
+ exports.CMSKiteAnalytics = CMSKiteAnalytics;
150
+ exports.createTracker = createTracker;
151
+ //# sourceMappingURL=browser.cjs.map
152
+ //# sourceMappingURL=browser.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/transport.ts","../src/analytics.ts","../src/browser.ts"],"names":[],"mappings":"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","file":"browser.cjs","sourcesContent":["import { CMSKiteError, ErrorCode } from './errors.js'\nimport type { Page, RequestOptions } from './types.js'\n\nexport const DEFAULT_BASE_URL = 'https://api.cmskite.com'\n/**\n * What a caller may pass as a query.\n *\n * An interface with named optional fields -- which every query type here is --\n * does not satisfy `Record<string, unknown>`, because TypeScript will not give\n * one an implicit index signature. Widening to `object` and reading entries off\n * it is the honest way to accept both, and the values are stringified anyway.\n */\ntype QueryInput = Record<string, unknown> | object\n\n/** Sent so we can tell an SDK request from a hand-rolled one in support. */\nconst VERSION = '0.1.0'\n\nexport interface TransportConfig {\n apiKey: string\n baseUrl: string\n timeoutMs: number\n /** Extra headers on every request. For a proxy or a trace id. */\n headers: Record<string, string>\n fetch: typeof globalThis.fetch\n}\n\ninterface Envelope<T> {\n success?: boolean\n data?: T\n pagination?: Page<T>['pagination']\n error?: { code?: string; message?: string; details?: Record<string, unknown> }\n requestId?: string\n}\n\n/**\n * One request, and everything that can go wrong with it turned into one type.\n *\n * Retries exactly once, and only what a retry can fix: a dropped connection, a\n * timeout, a 429, a 5xx. A 404 is a settled answer and asking again only delays\n * showing somebody the truth. A 401 will not become a 200 by being repeated.\n */\nexport async function request<T>(\n config: TransportConfig,\n method: string,\n path: string,\n init: { query?: QueryInput; body?: unknown; options?: RequestOptions } = {},\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n const url = buildUrl(config.baseUrl, path, init.query)\n const timeoutMs = init.options?.timeoutMs ?? config.timeoutMs\n\n let lastError: CMSKiteError | null = null\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n return await once<T>(config, method, url, timeoutMs, init)\n } catch (error) {\n const failure = error instanceof CMSKiteError ? error : toError(error)\n // A caller who aborted wants the abort, not a retry and not our wrapper.\n if (init.options?.signal?.aborted) throw failure\n if (!failure.isRetryable || attempt === 1) throw failure\n lastError = failure\n // One short pause. Anything longer is a decision the caller should make.\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n }\n\n throw lastError ?? new CMSKiteError(0, ErrorCode.NETWORK, 'Request failed.')\n}\n\nasync function once<T>(\n config: TransportConfig,\n method: string,\n url: string,\n timeoutMs: number,\n init: { body?: unknown; options?: RequestOptions },\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n /**\n * The caller's signal and our timeout, combined.\n *\n * `AbortSignal.any` is the correct tool and exists everywhere this package\n * supports; the timeout controller is still created so that a timeout can be\n * told apart from a caller's abort in the catch below.\n */\n const timeout = new AbortController()\n const timer = setTimeout(() => timeout.abort(), timeoutMs)\n const signal = init.options?.signal\n ? AbortSignal.any([init.options.signal, timeout.signal])\n : timeout.signal\n\n let response: Response\n let text: string\n try {\n response = await config.fetch(url, {\n method,\n signal,\n headers: {\n authorization: `Bearer ${config.apiKey}`,\n accept: 'application/json',\n 'x-cmskite-sdk': VERSION,\n ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),\n ...config.headers,\n },\n ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),\n })\n text = await response.text()\n } catch (error) {\n throw toError(error, timeout.signal.aborted, timeoutMs)\n } finally {\n clearTimeout(timer)\n }\n\n let payload: Envelope<T>\n try {\n payload = text ? (JSON.parse(text) as Envelope<T>) : {}\n } catch {\n throw new CMSKiteError(\n response.status,\n ErrorCode.BAD_RESPONSE,\n 'CMSKite returned something this client could not read.',\n {},\n response.headers.get('x-request-id'),\n )\n }\n\n if (!response.ok || payload.success === false) {\n throw new CMSKiteError(\n response.status,\n payload.error?.code ?? codeForStatus(response.status),\n payload.error?.message ?? `CMSKite answered ${response.status}.`,\n payload.error?.details ?? {},\n payload.requestId ?? response.headers.get('x-request-id'),\n )\n }\n\n const result: { data: T; pagination?: Page<T>['pagination'] } = { data: payload.data as T }\n if (payload.pagination) result.pagination = payload.pagination\n return result\n}\n\nfunction toError(error: unknown, timedOut = false, timeoutMs = 0): CMSKiteError {\n if (error instanceof CMSKiteError) return error\n if (timedOut) {\n return new CMSKiteError(0, ErrorCode.TIMEOUT, `CMSKite did not answer within ${timeoutMs}ms.`)\n }\n if (error instanceof Error && error.name === 'AbortError') {\n return new CMSKiteError(0, ErrorCode.NETWORK, 'The request was cancelled.')\n }\n return new CMSKiteError(0, ErrorCode.NETWORK, 'Could not reach CMSKite. Check the connection.')\n}\n\nfunction codeForStatus(status: number): string {\n if (status === 401) return ErrorCode.UNAUTHENTICATED\n if (status === 403) return ErrorCode.FORBIDDEN\n if (status === 404) return ErrorCode.NOT_FOUND\n if (status === 429) return ErrorCode.RATE_LIMITED\n if (status >= 400 && status < 500) return ErrorCode.INVALID_REQUEST\n return 'INTERNAL_ERROR'\n}\n\n/**\n * Query building, in one place.\n *\n * `undefined`, `null` and the empty string are dropped rather than sent, so a\n * caller can pass an optional filter straight through without writing the same\n * three-line guard at every call site.\n */\nexport function buildUrl(baseUrl: string, path: string, query: QueryInput = {}): string {\n const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)\n for (const [key, value] of Object.entries(query as Record<string, unknown>)) {\n if (value === undefined || value === null || value === '') continue\n url.searchParams.set(key, String(value))\n }\n return url.toString()\n}\n","import { DEFAULT_BASE_URL } from './transport.js'\n\n/**\n * Reporting what a reader did, without ever being able to break the page.\n *\n * Three properties, and every decision below follows from them:\n *\n * It cannot throw. Not on a network failure, not on a 500, not if the key is\n * wrong. A customer's article must render whether or not our analytics is\n * having a good day, so every path here ends in a swallowed error.\n *\n * It cannot block. Events are queued and flushed on a timer, and the flush\n * uses `sendBeacon` where it exists -- which hands the batch to the browser\n * and returns immediately, and which still delivers after the page has been\n * closed. Nothing awaits a response, because there is nothing in the\n * response.\n *\n * It cannot double-count. A view is remembered in `sessionStorage`, so a\n * refresh, a re-render and a client-side navigation back to a post already\n * read report nothing at all. The server deduplicates again by a\n * deterministic id, because storage can be cleared and a second tab has its\n * own.\n */\n\nexport interface TrackerOptions {\n apiKey: string\n baseUrl?: string\n /**\n * Off switch. `false` queues nothing and sends nothing.\n *\n * For a development build, or for a site that asks first and only turns this\n * on afterwards.\n */\n enabled?: boolean\n /** How long to hold events before sending. Default 1000ms. */\n flushIntervalMs?: number\n /**\n * Supply your own, for a test.\n *\n * The main client has taken one since it was written, and this did not --\n * which made the tracker the one part of the SDK that could not be exercised\n * outside a browser. It was verified by asserting that it did not throw,\n * which is a test that passes while nothing at all is recorded, and that is\n * exactly what happened the first time it was run end to end.\n */\n fetch?: typeof globalThis.fetch\n}\n\nexport type EventType = 'view' | 'click'\n\ninterface QueuedEvent {\n type: EventType\n postId: string\n path?: string\n target?: string\n nonce?: string\n}\n\nconst MAX_BATCH = 50\nconst STORAGE_PREFIX = 'cmskite:v:'\n\nexport class CMSKiteAnalytics {\n private readonly endpoint: string\n private readonly apiKey: string\n private readonly enabled: boolean\n private readonly flushIntervalMs: number\n private readonly fetchImpl: typeof globalThis.fetch | undefined\n private queue: QueuedEvent[] = []\n private timer: ReturnType<typeof setTimeout> | null = null\n private listening = false\n\n constructor(options: TrackerOptions) {\n this.apiKey = options.apiKey\n this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, '')}/v1/blog/events`\n this.enabled = options.enabled !== false\n this.flushIntervalMs = options.flushIntervalMs ?? 1000\n this.fetchImpl = options.fetch\n this.listenForUnload()\n }\n\n /**\n * One reader seeing one post.\n *\n * Call it when the post is rendered. Calling it again for the same post in\n * the same tab does nothing, which is what makes it safe to put in a React\n * effect that runs on every render.\n */\n trackView(postId: string, path?: string): void {\n if (!this.enabled || !postId) return\n if (this.alreadySeen(postId)) return\n this.remember(postId)\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }) })\n }\n\n /**\n * A link press.\n *\n * Not deduplicated: pressing the same link twice is two clicks, and the nonce\n * is what tells the server so.\n */\n trackClick(postId: string, target?: string, path?: string): void {\n if (!this.enabled || !postId) return\n this.push({\n type: 'click',\n postId,\n ...(target ? { target: target.slice(0, 300) } : {}),\n ...(path ? { path } : { path: currentPath() }),\n nonce: nonce(),\n })\n }\n\n /** Sends whatever is queued now. Called for you on page hide. */\n flush(): void {\n if (this.queue.length === 0) return\n const batch = this.queue.splice(0, MAX_BATCH)\n this.clearTimer()\n this.send(batch)\n }\n\n /** Stops the timer and the listeners. For a test, or a single-page teardown. */\n destroy(): void {\n this.flush()\n this.clearTimer()\n }\n\n // --- internals ------------------------------------------------------------\n\n private push(event: QueuedEvent): void {\n this.queue.push(event)\n if (this.queue.length >= MAX_BATCH) return this.flush()\n if (!this.timer) {\n this.timer = setTimeout(() => this.flush(), this.flushIntervalMs)\n // Never hold a Node process open for a view count.\n ;(this.timer as unknown as { unref?: () => void }).unref?.()\n }\n }\n\n private send(events: QueuedEvent[]): void {\n const body = JSON.stringify({ events })\n\n /**\n * `sendBeacon` first, because it is the one mechanism the browser promises\n * to finish after the page is gone -- which is exactly when the last view\n * of a session is reported.\n *\n * It cannot carry an Authorization header, so the key rides in the URL.\n * That is safe here and nowhere else: the key is read-only, scoped to one\n * project, and already present in the page that fetched the content.\n */\n // An injected fetch means a test, and a test wants to see the request\n // rather than hand it to a beacon it cannot observe.\n if (!this.fetchImpl) {\n try {\n if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {\n const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`\n const blob = new Blob([body], { type: 'application/json' })\n if (navigator.sendBeacon(url, blob)) return\n }\n } catch {\n // Fall through to fetch. Nothing here is worth surfacing.\n }\n }\n\n try {\n void (this.fetchImpl ?? fetch)(this.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },\n body,\n // Survives the navigation that triggered it, like sendBeacon does.\n keepalive: true,\n }).catch(() => {\n // Analytics must never break the page it is measuring.\n })\n } catch {\n // Nor must it break when `fetch` itself is missing.\n }\n }\n\n /**\n * What this tab has already reported.\n *\n * `sessionStorage`, not `localStorage`: a view should be counted again\n * tomorrow, and a session is the unit the server deduplicates on too. A\n * browser that refuses storage -- private mode, blocked site data -- falls\n * back to counting the view, which is the right way to be wrong.\n */\n private alreadySeen(postId: string): boolean {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null\n } catch {\n return false\n }\n }\n\n private remember(postId: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + postId, '1')\n } catch {\n // No storage, so no memory. The server still deduplicates.\n }\n }\n\n private listenForUnload(): void {\n if (this.listening || typeof document === 'undefined') return\n this.listening = true\n /**\n * `visibilitychange`, not `unload`.\n *\n * `unload` does not fire reliably on mobile Safari, which is where a reader\n * most often leaves by switching apps rather than by closing a tab.\n */\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') this.flush()\n })\n }\n\n private clearTimer(): void {\n if (this.timer) clearTimeout(this.timer)\n this.timer = null\n }\n}\n\nfunction currentPath(): string {\n try {\n return window.location.pathname\n } catch {\n return '/'\n }\n}\n\nfunction nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n","/**\n * The CMSKite SDK, for a page.\n *\n * import { createCMSKite } from 'cmskite'\n * import { createTracker } from 'cmskite/browser'\n *\n * const analytics = createTracker({ apiKey: PUBLIC_KEY })\n * analytics.trackView(post.id)\n *\n * Separate from the main entry point because it touches `document` and\n * `navigator`. A server that imported it by accident would fail at runtime; a\n * separate specifier makes that a build-time mistake instead.\n *\n * The key here is the read-only project key, which is the same one the page\n * already used to fetch the content. Nothing secret belongs in a bundle, and\n * nothing in this file asks for anything that is.\n */\nexport { CMSKiteAnalytics, type EventType, type TrackerOptions } from './analytics.js'\n\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * A tracker.\n *\n * One per page is plenty; it holds a queue and a timer and nothing else. Safe\n * to create at module scope.\n */\nexport function createTracker(options: TrackerOptions): CMSKiteAnalytics {\n return new CMSKiteAnalytics(options)\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import { T as TrackerOptions, C as CMSKiteAnalytics } from './analytics-B0zzt0nW.cjs';
2
+ export { E as EventType } from './analytics-B0zzt0nW.cjs';
3
+
4
+ /**
5
+ * The CMSKite SDK, for a page.
6
+ *
7
+ * import { createCMSKite } from 'cmskite'
8
+ * import { createTracker } from 'cmskite/browser'
9
+ *
10
+ * const analytics = createTracker({ apiKey: PUBLIC_KEY })
11
+ * analytics.trackView(post.id)
12
+ *
13
+ * Separate from the main entry point because it touches `document` and
14
+ * `navigator`. A server that imported it by accident would fail at runtime; a
15
+ * separate specifier makes that a build-time mistake instead.
16
+ *
17
+ * The key here is the read-only project key, which is the same one the page
18
+ * already used to fetch the content. Nothing secret belongs in a bundle, and
19
+ * nothing in this file asks for anything that is.
20
+ */
21
+
22
+ /**
23
+ * A tracker.
24
+ *
25
+ * One per page is plenty; it holds a queue and a timer and nothing else. Safe
26
+ * to create at module scope.
27
+ */
28
+ declare function createTracker(options: TrackerOptions): CMSKiteAnalytics;
29
+
30
+ export { CMSKiteAnalytics, TrackerOptions, createTracker };
@@ -0,0 +1,30 @@
1
+ import { T as TrackerOptions, C as CMSKiteAnalytics } from './analytics-B0zzt0nW.js';
2
+ export { E as EventType } from './analytics-B0zzt0nW.js';
3
+
4
+ /**
5
+ * The CMSKite SDK, for a page.
6
+ *
7
+ * import { createCMSKite } from 'cmskite'
8
+ * import { createTracker } from 'cmskite/browser'
9
+ *
10
+ * const analytics = createTracker({ apiKey: PUBLIC_KEY })
11
+ * analytics.trackView(post.id)
12
+ *
13
+ * Separate from the main entry point because it touches `document` and
14
+ * `navigator`. A server that imported it by accident would fail at runtime; a
15
+ * separate specifier makes that a build-time mistake instead.
16
+ *
17
+ * The key here is the read-only project key, which is the same one the page
18
+ * already used to fetch the content. Nothing secret belongs in a bundle, and
19
+ * nothing in this file asks for anything that is.
20
+ */
21
+
22
+ /**
23
+ * A tracker.
24
+ *
25
+ * One per page is plenty; it holds a queue and a timer and nothing else. Safe
26
+ * to create at module scope.
27
+ */
28
+ declare function createTracker(options: TrackerOptions): CMSKiteAnalytics;
29
+
30
+ export { CMSKiteAnalytics, TrackerOptions, createTracker };
@@ -0,0 +1,12 @@
1
+ import { CMSKiteAnalytics } from './chunk-QXWHDCMJ.js';
2
+ export { CMSKiteAnalytics } from './chunk-QXWHDCMJ.js';
3
+ import './chunk-DT3V5CH2.js';
4
+
5
+ // src/browser.ts
6
+ function createTracker(options) {
7
+ return new CMSKiteAnalytics(options);
8
+ }
9
+
10
+ export { createTracker };
11
+ //# sourceMappingURL=browser.js.map
12
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts"],"names":[],"mappings":";;;;;AA2BO,SAAS,cAAc,OAAA,EAA2C;AACvE,EAAA,OAAO,IAAI,iBAAiB,OAAO,CAAA;AACrC","file":"browser.js","sourcesContent":["/**\n * The CMSKite SDK, for a page.\n *\n * import { createCMSKite } from 'cmskite'\n * import { createTracker } from 'cmskite/browser'\n *\n * const analytics = createTracker({ apiKey: PUBLIC_KEY })\n * analytics.trackView(post.id)\n *\n * Separate from the main entry point because it touches `document` and\n * `navigator`. A server that imported it by accident would fail at runtime; a\n * separate specifier makes that a build-time mistake instead.\n *\n * The key here is the read-only project key, which is the same one the page\n * already used to fetch the content. Nothing secret belongs in a bundle, and\n * nothing in this file asks for anything that is.\n */\nexport { CMSKiteAnalytics, type EventType, type TrackerOptions } from './analytics.js'\n\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * A tracker.\n *\n * One per page is plenty; it holds a queue and a timer and nothing else. Safe\n * to create at module scope.\n */\nexport function createTracker(options: TrackerOptions): CMSKiteAnalytics {\n return new CMSKiteAnalytics(options)\n}\n"]}
@@ -0,0 +1,145 @@
1
+ // src/errors.ts
2
+ var CMSKiteError = class extends Error {
3
+ /** HTTP status, or 0 when no response arrived at all. */
4
+ status;
5
+ /** A stable machine-readable reason. Safe to branch on; see the constants. */
6
+ code;
7
+ /** Whatever the API attached. Field-level validation problems live here. */
8
+ details;
9
+ /** Quote this when asking us about a request. */
10
+ requestId;
11
+ constructor(status, code, message, details = {}, requestId = null) {
12
+ super(message);
13
+ this.name = "CMSKiteError";
14
+ this.status = status;
15
+ this.code = code;
16
+ this.details = details;
17
+ this.requestId = requestId;
18
+ }
19
+ /** Nothing you send differently will change the answer. */
20
+ get isClientError() {
21
+ return this.status >= 400 && this.status < 500;
22
+ }
23
+ /** Worth trying again. The client already retried once. */
24
+ get isRetryable() {
25
+ return this.status === 0 || this.status === 429 || this.status >= 500;
26
+ }
27
+ get isNotFound() {
28
+ return this.status === 404;
29
+ }
30
+ /** The key is missing, wrong, or has been revoked. */
31
+ get isAuthError() {
32
+ return this.status === 401 || this.status === 403;
33
+ }
34
+ };
35
+ var ErrorCode = {
36
+ NETWORK: "NETWORK",
37
+ TIMEOUT: "TIMEOUT",
38
+ BAD_RESPONSE: "BAD_RESPONSE",
39
+ UNAUTHENTICATED: "UNAUTHENTICATED",
40
+ FORBIDDEN: "FORBIDDEN",
41
+ NOT_FOUND: "NOT_FOUND",
42
+ RATE_LIMITED: "RATE_LIMITED",
43
+ INVALID_REQUEST: "INVALID_REQUEST"
44
+ };
45
+
46
+ // src/transport.ts
47
+ var DEFAULT_BASE_URL = "https://api.cmskite.com";
48
+ var VERSION = "0.1.0";
49
+ async function request(config, method, path, init = {}) {
50
+ const url = buildUrl(config.baseUrl, path, init.query);
51
+ const timeoutMs = init.options?.timeoutMs ?? config.timeoutMs;
52
+ let lastError = null;
53
+ for (let attempt = 0; attempt < 2; attempt++) {
54
+ try {
55
+ return await once(config, method, url, timeoutMs, init);
56
+ } catch (error) {
57
+ const failure = error instanceof CMSKiteError ? error : toError(error);
58
+ if (init.options?.signal?.aborted) throw failure;
59
+ if (!failure.isRetryable || attempt === 1) throw failure;
60
+ lastError = failure;
61
+ await new Promise((resolve) => setTimeout(resolve, 250));
62
+ }
63
+ }
64
+ throw lastError ?? new CMSKiteError(0, ErrorCode.NETWORK, "Request failed.");
65
+ }
66
+ async function once(config, method, url, timeoutMs, init) {
67
+ const timeout = new AbortController();
68
+ const timer = setTimeout(() => timeout.abort(), timeoutMs);
69
+ const signal = init.options?.signal ? AbortSignal.any([init.options.signal, timeout.signal]) : timeout.signal;
70
+ let response;
71
+ let text;
72
+ try {
73
+ response = await config.fetch(url, {
74
+ method,
75
+ signal,
76
+ headers: {
77
+ authorization: `Bearer ${config.apiKey}`,
78
+ accept: "application/json",
79
+ "x-cmskite-sdk": VERSION,
80
+ ...init.body === void 0 ? {} : { "content-type": "application/json" },
81
+ ...config.headers
82
+ },
83
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
84
+ });
85
+ text = await response.text();
86
+ } catch (error) {
87
+ throw toError(error, timeout.signal.aborted, timeoutMs);
88
+ } finally {
89
+ clearTimeout(timer);
90
+ }
91
+ let payload;
92
+ try {
93
+ payload = text ? JSON.parse(text) : {};
94
+ } catch {
95
+ throw new CMSKiteError(
96
+ response.status,
97
+ ErrorCode.BAD_RESPONSE,
98
+ "CMSKite returned something this client could not read.",
99
+ {},
100
+ response.headers.get("x-request-id")
101
+ );
102
+ }
103
+ if (!response.ok || payload.success === false) {
104
+ throw new CMSKiteError(
105
+ response.status,
106
+ payload.error?.code ?? codeForStatus(response.status),
107
+ payload.error?.message ?? `CMSKite answered ${response.status}.`,
108
+ payload.error?.details ?? {},
109
+ payload.requestId ?? response.headers.get("x-request-id")
110
+ );
111
+ }
112
+ const result = { data: payload.data };
113
+ if (payload.pagination) result.pagination = payload.pagination;
114
+ return result;
115
+ }
116
+ function toError(error, timedOut = false, timeoutMs = 0) {
117
+ if (error instanceof CMSKiteError) return error;
118
+ if (timedOut) {
119
+ return new CMSKiteError(0, ErrorCode.TIMEOUT, `CMSKite did not answer within ${timeoutMs}ms.`);
120
+ }
121
+ if (error instanceof Error && error.name === "AbortError") {
122
+ return new CMSKiteError(0, ErrorCode.NETWORK, "The request was cancelled.");
123
+ }
124
+ return new CMSKiteError(0, ErrorCode.NETWORK, "Could not reach CMSKite. Check the connection.");
125
+ }
126
+ function codeForStatus(status) {
127
+ if (status === 401) return ErrorCode.UNAUTHENTICATED;
128
+ if (status === 403) return ErrorCode.FORBIDDEN;
129
+ if (status === 404) return ErrorCode.NOT_FOUND;
130
+ if (status === 429) return ErrorCode.RATE_LIMITED;
131
+ if (status >= 400 && status < 500) return ErrorCode.INVALID_REQUEST;
132
+ return "INTERNAL_ERROR";
133
+ }
134
+ function buildUrl(baseUrl, path, query = {}) {
135
+ const url = new URL(path, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
136
+ for (const [key, value] of Object.entries(query)) {
137
+ if (value === void 0 || value === null || value === "") continue;
138
+ url.searchParams.set(key, String(value));
139
+ }
140
+ return url.toString();
141
+ }
142
+
143
+ export { CMSKiteError, DEFAULT_BASE_URL, ErrorCode, request };
144
+ //# sourceMappingURL=chunk-DT3V5CH2.js.map
145
+ //# sourceMappingURL=chunk-DT3V5CH2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/errors.ts","../src/transport.ts"],"names":[],"mappings":"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","file":"chunk-DT3V5CH2.js","sourcesContent":["/**\n * One error type, so a caller writes one catch.\n *\n * Everything that can go wrong on the way to an answer arrives as a\n * `CMSKiteError`: a refusal from the API, a dropped connection, a timeout, a\n * response that was not JSON. Without that, a `catch` has to tell a `TypeError`\n * from a `SyntaxError` from whatever the API said, and the ones that forget\n * show somebody \"Failed to fetch\" -- a sentence about our code rather than\n * about their connection.\n */\nexport class CMSKiteError extends Error {\n /** HTTP status, or 0 when no response arrived at all. */\n readonly status: number\n /** A stable machine-readable reason. Safe to branch on; see the constants. */\n readonly code: string\n /** Whatever the API attached. Field-level validation problems live here. */\n readonly details: Record<string, unknown>\n /** Quote this when asking us about a request. */\n readonly requestId: string | null\n\n constructor(\n status: number,\n code: string,\n message: string,\n details: Record<string, unknown> = {},\n requestId: string | null = null,\n ) {\n super(message)\n this.name = 'CMSKiteError'\n this.status = status\n this.code = code\n this.details = details\n this.requestId = requestId\n }\n\n /** Nothing you send differently will change the answer. */\n get isClientError(): boolean {\n return this.status >= 400 && this.status < 500\n }\n\n /** Worth trying again. The client already retried once. */\n get isRetryable(): boolean {\n return this.status === 0 || this.status === 429 || this.status >= 500\n }\n\n get isNotFound(): boolean {\n return this.status === 404\n }\n\n /** The key is missing, wrong, or has been revoked. */\n get isAuthError(): boolean {\n return this.status === 401 || this.status === 403\n }\n}\n\n/**\n * The codes worth branching on, as values rather than as strings scattered\n * through a codebase.\n */\nexport const ErrorCode = {\n NETWORK: 'NETWORK',\n TIMEOUT: 'TIMEOUT',\n BAD_RESPONSE: 'BAD_RESPONSE',\n UNAUTHENTICATED: 'UNAUTHENTICATED',\n FORBIDDEN: 'FORBIDDEN',\n NOT_FOUND: 'NOT_FOUND',\n RATE_LIMITED: 'RATE_LIMITED',\n INVALID_REQUEST: 'INVALID_REQUEST',\n} as const\n\nexport type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode]\n","import { CMSKiteError, ErrorCode } from './errors.js'\nimport type { Page, RequestOptions } from './types.js'\n\nexport const DEFAULT_BASE_URL = 'https://api.cmskite.com'\n/**\n * What a caller may pass as a query.\n *\n * An interface with named optional fields -- which every query type here is --\n * does not satisfy `Record<string, unknown>`, because TypeScript will not give\n * one an implicit index signature. Widening to `object` and reading entries off\n * it is the honest way to accept both, and the values are stringified anyway.\n */\ntype QueryInput = Record<string, unknown> | object\n\n/** Sent so we can tell an SDK request from a hand-rolled one in support. */\nconst VERSION = '0.1.0'\n\nexport interface TransportConfig {\n apiKey: string\n baseUrl: string\n timeoutMs: number\n /** Extra headers on every request. For a proxy or a trace id. */\n headers: Record<string, string>\n fetch: typeof globalThis.fetch\n}\n\ninterface Envelope<T> {\n success?: boolean\n data?: T\n pagination?: Page<T>['pagination']\n error?: { code?: string; message?: string; details?: Record<string, unknown> }\n requestId?: string\n}\n\n/**\n * One request, and everything that can go wrong with it turned into one type.\n *\n * Retries exactly once, and only what a retry can fix: a dropped connection, a\n * timeout, a 429, a 5xx. A 404 is a settled answer and asking again only delays\n * showing somebody the truth. A 401 will not become a 200 by being repeated.\n */\nexport async function request<T>(\n config: TransportConfig,\n method: string,\n path: string,\n init: { query?: QueryInput; body?: unknown; options?: RequestOptions } = {},\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n const url = buildUrl(config.baseUrl, path, init.query)\n const timeoutMs = init.options?.timeoutMs ?? config.timeoutMs\n\n let lastError: CMSKiteError | null = null\n for (let attempt = 0; attempt < 2; attempt++) {\n try {\n return await once<T>(config, method, url, timeoutMs, init)\n } catch (error) {\n const failure = error instanceof CMSKiteError ? error : toError(error)\n // A caller who aborted wants the abort, not a retry and not our wrapper.\n if (init.options?.signal?.aborted) throw failure\n if (!failure.isRetryable || attempt === 1) throw failure\n lastError = failure\n // One short pause. Anything longer is a decision the caller should make.\n await new Promise((resolve) => setTimeout(resolve, 250))\n }\n }\n\n throw lastError ?? new CMSKiteError(0, ErrorCode.NETWORK, 'Request failed.')\n}\n\nasync function once<T>(\n config: TransportConfig,\n method: string,\n url: string,\n timeoutMs: number,\n init: { body?: unknown; options?: RequestOptions },\n): Promise<{ data: T; pagination?: Page<T>['pagination'] }> {\n /**\n * The caller's signal and our timeout, combined.\n *\n * `AbortSignal.any` is the correct tool and exists everywhere this package\n * supports; the timeout controller is still created so that a timeout can be\n * told apart from a caller's abort in the catch below.\n */\n const timeout = new AbortController()\n const timer = setTimeout(() => timeout.abort(), timeoutMs)\n const signal = init.options?.signal\n ? AbortSignal.any([init.options.signal, timeout.signal])\n : timeout.signal\n\n let response: Response\n let text: string\n try {\n response = await config.fetch(url, {\n method,\n signal,\n headers: {\n authorization: `Bearer ${config.apiKey}`,\n accept: 'application/json',\n 'x-cmskite-sdk': VERSION,\n ...(init.body === undefined ? {} : { 'content-type': 'application/json' }),\n ...config.headers,\n },\n ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),\n })\n text = await response.text()\n } catch (error) {\n throw toError(error, timeout.signal.aborted, timeoutMs)\n } finally {\n clearTimeout(timer)\n }\n\n let payload: Envelope<T>\n try {\n payload = text ? (JSON.parse(text) as Envelope<T>) : {}\n } catch {\n throw new CMSKiteError(\n response.status,\n ErrorCode.BAD_RESPONSE,\n 'CMSKite returned something this client could not read.',\n {},\n response.headers.get('x-request-id'),\n )\n }\n\n if (!response.ok || payload.success === false) {\n throw new CMSKiteError(\n response.status,\n payload.error?.code ?? codeForStatus(response.status),\n payload.error?.message ?? `CMSKite answered ${response.status}.`,\n payload.error?.details ?? {},\n payload.requestId ?? response.headers.get('x-request-id'),\n )\n }\n\n const result: { data: T; pagination?: Page<T>['pagination'] } = { data: payload.data as T }\n if (payload.pagination) result.pagination = payload.pagination\n return result\n}\n\nfunction toError(error: unknown, timedOut = false, timeoutMs = 0): CMSKiteError {\n if (error instanceof CMSKiteError) return error\n if (timedOut) {\n return new CMSKiteError(0, ErrorCode.TIMEOUT, `CMSKite did not answer within ${timeoutMs}ms.`)\n }\n if (error instanceof Error && error.name === 'AbortError') {\n return new CMSKiteError(0, ErrorCode.NETWORK, 'The request was cancelled.')\n }\n return new CMSKiteError(0, ErrorCode.NETWORK, 'Could not reach CMSKite. Check the connection.')\n}\n\nfunction codeForStatus(status: number): string {\n if (status === 401) return ErrorCode.UNAUTHENTICATED\n if (status === 403) return ErrorCode.FORBIDDEN\n if (status === 404) return ErrorCode.NOT_FOUND\n if (status === 429) return ErrorCode.RATE_LIMITED\n if (status >= 400 && status < 500) return ErrorCode.INVALID_REQUEST\n return 'INTERNAL_ERROR'\n}\n\n/**\n * Query building, in one place.\n *\n * `undefined`, `null` and the empty string are dropped rather than sent, so a\n * caller can pass an optional filter straight through without writing the same\n * three-line guard at every call site.\n */\nexport function buildUrl(baseUrl: string, path: string, query: QueryInput = {}): string {\n const url = new URL(path, baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`)\n for (const [key, value] of Object.entries(query as Record<string, unknown>)) {\n if (value === undefined || value === null || value === '') continue\n url.searchParams.set(key, String(value))\n }\n return url.toString()\n}\n"]}
@@ -0,0 +1,143 @@
1
+ import { DEFAULT_BASE_URL } from './chunk-DT3V5CH2.js';
2
+
3
+ // src/analytics.ts
4
+ var MAX_BATCH = 50;
5
+ var STORAGE_PREFIX = "cmskite:v:";
6
+ var CMSKiteAnalytics = class {
7
+ endpoint;
8
+ apiKey;
9
+ enabled;
10
+ flushIntervalMs;
11
+ fetchImpl;
12
+ queue = [];
13
+ timer = null;
14
+ listening = false;
15
+ constructor(options) {
16
+ this.apiKey = options.apiKey;
17
+ this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "")}/v1/blog/events`;
18
+ this.enabled = options.enabled !== false;
19
+ this.flushIntervalMs = options.flushIntervalMs ?? 1e3;
20
+ this.fetchImpl = options.fetch;
21
+ this.listenForUnload();
22
+ }
23
+ /**
24
+ * One reader seeing one post.
25
+ *
26
+ * Call it when the post is rendered. Calling it again for the same post in
27
+ * the same tab does nothing, which is what makes it safe to put in a React
28
+ * effect that runs on every render.
29
+ */
30
+ trackView(postId, path) {
31
+ if (!this.enabled || !postId) return;
32
+ if (this.alreadySeen(postId)) return;
33
+ this.remember(postId);
34
+ this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() } });
35
+ }
36
+ /**
37
+ * A link press.
38
+ *
39
+ * Not deduplicated: pressing the same link twice is two clicks, and the nonce
40
+ * is what tells the server so.
41
+ */
42
+ trackClick(postId, target, path) {
43
+ if (!this.enabled || !postId) return;
44
+ this.push({
45
+ type: "click",
46
+ postId,
47
+ ...target ? { target: target.slice(0, 300) } : {},
48
+ ...path ? { path } : { path: currentPath() },
49
+ nonce: nonce()
50
+ });
51
+ }
52
+ /** Sends whatever is queued now. Called for you on page hide. */
53
+ flush() {
54
+ if (this.queue.length === 0) return;
55
+ const batch = this.queue.splice(0, MAX_BATCH);
56
+ this.clearTimer();
57
+ this.send(batch);
58
+ }
59
+ /** Stops the timer and the listeners. For a test, or a single-page teardown. */
60
+ destroy() {
61
+ this.flush();
62
+ this.clearTimer();
63
+ }
64
+ // --- internals ------------------------------------------------------------
65
+ push(event) {
66
+ this.queue.push(event);
67
+ if (this.queue.length >= MAX_BATCH) return this.flush();
68
+ if (!this.timer) {
69
+ this.timer = setTimeout(() => this.flush(), this.flushIntervalMs);
70
+ this.timer.unref?.();
71
+ }
72
+ }
73
+ send(events) {
74
+ const body = JSON.stringify({ events });
75
+ if (!this.fetchImpl) {
76
+ try {
77
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
78
+ const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
79
+ const blob = new Blob([body], { type: "application/json" });
80
+ if (navigator.sendBeacon(url, blob)) return;
81
+ }
82
+ } catch {
83
+ }
84
+ }
85
+ try {
86
+ void (this.fetchImpl ?? fetch)(this.endpoint, {
87
+ method: "POST",
88
+ headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
89
+ body,
90
+ // Survives the navigation that triggered it, like sendBeacon does.
91
+ keepalive: true
92
+ }).catch(() => {
93
+ });
94
+ } catch {
95
+ }
96
+ }
97
+ /**
98
+ * What this tab has already reported.
99
+ *
100
+ * `sessionStorage`, not `localStorage`: a view should be counted again
101
+ * tomorrow, and a session is the unit the server deduplicates on too. A
102
+ * browser that refuses storage -- private mode, blocked site data -- falls
103
+ * back to counting the view, which is the right way to be wrong.
104
+ */
105
+ alreadySeen(postId) {
106
+ try {
107
+ return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+ remember(postId) {
113
+ try {
114
+ sessionStorage.setItem(STORAGE_PREFIX + postId, "1");
115
+ } catch {
116
+ }
117
+ }
118
+ listenForUnload() {
119
+ if (this.listening || typeof document === "undefined") return;
120
+ this.listening = true;
121
+ document.addEventListener("visibilitychange", () => {
122
+ if (document.visibilityState === "hidden") this.flush();
123
+ });
124
+ }
125
+ clearTimer() {
126
+ if (this.timer) clearTimeout(this.timer);
127
+ this.timer = null;
128
+ }
129
+ };
130
+ function currentPath() {
131
+ try {
132
+ return window.location.pathname;
133
+ } catch {
134
+ return "/";
135
+ }
136
+ }
137
+ function nonce() {
138
+ return Math.random().toString(36).slice(2, 10);
139
+ }
140
+
141
+ export { CMSKiteAnalytics };
142
+ //# sourceMappingURL=chunk-QXWHDCMJ.js.map
143
+ //# sourceMappingURL=chunk-QXWHDCMJ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/analytics.ts"],"names":[],"mappings":"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","file":"chunk-QXWHDCMJ.js","sourcesContent":["import { DEFAULT_BASE_URL } from './transport.js'\n\n/**\n * Reporting what a reader did, without ever being able to break the page.\n *\n * Three properties, and every decision below follows from them:\n *\n * It cannot throw. Not on a network failure, not on a 500, not if the key is\n * wrong. A customer's article must render whether or not our analytics is\n * having a good day, so every path here ends in a swallowed error.\n *\n * It cannot block. Events are queued and flushed on a timer, and the flush\n * uses `sendBeacon` where it exists -- which hands the batch to the browser\n * and returns immediately, and which still delivers after the page has been\n * closed. Nothing awaits a response, because there is nothing in the\n * response.\n *\n * It cannot double-count. A view is remembered in `sessionStorage`, so a\n * refresh, a re-render and a client-side navigation back to a post already\n * read report nothing at all. The server deduplicates again by a\n * deterministic id, because storage can be cleared and a second tab has its\n * own.\n */\n\nexport interface TrackerOptions {\n apiKey: string\n baseUrl?: string\n /**\n * Off switch. `false` queues nothing and sends nothing.\n *\n * For a development build, or for a site that asks first and only turns this\n * on afterwards.\n */\n enabled?: boolean\n /** How long to hold events before sending. Default 1000ms. */\n flushIntervalMs?: number\n /**\n * Supply your own, for a test.\n *\n * The main client has taken one since it was written, and this did not --\n * which made the tracker the one part of the SDK that could not be exercised\n * outside a browser. It was verified by asserting that it did not throw,\n * which is a test that passes while nothing at all is recorded, and that is\n * exactly what happened the first time it was run end to end.\n */\n fetch?: typeof globalThis.fetch\n}\n\nexport type EventType = 'view' | 'click'\n\ninterface QueuedEvent {\n type: EventType\n postId: string\n path?: string\n target?: string\n nonce?: string\n}\n\nconst MAX_BATCH = 50\nconst STORAGE_PREFIX = 'cmskite:v:'\n\nexport class CMSKiteAnalytics {\n private readonly endpoint: string\n private readonly apiKey: string\n private readonly enabled: boolean\n private readonly flushIntervalMs: number\n private readonly fetchImpl: typeof globalThis.fetch | undefined\n private queue: QueuedEvent[] = []\n private timer: ReturnType<typeof setTimeout> | null = null\n private listening = false\n\n constructor(options: TrackerOptions) {\n this.apiKey = options.apiKey\n this.endpoint = `${(options.baseUrl ?? DEFAULT_BASE_URL).replace(/\\/+$/, '')}/v1/blog/events`\n this.enabled = options.enabled !== false\n this.flushIntervalMs = options.flushIntervalMs ?? 1000\n this.fetchImpl = options.fetch\n this.listenForUnload()\n }\n\n /**\n * One reader seeing one post.\n *\n * Call it when the post is rendered. Calling it again for the same post in\n * the same tab does nothing, which is what makes it safe to put in a React\n * effect that runs on every render.\n */\n trackView(postId: string, path?: string): void {\n if (!this.enabled || !postId) return\n if (this.alreadySeen(postId)) return\n this.remember(postId)\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }) })\n }\n\n /**\n * A link press.\n *\n * Not deduplicated: pressing the same link twice is two clicks, and the nonce\n * is what tells the server so.\n */\n trackClick(postId: string, target?: string, path?: string): void {\n if (!this.enabled || !postId) return\n this.push({\n type: 'click',\n postId,\n ...(target ? { target: target.slice(0, 300) } : {}),\n ...(path ? { path } : { path: currentPath() }),\n nonce: nonce(),\n })\n }\n\n /** Sends whatever is queued now. Called for you on page hide. */\n flush(): void {\n if (this.queue.length === 0) return\n const batch = this.queue.splice(0, MAX_BATCH)\n this.clearTimer()\n this.send(batch)\n }\n\n /** Stops the timer and the listeners. For a test, or a single-page teardown. */\n destroy(): void {\n this.flush()\n this.clearTimer()\n }\n\n // --- internals ------------------------------------------------------------\n\n private push(event: QueuedEvent): void {\n this.queue.push(event)\n if (this.queue.length >= MAX_BATCH) return this.flush()\n if (!this.timer) {\n this.timer = setTimeout(() => this.flush(), this.flushIntervalMs)\n // Never hold a Node process open for a view count.\n ;(this.timer as unknown as { unref?: () => void }).unref?.()\n }\n }\n\n private send(events: QueuedEvent[]): void {\n const body = JSON.stringify({ events })\n\n /**\n * `sendBeacon` first, because it is the one mechanism the browser promises\n * to finish after the page is gone -- which is exactly when the last view\n * of a session is reported.\n *\n * It cannot carry an Authorization header, so the key rides in the URL.\n * That is safe here and nowhere else: the key is read-only, scoped to one\n * project, and already present in the page that fetched the content.\n */\n // An injected fetch means a test, and a test wants to see the request\n // rather than hand it to a beacon it cannot observe.\n if (!this.fetchImpl) {\n try {\n if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {\n const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`\n const blob = new Blob([body], { type: 'application/json' })\n if (navigator.sendBeacon(url, blob)) return\n }\n } catch {\n // Fall through to fetch. Nothing here is worth surfacing.\n }\n }\n\n try {\n void (this.fetchImpl ?? fetch)(this.endpoint, {\n method: 'POST',\n headers: { 'content-type': 'application/json', authorization: `Bearer ${this.apiKey}` },\n body,\n // Survives the navigation that triggered it, like sendBeacon does.\n keepalive: true,\n }).catch(() => {\n // Analytics must never break the page it is measuring.\n })\n } catch {\n // Nor must it break when `fetch` itself is missing.\n }\n }\n\n /**\n * What this tab has already reported.\n *\n * `sessionStorage`, not `localStorage`: a view should be counted again\n * tomorrow, and a session is the unit the server deduplicates on too. A\n * browser that refuses storage -- private mode, blocked site data -- falls\n * back to counting the view, which is the right way to be wrong.\n */\n private alreadySeen(postId: string): boolean {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + postId) !== null\n } catch {\n return false\n }\n }\n\n private remember(postId: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + postId, '1')\n } catch {\n // No storage, so no memory. The server still deduplicates.\n }\n }\n\n private listenForUnload(): void {\n if (this.listening || typeof document === 'undefined') return\n this.listening = true\n /**\n * `visibilitychange`, not `unload`.\n *\n * `unload` does not fire reliably on mobile Safari, which is where a reader\n * most often leaves by switching apps rather than by closing a tab.\n */\n document.addEventListener('visibilitychange', () => {\n if (document.visibilityState === 'hidden') this.flush()\n })\n }\n\n private clearTimer(): void {\n if (this.timer) clearTimeout(this.timer)\n this.timer = null\n }\n}\n\nfunction currentPath(): string {\n try {\n return window.location.pathname\n } catch {\n return '/'\n }\n}\n\nfunction nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n"]}