luchy 0.2.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");
@@ -1,24 +1,36 @@
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';
1
5
  /**
2
- * React Router (v7+) on Cloudflare Workers — server events derived from
3
- * requests.
6
+ * React Router (v7.9+) — server events derived from requests, plus the
7
+ * identity the browser script needs.
4
8
  *
5
9
  * A React Router app already says what it did, in the request: every console
6
10
  * mutation is a form POST whose `intent` field names it (`rotate`,
7
11
  * `invite-member`, `create-api-key`, …), every JSON API mutation is
8
12
  * discriminated by its method, and every auth verb has a path that names the
9
- * operation. So instead of sprinkling `trackEvent` calls across route modules
10
- * — where a new intent is only tracked once somebody remembers to instrument
11
- * it — the Worker derives the event from the request it is already holding:
12
- * normalized route + intent (or method). One hook, zero per-route code, and a
13
- * new intent is tracked the day it is written.
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).
14
17
  *
15
- * This module is server-only but not Cloudflare-only: the flush context is
16
- * structural (`{ waitUntil }`), so anything with that shape works.
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.
17
28
  *
18
29
  * @module
19
30
  */
31
+ export type { LuchyIdentity, LuchyTraits };
20
32
  /** A property bag. Nested objects are not supported by the ingest API. */
21
- export type LuchyRequestPayload = Record<string, string | number | boolean>;
33
+ export type LuchyRequestPayload = LuchyPayload;
22
34
  /**
23
35
  * True for the methods worth an event.
24
36
  *
@@ -62,8 +74,8 @@ export type ServerEventInput = {
62
74
  /** The submitted `intent`, if the body carried one. */
63
75
  intent?: string | null;
64
76
  };
65
- /** The naming and filtering knobs, shared by the tracker and the pure helper. */
66
- export type ServerEventNameConfig = Pick<RequestTrackerOptions, 'ignorePrefixes' | 'ignoreRouteSuffixes' | 'ignoreEvents' | 'trackFailures' | 'methodSuffix'>;
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'>;
67
79
  /**
68
80
  * The event name for a mutation, or `null` when the request is not worth an
69
81
  * event (a read, machine traffic, an ignored route, a failure the app does not
@@ -80,17 +92,55 @@ export type ServerEventNameConfig = Pick<RequestTrackerOptions, 'ignorePrefixes'
80
92
  * ```
81
93
  */
82
94
  export declare function serverEventName(input: ServerEventInput, config?: ServerEventNameConfig): string | null;
