@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 +21 -0
- package/README.md +203 -0
- package/dist/index.cjs +269 -0
- package/dist/index.d.cts +158 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.js +235 -0
- package/package.json +56 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|