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.
@@ -62,9 +62,58 @@ var __async = (__this, __arguments, generator) => {
62
62
  language: navigator.language || ((_a = navigator.languages) == null ? void 0 : _a[0]) || "en"
63
63
  };
64
64
  }
65
+ function parseProps(raw) {
66
+ if (!raw) return void 0;
67
+ try {
68
+ const value = JSON.parse(raw);
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return void 0;
71
+ }
72
+ return value;
73
+ } catch (e) {
74
+ return void 0;
75
+ }
76
+ }
65
77
  function luchy(options) {
66
78
  const endpoint = options.endpoint || "https://api.luchy.app/ingest";
67
- const { apiKey, trackLocalhost = false } = options;
79
+ const { apiKey, trackLocalhost = false, script } = options;
80
+ let identity = options.identity;
81
+ let props = options.props;
82
+ function currentIdentity() {
83
+ if (!script) return identity;
84
+ return script.getAttribute("data-identity") || void 0;
85
+ }
86
+ function currentProps() {
87
+ if (!script) return props;
88
+ return parseProps(script.getAttribute("data-props"));
89
+ }
90
+ function identify(token, nextProps) {
91
+ if (!script) {
92
+ identity = token;
93
+ props = nextProps;
94
+ return;
95
+ }
96
+ script.setAttribute("data-identity", token);
97
+ if (nextProps) {
98
+ script.setAttribute("data-props", JSON.stringify(nextProps));
99
+ } else {
100
+ script.removeAttribute("data-props");
101
+ }
102
+ }
103
+ function reset() {
104
+ if (!script) {
105
+ identity = void 0;
106
+ props = void 0;
107
+ return;
108
+ }
109
+ script.removeAttribute("data-identity");
110
+ script.removeAttribute("data-props");
111
+ }
112
+ function withProps(payload) {
113
+ const global = currentProps();
114
+ if (!global) return payload;
115
+ return __spreadValues(__spreadValues({}, global), payload);
116
+ }
68
117
  const shouldTrack = () => {
69
118
  if (trackLocalhost) return true;
70
119
  return location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
@@ -80,7 +129,9 @@ var __async = (__this, __arguments, generator) => {
80
129
  apiKey,
81
130
  data: __spreadValues({
82
131
  pathname,
83
- referrer
132
+ referrer,
133
+ payload: withProps(data == null ? void 0 : data.payload),
134
+ identity: currentIdentity()
84
135
  }, browserData)
85
136
  });
86
137
  });
@@ -100,7 +151,8 @@ var __async = (__this, __arguments, generator) => {
100
151
  // `payload`, NOT `props`. The ingest endpoint validates with a
101
152
  // non-passthrough zod object, so an unknown key is stripped and the
102
153
  // event is stored with no properties at all — 200, success, no data.
103
- payload
154
+ payload: withProps(payload),
155
+ identity: currentIdentity()
104
156
  }, browserData)
105
157
  });
106
158
  });
@@ -264,11 +316,9 @@ var __async = (__this, __arguments, generator) => {
264
316
  const key = attr.name.slice(prefix.length).replace(/-/g, "_");
265
317
  payload[key] = attr.value;
266
318
  }
267
- trackEvent(
268
- name,
269
- Object.keys(payload).length > 0 ? payload : void 0,
270
- { type: "click" }
271
- );
319
+ trackEvent(name, Object.keys(payload).length > 0 ? payload : void 0, {
320
+ type: "click"
321
+ });
272
322
  };
273
323
  document.addEventListener("click", dataEventClickListener);
274
324
  }
@@ -313,6 +363,8 @@ var __async = (__this, __arguments, generator) => {
313
363
  return {
314
364
  trackPageview,
315
365
  trackEvent,
366
+ identify,
367
+ reset,
316
368
  setOptions
317
369
  };
318
370
  }
