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/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
+ };