luchy 0.1.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -21,9 +21,58 @@ function collectBrowserData() {
21
21
  language: navigator.language || navigator.languages?.[0] || "en"
22
22
  };
23
23
  }
24
+ function parseProps(raw) {
25
+ if (!raw) return void 0;
26
+ try {
27
+ const value = JSON.parse(raw);
28
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
29
+ return void 0;
30
+ }
31
+ return value;
32
+ } catch {
33
+ return void 0;
34
+ }
35
+ }
24
36
  function luchy(options) {
25
37
  const endpoint = options.endpoint || "https://api.luchy.app/ingest";
26
- const { apiKey, trackLocalhost = false } = options;
38
+ const { apiKey, trackLocalhost = false, script } = options;
39
+ let identity = options.identity;
40
+ let props = options.props;
41
+ function currentIdentity() {
42
+ if (!script) return identity;
43
+ return script.getAttribute("data-identity") || void 0;
44
+ }
45
+ function currentProps() {
46
+ if (!script) return props;
47
+ return parseProps(script.getAttribute("data-props"));
48
+ }
49
+ function identify(token, nextProps) {
50
+ if (!script) {
51
+ identity = token;
52
+ props = nextProps;
53
+ return;
54
+ }
55
+ script.setAttribute("data-identity", token);
56
+ if (nextProps) {
57
+ script.setAttribute("data-props", JSON.stringify(nextProps));
58
+ } else {
59
+ script.removeAttribute("data-props");
60
+ }
61
+ }
62
+ function reset() {
63
+ if (!script) {
64
+ identity = void 0;
65
+ props = void 0;
66
+ return;
67
+ }
68
+ script.removeAttribute("data-identity");
69
+ script.removeAttribute("data-props");
70
+ }
71
+ function withProps(payload) {
72
+ const global = currentProps();
73
+ if (!global) return payload;
74
+ return { ...global, ...payload };
75
+ }
27
76
  const shouldTrack = () => {
28
77
  if (trackLocalhost) return true;
29
78
  return location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
@@ -39,6 +88,8 @@ function luchy(options) {
39
88
  data: {
40
89
  pathname,
41
90
  referrer,
91
+ payload: withProps(data?.payload),
92
+ identity: currentIdentity(),
42
93
  ...browserData
43
94
  }
44
95
  });
@@ -57,7 +108,8 @@ function luchy(options) {
57
108
  // `payload`, NOT `props`. The ingest endpoint validates with a
58
109
  // non-passthrough zod object, so an unknown key is stripped and the
59
110
  // event is stored with no properties at all — 200, success, no data.
60
- payload,
111
+ payload: withProps(payload),
112
+ identity: currentIdentity(),
61
113
  ...browserData
62
114
  }
63
115
  });
@@ -219,11 +271,9 @@ function luchy(options) {
219
271
  const key = attr.name.slice(prefix.length).replace(/-/g, "_");
220
272
  payload[key] = attr.value;
221
273
  }
222
- trackEvent(
223
- name,
224
- Object.keys(payload).length > 0 ? payload : void 0,
225
- { type: "click" }
226
- );
274
+ trackEvent(name, Object.keys(payload).length > 0 ? payload : void 0, {
275
+ type: "click"
276
+ });
227
277
  };
228
278
  document.addEventListener("click", dataEventClickListener);
229
279
  }
@@ -268,6 +318,8 @@ function luchy(options) {
268
318
  return {
269
319
  trackPageview,
270
320
  trackEvent,
321
+ identify,
322
+ reset,
271
323
  setOptions
272
324
  };
273
325
  }