@@ -325,7 +377,8 @@ var __async = (__this, __arguments, generator) => {
325
377
  const tracker = luchy({
326
378
  apiKey,
327
379
  endpoint,
328
- trackLocalhost
380
+ trackLocalhost,
381
+ script
329
382
  });
330
383
  const autoPageviewsAttr = script.getAttribute("data-auto-pageviews");
331
384
  const autoOutboundAttr = script.getAttribute("data-auto-outbound");
Binary file
Binary file
@@ -62,9 +62,58 @@ var __async = (__this, __arguments, generator) => {
62
62
  language: navigator.language || ((_a = navigator.languages) == null ? void 0 : _a[0]) || "en"
63
63
  };
64
64
  }
65
+ function parseProps(raw) {
66
+ if (!raw) return void 0;
67
+ try {
68
+ const value = JSON.parse(raw);
69
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
70
+ return void 0;
71
+ }
72
+ return value;
73
+ } catch (e) {
74
+ return void 0;
75
+ }
76
+ }
65
77
  function luchy(options) {
66
78
  const endpoint = options.endpoint || "https://api.luchy.app/ingest";
67
- const { apiKey, trackLocalhost = false } = options;
79
+ const { apiKey, trackLocalhost = false, script } = options;
80
+ let identity = options.identity;
81
+ let props = options.props;
82
+ function currentIdentity() {
83
+ if (!script) return identity;
84
+ return script.getAttribute("data-identity") || void 0;
85
+ }
86
+ function currentProps() {
87
+ if (!script) return props;
88
+ return parseProps(script.getAttribute("data-props"));
89
+ }
90
+ function identify(token, nextProps) {
91
+ if (!script) {
92
+ identity = token;
93
+ props = nextProps;
94
+ return;
95
+ }
96
+ script.setAttribute("data-identity", token);
97
+ if (nextProps) {
98
+ script.setAttribute("data-props", JSON.stringify(nextProps));
99
+ } else {
100
+ script.removeAttribute("data-props");
101
+ }
102
+ }
103
+ function reset() {
104
+ if (!script) {
105
+ identity = void 0;
106
+ props = void 0;
107
+ return;
108
+ }
109
+ script.removeAttribute("data-identity");
110
+ script.removeAttribute("data-props");
111
+ }
112
+ function withProps(payload) {
113
+ const global = currentProps();
114
+ if (!global) return payload;
115
+ return __spreadValues(__spreadValues({}, global), payload);
116
+ }
68
117
  const shouldTrack = () => {
69
118
  if (trackLocalhost) return true;
70
119
  return location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
@@ -80,7 +129,9 @@ var __async = (__this, __arguments, generator) => {
80
129
  apiKey,
81
130
  data: __spreadValues({
82
131
  pathname,
83
- referrer
132
+ referrer,
133
+ payload: withProps(data == null ? void 0 : data.payload),
134
+ identity: currentIdentity()
84
135
  }, browserData)
85
136
  });
86
137
  });
@@ -100,7 +151,8 @@ var __async = (__this, __arguments, generator) => {
100
151
  // `payload`, NOT `props`. The ingest endpoint validates with a
101
152
  // non-passthrough zod object, so an unknown key is stripped and the
102
153
  // event is stored with no properties at all — 200, success, no data.
103
- payload
154
+ payload: withProps(payload),
155
+ identity: currentIdentity()
104
156
  }, browserData)
105
157
  });
106
158
  });
@@ -264,11 +316,9 @@ var __async = (__this, __arguments, generator) => {
264
316
  const key = attr.name.slice(prefix.length).replace(/-/g, "_");
265
317
  payload[key] = attr.value;
266
318
  }
267
- trackEvent(
268
- name,
269
- Object.keys(payload).length > 0 ? payload : void 0,
270
- { type: "click" }
271
- );
319
+ trackEvent(name, Object.keys(payload).length > 0 ? payload : void 0, {
320
+ type: "click"
321
+ });
272
322
  };
273
323
  document.addEventListener("click", dataEventClickListener);
274
324
  }
@@ -313,6 +363,8 @@ var __async = (__this, __arguments, generator) => {
313
363
  return {
314
364
  trackPageview,
315
365
  trackEvent,
366
+ identify,
367
+ reset,
316
368
  setOptions
317
369
  };
318
370
  }
