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/README.md +245 -95
- package/dist/api/index.d.ts +21 -9
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +46 -8
- package/dist/api/schema.d.ts +367 -79
- package/dist/identity.d.ts +35 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -8
- package/dist/react-router.d.ts +121 -53
- package/dist/react-router.d.ts.map +1 -1
- package/dist/react-router.js +185 -48
- package/dist/script/luchy.js +62 -9
- package/dist/script/luchy.js.br +0 -0
- package/dist/script/luchy.js.gz +0 -0
- package/dist/script/luchy.min.js +62 -9
- package/dist/script/luchy.min.js.br +0 -0
- package/dist/script/luchy.min.js.gz +0 -0
- package/dist/server.d.ts +71 -39
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +114 -26
- package/package.json +17 -2
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
|
-
|
|
224
|
-
|
|
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");
|
package/dist/react-router.d.ts
CHANGED
|
@@ -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+)
|
|
3
|
-
*
|
|
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 `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* normalized route + intent (or method).
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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 =
|
|
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
|
|
66
|
-
export type ServerEventNameConfig = Pick<
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
90
|
-
* request read, no network
|
|
91
|
-
*
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
*/
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
*
|
|
146
|
-
*
|
|
183
|
+
* The root middleware. Every mutation React Router serves becomes an event;
|
|
184
|
+
* every request gets the state `getLuchy` reads.
|
|
147
185
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
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
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
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
|
|
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":"
|
|
1
|
+
{"version":3,"file":"react-router.d.ts","sourceRoot":"","sources":["../src/react-router.ts"],"names":[],"mappings":"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"}
|
package/dist/react-router.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
94
|
+
const luchy = createLuchy({
|
|
50
95
|
apiKey: options.apiKey,
|
|
51
|
-
|
|
96
|
+
secret: options.secret,
|
|
97
|
+
endpoint,
|
|
98
|
+
fetch: options.fetch,
|
|
52
99
|
onError: options.onError
|
|
53
100
|
});
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
238
|
+
getLuchy,
|
|
104
239
|
isMutatingMethod,
|
|
240
|
+
luchyMiddleware,
|
|
105
241
|
normalizeRoute,
|
|
242
|
+
resetIdentifyCache,
|
|
106
243
|
serverEventName
|
|
107
244
|
};
|