cmskite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +462 -0
- package/dist/analytics-B0zzt0nW.d.cts +91 -0
- package/dist/analytics-B0zzt0nW.d.ts +91 -0
- package/dist/browser.cjs +152 -0
- package/dist/browser.cjs.map +1 -0
- package/dist/browser.d.cts +30 -0
- package/dist/browser.d.ts +30 -0
- package/dist/browser.js +12 -0
- package/dist/browser.js.map +1 -0
- package/dist/chunk-DT3V5CH2.js +145 -0
- package/dist/chunk-DT3V5CH2.js.map +1 -0
- package/dist/chunk-QXWHDCMJ.js +143 -0
- package/dist/chunk-QXWHDCMJ.js.map +1 -0
- package/dist/index.cjs +348 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +278 -0
- package/dist/index.d.ts +278 -0
- package/dist/index.js +204 -0
- package/dist/index.js.map +1 -0
- package/dist/react.cjs +175 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +45 -0
- package/dist/react.d.ts +45 -0
- package/dist/react.js +32 -0
- package/dist/react.js.map +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CMSKite
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
# cmskite
|
|
2
|
+
|
|
3
|
+
The official CMSKite client. Fetch your posts and measure who reads them.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install cmskite
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { createCMSKite } from 'cmskite'
|
|
11
|
+
|
|
12
|
+
const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
|
|
13
|
+
|
|
14
|
+
const { items } = await cms.posts.list({ limit: 10 })
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
That is the whole of getting started. Everything below is detail you can reach
|
|
18
|
+
for when you need it.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Contents
|
|
23
|
+
|
|
24
|
+
- [Initialization](#initialization)
|
|
25
|
+
- [Fetching posts](#fetching-posts)
|
|
26
|
+
- [Pagination](#pagination)
|
|
27
|
+
- [Filtering and sorting](#filtering-and-sorting)
|
|
28
|
+
- [Categories, tags and authors](#categories-tags-and-authors)
|
|
29
|
+
- [Search](#search)
|
|
30
|
+
- [Error handling](#error-handling)
|
|
31
|
+
- [Analytics](#analytics)
|
|
32
|
+
- [Next.js](#nextjs)
|
|
33
|
+
- [React](#react)
|
|
34
|
+
- [Node](#node)
|
|
35
|
+
- [Security: which key goes where](#security-which-key-goes-where)
|
|
36
|
+
- [Versioning and compatibility](#versioning-and-compatibility)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Initialization
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createCMSKite } from 'cmskite'
|
|
44
|
+
|
|
45
|
+
const cms = createCMSKite({
|
|
46
|
+
apiKey: process.env.CMSKITE_API_KEY!,
|
|
47
|
+
|
|
48
|
+
// Everything below is optional.
|
|
49
|
+
baseUrl: 'https://api.cmskite.com', // only for a self-hosted deployment
|
|
50
|
+
timeoutMs: 10_000, // per request
|
|
51
|
+
headers: { 'x-trace-id': traceId }, // sent on every request
|
|
52
|
+
fetch: myFetch, // a test double, or fetch with retries
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Create it once and reuse it. It holds no connection and no mutable state, so
|
|
57
|
+
module scope is the right place for it.
|
|
58
|
+
|
|
59
|
+
Your API key is on **Project → API keys** in the dashboard. A CMSKite key is
|
|
60
|
+
read-only and belongs to exactly one project; see
|
|
61
|
+
[Security](#security-which-key-goes-where) before you put one in a browser
|
|
62
|
+
bundle.
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Fetching posts
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
// A page of posts.
|
|
70
|
+
const { items, pagination } = await cms.posts.list({ limit: 10 })
|
|
71
|
+
|
|
72
|
+
// One post by id.
|
|
73
|
+
const post = await cms.posts.get('post_01a0c...')
|
|
74
|
+
|
|
75
|
+
// One post by its address — what a /blog/[slug] page wants.
|
|
76
|
+
const post = await cms.posts.getBySlug('building-better-apps')
|
|
77
|
+
|
|
78
|
+
// The same, but null instead of a throw when there is no such post.
|
|
79
|
+
const post = await cms.posts.findBySlug(slug)
|
|
80
|
+
if (!post) return notFound()
|
|
81
|
+
|
|
82
|
+
// Posts like this one.
|
|
83
|
+
const { items } = await cms.posts.related(post.id, { limit: 3 })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A list omits post bodies — twenty full articles is a payload nobody asked for —
|
|
87
|
+
and says so with `bodyOmitted: true`. Fetching one post always includes it.
|
|
88
|
+
|
|
89
|
+
### Old addresses keep working
|
|
90
|
+
|
|
91
|
+
If a post has been renamed, its old slug still resolves and the answer carries
|
|
92
|
+
`slugRedirectedFrom`. Use it to redirect rather than serving one post at two
|
|
93
|
+
addresses:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const post = await cms.posts.getBySlug(params.slug)
|
|
97
|
+
if (post.slugRedirectedFrom) redirect(`/blog/${post.slug}`)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
## Pagination
|
|
103
|
+
|
|
104
|
+
CMSKite paginates by cursor, not by page number — `?page=5000` on a large table
|
|
105
|
+
is a slow query anybody can issue. The cursor is opaque; pass it back and never
|
|
106
|
+
parse it.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
let cursor: string | undefined
|
|
110
|
+
do {
|
|
111
|
+
const page = await cms.posts.list({ limit: 50, cursor })
|
|
112
|
+
render(page.items)
|
|
113
|
+
cursor = page.pagination.nextCursor ?? undefined
|
|
114
|
+
} while (cursor)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or let the SDK write that loop for you. This is an async iterator, so a site
|
|
118
|
+
with four thousand posts never holds four thousand posts in memory:
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
for await (const post of cms.posts.all({ status: 'published' })) {
|
|
122
|
+
sitemap.add(`/blog/${post.slug}`)
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`pagination.total` is only present when you ask for it, because counting is not
|
|
127
|
+
free:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
const page = await cms.posts.list({ withTotal: true })
|
|
131
|
+
page.pagination.total // number
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Filtering and sorting
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
await cms.posts.list({
|
|
140
|
+
limit: 20,
|
|
141
|
+
status: 'published',
|
|
142
|
+
category: 'engineering', // slug or id
|
|
143
|
+
tag: 'databases', // slug or id
|
|
144
|
+
author: 'author_01a0c...',
|
|
145
|
+
search: 'postgres',
|
|
146
|
+
sort: '-publishedAt', // '-' is descending
|
|
147
|
+
publishedAfter: '2026-01-01T00:00:00Z',
|
|
148
|
+
})
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Omitted filters are left out of the request entirely, so you can pass an
|
|
152
|
+
optional value straight through without guarding it first.
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Categories, tags and authors
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
const { items: categories } = await cms.categories.list()
|
|
160
|
+
const { items: tags } = await cms.tags.list()
|
|
161
|
+
const { items: authors } = await cms.authors.list()
|
|
162
|
+
|
|
163
|
+
const category = await cms.categories.get('cat_01a0c...')
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
A category carries its full `path` — `engineering/databases` — so two
|
|
167
|
+
subcategories both called "Guides" can be told apart.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## Search
|
|
172
|
+
|
|
173
|
+
```ts
|
|
174
|
+
const { items } = await cms.posts.search('connection pooling', { limit: 10 })
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Searches titles and bodies. Search is a paid capability; on a plan without it
|
|
178
|
+
the call throws a `CMSKiteError` with `status: 403`.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## Error handling
|
|
183
|
+
|
|
184
|
+
Everything that can go wrong arrives as one type, so you write one `catch`.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
import { CMSKiteError } from 'cmskite'
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const post = await cms.posts.getBySlug(slug)
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (error instanceof CMSKiteError) {
|
|
193
|
+
error.status // 404, 401, 0 when nothing came back at all
|
|
194
|
+
error.code // 'NOT_FOUND', 'RATE_LIMITED', 'NETWORK', …
|
|
195
|
+
error.requestId // quote this when you ask us about it
|
|
196
|
+
error.isNotFound // convenience
|
|
197
|
+
error.isAuthError
|
|
198
|
+
error.isRetryable
|
|
199
|
+
}
|
|
200
|
+
throw error
|
|
201
|
+
}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
A dropped connection, a timeout and a proxy answering with HTML are all
|
|
205
|
+
`CMSKiteError` too — you will never see a bare `TypeError: Failed to fetch`
|
|
206
|
+
from this package.
|
|
207
|
+
|
|
208
|
+
**Retries.** The client retries once, and only what a retry can fix: a dropped
|
|
209
|
+
connection, a timeout, a 429, a 5xx. A 404 or a 401 is a settled answer, and
|
|
210
|
+
asking twice only delays showing somebody the truth.
|
|
211
|
+
|
|
212
|
+
**Cancellation.** Every method takes a signal:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
const controller = new AbortController()
|
|
216
|
+
const page = await cms.posts.list({ search }, { signal: controller.signal })
|
|
217
|
+
controller.abort() // e.g. a newer search superseded this one
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Analytics
|
|
223
|
+
|
|
224
|
+
CMSKite counts a view when a page reports one — not when the API is asked for a
|
|
225
|
+
post. Those are different numbers: a framework fetches every post at build
|
|
226
|
+
time, a cached page is fetched once and read ten thousand times, and a crawler
|
|
227
|
+
fetches each one exactly once.
|
|
228
|
+
|
|
229
|
+
So tracking lives in the browser:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { createTracker } from 'cmskite/browser'
|
|
233
|
+
|
|
234
|
+
const analytics = createTracker({ apiKey: PUBLIC_KEY })
|
|
235
|
+
|
|
236
|
+
analytics.trackView(post.id)
|
|
237
|
+
analytics.trackClick(post.id, 'https://example.com/pricing')
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**It cannot break your site.** Nothing here throws, nothing awaits a response,
|
|
241
|
+
and a failed request is swallowed. Your article renders whether or not our
|
|
242
|
+
analytics is having a good day.
|
|
243
|
+
|
|
244
|
+
**It cannot slow your site.** Events are queued and sent in one batch on a
|
|
245
|
+
timer, using `sendBeacon` where it exists — which hands the batch to the
|
|
246
|
+
browser and returns immediately, and still delivers after the page is closed.
|
|
247
|
+
|
|
248
|
+
**It will not double-count.** A view is remembered for the tab, so a refresh, a
|
|
249
|
+
re-render or a client-side navigation back to a post already read reports
|
|
250
|
+
nothing. The server deduplicates again by a deterministic id, because storage
|
|
251
|
+
can be cleared and a second tab has its own.
|
|
252
|
+
|
|
253
|
+
Clicks are not deduplicated: pressing the same link twice is two clicks.
|
|
254
|
+
|
|
255
|
+
Turn it off where you want to:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
createTracker({ apiKey: PUBLIC_KEY, enabled: process.env.NODE_ENV === 'production' })
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Supply your own `fetch` to see the requests in a test:
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
const tracker = createTracker({ apiKey: KEY, fetch: myFetch })
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### A view is a browser, not a script
|
|
268
|
+
|
|
269
|
+
Requests that announce themselves as automated are accepted and not counted —
|
|
270
|
+
`curl`, a crawler, a link-preview fetcher, a headless browser. That includes
|
|
271
|
+
Node's own `fetch`, whose user agent is `node`, so calling `trackView` from a
|
|
272
|
+
server records nothing. That is deliberate: a server fetching a post is not a
|
|
273
|
+
reader, which is the whole reason it is not counted as one.
|
|
274
|
+
|
|
275
|
+
### What is collected
|
|
276
|
+
|
|
277
|
+
No cookie, no browser storage identifier, no IP address and no user agent are
|
|
278
|
+
stored. A visitor is a salted hash that cannot be recomputed once the day's
|
|
279
|
+
salt rotates at midnight — including by us. The path is stored without its
|
|
280
|
+
query string, and a referrer is reduced to its host.
|
|
281
|
+
|
|
282
|
+
There is no identifier, so there is nothing for a visitor to consent to.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Next.js
|
|
287
|
+
|
|
288
|
+
Fetch on the server, track in the browser. That split is the whole integration.
|
|
289
|
+
|
|
290
|
+
**`app/blog/[slug]/page.tsx`**
|
|
291
|
+
|
|
292
|
+
```tsx
|
|
293
|
+
import { notFound } from 'next/navigation'
|
|
294
|
+
import { createCMSKite } from 'cmskite'
|
|
295
|
+
import { TrackView } from './track-view'
|
|
296
|
+
|
|
297
|
+
const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
|
|
298
|
+
|
|
299
|
+
export async function generateStaticParams() {
|
|
300
|
+
const params = []
|
|
301
|
+
for await (const post of cms.posts.all({ status: 'published' })) {
|
|
302
|
+
params.push({ slug: post.slug })
|
|
303
|
+
}
|
|
304
|
+
return params
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
|
308
|
+
const { slug } = await params
|
|
309
|
+
const post = await cms.posts.findBySlug(slug)
|
|
310
|
+
if (!post) notFound()
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<article>
|
|
314
|
+
<h1>{post.title}</h1>
|
|
315
|
+
<div dangerouslySetInnerHTML={{ __html: post.body }} />
|
|
316
|
+
<TrackView postId={post.id} />
|
|
317
|
+
</article>
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
**`app/blog/[slug]/track-view.tsx`**
|
|
323
|
+
|
|
324
|
+
```tsx
|
|
325
|
+
'use client'
|
|
326
|
+
|
|
327
|
+
import { useTrackView } from 'cmskite/react'
|
|
328
|
+
|
|
329
|
+
export function TrackView({ postId }: { postId: string }) {
|
|
330
|
+
useTrackView(postId, { apiKey: process.env.NEXT_PUBLIC_CMSKITE_KEY! })
|
|
331
|
+
return null
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Two keys, two `.env` entries, and they are not the same key — see
|
|
336
|
+
[Security](#security-which-key-goes-where).
|
|
337
|
+
|
|
338
|
+
```
|
|
339
|
+
CMSKITE_API_KEY=csk_live_… # server only
|
|
340
|
+
NEXT_PUBLIC_CMSKITE_KEY=csk_live_… # in the bundle, deliberately
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
**Revalidation.** The SDK uses `fetch`, so Next's cache options work. Pass them
|
|
344
|
+
through your own wrapper if you want ISR:
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
const cms = createCMSKite({
|
|
348
|
+
apiKey: process.env.CMSKITE_API_KEY!,
|
|
349
|
+
fetch: (url, init) => fetch(url, { ...init, next: { revalidate: 300 } }),
|
|
350
|
+
})
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## React
|
|
356
|
+
|
|
357
|
+
For a client-side app, fetch with whatever you already use for server state —
|
|
358
|
+
TanStack Query, SWR, your own hook. The SDK deliberately ships no data hook: a
|
|
359
|
+
`usePosts` here would be a worse query library wearing our name.
|
|
360
|
+
|
|
361
|
+
What it does ship is view tracking, because getting that right means knowing
|
|
362
|
+
when a component mounted, surviving React's double-invoked effects in
|
|
363
|
+
development, and not re-reporting on every render.
|
|
364
|
+
|
|
365
|
+
```tsx
|
|
366
|
+
'use client'
|
|
367
|
+
|
|
368
|
+
import { useCMSKiteAnalytics, useTrackView } from 'cmskite/react'
|
|
369
|
+
|
|
370
|
+
export function Article({ post }) {
|
|
371
|
+
useTrackView(post.id, { apiKey: PUBLIC_KEY })
|
|
372
|
+
const analytics = useCMSKiteAnalytics({ apiKey: PUBLIC_KEY })
|
|
373
|
+
|
|
374
|
+
return (
|
|
375
|
+
<article>
|
|
376
|
+
<h1>{post.title}</h1>
|
|
377
|
+
<a href={cta} onClick={() => analytics.trackClick(post.id, cta)}>Read more</a>
|
|
378
|
+
</article>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Passing a new `postId` reports the new post, which is what makes client-side
|
|
384
|
+
navigation between two articles count as two views with no router integration.
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
## Node
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
import { createCMSKite } from 'cmskite'
|
|
392
|
+
|
|
393
|
+
const cms = createCMSKite({ apiKey: process.env.CMSKITE_API_KEY! })
|
|
394
|
+
|
|
395
|
+
const { items } = await cms.posts.list({ limit: 100 })
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Node 18 or newer, for built-in `fetch` and `AbortSignal.any`. ESM and CommonJS
|
|
399
|
+
both work:
|
|
400
|
+
|
|
401
|
+
```js
|
|
402
|
+
const { createCMSKite } = require('cmskite')
|
|
403
|
+
```
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## Security: which key goes where
|
|
408
|
+
|
|
409
|
+
CMSKite has one kind of key and it is **read-only**. The only scope a key can
|
|
410
|
+
hold is `blog:read`: it returns published content and can write, change or
|
|
411
|
+
delete nothing. There is no secret credential in this SDK, because there is
|
|
412
|
+
none to have.
|
|
413
|
+
|
|
414
|
+
That makes a key safe in a browser bundle, with one thing to do first:
|
|
415
|
+
|
|
416
|
+
**Restrict the origins.** Project → Settings → Allowed origins. List the sites
|
|
417
|
+
that may use the key. Until you do, any page anywhere can read your published
|
|
418
|
+
content with it — which is content you are already publishing, but it is still
|
|
419
|
+
your bandwidth.
|
|
420
|
+
|
|
421
|
+
**Use two keys anyway.** One for your server, one for the browser. Not because
|
|
422
|
+
the browser one is weaker, but because you can revoke the exposed one without
|
|
423
|
+
taking your build down.
|
|
424
|
+
|
|
425
|
+
| | Server key | Browser key |
|
|
426
|
+
|---|---|---|
|
|
427
|
+
| Where | `CMSKITE_API_KEY` | `NEXT_PUBLIC_CMSKITE_KEY` |
|
|
428
|
+
| Used by | `createCMSKite` | `createTracker` |
|
|
429
|
+
| Origin allowlist | not needed | **set it** |
|
|
430
|
+
|
|
431
|
+
Never put a dashboard session token or an agent token in a browser. Those act
|
|
432
|
+
for a person; an API key acts for a project.
|
|
433
|
+
|
|
434
|
+
---
|
|
435
|
+
|
|
436
|
+
## Versioning and compatibility
|
|
437
|
+
|
|
438
|
+
The package follows semantic versioning, and the guarantee is about what you
|
|
439
|
+
import rather than about what we run.
|
|
440
|
+
|
|
441
|
+
- **Patch** — fixes. Nothing you wrote changes.
|
|
442
|
+
- **Minor** — new methods, new optional fields on a response. Existing code
|
|
443
|
+
keeps working, and a field you already read keeps meaning what it meant.
|
|
444
|
+
- **Major** — something you import changed shape. Rare, and it will come with a
|
|
445
|
+
migration note.
|
|
446
|
+
|
|
447
|
+
The API is versioned separately at `/v1`. The SDK pins to it, so an API change
|
|
448
|
+
reaches you as an SDK release you choose to take rather than as your site
|
|
449
|
+
breaking on a Tuesday.
|
|
450
|
+
|
|
451
|
+
Anything not exported from `cmskite`, `cmskite/browser` or
|
|
452
|
+
`cmskite/react` is internal, including the transport and the response
|
|
453
|
+
envelope. Those are the parts we expect to change.
|
|
454
|
+
|
|
455
|
+
Response types describe the API, not the database. A column renamed behind the
|
|
456
|
+
scenes is not your problem.
|
|
457
|
+
|
|
458
|
+
---
|
|
459
|
+
|
|
460
|
+
## License
|
|
461
|
+
|
|
462
|
+
MIT
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting what a reader did, without ever being able to break the page.
|
|
3
|
+
*
|
|
4
|
+
* Three properties, and every decision below follows from them:
|
|
5
|
+
*
|
|
6
|
+
* It cannot throw. Not on a network failure, not on a 500, not if the key is
|
|
7
|
+
* wrong. A customer's article must render whether or not our analytics is
|
|
8
|
+
* having a good day, so every path here ends in a swallowed error.
|
|
9
|
+
*
|
|
10
|
+
* It cannot block. Events are queued and flushed on a timer, and the flush
|
|
11
|
+
* uses `sendBeacon` where it exists -- which hands the batch to the browser
|
|
12
|
+
* and returns immediately, and which still delivers after the page has been
|
|
13
|
+
* closed. Nothing awaits a response, because there is nothing in the
|
|
14
|
+
* response.
|
|
15
|
+
*
|
|
16
|
+
* It cannot double-count. A view is remembered in `sessionStorage`, so a
|
|
17
|
+
* refresh, a re-render and a client-side navigation back to a post already
|
|
18
|
+
* read report nothing at all. The server deduplicates again by a
|
|
19
|
+
* deterministic id, because storage can be cleared and a second tab has its
|
|
20
|
+
* own.
|
|
21
|
+
*/
|
|
22
|
+
interface TrackerOptions {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Off switch. `false` queues nothing and sends nothing.
|
|
27
|
+
*
|
|
28
|
+
* For a development build, or for a site that asks first and only turns this
|
|
29
|
+
* on afterwards.
|
|
30
|
+
*/
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
/** How long to hold events before sending. Default 1000ms. */
|
|
33
|
+
flushIntervalMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Supply your own, for a test.
|
|
36
|
+
*
|
|
37
|
+
* The main client has taken one since it was written, and this did not --
|
|
38
|
+
* which made the tracker the one part of the SDK that could not be exercised
|
|
39
|
+
* outside a browser. It was verified by asserting that it did not throw,
|
|
40
|
+
* which is a test that passes while nothing at all is recorded, and that is
|
|
41
|
+
* exactly what happened the first time it was run end to end.
|
|
42
|
+
*/
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
}
|
|
45
|
+
type EventType = 'view' | 'click';
|
|
46
|
+
declare class CMSKiteAnalytics {
|
|
47
|
+
private readonly endpoint;
|
|
48
|
+
private readonly apiKey;
|
|
49
|
+
private readonly enabled;
|
|
50
|
+
private readonly flushIntervalMs;
|
|
51
|
+
private readonly fetchImpl;
|
|
52
|
+
private queue;
|
|
53
|
+
private timer;
|
|
54
|
+
private listening;
|
|
55
|
+
constructor(options: TrackerOptions);
|
|
56
|
+
/**
|
|
57
|
+
* One reader seeing one post.
|
|
58
|
+
*
|
|
59
|
+
* Call it when the post is rendered. Calling it again for the same post in
|
|
60
|
+
* the same tab does nothing, which is what makes it safe to put in a React
|
|
61
|
+
* effect that runs on every render.
|
|
62
|
+
*/
|
|
63
|
+
trackView(postId: string, path?: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* A link press.
|
|
66
|
+
*
|
|
67
|
+
* Not deduplicated: pressing the same link twice is two clicks, and the nonce
|
|
68
|
+
* is what tells the server so.
|
|
69
|
+
*/
|
|
70
|
+
trackClick(postId: string, target?: string, path?: string): void;
|
|
71
|
+
/** Sends whatever is queued now. Called for you on page hide. */
|
|
72
|
+
flush(): void;
|
|
73
|
+
/** Stops the timer and the listeners. For a test, or a single-page teardown. */
|
|
74
|
+
destroy(): void;
|
|
75
|
+
private push;
|
|
76
|
+
private send;
|
|
77
|
+
/**
|
|
78
|
+
* What this tab has already reported.
|
|
79
|
+
*
|
|
80
|
+
* `sessionStorage`, not `localStorage`: a view should be counted again
|
|
81
|
+
* tomorrow, and a session is the unit the server deduplicates on too. A
|
|
82
|
+
* browser that refuses storage -- private mode, blocked site data -- falls
|
|
83
|
+
* back to counting the view, which is the right way to be wrong.
|
|
84
|
+
*/
|
|
85
|
+
private alreadySeen;
|
|
86
|
+
private remember;
|
|
87
|
+
private listenForUnload;
|
|
88
|
+
private clearTimer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { CMSKiteAnalytics as C, type EventType as E, type TrackerOptions as T };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reporting what a reader did, without ever being able to break the page.
|
|
3
|
+
*
|
|
4
|
+
* Three properties, and every decision below follows from them:
|
|
5
|
+
*
|
|
6
|
+
* It cannot throw. Not on a network failure, not on a 500, not if the key is
|
|
7
|
+
* wrong. A customer's article must render whether or not our analytics is
|
|
8
|
+
* having a good day, so every path here ends in a swallowed error.
|
|
9
|
+
*
|
|
10
|
+
* It cannot block. Events are queued and flushed on a timer, and the flush
|
|
11
|
+
* uses `sendBeacon` where it exists -- which hands the batch to the browser
|
|
12
|
+
* and returns immediately, and which still delivers after the page has been
|
|
13
|
+
* closed. Nothing awaits a response, because there is nothing in the
|
|
14
|
+
* response.
|
|
15
|
+
*
|
|
16
|
+
* It cannot double-count. A view is remembered in `sessionStorage`, so a
|
|
17
|
+
* refresh, a re-render and a client-side navigation back to a post already
|
|
18
|
+
* read report nothing at all. The server deduplicates again by a
|
|
19
|
+
* deterministic id, because storage can be cleared and a second tab has its
|
|
20
|
+
* own.
|
|
21
|
+
*/
|
|
22
|
+
interface TrackerOptions {
|
|
23
|
+
apiKey: string;
|
|
24
|
+
baseUrl?: string;
|
|
25
|
+
/**
|
|
26
|
+
* Off switch. `false` queues nothing and sends nothing.
|
|
27
|
+
*
|
|
28
|
+
* For a development build, or for a site that asks first and only turns this
|
|
29
|
+
* on afterwards.
|
|
30
|
+
*/
|
|
31
|
+
enabled?: boolean;
|
|
32
|
+
/** How long to hold events before sending. Default 1000ms. */
|
|
33
|
+
flushIntervalMs?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Supply your own, for a test.
|
|
36
|
+
*
|
|
37
|
+
* The main client has taken one since it was written, and this did not --
|
|
38
|
+
* which made the tracker the one part of the SDK that could not be exercised
|
|
39
|
+
* outside a browser. It was verified by asserting that it did not throw,
|
|
40
|
+
* which is a test that passes while nothing at all is recorded, and that is
|
|
41
|
+
* exactly what happened the first time it was run end to end.
|
|
42
|
+
*/
|
|
43
|
+
fetch?: typeof globalThis.fetch;
|
|
44
|
+
}
|
|
45
|
+
type EventType = 'view' | 'click';
|
|
46
|
+
declare class CMSKiteAnalytics {
|
|
47
|
+
private readonly endpoint;
|
|
48
|
+
private readonly apiKey;
|
|
49
|
+
private readonly enabled;
|
|
50
|
+
private readonly flushIntervalMs;
|
|
51
|
+
private readonly fetchImpl;
|
|
52
|
+
private queue;
|
|
53
|
+
private timer;
|
|
54
|
+
private listening;
|
|
55
|
+
constructor(options: TrackerOptions);
|
|
56
|
+
/**
|
|
57
|
+
* One reader seeing one post.
|
|
58
|
+
*
|
|
59
|
+
* Call it when the post is rendered. Calling it again for the same post in
|
|
60
|
+
* the same tab does nothing, which is what makes it safe to put in a React
|
|
61
|
+
* effect that runs on every render.
|
|
62
|
+
*/
|
|
63
|
+
trackView(postId: string, path?: string): void;
|
|
64
|
+
/**
|
|
65
|
+
* A link press.
|
|
66
|
+
*
|
|
67
|
+
* Not deduplicated: pressing the same link twice is two clicks, and the nonce
|
|
68
|
+
* is what tells the server so.
|
|
69
|
+
*/
|
|
70
|
+
trackClick(postId: string, target?: string, path?: string): void;
|
|
71
|
+
/** Sends whatever is queued now. Called for you on page hide. */
|
|
72
|
+
flush(): void;
|
|
73
|
+
/** Stops the timer and the listeners. For a test, or a single-page teardown. */
|
|
74
|
+
destroy(): void;
|
|
75
|
+
private push;
|
|
76
|
+
private send;
|
|
77
|
+
/**
|
|
78
|
+
* What this tab has already reported.
|
|
79
|
+
*
|
|
80
|
+
* `sessionStorage`, not `localStorage`: a view should be counted again
|
|
81
|
+
* tomorrow, and a session is the unit the server deduplicates on too. A
|
|
82
|
+
* browser that refuses storage -- private mode, blocked site data -- falls
|
|
83
|
+
* back to counting the view, which is the right way to be wrong.
|
|
84
|
+
*/
|
|
85
|
+
private alreadySeen;
|
|
86
|
+
private remember;
|
|
87
|
+
private listenForUnload;
|
|
88
|
+
private clearTimer;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export { CMSKiteAnalytics as C, type EventType as E, type TrackerOptions as T };
|