luchy 0.2.0 → 1.1.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/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.1.0",
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
  }