cmskite 0.1.0 → 0.1.2
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.
- package/README.md +18 -13
- package/dist/browser.cjs +13 -4
- package/dist/browser.cjs.map +1 -1
- package/dist/browser.js +2 -2
- package/dist/{chunk-QXWHDCMJ.js → chunk-ZSNGGYYK.js} +15 -6
- package/dist/chunk-ZSNGGYYK.js.map +1 -0
- package/dist/index.d.cts +23 -6
- package/dist/index.d.ts +23 -6
- package/dist/react.cjs +13 -4
- package/dist/react.cjs.map +1 -1
- package/dist/react.js +1 -1
- package/package.json +27 -6
- package/dist/chunk-QXWHDCMJ.js.map +0 -1
package/README.md
CHANGED
|
@@ -294,7 +294,7 @@ import { notFound } from 'next/navigation'
|
|
|
294
294
|
import { createCMSKite } from 'cmskite'
|
|
295
295
|
import { TrackView } from './track-view'
|
|
296
296
|
|
|
297
|
-
const cms = createCMSKite({ apiKey: process.env.
|
|
297
|
+
const cms = createCMSKite({ apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
|
|
298
298
|
|
|
299
299
|
export async function generateStaticParams() {
|
|
300
300
|
const params = []
|
|
@@ -327,25 +327,31 @@ export default async function Page({ params }: { params: Promise<{ slug: string
|
|
|
327
327
|
import { useTrackView } from 'cmskite/react'
|
|
328
328
|
|
|
329
329
|
export function TrackView({ postId }: { postId: string }) {
|
|
330
|
-
|
|
330
|
+
// A project key (csk_live_…), baked in at build time. Off until it is set, and off in dev.
|
|
331
|
+
useTrackView(postId, {
|
|
332
|
+
apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY ?? '',
|
|
333
|
+
enabled: Boolean(process.env.NEXT_PUBLIC_CMSKITE_KEY) && process.env.NODE_ENV === 'production',
|
|
334
|
+
})
|
|
331
335
|
return null
|
|
332
336
|
}
|
|
333
337
|
```
|
|
334
338
|
|
|
335
|
-
|
|
339
|
+
One key, one `.env` entry. The server code and the tracker both read it — see
|
|
336
340
|
[Security](#security-which-key-goes-where).
|
|
337
341
|
|
|
338
342
|
```
|
|
339
|
-
|
|
340
|
-
NEXT_PUBLIC_CMSKITE_KEY=csk_live_… # in the bundle, deliberately
|
|
343
|
+
NEXT_PUBLIC_CMSKITE_KEY=csk_live_…
|
|
341
344
|
```
|
|
342
345
|
|
|
346
|
+
`NEXT_PUBLIC_` values are compiled in when the site is built. After you set or
|
|
347
|
+
change it on your host, redeploy.
|
|
348
|
+
|
|
343
349
|
**Revalidation.** The SDK uses `fetch`, so Next's cache options work. Pass them
|
|
344
350
|
through your own wrapper if you want ISR:
|
|
345
351
|
|
|
346
352
|
```ts
|
|
347
353
|
const cms = createCMSKite({
|
|
348
|
-
apiKey: process.env.
|
|
354
|
+
apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY!,
|
|
349
355
|
fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 300 } }),
|
|
350
356
|
})
|
|
351
357
|
```
|
|
@@ -418,15 +424,14 @@ that may use the key. Until you do, any page anywhere can read your published
|
|
|
418
424
|
content with it — which is content you are already publishing, but it is still
|
|
419
425
|
your bandwidth.
|
|
420
426
|
|
|
421
|
-
**
|
|
422
|
-
|
|
423
|
-
taking your build down.
|
|
427
|
+
**One key, one variable.** The same project key fetches posts and reports
|
|
428
|
+
views, so a site needs only one:
|
|
424
429
|
|
|
425
|
-
| |
|
|
430
|
+
| Your site | Variable | Used by |
|
|
426
431
|
|---|---|---|
|
|
427
|
-
|
|
|
428
|
-
|
|
|
429
|
-
|
|
|
432
|
+
| Next.js | `NEXT_PUBLIC_CMSKITE_KEY` | `createCMSKite` on the server, and the tracker |
|
|
433
|
+
| React with Vite | `VITE_CMSKITE_KEY` | `createCMSKite` and the tracker |
|
|
434
|
+
| Node, no browser code | `CMSKITE_API_KEY` | `createCMSKite` |
|
|
430
435
|
|
|
431
436
|
Never put a dashboard session token or an agent token in a browser. Those act
|
|
432
437
|
for a person; an API key acts for a project.
|
package/dist/browser.cjs
CHANGED
|
@@ -34,7 +34,8 @@ var CMSKiteAnalytics = class {
|
|
|
34
34
|
if (!this.enabled || !postId) return;
|
|
35
35
|
if (this.alreadySeen(postId)) return;
|
|
36
36
|
this.remember(postId);
|
|
37
|
-
|
|
37
|
+
const referrer = currentReferrer();
|
|
38
|
+
this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() }, ...referrer ? { referrer } : {} });
|
|
38
39
|
}
|
|
39
40
|
/**
|
|
40
41
|
* A link press.
|
|
@@ -79,16 +80,17 @@ var CMSKiteAnalytics = class {
|
|
|
79
80
|
try {
|
|
80
81
|
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
81
82
|
const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
|
|
82
|
-
const blob = new Blob([body], { type: "
|
|
83
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
83
84
|
if (navigator.sendBeacon(url, blob)) return;
|
|
84
85
|
}
|
|
85
86
|
} catch {
|
|
86
87
|
}
|
|
87
88
|
}
|
|
88
89
|
try {
|
|
89
|
-
void (this.fetchImpl ?? fetch)(this.endpoint
|
|
90
|
+
void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
|
|
90
91
|
method: "POST",
|
|
91
|
-
|
|
92
|
+
// Safelisted, so this is not preflighted either. See the beacon above.
|
|
93
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
92
94
|
body,
|
|
93
95
|
// Survives the navigation that triggered it, like sendBeacon does.
|
|
94
96
|
keepalive: true
|
|
@@ -137,6 +139,13 @@ function currentPath() {
|
|
|
137
139
|
return "/";
|
|
138
140
|
}
|
|
139
141
|
}
|
|
142
|
+
function currentReferrer() {
|
|
143
|
+
try {
|
|
144
|
+
return document.referrer.slice(0, 2048);
|
|
145
|
+
} catch {
|
|
146
|
+
return "";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
140
149
|
function nonce() {
|
|
141
150
|
return Math.random().toString(36).slice(2, 10);
|
|
142
151
|
}
|
package/dist/browser.cjs.map
CHANGED
|
@@ -1 +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"]}
|
|
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 referrer?: 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 const referrer = currentReferrer()\n // The page's own referrer: the request's Referer header is this page, which named the site as its own source.\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }), ...(referrer ? { referrer } : {}) })\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 * The body is JSON and the content type says `text/plain`, deliberately.\n * `application/json` is not on the CORS safelist, so the browser preflights\n * the beacon -- and a preflight carries no credential, so the API cannot\n * answer it from this project's allowlist and refuses it for every origin\n * no project has listed. The beacon is then dropped with nothing reported\n * anywhere: the page renders, the content is right, and every post reads\n * zero views forever. `text/plain` is safelisted, so the same bytes go\n * straight out with no preflight. The API accepts both.\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: 'text/plain;charset=UTF-8' })\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}?key=${encodeURIComponent(this.apiKey)}`, {\n method: 'POST',\n // Safelisted, so this is not preflighted either. See the beacon above.\n headers: { 'content-type': 'text/plain;charset=UTF-8' },\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 currentReferrer(): string {\n try {\n return document.referrer.slice(0, 2048)\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"]}
|
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { CMSKiteAnalytics } from './chunk-
|
|
2
|
-
export { CMSKiteAnalytics } from './chunk-
|
|
1
|
+
import { CMSKiteAnalytics } from './chunk-ZSNGGYYK.js';
|
|
2
|
+
export { CMSKiteAnalytics } from './chunk-ZSNGGYYK.js';
|
|
3
3
|
import './chunk-DT3V5CH2.js';
|
|
4
4
|
|
|
5
5
|
// src/browser.ts
|
|
@@ -31,7 +31,8 @@ var CMSKiteAnalytics = class {
|
|
|
31
31
|
if (!this.enabled || !postId) return;
|
|
32
32
|
if (this.alreadySeen(postId)) return;
|
|
33
33
|
this.remember(postId);
|
|
34
|
-
|
|
34
|
+
const referrer = currentReferrer();
|
|
35
|
+
this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() }, ...referrer ? { referrer } : {} });
|
|
35
36
|
}
|
|
36
37
|
/**
|
|
37
38
|
* A link press.
|
|
@@ -76,16 +77,17 @@ var CMSKiteAnalytics = class {
|
|
|
76
77
|
try {
|
|
77
78
|
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
78
79
|
const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
|
|
79
|
-
const blob = new Blob([body], { type: "
|
|
80
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
80
81
|
if (navigator.sendBeacon(url, blob)) return;
|
|
81
82
|
}
|
|
82
83
|
} catch {
|
|
83
84
|
}
|
|
84
85
|
}
|
|
85
86
|
try {
|
|
86
|
-
void (this.fetchImpl ?? fetch)(this.endpoint
|
|
87
|
+
void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
|
|
87
88
|
method: "POST",
|
|
88
|
-
|
|
89
|
+
// Safelisted, so this is not preflighted either. See the beacon above.
|
|
90
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
89
91
|
body,
|
|
90
92
|
// Survives the navigation that triggered it, like sendBeacon does.
|
|
91
93
|
keepalive: true
|
|
@@ -134,10 +136,17 @@ function currentPath() {
|
|
|
134
136
|
return "/";
|
|
135
137
|
}
|
|
136
138
|
}
|
|
139
|
+
function currentReferrer() {
|
|
140
|
+
try {
|
|
141
|
+
return document.referrer.slice(0, 2048);
|
|
142
|
+
} catch {
|
|
143
|
+
return "";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
137
146
|
function nonce() {
|
|
138
147
|
return Math.random().toString(36).slice(2, 10);
|
|
139
148
|
}
|
|
140
149
|
|
|
141
150
|
export { CMSKiteAnalytics };
|
|
142
|
-
//# sourceMappingURL=chunk-
|
|
143
|
-
//# sourceMappingURL=chunk-
|
|
151
|
+
//# sourceMappingURL=chunk-ZSNGGYYK.js.map
|
|
152
|
+
//# sourceMappingURL=chunk-ZSNGGYYK.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/analytics.ts"],"names":[],"mappings":"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","file":"chunk-ZSNGGYYK.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 referrer?: 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 const referrer = currentReferrer()\n // The page's own referrer: the request's Referer header is this page, which named the site as its own source.\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }), ...(referrer ? { referrer } : {}) })\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 * The body is JSON and the content type says `text/plain`, deliberately.\n * `application/json` is not on the CORS safelist, so the browser preflights\n * the beacon -- and a preflight carries no credential, so the API cannot\n * answer it from this project's allowlist and refuses it for every origin\n * no project has listed. The beacon is then dropped with nothing reported\n * anywhere: the page renders, the content is right, and every post reads\n * zero views forever. `text/plain` is safelisted, so the same bytes go\n * straight out with no preflight. The API accepts both.\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: 'text/plain;charset=UTF-8' })\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}?key=${encodeURIComponent(this.apiKey)}`, {\n method: 'POST',\n // Safelisted, so this is not preflighted either. See the beacon above.\n headers: { 'content-type': 'text/plain;charset=UTF-8' },\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 currentReferrer(): string {\n try {\n return document.referrer.slice(0, 2048)\n } catch {\n return ''\n }\n}\n\nfunction nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -34,16 +34,31 @@ interface Tag {
|
|
|
34
34
|
interface Media {
|
|
35
35
|
id: string;
|
|
36
36
|
url: string;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Named as the API names it. This was `alt` and the API has always sent
|
|
39
|
+
* `altText`, so every read of it was undefined -- which is indistinguishable
|
|
40
|
+
* from an image nobody described, and is why it never looked broken.
|
|
41
|
+
*/
|
|
42
|
+
altText: string | null;
|
|
38
43
|
width: number | null;
|
|
39
44
|
height: number | null;
|
|
40
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* What the API sends, key for key.
|
|
48
|
+
*
|
|
49
|
+
* These were written by hand and two of the names were wrong -- `ogImageUrl`
|
|
50
|
+
* for `ogImage`, `noindex` for `noIndex` -- so every site that trusted them
|
|
51
|
+
* rendered no social image and honoured no `noindex`, silently, because a
|
|
52
|
+
* missing field is `undefined` rather than an error. Every key is always
|
|
53
|
+
* present now, so there is nothing left to guess at.
|
|
54
|
+
*/
|
|
41
55
|
interface Seo {
|
|
42
|
-
title
|
|
43
|
-
description
|
|
44
|
-
canonicalUrl
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
title: string | null;
|
|
57
|
+
description: string | null;
|
|
58
|
+
canonicalUrl: string | null;
|
|
59
|
+
ogImage: string | null;
|
|
60
|
+
noIndex: boolean;
|
|
61
|
+
keywords: string[];
|
|
47
62
|
}
|
|
48
63
|
interface Post {
|
|
49
64
|
id: string;
|
|
@@ -74,6 +89,8 @@ interface Post {
|
|
|
74
89
|
slugRedirectedFrom: string | null;
|
|
75
90
|
wordCount: number;
|
|
76
91
|
readingMinutes: number;
|
|
92
|
+
/** Changes whenever the post is saved. Optional: older API responses did not carry it. */
|
|
93
|
+
revision?: number;
|
|
77
94
|
createdAt: string;
|
|
78
95
|
updatedAt: string;
|
|
79
96
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -34,16 +34,31 @@ interface Tag {
|
|
|
34
34
|
interface Media {
|
|
35
35
|
id: string;
|
|
36
36
|
url: string;
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Named as the API names it. This was `alt` and the API has always sent
|
|
39
|
+
* `altText`, so every read of it was undefined -- which is indistinguishable
|
|
40
|
+
* from an image nobody described, and is why it never looked broken.
|
|
41
|
+
*/
|
|
42
|
+
altText: string | null;
|
|
38
43
|
width: number | null;
|
|
39
44
|
height: number | null;
|
|
40
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* What the API sends, key for key.
|
|
48
|
+
*
|
|
49
|
+
* These were written by hand and two of the names were wrong -- `ogImageUrl`
|
|
50
|
+
* for `ogImage`, `noindex` for `noIndex` -- so every site that trusted them
|
|
51
|
+
* rendered no social image and honoured no `noindex`, silently, because a
|
|
52
|
+
* missing field is `undefined` rather than an error. Every key is always
|
|
53
|
+
* present now, so there is nothing left to guess at.
|
|
54
|
+
*/
|
|
41
55
|
interface Seo {
|
|
42
|
-
title
|
|
43
|
-
description
|
|
44
|
-
canonicalUrl
|
|
45
|
-
|
|
46
|
-
|
|
56
|
+
title: string | null;
|
|
57
|
+
description: string | null;
|
|
58
|
+
canonicalUrl: string | null;
|
|
59
|
+
ogImage: string | null;
|
|
60
|
+
noIndex: boolean;
|
|
61
|
+
keywords: string[];
|
|
47
62
|
}
|
|
48
63
|
interface Post {
|
|
49
64
|
id: string;
|
|
@@ -74,6 +89,8 @@ interface Post {
|
|
|
74
89
|
slugRedirectedFrom: string | null;
|
|
75
90
|
wordCount: number;
|
|
76
91
|
readingMinutes: number;
|
|
92
|
+
/** Changes whenever the post is saved. Optional: older API responses did not carry it. */
|
|
93
|
+
revision?: number;
|
|
77
94
|
createdAt: string;
|
|
78
95
|
updatedAt: string;
|
|
79
96
|
}
|
package/dist/react.cjs
CHANGED
|
@@ -36,7 +36,8 @@ var CMSKiteAnalytics = class {
|
|
|
36
36
|
if (!this.enabled || !postId) return;
|
|
37
37
|
if (this.alreadySeen(postId)) return;
|
|
38
38
|
this.remember(postId);
|
|
39
|
-
|
|
39
|
+
const referrer = currentReferrer();
|
|
40
|
+
this.push({ type: "view", postId, ...path ? { path } : { path: currentPath() }, ...referrer ? { referrer } : {} });
|
|
40
41
|
}
|
|
41
42
|
/**
|
|
42
43
|
* A link press.
|
|
@@ -81,16 +82,17 @@ var CMSKiteAnalytics = class {
|
|
|
81
82
|
try {
|
|
82
83
|
if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
|
|
83
84
|
const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
|
|
84
|
-
const blob = new Blob([body], { type: "
|
|
85
|
+
const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
|
|
85
86
|
if (navigator.sendBeacon(url, blob)) return;
|
|
86
87
|
}
|
|
87
88
|
} catch {
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
try {
|
|
91
|
-
void (this.fetchImpl ?? fetch)(this.endpoint
|
|
92
|
+
void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
|
|
92
93
|
method: "POST",
|
|
93
|
-
|
|
94
|
+
// Safelisted, so this is not preflighted either. See the beacon above.
|
|
95
|
+
headers: { "content-type": "text/plain;charset=UTF-8" },
|
|
94
96
|
body,
|
|
95
97
|
// Survives the navigation that triggered it, like sendBeacon does.
|
|
96
98
|
keepalive: true
|
|
@@ -139,6 +141,13 @@ function currentPath() {
|
|
|
139
141
|
return "/";
|
|
140
142
|
}
|
|
141
143
|
}
|
|
144
|
+
function currentReferrer() {
|
|
145
|
+
try {
|
|
146
|
+
return document.referrer.slice(0, 2048);
|
|
147
|
+
} catch {
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
142
151
|
function nonce() {
|
|
143
152
|
return Math.random().toString(36).slice(2, 10);
|
|
144
153
|
}
|
package/dist/react.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/transport.ts","../src/analytics.ts","../src/react.ts"],"names":["useRef","useEffect","useMemo"],"mappings":"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","file":"react.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","'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * Hooks, for the one thing a React app genuinely needs help with.\n *\n * Fetching is not that thing. A server component awaits `cms.posts.list()` and\n * is done; a client component that needs data has a query library already, and\n * a `usePosts` here would be a worse one wearing our name. So there is no data\n * hook, deliberately.\n *\n * View tracking is that thing, because getting it right means knowing when a\n * component mounted, surviving React's double-invoked effects in development,\n * and not re-reporting on every re-render. That is exactly what a hook is for.\n */\n\n/**\n * Reports one view for one post, once.\n *\n * Safe in an effect that runs twice -- React's development Strict Mode\n * deliberately mounts, unmounts and remounts every component, and a naive\n * tracker counts that as two views. This reports on the first mount only, and\n * the tracker remembers the post in `sessionStorage` besides.\n *\n * export default function Article({ post }) {\n * useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })\n * return <article dangerouslySetInnerHTML={{ __html: post.body }} />\n * }\n *\n * Passing a new `postId` reports the new post, which is what makes client-side\n * navigation between two articles count as two views without any router\n * integration.\n */\nexport function useTrackView(postId: string | null | undefined, options: TrackerOptions): void {\n const tracker = useTracker(options)\n const reported = useRef<string | null>(null)\n\n useEffect(() => {\n if (!postId || reported.current === postId) return\n reported.current = postId\n tracker.trackView(postId)\n }, [postId, tracker])\n}\n\n/**\n * The tracker itself, for click tracking and anything else bespoke.\n *\n * const analytics = useCMSKiteAnalytics({ apiKey })\n * <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>\n *\n * Memoised on the values that matter rather than on the options object, so a\n * caller passing an inline literal -- which is everybody -- does not rebuild\n * the tracker and lose its queue on every render.\n */\nexport function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics {\n return useTracker(options)\n}\n\nfunction useTracker(options: TrackerOptions): CMSKiteAnalytics {\n const { apiKey, baseUrl, enabled, flushIntervalMs } = options\n return useMemo(\n () =>\n new CMSKiteAnalytics({\n apiKey,\n ...(baseUrl === undefined ? {} : { baseUrl }),\n ...(enabled === undefined ? {} : { enabled }),\n ...(flushIntervalMs === undefined ? {} : { flushIntervalMs }),\n }),\n [apiKey, baseUrl, enabled, flushIntervalMs],\n )\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/transport.ts","../src/analytics.ts","../src/react.ts"],"names":["useRef","useEffect","useMemo"],"mappings":"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","file":"react.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 referrer?: 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 const referrer = currentReferrer()\n // The page's own referrer: the request's Referer header is this page, which named the site as its own source.\n this.push({ type: 'view', postId, ...(path ? { path } : { path: currentPath() }), ...(referrer ? { referrer } : {}) })\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 * The body is JSON and the content type says `text/plain`, deliberately.\n * `application/json` is not on the CORS safelist, so the browser preflights\n * the beacon -- and a preflight carries no credential, so the API cannot\n * answer it from this project's allowlist and refuses it for every origin\n * no project has listed. The beacon is then dropped with nothing reported\n * anywhere: the page renders, the content is right, and every post reads\n * zero views forever. `text/plain` is safelisted, so the same bytes go\n * straight out with no preflight. The API accepts both.\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: 'text/plain;charset=UTF-8' })\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}?key=${encodeURIComponent(this.apiKey)}`, {\n method: 'POST',\n // Safelisted, so this is not preflighted either. See the beacon above.\n headers: { 'content-type': 'text/plain;charset=UTF-8' },\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 currentReferrer(): string {\n try {\n return document.referrer.slice(0, 2048)\n } catch {\n return ''\n }\n}\n\nfunction nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n","'use client'\n\nimport { useEffect, useMemo, useRef } from 'react'\nimport { CMSKiteAnalytics, type TrackerOptions } from './analytics.js'\n\n/**\n * Hooks, for the one thing a React app genuinely needs help with.\n *\n * Fetching is not that thing. A server component awaits `cms.posts.list()` and\n * is done; a client component that needs data has a query library already, and\n * a `usePosts` here would be a worse one wearing our name. So there is no data\n * hook, deliberately.\n *\n * View tracking is that thing, because getting it right means knowing when a\n * component mounted, surviving React's double-invoked effects in development,\n * and not re-reporting on every re-render. That is exactly what a hook is for.\n */\n\n/**\n * Reports one view for one post, once.\n *\n * Safe in an effect that runs twice -- React's development Strict Mode\n * deliberately mounts, unmounts and remounts every component, and a naive\n * tracker counts that as two views. This reports on the first mount only, and\n * the tracker remembers the post in `sessionStorage` besides.\n *\n * export default function Article({ post }) {\n * useTrackView(post.id, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })\n * return <article dangerouslySetInnerHTML={{ __html: post.body }} />\n * }\n *\n * Passing a new `postId` reports the new post, which is what makes client-side\n * navigation between two articles count as two views without any router\n * integration.\n */\nexport function useTrackView(postId: string | null | undefined, options: TrackerOptions): void {\n const tracker = useTracker(options)\n const reported = useRef<string | null>(null)\n\n useEffect(() => {\n if (!postId || reported.current === postId) return\n reported.current = postId\n tracker.trackView(postId)\n }, [postId, tracker])\n}\n\n/**\n * The tracker itself, for click tracking and anything else bespoke.\n *\n * const analytics = useCMSKiteAnalytics({ apiKey })\n * <a onClick={() => analytics.trackClick(post.id, href)} href={href}>…</a>\n *\n * Memoised on the values that matter rather than on the options object, so a\n * caller passing an inline literal -- which is everybody -- does not rebuild\n * the tracker and lose its queue on every render.\n */\nexport function useCMSKiteAnalytics(options: TrackerOptions): CMSKiteAnalytics {\n return useTracker(options)\n}\n\nfunction useTracker(options: TrackerOptions): CMSKiteAnalytics {\n const { apiKey, baseUrl, enabled, flushIntervalMs } = options\n return useMemo(\n () =>\n new CMSKiteAnalytics({\n apiKey,\n ...(baseUrl === undefined ? {} : { baseUrl }),\n ...(enabled === undefined ? {} : { enabled }),\n ...(flushIntervalMs === undefined ? {} : { flushIntervalMs }),\n }),\n [apiKey, baseUrl, enabled, flushIntervalMs],\n )\n}\n"]}
|
package/dist/react.js
CHANGED
package/package.json
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cmskite",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "The official CMSKite client. Fetch your posts and measure who reads them, in a few lines.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://cmskite.com/docs",
|
|
7
|
-
"repository": {
|
|
8
|
-
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/devicorn/cmskite-sdk.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"cmskite",
|
|
13
|
+
"cms",
|
|
14
|
+
"headless-cms",
|
|
15
|
+
"blog",
|
|
16
|
+
"content-api",
|
|
17
|
+
"analytics"
|
|
18
|
+
],
|
|
9
19
|
"type": "module",
|
|
10
20
|
"sideEffects": false,
|
|
11
21
|
"exports": {
|
|
@@ -28,8 +38,14 @@
|
|
|
28
38
|
"main": "./dist/index.cjs",
|
|
29
39
|
"module": "./dist/index.js",
|
|
30
40
|
"types": "./dist/index.d.ts",
|
|
31
|
-
"files": [
|
|
32
|
-
|
|
41
|
+
"files": [
|
|
42
|
+
"dist",
|
|
43
|
+
"README.md",
|
|
44
|
+
"LICENSE"
|
|
45
|
+
],
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=18"
|
|
48
|
+
},
|
|
33
49
|
"scripts": {
|
|
34
50
|
"build": "tsup",
|
|
35
51
|
"typecheck": "tsc --noEmit",
|
|
@@ -41,14 +57,19 @@
|
|
|
41
57
|
"react": ">=18"
|
|
42
58
|
},
|
|
43
59
|
"peerDependenciesMeta": {
|
|
44
|
-
"react": {
|
|
60
|
+
"react": {
|
|
61
|
+
"optional": true
|
|
62
|
+
}
|
|
45
63
|
},
|
|
46
64
|
"devDependencies": {
|
|
65
|
+
"@eslint/js": "^9.39.5",
|
|
47
66
|
"@types/node": "^24.10.1",
|
|
48
67
|
"@types/react": "^19.2.0",
|
|
68
|
+
"eslint": "^9.39.5",
|
|
49
69
|
"react": "^19.2.0",
|
|
50
70
|
"tsup": "^8.5.0",
|
|
51
71
|
"typescript": "~5.9.3",
|
|
72
|
+
"typescript-eslint": "^8.70.1",
|
|
52
73
|
"vitest": "^3.2.4"
|
|
53
74
|
}
|
|
54
75
|
}
|
|
@@ -1 +0,0 @@
|
|
|
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"]}
|