@casoon/trackr 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +117 -49
- package/dist/chunk-7UN7MXBM.js +141 -0
- package/dist/chunk-7UN7MXBM.js.map +1 -0
- package/dist/{chunk-ABUPERUQ.js → chunk-AOB662OQ.js} +34 -3
- package/dist/chunk-AOB662OQ.js.map +1 -0
- package/dist/chunk-PEAZRYH7.js +78 -0
- package/dist/chunk-PEAZRYH7.js.map +1 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -2
- package/dist/server/index.d.ts +5 -2
- package/dist/server/index.js +8 -2
- package/dist/server/pixel.d.ts +5 -0
- package/dist/server/pixel.js +97 -0
- package/dist/server/pixel.js.map +1 -0
- package/dist/storage/api.d.ts +1 -1
- package/dist/storage/api.js.map +1 -1
- package/dist/storage/batch.d.ts +19 -0
- package/dist/storage/batch.js +59 -0
- package/dist/storage/batch.js.map +1 -0
- package/dist/storage/ga4.d.ts +47 -0
- package/dist/storage/ga4.js +131 -0
- package/dist/storage/ga4.js.map +1 -0
- package/dist/storage/multi.d.ts +21 -0
- package/dist/storage/multi.js +14 -0
- package/dist/storage/multi.js.map +1 -0
- package/dist/storage/postgres.d.ts +1 -1
- package/dist/storage/postgres.js +3 -1
- package/dist/storage/postgres.js.map +1 -1
- package/dist/storage/webhook.d.ts +22 -0
- package/dist/storage/webhook.js +54 -0
- package/dist/storage/webhook.js.map +1 -0
- package/dist/{types-EaeYBDKE.d.ts → types-CceMQIhZ.d.ts} +3 -1
- package/package.json +26 -2
- package/script.js +1 -0
- package/dist/chunk-ABUPERUQ.js.map +0 -1
- package/dist/chunk-L3N32JO4.js +0 -153
- package/dist/chunk-L3N32JO4.js.map +0 -1
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyPrivacy,
|
|
3
|
+
createSessionId,
|
|
4
|
+
isBot,
|
|
5
|
+
resolvePrivacyConfig
|
|
6
|
+
} from "../chunk-7UN7MXBM.js";
|
|
7
|
+
import "../chunk-7D4SUZUM.js";
|
|
8
|
+
|
|
9
|
+
// src/server/pixel.ts
|
|
10
|
+
var TRANSPARENT_GIF = new Uint8Array([
|
|
11
|
+
71,
|
|
12
|
+
73,
|
|
13
|
+
70,
|
|
14
|
+
56,
|
|
15
|
+
57,
|
|
16
|
+
97,
|
|
17
|
+
1,
|
|
18
|
+
0,
|
|
19
|
+
1,
|
|
20
|
+
0,
|
|
21
|
+
128,
|
|
22
|
+
0,
|
|
23
|
+
0,
|
|
24
|
+
255,
|
|
25
|
+
255,
|
|
26
|
+
255,
|
|
27
|
+
0,
|
|
28
|
+
0,
|
|
29
|
+
0,
|
|
30
|
+
33,
|
|
31
|
+
249,
|
|
32
|
+
4,
|
|
33
|
+
0,
|
|
34
|
+
0,
|
|
35
|
+
0,
|
|
36
|
+
0,
|
|
37
|
+
0,
|
|
38
|
+
44,
|
|
39
|
+
0,
|
|
40
|
+
0,
|
|
41
|
+
0,
|
|
42
|
+
0,
|
|
43
|
+
1,
|
|
44
|
+
0,
|
|
45
|
+
1,
|
|
46
|
+
0,
|
|
47
|
+
0,
|
|
48
|
+
2,
|
|
49
|
+
2,
|
|
50
|
+
68,
|
|
51
|
+
1,
|
|
52
|
+
0,
|
|
53
|
+
59
|
|
54
|
+
]);
|
|
55
|
+
var GIF_RESPONSE = new Response(TRANSPARENT_GIF, {
|
|
56
|
+
status: 200,
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "image/gif",
|
|
59
|
+
"Cache-Control": "no-store, no-cache, must-revalidate",
|
|
60
|
+
Pragma: "no-cache"
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
function createPixelHandler(config) {
|
|
64
|
+
return async (request) => {
|
|
65
|
+
if (config.botFilter && isBot(request)) {
|
|
66
|
+
return GIF_RESPONSE.clone();
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
const reqUrl = new URL(request.url);
|
|
70
|
+
const trackedUrl = reqUrl.searchParams.get("url");
|
|
71
|
+
if (!trackedUrl) {
|
|
72
|
+
return GIF_RESPONSE.clone();
|
|
73
|
+
}
|
|
74
|
+
let event = {
|
|
75
|
+
type: "pageview",
|
|
76
|
+
url: trackedUrl,
|
|
77
|
+
referrer: reqUrl.searchParams.get("ref") ?? void 0,
|
|
78
|
+
ts: Date.now()
|
|
79
|
+
};
|
|
80
|
+
const privacy = resolvePrivacyConfig(config.privacy);
|
|
81
|
+
event = applyPrivacy(event, privacy);
|
|
82
|
+
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "0.0.0.0";
|
|
83
|
+
const ua = request.headers.get("user-agent") || "";
|
|
84
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
85
|
+
if (privacy.anonymizeIp) {
|
|
86
|
+
event.sessionId = await createSessionId(ip, ua, today);
|
|
87
|
+
}
|
|
88
|
+
await config.storage.save(event);
|
|
89
|
+
} catch {
|
|
90
|
+
}
|
|
91
|
+
return GIF_RESPONSE.clone();
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
export {
|
|
95
|
+
createPixelHandler
|
|
96
|
+
};
|
|
97
|
+
//# sourceMappingURL=pixel.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/server/pixel.ts"],"sourcesContent":["import type { HandlerConfig, TrackrEvent } from \"../types.js\";\nimport { isBot } from \"./bot.js\";\nimport { applyPrivacy, createSessionId, resolvePrivacyConfig } from \"./privacy.js\";\n\n/**\n * Creates a handler for pixel tracking — returns a 1×1 transparent GIF\n * and records a pageview event based on query parameters.\n *\n * Use this for tracking email opens, external embeds, or any context\n * where JavaScript is unavailable.\n *\n * Query parameters:\n * url — the page/context being tracked (required)\n * ref — referrer (optional)\n *\n * Usage:\n * import { createPixelHandler } from \"@casoon/trackr/server/pixel\";\n * import { postgres } from \"@casoon/trackr/storage/postgres\";\n *\n * const pixel = createPixelHandler({\n * storage: postgres(process.env.DATABASE_URL),\n * privacy: { anonymizeIp: true, stripPii: true },\n * botFilter: true,\n * });\n *\n * // In your route handler (Hono, Cloudflare Workers, etc.):\n * app.get(\"/pixel.gif\", (req) => pixel(req));\n */\n\n// Minimal 1×1 transparent GIF (35 bytes)\nconst TRANSPARENT_GIF = new Uint8Array([\n 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00,\n 0x00, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x21, 0xf9, 0x04, 0x00, 0x00,\n 0x00, 0x00, 0x00, 0x2c, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00,\n 0x00, 0x02, 0x02, 0x44, 0x01, 0x00, 0x3b,\n]);\n\nconst GIF_RESPONSE = new Response(TRANSPARENT_GIF, {\n status: 200,\n headers: {\n \"Content-Type\": \"image/gif\",\n \"Cache-Control\": \"no-store, no-cache, must-revalidate\",\n Pragma: \"no-cache\",\n },\n});\n\nexport function createPixelHandler(\n config: HandlerConfig,\n): (request: Request) => Promise<Response> {\n return async (request: Request): Promise<Response> => {\n if (config.botFilter && isBot(request)) {\n return GIF_RESPONSE.clone();\n }\n\n try {\n const reqUrl = new URL(request.url);\n const trackedUrl = reqUrl.searchParams.get(\"url\");\n\n if (!trackedUrl) {\n return GIF_RESPONSE.clone();\n }\n\n let event: TrackrEvent = {\n type: \"pageview\",\n url: trackedUrl,\n referrer: reqUrl.searchParams.get(\"ref\") ?? undefined,\n ts: Date.now(),\n };\n\n const privacy = resolvePrivacyConfig(config.privacy);\n event = applyPrivacy(event, privacy);\n\n const ip =\n request.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ||\n request.headers.get(\"x-real-ip\") ||\n \"0.0.0.0\";\n const ua = request.headers.get(\"user-agent\") || \"\";\n const today = new Date().toISOString().split(\"T\")[0];\n\n if (privacy.anonymizeIp) {\n event.sessionId = await createSessionId(ip, ua, today);\n }\n\n await config.storage.save(event);\n } catch {\n // Always return the GIF — never let tracking errors break the response\n }\n\n return GIF_RESPONSE.clone();\n };\n}\n"],"mappings":";;;;;;;;;AA8BA,IAAM,kBAAkB,IAAI,WAAW;AAAA,EACrC;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAClE;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAClE;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAClE;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AAAA,EAAM;AACtC,CAAC;AAED,IAAM,eAAe,IAAI,SAAS,iBAAiB;AAAA,EACjD,QAAQ;AAAA,EACR,SAAS;AAAA,IACP,gBAAgB;AAAA,IAChB,iBAAiB;AAAA,IACjB,QAAQ;AAAA,EACV;AACF,CAAC;AAEM,SAAS,mBACd,QACyC;AACzC,SAAO,OAAO,YAAwC;AACpD,QAAI,OAAO,aAAa,MAAM,OAAO,GAAG;AACtC,aAAO,aAAa,MAAM;AAAA,IAC5B;AAEA,QAAI;AACF,YAAM,SAAS,IAAI,IAAI,QAAQ,GAAG;AAClC,YAAM,aAAa,OAAO,aAAa,IAAI,KAAK;AAEhD,UAAI,CAAC,YAAY;AACf,eAAO,aAAa,MAAM;AAAA,MAC5B;AAEA,UAAI,QAAqB;AAAA,QACvB,MAAM;AAAA,QACN,KAAK;AAAA,QACL,UAAU,OAAO,aAAa,IAAI,KAAK,KAAK;AAAA,QAC5C,IAAI,KAAK,IAAI;AAAA,MACf;AAEA,YAAM,UAAU,qBAAqB,OAAO,OAAO;AACnD,cAAQ,aAAa,OAAO,OAAO;AAEnC,YAAM,KACJ,QAAQ,QAAQ,IAAI,iBAAiB,GAAG,MAAM,GAAG,EAAE,CAAC,GAAG,KAAK,KAC5D,QAAQ,QAAQ,IAAI,WAAW,KAC/B;AACF,YAAM,KAAK,QAAQ,QAAQ,IAAI,YAAY,KAAK;AAChD,YAAM,SAAQ,oBAAI,KAAK,GAAE,YAAY,EAAE,MAAM,GAAG,EAAE,CAAC;AAEnD,UAAI,QAAQ,aAAa;AACvB,cAAM,YAAY,MAAM,gBAAgB,IAAI,IAAI,KAAK;AAAA,MACvD;AAEA,YAAM,OAAO,QAAQ,KAAK,KAAK;AAAA,IACjC,QAAQ;AAAA,IAER;AAEA,WAAO,aAAa,MAAM;AAAA,EAC5B;AACF;","names":[]}
|
package/dist/storage/api.d.ts
CHANGED
package/dist/storage/api.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/storage/api.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\ninterface ApiConfig {\n url: string;\n headers?: Record<string, string>;\n transform?: (event: TrackrEvent) => unknown;\n}\n\nexport function api(config: ApiConfig): StorageAdapter {\n return {\n async save(event: TrackrEvent): Promise<void> {\n const body = config.transform ? config.transform(event) : event;\n
|
|
1
|
+
{"version":3,"sources":["../../src/storage/api.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\ninterface ApiConfig {\n url: string;\n headers?: Record<string, string>;\n transform?: (event: TrackrEvent) => unknown;\n}\n\nexport function api(config: ApiConfig): StorageAdapter {\n return {\n async save(event: TrackrEvent): Promise<void> {\n const body = config.transform ? config.transform(event) : event;\n\n await fetch(config.url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n ...config.headers,\n },\n body: JSON.stringify(body),\n });\n },\n };\n}\n"],"mappings":";;;AAQO,SAAS,IAAI,QAAmC;AACrD,SAAO;AAAA,IACL,MAAM,KAAK,OAAmC;AAC5C,YAAM,OAAO,OAAO,YAAY,OAAO,UAAU,KAAK,IAAI;AAE1D,YAAM,MAAM,OAAO,KAAK;AAAA,QACtB,QAAQ;AAAA,QACR,SAAS;AAAA,UACP,gBAAgB;AAAA,UAChB,GAAG,OAAO;AAAA,QACZ;AAAA,QACA,MAAM,KAAK,UAAU,IAAI;AAAA,MAC3B,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { a as TrackrEvent, S as StorageAdapter } from '../types-CceMQIhZ.js';
|
|
2
|
+
|
|
3
|
+
interface BatchOptions {
|
|
4
|
+
/** Flush when buffer reaches this size (default: 10) */
|
|
5
|
+
maxSize?: number;
|
|
6
|
+
/** Flush after this many ms since first buffered event (default: 5000) */
|
|
7
|
+
maxWait?: number;
|
|
8
|
+
/** Called when a flush fails */
|
|
9
|
+
onError?: (error: unknown, events: TrackrEvent[]) => void;
|
|
10
|
+
}
|
|
11
|
+
interface BatchedAdapter extends StorageAdapter {
|
|
12
|
+
/** Manually flush all buffered events */
|
|
13
|
+
flush(): Promise<void>;
|
|
14
|
+
/** Number of currently buffered events */
|
|
15
|
+
readonly pending: number;
|
|
16
|
+
}
|
|
17
|
+
declare function batch(adapter: StorageAdapter, options?: BatchOptions): BatchedAdapter;
|
|
18
|
+
|
|
19
|
+
export { type BatchOptions, type BatchedAdapter, batch };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import "../chunk-7D4SUZUM.js";
|
|
2
|
+
|
|
3
|
+
// src/storage/batch.ts
|
|
4
|
+
function batch(adapter, options) {
|
|
5
|
+
const maxSize = options?.maxSize ?? 10;
|
|
6
|
+
const maxWait = options?.maxWait ?? 5e3;
|
|
7
|
+
const onError = options?.onError;
|
|
8
|
+
let buffer = [];
|
|
9
|
+
let timer = null;
|
|
10
|
+
async function flush() {
|
|
11
|
+
if (buffer.length === 0) return;
|
|
12
|
+
const events = buffer;
|
|
13
|
+
buffer = [];
|
|
14
|
+
if (timer) {
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
timer = null;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
if (adapter.saveBatch) {
|
|
20
|
+
await adapter.saveBatch(events);
|
|
21
|
+
} else {
|
|
22
|
+
await Promise.all(events.map((e) => adapter.save(e)));
|
|
23
|
+
}
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (onError) {
|
|
26
|
+
onError(err, events);
|
|
27
|
+
} else {
|
|
28
|
+
throw err;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function scheduleFlush() {
|
|
33
|
+
if (timer) return;
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
timer = null;
|
|
36
|
+
flush().catch((err) => {
|
|
37
|
+
if (onError) onError(err, []);
|
|
38
|
+
});
|
|
39
|
+
}, maxWait);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
async save(event) {
|
|
43
|
+
buffer.push(event);
|
|
44
|
+
if (buffer.length >= maxSize) {
|
|
45
|
+
await flush();
|
|
46
|
+
} else {
|
|
47
|
+
scheduleFlush();
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
flush,
|
|
51
|
+
get pending() {
|
|
52
|
+
return buffer.length;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
batch
|
|
58
|
+
};
|
|
59
|
+
//# sourceMappingURL=batch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/storage/batch.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\nexport interface BatchOptions {\n /** Flush when buffer reaches this size (default: 10) */\n maxSize?: number;\n /** Flush after this many ms since first buffered event (default: 5000) */\n maxWait?: number;\n /** Called when a flush fails */\n onError?: (error: unknown, events: TrackrEvent[]) => void;\n}\n\nexport interface BatchedAdapter extends StorageAdapter {\n /** Manually flush all buffered events */\n flush(): Promise<void>;\n /** Number of currently buffered events */\n readonly pending: number;\n}\n\nexport function batch(\n adapter: StorageAdapter,\n options?: BatchOptions,\n): BatchedAdapter {\n const maxSize = options?.maxSize ?? 10;\n const maxWait = options?.maxWait ?? 5000;\n const onError = options?.onError;\n\n let buffer: TrackrEvent[] = [];\n let timer: ReturnType<typeof setTimeout> | null = null;\n\n async function flush(): Promise<void> {\n if (buffer.length === 0) return;\n\n const events = buffer;\n buffer = [];\n\n if (timer) {\n clearTimeout(timer);\n timer = null;\n }\n\n try {\n if (adapter.saveBatch) {\n await adapter.saveBatch(events);\n } else {\n await Promise.all(events.map((e) => adapter.save(e)));\n }\n } catch (err) {\n if (onError) {\n onError(err, events);\n } else {\n throw err;\n }\n }\n }\n\n function scheduleFlush(): void {\n if (timer) return;\n timer = setTimeout(() => {\n timer = null;\n flush().catch((err) => {\n if (onError) onError(err, []);\n });\n }, maxWait);\n }\n\n return {\n async save(event: TrackrEvent): Promise<void> {\n buffer.push(event);\n if (buffer.length >= maxSize) {\n await flush();\n } else {\n scheduleFlush();\n }\n },\n\n flush,\n\n get pending(): number {\n return buffer.length;\n },\n };\n}\n"],"mappings":";;;AAkBO,SAAS,MACd,SACA,SACgB;AAChB,QAAM,UAAU,SAAS,WAAW;AACpC,QAAM,UAAU,SAAS,WAAW;AACpC,QAAM,UAAU,SAAS;AAEzB,MAAI,SAAwB,CAAC;AAC7B,MAAI,QAA8C;AAElD,iBAAe,QAAuB;AACpC,QAAI,OAAO,WAAW,EAAG;AAEzB,UAAM,SAAS;AACf,aAAS,CAAC;AAEV,QAAI,OAAO;AACT,mBAAa,KAAK;AAClB,cAAQ;AAAA,IACV;AAEA,QAAI;AACF,UAAI,QAAQ,WAAW;AACrB,cAAM,QAAQ,UAAU,MAAM;AAAA,MAChC,OAAO;AACL,cAAM,QAAQ,IAAI,OAAO,IAAI,CAAC,MAAM,QAAQ,KAAK,CAAC,CAAC,CAAC;AAAA,MACtD;AAAA,IACF,SAAS,KAAK;AACZ,UAAI,SAAS;AACX,gBAAQ,KAAK,MAAM;AAAA,MACrB,OAAO;AACL,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AAEA,WAAS,gBAAsB;AAC7B,QAAI,MAAO;AACX,YAAQ,WAAW,MAAM;AACvB,cAAQ;AACR,YAAM,EAAE,MAAM,CAAC,QAAQ;AACrB,YAAI,QAAS,SAAQ,KAAK,CAAC,CAAC;AAAA,MAC9B,CAAC;AAAA,IACH,GAAG,OAAO;AAAA,EACZ;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,OAAmC;AAC5C,aAAO,KAAK,KAAK;AACjB,UAAI,OAAO,UAAU,SAAS;AAC5B,cAAM,MAAM;AAAA,MACd,OAAO;AACL,sBAAc;AAAA,MAChB;AAAA,IACF;AAAA,IAEA;AAAA,IAEA,IAAI,UAAkB;AACpB,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { S as StorageAdapter } from '../types-CceMQIhZ.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GA4 Measurement Protocol adapter.
|
|
5
|
+
*
|
|
6
|
+
* Forwards trackr events server-side to Google Analytics 4 using the
|
|
7
|
+
* Measurement Protocol — no GA script is loaded in the browser.
|
|
8
|
+
*
|
|
9
|
+
* Privacy notes (operator responsibility):
|
|
10
|
+
* - client_id is derived from trackr's anonymized sessionId (daily-rotating
|
|
11
|
+
* hash of anonymized IP + User-Agent). No raw IP or persistent cookie is sent.
|
|
12
|
+
* - IP forwarding to Google: the Measurement Protocol request originates from
|
|
13
|
+
* your server, not the visitor's browser. Google receives your server's IP,
|
|
14
|
+
* not the visitor's.
|
|
15
|
+
* - Data is still processed by Google LLC. Depending on your jurisdiction and
|
|
16
|
+
* use case this may require a consent mechanism and/or DPA with Google.
|
|
17
|
+
* - Set `nonPersonalizedAds: true` (default) to opt out of ad personalization.
|
|
18
|
+
*
|
|
19
|
+
* @see https://developers.google.com/analytics/devguides/collection/protocol/ga4
|
|
20
|
+
*/
|
|
21
|
+
interface Ga4Config {
|
|
22
|
+
/** GA4 Measurement ID, e.g. "G-XXXXXXXXXX" */
|
|
23
|
+
measurementId: string;
|
|
24
|
+
/** GA4 API secret (create in GA Admin → Data Streams → Measurement Protocol) */
|
|
25
|
+
apiSecret: string;
|
|
26
|
+
/**
|
|
27
|
+
* Send to the GA4 validation / debug endpoint instead of the live endpoint.
|
|
28
|
+
* Useful during development — events are NOT recorded in GA.
|
|
29
|
+
* @default false
|
|
30
|
+
*/
|
|
31
|
+
debug?: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Instruct Google not to use events for ad personalization.
|
|
34
|
+
* Maps to `non_personalized_ads` in the event payload.
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
nonPersonalizedAds?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Strip these query parameters from the page URL before forwarding to GA.
|
|
40
|
+
* Useful to keep internal tracking params (e.g. "ref", "campaign_id") out of GA.
|
|
41
|
+
* Supports trailing wildcards: "fbclid*" removes all params starting with "fbclid".
|
|
42
|
+
*/
|
|
43
|
+
stripQueryParams?: string[];
|
|
44
|
+
}
|
|
45
|
+
declare function ga4(config: Ga4Config): StorageAdapter;
|
|
46
|
+
|
|
47
|
+
export { type Ga4Config, ga4 };
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import "../chunk-7D4SUZUM.js";
|
|
2
|
+
|
|
3
|
+
// src/storage/ga4.ts
|
|
4
|
+
var GA4_RESERVED_NAMES = /* @__PURE__ */ new Set([
|
|
5
|
+
"ad_activeview",
|
|
6
|
+
"ad_click",
|
|
7
|
+
"ad_exposure",
|
|
8
|
+
"ad_impression",
|
|
9
|
+
"ad_query",
|
|
10
|
+
"adunit_exposure",
|
|
11
|
+
"app_clear_data",
|
|
12
|
+
"app_exception",
|
|
13
|
+
"app_install",
|
|
14
|
+
"app_remove",
|
|
15
|
+
"app_store_refund",
|
|
16
|
+
"app_update",
|
|
17
|
+
"app_upgrade",
|
|
18
|
+
"dynamic_link_app_open",
|
|
19
|
+
"dynamic_link_app_update",
|
|
20
|
+
"dynamic_link_first_open",
|
|
21
|
+
"error",
|
|
22
|
+
"firebase_campaign",
|
|
23
|
+
"first_open",
|
|
24
|
+
"first_visit",
|
|
25
|
+
"in_app_purchase",
|
|
26
|
+
"notification_dismiss",
|
|
27
|
+
"notification_foreground",
|
|
28
|
+
"notification_open",
|
|
29
|
+
"notification_receive",
|
|
30
|
+
"os_update",
|
|
31
|
+
"session_start",
|
|
32
|
+
"user_engagement"
|
|
33
|
+
]);
|
|
34
|
+
function sanitizeEventName(name) {
|
|
35
|
+
let sanitized = name.replace(/[^a-zA-Z0-9_]/g, "_").replace(/^[^a-zA-Z]+/, "").slice(0, 40);
|
|
36
|
+
if (!sanitized) sanitized = "custom_event";
|
|
37
|
+
if (GA4_RESERVED_NAMES.has(sanitized)) sanitized = `trackr_${sanitized}`;
|
|
38
|
+
return sanitized;
|
|
39
|
+
}
|
|
40
|
+
function stripParams(url, params) {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(url, "http://localhost");
|
|
43
|
+
for (const p of params) {
|
|
44
|
+
if (p.endsWith("*")) {
|
|
45
|
+
const prefix = p.slice(0, -1);
|
|
46
|
+
for (const key of [...u.searchParams.keys()]) {
|
|
47
|
+
if (key.startsWith(prefix)) u.searchParams.delete(key);
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
u.searchParams.delete(p);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const original = new URL(url);
|
|
55
|
+
return original.origin + u.pathname + (u.search || "");
|
|
56
|
+
} catch {
|
|
57
|
+
return u.pathname + (u.search || "");
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
return url;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function buildPayload(event, config) {
|
|
64
|
+
const clientId = event.sessionId ?? `anon_${event.ts}`;
|
|
65
|
+
const nonPersonalizedAds = config.nonPersonalizedAds !== false;
|
|
66
|
+
const pageUrl = config.stripQueryParams?.length ? stripParams(event.url, config.stripQueryParams) : event.url;
|
|
67
|
+
const baseParams = {
|
|
68
|
+
session_id: clientId,
|
|
69
|
+
engagement_time_msec: 1
|
|
70
|
+
};
|
|
71
|
+
if (event.country) baseParams.country = event.country;
|
|
72
|
+
if (event.device) baseParams.device_category = event.device;
|
|
73
|
+
if (event.browser) baseParams.browser = event.browser;
|
|
74
|
+
let ga4Event;
|
|
75
|
+
if (event.type === "pageview") {
|
|
76
|
+
ga4Event = {
|
|
77
|
+
name: "page_view",
|
|
78
|
+
params: {
|
|
79
|
+
...baseParams,
|
|
80
|
+
page_location: pageUrl,
|
|
81
|
+
...event.referrer ? { page_referrer: event.referrer } : {}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
} else {
|
|
85
|
+
const name = sanitizeEventName(event.name ?? "custom_event");
|
|
86
|
+
const props = {};
|
|
87
|
+
if (event.props) {
|
|
88
|
+
for (const [k, v] of Object.entries(event.props)) {
|
|
89
|
+
const safeKey = k.replace(/[^a-zA-Z0-9_]/g, "_").slice(0, 40);
|
|
90
|
+
const safeVal = typeof v === "string" ? v.slice(0, 100) : v;
|
|
91
|
+
props[safeKey] = safeVal;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
ga4Event = {
|
|
95
|
+
name,
|
|
96
|
+
params: {
|
|
97
|
+
...baseParams,
|
|
98
|
+
page_location: pageUrl,
|
|
99
|
+
...props
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
client_id: clientId,
|
|
105
|
+
...nonPersonalizedAds ? { non_personalized_ads: true } : {},
|
|
106
|
+
events: [ga4Event]
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function ga4(config) {
|
|
110
|
+
const base = config.debug ? "https://www.google-analytics.com/debug/mp/collect" : "https://www.google-analytics.com/mp/collect";
|
|
111
|
+
const endpoint = `${base}?measurement_id=${encodeURIComponent(config.measurementId)}&api_secret=${encodeURIComponent(config.apiSecret)}`;
|
|
112
|
+
return {
|
|
113
|
+
async save(event) {
|
|
114
|
+
const payload = buildPayload(event, config);
|
|
115
|
+
const res = await fetch(endpoint, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify(payload)
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`GA4 Measurement Protocol error: ${res.status} ${res.statusText}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export {
|
|
129
|
+
ga4
|
|
130
|
+
};
|
|
131
|
+
//# sourceMappingURL=ga4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/storage/ga4.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\n/**\n * GA4 Measurement Protocol adapter.\n *\n * Forwards trackr events server-side to Google Analytics 4 using the\n * Measurement Protocol — no GA script is loaded in the browser.\n *\n * Privacy notes (operator responsibility):\n * - client_id is derived from trackr's anonymized sessionId (daily-rotating\n * hash of anonymized IP + User-Agent). No raw IP or persistent cookie is sent.\n * - IP forwarding to Google: the Measurement Protocol request originates from\n * your server, not the visitor's browser. Google receives your server's IP,\n * not the visitor's.\n * - Data is still processed by Google LLC. Depending on your jurisdiction and\n * use case this may require a consent mechanism and/or DPA with Google.\n * - Set `nonPersonalizedAds: true` (default) to opt out of ad personalization.\n *\n * @see https://developers.google.com/analytics/devguides/collection/protocol/ga4\n */\n\nexport interface Ga4Config {\n /** GA4 Measurement ID, e.g. \"G-XXXXXXXXXX\" */\n measurementId: string;\n /** GA4 API secret (create in GA Admin → Data Streams → Measurement Protocol) */\n apiSecret: string;\n /**\n * Send to the GA4 validation / debug endpoint instead of the live endpoint.\n * Useful during development — events are NOT recorded in GA.\n * @default false\n */\n debug?: boolean;\n /**\n * Instruct Google not to use events for ad personalization.\n * Maps to `non_personalized_ads` in the event payload.\n * @default true\n */\n nonPersonalizedAds?: boolean;\n /**\n * Strip these query parameters from the page URL before forwarding to GA.\n * Useful to keep internal tracking params (e.g. \"ref\", \"campaign_id\") out of GA.\n * Supports trailing wildcards: \"fbclid*\" removes all params starting with \"fbclid\".\n */\n stripQueryParams?: string[];\n}\n\ninterface Ga4Payload {\n client_id: string;\n non_personalized_ads?: boolean;\n events: Ga4Event[];\n}\n\ninterface Ga4Event {\n name: string;\n params: Record<string, string | number | boolean>;\n}\n\n/** GA4 event names that are reserved and must not be used. */\nconst GA4_RESERVED_NAMES = new Set([\n \"ad_activeview\",\n \"ad_click\",\n \"ad_exposure\",\n \"ad_impression\",\n \"ad_query\",\n \"adunit_exposure\",\n \"app_clear_data\",\n \"app_exception\",\n \"app_install\",\n \"app_remove\",\n \"app_store_refund\",\n \"app_update\",\n \"app_upgrade\",\n \"dynamic_link_app_open\",\n \"dynamic_link_app_update\",\n \"dynamic_link_first_open\",\n \"error\",\n \"firebase_campaign\",\n \"first_open\",\n \"first_visit\",\n \"in_app_purchase\",\n \"notification_dismiss\",\n \"notification_foreground\",\n \"notification_open\",\n \"notification_receive\",\n \"os_update\",\n \"session_start\",\n \"user_engagement\",\n]);\n\n/**\n * Sanitize an event name to comply with GA4 naming rules:\n * - Must start with a letter\n * - Only letters, digits, underscores\n * - Max 40 characters\n * - Must not be a reserved name\n */\nfunction sanitizeEventName(name: string): string {\n let sanitized = name\n .replace(/[^a-zA-Z0-9_]/g, \"_\")\n .replace(/^[^a-zA-Z]+/, \"\")\n .slice(0, 40);\n\n if (!sanitized) sanitized = \"custom_event\";\n if (GA4_RESERVED_NAMES.has(sanitized)) sanitized = `trackr_${sanitized}`;\n\n return sanitized;\n}\n\nfunction stripParams(url: string, params: string[]): string {\n try {\n const u = new URL(url, \"http://localhost\");\n for (const p of params) {\n if (p.endsWith(\"*\")) {\n const prefix = p.slice(0, -1);\n for (const key of [...u.searchParams.keys()]) {\n if (key.startsWith(prefix)) u.searchParams.delete(key);\n }\n } else {\n u.searchParams.delete(p);\n }\n }\n // Reconstruct with origin if the original URL had one\n try {\n const original = new URL(url);\n return original.origin + u.pathname + (u.search || \"\");\n } catch {\n return u.pathname + (u.search || \"\");\n }\n } catch {\n return url;\n }\n}\n\nfunction buildPayload(event: TrackrEvent, config: Ga4Config): Ga4Payload {\n const clientId = event.sessionId ?? `anon_${event.ts}`;\n const nonPersonalizedAds = config.nonPersonalizedAds !== false;\n\n const pageUrl =\n config.stripQueryParams?.length\n ? stripParams(event.url, config.stripQueryParams)\n : event.url;\n\n const baseParams: Record<string, string | number | boolean> = {\n session_id: clientId,\n engagement_time_msec: 1,\n };\n\n if (event.country) baseParams.country = event.country;\n if (event.device) baseParams.device_category = event.device;\n if (event.browser) baseParams.browser = event.browser;\n\n let ga4Event: Ga4Event;\n\n if (event.type === \"pageview\") {\n ga4Event = {\n name: \"page_view\",\n params: {\n ...baseParams,\n page_location: pageUrl,\n ...(event.referrer ? { page_referrer: event.referrer } : {}),\n },\n };\n } else {\n const name = sanitizeEventName(event.name ?? \"custom_event\");\n const props: Record<string, string | number | boolean> = {};\n\n if (event.props) {\n for (const [k, v] of Object.entries(event.props)) {\n // GA4 allows max 25 custom params, key max 40 chars, value max 100 chars\n const safeKey = k.replace(/[^a-zA-Z0-9_]/g, \"_\").slice(0, 40);\n const safeVal =\n typeof v === \"string\" ? v.slice(0, 100) : v;\n props[safeKey] = safeVal;\n }\n }\n\n ga4Event = {\n name,\n params: {\n ...baseParams,\n page_location: pageUrl,\n ...props,\n },\n };\n }\n\n return {\n client_id: clientId,\n ...(nonPersonalizedAds ? { non_personalized_ads: true } : {}),\n events: [ga4Event],\n };\n}\n\nexport function ga4(config: Ga4Config): StorageAdapter {\n const base = config.debug\n ? \"https://www.google-analytics.com/debug/mp/collect\"\n : \"https://www.google-analytics.com/mp/collect\";\n\n const endpoint = `${base}?measurement_id=${encodeURIComponent(config.measurementId)}&api_secret=${encodeURIComponent(config.apiSecret)}`;\n\n return {\n async save(event: TrackrEvent): Promise<void> {\n const payload = buildPayload(event, config);\n\n const res = await fetch(endpoint, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify(payload),\n });\n\n if (!res.ok) {\n throw new Error(\n `GA4 Measurement Protocol error: ${res.status} ${res.statusText}`,\n );\n }\n },\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { S as StorageAdapter } from '../types-CceMQIhZ.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combines multiple StorageAdapters into one.
|
|
5
|
+
* All adapters receive every event concurrently via Promise.all.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { multi } from "@casoon/trackr/storage/multi";
|
|
9
|
+
* import { postgres } from "@casoon/trackr/storage/postgres";
|
|
10
|
+
* import { ga4 } from "@casoon/trackr/storage/ga4";
|
|
11
|
+
*
|
|
12
|
+
* const handler = createHandler({
|
|
13
|
+
* storage: multi(
|
|
14
|
+
* postgres(process.env.DATABASE_URL),
|
|
15
|
+
* ga4({ measurementId: "G-XXXXXXXXXX", apiSecret: "..." }),
|
|
16
|
+
* ),
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
declare function multi(...adapters: StorageAdapter[]): StorageAdapter;
|
|
20
|
+
|
|
21
|
+
export { multi };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import "../chunk-7D4SUZUM.js";
|
|
2
|
+
|
|
3
|
+
// src/storage/multi.ts
|
|
4
|
+
function multi(...adapters) {
|
|
5
|
+
return {
|
|
6
|
+
async save(event) {
|
|
7
|
+
await Promise.all(adapters.map((a) => a.save(event)));
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
multi
|
|
13
|
+
};
|
|
14
|
+
//# sourceMappingURL=multi.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/storage/multi.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\n/**\n * Combines multiple StorageAdapters into one.\n * All adapters receive every event concurrently via Promise.all.\n *\n * Usage:\n * import { multi } from \"@casoon/trackr/storage/multi\";\n * import { postgres } from \"@casoon/trackr/storage/postgres\";\n * import { ga4 } from \"@casoon/trackr/storage/ga4\";\n *\n * const handler = createHandler({\n * storage: multi(\n * postgres(process.env.DATABASE_URL),\n * ga4({ measurementId: \"G-XXXXXXXXXX\", apiSecret: \"...\" }),\n * ),\n * });\n */\nexport function multi(...adapters: StorageAdapter[]): StorageAdapter {\n return {\n async save(event: TrackrEvent): Promise<void> {\n await Promise.all(adapters.map((a) => a.save(event)));\n },\n };\n}\n"],"mappings":";;;AAkBO,SAAS,SAAS,UAA4C;AACnE,SAAO;AAAA,IACL,MAAM,KAAK,OAAmC;AAC5C,YAAM,QAAQ,IAAI,SAAS,IAAI,CAAC,MAAM,EAAE,KAAK,KAAK,CAAC,CAAC;AAAA,IACtD;AAAA,EACF;AACF;","names":[]}
|
package/dist/storage/postgres.js
CHANGED
|
@@ -7,7 +7,9 @@ function postgres(connectionStringOrClient) {
|
|
|
7
7
|
if (client) return client;
|
|
8
8
|
if (typeof connectionStringOrClient === "string") {
|
|
9
9
|
const pg = await import("../esm-O5HEMDRH.js");
|
|
10
|
-
const pool = new pg.default.Pool({
|
|
10
|
+
const pool = new pg.default.Pool({
|
|
11
|
+
connectionString: connectionStringOrClient
|
|
12
|
+
});
|
|
11
13
|
client = pool;
|
|
12
14
|
return pool;
|
|
13
15
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/storage/postgres.ts"],"sourcesContent":["import type { StorageAdapter, TrackrEvent
|
|
1
|
+
{"version":3,"sources":["../../src/storage/postgres.ts"],"sourcesContent":["import type { QueryOptions, StorageAdapter, TrackrEvent } from \"../types.js\";\n\ninterface PostgresClient {\n query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>;\n}\n\nexport function postgres(\n connectionStringOrClient: string | PostgresClient,\n): StorageAdapter {\n let client: PostgresClient | null = null;\n\n const getClient = async (): Promise<PostgresClient> => {\n if (client) return client;\n\n if (typeof connectionStringOrClient === \"string\") {\n const pg = await import(\"pg\");\n const pool = new pg.default.Pool({\n connectionString: connectionStringOrClient,\n });\n client = pool;\n return pool;\n }\n\n client = connectionStringOrClient;\n return client;\n };\n\n return {\n async save(event: TrackrEvent): Promise<void> {\n const db = await getClient();\n await db.query(\n `INSERT INTO trackr_events (type, name, url, referrer_domain, country, device, browser, session_id, props, ts)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, to_timestamp($10 / 1000.0))`,\n [\n event.type,\n event.name || null,\n event.url,\n event.referrer || null,\n event.country || null,\n event.device || null,\n event.browser || null,\n event.sessionId || null,\n JSON.stringify(event.props || {}),\n event.ts,\n ],\n );\n },\n\n async query(options: QueryOptions): Promise<TrackrEvent[]> {\n const db = await getClient();\n const conditions: string[] = [];\n const values: unknown[] = [];\n let i = 1;\n\n if (options.from) {\n conditions.push(`ts >= $${i++}`);\n values.push(options.from);\n }\n if (options.to) {\n conditions.push(`ts <= $${i++}`);\n values.push(options.to);\n }\n if (options.type) {\n conditions.push(`type = $${i++}`);\n values.push(options.type);\n }\n\n const where = conditions.length\n ? `WHERE ${conditions.join(\" AND \")}`\n : \"\";\n const limit = options.limit ? `LIMIT ${options.limit}` : \"\";\n\n const result = await db.query(\n `SELECT * FROM trackr_events ${where} ORDER BY ts DESC ${limit}`,\n values,\n );\n\n return result.rows as TrackrEvent[];\n },\n };\n}\n"],"mappings":";;;AAMO,SAAS,SACd,0BACgB;AAChB,MAAI,SAAgC;AAEpC,QAAM,YAAY,YAAqC;AACrD,QAAI,OAAQ,QAAO;AAEnB,QAAI,OAAO,6BAA6B,UAAU;AAChD,YAAM,KAAK,MAAM,OAAO,oBAAI;AAC5B,YAAM,OAAO,IAAI,GAAG,QAAQ,KAAK;AAAA,QAC/B,kBAAkB;AAAA,MACpB,CAAC;AACD,eAAS;AACT,aAAO;AAAA,IACT;AAEA,aAAS;AACT,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,OAAmC;AAC5C,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,GAAG;AAAA,QACP;AAAA;AAAA,QAEA;AAAA,UACE,MAAM;AAAA,UACN,MAAM,QAAQ;AAAA,UACd,MAAM;AAAA,UACN,MAAM,YAAY;AAAA,UAClB,MAAM,WAAW;AAAA,UACjB,MAAM,UAAU;AAAA,UAChB,MAAM,WAAW;AAAA,UACjB,MAAM,aAAa;AAAA,UACnB,KAAK,UAAU,MAAM,SAAS,CAAC,CAAC;AAAA,UAChC,MAAM;AAAA,QACR;AAAA,MACF;AAAA,IACF;AAAA,IAEA,MAAM,MAAM,SAA+C;AACzD,YAAM,KAAK,MAAM,UAAU;AAC3B,YAAM,aAAuB,CAAC;AAC9B,YAAM,SAAoB,CAAC;AAC3B,UAAI,IAAI;AAER,UAAI,QAAQ,MAAM;AAChB,mBAAW,KAAK,UAAU,GAAG,EAAE;AAC/B,eAAO,KAAK,QAAQ,IAAI;AAAA,MAC1B;AACA,UAAI,QAAQ,IAAI;AACd,mBAAW,KAAK,UAAU,GAAG,EAAE;AAC/B,eAAO,KAAK,QAAQ,EAAE;AAAA,MACxB;AACA,UAAI,QAAQ,MAAM;AAChB,mBAAW,KAAK,WAAW,GAAG,EAAE;AAChC,eAAO,KAAK,QAAQ,IAAI;AAAA,MAC1B;AAEA,YAAM,QAAQ,WAAW,SACrB,SAAS,WAAW,KAAK,OAAO,CAAC,KACjC;AACJ,YAAM,QAAQ,QAAQ,QAAQ,SAAS,QAAQ,KAAK,KAAK;AAEzD,YAAM,SAAS,MAAM,GAAG;AAAA,QACtB,+BAA+B,KAAK,qBAAqB,KAAK;AAAA,QAC9D;AAAA,MACF;AAEA,aAAO,OAAO;AAAA,IAChB;AAAA,EACF;AACF;","names":[]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { a as TrackrEvent, S as StorageAdapter } from '../types-CceMQIhZ.js';
|
|
2
|
+
|
|
3
|
+
interface WebhookConfig {
|
|
4
|
+
/** Target URL to POST events to */
|
|
5
|
+
url: string;
|
|
6
|
+
/** Additional HTTP headers */
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
/** HMAC-SHA256 secret — when set, adds X-Trackr-Signature header */
|
|
9
|
+
secret?: string;
|
|
10
|
+
/** Transform event before sending */
|
|
11
|
+
transform?: (event: TrackrEvent) => unknown;
|
|
12
|
+
/** Retry options for failed requests (default: no retry) */
|
|
13
|
+
retry?: {
|
|
14
|
+
/** Max number of retries (default: 3) */
|
|
15
|
+
attempts?: number;
|
|
16
|
+
/** Base delay in ms, doubled on each retry (default: 500) */
|
|
17
|
+
baseDelay?: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
declare function webhook(config: WebhookConfig): StorageAdapter;
|
|
21
|
+
|
|
22
|
+
export { type WebhookConfig, webhook };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import "../chunk-7D4SUZUM.js";
|
|
2
|
+
|
|
3
|
+
// src/storage/webhook.ts
|
|
4
|
+
import { createHmac } from "crypto";
|
|
5
|
+
function sign(payload, secret) {
|
|
6
|
+
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
7
|
+
}
|
|
8
|
+
async function sendWithRetry(url, body, headers, attempts, baseDelay) {
|
|
9
|
+
for (let i = 0; i <= attempts; i++) {
|
|
10
|
+
const res = await fetch(url, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers,
|
|
13
|
+
body
|
|
14
|
+
});
|
|
15
|
+
if (res.ok) return;
|
|
16
|
+
if (i < attempts && res.status >= 500) {
|
|
17
|
+
await new Promise((r) => setTimeout(r, baseDelay * 2 ** i));
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
throw new Error(`Webhook failed: ${res.status} ${res.statusText}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function webhook(config) {
|
|
24
|
+
const maxAttempts = config.retry?.attempts ?? 0;
|
|
25
|
+
const baseDelay = config.retry?.baseDelay ?? 500;
|
|
26
|
+
function buildHeaders(body) {
|
|
27
|
+
const headers = {
|
|
28
|
+
"Content-Type": "application/json",
|
|
29
|
+
...config.headers
|
|
30
|
+
};
|
|
31
|
+
if (config.secret) {
|
|
32
|
+
headers["X-Trackr-Signature"] = sign(body, config.secret);
|
|
33
|
+
}
|
|
34
|
+
return headers;
|
|
35
|
+
}
|
|
36
|
+
function transformOne(event) {
|
|
37
|
+
return config.transform ? config.transform(event) : event;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
async save(event) {
|
|
41
|
+
const body = JSON.stringify(transformOne(event));
|
|
42
|
+
await sendWithRetry(config.url, body, buildHeaders(body), maxAttempts, baseDelay);
|
|
43
|
+
},
|
|
44
|
+
async saveBatch(events) {
|
|
45
|
+
const payload = events.map(transformOne);
|
|
46
|
+
const body = JSON.stringify(payload);
|
|
47
|
+
await sendWithRetry(config.url, body, buildHeaders(body), maxAttempts, baseDelay);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
webhook
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=webhook.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/storage/webhook.ts"],"sourcesContent":["import { createHmac } from \"node:crypto\";\nimport type { StorageAdapter, TrackrEvent } from \"../types.js\";\n\nexport interface WebhookConfig {\n /** Target URL to POST events to */\n url: string;\n /** Additional HTTP headers */\n headers?: Record<string, string>;\n /** HMAC-SHA256 secret — when set, adds X-Trackr-Signature header */\n secret?: string;\n /** Transform event before sending */\n transform?: (event: TrackrEvent) => unknown;\n /** Retry options for failed requests (default: no retry) */\n retry?: {\n /** Max number of retries (default: 3) */\n attempts?: number;\n /** Base delay in ms, doubled on each retry (default: 500) */\n baseDelay?: number;\n };\n}\n\nfunction sign(payload: string, secret: string): string {\n return createHmac(\"sha256\", secret).update(payload).digest(\"hex\");\n}\n\nasync function sendWithRetry(\n url: string,\n body: string,\n headers: Record<string, string>,\n attempts: number,\n baseDelay: number,\n): Promise<void> {\n for (let i = 0; i <= attempts; i++) {\n const res = await fetch(url, {\n method: \"POST\",\n headers,\n body,\n });\n\n if (res.ok) return;\n\n if (i < attempts && res.status >= 500) {\n await new Promise((r) => setTimeout(r, baseDelay * 2 ** i));\n continue;\n }\n\n throw new Error(`Webhook failed: ${res.status} ${res.statusText}`);\n }\n}\n\nexport function webhook(config: WebhookConfig): StorageAdapter {\n const maxAttempts = config.retry?.attempts ?? 0;\n const baseDelay = config.retry?.baseDelay ?? 500;\n\n function buildHeaders(body: string): Record<string, string> {\n const headers: Record<string, string> = {\n \"Content-Type\": \"application/json\",\n ...config.headers,\n };\n if (config.secret) {\n headers[\"X-Trackr-Signature\"] = sign(body, config.secret);\n }\n return headers;\n }\n\n function transformOne(event: TrackrEvent): unknown {\n return config.transform ? config.transform(event) : event;\n }\n\n return {\n async save(event: TrackrEvent): Promise<void> {\n const body = JSON.stringify(transformOne(event));\n await sendWithRetry(config.url, body, buildHeaders(body), maxAttempts, baseDelay);\n },\n\n async saveBatch(events: TrackrEvent[]): Promise<void> {\n const payload = events.map(transformOne);\n const body = JSON.stringify(payload);\n await sendWithRetry(config.url, body, buildHeaders(body), maxAttempts, baseDelay);\n },\n };\n}\n"],"mappings":";;;AAAA,SAAS,kBAAkB;AAqB3B,SAAS,KAAK,SAAiB,QAAwB;AACrD,SAAO,WAAW,UAAU,MAAM,EAAE,OAAO,OAAO,EAAE,OAAO,KAAK;AAClE;AAEA,eAAe,cACb,KACA,MACA,SACA,UACA,WACe;AACf,WAAS,IAAI,GAAG,KAAK,UAAU,KAAK;AAClC,UAAM,MAAM,MAAM,MAAM,KAAK;AAAA,MAC3B,QAAQ;AAAA,MACR;AAAA,MACA;AAAA,IACF,CAAC;AAED,QAAI,IAAI,GAAI;AAEZ,QAAI,IAAI,YAAY,IAAI,UAAU,KAAK;AACrC,YAAM,IAAI,QAAQ,CAAC,MAAM,WAAW,GAAG,YAAY,KAAK,CAAC,CAAC;AAC1D;AAAA,IACF;AAEA,UAAM,IAAI,MAAM,mBAAmB,IAAI,MAAM,IAAI,IAAI,UAAU,EAAE;AAAA,EACnE;AACF;AAEO,SAAS,QAAQ,QAAuC;AAC7D,QAAM,cAAc,OAAO,OAAO,YAAY;AAC9C,QAAM,YAAY,OAAO,OAAO,aAAa;AAE7C,WAAS,aAAa,MAAsC;AAC1D,UAAM,UAAkC;AAAA,MACtC,gBAAgB;AAAA,MAChB,GAAG,OAAO;AAAA,IACZ;AACA,QAAI,OAAO,QAAQ;AACjB,cAAQ,oBAAoB,IAAI,KAAK,MAAM,OAAO,MAAM;AAAA,IAC1D;AACA,WAAO;AAAA,EACT;AAEA,WAAS,aAAa,OAA6B;AACjD,WAAO,OAAO,YAAY,OAAO,UAAU,KAAK,IAAI;AAAA,EACtD;AAEA,SAAO;AAAA,IACL,MAAM,KAAK,OAAmC;AAC5C,YAAM,OAAO,KAAK,UAAU,aAAa,KAAK,CAAC;AAC/C,YAAM,cAAc,OAAO,KAAK,MAAM,aAAa,IAAI,GAAG,aAAa,SAAS;AAAA,IAClF;AAAA,IAEA,MAAM,UAAU,QAAsC;AACpD,YAAM,UAAU,OAAO,IAAI,YAAY;AACvC,YAAM,OAAO,KAAK,UAAU,OAAO;AACnC,YAAM,cAAc,OAAO,KAAK,MAAM,aAAa,IAAI,GAAG,aAAa,SAAS;AAAA,IAClF;AAAA,EACF;AACF;","names":[]}
|
|
@@ -6,6 +6,7 @@ interface TrackrEvent {
|
|
|
6
6
|
country?: string;
|
|
7
7
|
device?: "desktop" | "mobile" | "tablet";
|
|
8
8
|
browser?: string;
|
|
9
|
+
os?: string;
|
|
9
10
|
sessionId?: string;
|
|
10
11
|
props?: Record<string, string | number | boolean>;
|
|
11
12
|
ts: number;
|
|
@@ -16,6 +17,7 @@ interface TrackrConfig {
|
|
|
16
17
|
}
|
|
17
18
|
interface StorageAdapter {
|
|
18
19
|
save(event: TrackrEvent): Promise<void>;
|
|
20
|
+
saveBatch?(events: TrackrEvent[]): Promise<void>;
|
|
19
21
|
query?(options: QueryOptions): Promise<TrackrEvent[]>;
|
|
20
22
|
}
|
|
21
23
|
interface QueryOptions {
|
|
@@ -35,4 +37,4 @@ interface HandlerConfig {
|
|
|
35
37
|
botFilter?: boolean;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
export type { HandlerConfig as H, PrivacyConfig as P, QueryOptions as Q, StorageAdapter as S,
|
|
40
|
+
export type { HandlerConfig as H, PrivacyConfig as P, QueryOptions as Q, StorageAdapter as S, TrackrConfig as T, TrackrEvent as a };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@casoon/trackr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Privacy-first, GDPR-native analytics for static sites.",
|
|
5
5
|
"author": "Joern Seidel <joern@casoon.de>",
|
|
6
6
|
"license": "LGPL-3.0-or-later",
|
|
@@ -25,6 +25,26 @@
|
|
|
25
25
|
"./storage/api": {
|
|
26
26
|
"types": "./dist/storage/api.d.ts",
|
|
27
27
|
"import": "./dist/storage/api.js"
|
|
28
|
+
},
|
|
29
|
+
"./storage/ga4": {
|
|
30
|
+
"types": "./dist/storage/ga4.d.ts",
|
|
31
|
+
"import": "./dist/storage/ga4.js"
|
|
32
|
+
},
|
|
33
|
+
"./storage/multi": {
|
|
34
|
+
"types": "./dist/storage/multi.d.ts",
|
|
35
|
+
"import": "./dist/storage/multi.js"
|
|
36
|
+
},
|
|
37
|
+
"./server/pixel": {
|
|
38
|
+
"types": "./dist/server/pixel.d.ts",
|
|
39
|
+
"import": "./dist/server/pixel.js"
|
|
40
|
+
},
|
|
41
|
+
"./storage/webhook": {
|
|
42
|
+
"types": "./dist/storage/webhook.d.ts",
|
|
43
|
+
"import": "./dist/storage/webhook.js"
|
|
44
|
+
},
|
|
45
|
+
"./storage/batch": {
|
|
46
|
+
"types": "./dist/storage/batch.d.ts",
|
|
47
|
+
"import": "./dist/storage/batch.js"
|
|
28
48
|
}
|
|
29
49
|
},
|
|
30
50
|
"files": [
|
|
@@ -37,9 +57,13 @@
|
|
|
37
57
|
"build": "tsup",
|
|
38
58
|
"dev": "tsup --watch",
|
|
39
59
|
"test": "vitest",
|
|
40
|
-
"typecheck": "tsc --noEmit"
|
|
60
|
+
"typecheck": "tsc --noEmit",
|
|
61
|
+
"lint": "biome check",
|
|
62
|
+
"lint:fix": "biome check --fix",
|
|
63
|
+
"format": "biome format --write"
|
|
41
64
|
},
|
|
42
65
|
"devDependencies": {
|
|
66
|
+
"@biomejs/biome": "^2.3.8",
|
|
43
67
|
"@types/node": "^22.10.2",
|
|
44
68
|
"@types/pg": "^8.16.0",
|
|
45
69
|
"tsup": "^8.3.5",
|