@iann29/rastro 0.1.0-alpha.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 +201 -0
- package/README.md +352 -0
- package/assets/rastro-live-atlas.png +0 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +960 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +591 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +60 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +569 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +133 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +80 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/affiliates.d.ts +28 -0
- package/dist/component/affiliates.d.ts.map +1 -0
- package/dist/component/affiliates.js +110 -0
- package/dist/component/affiliates.js.map +1 -0
- package/dist/component/constants.d.ts +28 -0
- package/dist/component/constants.d.ts.map +1 -0
- package/dist/component/constants.js +28 -0
- package/dist/component/constants.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/errors.d.ts +3 -0
- package/dist/component/errors.d.ts.map +1 -0
- package/dist/component/errors.js +5 -0
- package/dist/component/errors.js.map +1 -0
- package/dist/component/funnels.d.ts +36 -0
- package/dist/component/funnels.d.ts.map +1 -0
- package/dist/component/funnels.js +117 -0
- package/dist/component/funnels.js.map +1 -0
- package/dist/component/goals.d.ts +30 -0
- package/dist/component/goals.d.ts.map +1 -0
- package/dist/component/goals.js +110 -0
- package/dist/component/goals.js.map +1 -0
- package/dist/component/guards.d.ts +2 -0
- package/dist/component/guards.d.ts.map +1 -0
- package/dist/component/guards.js +4 -0
- package/dist/component/guards.js.map +1 -0
- package/dist/component/http.d.ts +3 -0
- package/dist/component/http.d.ts.map +1 -0
- package/dist/component/http.js +270 -0
- package/dist/component/http.js.map +1 -0
- package/dist/component/ingest.d.ts +51 -0
- package/dist/component/ingest.d.ts.map +1 -0
- package/dist/component/ingest.js +991 -0
- package/dist/component/ingest.js.map +1 -0
- package/dist/component/live.d.ts +9 -0
- package/dist/component/live.d.ts.map +1 -0
- package/dist/component/live.js +36 -0
- package/dist/component/live.js.map +1 -0
- package/dist/component/reports.d.ts +330 -0
- package/dist/component/reports.d.ts.map +1 -0
- package/dist/component/reports.js +595 -0
- package/dist/component/reports.js.map +1 -0
- package/dist/component/retention.d.ts +10 -0
- package/dist/component/retention.d.ts.map +1 -0
- package/dist/component/retention.js +107 -0
- package/dist/component/retention.js.map +1 -0
- package/dist/component/sanitize.d.ts +20 -0
- package/dist/component/sanitize.d.ts.map +1 -0
- package/dist/component/sanitize.js +269 -0
- package/dist/component/sanitize.js.map +1 -0
- package/dist/component/schema.d.ts +517 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +209 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/sites.d.ts +54 -0
- package/dist/component/sites.d.ts.map +1 -0
- package/dist/component/sites.js +164 -0
- package/dist/component/sites.js.map +1 -0
- package/dist/component/validators.d.ts +377 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +176 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/react/index.d.ts +40 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +31 -0
- package/dist/react/index.js.map +1 -0
- package/dist/tracker/generated.d.ts +5 -0
- package/dist/tracker/generated.d.ts.map +1 -0
- package/dist/tracker/generated.js +6 -0
- package/dist/tracker/generated.js.map +1 -0
- package/dist/tracker/tracker.d.ts +27 -0
- package/dist/tracker/tracker.d.ts.map +1 -0
- package/dist/tracker/tracker.js +108 -0
- package/dist/tracker/tracker.js.map +1 -0
- package/dist/tracker.min.js +1 -0
- package/package.json +121 -0
- package/src/component/_generated/api.ts +76 -0
- package/src/component/_generated/component.ts +706 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +169 -0
- package/src/component/affiliates.ts +119 -0
- package/src/component/constants.ts +28 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/errors.ts +20 -0
- package/src/component/funnels.ts +134 -0
- package/src/component/goals.ts +122 -0
- package/src/component/guards.ts +3 -0
- package/src/component/http.ts +301 -0
- package/src/component/ingest.ts +1364 -0
- package/src/component/live.ts +36 -0
- package/src/component/reports.ts +720 -0
- package/src/component/retention.ts +180 -0
- package/src/component/sanitize.ts +278 -0
- package/src/component/schema.ts +232 -0
- package/src/component/sites.ts +188 -0
- package/src/component/validators.ts +226 -0
- package/src/test.ts +19 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { httpRouter } from "convex/server";
|
|
2
|
+
import { ConvexError } from "convex/values";
|
|
3
|
+
import type { Id } from "./_generated/dataModel.js";
|
|
4
|
+
import { api } from "./_generated/api.js";
|
|
5
|
+
import { httpAction } from "./_generated/server.js";
|
|
6
|
+
import { MAX_BATCH_BYTES } from "./constants.js";
|
|
7
|
+
import { isPlainRecord } from "./guards.js";
|
|
8
|
+
import type { TrackerEvent } from "./validators.js";
|
|
9
|
+
import {
|
|
10
|
+
TRACKER_GZIP_BASE64,
|
|
11
|
+
TRACKER_GZIP_BYTES,
|
|
12
|
+
} from "../tracker/generated.js";
|
|
13
|
+
|
|
14
|
+
const TRACKER_GZIP = Uint8Array.from(
|
|
15
|
+
atob(TRACKER_GZIP_BASE64),
|
|
16
|
+
(character) => character.charCodeAt(0),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const http = httpRouter();
|
|
20
|
+
|
|
21
|
+
http.route({
|
|
22
|
+
path: "/events",
|
|
23
|
+
method: "POST",
|
|
24
|
+
handler: httpAction(async (ctx, request) => {
|
|
25
|
+
const origin = request.headers.get("origin");
|
|
26
|
+
if (!origin) return json({ error: { code: "ORIGIN_REQUIRED" } }, 400);
|
|
27
|
+
const bytes = await request.arrayBuffer();
|
|
28
|
+
if (bytes.byteLength > MAX_BATCH_BYTES) {
|
|
29
|
+
return json({ error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } }, 413);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let payload: { siteId: string; events: TrackerEvent[] };
|
|
33
|
+
try {
|
|
34
|
+
const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
|
|
35
|
+
payload = parsePayload(parsed);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return json(
|
|
38
|
+
{
|
|
39
|
+
error: {
|
|
40
|
+
code: "INVALID_BODY",
|
|
41
|
+
message: error instanceof Error ? error.message : "invalid JSON body",
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
400,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const userAgent = request.headers.get("user-agent") ?? "";
|
|
49
|
+
const context = {
|
|
50
|
+
country: firstHeader(request, ["x-vercel-ip-country", "cf-ipcountry"]),
|
|
51
|
+
city: decodeHeader(firstHeader(request, ["x-vercel-ip-city", "cf-ipcity"])),
|
|
52
|
+
latitude: numericHeader(request, ["x-vercel-ip-latitude", "cf-iplatitude"]),
|
|
53
|
+
longitude: numericHeader(request, ["x-vercel-ip-longitude", "cf-iplongitude"]),
|
|
54
|
+
...classifyUserAgent(userAgent),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const result = await ctx.runMutation(api.ingest.ingestBatch, {
|
|
59
|
+
siteId: payload.siteId as Id<"sites">,
|
|
60
|
+
origin,
|
|
61
|
+
events: payload.events,
|
|
62
|
+
context,
|
|
63
|
+
});
|
|
64
|
+
return json(result, 200, corsHeaders(origin));
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof ConvexError) {
|
|
67
|
+
const data = error.data as { code?: string; message?: string };
|
|
68
|
+
const status =
|
|
69
|
+
data.code === "ORIGIN_NOT_ALLOWED"
|
|
70
|
+
? 403
|
|
71
|
+
: data.code === "RATE_LIMITED"
|
|
72
|
+
? 429
|
|
73
|
+
: 400;
|
|
74
|
+
return json({ error: data }, status);
|
|
75
|
+
}
|
|
76
|
+
return json({ error: { code: "INVALID_REQUEST" } }, 400);
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
http.route({
|
|
82
|
+
path: "/events",
|
|
83
|
+
method: "OPTIONS",
|
|
84
|
+
handler: httpAction(async (_ctx, request) => {
|
|
85
|
+
const origin = request.headers.get("origin");
|
|
86
|
+
if (!origin) return new Response(null, { status: 400 });
|
|
87
|
+
try {
|
|
88
|
+
const url = new URL(origin);
|
|
89
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
90
|
+
return new Response(null, { status: 403 });
|
|
91
|
+
}
|
|
92
|
+
return new Response(null, { status: 204, headers: corsHeaders(origin) });
|
|
93
|
+
} catch {
|
|
94
|
+
return new Response(null, { status: 400 });
|
|
95
|
+
}
|
|
96
|
+
}),
|
|
97
|
+
});
|
|
98
|
+
http.route({
|
|
99
|
+
path: "/tracker.js",
|
|
100
|
+
method: "GET",
|
|
101
|
+
handler: httpAction(async () => {
|
|
102
|
+
return new Response(TRACKER_GZIP, {
|
|
103
|
+
status: 200,
|
|
104
|
+
headers: {
|
|
105
|
+
"Content-Type": "text/javascript; charset=utf-8",
|
|
106
|
+
"Content-Encoding": "gzip",
|
|
107
|
+
"Content-Length": String(TRACKER_GZIP_BYTES),
|
|
108
|
+
"Cache-Control": "public, max-age=300, stale-while-revalidate=86400",
|
|
109
|
+
"Access-Control-Allow-Origin": "*",
|
|
110
|
+
Vary: "Accept-Encoding",
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
http.route({
|
|
118
|
+
path: "/health",
|
|
119
|
+
method: "GET",
|
|
120
|
+
handler: httpAction(async () => {
|
|
121
|
+
return json(
|
|
122
|
+
{ ok: true, component: "@iann29/rastro" },
|
|
123
|
+
200,
|
|
124
|
+
{
|
|
125
|
+
"Access-Control-Allow-Origin": "*",
|
|
126
|
+
"Cache-Control": "no-store",
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}),
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[] } {
|
|
133
|
+
if (!isPlainRecord(value) || typeof value.siteId !== "string" || !Array.isArray(value.events)) {
|
|
134
|
+
throw new Error("body must contain siteId and events");
|
|
135
|
+
}
|
|
136
|
+
return { siteId: value.siteId, events: value.events.map(parseEvent) };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parseEvent(value: unknown): TrackerEvent {
|
|
140
|
+
if (!isPlainRecord(value)) throw new Error("each event must be an object");
|
|
141
|
+
const type = value.type;
|
|
142
|
+
if (
|
|
143
|
+
type !== "pageview" &&
|
|
144
|
+
type !== "click" &&
|
|
145
|
+
type !== "custom" &&
|
|
146
|
+
type !== "conversion" &&
|
|
147
|
+
type !== "heartbeat" &&
|
|
148
|
+
type !== "outbound"
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("event type is invalid");
|
|
151
|
+
}
|
|
152
|
+
if (
|
|
153
|
+
typeof value.eventId !== "string" ||
|
|
154
|
+
typeof value.sessionId !== "string" ||
|
|
155
|
+
typeof value.visitorId !== "string" ||
|
|
156
|
+
typeof value.path !== "string" ||
|
|
157
|
+
typeof value.timestamp !== "number" ||
|
|
158
|
+
typeof value.sequence !== "number"
|
|
159
|
+
) {
|
|
160
|
+
throw new Error("event is missing required fields");
|
|
161
|
+
}
|
|
162
|
+
const properties = parseProperties(value.properties);
|
|
163
|
+
return {
|
|
164
|
+
eventId: value.eventId,
|
|
165
|
+
sessionId: value.sessionId,
|
|
166
|
+
visitorId: value.visitorId,
|
|
167
|
+
type,
|
|
168
|
+
...(optionalString(value.name, "name") !== undefined
|
|
169
|
+
? { name: optionalString(value.name, "name") }
|
|
170
|
+
: {}),
|
|
171
|
+
path: value.path,
|
|
172
|
+
...(optionalString(value.referrer, "referrer") !== undefined
|
|
173
|
+
? { referrer: optionalString(value.referrer, "referrer") }
|
|
174
|
+
: {}),
|
|
175
|
+
timestamp: value.timestamp,
|
|
176
|
+
sequence: value.sequence,
|
|
177
|
+
...(optionalString(value.target, "target") !== undefined
|
|
178
|
+
? { target: optionalString(value.target, "target") }
|
|
179
|
+
: {}),
|
|
180
|
+
...(optionalString(value.href, "href") !== undefined
|
|
181
|
+
? { href: optionalString(value.href, "href") }
|
|
182
|
+
: {}),
|
|
183
|
+
...(properties ? { properties } : {}),
|
|
184
|
+
...(optionalNumber(value.revenueCents, "revenueCents") !== undefined
|
|
185
|
+
? { revenueCents: optionalNumber(value.revenueCents, "revenueCents") }
|
|
186
|
+
: {}),
|
|
187
|
+
...(optionalString(value.currency, "currency") !== undefined
|
|
188
|
+
? { currency: optionalString(value.currency, "currency") }
|
|
189
|
+
: {}),
|
|
190
|
+
...(optionalString(value.affiliateSlug, "affiliateSlug") !== undefined
|
|
191
|
+
? { affiliateSlug: optionalString(value.affiliateSlug, "affiliateSlug") }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseProperties(value: unknown): TrackerEvent["properties"] {
|
|
197
|
+
if (value === undefined) return undefined;
|
|
198
|
+
if (!isPlainRecord(value)) throw new Error("properties must be an object");
|
|
199
|
+
const result: NonNullable<TrackerEvent["properties"]> = {};
|
|
200
|
+
for (const [key, property] of Object.entries(value)) {
|
|
201
|
+
if (
|
|
202
|
+
typeof property !== "string" &&
|
|
203
|
+
typeof property !== "number" &&
|
|
204
|
+
typeof property !== "boolean" &&
|
|
205
|
+
property !== null
|
|
206
|
+
) {
|
|
207
|
+
throw new Error(`property ${key} has an invalid value`);
|
|
208
|
+
}
|
|
209
|
+
result[key] = property;
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function optionalString(value: unknown, field: string): string | undefined {
|
|
215
|
+
if (value === undefined) return undefined;
|
|
216
|
+
if (typeof value !== "string") throw new Error(`${field} must be a string`);
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function optionalNumber(value: unknown, field: string): number | undefined {
|
|
221
|
+
if (value === undefined) return undefined;
|
|
222
|
+
if (typeof value !== "number") throw new Error(`${field} must be a number`);
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
function firstHeader(request: Request, names: string[]): string | undefined {
|
|
228
|
+
for (const name of names) {
|
|
229
|
+
const value = request.headers.get(name);
|
|
230
|
+
if (value) return value;
|
|
231
|
+
}
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function numericHeader(request: Request, names: string[]): number | undefined {
|
|
236
|
+
const value = firstHeader(request, names);
|
|
237
|
+
if (!value) return undefined;
|
|
238
|
+
const number = Number(value);
|
|
239
|
+
return Number.isFinite(number) ? number : undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function decodeHeader(value: string | undefined): string | undefined {
|
|
243
|
+
if (!value) return undefined;
|
|
244
|
+
try {
|
|
245
|
+
return decodeURIComponent(value);
|
|
246
|
+
} catch {
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function classifyUserAgent(userAgent: string): {
|
|
252
|
+
browser: string;
|
|
253
|
+
os: string;
|
|
254
|
+
device: string;
|
|
255
|
+
} {
|
|
256
|
+
const browser = /Edg\//.test(userAgent)
|
|
257
|
+
? "Edge"
|
|
258
|
+
: /Firefox\//.test(userAgent)
|
|
259
|
+
? "Firefox"
|
|
260
|
+
: /Chrome\//.test(userAgent)
|
|
261
|
+
? "Chrome"
|
|
262
|
+
: /Safari\//.test(userAgent)
|
|
263
|
+
? "Safari"
|
|
264
|
+
: "Other";
|
|
265
|
+
const os = /Android/.test(userAgent)
|
|
266
|
+
? "Android"
|
|
267
|
+
: /iPhone|iPad/.test(userAgent)
|
|
268
|
+
? "iOS"
|
|
269
|
+
: /Windows/.test(userAgent)
|
|
270
|
+
? "Windows"
|
|
271
|
+
: /Mac OS X/.test(userAgent)
|
|
272
|
+
? "macOS"
|
|
273
|
+
: /Linux/.test(userAgent)
|
|
274
|
+
? "Linux"
|
|
275
|
+
: "Other";
|
|
276
|
+
const device = /iPad|Tablet/.test(userAgent)
|
|
277
|
+
? "tablet"
|
|
278
|
+
: /Mobile|Android|iPhone/.test(userAgent)
|
|
279
|
+
? "mobile"
|
|
280
|
+
: "desktop";
|
|
281
|
+
return { browser, os, device };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function corsHeaders(origin: string): HeadersInit {
|
|
285
|
+
return {
|
|
286
|
+
"Access-Control-Allow-Origin": origin,
|
|
287
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
|
288
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
289
|
+
"Access-Control-Max-Age": "86400",
|
|
290
|
+
Vary: "Origin",
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function json(value: unknown, status: number, headers?: HeadersInit): Response {
|
|
295
|
+
return new Response(JSON.stringify(value), {
|
|
296
|
+
status,
|
|
297
|
+
headers: { "Content-Type": "application/json; charset=utf-8", ...headers },
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export default http;
|