@mrrocket/sdk 0.11.0 → 0.13.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/dist/forms.js ADDED
@@ -0,0 +1,124 @@
1
+ // src/forms.ts
2
+ var SENSITIVE = /pass(word|wd|code)?|pwd|secret|token|csrf|xsrf|nonce|captcha|honeypot|card|^cc([-_ ]|num|exp|$)|cvv|cvc|csc|expir|iban|(^|[^a-z])bic([^a-z]|$)|swift|routing|account[-_ ]?(no|num)|sort[-_ ]?code|(^|[^a-z])ssn([^a-z]|$)|social[-_ ]?sec|tax[-_ ]?id|vat[-_ ]?id|steuer|passport|(^|[^a-z])(pin|otp|pw)([^a-z]|$)|one[-_ ]?time|auth(?!or)|api[-_ ]?key|session|signature/i;
3
+ var SKIP_TYPES = /* @__PURE__ */ new Set(["password", "hidden", "file", "submit", "button", "reset", "image"]);
4
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
5
+ var MAX_FIELDS = 30;
6
+ var MAX_VALUE = 1e3;
7
+ var norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, "");
8
+ var sendable = (f) => {
9
+ if (f.ignored || SKIP_TYPES.has(f.type)) return false;
10
+ const ac = (f.autocomplete ?? "").toLowerCase();
11
+ if (/^cc-|password|one-time-code/.test(ac)) return false;
12
+ return !SENSITIVE.test(f.name);
13
+ };
14
+ var isEmailField = (f) => f.type === "email" || /e-?mail/i.test(f.name) || f.autocomplete === "email";
15
+ var fieldsOf = (inputs) => {
16
+ let email = null;
17
+ let full = null;
18
+ let first = null;
19
+ let last = null;
20
+ let consent;
21
+ const fields = {};
22
+ for (const f of inputs) {
23
+ if (f.consent) {
24
+ consent = (consent ?? false) || !f.unchecked;
25
+ continue;
26
+ }
27
+ if (!f.name || !sendable(f) || f.unchecked) continue;
28
+ const value = f.value.trim().slice(0, MAX_VALUE);
29
+ if (!value) continue;
30
+ const n = norm(f.name);
31
+ const ac = (f.autocomplete ?? "").toLowerCase();
32
+ if (!email && isEmailField(f) && EMAIL_RE.test(value)) {
33
+ email = value.toLowerCase();
34
+ continue;
35
+ }
36
+ if (!full && (ac === "name" || ["name", "fullname", "yourname", "contactname"].includes(n))) {
37
+ full = value.slice(0, 100);
38
+ continue;
39
+ }
40
+ if (!first && (ac === "given-name" || ["firstname", "first", "vorname", "givenname", "fname"].includes(n))) {
41
+ first = value.slice(0, 60);
42
+ continue;
43
+ }
44
+ if (!last && (ac === "family-name" || ["lastname", "last", "nachname", "surname", "familyname", "lname"].includes(n))) {
45
+ last = value.slice(0, 60);
46
+ continue;
47
+ }
48
+ const key = f.name.trim().slice(0, 60);
49
+ if (key in fields) fields[key] = `${fields[key]}, ${value}`.slice(0, MAX_VALUE);
50
+ else if (Object.keys(fields).length < MAX_FIELDS) fields[key] = value;
51
+ }
52
+ const name = full ?? ([first, last].filter(Boolean).join(" ") || null);
53
+ return { email, name, fields, ...consent === void 0 ? {} : { consent } };
54
+ };
55
+ var formName = (form) => (form.getAttribute("data-mrrocket-form") || form.getAttribute("name") || form.id || form.getAttribute("aria-label") || `Form on ${window.location.pathname}`).trim().slice(0, 100);
56
+ var captures = (form, auto) => {
57
+ if (form.closest("[data-mrrocket-ignore]")) return false;
58
+ if (form.hasAttribute("data-mrrocket-form")) return true;
59
+ return auto && !!form.querySelector('input[type="email"], input[name*="email" i]') && !form.querySelector('input[type="password"]');
60
+ };
61
+ var inputsOf = (form) => Array.from(form.elements).flatMap((el) => {
62
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)) return [];
63
+ if (el.disabled) return [];
64
+ const type = el instanceof HTMLInputElement ? el.type.toLowerCase() : el instanceof HTMLSelectElement ? "select" : "textarea";
65
+ const check = type === "checkbox" || type === "radio";
66
+ const value = el instanceof HTMLSelectElement && el.multiple ? Array.from(el.selectedOptions, (o) => o.value).join(", ") : check && el.value === "on" ? "yes" : el.value;
67
+ return [
68
+ {
69
+ name: el.name || el.id || "",
70
+ type,
71
+ value,
72
+ autocomplete: el.getAttribute("autocomplete") ?? void 0,
73
+ unchecked: check && !el.checked,
74
+ ignored: !!el.closest("[data-mrrocket-ignore]"),
75
+ consent: el.hasAttribute("data-mrrocket-consent")
76
+ }
77
+ ];
78
+ });
79
+ var watchForms = (o) => {
80
+ const w = window;
81
+ w.__mrrocketFormsOff?.();
82
+ const onSubmit = (e) => {
83
+ const form = e.target;
84
+ if (!(form instanceof HTMLFormElement) || !captures(form, !!o.auto)) return;
85
+ const read = fieldsOf(inputsOf(form));
86
+ if (!read.email) return;
87
+ const utm = Object.fromEntries(Object.entries(o.utm?.() ?? {}).filter((x) => typeof x[1] === "string" && !!x[1]));
88
+ const lead = {
89
+ key: o.apiKey,
90
+ form: formName(form),
91
+ url: window.location.href.slice(0, 500),
92
+ title: document.title.slice(0, 200) || void 0,
93
+ referrer: o.referrer?.() || document.referrer || void 0,
94
+ ...Object.keys(utm).length ? { utm } : {},
95
+ distinctId: o.distinctId(),
96
+ ...read
97
+ };
98
+ const body = JSON.stringify(lead);
99
+ const url = `${o.host}/api/sdk/forms`;
100
+ const sent = "sendBeacon" in navigator && navigator.sendBeacon(url, new Blob([body], { type: "text/plain" }));
101
+ if (!sent) void fetch(url, { method: "POST", headers: { "content-type": "text/plain" }, body, keepalive: true }).catch(() => void 0);
102
+ if (o.tracking() && !o.identified()) o.identify(read.email, { email: read.email, ...read.name ? { name: read.name } : {} });
103
+ };
104
+ document.addEventListener("submit", onSubmit, true);
105
+ const off = () => {
106
+ document.removeEventListener("submit", onSubmit, true);
107
+ if (w.__mrrocketFormsOff === off) delete w.__mrrocketFormsOff;
108
+ };
109
+ w.__mrrocketFormsOff = off;
110
+ return off;
111
+ };
112
+ export {
113
+ EMAIL_RE,
114
+ MAX_FIELDS,
115
+ MAX_VALUE,
116
+ SENSITIVE,
117
+ captures,
118
+ fieldsOf,
119
+ formName,
120
+ inputsOf,
121
+ sendable,
122
+ watchForms
123
+ };
124
+ //# sourceMappingURL=forms.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms.ts"],"sourcesContent":["/**\n * Form leads (0.12.0): a form on the customer's site becomes a contact in MRRocket when it is submitted. Forms marked\n * `data-mrrocket-form` (its value names the form, e.g. `data-mrrocket-form=\"Demo request\"`) are captured; with\n * auto-detection on (`data-forms=\"auto\"` on the script tag, `forms=\"auto\"` on the provider) also any form with an email\n * field, except forms with a password field (sign-in and sign-up) and forms marked `data-mrrocket-ignore`.\n *\n * What is sent: the email (required), the name, and the other fields a person typed, never passwords, card fields,\n * hidden fields, files or fields whose name looks sensitive (`SENSITIVE`); a field or form part marked\n * `data-mrrocket-ignore` stays out too. Plus the form's name, the page, the referrer, UTM, and the state of a checkbox\n * marked `data-mrrocket-consent` (the visitor's own yes to hear from the site). MRRocket records consent as the contact's\n * legal basis only when that box was ticked.\n *\n * No React and no globals beyond the DOM, so the script tag (`sdk-forms.js`, loaded only on pages with a form to\n * capture) and the Next.js provider (a dynamic import) share it. `fieldsOf` is pure for the tests.\n */\n\n/** Field names that never leave the page, whatever the form says. */\nexport const SENSITIVE =\n /pass(word|wd|code)?|pwd|secret|token|csrf|xsrf|nonce|captcha|honeypot|card|^cc([-_ ]|num|exp|$)|cvv|cvc|csc|expir|iban|(^|[^a-z])bic([^a-z]|$)|swift|routing|account[-_ ]?(no|num)|sort[-_ ]?code|(^|[^a-z])ssn([^a-z]|$)|social[-_ ]?sec|tax[-_ ]?id|vat[-_ ]?id|steuer|passport|(^|[^a-z])(pin|otp|pw)([^a-z]|$)|one[-_ ]?time|auth(?!or)|api[-_ ]?key|session|signature/i;\n\n/** Input types whose value is never sent. */\nconst SKIP_TYPES = new Set([\"password\", \"hidden\", \"file\", \"submit\", \"button\", \"reset\", \"image\"]);\n\nexport const EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\n\n/** Limits: what a lead may carry, also checked again by the server. */\nexport const MAX_FIELDS = 30;\nexport const MAX_VALUE = 1000;\n\n/** One control of a form as the filter sees it. */\nexport interface FieldInput {\n name: string;\n type: string;\n value: string;\n autocomplete?: string;\n /** A checkbox or radio that is not ticked. */\n unchecked?: boolean;\n /** Marked `data-mrrocket-ignore` (itself or a part of the form around it). */\n ignored?: boolean;\n /** Marked `data-mrrocket-consent`. */\n consent?: boolean;\n}\n\nexport interface FormFields {\n email: string | null;\n name: string | null;\n /** The other fields, by name; checkbox groups joined with \", \". */\n fields: Record<string, string>;\n /** True or false when the form has a checkbox marked `data-mrrocket-consent`; undefined without one. */\n consent?: boolean;\n}\n\nconst norm = (s: string) => s.toLowerCase().replace(/[\\s_-]+/g, \"\");\n\n/** Whether a field's value may be sent: not a password, card, hidden or sensitive-looking field. */\nexport const sendable = (f: Pick<FieldInput, \"name\" | \"type\" | \"autocomplete\" | \"ignored\">) => {\n if (f.ignored || SKIP_TYPES.has(f.type)) return false;\n const ac = (f.autocomplete ?? \"\").toLowerCase();\n if (/^cc-|password|one-time-code/.test(ac)) return false;\n return !SENSITIVE.test(f.name);\n};\n\nconst isEmailField = (f: FieldInput) => f.type === \"email\" || /e-?mail/i.test(f.name) || f.autocomplete === \"email\";\n\n/**\n * The lead in a form's controls: the email (first valid one), the name (a full-name field, or first and last), the\n * consent box's state, and the other sendable fields. Pure.\n */\nexport const fieldsOf = (inputs: FieldInput[]): FormFields => {\n let email: string | null = null;\n let full: string | null = null;\n let first: string | null = null;\n let last: string | null = null;\n let consent: boolean | undefined;\n const fields: Record<string, string> = {};\n for (const f of inputs) {\n if (f.consent) {\n consent = (consent ?? false) || !f.unchecked;\n continue;\n }\n if (!f.name || !sendable(f) || f.unchecked) continue;\n const value = f.value.trim().slice(0, MAX_VALUE);\n if (!value) continue;\n const n = norm(f.name);\n const ac = (f.autocomplete ?? \"\").toLowerCase();\n if (!email && isEmailField(f) && EMAIL_RE.test(value)) {\n email = value.toLowerCase();\n continue;\n }\n if (!full && (ac === \"name\" || [\"name\", \"fullname\", \"yourname\", \"contactname\"].includes(n))) {\n full = value.slice(0, 100);\n continue;\n }\n if (!first && (ac === \"given-name\" || [\"firstname\", \"first\", \"vorname\", \"givenname\", \"fname\"].includes(n))) {\n first = value.slice(0, 60);\n continue;\n }\n if (!last && (ac === \"family-name\" || [\"lastname\", \"last\", \"nachname\", \"surname\", \"familyname\", \"lname\"].includes(n))) {\n last = value.slice(0, 60);\n continue;\n }\n const key = f.name.trim().slice(0, 60);\n if (key in fields) fields[key] = `${fields[key]}, ${value}`.slice(0, MAX_VALUE);\n else if (Object.keys(fields).length < MAX_FIELDS) fields[key] = value;\n }\n const name = full ?? ([first, last].filter(Boolean).join(\" \") || null);\n return { email, name, fields, ...(consent === undefined ? {} : { consent }) };\n};\n\n/** What the page sends to `POST /api/sdk/forms`. */\nexport interface FormLead extends FormFields {\n key: string;\n form: string;\n url: string;\n title?: string;\n referrer?: string;\n utm?: Record<string, string>;\n distinctId?: string;\n}\n\n/** The form's name: the value of `data-mrrocket-form`, else its name, id or label, else \"Form on /path\". */\nexport const formName = (form: HTMLFormElement) =>\n (form.getAttribute(\"data-mrrocket-form\") || form.getAttribute(\"name\") || form.id || form.getAttribute(\"aria-label\") || `Form on ${window.location.pathname}`)\n .trim()\n .slice(0, 100);\n\n/** Whether a form is captured: marked, or (auto) one with an email field and no password field. */\nexport const captures = (form: HTMLFormElement, auto: boolean) => {\n if (form.closest(\"[data-mrrocket-ignore]\")) return false;\n if (form.hasAttribute(\"data-mrrocket-form\")) return true;\n return auto && !!form.querySelector('input[type=\"email\"], input[name*=\"email\" i]') && !form.querySelector('input[type=\"password\"]');\n};\n\n/** The controls of a form, read for `fieldsOf`. */\nexport const inputsOf = (form: HTMLFormElement): FieldInput[] =>\n Array.from(form.elements).flatMap((el) => {\n if (!(el instanceof HTMLInputElement || el instanceof HTMLSelectElement || el instanceof HTMLTextAreaElement)) return [];\n if (el.disabled) return [];\n const type = el instanceof HTMLInputElement ? el.type.toLowerCase() : el instanceof HTMLSelectElement ? \"select\" : \"textarea\";\n const check = type === \"checkbox\" || type === \"radio\";\n const value = el instanceof HTMLSelectElement && el.multiple ? Array.from(el.selectedOptions, (o) => o.value).join(\", \") : check && el.value === \"on\" ? \"yes\" : el.value;\n return [\n {\n name: el.name || el.id || \"\",\n type,\n value,\n autocomplete: el.getAttribute(\"autocomplete\") ?? undefined,\n unchecked: check && !(el as HTMLInputElement).checked,\n ignored: !!el.closest(\"[data-mrrocket-ignore]\"),\n consent: el.hasAttribute(\"data-mrrocket-consent\"),\n },\n ];\n });\n\nexport interface WatchFormsOptions {\n host: string;\n apiKey: string;\n /** Also forms with an email field that are not marked (default false). */\n auto?: boolean;\n /** The visitor id to link the lead to, while analytics consent allows one. */\n distinctId: () => string | undefined;\n /** Whether the site already called identify(). */\n identified: () => boolean;\n /** Links this visitor's history to the person (the SDK's own identify). */\n identify: (id: string, props: Record<string, unknown>) => void;\n /** Whether analytics consent lets the SDK identify the visitor (not \"off\"). */\n tracking: () => boolean;\n referrer?: () => string | undefined;\n utm?: () => Record<string, string | undefined> | undefined;\n}\n\n/**\n * Captures submits from now on (one listener on the document, capture phase, so it sees forms that render later and\n * forms whose own handler stops the event). The lead goes out with `sendBeacon`, which survives the page leaving. Returns\n * a function that stops it. Calling it twice keeps one listener.\n */\nexport const watchForms = (o: WatchFormsOptions): (() => void) => {\n const w = window as Window & { __mrrocketFormsOff?: () => void };\n w.__mrrocketFormsOff?.();\n const onSubmit = (e: Event) => {\n const form = e.target;\n if (!(form instanceof HTMLFormElement) || !captures(form, !!o.auto)) return;\n const read = fieldsOf(inputsOf(form));\n if (!read.email) return;\n const utm = Object.fromEntries(Object.entries(o.utm?.() ?? {}).filter((x): x is [string, string] => typeof x[1] === \"string\" && !!x[1]));\n const lead: FormLead = {\n key: o.apiKey,\n form: formName(form),\n url: window.location.href.slice(0, 500),\n title: document.title.slice(0, 200) || undefined,\n referrer: o.referrer?.() || document.referrer || undefined,\n ...(Object.keys(utm).length ? { utm } : {}),\n distinctId: o.distinctId(),\n ...read,\n };\n const body = JSON.stringify(lead);\n const url = `${o.host}/api/sdk/forms`;\n // text/plain keeps it a simple request (no CORS preflight), which sendBeacon needs anyway.\n const sent = \"sendBeacon\" in navigator && navigator.sendBeacon(url, new Blob([body], { type: \"text/plain\" }));\n if (!sent) void fetch(url, { method: \"POST\", headers: { \"content-type\": \"text/plain\" }, body, keepalive: true }).catch(() => undefined);\n // Their visits before the form and after it belong to the same person from now on.\n if (o.tracking() && !o.identified()) o.identify(read.email, { email: read.email, ...(read.name ? { name: read.name } : {}) });\n };\n document.addEventListener(\"submit\", onSubmit, true);\n const off = () => {\n document.removeEventListener(\"submit\", onSubmit, true);\n if (w.__mrrocketFormsOff === off) delete w.__mrrocketFormsOff;\n };\n w.__mrrocketFormsOff = off;\n return off;\n};\n"],"mappings":"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","names":[]}
package/dist/proxy.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type CockpitConfig } from "./core.js";
2
2
  export { getCockpitConfig } from "./server.js";