@@ -280,7 +332,8 @@ if (typeof document !== "undefined" && document.currentScript) {
280
332
  const tracker = luchy({
281
333
  apiKey,
282
334
  endpoint,
283
- trackLocalhost
335
+ trackLocalhost,
336
+ script
284
337
  });
285
338
  const autoPageviewsAttr = script.getAttribute("data-auto-pageviews");
286
339
  const autoOutboundAttr = script.getAttribute("data-auto-outbound");
@@ -0,0 +1,232 @@
1
+ import { ReactElement } from 'react';
2
+ import { MiddlewareFunction, RouterContextProvider } from 'react-router';
3
+ import { LuchyIdentity } from './identity.js';
4
+ import { LuchyPayload, LuchyTraits } from './server.js';
5
+ /**
6
+ * React Router (v7.9+) — server events derived from requests, plus the
7
+ * identity the browser script needs.
8
+ *
9
+ * A React Router app already says what it did, in the request: every console
10
+ * mutation is a form POST whose `intent` field names it (`rotate`,
11
+ * `invite-member`, `create-api-key`, …), every JSON API mutation is
12
+ * discriminated by its method, and every auth verb has a path that names the
13
+ * operation. So instead of sprinkling `track` calls across route modules —
14
+ * where a new intent is only tracked once somebody remembers to instrument it
15
+ * — a root middleware derives the event from the request it is already
16
+ * holding: normalized route + intent (or method).
17
+ *
18
+ * The same middleware resolves who the request belongs to, once, and hands a
19
+ * signed token to the root loader (`getLuchy`) so `<LuchyScript>` can put it
20
+ * on the browser script. Server events and browser pageviews then land in the
21
+ * same session.
22
+ *
23
+ * Only requests React Router routes are seen — anything the Worker answers
24
+ * before handing over to React Router is invisible here.
25
+ *
26
+ * Not Cloudflare-only: shipping goes through the injected `waitUntil`, so this
27
+ * module imports no runtime.
28
+ *
29
+ * @module
30
+ */
31
+ export type { LuchyIdentity, LuchyTraits };
32
+ /** A property bag. Nested objects are not supported by the ingest API. */
33
+ export type LuchyRequestPayload = LuchyPayload;
34
+ /**
35
+ * True for the methods worth an event.
36
+ *
37
+ * Reads are the browser tracker's job; only mutations describe what the app
38
+ * did.
39
+ *
40
+ * ```ts
41
+ * isMutatingMethod('post'); // true
42
+ * isMutatingMethod('GET'); // false
43
+ * ```
44
+ */
45
+ export declare function isMutatingMethod(method: string): boolean;
46
+ /**
47
+ * True when the request body is worth parsing for an `intent` field.
48
+ *
49
+ * Only urlencoded bodies are read: a multipart upload could be megabytes, and
50
+ * no route reads an intent out of one.
51
+ *
52
+ * ```ts
53
+ * carriesIntent(request.headers.get('content-type'));
54
+ * ```
55
+ */
56
+ export declare function carriesIntent(contentType: string | null | undefined): boolean;
57
+ /**
58
+ * Turns a pathname into a route name: `/users/aBc123.../identities` →
59
+ * `users/:id/identities`.
60
+ *
61
+ * Client-side submissions land on React Router's single-fetch URL
62
+ * (`/users/abc.data`), so the `.data` suffix is stripped — otherwise the same
63
+ * action would report under two names depending on whether the page was
64
+ * hydrated. `/` and the single-fetch root (`/_root.data`) both become `home`.
65
+ */
66
+ export declare function normalizeRoute(pathname: string): string;
67
+ /** What `serverEventName` needs to know about a finished request. */
68
+ export type ServerEventInput = {
69
+ method: string;
70
+ /** The raw pathname, before normalization. */
71
+ pathname: string;
72
+ /** The response status. Only consulted when `trackFailures` is false. */
73
+ status: number;
74
+ /** The submitted `intent`, if the body carried one. */
75
+ intent?: string | null;
76
+ };
77
+ /** The naming and filtering knobs, shared by the middleware and the pure helper. */
78
+ export type ServerEventNameConfig = Pick<LuchyMiddlewareOptions, 'ignorePrefixes' | 'ignoreRouteSuffixes' | 'ignoreEvents' | 'trackFailures' | 'methodSuffix'>;
79
+ /**
80
+ * The event name for a mutation, or `null` when the request is not worth an
81
+ * event (a read, machine traffic, an ignored route, a failure the app does not
82
+ * track).
83
+ *
84
+ * Names are `route:intent` — an intent alone is ambiguous (`delete` and
85
+ * `update` appear on several routes), the pair is not. Without an intent the
86
+ * name is the bare route, or `route:post` / `route:delete` when
87
+ * `methodSuffix` is on, which keeps a REST route's methods distinct.
88
+ *
89
+ * ```ts
90
+ * serverEventName({ method: 'POST', pathname: '/team', status: 200, intent: 'invite' });
91
+ * // 'team:invite'
92
+ * ```
93
+ */
94
+ export declare function serverEventName(input: ServerEventInput, config?: ServerEventNameConfig): string | null;
95
+ /** What the root middleware gets from React Router. */
96
+ export type LuchyMiddlewareArgs = Parameters<MiddlewareFunction>[0];
97
+ /**
98
+ * Generic over what `next()` resolves to, so it fits both framework mode's
99
+ * `Route.MiddlewareFunction` (a Response) and data-mode route objects.
100
+ */
101
+ export type LuchyMiddleware = <Result>(args: LuchyMiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
102
+ /**
103
+ * An identity plus the props to stamp on every event of the request, and the
104
+ * person's profile traits (sent via identify, deduped per isolate).
105
+ */
106
+ export type LuchyRequestIdentity = LuchyIdentity & {
107
+ props?: LuchyRequestPayload;
108
+ traits?: LuchyTraits;
109
+ };
110
+ /** Clears the identify dedupe. For tests. */
111
+ export declare function resetIdentifyCache(): void;
112
+ export type LuchyMiddlewareOptions = {
113
+ /** A Luchy API key. The public ingest key is fine — it ships in the HTML. */
114
+ apiKey: string;
115
+ /**
116
+ * The project's secret key (`lsk_…`). Without it no identity is sent —
117
+ * ingest would drop an unsigned one anyway — and traits are not identified.
118
+ */
119
+ secret?: string;
120
+ /** The ingest base, without a trailing slash. Defaults to the hosted API. */
121
+ endpoint?: string;
122
+ /**
123
+ * Defaults to `true`. When false no server event is produced — no clone, no
124
+ * request read, no network. `getLuchy` keeps working so the browser script
125
+ * still renders.
126
+ */
127
+ enabled?: boolean;
128
+ /**
129
+ * How to keep the event alive after the response is sent. Called only for
130
+ * requests that produce an event. On Cloudflare:
131
+ * `({ context }) => (p) => context.get(cfContext).ctx.waitUntil(p)`.
132
+ */
133
+ waitUntil: (args: LuchyMiddlewareArgs) => (promise: Promise<unknown>) => void;
134
+ /**
135
+ * Who the request belongs to. Resolved lazily — only when an event ships or
136
+ * `getLuchy` asks — and at most once per request. A rejection is reported
137
+ * to `onError` and the request is treated as anonymous.
138
+ *
139
+ * Return `traits` (`{ name, email }`, …) to keep the person's profile up to
140
+ * date: they are sent via identify through `waitUntil`, at most once per
141
+ * isolate per hour for the same user and traits. Requires `secret`.
142
+ */
143
+ identity?: (args: LuchyMiddlewareArgs) => LuchyRequestIdentity | undefined | Promise<LuchyRequestIdentity | undefined>;
144
+ /**
145
+ * Raw-pathname prefixes to drop, matched before normalization. Merged with
146
+ * the built-in defaults (`/__manifest`) rather than replacing them.
147
+ */
148
+ ignorePrefixes?: string[];
149
+ /**
150
+ * Normalized-route suffixes to drop, e.g. `/user-keys/validate`. Matched on
151
+ * the route so that literal app ids in the path do not each need an entry.
152
+ */
153
+ ignoreRouteSuffixes?: string[];
154
+ /** Fully-formed event names to drop, e.g. `notifications:markRead`. */
155
+ ignoreEvents?: string[];
156
+ /**
157
+ * Defaults to `false`: a 4xx/5xx produces no event. Turn it on when failed
158
+ * mutations are themselves product signal (the `status` payload field tells
159
+ * them apart).
160
+ */
161
+ trackFailures?: boolean;
162
+ /**
163
+ * Defaults to `false`: an intent-less mutation is named after its route
164
+ * alone. Turn it on for REST-ish routes, where `PATCH` and `DELETE` on the
165
+ * same path should report as `route:patch` and `route:delete`.
166
+ */
167
+ methodSuffix?: boolean;
168
+ /** Called when anything in the pipeline fails. Nothing else surfaces it. */
169
+ onError?: (error: unknown) => void;
170
+ /** Transport override, for tests and scoped runtimes (service bindings). */
171
+ fetch?: typeof globalThis.fetch;
172
+ };
173
+ /** What the browser script needs: spread it into `<LuchyScript>`. */
174
+ export type LuchyClientConfig = {
175
+ apiKey: string;
176
+ /** The ingest base, as the script's `data-endpoint` takes it. */
177
+ endpoint: string;
178
+ /** The signed identity token, when the request has an identity. */
179
+ identity?: string;
180
+ props?: LuchyRequestPayload;
181
+ };
182
+ /**
183
+ * The root middleware. Every mutation React Router serves becomes an event;
184
+ * every request gets the state `getLuchy` reads.
185
+ *
186
+ * ```ts
187
+ * // app/root.tsx
188
+ * export const middleware: Route.MiddlewareFunction[] = [
189
+ * luchyMiddleware({
190
+ * apiKey: env.LUCHY_API_KEY,
191
+ * secret: env.LUCHY_SECRET,
192
+ * waitUntil: ({ context }) => (p) => context.get(cfContext).ctx.waitUntil(p),
193
+ * identity: async ({ context }) => {
194
+ * const s = await context.get(appContext).getMaybeSessionContext();
195
+ * if (!s) {
196
+ * return undefined;
197
+ * }
198
+ *
199
+ * return { user: s.user.id, tenant: String(s.workspaceId) };
200
+ * }
201
+ * })
202
+ * ];
203
+ * ```
204
+ */
205
+ export declare function luchyMiddleware(options: LuchyMiddlewareOptions): LuchyMiddleware;
206
+ /**
207
+ * The browser script's config for this request, for the root loader. Resolves
208
+ * the request's identity (once — the middleware shares it) and signs it.
209
+ *
210
+ * ```ts
211
+ * export async function loader({ context }: Route.LoaderArgs) {
212
+ * return { luchy: await getLuchy(context) };
213
+ * }
214
+ * ```
215
+ */
216
+ export declare function getLuchy(context: Readonly<RouterContextProvider>): Promise<LuchyClientConfig>;
217
+ export type LuchyScriptProps = LuchyClientConfig & {
218
+ /** Defaults to the CDN build. */
219
+ src?: string;
220
+ /** For a CSP that allows scripts by nonce. */
221
+ nonce?: string;
222
+ };
223
+ /**
224
+ * The browser script tag. Render it in the root layout with what `getLuchy`
225
+ * returned. The script re-reads `data-identity` / `data-props` on every send,
226
+ * so a re-render after login or logout is picked up without a reload.
227
+ *
228
+ * It must be server-rendered: React does not execute `<script>` elements it
229
+ * creates on the client.
230
+ */
231
+ export declare function LuchyScript(props: LuchyScriptProps): ReactElement;
232
+ //# sourceMappingURL=react-router.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"react-router.d.ts","sourceRoot":"","sources":["../src/react-router.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,244 @@
1
+ import { createElement } from "react";
2
+ import { createContext } from "react-router";
3
+ import { DEFAULT_ENDPOINT, createLuchy, signIdentity } from "./server.js";
4
+ const MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
5
+ const DEFAULT_IGNORE_PREFIXES = ["/__manifest"];
6
+ const INTENT_PATTERN = /^[A-Za-z0-9_-]{1,40}$/;
7
+ function isMutatingMethod(method) {
8
+ return MUTATING_METHODS.has(method.toUpperCase());
9
+ }
10
+ function carriesIntent(contentType) {
11
+ return (contentType ?? "").includes("application/x-www-form-urlencoded");
12
+ }
13
+ function isIdSegment(segment) {
14
+ if (segment.length === 0) return false;
15
+ if (/^\d+$/.test(segment)) return true;
16
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment)) return true;
17
+ return segment.length >= 16 && /\d/.test(segment);
18
+ }
19
+ function normalizeRoute(pathname) {
20
+ const segments = pathname.replace(/\.data$/, "").split("/").filter(Boolean);
21
+ if (segments.length === 0) return "home";
22
+ if (segments.length === 1 && segments[0] === "_root") return "home";
23
+ return segments.map((s) => isIdSegment(s) ? ":id" : s).join("/");
24
+ }
25
+ function serverEventName(input, config = {}) {
26
+ const method = input.method.toUpperCase();
27
+ if (!MUTATING_METHODS.has(method)) return null;
28
+ if (!config.trackFailures && input.status >= 400) return null;
29
+ const prefixes = [
30
+ ...DEFAULT_IGNORE_PREFIXES,
31
+ ...config.ignorePrefixes ?? []
32
+ ];
33
+ if (prefixes.some((p) => input.pathname.startsWith(p))) return null;
34
+ const route = normalizeRoute(input.pathname);
35
+ if ((config.ignoreRouteSuffixes ?? []).some((s) => route.endsWith(s))) {
36
+ return null;
37
+ }
38
+ const intent = input.intent && INTENT_PATTERN.test(input.intent) ? input.intent : null;
39
+ const name = intent ? `${route}:${intent}` : config.methodSuffix ? `${route}:${method.toLowerCase()}` : route;
40
+ if ((config.ignoreEvents ?? []).includes(name)) return null;
41
+ return name;
42
+ }
43
+ const IDENTIFY_TTL_MS = 60 * 60 * 1e3;
44
+ const IDENTIFY_MAX_ENTRIES = 1e3;
45
+ const identified = /* @__PURE__ */ new Map();
46
+ function traitsHash(traits) {
47
+ const json = JSON.stringify(
48
+ Object.keys(traits).sort().map((key) => [key, traits[key]])
49
+ );
50
+ let hash = 2166136261;
51
+ for (let i = 0; i < json.length; i++) {
52
+ hash ^= json.charCodeAt(i);
53
+ hash = Math.imul(hash, 16777619);
54
+ }
55
+ return (hash >>> 0).toString(36);
56
+ }
57
+ function claimIdentify(key, traits, now) {
58
+ const hash = traitsHash(traits);
59
+ const seen = identified.get(key);
60
+ if (seen && seen.hash === hash && now - seen.at < IDENTIFY_TTL_MS) {
61
+ return false;
62
+ }
63
+ identified.delete(key);
64
+ if (identified.size >= IDENTIFY_MAX_ENTRIES) {
65
+ const oldest = identified.keys().next().value;
66
+ if (oldest !== void 0) identified.delete(oldest);
67
+ }
68
+ identified.set(key, { hash, at: now });
69
+ return true;
70
+ }
71
+ function resetIdentifyCache() {
72
+ identified.clear();
73
+ }
74
+ const luchyContext = createContext(null);
75
+ function clientInfo(request) {
76
+ const headers = request.headers;
77
+ const cf = request.cf;
78
+ const cfCountry = typeof cf?.country === "string" ? cf.country : void 0;
79
+ const language = headers.get("accept-language")?.split(",")[0]?.split(";")[0]?.trim();
80
+ return {
81
+ ip: headers.get("cf-connecting-ip") ?? void 0,
82
+ country: cfCountry ?? headers.get("cf-ipcountry") ?? void 0,
83
+ language: language || void 0,
84
+ userAgent: headers.get("user-agent") ?? void 0
85
+ };
86
+ }
87
+ function luchyMiddleware(options) {
88
+ const enabled = options.enabled ?? true;
89
+ const endpoint = (options.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
90
+ const prefixes = [
91
+ ...DEFAULT_IGNORE_PREFIXES,
92
+ ...options.ignorePrefixes ?? []
93
+ ];
94
+ const luchy = createLuchy({
95
+ apiKey: options.apiKey,
96
+ secret: options.secret,
97
+ endpoint,
98
+ fetch: options.fetch,
99
+ onError: options.onError
100
+ });
101
+ function identify(args, user, traits) {
102
+ if (!claimIdentify(`${options.apiKey}\0${user}`, traits, Date.now())) {
103
+ return;
104
+ }
105
+ try {
106
+ options.waitUntil(args)(luchy.identify(user, traits));
107
+ } catch (error) {
108
+ options.onError?.(error);
109
+ }
110
+ }
111
+ function identityResolver(args) {
112
+ let memo;
113
+ async function resolve() {
114
+ if (!options.identity) {
115
+ return void 0;
116
+ }
117
+ try {
118
+ const resolved = await options.identity(args);
119
+ if (!resolved) {
120
+ return void 0;
121
+ }
122
+ const { props, traits, ...identity } = resolved;
123
+ if (enabled && traits) {
124
+ identify(args, identity.user, traits);
125
+ }
126
+ const token = options.secret ? await signIdentity(identity, options.secret) : void 0;
127
+ return { token, props };
128
+ } catch (error) {
129
+ options.onError?.(error);
130
+ return void 0;
131
+ }
132
+ }
133
+ return () => {
134
+ memo ?? (memo = resolve());
135
+ return memo;
136
+ };
137
+ }
138
+ return async (args, next) => {
139
+ const { request } = args;
140
+ const resolveIdentity = identityResolver(args);
141
+ args.context.set(luchyContext, {
142
+ apiKey: options.apiKey,
143
+ endpoint,
144
+ resolveIdentity
145
+ });
146
+ if (!enabled) {
147
+ return next();
148
+ }
149
+ if (!isMutatingMethod(request.method)) {
150
+ return next();
151
+ }
152
+ const url = new URL(request.url);
153
+ if (prefixes.some((p) => url.pathname.startsWith(p))) {
154
+ return next();
155
+ }
156
+ const clone = carriesIntent(request.headers.get("content-type")) ? request.clone() : null;
157
+ async function ship(status) {
158
+ const intent = clone ? new URLSearchParams(await clone.text()).get("intent") : null;
159
+ const name = serverEventName(
160
+ { method: request.method, pathname: url.pathname, status, intent },
161
+ options
162
+ );
163
+ if (!name) {
164
+ return;
165
+ }
166
+ const who = await resolveIdentity();
167
+ await luchy.track(
168
+ {
169
+ name,
170
+ type: "server",
171
+ // The raw pathname, not the normalized route: the name carries the
172
+ // shape, the pathname keeps the detail.
173
+ pathname: url.pathname,
174
+ // `status` last: it is the tracker's own field, props cannot fake it.
175
+ payload: { ...who?.props, status },
176
+ ...clientInfo(request)
177
+ },
178
+ who?.token
179
+ );
180
+ }
181
+ function schedule(status) {
182
+ try {
183
+ options.waitUntil(args)(
184
+ ship(status).catch((error) => {
185
+ options.onError?.(error);
186
+ })
187
+ );
188
+ } catch (error) {
189
+ options.onError?.(error);
190
+ }
191
+ }
192
+ let result;
193
+ try {
194
+ result = await next();
195
+ } catch (thrown) {
196
+ schedule(thrown instanceof Response ? thrown.status : 500);
197
+ throw thrown;
198
+ }
199
+ schedule(result instanceof Response ? result.status : 200);
200
+ return result;
201
+ };
202
+ }
203
+ async function getLuchy(context) {
204
+ const state = context.get(luchyContext);
205
+ if (!state) {
206
+ throw new Error(
207
+ "getLuchy: luchyMiddleware is not installed on the root route"
208
+ );
209
+ }
210
+ const config = {
211
+ apiKey: state.apiKey,
212
+ endpoint: state.endpoint
213
+ };
214
+ const resolved = await state.resolveIdentity();
215
+ if (resolved?.token) {
216
+ config.identity = resolved.token;
217
+ }
218
+ if (resolved?.props) {
219
+ config.props = resolved.props;
220
+ }
221
+ return config;
222
+ }
223
+ const DEFAULT_SRC = "https://cdn.luchy.app/luchy.min.js";
224
+ function LuchyScript(props) {
225
+ return createElement("script", {
226
+ src: props.src ?? DEFAULT_SRC,
227
+ defer: true,
228
+ nonce: props.nonce,
229
+ "data-api-key": props.apiKey,
230
+ "data-endpoint": props.endpoint,
231
+ "data-identity": props.identity,
232
+ "data-props": props.props ? JSON.stringify(props.props) : void 0
233
+ });
234
+ }
235
+ export {
236
+ LuchyScript,
237
+ carriesIntent,
238
+ getLuchy,
239
+ isMutatingMethod,
240
+ luchyMiddleware,
241
+ normalizeRoute,
242
+ resetIdentifyCache,
243
+ serverEventName
244
+ };