@@ -325,7 +377,8 @@ var __async = (__this, __arguments, generator) => {
325
377
  const tracker = luchy({
326
378
  apiKey,
327
379
  endpoint,
328
- trackLocalhost
380
+ trackLocalhost,
381
+ script
329
382
  });
330
383
  const autoPageviewsAttr = script.getAttribute("data-auto-pageviews");
331
384
  const autoOutboundAttr = script.getAttribute("data-auto-outbound");
Binary file
Binary file
package/dist/server.d.ts CHANGED
@@ -1,35 +1,61 @@
1
+ import { signIdentity, LuchyIdentity } from './identity.js';
1
2
  /**
2
3
  * Server-side tracking.
3
4
  *
4
- * The browser tracker (`./index.ts`) can only see what happens in a page. A
5
- * server sees what the application actually *did* — an order placed, a payment
6
- * recorded, a webhook processed — and those are usually the events worth
7
- * having. This entry point carries no browser globals (`window`, `document`,
8
- * `location`, `navigator`), so it runs in Node, Bun, Deno and Cloudflare
9
- * Workers.
5
+ * The browser script only sees what happens in a page. A server sees what the
6
+ * application actually *did* — an order placed, a payment recorded, a webhook
7
+ * processed — and those are usually the events worth having. No browser
8
+ * globals here, so it runs in Node, Bun, Deno and Cloudflare Workers.
10
9
  *
11
- * It exists mostly so that callers stop hand-rolling `fetch` against the
12
- * ingest API: doing that is how the `props`/`payload` field-name split shipped
13
- * unnoticed, since nothing typed the wire format for a non-browser sender.
10
+ * The wire format lives in `./api` (generated from the API's OpenAPI
11
+ * document); this module is the ergonomic face of it plus identity signing
12
+ * and person profiles (`identify`).
14
13
  *
15
- * The transport now lives in `./api`, generated from the API's own OpenAPI
16
- * document, so the wire format is no longer restated here at all. This module
17
- * is the ergonomic face of it: two methods, no client object to hold.
14
+ * @module
18
15
  */
19
- export type LuchyServerOptions = {
16
+ export { signIdentity, type LuchyIdentity };
17
+ /** A property bag. Nested objects are not supported by the ingest API. */
18
+ export type LuchyPayload = Record<string, string | number | boolean>;
19
+ /**
20
+ * Person profile traits. Merged into the stored profile; `null` deletes a
21
+ * trait. The dashboard labels a person by `name`, else `email`, else the id.
22
+ */
23
+ export type LuchyTraits = Record<string, string | number | boolean | null>;
24
+ export type LuchyOptions = {
20
25
  apiKey: string;
21
- /** Defaults to the hosted ingest API. */
26
+ /**
27
+ * The project's secret key (`lsk_…`, from the dashboard settings).
28
+ * Without it identities passed to `track`/`pageview` are not sent (ingest
29
+ * drops unsigned identities anyway, so sending one would only look like it
30
+ * worked) and `identify` is a no-op. Server-side only: never ship it.
31
+ */
32
+ secret?: string;
33
+ /** The ingest base, without a trailing slash. Defaults to the hosted API. */
22
34
  endpoint?: string;
23
35
  /**
24
- * Called when an event fails to reach the ingest API. Analytics must never
25
- * break the request it is describing, so failures are swallowed by default —
26
- * pass this to log them.
36
+ * Defaults to `true`. When false every call is a no-op, so apps can pass
37
+ * `env.APP_ENV === 'production'` and leave the wiring in place.
38
+ */
39
+ enabled?: boolean;
40
+ /**
41
+ * Called when a hit fails to reach ingest. Analytics must never break the
42
+ * request it describes, so failures are swallowed unless you log them here.
27
43
  */
28
44
  onError?: (error: unknown) => void;
45
+ /** Transport override, for tests and scoped runtimes (service bindings). */
46
+ fetch?: typeof globalThis.fetch;
29
47
  };
30
- /** A property bag. Nested objects are not supported by the ingest API. */
31
- export type LuchyPayload = Record<string, string | number | boolean>;
32
- export type LuchyServerEvent = {
48
+ /** What the end user's request looked like, for hits caused by one. */
49
+ export type LuchyRequestInfo = {
50
+ /** Forward the end user's UA so the hit joins their browser session. */
51
+ userAgent?: string;
52
+ /** The end user's IP. Only used for the visitor hash, never stored. */
53
+ ip?: string;
54
+ /** ISO country code. Wins over the sender's own `cf-ipcountry`. */
55
+ country?: string;
56
+ language?: string;
57
+ };
58
+ export type LuchyServerEvent = LuchyRequestInfo & {
33
59
  /** The event name, e.g. `order:placed`. */
34
60
  name: string;
35
61
  /** Where it happened — a route, not a raw URL with ids in it. */
@@ -37,35 +63,41 @@ export type LuchyServerEvent = {
37
63
  /** Free-form category, e.g. `server`, `webhook`, `cron`. */
38
64
  type?: string;
39
65
  payload?: LuchyPayload;
40
- /** Forward the end user's UA when the event was caused by a request. */
41
- userAgent?: string;
42
- language?: string;
43
- /** ISO country code. Cloudflare deployments can leave this to the server. */
44
- country?: string;
45
66
  };
46
- export type LuchyServerPageview = {
67
+ export type LuchyServerPageview = LuchyRequestInfo & {
47
68
  pathname: string;
48
69
  referrer?: string;
49
- userAgent?: string;
50
- language?: string;
51
- country?: string;
70
+ payload?: LuchyPayload;
52
71
  };
53
- export type LuchyServerTracker = {
54
- trackEvent(event: LuchyServerEvent): Promise<void>;
55
- trackPageview(pageview: LuchyServerPageview): Promise<void>;
72
+ /**
73
+ * An identity to sign, or a token that is already signed (e.g. one rendered
74
+ * into the page and posted back).
75
+ */
76
+ export type LuchyIdentityInput = LuchyIdentity | string;
77
+ export type Luchy = {
78
+ track(event: LuchyServerEvent, identity?: LuchyIdentityInput): Promise<void>;
79
+ pageview(pageview: LuchyServerPageview, identity?: LuchyIdentityInput): Promise<void>;
80
+ /**
81
+ * Stores profile traits for a user (`POST /ingest/identify`, authenticated
82
+ * with the secret key). Idempotent: traits merge, `null` deletes one.
83
+ */
84
+ identify(user: string, traits: LuchyTraits): Promise<void>;
56
85
  };
86
+ /** The hosted ingest base. */
87
+ export declare const DEFAULT_ENDPOINT = "https://dash.luchy.app/api/ingest";
57
88
  /**
58
89
  * Creates a server-side tracker. Every method resolves rather than rejects —
59
90
  * see `onError`.
60
91
  *
61
92
  * ```ts
62
- * const luchy = createServerTracker({ apiKey: process.env.LUCHY_API_KEY! });
63
- * await luchy.trackEvent({
64
- * name: 'order:placed',
65
- * pathname: '/checkout',
66
- * payload: { plan: 'pro', amount: 29 }
67
- * });
93
+ * const luchy = createLuchy({ apiKey: env.LUCHY_API_KEY, secret: env.LUCHY_SECRET });
94
+ * ctx.waitUntil(
95
+ * luchy.track(
96
+ * { name: 'order:placed', pathname: '/checkout', payload: { plan: 'pro' } },
97
+ * { user: user.id, tenant: String(workspace.id) }
98
+ * )
99
+ * );
68
100
  * ```
69
101
  */
70
- export declare function createServerTracker(options: LuchyServerOptions): LuchyServerTracker;
102
+ export declare function createLuchy(options: LuchyOptions): Luchy;
71
103
  //# sourceMappingURL=server.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAMA;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,MAAM,kBAAkB,GAAG;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC,CAAC;AAEF,0EAA0E;AAC1E,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAErE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAEF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,UAAU,CAAC,KAAK,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,aAAa,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7D,CAAC;AAcF;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,kBAAkB,GAC1B,kBAAkB,CAkCpB"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,eAAe,CAAC;AAEjE;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,CAAC;AAE5C,0EAA0E;AAC1E,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC,CAAC;AAErE;;;GAGG;AACH,MAAM,MAAM,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,GAAG,IAAI,CAAC,CAAC;AAE3E,MAAM,MAAM,YAAY,GAAG;IACzB,MAAM,EAAE,MAAM,CAAC;IACf;;;;;OAKG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;IACnC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;CACjC,CAAC;AAEF,uEAAuE;AACvE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,mEAAmE;IACnE,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG,gBAAgB,GAAG;IAChD,2CAA2C;IAC3C,IAAI,EAAE,MAAM,CAAC;IACb,iEAAiE;IACjE,QAAQ,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG,gBAAgB,GAAG;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,OAAO,CAAC,EAAE,YAAY,CAAC;CACxB,CAAC;AAEF;;;GAGG;AACH,MAAM,MAAM,kBAAkB,GAAG,aAAa,GAAG,MAAM,CAAC;AAExD,MAAM,MAAM,KAAK,GAAG;IAClB,KAAK,CAAC,KAAK,EAAE,gBAAgB,EAAE,QAAQ,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7E,QAAQ,CACN,QAAQ,EAAE,mBAAmB,EAC7B,QAAQ,CAAC,EAAE,kBAAkB,GAC5B,OAAO,CAAC,IAAI,CAAC,CAAC;IACjB;;;OAGG;IACH,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5D,CAAC;AAEF,8BAA8B;AAC9B,eAAO,MAAM,gBAAgB,sCAAsC,CAAC;AAWpE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAiHxD"}
package/dist/server.js CHANGED
@@ -1,40 +1,128 @@
1
- import { createLuchyClient } from "./api/index.js";
2
- const DEFAULT_ENDPOINT = "https://api.luchy.app/ingest";
1
+ import { createLuchyClient, LuchyApiError } from "./api/index.js";
2
+ const encoder = new TextEncoder();
3
+ function base64url(bytes) {
4
+ let binary = "";
5
+ for (const byte of bytes) {
6
+ binary += String.fromCharCode(byte);
7
+ }
8
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+ async function signIdentity(identity, secret) {
11
+ const claims = { u: identity.user, a: identity.actor, t: identity.tenant };
12
+ const body = base64url(encoder.encode(JSON.stringify(claims)));
13
+ const key = await crypto.subtle.importKey(
14
+ "raw",
15
+ encoder.encode(secret),
16
+ { name: "HMAC", hash: "SHA-256" },
17
+ false,
18
+ ["sign"]
19
+ );
20
+ const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(body));
21
+ return `${body}.${base64url(new Uint8Array(signature))}`;
22
+ }
23
+ const DEFAULT_ENDPOINT = "https://dash.luchy.app/api/ingest";
3
24
  function apiBaseFrom(endpoint) {
4
- const withoutSlash = endpoint.replace(/\/+$/, "");
5
- return withoutSlash.replace(/\/ingest$/, "");
25
+ return endpoint.replace(/\/+$/, "").replace(/\/ingest$/, "");
6
26
  }
7
- function createServerTracker(options) {
27
+ function createLuchy(options) {
28
+ const enabled = options.enabled ?? true;
8
29
  const client = createLuchyClient({
9
30
  apiKey: options.apiKey,
10
31
  endpoint: apiBaseFrom(options.endpoint || DEFAULT_ENDPOINT),
32
+ fetch: options.fetch,
11
33
  onError: options.onError
12
34
  });
35
+ const secretApi = options.secret ? createLuchyClient({
36
+ apiKey: options.secret,
37
+ endpoint: apiBaseFrom(options.endpoint || DEFAULT_ENDPOINT),
38
+ fetch: options.fetch
39
+ }).api : void 0;
40
+ let reportedMissingSecret = false;
41
+ async function tokenFor(identity) {
42
+ if (!identity) {
43
+ return void 0;
44
+ }
45
+ if (typeof identity === "string") {
46
+ return identity;
47
+ }
48
+ if (!options.secret) {
49
+ return void 0;
50
+ }
51
+ return signIdentity(identity, options.secret);
52
+ }
13
53
  return {
14
- async trackEvent(event) {
15
- const body = {
16
- name: event.name,
17
- type: event.type,
18
- pathname: event.pathname,
19
- payload: event.payload,
20
- userAgent: event.userAgent,
21
- language: event.language,
22
- country: event.country
23
- };
24
- await client.trackEvent(body);
54
+ async track(event, identity) {
55
+ if (!enabled) {
56
+ return;
57
+ }
58
+ try {
59
+ await client.trackEvent({
60
+ name: event.name,
61
+ type: event.type,
62
+ pathname: event.pathname,
63
+ payload: event.payload,
64
+ identity: await tokenFor(identity),
65
+ userAgent: event.userAgent,
66
+ ip: event.ip,
67
+ country: event.country,
68
+ language: event.language
69
+ });
70
+ } catch (error) {
71
+ options.onError?.(error);
72
+ }
73
+ },
74
+ async pageview(pageview, identity) {
75
+ if (!enabled) {
76
+ return;
77
+ }
78
+ try {
79
+ await client.trackPageview({
80
+ pathname: pageview.pathname,
81
+ referrer: pageview.referrer,
82
+ payload: pageview.payload,
83
+ identity: await tokenFor(identity),
84
+ userAgent: pageview.userAgent,
85
+ ip: pageview.ip,
86
+ country: pageview.country,
87
+ language: pageview.language
88
+ });
89
+ } catch (error) {
90
+ options.onError?.(error);
91
+ }
25
92
  },
26
- async trackPageview(pageview) {
27
- const body = {
28
- pathname: pageview.pathname,
29
- referrer: pageview.referrer,
30
- userAgent: pageview.userAgent,
31
- language: pageview.language,
32
- country: pageview.country
33
- };
34
- await client.trackPageview(body);
93
+ async identify(user, traits) {
94
+ if (!enabled) {
95
+ return;
96
+ }
97
+ if (!secretApi) {
98
+ if (!reportedMissingSecret) {
99
+ reportedMissingSecret = true;
100
+ options.onError?.(
101
+ new Error("luchy: identify needs the `secret` option; skipped")
102
+ );
103
+ }
104
+ return;
105
+ }
106
+ try {
107
+ const { error, response } = await secretApi.POST("/ingest/identify", {
108
+ body: { user, traits }
109
+ });
110
+ if (error || !response.ok) {
111
+ options.onError?.(
112
+ new LuchyApiError(
113
+ `Luchy identify failed with ${response.status}`,
114
+ response.status
115
+ )
116
+ );
117
+ }
118
+ } catch (error) {
119
+ options.onError?.(error);
120
+ }
35
121
  }
36
122
  };
37
123
  }
38
124
  export {
39
- createServerTracker
125
+ DEFAULT_ENDPOINT,
126
+ createLuchy,
127
+ signIdentity
40
128
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "luchy",
3
- "version": "0.2.0",
4
- "description": "Luchy tracking - browser script + server-side tracker",
3
+ "version": "1.0.1",
4
+ "description": "Luchy tracking - browser script, server tracker and React Router middleware",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -55,8 +55,11 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/node": "^24.7.0",
58
+ "@types/react": "^19.2.2",
58
59
  "jsdom": "^25.0.1",
59
60
  "openapi-typescript": "^7.13.0",
61
+ "react": "^19.2.0",
62
+ "react-router": "^7.14.0",
60
63
  "terser": "^5.37.0",
61
64
  "tsx": "^4.20.6",
62
65
  "typescript": "^5.9.3",
@@ -68,5 +71,17 @@
68
71
  },
69
72
  "dependencies": {
70
73
  "openapi-fetch": "^0.17.0"
74
+ },
75
+ "peerDependencies": {
76
+ "react": ">=18",
77
+ "react-router": ">=7.9.0"
78
+ },
79
+ "peerDependenciesMeta": {
80
+ "react": {
81
+ "optional": true
82
+ },
83
+ "react-router": {
84
+ "optional": true
85
+ }
71
86
  }
72
87
  }