luchy 0.0.1 → 0.2.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/README.md +373 -0
- package/dist/api/index.d.ts +112 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +125 -0
- package/dist/api/schema.d.ts +457 -0
- package/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +314 -0
- package/dist/react-router.d.ts +164 -0
- package/dist/react-router.d.ts.map +1 -0
- package/dist/react-router.js +107 -0
- package/dist/script/luchy.js +357 -0
- package/dist/script/luchy.js.br +0 -0
- package/dist/script/luchy.js.gz +0 -0
- package/dist/script/luchy.min.js +357 -0
- package/dist/script/luchy.min.js.br +0 -0
- package/dist/script/luchy.min.js.gz +0 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +40 -0
- package/package.json +69 -9
- package/index.js +0 -1
package/dist/index.js
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
async function sendEvent(options) {
|
|
2
|
+
const { endpoint, apiKey, data, onError } = options;
|
|
3
|
+
try {
|
|
4
|
+
await fetch(endpoint, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: {
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
Authorization: `Bearer ${apiKey}`
|
|
9
|
+
},
|
|
10
|
+
body: JSON.stringify(data),
|
|
11
|
+
keepalive: true
|
|
12
|
+
// Important for page unload events
|
|
13
|
+
});
|
|
14
|
+
} catch (error) {
|
|
15
|
+
onError?.(error);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function collectBrowserData() {
|
|
19
|
+
return {
|
|
20
|
+
userAgent: navigator.userAgent,
|
|
21
|
+
language: navigator.language || navigator.languages?.[0] || "en"
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function luchy(options) {
|
|
25
|
+
const endpoint = options.endpoint || "https://api.luchy.app/ingest";
|
|
26
|
+
const { apiKey, trackLocalhost = false } = options;
|
|
27
|
+
const shouldTrack = () => {
|
|
28
|
+
if (trackLocalhost) return true;
|
|
29
|
+
return location.hostname !== "localhost" && location.hostname !== "127.0.0.1";
|
|
30
|
+
};
|
|
31
|
+
async function trackPageview(data) {
|
|
32
|
+
if (!shouldTrack()) return;
|
|
33
|
+
const browserData = collectBrowserData();
|
|
34
|
+
const pathname = data?.pathname || location.pathname;
|
|
35
|
+
const referrer = data?.referrer || document.referrer || void 0;
|
|
36
|
+
await sendEvent({
|
|
37
|
+
endpoint: `${endpoint}/pageview`,
|
|
38
|
+
apiKey,
|
|
39
|
+
data: {
|
|
40
|
+
pathname,
|
|
41
|
+
referrer,
|
|
42
|
+
...browserData
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
async function trackEvent(name, payload, options2) {
|
|
47
|
+
if (!shouldTrack()) return;
|
|
48
|
+
const browserData = collectBrowserData();
|
|
49
|
+
const pathname = location.pathname;
|
|
50
|
+
await sendEvent({
|
|
51
|
+
endpoint: `${endpoint}/event`,
|
|
52
|
+
apiKey,
|
|
53
|
+
data: {
|
|
54
|
+
name,
|
|
55
|
+
type: options2?.type,
|
|
56
|
+
pathname,
|
|
57
|
+
// `payload`, NOT `props`. The ingest endpoint validates with a
|
|
58
|
+
// non-passthrough zod object, so an unknown key is stripped and the
|
|
59
|
+
// event is stored with no properties at all — 200, success, no data.
|
|
60
|
+
payload,
|
|
61
|
+
...browserData
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
let autoPageviewsEnabled = false;
|
|
66
|
+
let autoOutboundEnabled = false;
|
|
67
|
+
let autoEventsEnabled = false;
|
|
68
|
+
let hashRoutingEnabled = false;
|
|
69
|
+
let pageEnteredAt = 0;
|
|
70
|
+
let currentPathname = "";
|
|
71
|
+
let pageleaveSent = false;
|
|
72
|
+
let trackedLocation = "";
|
|
73
|
+
function locationKey() {
|
|
74
|
+
return `${location.pathname}${location.search || ""}${location.hash || ""}`;
|
|
75
|
+
}
|
|
76
|
+
function trackNavigation() {
|
|
77
|
+
const next = locationKey();
|
|
78
|
+
if (next === trackedLocation) return;
|
|
79
|
+
sendPageleave();
|
|
80
|
+
trackedLocation = next;
|
|
81
|
+
trackPageview();
|
|
82
|
+
resetPageTiming();
|
|
83
|
+
}
|
|
84
|
+
function sendPageleave() {
|
|
85
|
+
if (pageleaveSent || !currentPathname || !pageEnteredAt) return;
|
|
86
|
+
pageleaveSent = true;
|
|
87
|
+
const duration = Date.now() - pageEnteredAt;
|
|
88
|
+
trackEvent(
|
|
89
|
+
"__pageleave",
|
|
90
|
+
{ duration, pathname: currentPathname },
|
|
91
|
+
{ type: "__internal" }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
function resetPageTiming() {
|
|
95
|
+
pageEnteredAt = Date.now();
|
|
96
|
+
currentPathname = location.pathname;
|
|
97
|
+
pageleaveSent = false;
|
|
98
|
+
}
|
|
99
|
+
let visibilityListener = null;
|
|
100
|
+
let pagehideListener = null;
|
|
101
|
+
let originalPushState = null;
|
|
102
|
+
let originalReplaceState = null;
|
|
103
|
+
let popstateListener = null;
|
|
104
|
+
let hashchangeListener = null;
|
|
105
|
+
let clickListener = null;
|
|
106
|
+
let dataEventClickListener = null;
|
|
107
|
+
function enableAutoPageviews() {
|
|
108
|
+
if (autoPageviewsEnabled) return;
|
|
109
|
+
autoPageviewsEnabled = true;
|
|
110
|
+
trackedLocation = locationKey();
|
|
111
|
+
trackPageview();
|
|
112
|
+
resetPageTiming();
|
|
113
|
+
if (!originalPushState) {
|
|
114
|
+
originalPushState = history.pushState.bind(history);
|
|
115
|
+
}
|
|
116
|
+
if (!originalReplaceState) {
|
|
117
|
+
originalReplaceState = history.replaceState.bind(history);
|
|
118
|
+
}
|
|
119
|
+
history.pushState = function(data, unused, url) {
|
|
120
|
+
originalPushState.apply(this, [data, unused, url]);
|
|
121
|
+
trackNavigation();
|
|
122
|
+
};
|
|
123
|
+
history.replaceState = function(data, unused, url) {
|
|
124
|
+
originalReplaceState.apply(this, [data, unused, url]);
|
|
125
|
+
trackNavigation();
|
|
126
|
+
};
|
|
127
|
+
popstateListener = () => {
|
|
128
|
+
trackNavigation();
|
|
129
|
+
};
|
|
130
|
+
window.addEventListener("popstate", popstateListener);
|
|
131
|
+
visibilityListener = () => {
|
|
132
|
+
if (document.visibilityState === "hidden") {
|
|
133
|
+
sendPageleave();
|
|
134
|
+
} else if (document.visibilityState === "visible") {
|
|
135
|
+
resetPageTiming();
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
document.addEventListener("visibilitychange", visibilityListener);
|
|
139
|
+
pagehideListener = () => sendPageleave();
|
|
140
|
+
window.addEventListener("pagehide", pagehideListener);
|
|
141
|
+
}
|
|
142
|
+
function disableAutoPageviews() {
|
|
143
|
+
if (!autoPageviewsEnabled) return;
|
|
144
|
+
autoPageviewsEnabled = false;
|
|
145
|
+
if (originalPushState) {
|
|
146
|
+
history.pushState = originalPushState;
|
|
147
|
+
}
|
|
148
|
+
if (originalReplaceState) {
|
|
149
|
+
history.replaceState = originalReplaceState;
|
|
150
|
+
}
|
|
151
|
+
if (popstateListener) {
|
|
152
|
+
window.removeEventListener("popstate", popstateListener);
|
|
153
|
+
popstateListener = null;
|
|
154
|
+
}
|
|
155
|
+
if (visibilityListener) {
|
|
156
|
+
document.removeEventListener("visibilitychange", visibilityListener);
|
|
157
|
+
visibilityListener = null;
|
|
158
|
+
}
|
|
159
|
+
if (pagehideListener) {
|
|
160
|
+
window.removeEventListener("pagehide", pagehideListener);
|
|
161
|
+
pagehideListener = null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function enableHashRouting() {
|
|
165
|
+
if (hashRoutingEnabled) return;
|
|
166
|
+
hashRoutingEnabled = true;
|
|
167
|
+
hashchangeListener = () => trackNavigation();
|
|
168
|
+
window.addEventListener("hashchange", hashchangeListener);
|
|
169
|
+
}
|
|
170
|
+
function disableHashRouting() {
|
|
171
|
+
if (!hashRoutingEnabled) return;
|
|
172
|
+
hashRoutingEnabled = false;
|
|
173
|
+
if (hashchangeListener) {
|
|
174
|
+
window.removeEventListener("hashchange", hashchangeListener);
|
|
175
|
+
hashchangeListener = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function enableAutoOutboundTracking() {
|
|
179
|
+
if (autoOutboundEnabled) return;
|
|
180
|
+
autoOutboundEnabled = true;
|
|
181
|
+
clickListener = (event) => {
|
|
182
|
+
const link = event.target?.closest("a");
|
|
183
|
+
if (!link) return;
|
|
184
|
+
if (link.closest("[data-luchy-event]")) return;
|
|
185
|
+
const href = link.href;
|
|
186
|
+
if (!href) return;
|
|
187
|
+
try {
|
|
188
|
+
const url = new URL(href);
|
|
189
|
+
if (url.hostname !== location.hostname) {
|
|
190
|
+
trackEvent("Outbound Link", { url: href }, { type: "click" });
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
document.addEventListener("click", clickListener);
|
|
196
|
+
}
|
|
197
|
+
function disableAutoOutboundTracking() {
|
|
198
|
+
if (!autoOutboundEnabled) return;
|
|
199
|
+
autoOutboundEnabled = false;
|
|
200
|
+
if (clickListener) {
|
|
201
|
+
document.removeEventListener("click", clickListener);
|
|
202
|
+
clickListener = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function enableAutoEvents() {
|
|
206
|
+
if (autoEventsEnabled) return;
|
|
207
|
+
autoEventsEnabled = true;
|
|
208
|
+
dataEventClickListener = (event) => {
|
|
209
|
+
const target = event.target?.closest(
|
|
210
|
+
"[data-luchy-event]"
|
|
211
|
+
);
|
|
212
|
+
if (!target) return;
|
|
213
|
+
const name = target.getAttribute("data-luchy-event");
|
|
214
|
+
if (!name) return;
|
|
215
|
+
const payload = {};
|
|
216
|
+
for (const attr of Array.from(target.attributes)) {
|
|
217
|
+
const prefix = attr.name.startsWith("data-luchy-payload-") ? "data-luchy-payload-" : attr.name.startsWith("data-luchy-prop-") ? "data-luchy-prop-" : null;
|
|
218
|
+
if (!prefix) continue;
|
|
219
|
+
const key = attr.name.slice(prefix.length).replace(/-/g, "_");
|
|
220
|
+
payload[key] = attr.value;
|
|
221
|
+
}
|
|
222
|
+
trackEvent(
|
|
223
|
+
name,
|
|
224
|
+
Object.keys(payload).length > 0 ? payload : void 0,
|
|
225
|
+
{ type: "click" }
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
document.addEventListener("click", dataEventClickListener);
|
|
229
|
+
}
|
|
230
|
+
function disableAutoEvents() {
|
|
231
|
+
if (!autoEventsEnabled) return;
|
|
232
|
+
autoEventsEnabled = false;
|
|
233
|
+
if (dataEventClickListener) {
|
|
234
|
+
document.removeEventListener("click", dataEventClickListener);
|
|
235
|
+
dataEventClickListener = null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function setOptions(options2) {
|
|
239
|
+
if (options2.autoPageviews !== void 0) {
|
|
240
|
+
if (options2.autoPageviews) {
|
|
241
|
+
enableAutoPageviews();
|
|
242
|
+
} else {
|
|
243
|
+
disableAutoPageviews();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (options2.autoOutbound !== void 0) {
|
|
247
|
+
if (options2.autoOutbound) {
|
|
248
|
+
enableAutoOutboundTracking();
|
|
249
|
+
} else {
|
|
250
|
+
disableAutoOutboundTracking();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (options2.autoEvents !== void 0) {
|
|
254
|
+
if (options2.autoEvents) {
|
|
255
|
+
enableAutoEvents();
|
|
256
|
+
} else {
|
|
257
|
+
disableAutoEvents();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (options2.hashRouting !== void 0) {
|
|
261
|
+
if (options2.hashRouting) {
|
|
262
|
+
enableHashRouting();
|
|
263
|
+
} else {
|
|
264
|
+
disableHashRouting();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
trackPageview,
|
|
270
|
+
trackEvent,
|
|
271
|
+
setOptions
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (typeof document !== "undefined" && document.currentScript) {
|
|
275
|
+
const script = document.currentScript;
|
|
276
|
+
const apiKey = script.getAttribute("data-api-key");
|
|
277
|
+
const endpoint = script.getAttribute("data-endpoint") || void 0;
|
|
278
|
+
const trackLocalhost = script.hasAttribute("data-track-localhost");
|
|
279
|
+
if (apiKey) {
|
|
280
|
+
const tracker = luchy({
|
|
281
|
+
apiKey,
|
|
282
|
+
endpoint,
|
|
283
|
+
trackLocalhost
|
|
284
|
+
});
|
|
285
|
+
const autoPageviewsAttr = script.getAttribute("data-auto-pageviews");
|
|
286
|
+
const autoOutboundAttr = script.getAttribute("data-auto-outbound");
|
|
287
|
+
const autoEventsAttr = script.getAttribute("data-auto-events");
|
|
288
|
+
const hashRoutingAttr = script.getAttribute("data-hash-routing");
|
|
289
|
+
const options = {};
|
|
290
|
+
if (autoPageviewsAttr !== null) {
|
|
291
|
+
options.autoPageviews = autoPageviewsAttr !== "false";
|
|
292
|
+
}
|
|
293
|
+
if (autoOutboundAttr !== null) {
|
|
294
|
+
options.autoOutbound = autoOutboundAttr !== "false";
|
|
295
|
+
}
|
|
296
|
+
if (autoEventsAttr !== null) {
|
|
297
|
+
options.autoEvents = autoEventsAttr !== "false";
|
|
298
|
+
}
|
|
299
|
+
if (hashRoutingAttr !== null) {
|
|
300
|
+
options.hashRouting = hashRoutingAttr !== "false";
|
|
301
|
+
}
|
|
302
|
+
tracker.setOptions({
|
|
303
|
+
autoPageviews: true,
|
|
304
|
+
autoOutbound: true,
|
|
305
|
+
autoEvents: true,
|
|
306
|
+
hashRouting: true,
|
|
307
|
+
...options
|
|
308
|
+
});
|
|
309
|
+
window.luchy = tracker;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
export {
|
|
313
|
+
luchy
|
|
314
|
+
};
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Router (v7+) on Cloudflare Workers — server events derived from
|
|
3
|
+
* requests.
|
|
4
|
+
*
|
|
5
|
+
* A React Router app already says what it did, in the request: every console
|
|
6
|
+
* mutation is a form POST whose `intent` field names it (`rotate`,
|
|
7
|
+
* `invite-member`, `create-api-key`, …), every JSON API mutation is
|
|
8
|
+
* 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.
|
|
14
|
+
*
|
|
15
|
+
* This module is server-only but not Cloudflare-only: the flush context is
|
|
16
|
+
* structural (`{ waitUntil }`), so anything with that shape works.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
/** A property bag. Nested objects are not supported by the ingest API. */
|
|
21
|
+
export type LuchyRequestPayload = Record<string, string | number | boolean>;
|
|
22
|
+
/**
|
|
23
|
+
* True for the methods worth an event.
|
|
24
|
+
*
|
|
25
|
+
* Reads are the browser tracker's job; only mutations describe what the app
|
|
26
|
+
* did.
|
|
27
|
+
*
|
|
28
|
+
* ```ts
|
|
29
|
+
* isMutatingMethod('post'); // true
|
|
30
|
+
* isMutatingMethod('GET'); // false
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export declare function isMutatingMethod(method: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* True when the request body is worth parsing for an `intent` field.
|
|
36
|
+
*
|
|
37
|
+
* Only urlencoded bodies are read: a multipart upload could be megabytes, and
|
|
38
|
+
* no route reads an intent out of one.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* carriesIntent(request.headers.get('content-type'));
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export declare function carriesIntent(contentType: string | null | undefined): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Turns a pathname into a route name: `/users/aBc123.../identities` →
|
|
47
|
+
* `users/:id/identities`.
|
|
48
|
+
*
|
|
49
|
+
* Client-side submissions land on React Router's single-fetch URL
|
|
50
|
+
* (`/users/abc.data`), so the `.data` suffix is stripped — otherwise the same
|
|
51
|
+
* action would report under two names depending on whether the page was
|
|
52
|
+
* hydrated. `/` and the single-fetch root (`/_root.data`) both become `home`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function normalizeRoute(pathname: string): string;
|
|
55
|
+
/** What `serverEventName` needs to know about a finished request. */
|
|
56
|
+
export type ServerEventInput = {
|
|
57
|
+
method: string;
|
|
58
|
+
/** The raw pathname, before normalization. */
|
|
59
|
+
pathname: string;
|
|
60
|
+
/** The response status. Only consulted when `trackFailures` is false. */
|
|
61
|
+
status: number;
|
|
62
|
+
/** The submitted `intent`, if the body carried one. */
|
|
63
|
+
intent?: string | null;
|
|
64
|
+
};
|
|
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'>;
|
|
67
|
+
/**
|
|
68
|
+
* The event name for a mutation, or `null` when the request is not worth an
|
|
69
|
+
* event (a read, machine traffic, an ignored route, a failure the app does not
|
|
70
|
+
* track).
|
|
71
|
+
*
|
|
72
|
+
* Names are `route:intent` — an intent alone is ambiguous (`delete` and
|
|
73
|
+
* `update` appear on several routes), the pair is not. Without an intent the
|
|
74
|
+
* name is the bare route, or `route:post` / `route:delete` when
|
|
75
|
+
* `methodSuffix` is on, which keeps a REST route's methods distinct.
|
|
76
|
+
*
|
|
77
|
+
* ```ts
|
|
78
|
+
* serverEventName({ method: 'POST', pathname: '/team', status: 200, intent: 'invite' });
|
|
79
|
+
* // 'team:invite'
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export declare function serverEventName(input: ServerEventInput, config?: ServerEventNameConfig): string | null;
|
|
83
|
+
export type RequestTrackerOptions = {
|
|
84
|
+
/** A Luchy API key. The public ingest key is fine — it ships in the HTML. */
|
|
85
|
+
apiKey: string;
|
|
86
|
+
/** The API root, without a trailing slash. Defaults to the hosted API. */
|
|
87
|
+
endpoint?: string;
|
|
88
|
+
/**
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
enabled?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Raw-pathname prefixes to drop, matched before normalization. Merged with
|
|
96
|
+
* the built-in defaults (`/__manifest`) rather than replacing them.
|
|
97
|
+
*/
|
|
98
|
+
ignorePrefixes?: string[];
|
|
99
|
+
/**
|
|
100
|
+
* Normalized-route suffixes to drop, e.g. `/user-keys/validate`. Matched on
|
|
101
|
+
* the route so that literal app ids in the path do not each need an entry.
|
|
102
|
+
*/
|
|
103
|
+
ignoreRouteSuffixes?: string[];
|
|
104
|
+
/** Fully-formed event names to drop, e.g. `notifications:markRead`. */
|
|
105
|
+
ignoreEvents?: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Defaults to `false`: a 4xx/5xx produces no event. Turn it on when failed
|
|
108
|
+
* mutations are themselves product signal (the `status` payload field tells
|
|
109
|
+
* them apart).
|
|
110
|
+
*/
|
|
111
|
+
trackFailures?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Defaults to `false`: an intent-less mutation is named after its route
|
|
114
|
+
* alone. Turn it on for REST-ish routes, where `PATCH` and `DELETE` on the
|
|
115
|
+
* same path should report as `route:patch` and `route:delete`.
|
|
116
|
+
*/
|
|
117
|
+
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
|
+
/** Called when anything in the pipeline fails. Nothing else surfaces it. */
|
|
126
|
+
onError?: (error: unknown) => void;
|
|
127
|
+
};
|
|
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;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* Creates the request tracker: one hook in the Worker's `fetch`, and every
|
|
146
|
+
* mutation the app serves becomes an event.
|
|
147
|
+
*
|
|
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.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```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
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export declare function createRequestTracker(options: RequestTrackerOptions): RequestTracker;
|
|
164
|
+
//# 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":"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"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createLuchyClient } from "./api/index.js";
|
|
2
|
+
const MUTATING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
3
|
+
const DEFAULT_IGNORE_PREFIXES = ["/__manifest"];
|
|
4
|
+
const INTENT_PATTERN = /^[A-Za-z0-9_-]{1,40}$/;
|
|
5
|
+
function isMutatingMethod(method) {
|
|
6
|
+
return MUTATING_METHODS.has(method.toUpperCase());
|
|
7
|
+
}
|
|
8
|
+
function carriesIntent(contentType) {
|
|
9
|
+
return (contentType ?? "").includes("application/x-www-form-urlencoded");
|
|
10
|
+
}
|
|
11
|
+
function isIdSegment(segment) {
|
|
12
|
+
if (segment.length === 0) return false;
|
|
13
|
+
if (/^\d+$/.test(segment)) return true;
|
|
14
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(segment)) return true;
|
|
15
|
+
return segment.length >= 16 && /\d/.test(segment);
|
|
16
|
+
}
|
|
17
|
+
function normalizeRoute(pathname) {
|
|
18
|
+
const segments = pathname.replace(/\.data$/, "").split("/").filter(Boolean);
|
|
19
|
+
if (segments.length === 0) return "home";
|
|
20
|
+
if (segments.length === 1 && segments[0] === "_root") return "home";
|
|
21
|
+
return segments.map((s) => isIdSegment(s) ? ":id" : s).join("/");
|
|
22
|
+
}
|
|
23
|
+
function serverEventName(input, config = {}) {
|
|
24
|
+
const method = input.method.toUpperCase();
|
|
25
|
+
if (!MUTATING_METHODS.has(method)) return null;
|
|
26
|
+
if (!config.trackFailures && input.status >= 400) return null;
|
|
27
|
+
const prefixes = [
|
|
28
|
+
...DEFAULT_IGNORE_PREFIXES,
|
|
29
|
+
...config.ignorePrefixes ?? []
|
|
30
|
+
];
|
|
31
|
+
if (prefixes.some((p) => input.pathname.startsWith(p))) return null;
|
|
32
|
+
const route = normalizeRoute(input.pathname);
|
|
33
|
+
if ((config.ignoreRouteSuffixes ?? []).some((s) => route.endsWith(s))) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const intent = input.intent && INTENT_PATTERN.test(input.intent) ? input.intent : null;
|
|
37
|
+
const name = intent ? `${route}:${intent}` : config.methodSuffix ? `${route}:${method.toLowerCase()}` : route;
|
|
38
|
+
if ((config.ignoreEvents ?? []).includes(name)) return null;
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
const NOOP_FINISH = () => {
|
|
42
|
+
};
|
|
43
|
+
function createRequestTracker(options) {
|
|
44
|
+
const enabled = options.enabled ?? true;
|
|
45
|
+
const prefixes = [
|
|
46
|
+
...DEFAULT_IGNORE_PREFIXES,
|
|
47
|
+
...options.ignorePrefixes ?? []
|
|
48
|
+
];
|
|
49
|
+
const client = createLuchyClient({
|
|
50
|
+
apiKey: options.apiKey,
|
|
51
|
+
endpoint: options.endpoint,
|
|
52
|
+
onError: options.onError
|
|
53
|
+
});
|
|
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) => {
|
|
94
|
+
options.onError?.(error);
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export {
|
|
102
|
+
carriesIntent,
|
|
103
|
+
createRequestTracker,
|
|
104
|
+
isMutatingMethod,
|
|
105
|
+
normalizeRoute,
|
|
106
|
+
serverEventName
|
|
107
|
+
};
|