@mrrocket/sdk 0.3.0 → 0.8.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/CHANGELOG.md +39 -0
- package/README.md +240 -5
- package/dist/api.d.ts +23 -3
- package/dist/api.js +7 -3
- package/dist/api.js.map +1 -1
- package/dist/batch.d.ts +33 -0
- package/dist/batch.js +50 -0
- package/dist/batch.js.map +1 -0
- package/dist/browser/mrrocket-flows.js +64 -0
- package/dist/browser/mrrocket-push.js +1 -0
- package/dist/browser/mrrocket-replay.js +78 -0
- package/dist/browser/mrrocket-sw.js +1 -0
- package/dist/browser/mrrocket.js +2 -0
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +3 -1
- package/dist/browser.js.map +1 -1
- package/dist/client.d.ts +36 -1
- package/dist/client.js +163 -83
- package/dist/client.js.map +1 -1
- package/dist/context.d.ts +10 -0
- package/dist/context.js.map +1 -1
- package/dist/core.d.ts +91 -4
- package/dist/core.js +83 -6
- package/dist/core.js.map +1 -1
- package/dist/feedback.d.ts +19 -4
- package/dist/feedback.js +652 -190
- package/dist/feedback.js.map +1 -1
- package/dist/flows-ui.d.ts +23 -0
- package/dist/flows-ui.js +384 -0
- package/dist/flows-ui.js.map +1 -0
- package/dist/flows-web.d.ts +19 -0
- package/dist/flows-web.js +42 -0
- package/dist/flows-web.js.map +1 -0
- package/dist/flows.d.ts +222 -0
- package/dist/flows.js +409 -0
- package/dist/flows.js.map +1 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +34 -1
- package/dist/proxy.js +14 -1
- package/dist/proxy.js.map +1 -1
- package/dist/push-web.d.ts +43 -0
- package/dist/push-web.js +81 -0
- package/dist/push-web.js.map +1 -0
- package/dist/react-native/index.d.cts +561 -0
- package/dist/react-native/index.d.ts +561 -0
- package/dist/react-native/index.js +1743 -0
- package/dist/react-native/index.js.map +1 -0
- package/dist/react-native/package.json +3 -0
- package/dist/referral.d.ts +6 -0
- package/dist/referral.js +7 -3
- package/dist/referral.js.map +1 -1
- package/dist/styles.d.ts +1 -1
- package/dist/styles.js +127 -24
- package/dist/styles.js.map +1 -1
- package/dist/ui.d.ts +3 -0
- package/dist/ui.js +12 -0
- package/dist/ui.js.map +1 -1
- package/dist/web-state.d.ts +36 -0
- package/dist/web-state.js +64 -0
- package/dist/web-state.js.map +1 -0
- package/package.json +64 -6
- package/react-native/package.json +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this package. It follows semantic versioning; before 1.0 a minor version may change an API.
|
|
4
4
|
|
|
5
|
+
## 0.8.0
|
|
6
|
+
|
|
7
|
+
- **Texts in React Native:** `useText(id, fallback)` and `client.getText(id, fallback)` / `client.peekText(id)` / `client.exposeText(id)` return the copy published in MRRocket for `id` with the person's variant of a running text test on top, the same rules as `<Text id>` on the web (`textChanges`). Paywall copy (`paywall.<placement>.headline`, `.body`, `.cta`) and text A/B tests now work in apps; showing a text records `$exposure` for every test that changes it, once per screen. Works from the cached config on a cold start.
|
|
8
|
+
- Version reported in event batches: `0.8.0`.
|
|
9
|
+
|
|
10
|
+
## 0.7.0
|
|
11
|
+
|
|
12
|
+
- **Cancel flow:** `useCancelFlow().open({ customer, price, onCancel, onSave, onClose })` (Next.js and React Native), `mrrocket.cancelFlow.open(…)` (script tag) show the flow built in MRRocket › Churn › Cancel flow from the config's new `cancelFlow`: business customers get the survey and the offer for their answer (discount, pause, downgrade, talk to us), anyone else the plain confirmation (§ 312k BGB); without a live flow the plain confirmation, so the Cancel button always works. Resolves with `{ outcome: "cancelled" | "saved" | "kept", reason, offer, comment, run }`. MRRocket never changes a subscription: your `onCancel` / `onSave` do, and only after they succeed is the outcome recorded (`$cancel_flow`); MRRocket shows it under Churn as a cancellation or a save.
|
|
13
|
+
- **Onboarding:** the tour, checklist and modal steps of live onboarding flows (config `onboarding`) start on their own when the signed-in person (or a visitor with `consent="cookies"`) does the trigger (`track("Signed up")`, the first page for "First launch"), once per person; `startOnboarding()` starts one by hand, `onboarding={false}` switches the automatic start off. Tour steps point at `data-ck-target` elements on the web; checklist items tick themselves on their event. Progress comes back from MRRocket for signed-in people (`GET /api/sdk/inapp`) and records as `$onboarding`.
|
|
14
|
+
- **In-app messages:** journey steps of the kind "In-app message" show to the signed-in person as a banner or a dialog (`$inapp` shown, clicked, dismissed); the journey moves them on.
|
|
15
|
+
- **Look:** `appearance={{ theme, accent, className }}`, CSS variables `--ckf-*` on `.ckf-root`; script tag `data-theme`, `data-accent`. The web dialogs load lazily (a dynamic import; `sdk-flows.js` for the script tag).
|
|
16
|
+
- React Native: `MRRocketProvider` draws them (`flows={false}` to opt out), new `useCancelFlow`, `useOnboarding`, `MRRocketFlows`, `client.openCancelFlow`, `client.startOnboarding`, `client.flows`.
|
|
17
|
+
- **Push notifications** for Lifecycle journeys' push steps. React Native and Expo: `usePush().register()` / `client.registerForPush({ ask, projectId })` after `identify` asks the permission, gets the Expo push token (`expo-notifications`, new optional peer dependency with `expo-constants`) and sends it to `POST /api/sdk/push`; `reset()` takes the token back; opens of MRRocket notifications record `$push {action: "opened"}` and open the step's link when it has a scheme. Web: `usePush().subscribe()` (Next.js), `mrrocket.push.subscribe()` (script tag) asks the permission from the click, registers the service worker the site hosts (`/mrrocket-sw.js`, built as `dist/browser/mrrocket-sw.js`; `serviceWorker: false` for sites that import it into their own) and sends the subscription made with the workspace's VAPID key (`GET /api/sdk/push`); `unsubscribe()` on sign-out. The sign-up code loads lazily (a dynamic import; `sdk-push.js` for the script tag).
|
|
18
|
+
- **Redirects:** the config's `redirects` (page redirects) and redirect tests (an experiment with `path`, variants with `url`) send the visitor on before the page shows, keeping the query and hash; a running test adds `ck_x=<test>:<variant>`, and the page it lands on records the exposure and drops the parameter. New `redirectFor`, `exposureFrom`, `EXPOSURE_PARAM`; `experimentRedirect(request, config, { distinctId })` and a re-exported `getCockpitConfig` in `@mrrocket/sdk/next/proxy` redirect in `proxy.ts` before the page renders. The script tag removes the `ck-hide` class from `<html>` once it decided (the optional anti-flicker snippet).
|
|
19
|
+
- Version reported in event batches: `0.7.0`.
|
|
20
|
+
|
|
21
|
+
## 0.6.0
|
|
22
|
+
|
|
23
|
+
- **Feedback board after Featurebase:** `<FeedbackBoard>` gets a top bar with your product's logo and name (from MRRocket), Feedback · Roadmap · Changelog, search, a light/dark toggle and "Submit feedback"; post cards with type chip, excerpt, author and votes; the Roadmap has four columns, Backlog (under consideration), Next up, In progress and Done; a post page with comments, upvote and status; Changelog with dates beside the entries; the submit form and dialogs rise as a sheet on phones, the roadmap scrolls column by column. New props `header` (default true; `false` keeps the old plain heading), `syncUrl` (`?view=` and `?post=` in the address, so a post can be linked), `fullPage` and `initialTab`.
|
|
24
|
+
- **Who posts:** a visitor gives a name and email once before posting, voting or commenting; the board keeps them for this site in localStorage (`ckw-board-who:<key>`) so their votes stay theirs. A visitor your app called `identify()` for with a name or email is not asked. Others see "First L.". The floating widget asks the same way.
|
|
25
|
+
- API: posts carry `author`; the list carries `product` ({ name, logo }); the roadmap carries a `review` column; `createApi(...).post(id)` reads one post. Older servers still work (the new fields are optional).
|
|
26
|
+
- Fix: primary buttons, the floating button and tabs keep their own colors on pages whose CSS styles every `button` (the widget's reset no longer outranks its own classes).
|
|
27
|
+
|
|
28
|
+
## 0.5.0
|
|
29
|
+
|
|
30
|
+
- **Published texts:** the config carries `texts`, the copy published in MRRocket for texts the site marks: a landing page's headline, subheadline and button (`<Text id="pricing.headline">`, `data-ck-text="pricing.headline"`; `/` is `home`, `/features/ai` is `features-ai`) and a paywall's (`paywall.<placement>.headline`, `.body`, `.cta`). `textChanges` (and with it `<Text>`, `getTexts` and the script tag) applies them first and a running test's variant on top; without them the page keeps its own words. Older versions ignore the field.
|
|
31
|
+
- **Experiments are served:** MRRocket's config now lists the product's running tests with their variants (`control`, `a`, `b` …) and splits, and a shipped winner for everyone; nothing changes in how a visitor is assigned (a stable hash of test and visitor id).
|
|
32
|
+
- `<Text id>` records the exposure of every running test that changes that text, as the script tag already did, so a test that only swaps texts is counted without `useVariant`. New export `experimentsOfText`.
|
|
33
|
+
- Version reported in event batches: `0.5.0`.
|
|
34
|
+
|
|
35
|
+
## 0.4.0
|
|
36
|
+
|
|
37
|
+
- **React Native and Expo:** new entry `@mrrocket/sdk/react-native` (one CommonJS file for Metro, no DOM, no `"use client"`, no rrweb). `MRRocketProvider`, `useMRRocket`, `useFlag`, `useFlagVariant`, `useVariant`, `createMRRocket` / `getMRRocket` outside React; `track`, `identify`, `reset`, `screen`, `setConsent`, `captureError`, `flush`. Screen views through `useExpoRouterTracking()` (Expo Router) or `useNavigationTracking()` (React Navigation `onStateChange`). Lifecycle events `$app_open` (with `cold`) and `$app_background`; a new session after 30 minutes in the background. Events are batched, sent every 10 s and when the app goes to the background, and kept on the device while offline (AsyncStorage when installed, else memory; at most 1,000, oldest dropped; retries with backoff). Consent `"anonymous"` (default: no id stored), `"persistent"` (a random install id stored on the device) or `"off"` (nothing sent, stored data deleted). Every event carries the app and device (`platform`, `appVersion`, `build`, `osVersion`, `deviceModel`, `deviceType`, `locale`) from `expo-application`, `expo-device` and `expo-localization` when installed. Uncaught JavaScript errors (through `ErrorUtils`, the app's own handler still runs) and `captureError` go to Build › Errors with the app version as release. The config for flags and experiments is cached, so a cold start shows the same variant as the last run. Install attribution: UTM and referral parameters of the deep link that opened the app the first time, sent once with `$install`; no IDFA, no MMP.
|
|
38
|
+
- **Script tag** for websites without npm (Webflow, Framer, WordPress, Shopify, plain HTML): `<script async src="https://app.mrrocket.dev/sdk.js" data-key="mrr_pub_…"></script>`, built from the same sources into `dist/browser/mrrocket.js` (about 8 KB gzip) and served by MRRocket at `/sdk.js`. It does what the Next.js provider does on the client: page views including History API navigation, `data-ck-goal` clicks, heatmap clicks and scroll depth, UTM and referrer, affiliate referrals, error tracking, feature flags, experiments on texts marked `data-ck-text`, and session recordings (the recorder, `sdk-replay.js`, about 26 KB gzip, loads only when a visit records). `window.mrrocket` takes `track`, `identify`, `reset`, `captureError`, `setConsent`, `getFlag`, `getFlagVariant`, `getVariant`, `ready`; calls made before it loaded wait in a queue stub. Settings as data attributes: `data-api-host`, `data-consent`, `data-replay`, `data-respect-dnt`, `data-heatmaps`, `data-errors`, `data-release`, `data-attribution`, `data-referral-param`.
|
|
39
|
+
- Shared code: batching (`batch.ts`) and the browser's visitor and session state (`web-state.ts`) moved out of the Next.js provider so the script tag uses the same code. The Next.js entry behaves as in 0.3.0.
|
|
40
|
+
- Peers: `next` and `react-dom` are optional now (an app needs neither); `react-native`, `expo-router`, `expo-device`, `expo-application`, `expo-localization` and `@react-native-async-storage/async-storage` are optional peers. `react` from 18.
|
|
41
|
+
- Event batches carry `sdk.platform` ("web", "ios" or "android"); MRRocket stores an app's device context with each event.
|
|
42
|
+
- Not in this version (coming later): session recordings and heatmaps in apps, the feedback and survey UI in apps, native modules (store install referrer, SKAdNetwork). `<Text>` and `<Experiment>` stay Next.js-only; the script tag swaps texts through `data-ck-text`.
|
|
43
|
+
|
|
5
44
|
## 0.3.0
|
|
6
45
|
|
|
7
46
|
- **Error tracking** for MRRocket › Build › Errors, on by default (`errors` prop): uncaught errors and unhandled promise rejections, with the page URL and the last 20 pages and clicks, go to `POST /api/sdk/errors` in batches every 2 s (`sendBeacon` on page hide), under the normal analytics consent and never from a preview. The same error once per page load, at most 20 per page load. `useCockpit().captureError(error)` reports one you caught; the `release` prop names your release. Independent of session recordings.
|
package/README.md
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
# @mrrocket/sdk
|
|
2
2
|
|
|
3
|
-
The MRRocket SDK
|
|
3
|
+
The MRRocket SDK, one package with three ways in:
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
| Where | Import | Section |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Next.js (App Router) | `@mrrocket/sdk/next` | 1 to 12 |
|
|
8
|
+
| Any website without npm (Webflow, Framer, WordPress, Shopify, plain HTML) | `<script src="…/sdk.js">` | 13 |
|
|
9
|
+
| React Native and Expo apps | `@mrrocket/sdk/react-native` | 14 |
|
|
10
|
+
|
|
11
|
+
One provider in your layout (or one script tag) gives MRRocket page views, your events, A/B tests, feature flags and errors that the agents can run and read. MRRocket shows every variant of your real pages, because the SDK can render any variant on request.
|
|
12
|
+
|
|
13
|
+
Status: 0.8.0, MIT licensed, compiled to ESM JavaScript with type declarations (no `transpilePackages`); the React Native entry is CommonJS for Metro. Works with Next.js 15 and 16, React 18 and 19, on Node and on Cloudflare Workers (OpenNext), and with React Native 0.72+ / Expo. The source lives in the MRRocket repo (`prototype/web/packages/cockpit-next`).
|
|
6
14
|
|
|
7
15
|
## Install
|
|
8
16
|
|
|
@@ -133,18 +141,18 @@ MRRocket shows each variant of your page by loading it with `?ck_preview=<experi
|
|
|
133
141
|
|
|
134
142
|
## 7. Feedback board
|
|
135
143
|
|
|
136
|
-
A
|
|
144
|
+
A Featurebase-style board for your own `/feedback` page: your product's logo and name, requests with votes and comments, a roadmap (Backlog, Next up, In progress, Done), a changelog, search and a light/dark toggle. A duplicate hint shows similar requests while visitors type.
|
|
137
145
|
|
|
138
146
|
```tsx
|
|
139
147
|
// app/feedback/page.tsx
|
|
140
148
|
import { FeedbackBoard } from "@mrrocket/sdk/next";
|
|
141
149
|
|
|
142
150
|
export default function Page() {
|
|
143
|
-
return <FeedbackBoard
|
|
151
|
+
return <FeedbackBoard theme="auto" accent="#3f76ff" syncUrl />;
|
|
144
152
|
}
|
|
145
153
|
```
|
|
146
154
|
|
|
147
|
-
It must sit inside `CockpitProvider`.
|
|
155
|
+
It must sit inside `CockpitProvider`. Visitors give a name and email once before they post, vote or comment (kept in their browser); call `identify(user.id, { name, email })` and signed-in users are not asked. `syncUrl` keeps the tab and the open post in the address (`?view=roadmap`, `?post=…`), `fullPage` fills the window, `header={false}` shows a plain heading instead of the top bar. MRRocket also hosts the same board at `/b/<productId>` (Feedback › Public board).
|
|
148
156
|
|
|
149
157
|
## 8. Feedback widget
|
|
150
158
|
|
|
@@ -232,6 +240,231 @@ The recorder (about 26 KB gzip with rrweb) loads only when a visit records: prod
|
|
|
232
240
|
|
|
233
241
|
MRRocket keeps recordings 30 days by default (at most 90), in the EU. Add the recording paragraph to your privacy policy; Sessions' "Copy for AI" prompt adds the banner choice, the wiring and the paragraph for you.
|
|
234
242
|
|
|
243
|
+
## 13. Script tag (any website)
|
|
244
|
+
|
|
245
|
+
For sites without npm. Paste into the `<head>` of every page (MRRocket › Settings › Products › Tracking shows it with your key filled in):
|
|
246
|
+
|
|
247
|
+
```html
|
|
248
|
+
<script async src="https://app.mrrocket.dev/sdk.js" data-key="mrr_pub_…"></script>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Where it goes: Webflow: Site settings › Custom code › Head. Framer: Site settings › General › Custom code. WordPress: the theme's header or a header-code plugin. Shopify: `theme.liquid`, before `</head>`.
|
|
252
|
+
|
|
253
|
+
On its own it sends page views (also client-side navigation through the History API), `data-ck-goal` clicks (2), heatmap clicks and scroll depth (11), UTM and referrer, affiliate referrals (10), uncaught errors, and loads flags and experiments. Calls go to `window.mrrocket`:
|
|
254
|
+
|
|
255
|
+
```js
|
|
256
|
+
mrrocket.track("signup", { plan: "team" });
|
|
257
|
+
mrrocket.identify(user.id, { email: user.email, plan: "pro" });
|
|
258
|
+
mrrocket.reset(); // on sign-out
|
|
259
|
+
mrrocket.captureError(error);
|
|
260
|
+
mrrocket.setConsent("cookies"); // "cookieless" | "cookies" | "off" | { analytics?, replay? }
|
|
261
|
+
mrrocket.ready(function (m) {
|
|
262
|
+
if (m.getFlag("new-checkout")) showNewCheckout();
|
|
263
|
+
var v = m.getVariant("pricing-hero"); // records the exposure
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
To call it before the file has loaded, put this stub above the script tag; the calls wait in a queue and run once it has:
|
|
268
|
+
|
|
269
|
+
```html
|
|
270
|
+
<script>!function(w,m){w[m]=w[m]||{q:[]};"track identify reset captureError setConsent ready openCancelFlow startOnboarding".split(" ").forEach(function(k){w[m][k]=w[m][k]||function(){w[m].q.push([k,arguments])}})}(window,"mrrocket");</script>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
Settings are data attributes on the script tag:
|
|
274
|
+
|
|
275
|
+
| Attribute | What | Default |
|
|
276
|
+
| --- | --- | --- |
|
|
277
|
+
| `data-key` | The product's public key | required |
|
|
278
|
+
| `data-api-host` | Where MRRocket runs | where `sdk.js` came from |
|
|
279
|
+
| `data-consent` | `cookieless`, `cookies` or `off` (see Privacy) | `cookieless` |
|
|
280
|
+
| `data-replay` | `true` once the visitor agreed to session recordings | off |
|
|
281
|
+
| `data-respect-dnt` | Send nothing under Do Not Track or GPC | off |
|
|
282
|
+
| `data-heatmaps` / `data-errors` | `false` turns them off | on |
|
|
283
|
+
| `data-release` | Your release, for Build › Errors | none |
|
|
284
|
+
| `data-attribution` / `data-referral-param` | Affiliate referrals: `consent`, `always`, `off`; the direct-link parameter | `consent`, `ref` |
|
|
285
|
+
|
|
286
|
+
**Experiments on texts:** mark an element `data-ck-text="hero-title"` and MRRocket's variant texts replace its text; the original text is the control. `<Text>` and `<Experiment>` (React components) are Next.js-only; React Native reads texts with `useText`.
|
|
287
|
+
|
|
288
|
+
**Session recordings:** when the product records and the visitor's replay consent is given (`data-replay="true"` or `setConsent({ replay: true })`), the recorder loads from `sdk-replay.js` (about 26 KB gzip) next to `sdk.js`; pages that do not record never load it. Same privacy defaults as 12.
|
|
289
|
+
|
|
290
|
+
**Cancel flow, onboarding, in-app messages (0.7.0):** `mrrocket.cancelFlow.open({ … })` and `mrrocket.onboarding.start()` (see 15); `data-onboarding="false"` keeps flows from starting on their own, `data-theme` and `data-accent` style the dialogs. The dialogs load from `sdk-flows.js` (about 5 KB gzip) only when one shows.
|
|
291
|
+
|
|
292
|
+
`sdk.js` is about 11 KB gzip. MRRocket serves it with open CORS and a short cache (browsers 5 minutes, CDN 1 hour), so a new release reaches every site within the hour.
|
|
293
|
+
|
|
294
|
+
## 14. React Native and Expo
|
|
295
|
+
|
|
296
|
+
```bash
|
|
297
|
+
npx expo install @mrrocket/sdk @react-native-async-storage/async-storage expo-device expo-application expo-localization
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
Only `@mrrocket/sdk` is required. AsyncStorage keeps unsent events and the cached config across launches (without it: memory only); the three `expo-*` modules add app version, OS, device and locale. A bare React Native app installs `@mrrocket/sdk` and AsyncStorage and runs `pod install`. No native module of its own: Expo Go works.
|
|
301
|
+
|
|
302
|
+
```tsx
|
|
303
|
+
// app/_layout.tsx (Expo Router)
|
|
304
|
+
import { Stack } from "expo-router";
|
|
305
|
+
import { MRRocketProvider, useExpoRouterTracking } from "@mrrocket/sdk/react-native";
|
|
306
|
+
|
|
307
|
+
function Screens() {
|
|
308
|
+
useExpoRouterTracking(); // one $screen per path
|
|
309
|
+
return <Stack />;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export default function RootLayout() {
|
|
313
|
+
return (
|
|
314
|
+
<MRRocketProvider apiKey="mrr_pub_…">
|
|
315
|
+
<Screens />
|
|
316
|
+
</MRRocketProvider>
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
React Navigation instead of Expo Router:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
const ref = useNavigationContainerRef();
|
|
325
|
+
const onStateChange = useNavigationTracking();
|
|
326
|
+
<NavigationContainer ref={ref} onReady={() => onStateChange(ref.getRootState())} onStateChange={onStateChange}>
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Use the same public key as the product's website. In components:
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
const { track, identify, reset, screen, setConsent, captureError, flush, ready } = useMRRocket();
|
|
333
|
+
const newCheckout = useFlag("new-checkout"); // false until the config is known
|
|
334
|
+
const plan = useFlagVariant("plan-picker"); // string | null
|
|
335
|
+
const variant = useVariant("paywall"); // "control" until known; records the exposure once per screen
|
|
336
|
+
const headline = useText("paywall.onboarding.headline", "Unlock everything"); // your words until MRRocket has some
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**Texts (paywall copy, text tests):** `useText(id, fallback)` is the app's `<Text id>`. It returns the copy published in MRRocket for `id` (a paywall's `paywall.<placement>.headline`, `.body` and `.cta`, as Conversion › Paywalls shows them), with this person's variant of a running text test on top, the same rules as the website: control keeps the published words, and your `fallback` shows while nothing is published (or on the very first start before the config arrives; later starts use the cached config). Showing the text records `$exposure` for every test that changes `id`, once per screen, so a test that only swaps copy needs no `useVariant`. Outside React: `client.getText(id, fallback)` (records the exposure), or `client.peekText(id)` with `client.exposeText(id)` when you render it later.
|
|
340
|
+
|
|
341
|
+
```tsx
|
|
342
|
+
const PLACEMENT = "onboarding";
|
|
343
|
+
function Paywall() {
|
|
344
|
+
const headline = useText(`paywall.${PLACEMENT}.headline`, "Unlock everything");
|
|
345
|
+
const body = useText(`paywall.${PLACEMENT}.body`, "All features, cancel anytime.");
|
|
346
|
+
const cta = useText(`paywall.${PLACEMENT}.cta`, "Start free trial");
|
|
347
|
+
return (
|
|
348
|
+
<View>
|
|
349
|
+
<Text style={styles.title}>{headline}</Text>
|
|
350
|
+
<Text>{body}</Text>
|
|
351
|
+
<Button title={cta} onPress={buy} />
|
|
352
|
+
</View>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Outside React (a store, a background task): `createMRRocket({ apiKey })` returns the one client the provider would use; `getMRRocket()` returns the running one.
|
|
358
|
+
|
|
359
|
+
| Prop | What | Default |
|
|
360
|
+
| --- | --- | --- |
|
|
361
|
+
| `apiKey` | The product's public key | required |
|
|
362
|
+
| `host` | Where MRRocket runs | `https://app.mrrocket.dev` |
|
|
363
|
+
| `consent` | `"anonymous"`, `"persistent"` or `"off"` (below) | `"anonymous"` |
|
|
364
|
+
| `errors` | Uncaught JS errors and `captureError` to Build › Errors | true |
|
|
365
|
+
| `release` | Your release for Build › Errors | app version and build, like `1.4.0 (42)` |
|
|
366
|
+
| `lifecycle` | `$app_open` and `$app_background` | true |
|
|
367
|
+
| `attribution` | UTM and referral parameters of the first deep link, sent with `$install` | true |
|
|
368
|
+
| `flushIntervalMs` | How often the queue is sent while open | 10 000 |
|
|
369
|
+
| `maxQueue` | Events kept on the device while offline; the oldest go first | 1,000 |
|
|
370
|
+
| `storage` | AsyncStorage-shaped storage (MMKV, SecureStore), or `null` for memory | AsyncStorage when installed |
|
|
371
|
+
| `debug` | Log to the console | false |
|
|
372
|
+
|
|
373
|
+
What it does:
|
|
374
|
+
|
|
375
|
+
- **Lifecycle:** `$app_open` (`cold: true` on a fresh start) and `$app_background`. A session ends after 30 minutes in the background.
|
|
376
|
+
- **Queue:** events are batched, sent every 10 s and when the app goes to the background, and kept on the device while offline, then retried with backoff.
|
|
377
|
+
- **Device:** every event carries `app: { platform, appVersion, build, osVersion, deviceModel, deviceType, locale }`. MRRocket shows the app as browser "App" with OS iOS or Android.
|
|
378
|
+
- **Errors:** a global handler through `ErrorUtils` (your own handler still runs, so the red box still shows in development); `captureError(error)` for caught ones; the same error once per launch, at most 20.
|
|
379
|
+
- **Flags and experiments:** from the same config as the website, cached on the device, so a cold start shows the variant of the last run before the network answers.
|
|
380
|
+
- **Install attribution:** on the first open, the UTM and `ref` parameters of the deep link that opened the app go with `$install`. No IDFA and no ad network SDK, so no App Tracking Transparency prompt is needed.
|
|
381
|
+
|
|
382
|
+
Consent:
|
|
383
|
+
|
|
384
|
+
| `consent` | Stored on the device | Sent |
|
|
385
|
+
| --- | --- | --- |
|
|
386
|
+
| `"anonymous"` (default) | Unsent events and the config, no id | Events without an id until `identify()`; flags use a random id for this launch |
|
|
387
|
+
| `"persistent"` | A random install id (gone with the app), plus the above | Events with the install id |
|
|
388
|
+
| `"off"` | Nothing; what the SDK stored is deleted | Nothing |
|
|
389
|
+
|
|
390
|
+
The SDK does not remember the choice: pass it on every start from your consent screen's stored answer, or call `setConsent()`.
|
|
391
|
+
|
|
392
|
+
Not in this version (coming later): session recordings and heatmaps, the feedback and survey UI, store install referrer and SKAdNetwork.
|
|
393
|
+
|
|
394
|
+
## 15. Cancel flow, onboarding and in-app messages (0.7.0)
|
|
395
|
+
|
|
396
|
+
What you build in MRRocket shows inside your app; the config carries it, and the dialogs load only when one shows.
|
|
397
|
+
|
|
398
|
+
**Cancel flow** (Churn › Cancel flow): call it from your Cancel button. Business customers get the survey ("Why are you leaving?") and the offer you set for their answer; anyone else gets the plain confirmation, so a consumer can always cancel in one step (§ 312k BGB). Without a live flow it shows the plain confirmation too, so the button always works.
|
|
399
|
+
|
|
400
|
+
```tsx
|
|
401
|
+
import { useCancelFlow } from "@mrrocket/sdk/next"; // React Native: from "@mrrocket/sdk/react-native"
|
|
402
|
+
|
|
403
|
+
const cancelFlow = useCancelFlow();
|
|
404
|
+
<button
|
|
405
|
+
onClick={async () => {
|
|
406
|
+
const result = await cancelFlow.open({
|
|
407
|
+
customer: account.isBusiness ? "business" : "consumer",
|
|
408
|
+
price: { amount: 29, currency: "EUR" }, // optional: shows what the discount costs
|
|
409
|
+
onCancel: (r) => api.cancelSubscription({ reason: r.reason, comment: r.comment }),
|
|
410
|
+
onSave: (r) => api.applyOffer(r.offer), // { kind: "discount", percent, months } | { kind: "pause", months } | { kind: "downgrade", plan } | { kind: "contact" }
|
|
411
|
+
});
|
|
412
|
+
// result.outcome: "cancelled" | "saved" | "kept"
|
|
413
|
+
}}
|
|
414
|
+
>
|
|
415
|
+
Cancel subscription
|
|
416
|
+
</button>;
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
MRRocket never changes a subscription (its payment key is read-only): `onCancel` and `onSave` are your code. Only after they succeed is the outcome recorded, and MRRocket shows it under Churn as a cancellation or a save with the customer's plan and monthly revenue (matched by your `identify()` id, or the email you passed with it). If one throws, the dialog stays open with "Something went wrong. Please try again." Script tag: `mrrocket.cancelFlow.open({ … })`.
|
|
420
|
+
|
|
421
|
+
**Onboarding** (Conversion › Onboarding): the tour, checklist and modal steps of a live flow. A flow starts on its own when the person does its trigger: `track("Signed up")` (or `signup`, `sign_up`) for "Signed up", `track("first_export")` for "First export", and the first page or screen for "First launch" / "First visit", once per person. Automatic starts need a signed-in person (`identify`) or stored progress (`consent="cookies"` / `"persistent"`), so nobody gets the same tour on every page. `useCockpit().startOnboarding()` (script tag `mrrocket.onboarding.start()`, React Native `useOnboarding().start()`) starts one whenever you want; `onboarding={false}` on the provider switches the automatic start off. A tour step points at the element marked `data-ck-target="…"` (web); a checklist item with an event ticks itself when you track it. Emails of the flow go out from MRRocket.
|
|
422
|
+
|
|
423
|
+
**In-app messages** (Lifecycle › Journeys, an "In-app message" step): a signed-in person's pending messages load after `identify()` and show as a banner or a dialog. Clicking or closing it moves the person on in the journey; after 7 days without an answer they go on anyway.
|
|
424
|
+
|
|
425
|
+
**Look:** `appearance={{ theme: "dark", accent: "#0f766e", className: "my-dialogs" }}` on `CockpitProvider` (React Native: `{ theme, accent }`; script tag: `data-theme`, `data-accent`). On the web the dialogs read CSS variables on `.ckf-root`: `--ckf-accent`, `--ckf-bg`, `--ckf-fg`, `--ckf-muted`, `--ckf-line`, `--ckf-radius`, `--ckf-font`. React Native draws them next to your app through `MRRocketProvider` (`flows={false}` to draw `client.flows` yourself with `<MRRocketFlows flows={client.flows} />`); tour steps show as a card there.
|
|
426
|
+
|
|
427
|
+
**Events** (under your analytics consent): `$cancel_flow` `{ action: "shown" | "answered" | "offer" | "finished", run, version, reason?, offer?, outcome?, comment? }`, `$onboarding` `{ flow, step?, action: "started" | "shown" | "completed" | "dismissed" }`, `$inapp` `{ message, journey, action: "shown" | "clicked" | "dismissed" }`. With consent `"off"` the cancel flow still works, but nothing is recorded, and no onboarding or messages show.
|
|
428
|
+
|
|
429
|
+
## 16. Push notifications (0.7.0)
|
|
430
|
+
|
|
431
|
+
A Lifecycle journey's Push step goes to the phones and browsers where the signed-in person turned notifications on.
|
|
432
|
+
|
|
433
|
+
```tsx
|
|
434
|
+
// Expo (needs expo-notifications; expo-constants for the EAS project id). After identify():
|
|
435
|
+
import { usePush } from "@mrrocket/sdk/react-native";
|
|
436
|
+
const push = usePush();
|
|
437
|
+
await push.register(); // { status: "registered" | "denied" | "not-asked" | "unavailable" | "not-signed-in" | "error" }
|
|
438
|
+
|
|
439
|
+
// Next.js, from a click, after identify(). Your site serves /mrrocket-sw.js (a copy of dist/browser/mrrocket-sw.js):
|
|
440
|
+
import { usePush as useWebPush } from "@mrrocket/sdk/next";
|
|
441
|
+
const webPush = useWebPush();
|
|
442
|
+
<button onClick={() => webPush.subscribe()}>Turn on notifications</button>;
|
|
443
|
+
|
|
444
|
+
// Script tag
|
|
445
|
+
mrrocket.push.subscribe();
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
`register({ ask: false })` / `subscribe({ ask: false })` only send an existing token or subscription again. `reset()` on React Native takes the token back; on the web call `push.unsubscribe()` before `reset()`. A site with its own service worker adds `importScripts("/mrrocket-sw.js")` to it and calls `subscribe({ serviceWorker: false })`. Opening a MRRocket notification records `$push { action: "opened", message }`.
|
|
449
|
+
|
|
450
|
+
## 17. Redirects and redirect tests (0.7.0)
|
|
451
|
+
|
|
452
|
+
Page redirects (Landing pages › Redirect) and redirect tests ("Whole page" in Experiments) come with the config: the SDK sends the visitor on before the page shows, keeping the query and hash, and the page a test's variant lands on records the exposure (`ck_x=<test>:<variant>`, removed from the address). Nothing to add for the script tag (the optional `ck-hide` snippet stops a flash of the old page). In Next.js `<CockpitProvider>` redirects after the first paint; to answer before rendering, use the proxy helper:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// proxy.ts
|
|
456
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
457
|
+
import { experimentRedirect, getCockpitConfig } from "@mrrocket/sdk/next/proxy";
|
|
458
|
+
|
|
459
|
+
export async function proxy(request: NextRequest) {
|
|
460
|
+
const to = experimentRedirect(request, await getCockpitConfig({ timeoutMs: 800 }));
|
|
461
|
+
return to ? NextResponse.redirect(to, 307) : NextResponse.next();
|
|
462
|
+
}
|
|
463
|
+
export const config = { matcher: ["/pricing"] };
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
A running test needs a stable id on the server (the `ck_id` cookie with `consent="cookies"`, or `{ distinctId }`); without one it is left to the provider.
|
|
467
|
+
|
|
235
468
|
## Privacy and consent
|
|
236
469
|
|
|
237
470
|
```tsx
|
|
@@ -274,7 +507,9 @@ MRRocket keeps recordings 30 days by default (at most 90), in the EU. Add the re
|
|
|
274
507
|
utm_term?: string;
|
|
275
508
|
utm_content?: string;
|
|
276
509
|
screen?: { w: number; h: number };
|
|
510
|
+
app?: { platform: string; appVersion?: string; build?: string; osVersion?: string; deviceModel?: string; deviceType?: string; locale?: string }; // React Native only
|
|
277
511
|
}[];
|
|
512
|
+
sdk?: { v: string; errors: boolean; platform: "web" | "ios" | "android" };
|
|
278
513
|
}
|
|
279
514
|
```
|
|
280
515
|
|
package/dist/api.d.ts
CHANGED
|
@@ -14,13 +14,23 @@ export interface FeedbackPost {
|
|
|
14
14
|
comments: number;
|
|
15
15
|
voted: boolean;
|
|
16
16
|
createdAt: string;
|
|
17
|
+
/** "Lena K.", "Anonymous" or "Team". Missing from servers before SDK 0.6. */
|
|
18
|
+
author?: string;
|
|
19
|
+
}
|
|
20
|
+
/** The product's public face for the board's header. */
|
|
21
|
+
export interface FeedbackProduct {
|
|
22
|
+
name: string;
|
|
23
|
+
logo: string | null;
|
|
17
24
|
}
|
|
18
25
|
export interface FeedbackList {
|
|
19
26
|
boards: FeedbackBoardInfo[];
|
|
20
27
|
posts: FeedbackPost[];
|
|
28
|
+
product?: FeedbackProduct;
|
|
21
29
|
}
|
|
22
30
|
export interface FeedbackRoadmap {
|
|
31
|
+
/** `review` (Backlog) comes from servers of SDK 0.6 on. */
|
|
23
32
|
columns: {
|
|
33
|
+
review?: FeedbackPost[];
|
|
24
34
|
planned: FeedbackPost[];
|
|
25
35
|
progress: FeedbackPost[];
|
|
26
36
|
shipped: FeedbackPost[];
|
|
@@ -69,7 +79,7 @@ interface Conn {
|
|
|
69
79
|
apiKey: string;
|
|
70
80
|
distinctId: string;
|
|
71
81
|
user?: {
|
|
72
|
-
id
|
|
82
|
+
id?: string;
|
|
73
83
|
[key: string]: unknown;
|
|
74
84
|
};
|
|
75
85
|
}
|
|
@@ -80,6 +90,7 @@ export declare const createApi: (c: Conn) => {
|
|
|
80
90
|
sort?: "top" | "new";
|
|
81
91
|
q?: string;
|
|
82
92
|
}, signal?: AbortSignal) => Promise<FeedbackList>;
|
|
93
|
+
post: (id: string, signal?: AbortSignal) => Promise<FeedbackPost>;
|
|
83
94
|
roadmap: (signal?: AbortSignal) => Promise<FeedbackRoadmap>;
|
|
84
95
|
changelog: (signal?: AbortSignal) => Promise<{
|
|
85
96
|
entries: ChangelogEntry[];
|
|
@@ -108,6 +119,15 @@ export declare const createApi: (c: Conn) => {
|
|
|
108
119
|
}) => Promise<unknown>;
|
|
109
120
|
};
|
|
110
121
|
export type Api = ReturnType<typeof createApi>;
|
|
111
|
-
/**
|
|
112
|
-
export
|
|
122
|
+
/** Who writes: the provider's visitor, or the name and email a visitor gave the board (see useBoardIdentity). */
|
|
123
|
+
export interface Writer {
|
|
124
|
+
distinctId: string;
|
|
125
|
+
user?: {
|
|
126
|
+
id?: string;
|
|
127
|
+
name?: string;
|
|
128
|
+
email?: string;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** The client for the provider's key, host and visitor, or for `as` when given. */
|
|
132
|
+
export declare const useApi: (as?: Writer | null) => Api;
|
|
113
133
|
export {};
|
package/dist/api.js
CHANGED
|
@@ -47,7 +47,8 @@ var createApi = (c) => {
|
|
|
47
47
|
const post = (path, body, signal) => call(`${base}${path}`, { method: "POST", body: JSON.stringify({ ...who, ...body }) }, signal);
|
|
48
48
|
return {
|
|
49
49
|
list: (o, signal) => call(`${base}/feedback?${qs({ key: c.apiKey, distinctId: c.distinctId, board: o.board, sort: o.sort, q: o.q })}`, void 0, signal),
|
|
50
|
-
|
|
50
|
+
post: (id, signal) => call(`${base}/feedback/${encodeURIComponent(id)}?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, void 0, signal),
|
|
51
|
+
roadmap: (signal) => call(`${base}/feedback/roadmap?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, void 0, signal),
|
|
51
52
|
changelog: (signal) => call(`${base}/feedback/changelog?${qs({ key: c.apiKey })}`, void 0, signal),
|
|
52
53
|
submit: (p) => post("/feedback", p),
|
|
53
54
|
vote: (id) => post(`/feedback/${encodeURIComponent(id)}/vote`, {}),
|
|
@@ -58,8 +59,11 @@ var createApi = (c) => {
|
|
|
58
59
|
respond: (id, r) => post(`/surveys/${encodeURIComponent(id)}/respond`, r)
|
|
59
60
|
};
|
|
60
61
|
};
|
|
61
|
-
var useApi = () => {
|
|
62
|
-
const
|
|
62
|
+
var useApi = (as) => {
|
|
63
|
+
const ctx = useCtx();
|
|
64
|
+
const distinctId = as?.distinctId ?? ctx.distinctId;
|
|
65
|
+
const user = as ? as.user : ctx.user;
|
|
66
|
+
const { apiKey, host } = ctx;
|
|
63
67
|
return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);
|
|
64
68
|
};
|
|
65
69
|
export {
|
package/dist/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/api.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useCtx } from \"./context\";\n\nexport type PostStatus = \"review\" | \"planned\" | \"progress\" | \"shipped\";\n\nexport const STATUS_LABEL: Record<PostStatus, string> = {\n review: \"Under review\",\n planned: \"Planned\",\n progress: \"In progress\",\n shipped: \"Shipped\",\n};\n\nexport interface FeedbackBoardInfo {\n id: string;\n label: string;\n}\n\nexport interface FeedbackPost {\n id: string;\n title: string;\n body: string;\n board: string;\n status: PostStatus;\n votes: number;\n comments: number;\n voted: boolean;\n createdAt: string;\n}\n\nexport interface FeedbackList {\n boards: FeedbackBoardInfo[];\n posts: FeedbackPost[];\n}\n\nexport interface FeedbackRoadmap {\n columns: { planned: FeedbackPost[]; progress: FeedbackPost[]; shipped: FeedbackPost[] };\n}\n\nexport interface ChangelogEntry {\n id: string;\n title: string;\n body: string;\n publishedAt: string;\n}\n\nexport interface FeedbackComment {\n id: string;\n body: string;\n author: string;\n isTeam: boolean;\n postedAt: string;\n}\n\nexport type SurveyQuestionKind = \"nps\" | \"rating\" | \"single\" | \"multi\" | \"text\";\n\nexport interface SurveyQuestion {\n key: string;\n kind: SurveyQuestionKind;\n title: string;\n description?: string;\n required: boolean;\n options?: string[];\n scale?: number;\n low?: string;\n high?: string;\n placeholder?: string;\n}\n\nexport interface SurveyDefinition {\n id: string;\n name: string;\n type: string;\n thanks: string;\n questions: SurveyQuestion[];\n}\n\nexport type SurveyAnswer = number | string | string[];\n\nexport class ApiError extends Error {\n status: number;\n constructor(message: string, status: number) {\n super(message);\n this.status = status;\n }\n}\n\ninterface Conn {\n host: string;\n apiKey: string;\n distinctId: string;\n user?: { id
|
|
1
|
+
{"version":3,"sources":["../src/api.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useCtx } from \"./context\";\n\nexport type PostStatus = \"review\" | \"planned\" | \"progress\" | \"shipped\";\n\nexport const STATUS_LABEL: Record<PostStatus, string> = {\n review: \"Under review\",\n planned: \"Planned\",\n progress: \"In progress\",\n shipped: \"Shipped\",\n};\n\nexport interface FeedbackBoardInfo {\n id: string;\n label: string;\n}\n\nexport interface FeedbackPost {\n id: string;\n title: string;\n body: string;\n board: string;\n status: PostStatus;\n votes: number;\n comments: number;\n voted: boolean;\n createdAt: string;\n /** \"Lena K.\", \"Anonymous\" or \"Team\". Missing from servers before SDK 0.6. */\n author?: string;\n}\n\n/** The product's public face for the board's header. */\nexport interface FeedbackProduct {\n name: string;\n logo: string | null;\n}\n\nexport interface FeedbackList {\n boards: FeedbackBoardInfo[];\n posts: FeedbackPost[];\n product?: FeedbackProduct;\n}\n\nexport interface FeedbackRoadmap {\n /** `review` (Backlog) comes from servers of SDK 0.6 on. */\n columns: { review?: FeedbackPost[]; planned: FeedbackPost[]; progress: FeedbackPost[]; shipped: FeedbackPost[] };\n}\n\nexport interface ChangelogEntry {\n id: string;\n title: string;\n body: string;\n publishedAt: string;\n}\n\nexport interface FeedbackComment {\n id: string;\n body: string;\n author: string;\n isTeam: boolean;\n postedAt: string;\n}\n\nexport type SurveyQuestionKind = \"nps\" | \"rating\" | \"single\" | \"multi\" | \"text\";\n\nexport interface SurveyQuestion {\n key: string;\n kind: SurveyQuestionKind;\n title: string;\n description?: string;\n required: boolean;\n options?: string[];\n scale?: number;\n low?: string;\n high?: string;\n placeholder?: string;\n}\n\nexport interface SurveyDefinition {\n id: string;\n name: string;\n type: string;\n thanks: string;\n questions: SurveyQuestion[];\n}\n\nexport type SurveyAnswer = number | string | string[];\n\nexport class ApiError extends Error {\n status: number;\n constructor(message: string, status: number) {\n super(message);\n this.status = status;\n }\n}\n\ninterface Conn {\n host: string;\n apiKey: string;\n distinctId: string;\n user?: { id?: string; [key: string]: unknown };\n}\n\nconst qs = (params: Record<string, string | undefined>) => {\n const p = new URLSearchParams();\n for (const [k, v] of Object.entries(params)) if (v) p.set(k, v);\n return p.toString();\n};\n\nconst call = async <T>(url: string, init: RequestInit | undefined, signal?: AbortSignal): Promise<T> => {\n let res: Response;\n try {\n res = await fetch(url, {\n ...init,\n signal,\n headers: init?.body ? { \"content-type\": \"application/json\" } : undefined,\n });\n } catch (e) {\n if ((e as Error).name === \"AbortError\") throw e;\n throw new ApiError(\"Could not reach the server. Check your connection and try again.\", 0);\n }\n if (!res.ok) {\n let detail = \"\";\n try {\n detail = ((await res.json()) as { error?: string }).error ?? \"\";\n } catch {\n // Not JSON; the status is enough.\n }\n throw new ApiError(detail || (res.status === 429 ? \"Too many requests. Try again in a minute.\" : \"Something went wrong. Please try again.\"), res.status);\n }\n return (await res.json()) as T;\n};\n\n/** The small fetch client behind the widgets. Every read takes an AbortSignal so unmounting cancels it. */\nexport const createApi = (c: Conn) => {\n const base = `${c.host}/api/sdk`;\n const who = { key: c.apiKey, distinctId: c.distinctId, user: c.user };\n const post = <T>(path: string, body: Record<string, unknown>, signal?: AbortSignal) =>\n call<T>(`${base}${path}`, { method: \"POST\", body: JSON.stringify({ ...who, ...body }) }, signal);\n return {\n list: (o: { board?: string; sort?: \"top\" | \"new\"; q?: string }, signal?: AbortSignal) =>\n call<FeedbackList>(`${base}/feedback?${qs({ key: c.apiKey, distinctId: c.distinctId, board: o.board, sort: o.sort, q: o.q })}`, undefined, signal),\n post: (id: string, signal?: AbortSignal) =>\n call<FeedbackPost>(`${base}/feedback/${encodeURIComponent(id)}?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, undefined, signal),\n roadmap: (signal?: AbortSignal) => call<FeedbackRoadmap>(`${base}/feedback/roadmap?${qs({ key: c.apiKey, distinctId: c.distinctId })}`, undefined, signal),\n changelog: (signal?: AbortSignal) => call<{ entries: ChangelogEntry[] }>(`${base}/feedback/changelog?${qs({ key: c.apiKey })}`, undefined, signal),\n submit: (p: { title: string; body?: string; board: string }) => post<FeedbackPost>(\"/feedback\", p),\n vote: (id: string) => post<{ voted: boolean; votes: number }>(`/feedback/${encodeURIComponent(id)}/vote`, {}),\n comments: (id: string, signal?: AbortSignal) =>\n call<{ comments: FeedbackComment[] }>(`${base}/feedback/${encodeURIComponent(id)}/comments?${qs({ key: c.apiKey })}`, undefined, signal),\n comment: (id: string, body: string) => post<FeedbackComment>(`/feedback/${encodeURIComponent(id)}/comments`, { body }),\n survey: (url: string, signal?: AbortSignal) =>\n call<{ survey: SurveyDefinition | null }>(`${base}/surveys?${qs({ key: c.apiKey, distinctId: c.distinctId, url })}`, undefined, signal),\n seen: (id: string) => post<unknown>(`/surveys/${encodeURIComponent(id)}/seen`, { user: undefined }),\n respond: (id: string, r: { answers: Record<string, SurveyAnswer>; completed: boolean; seconds: number }) =>\n post<unknown>(`/surveys/${encodeURIComponent(id)}/respond`, r),\n };\n};\n\nexport type Api = ReturnType<typeof createApi>;\n\n/** Who writes: the provider's visitor, or the name and email a visitor gave the board (see useBoardIdentity). */\nexport interface Writer {\n distinctId: string;\n user?: { id?: string; name?: string; email?: string };\n}\n\n/** The client for the provider's key, host and visitor, or for `as` when given. */\nexport const useApi = (as?: Writer | null): Api => {\n const ctx = useCtx();\n const distinctId = as?.distinctId ?? ctx.distinctId;\n const user = as ? as.user : ctx.user;\n const { apiKey, host } = ctx;\n return useMemo(() => createApi({ apiKey, host, distinctId, user }), [apiKey, host, distinctId, user]);\n};\n"],"mappings":"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","names":[]}
|
package/dist/batch.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How events go over the wire to `POST /api/sdk/events`, shared by every runtime (Next.js, the script tag, React
|
|
3
|
+
* Native): request bodies of at most 100 events each, under the server's 64 KB. No React, no browser APIs, no
|
|
4
|
+
* TextEncoder (Hermes on older React Native lacks it), so it runs anywhere.
|
|
5
|
+
*/
|
|
6
|
+
import type { CockpitEvent } from "./core.js";
|
|
7
|
+
/** The server takes at most 100 events and 64 KB per request; stay a little under the size. */
|
|
8
|
+
export declare const MAX_BATCH = 100;
|
|
9
|
+
export declare const MAX_BYTES = 60000;
|
|
10
|
+
/** What a batch says about the SDK that sent it (Build › Errors and Feature flags show it). */
|
|
11
|
+
export interface SdkInfo {
|
|
12
|
+
v: string;
|
|
13
|
+
errors: boolean;
|
|
14
|
+
/** "web" (script tag), "ios" or "android" (React Native). Missing from the Next.js provider. */
|
|
15
|
+
platform?: string;
|
|
16
|
+
}
|
|
17
|
+
/** UTF-8 length of a string, the way TextEncoder counts it (a lone surrogate becomes U+FFFD, three bytes). */
|
|
18
|
+
export declare const byteLength: (s: string) => number;
|
|
19
|
+
/** One request: its body and the events in it (so a client can keep the ones that did not arrive). */
|
|
20
|
+
export interface BatchRequest<E> {
|
|
21
|
+
body: string;
|
|
22
|
+
events: E[];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Splits events into request bodies of at most 100 events each, under the size limit. An event too large on its own is
|
|
26
|
+
* dropped (it is in no request).
|
|
27
|
+
*/
|
|
28
|
+
export declare const batchRequests: <E extends CockpitEvent>(key: string, events: E[], sdk: SdkInfo) => BatchRequest<E>[];
|
|
29
|
+
/**
|
|
30
|
+
* Request bodies of at most 100 events each, under the size limit. An event too large on its own is dropped. `sdk` tells
|
|
31
|
+
* MRRocket which SDK the site runs and whether it reports errors (Build › Errors and Feature flags show it).
|
|
32
|
+
*/
|
|
33
|
+
export declare const bodies: (key: string, events: CockpitEvent[], sdk: SdkInfo) => string[];
|
package/dist/batch.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/batch.ts
|
|
2
|
+
var MAX_BATCH = 100;
|
|
3
|
+
var MAX_BYTES = 6e4;
|
|
4
|
+
var byteLength = (s) => {
|
|
5
|
+
let n = 0;
|
|
6
|
+
for (let i = 0; i < s.length; i++) {
|
|
7
|
+
const c = s.charCodeAt(i);
|
|
8
|
+
if (c < 128) n += 1;
|
|
9
|
+
else if (c < 2048) n += 2;
|
|
10
|
+
else if (c >= 55296 && c <= 56319 && i + 1 < s.length && (s.charCodeAt(i + 1) & 64512) === 56320) {
|
|
11
|
+
n += 4;
|
|
12
|
+
i++;
|
|
13
|
+
} else n += 3;
|
|
14
|
+
}
|
|
15
|
+
return n;
|
|
16
|
+
};
|
|
17
|
+
var batchRequests = (key, events, sdk) => {
|
|
18
|
+
const head = `{"key":${JSON.stringify(key)},"sdk":${JSON.stringify(sdk)},"events":[`;
|
|
19
|
+
const empty = byteLength(head) + 2;
|
|
20
|
+
const out = [];
|
|
21
|
+
let chunk = [];
|
|
22
|
+
let items = [];
|
|
23
|
+
let size = empty;
|
|
24
|
+
const close = () => {
|
|
25
|
+
if (chunk.length) out.push({ body: `${head}${chunk.join(",")}]}`, events: items });
|
|
26
|
+
chunk = [];
|
|
27
|
+
items = [];
|
|
28
|
+
size = empty;
|
|
29
|
+
};
|
|
30
|
+
for (const event of events) {
|
|
31
|
+
const json = JSON.stringify(event);
|
|
32
|
+
const n = byteLength(json) + 1;
|
|
33
|
+
if (empty + n > MAX_BYTES) continue;
|
|
34
|
+
if (chunk.length === MAX_BATCH || size + n > MAX_BYTES) close();
|
|
35
|
+
chunk.push(json);
|
|
36
|
+
items.push(event);
|
|
37
|
+
size += n;
|
|
38
|
+
}
|
|
39
|
+
close();
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
var bodies = (key, events, sdk) => batchRequests(key, events, sdk).map((r) => r.body);
|
|
43
|
+
export {
|
|
44
|
+
MAX_BATCH,
|
|
45
|
+
MAX_BYTES,
|
|
46
|
+
batchRequests,
|
|
47
|
+
bodies,
|
|
48
|
+
byteLength
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=batch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/batch.ts"],"sourcesContent":["/**\n * How events go over the wire to `POST /api/sdk/events`, shared by every runtime (Next.js, the script tag, React\n * Native): request bodies of at most 100 events each, under the server's 64 KB. No React, no browser APIs, no\n * TextEncoder (Hermes on older React Native lacks it), so it runs anywhere.\n */\nimport type { CockpitEvent } from \"./core\";\n\n/** The server takes at most 100 events and 64 KB per request; stay a little under the size. */\nexport const MAX_BATCH = 100;\nexport const MAX_BYTES = 60_000;\n\n/** What a batch says about the SDK that sent it (Build › Errors and Feature flags show it). */\nexport interface SdkInfo {\n v: string;\n errors: boolean;\n /** \"web\" (script tag), \"ios\" or \"android\" (React Native). Missing from the Next.js provider. */\n platform?: string;\n}\n\n/** UTF-8 length of a string, the way TextEncoder counts it (a lone surrogate becomes U+FFFD, three bytes). */\nexport const byteLength = (s: string) => {\n let n = 0;\n for (let i = 0; i < s.length; i++) {\n const c = s.charCodeAt(i);\n if (c < 0x80) n += 1;\n else if (c < 0x800) n += 2;\n else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length && (s.charCodeAt(i + 1) & 0xfc00) === 0xdc00) {\n n += 4;\n i++;\n } else n += 3;\n }\n return n;\n};\n\n/** One request: its body and the events in it (so a client can keep the ones that did not arrive). */\nexport interface BatchRequest<E> {\n body: string;\n events: E[];\n}\n\n/**\n * Splits events into request bodies of at most 100 events each, under the size limit. An event too large on its own is\n * dropped (it is in no request).\n */\nexport const batchRequests = <E extends CockpitEvent>(key: string, events: E[], sdk: SdkInfo): BatchRequest<E>[] => {\n const head = `{\"key\":${JSON.stringify(key)},\"sdk\":${JSON.stringify(sdk)},\"events\":[`;\n const empty = byteLength(head) + 2;\n const out: BatchRequest<E>[] = [];\n let chunk: string[] = [];\n let items: E[] = [];\n let size = empty;\n const close = () => {\n if (chunk.length) out.push({ body: `${head}${chunk.join(\",\")}]}`, events: items });\n chunk = [];\n items = [];\n size = empty;\n };\n for (const event of events) {\n const json = JSON.stringify(event);\n const n = byteLength(json) + 1;\n if (empty + n > MAX_BYTES) continue;\n if (chunk.length === MAX_BATCH || size + n > MAX_BYTES) close();\n chunk.push(json);\n items.push(event);\n size += n;\n }\n close();\n return out;\n};\n\n/**\n * Request bodies of at most 100 events each, under the size limit. An event too large on its own is dropped. `sdk` tells\n * MRRocket which SDK the site runs and whether it reports errors (Build › Errors and Feature flags show it).\n */\nexport const bodies = (key: string, events: CockpitEvent[], sdk: SdkInfo) => batchRequests(key, events, sdk).map((r) => r.body);\n"],"mappings":";AAQO,IAAM,YAAY;AAClB,IAAM,YAAY;AAWlB,IAAM,aAAa,CAAC,MAAc;AACrC,MAAI,IAAI;AACR,WAAS,IAAI,GAAG,IAAI,EAAE,QAAQ,KAAK;AAC/B,UAAM,IAAI,EAAE,WAAW,CAAC;AACxB,QAAI,IAAI,IAAM,MAAK;AAAA,aACV,IAAI,KAAO,MAAK;AAAA,aAChB,KAAK,SAAU,KAAK,SAAU,IAAI,IAAI,EAAE,WAAW,EAAE,WAAW,IAAI,CAAC,IAAI,WAAY,OAAQ;AAClG,WAAK;AACL;AAAA,IACJ,MAAO,MAAK;AAAA,EAChB;AACA,SAAO;AACX;AAYO,IAAM,gBAAgB,CAAyB,KAAa,QAAa,QAAoC;AAChH,QAAM,OAAO,UAAU,KAAK,UAAU,GAAG,CAAC,UAAU,KAAK,UAAU,GAAG,CAAC;AACvE,QAAM,QAAQ,WAAW,IAAI,IAAI;AACjC,QAAM,MAAyB,CAAC;AAChC,MAAI,QAAkB,CAAC;AACvB,MAAI,QAAa,CAAC;AAClB,MAAI,OAAO;AACX,QAAM,QAAQ,MAAM;AAChB,QAAI,MAAM,OAAQ,KAAI,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,KAAK,GAAG,CAAC,MAAM,QAAQ,MAAM,CAAC;AACjF,YAAQ,CAAC;AACT,YAAQ,CAAC;AACT,WAAO;AAAA,EACX;AACA,aAAW,SAAS,QAAQ;AACxB,UAAM,OAAO,KAAK,UAAU,KAAK;AACjC,UAAM,IAAI,WAAW,IAAI,IAAI;AAC7B,QAAI,QAAQ,IAAI,UAAW;AAC3B,QAAI,MAAM,WAAW,aAAa,OAAO,IAAI,UAAW,OAAM;AAC9D,UAAM,KAAK,IAAI;AACf,UAAM,KAAK,KAAK;AAChB,YAAQ;AAAA,EACZ;AACA,QAAM;AACN,SAAO;AACX;AAMO,IAAM,SAAS,CAAC,KAAa,QAAwB,QAAiB,cAAc,KAAK,QAAQ,GAAG,EAAE,IAAI,CAAC,MAAM,EAAE,IAAI;","names":[]}
|