@nodewrite-labs/analytics-sdk 0.0.0-stage → 0.1.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 +98 -2
- package/dist/chunk-F7BY2JQ3.js +9 -0
- package/dist/chunk-GVOESWHZ.js +270 -0
- package/dist/chunk-GWOVVKP3.js +179 -0
- package/dist/chunk-Q4JRXZ6M.js +132 -0
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.js +219 -0
- package/dist/event-body-fpV2mYpY.d.ts +18 -0
- package/dist/event-store-BhisLdw_.d.ts +72 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +49 -0
- package/dist/options-D3s0dcJ-.d.ts +19 -0
- package/dist/payload/admin-view.d.ts +6 -0
- package/dist/payload/admin-view.js +290 -0
- package/dist/payload/index.d.ts +70 -0
- package/dist/payload/index.js +100 -0
- package/dist/payload/nav-link.d.ts +5 -0
- package/dist/payload/nav-link.js +8 -0
- package/dist/postgres/index.d.ts +17 -0
- package/dist/postgres/index.js +156 -0
- package/dist/server/index.d.ts +44 -0
- package/dist/server/index.js +9 -0
- package/package.json +88 -4
- package/src/postgres/schema.sql +12 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nodewrite-hq
|
|
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
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @nodewrite-labs/analytics-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Records page views and named clicks. The browser sends an event. The server checks it and writes it through a store. Country and city come from the caller. A value on the JSON body is ignored.
|
|
4
|
+
|
|
5
|
+
`payload` and `pg` are optional peer dependencies. Install one only when that entry is used.
|
|
6
|
+
|
|
7
|
+
## Entries
|
|
8
|
+
|
|
9
|
+
| Import | What it exports |
|
|
10
|
+
| ----------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
11
|
+
| `@nodewrite-labs/analytics-sdk` | Event types, module flags, `AnalyticsEventStore`, `MemoryAnalyticsEventStore`, `summarizeAnalyticsEvents` |
|
|
12
|
+
| `@nodewrite-labs/analytics-sdk/client` | `startAnalytics`, `recordNamedClick` |
|
|
13
|
+
| `@nodewrite-labs/analytics-sdk/server` | `recordAnalyticsEvent`, `readRequestGeo` |
|
|
14
|
+
| `@nodewrite-labs/analytics-sdk/postgres` | `PostgresAnalyticsEventStore`, `schema.sql` |
|
|
15
|
+
| `@nodewrite-labs/analytics-sdk/payload` | Payload collection, ingest, and admin screen |
|
|
16
|
+
|
|
17
|
+
## Events
|
|
18
|
+
|
|
19
|
+
A view is `{ type: "view", path, locale, sessionId }`.
|
|
20
|
+
|
|
21
|
+
A duration is `{ type: "duration", id, durationSeconds }`. A duration is kept only inside 0..1800 seconds.
|
|
22
|
+
|
|
23
|
+
A click is `{ type: "click", path, locale, sessionId, eventName }`. `eventName` is lowercase letters, digits, and underscores.
|
|
24
|
+
|
|
25
|
+
`path` starts with `/`. The query string is removed before the row is stored.
|
|
26
|
+
|
|
27
|
+
## Modules
|
|
28
|
+
|
|
29
|
+
`country`, `city`, `sessions`, and `clicks`. A module that is off stores null for its field. The summary omits that breakdown.
|
|
30
|
+
|
|
31
|
+
## Browser
|
|
32
|
+
|
|
33
|
+
Call `startAnalytics` after the visitor has consented. `enabled: false` sends nothing.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { startAnalytics } from "@nodewrite-labs/analytics-sdk/client";
|
|
37
|
+
|
|
38
|
+
startAnalytics({
|
|
39
|
+
endpoint: "/analytics/events",
|
|
40
|
+
enabled: true,
|
|
41
|
+
locale: "en",
|
|
42
|
+
sessions: true,
|
|
43
|
+
clicks: true,
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A named click is an element with `data-analytics`. The attribute value is the event name. Clicks are sent only when `clicks` is true.
|
|
48
|
+
|
|
49
|
+
```html
|
|
50
|
+
<button type="button" data-analytics="cta_clicked">Continue</button>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`recordNamedClick(name)` sends one named click when clicks are on.
|
|
54
|
+
|
|
55
|
+
## Server
|
|
56
|
+
|
|
57
|
+
`recordAnalyticsEvent` checks one event and writes it through an `AnalyticsEventStore`.
|
|
58
|
+
|
|
59
|
+
Pass `{ body, country, city }`, or `{ request, country, city }` when `request` is a Fetch `Request` and `body` is omitted. Country and city are those properties. `readRequestGeo` reads the same two properties.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { recordAnalyticsEvent } from "@nodewrite-labs/analytics-sdk/server";
|
|
63
|
+
|
|
64
|
+
const result = await recordAnalyticsEvent(
|
|
65
|
+
{ body, country, city },
|
|
66
|
+
{
|
|
67
|
+
store,
|
|
68
|
+
options: {
|
|
69
|
+
enabled: true,
|
|
70
|
+
modules: { country: true, city: true, sessions: true, clicks: true },
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`enabled: false` writes nothing and returns status 204. A stored view or click returns status 200 and `{ id }`. A recorded duration returns 204. An event that cannot be stored returns 400.
|
|
77
|
+
|
|
78
|
+
## Store
|
|
79
|
+
|
|
80
|
+
`AnalyticsEventStore` is three methods: `insertAnalyticsEvent`, `recordAnalyticsDuration`, and `summarizeAnalytics`.
|
|
81
|
+
|
|
82
|
+
`MemoryAnalyticsEventStore` keeps the rows in memory. `PostgresAnalyticsEventStore` reads and writes `analytics_event`. The SQL shipped with this package is `src/postgres/schema.sql`:
|
|
83
|
+
|
|
84
|
+
```sql
|
|
85
|
+
CREATE TABLE IF NOT EXISTS analytics_event (
|
|
86
|
+
id uuid primary key,
|
|
87
|
+
kind text not null,
|
|
88
|
+
path text not null,
|
|
89
|
+
locale text,
|
|
90
|
+
occurred_at timestamptz not null,
|
|
91
|
+
duration_seconds integer,
|
|
92
|
+
country text,
|
|
93
|
+
city text,
|
|
94
|
+
session_id text,
|
|
95
|
+
event_name text
|
|
96
|
+
);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`applyAnalyticsSchema(pool)` runs that file. `summarizeAnalyticsEvents` groups the stored rows for one time range: total views, average duration, and counts by path. Country, city, sessions, and named clicks are included only when that module is on.
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
acceptDurationSeconds,
|
|
3
|
+
summarizeAnalyticsEvents
|
|
4
|
+
} from "./chunk-Q4JRXZ6M.js";
|
|
5
|
+
import {
|
|
6
|
+
AnalyticsEventKind
|
|
7
|
+
} from "./chunk-F7BY2JQ3.js";
|
|
8
|
+
|
|
9
|
+
// src/payload/access.ts
|
|
10
|
+
var deny = () => false;
|
|
11
|
+
function analyticsEventsAccess(read) {
|
|
12
|
+
return {
|
|
13
|
+
read: (args) => read ? read(args) : Boolean(args.req.user),
|
|
14
|
+
create: deny,
|
|
15
|
+
update: deny,
|
|
16
|
+
delete: deny
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/payload/admin/sections.ts
|
|
21
|
+
function adminSections(summary, modules, query) {
|
|
22
|
+
const blocks = [
|
|
23
|
+
{
|
|
24
|
+
kind: "totals",
|
|
25
|
+
totalViews: summary.totalViews,
|
|
26
|
+
averageDurationSeconds: summary.averageDurationSeconds
|
|
27
|
+
},
|
|
28
|
+
{ kind: "paths", rows: summary.byPath }
|
|
29
|
+
];
|
|
30
|
+
if (modules.country) blocks.push({ kind: "countries", rows: summary.byCountry ?? [] });
|
|
31
|
+
if (modules.city) blocks.push({ kind: "cities", rows: summary.byCity ?? [] });
|
|
32
|
+
if (modules.clicks) blocks.push({ kind: "namedClicks", rows: summary.byEventName ?? [] });
|
|
33
|
+
if (modules.sessions) {
|
|
34
|
+
if (sessionText(query.session)) {
|
|
35
|
+
blocks.push({ kind: "trail", events: orderedEvents(summary.events ?? []) });
|
|
36
|
+
} else {
|
|
37
|
+
blocks.push({ kind: "sessions", rows: summary.sessions ?? [] });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return blocks;
|
|
41
|
+
}
|
|
42
|
+
function sessionText(value) {
|
|
43
|
+
if (typeof value !== "string") return null;
|
|
44
|
+
const trimmed = value.trim();
|
|
45
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
46
|
+
}
|
|
47
|
+
function orderedEvents(events) {
|
|
48
|
+
return events.slice().sort((left, right) => {
|
|
49
|
+
if (left.occurredAt < right.occurredAt) return -1;
|
|
50
|
+
if (left.occurredAt > right.occurredAt) return 1;
|
|
51
|
+
if (left.id < right.id) return -1;
|
|
52
|
+
if (left.id > right.id) return 1;
|
|
53
|
+
return 0;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/payload/collection.ts
|
|
58
|
+
var ANALYTICS_EVENTS_SLUG = "analytics-events";
|
|
59
|
+
function analyticsEventsCollection(read) {
|
|
60
|
+
return {
|
|
61
|
+
slug: ANALYTICS_EVENTS_SLUG,
|
|
62
|
+
labels: {
|
|
63
|
+
singular: "Analytics event",
|
|
64
|
+
plural: "Analytics events"
|
|
65
|
+
},
|
|
66
|
+
admin: {
|
|
67
|
+
hidden: true
|
|
68
|
+
},
|
|
69
|
+
timestamps: false,
|
|
70
|
+
versions: false,
|
|
71
|
+
access: analyticsEventsAccess(read),
|
|
72
|
+
fields: [
|
|
73
|
+
{
|
|
74
|
+
name: "kind",
|
|
75
|
+
type: "select",
|
|
76
|
+
required: true,
|
|
77
|
+
options: [
|
|
78
|
+
{ label: "View", value: "view" },
|
|
79
|
+
{ label: "Click", value: "click" }
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: "path",
|
|
84
|
+
type: "text",
|
|
85
|
+
required: true,
|
|
86
|
+
maxLength: 2048
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "locale",
|
|
90
|
+
type: "text",
|
|
91
|
+
maxLength: 35
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: "occurredAt",
|
|
95
|
+
type: "date",
|
|
96
|
+
required: true
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
name: "durationSeconds",
|
|
100
|
+
type: "number",
|
|
101
|
+
min: 0,
|
|
102
|
+
max: 1800
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "country",
|
|
106
|
+
type: "text",
|
|
107
|
+
maxLength: 2
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: "city",
|
|
111
|
+
type: "text",
|
|
112
|
+
maxLength: 80
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "sessionId",
|
|
116
|
+
type: "text",
|
|
117
|
+
maxLength: 36
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "eventName",
|
|
121
|
+
type: "text",
|
|
122
|
+
maxLength: 64
|
|
123
|
+
}
|
|
124
|
+
]
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/payload/store.ts
|
|
129
|
+
var PayloadAnalyticsEventStore = class {
|
|
130
|
+
constructor(payload, modules) {
|
|
131
|
+
this.payload = payload;
|
|
132
|
+
this.modules = modules;
|
|
133
|
+
}
|
|
134
|
+
payload;
|
|
135
|
+
modules;
|
|
136
|
+
async insertAnalyticsEvent(event, occurredAt) {
|
|
137
|
+
const data = {
|
|
138
|
+
kind: event.kind,
|
|
139
|
+
path: event.path,
|
|
140
|
+
locale: event.locale,
|
|
141
|
+
occurredAt,
|
|
142
|
+
durationSeconds: event.durationSeconds,
|
|
143
|
+
country: this.modules.country ? event.country : null,
|
|
144
|
+
city: this.modules.city ? event.city : null,
|
|
145
|
+
sessionId: this.modules.sessions ? event.sessionId : null,
|
|
146
|
+
eventName: this.modules.clicks ? event.eventName : null
|
|
147
|
+
};
|
|
148
|
+
const doc = await this.payload.create({
|
|
149
|
+
collection: ANALYTICS_EVENTS_SLUG,
|
|
150
|
+
data,
|
|
151
|
+
overrideAccess: true
|
|
152
|
+
});
|
|
153
|
+
const stored = mapDoc(doc, occurredAt);
|
|
154
|
+
if (!stored) throw new Error("analytics-events create did not return a row");
|
|
155
|
+
return stored;
|
|
156
|
+
}
|
|
157
|
+
async recordAnalyticsDuration(id, durationSeconds) {
|
|
158
|
+
const accepted = acceptDurationSeconds(durationSeconds);
|
|
159
|
+
if (accepted === null) return;
|
|
160
|
+
await this.payload.update({
|
|
161
|
+
collection: ANALYTICS_EVENTS_SLUG,
|
|
162
|
+
where: {
|
|
163
|
+
and: [{ id: { equals: id } }, { kind: { equals: AnalyticsEventKind.VIEW } }]
|
|
164
|
+
},
|
|
165
|
+
data: { durationSeconds: accepted },
|
|
166
|
+
overrideAccess: true
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async summarizeAnalytics(query, modules) {
|
|
170
|
+
const result = await this.payload.find({
|
|
171
|
+
collection: ANALYTICS_EVENTS_SLUG,
|
|
172
|
+
where: summaryWhere(query),
|
|
173
|
+
pagination: false,
|
|
174
|
+
depth: 0,
|
|
175
|
+
overrideAccess: true
|
|
176
|
+
});
|
|
177
|
+
const rows = [];
|
|
178
|
+
for (const doc of result.docs) {
|
|
179
|
+
const mapped = mapDoc(doc);
|
|
180
|
+
if (mapped) rows.push(mapped);
|
|
181
|
+
}
|
|
182
|
+
return summarizeAnalyticsEvents(rows, query, modules);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
function summaryWhere(query) {
|
|
186
|
+
const and = [
|
|
187
|
+
{ occurredAt: { greater_than_equal: query.from } },
|
|
188
|
+
{ occurredAt: { less_than_equal: query.to } }
|
|
189
|
+
];
|
|
190
|
+
if (query.country !== null) and.push({ country: { equals: query.country } });
|
|
191
|
+
if (query.path !== null) and.push({ path: { equals: query.path } });
|
|
192
|
+
if (query.sessionId !== null) and.push({ sessionId: { equals: query.sessionId } });
|
|
193
|
+
return { and };
|
|
194
|
+
}
|
|
195
|
+
function mapDoc(doc, occurredAtFallback) {
|
|
196
|
+
if (!isRecord(doc)) return null;
|
|
197
|
+
const kind = doc.kind;
|
|
198
|
+
const path = doc.path;
|
|
199
|
+
if (!isId(doc.id) || kind !== AnalyticsEventKind.VIEW && kind !== AnalyticsEventKind.CLICK) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
if (typeof path !== "string") return null;
|
|
203
|
+
const occurredAt = timeText(doc.occurredAt) ?? occurredAtFallback ?? null;
|
|
204
|
+
if (occurredAt === null) return null;
|
|
205
|
+
return {
|
|
206
|
+
id: String(doc.id),
|
|
207
|
+
kind,
|
|
208
|
+
path,
|
|
209
|
+
locale: textOrNull(doc.locale),
|
|
210
|
+
occurredAt,
|
|
211
|
+
durationSeconds: numberOrNull(doc.durationSeconds),
|
|
212
|
+
country: textOrNull(doc.country),
|
|
213
|
+
city: textOrNull(doc.city),
|
|
214
|
+
sessionId: textOrNull(doc.sessionId),
|
|
215
|
+
eventName: textOrNull(doc.eventName)
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
function isId(value) {
|
|
219
|
+
return typeof value === "string" || typeof value === "number";
|
|
220
|
+
}
|
|
221
|
+
function isRecord(value) {
|
|
222
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
223
|
+
}
|
|
224
|
+
function textOrNull(value) {
|
|
225
|
+
return typeof value === "string" ? value : null;
|
|
226
|
+
}
|
|
227
|
+
function numberOrNull(value) {
|
|
228
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
229
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
230
|
+
const numeric = Number(value);
|
|
231
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
function timeText(value) {
|
|
236
|
+
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString();
|
|
237
|
+
if (typeof value !== "string" || value.length === 0) return null;
|
|
238
|
+
const parsed = new Date(value);
|
|
239
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
240
|
+
return parsed.toISOString();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/payload/stored-modules.ts
|
|
244
|
+
var ANALYTICS_CUSTOM_KEY = "analytics";
|
|
245
|
+
function analyticsModulesFromConfig(config) {
|
|
246
|
+
const stored = config.custom?.[ANALYTICS_CUSTOM_KEY];
|
|
247
|
+
if (!isAnalyticsCustom(stored)) {
|
|
248
|
+
return { country: false, city: false, sessions: false, clicks: false };
|
|
249
|
+
}
|
|
250
|
+
return stored.modules;
|
|
251
|
+
}
|
|
252
|
+
function isAnalyticsCustom(value) {
|
|
253
|
+
if (typeof value !== "object" || value === null || !("modules" in value)) return false;
|
|
254
|
+
const modules = value.modules;
|
|
255
|
+
if (typeof modules !== "object" || modules === null) return false;
|
|
256
|
+
return hasBoolean(modules, "country") && hasBoolean(modules, "city") && hasBoolean(modules, "sessions") && hasBoolean(modules, "clicks");
|
|
257
|
+
}
|
|
258
|
+
function hasBoolean(value, key) {
|
|
259
|
+
const record = value;
|
|
260
|
+
return typeof record[key] === "boolean";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export {
|
|
264
|
+
analyticsEventsAccess,
|
|
265
|
+
adminSections,
|
|
266
|
+
analyticsEventsCollection,
|
|
267
|
+
PayloadAnalyticsEventStore,
|
|
268
|
+
ANALYTICS_CUSTOM_KEY,
|
|
269
|
+
analyticsModulesFromConfig
|
|
270
|
+
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AnalyticsEventKind
|
|
3
|
+
} from "./chunk-F7BY2JQ3.js";
|
|
4
|
+
|
|
5
|
+
// src/server/read-request-geo.ts
|
|
6
|
+
function readRequestGeo(request) {
|
|
7
|
+
const fields = request;
|
|
8
|
+
return {
|
|
9
|
+
country: textField(fields.country),
|
|
10
|
+
city: textField(fields.city)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function textField(value) {
|
|
14
|
+
if (typeof value !== "string") return null;
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return trimmed.length === 0 ? null : trimmed;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// src/server/record-analytics-event.ts
|
|
20
|
+
var PATH_MAX = 2048;
|
|
21
|
+
var LOCALE_MAX = 35;
|
|
22
|
+
var SESSION_ID_MAX = 36;
|
|
23
|
+
var EVENT_NAME_MAX = 64;
|
|
24
|
+
var COUNTRY_MAX = 8;
|
|
25
|
+
var CITY_MAX = 80;
|
|
26
|
+
var DURATION_ID_MAX = 64;
|
|
27
|
+
var EVENT_NAME = /^[a-z0-9_]+$/;
|
|
28
|
+
async function recordAnalyticsEvent(request, deps) {
|
|
29
|
+
if (!deps.options.enabled) return { status: 204, body: null };
|
|
30
|
+
const incoming = await incomingRequest(request);
|
|
31
|
+
const body = incoming.body;
|
|
32
|
+
if (!isRecord(body) || typeof body.type !== "string") return invalid();
|
|
33
|
+
if (body.type === "view") return recordView(body, incoming, deps);
|
|
34
|
+
if (body.type === "duration") return recordDuration(body, deps);
|
|
35
|
+
if (body.type === "click") return recordClick(body, incoming, deps);
|
|
36
|
+
return invalid();
|
|
37
|
+
}
|
|
38
|
+
async function recordView(body, request, deps) {
|
|
39
|
+
const parsed = parseLocation(body);
|
|
40
|
+
if (!parsed) return invalid();
|
|
41
|
+
const sessionId = sessionFor(body.sessionId, deps);
|
|
42
|
+
if (sessionId === void 0) return invalid();
|
|
43
|
+
const accepted = {
|
|
44
|
+
type: "view",
|
|
45
|
+
path: parsed.path,
|
|
46
|
+
locale: parsed.locale,
|
|
47
|
+
sessionId
|
|
48
|
+
};
|
|
49
|
+
const stored = await deps.store.insertAnalyticsEvent(newEvent(accepted, request, deps), clock(deps));
|
|
50
|
+
return { status: 200, body: { id: stored.id } };
|
|
51
|
+
}
|
|
52
|
+
async function recordDuration(body, deps) {
|
|
53
|
+
if (typeof body.id !== "string" || body.id.length === 0 || body.id.length > DURATION_ID_MAX) {
|
|
54
|
+
return invalid();
|
|
55
|
+
}
|
|
56
|
+
if (typeof body.durationSeconds !== "number") return invalid();
|
|
57
|
+
const accepted = {
|
|
58
|
+
type: "duration",
|
|
59
|
+
id: body.id,
|
|
60
|
+
durationSeconds: body.durationSeconds
|
|
61
|
+
};
|
|
62
|
+
await deps.store.recordAnalyticsDuration(accepted.id, accepted.durationSeconds);
|
|
63
|
+
return { status: 204, body: null };
|
|
64
|
+
}
|
|
65
|
+
async function recordClick(body, request, deps) {
|
|
66
|
+
if (!deps.options.modules.clicks) return { status: 204, body: null };
|
|
67
|
+
const parsed = parseLocation(body);
|
|
68
|
+
const eventName = body.eventName;
|
|
69
|
+
if (!parsed || !validEventName(eventName)) return invalid();
|
|
70
|
+
const sessionId = sessionFor(body.sessionId, deps);
|
|
71
|
+
if (sessionId === void 0) return invalid();
|
|
72
|
+
const accepted = {
|
|
73
|
+
type: "click",
|
|
74
|
+
path: parsed.path,
|
|
75
|
+
locale: parsed.locale,
|
|
76
|
+
sessionId,
|
|
77
|
+
eventName
|
|
78
|
+
};
|
|
79
|
+
const stored = await deps.store.insertAnalyticsEvent(newEvent(accepted, request, deps), clock(deps));
|
|
80
|
+
return { status: 200, body: { id: stored.id } };
|
|
81
|
+
}
|
|
82
|
+
function newEvent(body, request, deps) {
|
|
83
|
+
const modules = deps.options.modules;
|
|
84
|
+
const geo = readGeo(request);
|
|
85
|
+
return {
|
|
86
|
+
kind: body.type === "click" ? AnalyticsEventKind.CLICK : AnalyticsEventKind.VIEW,
|
|
87
|
+
path: body.path,
|
|
88
|
+
locale: body.locale,
|
|
89
|
+
durationSeconds: null,
|
|
90
|
+
country: modules.country ? geo.country : null,
|
|
91
|
+
city: modules.city ? geo.city : null,
|
|
92
|
+
sessionId: modules.sessions ? body.sessionId : null,
|
|
93
|
+
eventName: body.type === "click" ? body.eventName : null
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
async function incomingRequest(request) {
|
|
97
|
+
if (isWebRequest(request)) {
|
|
98
|
+
return { body: await readJsonBody(request), headers: request.headers };
|
|
99
|
+
}
|
|
100
|
+
const source = request.request;
|
|
101
|
+
const body = request.body !== void 0 ? request.body : source ? await readJsonBody(source) : void 0;
|
|
102
|
+
return {
|
|
103
|
+
body,
|
|
104
|
+
headers: request.headers ?? source?.headers,
|
|
105
|
+
country: request.country,
|
|
106
|
+
city: request.city
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function isWebRequest(request) {
|
|
110
|
+
return typeof Request !== "undefined" && request instanceof Request;
|
|
111
|
+
}
|
|
112
|
+
async function readJsonBody(request) {
|
|
113
|
+
try {
|
|
114
|
+
return await request.json();
|
|
115
|
+
} catch {
|
|
116
|
+
return void 0;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function readGeo(request) {
|
|
120
|
+
const read = readRequestGeo(request);
|
|
121
|
+
return {
|
|
122
|
+
country: boundedText(read.country, COUNTRY_MAX),
|
|
123
|
+
city: boundedText(read.city, CITY_MAX)
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function parseLocation(body) {
|
|
127
|
+
if (typeof body.path !== "string") return null;
|
|
128
|
+
const path = normalizePath(body.path);
|
|
129
|
+
if (path === null) return null;
|
|
130
|
+
const locale = readLocale(body.locale);
|
|
131
|
+
if (locale === void 0) return null;
|
|
132
|
+
return { path, locale };
|
|
133
|
+
}
|
|
134
|
+
function sessionFor(value, deps) {
|
|
135
|
+
if (!deps.options.modules.sessions) return null;
|
|
136
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
137
|
+
if (typeof value !== "string" || value.length > SESSION_ID_MAX) return void 0;
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function readLocale(value) {
|
|
141
|
+
if (value === void 0 || value === null || value === "") return null;
|
|
142
|
+
if (typeof value !== "string" || value.length > LOCALE_MAX) return void 0;
|
|
143
|
+
return value;
|
|
144
|
+
}
|
|
145
|
+
function normalizePath(path) {
|
|
146
|
+
if (!path.startsWith("/")) return null;
|
|
147
|
+
if (path.includes("://")) return null;
|
|
148
|
+
const queryIndex = path.indexOf("?");
|
|
149
|
+
const withoutQuery = queryIndex === -1 ? path : path.slice(0, queryIndex);
|
|
150
|
+
const hashIndex = withoutQuery.indexOf("#");
|
|
151
|
+
const bare = hashIndex === -1 ? withoutQuery : withoutQuery.slice(0, hashIndex);
|
|
152
|
+
if (bare.length === 0 || bare.length > PATH_MAX) return null;
|
|
153
|
+
if (!bare.startsWith("/")) return null;
|
|
154
|
+
if (/[\u0000-\u001F\u007F]/.test(bare)) return null;
|
|
155
|
+
return bare;
|
|
156
|
+
}
|
|
157
|
+
function validEventName(value) {
|
|
158
|
+
return typeof value === "string" && value.length <= EVENT_NAME_MAX && EVENT_NAME.test(value);
|
|
159
|
+
}
|
|
160
|
+
function boundedText(value, max) {
|
|
161
|
+
if (typeof value !== "string") return null;
|
|
162
|
+
const trimmed = value.trim();
|
|
163
|
+
if (trimmed.length === 0 || trimmed.length > max) return null;
|
|
164
|
+
return trimmed;
|
|
165
|
+
}
|
|
166
|
+
function clock(deps) {
|
|
167
|
+
return deps.now ? deps.now() : (/* @__PURE__ */ new Date()).toISOString();
|
|
168
|
+
}
|
|
169
|
+
function invalid() {
|
|
170
|
+
return { status: 400, body: null };
|
|
171
|
+
}
|
|
172
|
+
function isRecord(value) {
|
|
173
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export {
|
|
177
|
+
readRequestGeo,
|
|
178
|
+
recordAnalyticsEvent
|
|
179
|
+
};
|