3
+ export { type SecurityOptions, reportSuspiciousRequest, watchRequest } from "./security.js";
3
4
  /** What `request.cookies` (Next's `RequestCookies`) offers: read, and set so that the page of this request sees the id. */
4
5
  interface IncomingCookies {
5
6
  get(name: string): {
package/dist/proxy.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // src/proxy.ts
2
2
  import { ID_COOKIE, PREVIEW_PARAM, newDistinctId, parsePreview, redirectFor } from "./core.js";
3
3
  import { getCockpitConfig } from "./server.js";
4
+ import { reportSuspiciousRequest, watchRequest } from "./security.js";
4
5
  var withCockpitId = (incoming, outgoing) => {
5
6
  if (incoming.get(ID_COOKIE)) return;
6
7
  const id = newDistinctId();
@@ -20,6 +21,8 @@ var experimentRedirect = (request, config, options = {}) => {
20
21
  export {
21
22
  experimentRedirect,
22
23
  getCockpitConfig,
24
+ reportSuspiciousRequest,
25
+ watchRequest,
23
26
  withCockpitId
24
27
  };
25
28
  //# sourceMappingURL=proxy.js.map
package/dist/proxy.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/proxy.ts"],"sourcesContent":["import { type CockpitConfig, ID_COOKIE, PREVIEW_PARAM, newDistinctId, parsePreview, redirectFor } from \"./core\";\n\nexport { getCockpitConfig } from \"./server\";\n\n/** What `request.cookies` (Next's `RequestCookies`) offers: read, and set so that the page of this request sees the id. */\ninterface IncomingCookies {\n get(name: string): { value: string } | undefined;\n set(name: string, value: string): unknown;\n}\n\n/** What `response.cookies` (Next's `ResponseCookies`) offers: set with options. */\ninterface OutgoingCookies {\n set(name: string, value: string, options?: { path?: string; maxAge?: number; sameSite?: \"lax\" | \"strict\" | \"none\" }): unknown;\n}\n\n/**\n * For `proxy.ts`: gives a first-time visitor their `ck_id` before the page renders, so the server already assigns\n * the variant they will keep. It writes a cookie, so only use it with `consent=\"cookies\"` and only once the visitor\n * agreed; in the default cookieless mode leave it out.\n *\n * export function proxy(request: NextRequest) {\n * const response = NextResponse.next();\n * withCockpitId(request.cookies, response.cookies);\n * return response;\n * }\n */\nexport const withCockpitId = (incoming: IncomingCookies, outgoing: OutgoingCookies) => {\n if (incoming.get(ID_COOKIE)) return;\n const id = newDistinctId();\n incoming.set(ID_COOKIE, id);\n outgoing.set(ID_COOKIE, id, { path: \"/\", maxAge: 60 * 60 * 24 * 365, sameSite: \"lax\" });\n};\n\n/**\n * For `proxy.ts`: where to send this request, or null to render it. Page redirects set in MRRocket (Landing pages ›\n * Redirect) and redirect tests (Experiments) answer before the page renders, so nobody sees the old page flash. The\n * page the visitor lands on records the test's exposure (`<CockpitProvider>` reads `ck_x` there); control renders as\n * usual and the provider records it.\n *\n * A running test needs a stable visitor id to keep each person in one variant: the `ck_id` cookie (consent \"cookies\",\n * with `withCockpitId` before this), or your signed-in user id as `distinctId`. Without either, the test is left to the\n * browser (the provider redirects after the first paint); page redirects and shipped winners still answer here.\n *\n * import { NextResponse, type NextRequest } from \"next/server\";\n * import { experimentRedirect, getCockpitConfig } from \"@mrrocket/sdk/next/proxy\";\n *\n * export async function proxy(request: NextRequest) {\n * const to = experimentRedirect(request, await getCockpitConfig({ timeoutMs: 800 }));\n * return to ? NextResponse.redirect(to, 307) : NextResponse.next();\n * }\n * export const config = { matcher: [\"/pricing\", \"/old-pricing\"] };\n *\n * Keep the matcher to the pages with a redirect or a test: each matched request waits for the config (at most\n * `timeoutMs`; MRRocket unreachable means no redirect).\n */\nexport const experimentRedirect = (\n request: { url: string; cookies?: { get(name: string): { value: string } | undefined } },\n config: CockpitConfig | null,\n options: { distinctId?: string } = {},\n): string | null => {\n const id = options.distinctId ?? request.cookies?.get(ID_COOKIE)?.value ?? null;\n let preview = {};\n try {\n preview = parsePreview(new URL(request.url).searchParams.get(PREVIEW_PARAM));\n } catch {\n return null;\n }\n return redirectFor(config, request.url, id, preview)?.to ?? null;\n};\n"],"mappings":";AAAA,SAA6B,WAAW,eAAe,eAAe,cAAc,mBAAmB;AAEvG,SAAS,wBAAwB;AAwB1B,IAAM,gBAAgB,CAAC,UAA2B,aAA8B;AACnF,MAAI,SAAS,IAAI,SAAS,EAAG;AAC7B,QAAM,KAAK,cAAc;AACzB,WAAS,IAAI,WAAW,EAAE;AAC1B,WAAS,IAAI,WAAW,IAAI,EAAE,MAAM,KAAK,QAAQ,KAAK,KAAK,KAAK,KAAK,UAAU,MAAM,CAAC;AAC1F;AAwBO,IAAM,qBAAqB,CAC9B,SACA,QACA,UAAmC,CAAC,MACpB;AAChB,QAAM,KAAK,QAAQ,cAAc,QAAQ,SAAS,IAAI,SAAS,GAAG,SAAS;AAC3E,MAAI,UAAU,CAAC;AACf,MAAI;AACA,cAAU,aAAa,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IAAI,aAAa,CAAC;AAAA,EAC/E,QAAQ;AACJ,WAAO;AAAA,EACX;AACA,SAAO,YAAY,QAAQ,QAAQ,KAAK,IAAI,OAAO,GAAG,MAAM;AAChE;","names":[]}
1
+ {"version":3,"sources":["../src/proxy.ts"],"sourcesContent":["import { type CockpitConfig, ID_COOKIE, PREVIEW_PARAM, newDistinctId, parsePreview, redirectFor } from \"./core\";\n\nexport { getCockpitConfig } from \"./server\";\nexport { type SecurityOptions, reportSuspiciousRequest, watchRequest } from \"./security\";\n\n/** What `request.cookies` (Next's `RequestCookies`) offers: read, and set so that the page of this request sees the id. */\ninterface IncomingCookies {\n get(name: string): { value: string } | undefined;\n set(name: string, value: string): unknown;\n}\n\n/** What `response.cookies` (Next's `ResponseCookies`) offers: set with options. */\ninterface OutgoingCookies {\n set(name: string, value: string, options?: { path?: string; maxAge?: number; sameSite?: \"lax\" | \"strict\" | \"none\" }): unknown;\n}\n\n/**\n * For `proxy.ts`: gives a first-time visitor their `ck_id` before the page renders, so the server already assigns\n * the variant they will keep. It writes a cookie, so only use it with `consent=\"cookies\"` and only once the visitor\n * agreed; in the default cookieless mode leave it out.\n *\n * export function proxy(request: NextRequest) {\n * const response = NextResponse.next();\n * withCockpitId(request.cookies, response.cookies);\n * return response;\n * }\n */\nexport const withCockpitId = (incoming: IncomingCookies, outgoing: OutgoingCookies) => {\n if (incoming.get(ID_COOKIE)) return;\n const id = newDistinctId();\n incoming.set(ID_COOKIE, id);\n outgoing.set(ID_COOKIE, id, { path: \"/\", maxAge: 60 * 60 * 24 * 365, sameSite: \"lax\" });\n};\n\n/**\n * For `proxy.ts`: where to send this request, or null to render it. Page redirects set in MRRocket (Landing pages ›\n * Redirect) and redirect tests (Experiments) answer before the page renders, so nobody sees the old page flash. The\n * page the visitor lands on records the test's exposure (`<CockpitProvider>` reads `ck_x` there); control renders as\n * usual and the provider records it.\n *\n * A running test needs a stable visitor id to keep each person in one variant: the `ck_id` cookie (consent \"cookies\",\n * with `withCockpitId` before this), or your signed-in user id as `distinctId`. Without either, the test is left to the\n * browser (the provider redirects after the first paint); page redirects and shipped winners still answer here.\n *\n * import { NextResponse, type NextRequest } from \"next/server\";\n * import { experimentRedirect, getCockpitConfig } from \"@mrrocket/sdk/next/proxy\";\n *\n * export async function proxy(request: NextRequest) {\n * const to = experimentRedirect(request, await getCockpitConfig({ timeoutMs: 800 }));\n * return to ? NextResponse.redirect(to, 307) : NextResponse.next();\n * }\n * export const config = { matcher: [\"/pricing\", \"/old-pricing\"] };\n *\n * Keep the matcher to the pages with a redirect or a test: each matched request waits for the config (at most\n * `timeoutMs`; MRRocket unreachable means no redirect).\n */\nexport const experimentRedirect = (\n request: { url: string; cookies?: { get(name: string): { value: string } | undefined } },\n config: CockpitConfig | null,\n options: { distinctId?: string } = {},\n): string | null => {\n const id = options.distinctId ?? request.cookies?.get(ID_COOKIE)?.value ?? null;\n let preview = {};\n try {\n preview = parsePreview(new URL(request.url).searchParams.get(PREVIEW_PARAM));\n } catch {\n return null;\n }\n return redirectFor(config, request.url, id, preview)?.to ?? null;\n};\n"],"mappings":";AAAA,SAA6B,WAAW,eAAe,eAAe,cAAc,mBAAmB;AAEvG,SAAS,wBAAwB;AACjC,SAA+B,yBAAyB,oBAAoB;AAwBrE,IAAM,gBAAgB,CAAC,UAA2B,aAA8B;AACnF,MAAI,SAAS,IAAI,SAAS,EAAG;AAC7B,QAAM,KAAK,cAAc;AACzB,WAAS,IAAI,WAAW,EAAE;AAC1B,WAAS,IAAI,WAAW,IAAI,EAAE,MAAM,KAAK,QAAQ,KAAK,KAAK,KAAK,KAAK,UAAU,MAAM,CAAC;AAC1F;AAwBO,IAAM,qBAAqB,CAC9B,SACA,QACA,UAAmC,CAAC,MACpB;AAChB,QAAM,KAAK,QAAQ,cAAc,QAAQ,SAAS,IAAI,SAAS,GAAG,SAAS;AAC3E,MAAI,UAAU,CAAC;AACf,MAAI;AACA,cAAU,aAAa,IAAI,IAAI,QAAQ,GAAG,EAAE,aAAa,IAAI,aAAa,CAAC;AAAA,EAC/E,QAAQ;AACJ,WAAO;AAAA,EACX;AACA,SAAO,YAAY,QAAQ,QAAQ,KAAK,IAAI,OAAO,GAAG,MAAM;AAChE;","names":[]}
@@ -421,7 +421,7 @@ interface FlowsController {
421
421
 
422
422
  type VariantId = string;
423
423
  /** This build of the SDK. Sent with the config request so MRRocket knows what the site runs (error tracking, flags). */
424
- declare const SDK_VERSION = "0.11.0";
424
+ declare const SDK_VERSION = "0.13.0";
425
425
  /** One variant of an experiment. `changes` replaces texts that the page marks with `<Text id>`. */
426
426
  interface VariantConfig {
427
427
  id: VariantId;
@@ -421,7 +421,7 @@ interface FlowsController {
421
421
 
422
422
  type VariantId = string;
423
423
  /** This build of the SDK. Sent with the config request so MRRocket knows what the site runs (error tracking, flags). */
424
- declare const SDK_VERSION = "0.11.0";
424
+ declare const SDK_VERSION = "0.13.0";
425
425
  /** One variant of an experiment. `changes` replaces texts that the page marks with `<Text id>`. */
426
426
  interface VariantConfig {
427
427
  id: VariantId;
@@ -49,7 +49,7 @@ module.exports = __toCommonJS(native_exports);
49
49
  var import_react3 = require("react");
50
50
 
51
51
  // src/core.ts
52
- var SDK_VERSION = "0.11.0";
52
+ var SDK_VERSION = "0.13.0";
53
53
  var propOf = (person, name) => {
54
54
  const want = name.trim().toLowerCase();
55
55
  for (const [k, v] of Object.entries(person)) if (k.toLowerCase() === want) return v;