83
- export type RequestTrackerOptions = {
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 = {
84
113
  /** A Luchy API key. The public ingest key is fine — it ships in the HTML. */
85
114
  apiKey: string;
86
- /** The API root, without a trailing slash. Defaults to the hosted API. */
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. */
87
121
  endpoint?: string;
88
122
  /**
89
- * Defaults to `true`. When false every call is a no-op — no clone, no
90
- * request read, no network — so apps can pass
91
- * `env.APP_ENV === 'production'` and leave the wiring in place.
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.
92
126
  */
93
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>;
94
144
  /**
95
145
  * Raw-pathname prefixes to drop, matched before normalization. Merged with
96
146
  * the built-in defaults (`/__manifest`) rather than replacing them.
@@ -115,50 +165,68 @@ export type RequestTrackerOptions = {
115
165
  * same path should report as `route:patch` and `route:delete`.
116
166
  */
117
167
  methodSuffix?: boolean;
118
- /**
119
- * App enrichment — session ids, actor labels, plan names. It runs inside
120
- * `waitUntil` (never on the request's critical path), its result is merged
121
- * over the tracker's own `status` field, and a rejection is swallowed: a
122
- * failed lookup costs the payload, not the event.
123
- */
124
- payload?: (request: Request, response: Response) => Promise<LuchyRequestPayload> | LuchyRequestPayload;
125
168
  /** Called when anything in the pipeline fails. Nothing else surfaces it. */
126
169
  onError?: (error: unknown) => void;
170
+ /** Transport override, for tests and scoped runtimes (service bindings). */
171
+ fetch?: typeof globalThis.fetch;
127
172
  };
128
- /**
129
- * Flushes work after the response is sent. Structurally Cloudflare's
130
- * `ExecutionContext`, declared here so this module imports no runtime types.
131
- */
132
- export type FlushContext = {
133
- waitUntil(promise: Promise<unknown>): void;
134
- };
135
- /** Ends a tracked request. Never throws, never blocks the response. */
136
- export type FinishFn = (response: Response, ctx: FlushContext) => void;
137
- export type RequestTracker = {
138
- /**
139
- * Called with the incoming request, before the React Router handler consumes
140
- * its body. Returns the function to call with the response.
141
- */
142
- begin(request: Request): FinishFn;
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;
143
181
  };
144
182
  /**
145
- * Creates the request tracker: one hook in the Worker's `fetch`, and every
146
- * mutation the app serves becomes an event.
183
+ * The root middleware. Every mutation React Router serves becomes an event;
184
+ * every request gets the state `getLuchy` reads.
147
185
  *
148
- * `begin` must run before the request is handed to React Router, because it is
149
- * what clones the body — once the handler has read it, it is gone. `finish`
150
- * does everything else on `ctx.waitUntil`, so the response is not held up by
151
- * analytics.
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.
152
209
  *
153
- * @example
154
210
  * ```ts
155
- * const tracker = createRequestTracker({ apiKey, enabled: env.APP_ENV === 'production' })
156
- * // in fetch(request, env, ctx):
157
- * const finish = tracker.begin(request)
158
- * const response = await requestHandler(request, loadContext)
159
- * finish(response, ctx)
160
- * return response
211
+ * export async function loader({ context }: Route.LoaderArgs) {
212
+ * return { luchy: await getLuchy(context) };
213
+ * }
161
214
  * ```
162
215
  */
163
- export declare function createRequestTracker(options: RequestTrackerOptions): RequestTracker;
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;
164
232
  //# sourceMappingURL=react-router.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"react-router.d.ts","sourceRoot":"","sources":["../src/react-router.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,0EAA0E;AAC1E,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAe5E;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE7E;AAaD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQvD;AAED,qEAAqE;AACrE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF,iFAAiF;AACjF,MAAM,MAAM,qBAAqB,GAAG,IAAI,CACtC,qBAAqB,EACnB,gBAAgB,GAChB,qBAAqB,GACrB,cAAc,GACd,eAAe,GACf,cAAc,CACjB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,gBAAgB,EACvB,MAAM,GAAE,qBAA0B,GACjC,MAAM,GAAG,IAAI,CA0Bf;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CACR,OAAO,EAAE,OAAO,EAChB,QAAQ,EAAE,QAAQ,KACf,OAAO,CAAC,mBAAmB,CAAC,GAAG,mBAAmB,CAAC;IACxD,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI,CAAC;CAC5C,CAAC;AAEF,uEAAuE;AACvE,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,QAAQ,EAAE,GAAG,EAAE,YAAY,KAAK,IAAI,CAAC;AAEvE,MAAM,MAAM,cAAc,GAAG;IAC3B;;;OAGG;IACH,KAAK,CAAC,OAAO,EAAE,OAAO,GAAG,QAAQ,CAAC;CACnC,CAAC;AAKF;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,qBAAqB,GAC7B,cAAc,CAgFhB"}
1
+ {"version":3,"file":"react-router.d.ts","sourceRoot":"","sources":["../src/react-router.ts"],"names":[],"mappings":"AAAA,OAAO,EAAiB,KAAK,YAAY,EAAE,MAAM,OAAO,CAAC;AACzD,OAAO,EAEL,KAAK,kBAAkB,EACvB,KAAK,qBAAqB,EAC3B,MAAM,cAAc,CAAC;AACtB,OAAO,EAAgB,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AACjE,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,WAAW,EACjB,MAAM,aAAa,CAAC;AAErB;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAEH,YAAY,EAAE,aAAa,EAAE,WAAW,EAAE,CAAC;AAE3C,0EAA0E;AAC1E,MAAM,MAAM,mBAAmB,GAAG,YAAY,CAAC;AAe/C;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAExD;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAE7E;AAaD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAQvD;AAED,qEAAqE;AACrE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,8CAA8C;IAC9C,QAAQ,EAAE,MAAM,CAAC;IACjB,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACxB,CAAC;AAEF,oFAAoF;AACpF,MAAM,MAAM,qBAAqB,GAAG,IAAI,CACtC,sBAAsB,EACpB,gBAAgB,GAChB,qBAAqB,GACrB,cAAc,GACd,eAAe,GACf,cAAc,CACjB,CAAC;AAEF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,gBAAgB,EACvB,MAAM,GAAE,qBAA0B,GACjC,MAAM,GAAG,IAAI,CA0Bf;AAED,uDAAuD;AACvD,MAAM,MAAM,mBAAmB,GAAG,UAAU,CAAC,kBAAkB,CAAC,CAAC,CAAC,CAAC,CAAC;AAEpE;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,MAAM,EACnC,IAAI,EAAE,mBAAmB,EACzB,IAAI,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,KACxB,OAAO,CAAC,MAAM,CAAC,CAAC;AAErB;;;GAGG;AACH,MAAM,MAAM,oBAAoB,GAAG,aAAa,GAAG;IACjD,KAAK,CAAC,EAAE,mBAAmB,CAAC;IAC5B,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB,CAAC;AAkDF,6CAA6C;AAC7C,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC;AAED,MAAM,MAAM,sBAAsB,GAAG;IACnC,6EAA6E;IAC7E,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,SAAS,EAAE,CAAC,IAAI,EAAE,mBAAmB,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC;IAC9E;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,CACT,IAAI,EAAE,mBAAmB,KAEvB,oBAAoB,GACpB,SAAS,GACT,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAC;IAC9C;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAC1B;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC/B,uEAAuE;IACvE,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB;;;;OAIG;IACH,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB;;;;OAIG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,4EAA4E;IAC5E,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC,CAAC;AAEF,qEAAqE;AACrE,MAAM,MAAM,iBAAiB,GAAG;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,mEAAmE;IACnE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAsCF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,sBAAsB,GAC9B,eAAe,CAqKjB;AAED;;;;;;;;;GASG;AACH,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,QAAQ,CAAC,qBAAqB,CAAC,GACvC,OAAO,CAAC,iBAAiB,CAAC,CAqB5B;AAED,MAAM,MAAM,gBAAgB,GAAG,iBAAiB,GAAG;IACjD,iCAAiC;IACjC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAIF;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,gBAAgB,GAAG,YAAY,CAUjE"}
@@ -1,4 +1,6 @@
1
- import { createLuchyClient } from "./api/index.js";
1
+ import { createElement } from "react";
2
+ import { createContext } from "react-router";
3
+ import { DEFAULT_ENDPOINT, createLuchy, signIdentity } from "./server.js";
2
4
  const MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
3
5
  const DEFAULT_IGNORE_PREFIXES = ["/__manifest"];
4
6
  const INTENT_PATTERN = /^[A-Za-z0-9_-]{1,40}$/;
@@ -38,70 +40,205 @@ function serverEventName(input, config = {}) {
38
40
  if ((config.ignoreEvents ?? []).includes(name)) return null;
39
41
  return name;
40
42
  }
41
- const NOOP_FINISH = () => {
42
- };
43
- function createRequestTracker(options) {
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) {
44
88
  const enabled = options.enabled ?? true;
89
+ const endpoint = (options.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
45
90
  const prefixes = [
46
91
  ...DEFAULT_IGNORE_PREFIXES,
47
92
  ...options.ignorePrefixes ?? []
48
93
  ];
49
- const client = createLuchyClient({
94
+ const luchy = createLuchy({
50
95
  apiKey: options.apiKey,
51
- endpoint: options.endpoint,
96
+ secret: options.secret,
97
+ endpoint,
98
+ fetch: options.fetch,
52
99
  onError: options.onError
53
100
  });
54
- return {
55
- begin(request) {
56
- if (!enabled) return NOOP_FINISH;
57
- if (!isMutatingMethod(request.method)) return NOOP_FINISH;
58
- const url = new URL(request.url);
59
- if (prefixes.some((p) => url.pathname.startsWith(p))) return NOOP_FINISH;
60
- const clone = carriesIntent(request.headers.get("content-type")) ? request.clone() : null;
61
- return (response, ctx) => {
62
- ctx.waitUntil(
63
- (async () => {
64
- const intent = clone ? new URLSearchParams(await clone.text()).get("intent") : null;
65
- const name = serverEventName(
66
- {
67
- method: request.method,
68
- pathname: url.pathname,
69
- status: response.status,
70
- intent
71
- },
72
- options
73
- );
74
- if (!name) return;
75
- let payload = { status: response.status };
76
- if (options.payload) {
77
- try {
78
- const extra = await options.payload(request, response);
79
- payload = { ...payload, ...extra };
80
- } catch (error) {
81
- options.onError?.(error);
82
- }
83
- }
84
- await client.trackEvent({
85
- name,
86
- type: "server",
87
- // The raw pathname, not the normalized route: the name carries
88
- // the shape, the pathname keeps the detail.
89
- pathname: url.pathname,
90
- payload,
91
- userAgent: request.headers.get("user-agent") ?? void 0
92
- });
93
- })().catch((error) => {
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) => {
94
185
  options.onError?.(error);
95
186
  })
96
187
  );
97
- };
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;
98
198
  }
199
+ schedule(result instanceof Response ? result.status : 200);
200
+ return result;
99
201
  };
100
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
+ }
101
235
  export {
236
+ LuchyScript,
102
237
  carriesIntent,
103
- createRequestTracker,
238
+ getLuchy,
104
239
  isMutatingMethod,
240
+ luchyMiddleware,
105
241
  normalizeRoute,
242
+ resetIdentifyCache,
106
243
  serverEventName
107
244
  };