cmskite 0.1.0 → 0.1.1

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/dist/browser.cjs CHANGED
@@ -79,16 +79,17 @@ var CMSKiteAnalytics = class {
79
79
  try {
80
80
  if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
81
81
  const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
82
- const blob = new Blob([body], { type: "application/json" });
82
+ const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
83
83
  if (navigator.sendBeacon(url, blob)) return;
84
84
  }
85
85
  } catch {
86
86
  }
87
87
  }
88
88
  try {
89
- void (this.fetchImpl ?? fetch)(this.endpoint, {
89
+ void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
90
90
  method: "POST",
91
- headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
91
+ // Safelisted, so this is not preflighted either. See the beacon above.
92
+ headers: { "content-type": "text/plain;charset=UTF-8" },
92
93
  body,
93
94
  // Survives the navigation that triggered it, like sendBeacon does.
94
95
  keepalive: true
@@ -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}\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 * 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 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-QXWHDCMJ.js';
2
- export { CMSKiteAnalytics } from './chunk-QXWHDCMJ.js';
1
+ import { CMSKiteAnalytics } from './chunk-FOFW3A46.js';
2
+ export { CMSKiteAnalytics } from './chunk-FOFW3A46.js';
3
3
  import './chunk-DT3V5CH2.js';
4
4
 
5
5
  // src/browser.ts
@@ -76,16 +76,17 @@ var CMSKiteAnalytics = class {
76
76
  try {
77
77
  if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
78
78
  const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
79
- const blob = new Blob([body], { type: "application/json" });
79
+ const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
80
80
  if (navigator.sendBeacon(url, blob)) return;
81
81
  }
82
82
  } catch {
83
83
  }
84
84
  }
85
85
  try {
86
- void (this.fetchImpl ?? fetch)(this.endpoint, {
86
+ void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
87
87
  method: "POST",
88
- headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
88
+ // Safelisted, so this is not preflighted either. See the beacon above.
89
+ headers: { "content-type": "text/plain;charset=UTF-8" },
89
90
  body,
90
91
  // Survives the navigation that triggered it, like sendBeacon does.
91
92
  keepalive: true
@@ -139,5 +140,5 @@ function nonce() {
139
140
  }
140
141
 
141
142
  export { CMSKiteAnalytics };
142
- //# sourceMappingURL=chunk-QXWHDCMJ.js.map
143
- //# sourceMappingURL=chunk-QXWHDCMJ.js.map
143
+ //# sourceMappingURL=chunk-FOFW3A46.js.map
144
+ //# sourceMappingURL=chunk-FOFW3A46.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/analytics.ts"],"names":[],"mappings":";;;AA0DA,IAAM,SAAA,GAAY,EAAA;AAClB,IAAM,cAAA,GAAiB,YAAA;AAEhB,IAAM,mBAAN,MAAuB;AAAA,EACX,QAAA;AAAA,EACA,MAAA;AAAA,EACA,OAAA;AAAA,EACA,eAAA;AAAA,EACA,SAAA;AAAA,EACT,QAAuB,EAAC;AAAA,EACxB,KAAA,GAA8C,IAAA;AAAA,EAC9C,SAAA,GAAY,KAAA;AAAA,EAEpB,YAAY,OAAA,EAAyB;AACnC,IAAA,IAAA,CAAK,SAAS,OAAA,CAAQ,MAAA;AACtB,IAAA,IAAA,CAAK,QAAA,GAAW,IAAI,OAAA,CAAQ,OAAA,IAAW,kBAAkB,OAAA,CAAQ,MAAA,EAAQ,EAAE,CAAC,CAAA,eAAA,CAAA;AAC5E,IAAA,IAAA,CAAK,OAAA,GAAU,QAAQ,OAAA,KAAY,KAAA;AACnC,IAAA,IAAA,CAAK,eAAA,GAAkB,QAAQ,eAAA,IAAmB,GAAA;AAClD,IAAA,IAAA,CAAK,YAAY,OAAA,CAAQ,KAAA;AACzB,IAAA,IAAA,CAAK,eAAA,EAAgB;AAAA,EACvB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,SAAA,CAAU,QAAgB,IAAA,EAAqB;AAC7C,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,IAAW,CAAC,MAAA,EAAQ;AAC9B,IAAA,IAAI,IAAA,CAAK,WAAA,CAAY,MAAM,CAAA,EAAG;AAC9B,IAAA,IAAA,CAAK,SAAS,MAAM,CAAA;AACpB,IAAA,IAAA,CAAK,IAAA,CAAK,EAAE,IAAA,EAAM,MAAA,EAAQ,QAAQ,GAAI,IAAA,GAAO,EAAE,IAAA,KAAS,EAAE,IAAA,EAAM,WAAA,EAAY,IAAM,CAAA;AAAA,EACpF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,UAAA,CAAW,MAAA,EAAgB,MAAA,EAAiB,IAAA,EAAqB;AAC/D,IAAA,IAAI,CAAC,IAAA,CAAK,OAAA,IAAW,CAAC,MAAA,EAAQ;AAC9B,IAAA,IAAA,CAAK,IAAA,CAAK;AAAA,MACR,IAAA,EAAM,OAAA;AAAA,MACN,MAAA;AAAA,MACA,GAAI,MAAA,GAAS,EAAE,MAAA,EAAQ,MAAA,CAAO,MAAM,CAAA,EAAG,GAAG,CAAA,EAAE,GAAI,EAAC;AAAA,MACjD,GAAI,OAAO,EAAE,IAAA,KAAS,EAAE,IAAA,EAAM,aAAY,EAAE;AAAA,MAC5C,OAAO,KAAA;AAAM,KACd,CAAA;AAAA,EACH;AAAA;AAAA,EAGA,KAAA,GAAc;AACZ,IAAA,IAAI,IAAA,CAAK,KAAA,CAAM,MAAA,KAAW,CAAA,EAAG;AAC7B,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,KAAA,CAAM,MAAA,CAAO,GAAG,SAAS,CAAA;AAC5C,IAAA,IAAA,CAAK,UAAA,EAAW;AAChB,IAAA,IAAA,CAAK,KAAK,KAAK,CAAA;AAAA,EACjB;AAAA;AAAA,EAGA,OAAA,GAAgB;AACd,IAAA,IAAA,CAAK,KAAA,EAAM;AACX,IAAA,IAAA,CAAK,UAAA,EAAW;AAAA,EAClB;AAAA;AAAA,EAIQ,KAAK,KAAA,EAA0B;AACrC,IAAA,IAAA,CAAK,KAAA,CAAM,KAAK,KAAK,CAAA;AACrB,IAAA,IAAI,KAAK,KAAA,CAAM,MAAA,IAAU,SAAA,EAAW,OAAO,KAAK,KAAA,EAAM;AACtD,IAAA,IAAI,CAAC,KAAK,KAAA,EAAO;AACf,MAAA,IAAA,CAAK,QAAQ,UAAA,CAAW,MAAM,KAAK,KAAA,EAAM,EAAG,KAAK,eAAe,CAAA;AAE/D,MAAC,IAAA,CAAK,MAA4C,KAAA,IAAQ;AAAA,IAC7D;AAAA,EACF;AAAA,EAEQ,KAAK,MAAA,EAA6B;AACxC,IAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,CAAU,EAAE,QAAQ,CAAA;AAsBtC,IAAA,IAAI,CAAC,KAAK,SAAA,EAAW;AACnB,MAAA,IAAI;AACF,QAAA,IAAI,OAAO,SAAA,KAAc,WAAA,IAAe,OAAO,SAAA,CAAU,eAAe,UAAA,EAAY;AAClF,UAAA,MAAM,GAAA,GAAM,GAAG,IAAA,CAAK,QAAQ,QAAQ,kBAAA,CAAmB,IAAA,CAAK,MAAM,CAAC,CAAA,CAAA;AACnE,UAAA,MAAM,IAAA,GAAO,IAAI,IAAA,CAAK,CAAC,IAAI,CAAA,EAAG,EAAE,IAAA,EAAM,0BAAA,EAA4B,CAAA;AAClE,UAAA,IAAI,SAAA,CAAU,UAAA,CAAW,GAAA,EAAK,IAAI,CAAA,EAAG;AAAA,QACvC;AAAA,MACF,CAAA,CAAA,MAAQ;AAAA,MAER;AAAA,IACF;AAEA,IAAA,IAAI;AACF,MAAA,KAAA,CAAM,IAAA,CAAK,SAAA,IAAa,KAAA,EAAO,CAAA,EAAG,IAAA,CAAK,QAAQ,CAAA,KAAA,EAAQ,kBAAA,CAAmB,IAAA,CAAK,MAAM,CAAC,CAAA,CAAA,EAAI;AAAA,QACxF,MAAA,EAAQ,MAAA;AAAA;AAAA,QAER,OAAA,EAAS,EAAE,cAAA,EAAgB,0BAAA,EAA2B;AAAA,QACtD,IAAA;AAAA;AAAA,QAEA,SAAA,EAAW;AAAA,OACZ,CAAA,CAAE,KAAA,CAAM,MAAM;AAAA,MAEf,CAAC,CAAA;AAAA,IACH,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUQ,YAAY,MAAA,EAAyB;AAC3C,IAAA,IAAI;AACF,MAAA,OAAO,cAAA,CAAe,OAAA,CAAQ,cAAA,GAAiB,MAAM,CAAA,KAAM,IAAA;AAAA,IAC7D,CAAA,CAAA,MAAQ;AACN,MAAA,OAAO,KAAA;AAAA,IACT;AAAA,EACF;AAAA,EAEQ,SAAS,MAAA,EAAsB;AACrC,IAAA,IAAI;AACF,MAAA,cAAA,CAAe,OAAA,CAAQ,cAAA,GAAiB,MAAA,EAAQ,GAAG,CAAA;AAAA,IACrD,CAAA,CAAA,MAAQ;AAAA,IAER;AAAA,EACF;AAAA,EAEQ,eAAA,GAAwB;AAC9B,IAAA,IAAI,IAAA,CAAK,SAAA,IAAa,OAAO,QAAA,KAAa,WAAA,EAAa;AACvD,IAAA,IAAA,CAAK,SAAA,GAAY,IAAA;AAOjB,IAAA,QAAA,CAAS,gBAAA,CAAiB,oBAAoB,MAAM;AAClD,MAAA,IAAI,QAAA,CAAS,eAAA,KAAoB,QAAA,EAAU,IAAA,CAAK,KAAA,EAAM;AAAA,IACxD,CAAC,CAAA;AAAA,EACH;AAAA,EAEQ,UAAA,GAAmB;AACzB,IAAA,IAAI,IAAA,CAAK,KAAA,EAAO,YAAA,CAAa,IAAA,CAAK,KAAK,CAAA;AACvC,IAAA,IAAA,CAAK,KAAA,GAAQ,IAAA;AAAA,EACf;AACF;AAEA,SAAS,WAAA,GAAsB;AAC7B,EAAA,IAAI;AACF,IAAA,OAAO,OAAO,QAAA,CAAS,QAAA;AAAA,EACzB,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,GAAA;AAAA,EACT;AACF;AAEA,SAAS,KAAA,GAAgB;AACvB,EAAA,OAAO,IAAA,CAAK,QAAO,CAAE,QAAA,CAAS,EAAE,CAAA,CAAE,KAAA,CAAM,GAAG,EAAE,CAAA;AAC/C","file":"chunk-FOFW3A46.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 * 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 nonce(): string {\n return Math.random().toString(36).slice(2, 10)\n}\n"]}
package/dist/index.d.cts CHANGED
@@ -34,7 +34,12 @@ interface Tag {
34
34
  interface Media {
35
35
  id: string;
36
36
  url: string;
37
- alt: string | null;
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
  }
package/dist/index.d.ts CHANGED
@@ -34,7 +34,12 @@ interface Tag {
34
34
  interface Media {
35
35
  id: string;
36
36
  url: string;
37
- alt: string | null;
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
  }
package/dist/react.cjs CHANGED
@@ -81,16 +81,17 @@ var CMSKiteAnalytics = class {
81
81
  try {
82
82
  if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
83
83
  const url = `${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`;
84
- const blob = new Blob([body], { type: "application/json" });
84
+ const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
85
85
  if (navigator.sendBeacon(url, blob)) return;
86
86
  }
87
87
  } catch {
88
88
  }
89
89
  }
90
90
  try {
91
- void (this.fetchImpl ?? fetch)(this.endpoint, {
91
+ void (this.fetchImpl ?? fetch)(`${this.endpoint}?key=${encodeURIComponent(this.apiKey)}`, {
92
92
  method: "POST",
93
- headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
93
+ // Safelisted, so this is not preflighted either. See the beacon above.
94
+ headers: { "content-type": "text/plain;charset=UTF-8" },
94
95
  body,
95
96
  // Survives the navigation that triggered it, like sendBeacon does.
96
97
  keepalive: true
@@ -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}\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 * 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 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
@@ -1,4 +1,4 @@
1
- import { CMSKiteAnalytics } from './chunk-QXWHDCMJ.js';
1
+ import { CMSKiteAnalytics } from './chunk-FOFW3A46.js';
2
2
  import './chunk-DT3V5CH2.js';
3
3
  import { useRef, useEffect, useMemo } from 'react';
4
4
 
package/package.json CHANGED
@@ -1,11 +1,21 @@
1
1
  {
2
2
  "name": "cmskite",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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": { "type": "git", "url": "https://github.com/devicorn/cmskite-sdk.git" },
8
- "keywords": ["cmskite", "cms", "headless-cms", "blog", "content-api", "analytics"],
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": ["dist", "README.md", "LICENSE"],
32
- "engines": { "node": ">=18" },
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,7 +57,9 @@
41
57
  "react": ">=18"
42
58
  },
43
59
  "peerDependenciesMeta": {
44
- "react": { "optional": true }
60
+ "react": {
61
+ "optional": true
62
+ }
45
63
  },
46
64
  "devDependencies": {
47
65
  "@types/node": "^24.10.1",
@@ -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"]}