@iorg1/trackking-next 0.4.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iorg1
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # @iorg1/trackking-next
2
+
3
+ Cookieless, server-side page-view tracking for Next.js.
4
+
5
+ `@iorg1/trackking-next` is a tiny Next.js **middleware** that beacons each page navigation to
6
+ a Trackking collector over HTTPS. There is
7
+ **no cookie**, no client-side script, and no consent banner machinery — the
8
+ middleware runs on the server (Edge runtime), reads the request headers, and
9
+ fires a fire-and-forget POST. Because it observes the document request, it also
10
+ sees **bots and AI crawlers** (Googlebot, GPTBot, ClaudeBot, PerplexityBot) that
11
+ JavaScript-based analytics never record.
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @iorg1/trackking-next
17
+ ```
18
+
19
+ ## Configure
20
+
21
+ Set two environment variables in your Next.js app:
22
+
23
+ ```bash
24
+ TRACKKING_ENDPOINT=https://collector.example.com/collect
25
+ TRACKKING_API_KEY=your_site_api_key
26
+ ```
27
+
28
+ Optional:
29
+
30
+ ```bash
31
+ # Reverse proxies in front of your app — trust the right client IP (see Options).
32
+ TRACKKING_TRUSTED_PROXY_HOPS=1
33
+ # Enable the first-party client-event route (see "First-party client events").
34
+ TRACKKING_EVENT_ROUTE=/data/:event
35
+ # Collector event endpoint; defaults to TRACKKING_ENDPOINT with /collect → /event.
36
+ TRACKKING_EVENT_ENDPOINT=https://collector.example.com/event
37
+ ```
38
+
39
+ ## Use
40
+
41
+ ### The whole middleware (simplest)
42
+
43
+ ```ts
44
+ // middleware.ts
45
+ import { createTrackingMiddleware } from "@iorg1/trackking-next";
46
+
47
+ export const middleware = createTrackingMiddleware();
48
+
49
+ export const config = {
50
+ // Track real page navigations; skip Next internals, the API, and static files.
51
+ matcher: ["/", "/((?!_next/|api/|.*\\..*).*)"],
52
+ };
53
+ ```
54
+
55
+ ### Inside an existing middleware
56
+
57
+ Already have a `middleware.ts` (locale negotiation, auth, …)? Call `track()` and
58
+ return your own response:
59
+
60
+ ```ts
61
+ import { NextResponse, type NextRequest, type NextFetchEvent } from "next/server";
62
+ import { track } from "@iorg1/trackking-next";
63
+
64
+ export function middleware(req: NextRequest, event: NextFetchEvent) {
65
+ track(req, event); // fire-and-forget, never throws
66
+ // ...your existing logic...
67
+ return NextResponse.next();
68
+ }
69
+ ```
70
+
71
+ ### First-party client events
72
+
73
+ Page-view tracking is fully server-side, but sometimes you want to record a
74
+ **client-triggered event** — a signup click, a preview generated, a "real
75
+ browser rendered this" ping. Instead of beaconing the collector's origin from
76
+ the browser (blocked by many ad/tracker blockers, and needing CORS), expose a
77
+ **same-origin** route and let the middleware forward the event server-side. Your
78
+ API key never reaches the browser.
79
+
80
+ Set `eventRoute` (or `TRACKKING_EVENT_ROUTE`) and add the route to your matcher.
81
+ When a request matches, `handleEvent` records a named event — the captured
82
+ segment is the event name — and answers the client `200 {}`; when it doesn't, it
83
+ returns `null` so you fall through to normal tracking:
84
+
85
+ ```ts
86
+ import { NextResponse, type NextRequest, type NextFetchEvent } from "next/server";
87
+ import { track, handleEvent } from "@iorg1/trackking-next";
88
+
89
+ export async function middleware(req: NextRequest, event: NextFetchEvent) {
90
+ const res = await handleEvent(req, event, { eventRoute: "/data/:event" });
91
+ if (res) return res; // it was a /data/<event> beacon → 200 {}
92
+
93
+ track(req, event);
94
+ return NextResponse.next();
95
+ }
96
+
97
+ export const config = {
98
+ // Add the event route to the matcher so the middleware runs for it.
99
+ matcher: ["/", "/data/:path*", "/((?!_next/|api/|.*\\..*).*)"],
100
+ };
101
+ ```
102
+
103
+ `createTrackingMiddleware()` does this for you automatically when `eventRoute` /
104
+ `TRACKKING_EVENT_ROUTE` is set — just remember to add the route to the matcher.
105
+
106
+ From the browser, POST to the route with an optional `{ path?, props? }` body:
107
+
108
+ ```ts
109
+ navigator.sendBeacon(
110
+ "/data/signup",
111
+ new Blob([JSON.stringify({ props: { plan: "pro" } })], { type: "application/json" }),
112
+ );
113
+ ```
114
+
115
+ The collector records `{ name: "signup", path, props }` (props are re-sanitized
116
+ server-side). Route templates support `:name` (one path segment) and `*` (the
117
+ rest of the path). For full control, pass `resolveEvent: (req) => string | null`
118
+ instead of `eventRoute` — return the event name, or `null` to pass through.
119
+
120
+ ## Options
121
+
122
+ ```ts
123
+ createTrackingMiddleware({
124
+ endpoint: process.env.TRACKKING_ENDPOINT, // default
125
+ apiKey: process.env.TRACKKING_API_KEY, // default
126
+ shouldTrack: (req) => !req.nextUrl.pathname.startsWith("/admin"),
127
+ getClientIp: (req) => req.headers.get("cf-connecting-ip"),
128
+ trustedProxyHops: 1, // trust the right x-forwarded-for entry (see below)
129
+ eventRoute: "/data/:event", // first-party client-event route (see above)
130
+ resolveEvent: (req) => …, // …or fully custom event extraction (overrides eventRoute)
131
+ eventEndpoint: process.env.TRACKKING_EVENT_ENDPOINT,
132
+ debug: process.env.NODE_ENV !== "production", // verbose logging (see below)
133
+ });
134
+ ```
135
+
136
+ The middleware automatically skips non-`GET` requests and Next.js prefetches
137
+ (the event route is exempt — it handles the beacon method you send).
138
+
139
+ ### `trustedProxyHops`
140
+
141
+ `x-forwarded-for` is appended left→right as a request passes through proxies, and
142
+ the **leftmost entry is client-controlled** (spoofable). This option is the
143
+ number of reverse proxies you run in front of the app, so the middleware trusts
144
+ the value *your* proxy appended:
145
+
146
+ - `0` (default) — use the leftmost entry. Correct only on a platform that
147
+ overwrites XFF for you (e.g. Vercel, Cloudflare).
148
+ - `1+` — use the entry that many hops from the right. Behind your own
149
+ Caddy/nginx/Traefik, set this (usually `1`) so a spoofed leftmost entry can't
150
+ poison the visitor hash or geo/ASN lookup.
151
+
152
+ Falls back to `TRACKKING_TRUSTED_PROXY_HOPS`. Ignored when `getClientIp` is set.
153
+
154
+ ## Debugging
155
+
156
+ Tracking is intentionally silent — it never throws and, by default, never logs,
157
+ so a misconfiguration (wrong endpoint, missing key, an unexpected `4xx`) looks
158
+ like "nothing happens." To see what it's doing, set `TRACKKING_DEBUG`:
159
+
160
+ ```bash
161
+ TRACKKING_DEBUG=1 # any truthy value; "0" / "false" / "" disable it
162
+ ```
163
+
164
+ (or pass `debug: true` in the config, which overrides the env var). For each
165
+ request the middleware then logs to the console via `console.debug`, prefixed
166
+ with `[trackking]`:
167
+
168
+ - the resolved **config** — endpoint, a masked API key (first 6 chars + length,
169
+ never the full secret), and which config keys you passed;
170
+ - the **decision** — tracked, or skipped with the reason (not configured,
171
+ non-`GET`, prefetch, or `shouldTrack()` returned false);
172
+ - the **event payload** that gets posted; and
173
+ - the collector's **response** — `POST <endpoint> → <status> <statusText>`, plus
174
+ the response body on a non-2xx, or the error if the request threw.
175
+
176
+ ```
177
+ [trackking] GET /pricing { endpoint: 'https://…/collect', apiKey: 'tk_abc…(35 chars)', configKeys: [] }
178
+ [trackking] tracking event: { path: '/pricing', host: 'example.com', referrer: null, userAgent: '…', ip: '203.0.113.10', headers: { … } }
179
+ [trackking] POST https://…/collect → 204 No Content (/pricing)
180
+ ```
181
+
182
+ Leave it unset in production: it logs request metadata (path, IP, user-agent) to
183
+ your server logs.
184
+
185
+ ## What gets sent
186
+
187
+ **Page views** — a single JSON object: `{ path, host, referrer, userAgent, ip,
188
+ headers }`. The collector hashes `ip` into a daily-rotating visitor id **and
189
+ discards it** — the raw IP is never stored. The referrer is truncated to its
190
+ host. `headers` is a small bag of low-entropy request headers (`Accept-Language`,
191
+ `Accept`, the `Sec-Fetch-*` fetch-metadata set, and `Sec-CH-UA*` client hints)
192
+ that help the collector tell real browsers from bots — it is **not** a device
193
+ fingerprint, and no cookies or client identifiers are involved.
194
+
195
+ **Events** (if you use the event route) — `{ name, path, props }`, carrying no
196
+ visitor identity. `props` is an optional small, flat bag of non-PII metadata,
197
+ re-sanitized by the collector.
198
+
199
+ No personal identifiers are persisted.
200
+
201
+ ## License
202
+
203
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ buildEvent: () => buildEvent,
24
+ createTrackingMiddleware: () => createTrackingMiddleware,
25
+ deriveEventEndpoint: () => deriveEventEndpoint,
26
+ getClientIp: () => getClientIp,
27
+ handleEvent: () => handleEvent,
28
+ matchEventRoute: () => matchEventRoute,
29
+ resolveEventName: () => resolveEventName,
30
+ track: () => track
31
+ });
32
+ module.exports = __toCommonJS(index_exports);
33
+
34
+ // src/middleware.ts
35
+ var import_server = require("next/server");
36
+
37
+ // src/forwarded.ts
38
+ function clientIpFromForwardedFor(xff, trustedProxyHops2 = 0) {
39
+ if (!xff) return null;
40
+ const parts = xff.split(",").map((p) => p.trim()).filter(Boolean);
41
+ if (parts.length === 0) return null;
42
+ if (trustedProxyHops2 <= 0) return parts[0] ?? null;
43
+ const idx = Math.max(0, parts.length - trustedProxyHops2);
44
+ return parts[idx] ?? null;
45
+ }
46
+
47
+ // src/event-route.ts
48
+ function eventRouteRegex(template) {
49
+ const source = template.split("/").map((seg) => {
50
+ if (seg.startsWith(":")) return "([^/]+)";
51
+ if (seg === "*") return "(.+)";
52
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
53
+ }).join("/");
54
+ return new RegExp(`^${source}$`);
55
+ }
56
+ function matchEventRoute(pathname, template) {
57
+ if (!template) return null;
58
+ const match = eventRouteRegex(template).exec(pathname);
59
+ if (!match) return null;
60
+ const captured = match[1] ?? pathname.split("/").filter(Boolean).pop() ?? "";
61
+ try {
62
+ return decodeURIComponent(captured);
63
+ } catch {
64
+ return captured;
65
+ }
66
+ }
67
+ function deriveEventEndpoint(base) {
68
+ if (!base) return void 0;
69
+ return base.replace(/\/collect\/?$/, "/event");
70
+ }
71
+ function parseEventBody(body) {
72
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
73
+ const b = body;
74
+ const out = {};
75
+ if (typeof b.path === "string") out.path = b.path;
76
+ if (b.props && typeof b.props === "object" && !Array.isArray(b.props)) {
77
+ out.props = b.props;
78
+ }
79
+ return out;
80
+ }
81
+
82
+ // src/middleware.ts
83
+ function trustedProxyHops(config) {
84
+ if (typeof config.trustedProxyHops === "number") return config.trustedProxyHops;
85
+ const raw = process.env.TRACKKING_TRUSTED_PROXY_HOPS;
86
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
87
+ return Number.isFinite(n) && n > 0 ? n : 0;
88
+ }
89
+ function envDebug() {
90
+ const v = process.env.TRACKKING_DEBUG;
91
+ return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
92
+ }
93
+ function isDebug(config) {
94
+ return config.debug ?? envDebug();
95
+ }
96
+ function log(...args) {
97
+ console.debug("[trackking]", ...args);
98
+ }
99
+ function maskKey(key) {
100
+ if (!key) return "(unset)";
101
+ if (key.length <= 8) return "********";
102
+ return `${key.slice(0, 6)}\u2026(${key.length} chars)`;
103
+ }
104
+ function getClientIp(req, hops = 0) {
105
+ const xff = clientIpFromForwardedFor(req.headers.get("x-forwarded-for"), hops);
106
+ if (xff) return xff;
107
+ const real = req.headers.get("x-real-ip");
108
+ if (real) return real.trim();
109
+ const ip = req.ip;
110
+ return ip ?? null;
111
+ }
112
+ function isPrefetch(req) {
113
+ if (req.headers.get("next-router-prefetch")) return true;
114
+ const purpose = req.headers.get("purpose") ?? req.headers.get("x-purpose");
115
+ if (purpose && purpose.toLowerCase() === "prefetch") return true;
116
+ const secPurpose = req.headers.get("sec-purpose");
117
+ if (secPurpose && secPurpose.toLowerCase().includes("prefetch")) return true;
118
+ return false;
119
+ }
120
+ function headerSignals(req) {
121
+ const h = req.headers;
122
+ return {
123
+ acceptLanguage: h.get("accept-language"),
124
+ accept: h.get("accept"),
125
+ secFetchSite: h.get("sec-fetch-site"),
126
+ secFetchMode: h.get("sec-fetch-mode"),
127
+ secFetchDest: h.get("sec-fetch-dest"),
128
+ secFetchUser: h.get("sec-fetch-user"),
129
+ secChUa: h.get("sec-ch-ua"),
130
+ secChUaMobile: h.get("sec-ch-ua-mobile"),
131
+ secChUaPlatform: h.get("sec-ch-ua-platform"),
132
+ upgradeInsecureRequests: h.get("upgrade-insecure-requests")
133
+ };
134
+ }
135
+ function buildEvent(req, config = {}) {
136
+ const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
137
+ return {
138
+ path: req.nextUrl.pathname,
139
+ host: req.nextUrl.host || req.headers.get("host"),
140
+ referrer: req.headers.get("referer"),
141
+ userAgent: req.headers.get("user-agent"),
142
+ ip: ipFn(req),
143
+ headers: headerSignals(req)
144
+ };
145
+ }
146
+ async function sendEvent(event, endpoint, apiKey, debug) {
147
+ try {
148
+ const res = await fetch(endpoint, {
149
+ method: "POST",
150
+ headers: {
151
+ "content-type": "application/json",
152
+ authorization: `Bearer ${apiKey}`
153
+ },
154
+ body: JSON.stringify(event),
155
+ // Survive a terminating edge runtime tearing the request down.
156
+ keepalive: true
157
+ });
158
+ if (debug) {
159
+ log(`POST ${endpoint} \u2192 ${res.status} ${res.statusText} (${event.path})`);
160
+ if (!res.ok) {
161
+ const body = await res.text().catch(() => "");
162
+ if (body) log("response body:", body.slice(0, 500));
163
+ }
164
+ }
165
+ } catch (err) {
166
+ if (debug) log("request failed:", err);
167
+ }
168
+ }
169
+ function track(req, event, config = {}) {
170
+ const endpoint = config.endpoint ?? process.env.TRACKKING_ENDPOINT;
171
+ const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
172
+ const debug = isDebug(config);
173
+ if (debug) {
174
+ log(`${req.method} ${req.nextUrl.pathname}`, {
175
+ endpoint: endpoint ?? "(unset)",
176
+ apiKey: maskKey(apiKey),
177
+ configKeys: Object.keys(config)
178
+ });
179
+ }
180
+ if (!endpoint || !apiKey) {
181
+ if (debug) log("skip: not configured (endpoint and/or apiKey missing)");
182
+ return;
183
+ }
184
+ if (req.method !== "GET") {
185
+ if (debug) log(`skip: method ${req.method} is not GET`);
186
+ return;
187
+ }
188
+ if (isPrefetch(req)) {
189
+ if (debug) log("skip: prefetch request");
190
+ return;
191
+ }
192
+ if (config.shouldTrack && !config.shouldTrack(req)) {
193
+ if (debug) log("skip: shouldTrack() returned false");
194
+ return;
195
+ }
196
+ const payload = buildEvent(req, config);
197
+ if (debug) log("tracking event:", payload);
198
+ event.waitUntil(sendEvent(payload, endpoint, apiKey, debug));
199
+ }
200
+ function eventEndpointFor(config) {
201
+ return config.eventEndpoint ?? process.env.TRACKKING_EVENT_ENDPOINT ?? deriveEventEndpoint(config.endpoint ?? process.env.TRACKKING_ENDPOINT);
202
+ }
203
+ function resolveEventName(req, config = {}) {
204
+ if (config.resolveEvent) return config.resolveEvent(req);
205
+ const template = config.eventRoute ?? process.env.TRACKKING_EVENT_ROUTE;
206
+ if (!template) return null;
207
+ return matchEventRoute(req.nextUrl.pathname, template);
208
+ }
209
+ async function sendClientEvent(payload, endpoint, apiKey, debug) {
210
+ try {
211
+ const res = await fetch(endpoint, {
212
+ method: "POST",
213
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
214
+ body: JSON.stringify(payload),
215
+ keepalive: true
216
+ });
217
+ if (debug) log(`event POST ${endpoint} \u2192 ${res.status} (${payload.name})`);
218
+ } catch (err) {
219
+ if (debug) log("event request failed:", err);
220
+ }
221
+ }
222
+ async function handleEvent(req, event, config = {}) {
223
+ const name = resolveEventName(req, config);
224
+ if (name == null || name === "") return null;
225
+ const debug = isDebug(config);
226
+ let body = null;
227
+ if (req.method !== "GET" && req.method !== "HEAD") {
228
+ body = await req.json().catch(() => null);
229
+ }
230
+ const { path, props } = parseEventBody(body);
231
+ const referrerPath = (() => {
232
+ const ref = req.headers.get("referer");
233
+ if (!ref) return void 0;
234
+ try {
235
+ return new URL(ref).pathname;
236
+ } catch {
237
+ return void 0;
238
+ }
239
+ })();
240
+ const payload = { name, path: path ?? referrerPath, props };
241
+ const endpoint = eventEndpointFor(config);
242
+ const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
243
+ if (endpoint && apiKey) {
244
+ if (debug) log("tracking client event:", payload);
245
+ event.waitUntil(sendClientEvent(payload, endpoint, apiKey, debug));
246
+ } else if (debug) {
247
+ log("event route matched but not configured (endpoint and/or apiKey missing)");
248
+ }
249
+ return import_server.NextResponse.json({});
250
+ }
251
+ function createTrackingMiddleware(config = {}) {
252
+ return async function middleware(req, event) {
253
+ const eventResponse = await handleEvent(req, event, config);
254
+ if (eventResponse) return eventResponse;
255
+ track(req, event, config);
256
+ return import_server.NextResponse.next();
257
+ };
258
+ }
259
+ // Annotate the CommonJS export names for ESM import in node:
260
+ 0 && (module.exports = {
261
+ buildEvent,
262
+ createTrackingMiddleware,
263
+ deriveEventEndpoint,
264
+ getClientIp,
265
+ handleEvent,
266
+ matchEventRoute,
267
+ resolveEventName,
268
+ track
269
+ });
@@ -0,0 +1,158 @@
1
+ import { NextRequest, NextFetchEvent, NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * The wire format posted to the Trackking collector's `/collect` endpoint.
5
+ * Deliberately minimal: no cookies, no client identifiers. The raw `ip` is
6
+ * sent over HTTPS so the collector can derive a daily-rotating visitor hash —
7
+ * the collector hashes it on arrival and never stores it.
8
+ */
9
+ interface TrackkingEvent {
10
+ /** Pathname only (no query string), e.g. "/en/pricing". */
11
+ path: string;
12
+ /** Host of the tracked site, used by the collector to drop self-referrals. */
13
+ host: string | null;
14
+ /** Raw `Referer` header value, or null. Truncated to its host by the collector. */
15
+ referrer: string | null;
16
+ /** Raw `User-Agent` header value, or null. */
17
+ userAgent: string | null;
18
+ /** Best-effort client IP. Hashed-and-discarded by the collector; never stored. */
19
+ ip: string | null;
20
+ /** Low-cost request headers the collector stores for server-side bot scoring. */
21
+ headers?: TrackkingHeaderSignals;
22
+ /** Optional ISO timestamp. The collector stamps server time if omitted. */
23
+ timestamp?: string;
24
+ }
25
+ /**
26
+ * Request headers that help the collector tell real browser navigations from
27
+ * bots without any client-side JS: Fetch Metadata (Sec-Fetch-*), low-entropy
28
+ * client hints (Sec-CH-UA*), Accept-Language, and Upgrade-Insecure-Requests.
29
+ * All optional — browsers differ (Firefox/Safari omit client hints), so the
30
+ * collector treats a missing value as "unknown", never a bot signal on its own.
31
+ */
32
+ interface TrackkingHeaderSignals {
33
+ acceptLanguage?: string | null;
34
+ accept?: string | null;
35
+ secFetchSite?: string | null;
36
+ secFetchMode?: string | null;
37
+ secFetchDest?: string | null;
38
+ secFetchUser?: string | null;
39
+ secChUa?: string | null;
40
+ secChUaMobile?: string | null;
41
+ secChUaPlatform?: string | null;
42
+ upgradeInsecureRequests?: string | null;
43
+ }
44
+ interface TrackkingConfig {
45
+ /** Collector URL. Defaults to `process.env.TRACKKING_ENDPOINT`. */
46
+ endpoint?: string;
47
+ /** API key identifying the site. Defaults to `process.env.TRACKKING_API_KEY`. */
48
+ apiKey?: string;
49
+ /**
50
+ * Collector *event* endpoint (for the first-party event route). Defaults to
51
+ * `process.env.TRACKKING_EVENT_ENDPOINT`, then to the page-view `endpoint`
52
+ * with a trailing `/collect` swapped for `/event`.
53
+ */
54
+ eventEndpoint?: string;
55
+ /**
56
+ * First-party route template for client event beacons, e.g. `"/data/:event"`
57
+ * or `"/data/*"`. When a request path matches, the middleware records a named
58
+ * event (the captured segment is the event name), forwards it to the collector
59
+ * server-side, and answers the client `200 {}` instead of passing through.
60
+ * Defaults to the `TRACKKING_EVENT_ROUTE` env var. Ignored when `resolveEvent`
61
+ * is set.
62
+ */
63
+ eventRoute?: string;
64
+ /**
65
+ * Full control over event extraction. Return the event name to record (the
66
+ * middleware then answers `200 {}`), or null to let the request pass through
67
+ * untouched. Overrides `eventRoute`.
68
+ */
69
+ resolveEvent?: (req: NextRequest) => string | null;
70
+ /** Override how the client IP is extracted from the request. */
71
+ getClientIp?: (req: NextRequest) => string | null;
72
+ /**
73
+ * Number of reverse proxies you run in front of the tracked app. Controls
74
+ * which `x-forwarded-for` entry is trusted as the client IP: 0 (default) uses
75
+ * the spoofable leftmost entry; 1+ uses the value your own proxy appended,
76
+ * counting from the right. Falls back to the `TRACKKING_TRUSTED_PROXY_HOPS`
77
+ * env var. Ignored when `getClientIp` is provided.
78
+ */
79
+ trustedProxyHops?: number;
80
+ /** Return false to skip tracking a given request. */
81
+ shouldTrack?: (req: NextRequest) => boolean;
82
+ /**
83
+ * Log verbose diagnostics (config, per-request decisions, response status) to
84
+ * the console. Defaults to the `TRACKKING_DEBUG` env var being set to a truthy
85
+ * value ("1", "true", any non-empty value other than "0"/"false").
86
+ */
87
+ debug?: boolean;
88
+ }
89
+
90
+ /**
91
+ * Best-effort client IP. Behind a proxy (Vercel, Caddy, nginx) the real visitor
92
+ * IP is in `x-forwarded-for`; the socket IP would be the proxy.
93
+ *
94
+ * `hops` is the number of reverse proxies you run in front of the tracked app.
95
+ * With `hops = 0` (default) the leftmost XFF entry is used — spoofable, so treat
96
+ * counts as directional. With `hops >= 1` the value your own proxy appended is
97
+ * used instead (see `clientIpFromForwardedFor`), which a client cannot forge.
98
+ */
99
+ declare function getClientIp(req: NextRequest, hops?: number): string | null;
100
+ /** Build the event payload from an incoming request. */
101
+ declare function buildEvent(req: NextRequest, config?: TrackkingConfig): TrackkingEvent;
102
+ /**
103
+ * Fire-and-forget a page-view beacon. Call this from inside your own Next.js
104
+ * middleware when you already have one, then return your own response:
105
+ *
106
+ * export function middleware(req: NextRequest, event: NextFetchEvent) {
107
+ * track(req, event);
108
+ * return NextResponse.next();
109
+ * }
110
+ */
111
+ declare function track(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): void;
112
+ /**
113
+ * Decide whether a request is a client-event beacon and, if so, extract the
114
+ * event name. `resolveEvent` wins; otherwise the `eventRoute` template (or the
115
+ * `TRACKKING_EVENT_ROUTE` env var) is matched against the path. Returns null
116
+ * when this isn't an event request (let it pass through).
117
+ */
118
+ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): string | null;
119
+ /**
120
+ * Handle a client-event beacon on the first-party event route. When the request
121
+ * matches, records the event (server-side, so the API key never reaches the
122
+ * browser) and returns a `200 {}` response to send back to the client. Returns
123
+ * null when the request is not an event beacon — the caller should then proceed
124
+ * with normal page-view tracking.
125
+ *
126
+ * export async function middleware(req, event) {
127
+ * const res = await handleEvent(req, event, config);
128
+ * if (res) return res; // it was a /data/<event> beacon
129
+ * track(req, event, config); // otherwise track the page view
130
+ * return NextResponse.next();
131
+ * }
132
+ */
133
+ declare function handleEvent(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): Promise<NextResponse | null>;
134
+ /**
135
+ * Returns a complete Next.js middleware function for the common case where
136
+ * tracking is the only thing your middleware does. It also serves the
137
+ * first-party event route when `eventRoute`/`TRACKKING_EVENT_ROUTE` (or
138
+ * `resolveEvent`) is set:
139
+ *
140
+ * export const middleware = createTrackingMiddleware();
141
+ * export const config = { matcher: ["/", "/data/:path*", "/((?!_next|api|.*\\..*).*)"] };
142
+ */
143
+ declare function createTrackingMiddleware(config?: TrackkingConfig): (req: NextRequest, event: NextFetchEvent) => Promise<NextResponse>;
144
+
145
+ /**
146
+ * Return the event name captured from `pathname` by `template`, or null when the
147
+ * path doesn't match. The name is URI-decoded. When the template has no
148
+ * placeholder but still matches, the last non-empty path segment is used.
149
+ */
150
+ declare function matchEventRoute(pathname: string, template: string): string | null;
151
+ /**
152
+ * Derive the collector's event endpoint from the page-view endpoint by swapping
153
+ * a trailing `/collect` for `/event` — so the same base URL used for page views
154
+ * works for events without extra config. Returns undefined when `base` is unset.
155
+ */
156
+ declare function deriveEventEndpoint(base: string | undefined): string | undefined;
157
+
158
+ export { type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track };
@@ -0,0 +1,158 @@
1
+ import { NextRequest, NextFetchEvent, NextResponse } from 'next/server';
2
+
3
+ /**
4
+ * The wire format posted to the Trackking collector's `/collect` endpoint.
5
+ * Deliberately minimal: no cookies, no client identifiers. The raw `ip` is
6
+ * sent over HTTPS so the collector can derive a daily-rotating visitor hash —
7
+ * the collector hashes it on arrival and never stores it.
8
+ */
9
+ interface TrackkingEvent {
10
+ /** Pathname only (no query string), e.g. "/en/pricing". */
11
+ path: string;
12
+ /** Host of the tracked site, used by the collector to drop self-referrals. */
13
+ host: string | null;
14
+ /** Raw `Referer` header value, or null. Truncated to its host by the collector. */
15
+ referrer: string | null;
16
+ /** Raw `User-Agent` header value, or null. */
17
+ userAgent: string | null;
18
+ /** Best-effort client IP. Hashed-and-discarded by the collector; never stored. */
19
+ ip: string | null;
20
+ /** Low-cost request headers the collector stores for server-side bot scoring. */
21
+ headers?: TrackkingHeaderSignals;
22
+ /** Optional ISO timestamp. The collector stamps server time if omitted. */
23
+ timestamp?: string;
24
+ }
25
+ /**
26
+ * Request headers that help the collector tell real browser navigations from
27
+ * bots without any client-side JS: Fetch Metadata (Sec-Fetch-*), low-entropy
28
+ * client hints (Sec-CH-UA*), Accept-Language, and Upgrade-Insecure-Requests.
29
+ * All optional — browsers differ (Firefox/Safari omit client hints), so the
30
+ * collector treats a missing value as "unknown", never a bot signal on its own.
31
+ */
32
+ interface TrackkingHeaderSignals {
33
+ acceptLanguage?: string | null;
34
+ accept?: string | null;
35
+ secFetchSite?: string | null;
36
+ secFetchMode?: string | null;
37
+ secFetchDest?: string | null;
38
+ secFetchUser?: string | null;
39
+ secChUa?: string | null;
40
+ secChUaMobile?: string | null;
41
+ secChUaPlatform?: string | null;
42
+ upgradeInsecureRequests?: string | null;
43
+ }
44
+ interface TrackkingConfig {
45
+ /** Collector URL. Defaults to `process.env.TRACKKING_ENDPOINT`. */
46
+ endpoint?: string;
47
+ /** API key identifying the site. Defaults to `process.env.TRACKKING_API_KEY`. */
48
+ apiKey?: string;
49
+ /**
50
+ * Collector *event* endpoint (for the first-party event route). Defaults to
51
+ * `process.env.TRACKKING_EVENT_ENDPOINT`, then to the page-view `endpoint`
52
+ * with a trailing `/collect` swapped for `/event`.
53
+ */
54
+ eventEndpoint?: string;
55
+ /**
56
+ * First-party route template for client event beacons, e.g. `"/data/:event"`
57
+ * or `"/data/*"`. When a request path matches, the middleware records a named
58
+ * event (the captured segment is the event name), forwards it to the collector
59
+ * server-side, and answers the client `200 {}` instead of passing through.
60
+ * Defaults to the `TRACKKING_EVENT_ROUTE` env var. Ignored when `resolveEvent`
61
+ * is set.
62
+ */
63
+ eventRoute?: string;
64
+ /**
65
+ * Full control over event extraction. Return the event name to record (the
66
+ * middleware then answers `200 {}`), or null to let the request pass through
67
+ * untouched. Overrides `eventRoute`.
68
+ */
69
+ resolveEvent?: (req: NextRequest) => string | null;
70
+ /** Override how the client IP is extracted from the request. */
71
+ getClientIp?: (req: NextRequest) => string | null;
72
+ /**
73
+ * Number of reverse proxies you run in front of the tracked app. Controls
74
+ * which `x-forwarded-for` entry is trusted as the client IP: 0 (default) uses
75
+ * the spoofable leftmost entry; 1+ uses the value your own proxy appended,
76
+ * counting from the right. Falls back to the `TRACKKING_TRUSTED_PROXY_HOPS`
77
+ * env var. Ignored when `getClientIp` is provided.
78
+ */
79
+ trustedProxyHops?: number;
80
+ /** Return false to skip tracking a given request. */
81
+ shouldTrack?: (req: NextRequest) => boolean;
82
+ /**
83
+ * Log verbose diagnostics (config, per-request decisions, response status) to
84
+ * the console. Defaults to the `TRACKKING_DEBUG` env var being set to a truthy
85
+ * value ("1", "true", any non-empty value other than "0"/"false").
86
+ */
87
+ debug?: boolean;
88
+ }
89
+
90
+ /**
91
+ * Best-effort client IP. Behind a proxy (Vercel, Caddy, nginx) the real visitor
92
+ * IP is in `x-forwarded-for`; the socket IP would be the proxy.
93
+ *
94
+ * `hops` is the number of reverse proxies you run in front of the tracked app.
95
+ * With `hops = 0` (default) the leftmost XFF entry is used — spoofable, so treat
96
+ * counts as directional. With `hops >= 1` the value your own proxy appended is
97
+ * used instead (see `clientIpFromForwardedFor`), which a client cannot forge.
98
+ */
99
+ declare function getClientIp(req: NextRequest, hops?: number): string | null;
100
+ /** Build the event payload from an incoming request. */
101
+ declare function buildEvent(req: NextRequest, config?: TrackkingConfig): TrackkingEvent;
102
+ /**
103
+ * Fire-and-forget a page-view beacon. Call this from inside your own Next.js
104
+ * middleware when you already have one, then return your own response:
105
+ *
106
+ * export function middleware(req: NextRequest, event: NextFetchEvent) {
107
+ * track(req, event);
108
+ * return NextResponse.next();
109
+ * }
110
+ */
111
+ declare function track(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): void;
112
+ /**
113
+ * Decide whether a request is a client-event beacon and, if so, extract the
114
+ * event name. `resolveEvent` wins; otherwise the `eventRoute` template (or the
115
+ * `TRACKKING_EVENT_ROUTE` env var) is matched against the path. Returns null
116
+ * when this isn't an event request (let it pass through).
117
+ */
118
+ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): string | null;
119
+ /**
120
+ * Handle a client-event beacon on the first-party event route. When the request
121
+ * matches, records the event (server-side, so the API key never reaches the
122
+ * browser) and returns a `200 {}` response to send back to the client. Returns
123
+ * null when the request is not an event beacon — the caller should then proceed
124
+ * with normal page-view tracking.
125
+ *
126
+ * export async function middleware(req, event) {
127
+ * const res = await handleEvent(req, event, config);
128
+ * if (res) return res; // it was a /data/<event> beacon
129
+ * track(req, event, config); // otherwise track the page view
130
+ * return NextResponse.next();
131
+ * }
132
+ */
133
+ declare function handleEvent(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): Promise<NextResponse | null>;
134
+ /**
135
+ * Returns a complete Next.js middleware function for the common case where
136
+ * tracking is the only thing your middleware does. It also serves the
137
+ * first-party event route when `eventRoute`/`TRACKKING_EVENT_ROUTE` (or
138
+ * `resolveEvent`) is set:
139
+ *
140
+ * export const middleware = createTrackingMiddleware();
141
+ * export const config = { matcher: ["/", "/data/:path*", "/((?!_next|api|.*\\..*).*)"] };
142
+ */
143
+ declare function createTrackingMiddleware(config?: TrackkingConfig): (req: NextRequest, event: NextFetchEvent) => Promise<NextResponse>;
144
+
145
+ /**
146
+ * Return the event name captured from `pathname` by `template`, or null when the
147
+ * path doesn't match. The name is URI-decoded. When the template has no
148
+ * placeholder but still matches, the last non-empty path segment is used.
149
+ */
150
+ declare function matchEventRoute(pathname: string, template: string): string | null;
151
+ /**
152
+ * Derive the collector's event endpoint from the page-view endpoint by swapping
153
+ * a trailing `/collect` for `/event` — so the same base URL used for page views
154
+ * works for events without extra config. Returns undefined when `base` is unset.
155
+ */
156
+ declare function deriveEventEndpoint(base: string | undefined): string | undefined;
157
+
158
+ export { type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track };
package/dist/index.js ADDED
@@ -0,0 +1,235 @@
1
+ // src/middleware.ts
2
+ import { NextResponse } from "next/server";
3
+
4
+ // src/forwarded.ts
5
+ function clientIpFromForwardedFor(xff, trustedProxyHops2 = 0) {
6
+ if (!xff) return null;
7
+ const parts = xff.split(",").map((p) => p.trim()).filter(Boolean);
8
+ if (parts.length === 0) return null;
9
+ if (trustedProxyHops2 <= 0) return parts[0] ?? null;
10
+ const idx = Math.max(0, parts.length - trustedProxyHops2);
11
+ return parts[idx] ?? null;
12
+ }
13
+
14
+ // src/event-route.ts
15
+ function eventRouteRegex(template) {
16
+ const source = template.split("/").map((seg) => {
17
+ if (seg.startsWith(":")) return "([^/]+)";
18
+ if (seg === "*") return "(.+)";
19
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }).join("/");
21
+ return new RegExp(`^${source}$`);
22
+ }
23
+ function matchEventRoute(pathname, template) {
24
+ if (!template) return null;
25
+ const match = eventRouteRegex(template).exec(pathname);
26
+ if (!match) return null;
27
+ const captured = match[1] ?? pathname.split("/").filter(Boolean).pop() ?? "";
28
+ try {
29
+ return decodeURIComponent(captured);
30
+ } catch {
31
+ return captured;
32
+ }
33
+ }
34
+ function deriveEventEndpoint(base) {
35
+ if (!base) return void 0;
36
+ return base.replace(/\/collect\/?$/, "/event");
37
+ }
38
+ function parseEventBody(body) {
39
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
40
+ const b = body;
41
+ const out = {};
42
+ if (typeof b.path === "string") out.path = b.path;
43
+ if (b.props && typeof b.props === "object" && !Array.isArray(b.props)) {
44
+ out.props = b.props;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ // src/middleware.ts
50
+ function trustedProxyHops(config) {
51
+ if (typeof config.trustedProxyHops === "number") return config.trustedProxyHops;
52
+ const raw = process.env.TRACKKING_TRUSTED_PROXY_HOPS;
53
+ const n = raw ? Number.parseInt(raw, 10) : NaN;
54
+ return Number.isFinite(n) && n > 0 ? n : 0;
55
+ }
56
+ function envDebug() {
57
+ const v = process.env.TRACKKING_DEBUG;
58
+ return v != null && v !== "" && v !== "0" && v.toLowerCase() !== "false";
59
+ }
60
+ function isDebug(config) {
61
+ return config.debug ?? envDebug();
62
+ }
63
+ function log(...args) {
64
+ console.debug("[trackking]", ...args);
65
+ }
66
+ function maskKey(key) {
67
+ if (!key) return "(unset)";
68
+ if (key.length <= 8) return "********";
69
+ return `${key.slice(0, 6)}\u2026(${key.length} chars)`;
70
+ }
71
+ function getClientIp(req, hops = 0) {
72
+ const xff = clientIpFromForwardedFor(req.headers.get("x-forwarded-for"), hops);
73
+ if (xff) return xff;
74
+ const real = req.headers.get("x-real-ip");
75
+ if (real) return real.trim();
76
+ const ip = req.ip;
77
+ return ip ?? null;
78
+ }
79
+ function isPrefetch(req) {
80
+ if (req.headers.get("next-router-prefetch")) return true;
81
+ const purpose = req.headers.get("purpose") ?? req.headers.get("x-purpose");
82
+ if (purpose && purpose.toLowerCase() === "prefetch") return true;
83
+ const secPurpose = req.headers.get("sec-purpose");
84
+ if (secPurpose && secPurpose.toLowerCase().includes("prefetch")) return true;
85
+ return false;
86
+ }
87
+ function headerSignals(req) {
88
+ const h = req.headers;
89
+ return {
90
+ acceptLanguage: h.get("accept-language"),
91
+ accept: h.get("accept"),
92
+ secFetchSite: h.get("sec-fetch-site"),
93
+ secFetchMode: h.get("sec-fetch-mode"),
94
+ secFetchDest: h.get("sec-fetch-dest"),
95
+ secFetchUser: h.get("sec-fetch-user"),
96
+ secChUa: h.get("sec-ch-ua"),
97
+ secChUaMobile: h.get("sec-ch-ua-mobile"),
98
+ secChUaPlatform: h.get("sec-ch-ua-platform"),
99
+ upgradeInsecureRequests: h.get("upgrade-insecure-requests")
100
+ };
101
+ }
102
+ function buildEvent(req, config = {}) {
103
+ const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
104
+ return {
105
+ path: req.nextUrl.pathname,
106
+ host: req.nextUrl.host || req.headers.get("host"),
107
+ referrer: req.headers.get("referer"),
108
+ userAgent: req.headers.get("user-agent"),
109
+ ip: ipFn(req),
110
+ headers: headerSignals(req)
111
+ };
112
+ }
113
+ async function sendEvent(event, endpoint, apiKey, debug) {
114
+ try {
115
+ const res = await fetch(endpoint, {
116
+ method: "POST",
117
+ headers: {
118
+ "content-type": "application/json",
119
+ authorization: `Bearer ${apiKey}`
120
+ },
121
+ body: JSON.stringify(event),
122
+ // Survive a terminating edge runtime tearing the request down.
123
+ keepalive: true
124
+ });
125
+ if (debug) {
126
+ log(`POST ${endpoint} \u2192 ${res.status} ${res.statusText} (${event.path})`);
127
+ if (!res.ok) {
128
+ const body = await res.text().catch(() => "");
129
+ if (body) log("response body:", body.slice(0, 500));
130
+ }
131
+ }
132
+ } catch (err) {
133
+ if (debug) log("request failed:", err);
134
+ }
135
+ }
136
+ function track(req, event, config = {}) {
137
+ const endpoint = config.endpoint ?? process.env.TRACKKING_ENDPOINT;
138
+ const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
139
+ const debug = isDebug(config);
140
+ if (debug) {
141
+ log(`${req.method} ${req.nextUrl.pathname}`, {
142
+ endpoint: endpoint ?? "(unset)",
143
+ apiKey: maskKey(apiKey),
144
+ configKeys: Object.keys(config)
145
+ });
146
+ }
147
+ if (!endpoint || !apiKey) {
148
+ if (debug) log("skip: not configured (endpoint and/or apiKey missing)");
149
+ return;
150
+ }
151
+ if (req.method !== "GET") {
152
+ if (debug) log(`skip: method ${req.method} is not GET`);
153
+ return;
154
+ }
155
+ if (isPrefetch(req)) {
156
+ if (debug) log("skip: prefetch request");
157
+ return;
158
+ }
159
+ if (config.shouldTrack && !config.shouldTrack(req)) {
160
+ if (debug) log("skip: shouldTrack() returned false");
161
+ return;
162
+ }
163
+ const payload = buildEvent(req, config);
164
+ if (debug) log("tracking event:", payload);
165
+ event.waitUntil(sendEvent(payload, endpoint, apiKey, debug));
166
+ }
167
+ function eventEndpointFor(config) {
168
+ return config.eventEndpoint ?? process.env.TRACKKING_EVENT_ENDPOINT ?? deriveEventEndpoint(config.endpoint ?? process.env.TRACKKING_ENDPOINT);
169
+ }
170
+ function resolveEventName(req, config = {}) {
171
+ if (config.resolveEvent) return config.resolveEvent(req);
172
+ const template = config.eventRoute ?? process.env.TRACKKING_EVENT_ROUTE;
173
+ if (!template) return null;
174
+ return matchEventRoute(req.nextUrl.pathname, template);
175
+ }
176
+ async function sendClientEvent(payload, endpoint, apiKey, debug) {
177
+ try {
178
+ const res = await fetch(endpoint, {
179
+ method: "POST",
180
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
181
+ body: JSON.stringify(payload),
182
+ keepalive: true
183
+ });
184
+ if (debug) log(`event POST ${endpoint} \u2192 ${res.status} (${payload.name})`);
185
+ } catch (err) {
186
+ if (debug) log("event request failed:", err);
187
+ }
188
+ }
189
+ async function handleEvent(req, event, config = {}) {
190
+ const name = resolveEventName(req, config);
191
+ if (name == null || name === "") return null;
192
+ const debug = isDebug(config);
193
+ let body = null;
194
+ if (req.method !== "GET" && req.method !== "HEAD") {
195
+ body = await req.json().catch(() => null);
196
+ }
197
+ const { path, props } = parseEventBody(body);
198
+ const referrerPath = (() => {
199
+ const ref = req.headers.get("referer");
200
+ if (!ref) return void 0;
201
+ try {
202
+ return new URL(ref).pathname;
203
+ } catch {
204
+ return void 0;
205
+ }
206
+ })();
207
+ const payload = { name, path: path ?? referrerPath, props };
208
+ const endpoint = eventEndpointFor(config);
209
+ const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
210
+ if (endpoint && apiKey) {
211
+ if (debug) log("tracking client event:", payload);
212
+ event.waitUntil(sendClientEvent(payload, endpoint, apiKey, debug));
213
+ } else if (debug) {
214
+ log("event route matched but not configured (endpoint and/or apiKey missing)");
215
+ }
216
+ return NextResponse.json({});
217
+ }
218
+ function createTrackingMiddleware(config = {}) {
219
+ return async function middleware(req, event) {
220
+ const eventResponse = await handleEvent(req, event, config);
221
+ if (eventResponse) return eventResponse;
222
+ track(req, event, config);
223
+ return NextResponse.next();
224
+ };
225
+ }
226
+ export {
227
+ buildEvent,
228
+ createTrackingMiddleware,
229
+ deriveEventEndpoint,
230
+ getClientIp,
231
+ handleEvent,
232
+ matchEventRoute,
233
+ resolveEventName,
234
+ track
235
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@iorg1/trackking-next",
3
+ "version": "0.4.0",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/iorg1/trackking.git",
7
+ "directory": "packages/middleware"
8
+ },
9
+ "description": "Cookieless, server-side page-view tracking for Next.js. Drop-in middleware that beacons visits to a Trackking collector — no cookies, no client-side script.",
10
+ "license": "MIT",
11
+ "type": "module",
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js",
19
+ "require": "./dist/index.cjs"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "README.md"
25
+ ],
26
+ "sideEffects": false,
27
+ "scripts": {
28
+ "build": "tsup",
29
+ "dev": "tsup --watch",
30
+ "typecheck": "tsc --noEmit",
31
+ "test": "node --import tsx --test src/*.test.ts"
32
+ },
33
+ "keywords": [
34
+ "nextjs",
35
+ "analytics",
36
+ "tracking",
37
+ "cookieless",
38
+ "middleware",
39
+ "privacy",
40
+ "gdpr"
41
+ ],
42
+ "peerDependencies": {
43
+ "next": ">=13.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.0.0",
47
+ "next": "^14.2.15",
48
+ "tsup": "^8.3.0",
49
+ "tsx": "^4.19.0",
50
+ "typescript": "^5.5.0"
51
+ },
52
+ "publishConfig": {
53
+ "registry": "https://registry.npmjs.org",
54
+ "access": "public"
55
+ }
56
+ }