@iann29/rastro 0.1.0-alpha.0 → 0.1.0-alpha.1

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.
Files changed (68) hide show
  1. package/README.md +85 -18
  2. package/dist/component/_generated/api.d.ts +2 -0
  3. package/dist/component/_generated/api.d.ts.map +1 -1
  4. package/dist/component/_generated/api.js.map +1 -1
  5. package/dist/component/constants.d.ts +9 -6
  6. package/dist/component/constants.d.ts.map +1 -1
  7. package/dist/component/constants.js +9 -6
  8. package/dist/component/constants.js.map +1 -1
  9. package/dist/component/eventStore.d.ts +94 -0
  10. package/dist/component/eventStore.d.ts.map +1 -0
  11. package/dist/component/eventStore.js +304 -0
  12. package/dist/component/eventStore.js.map +1 -0
  13. package/dist/component/goals.d.ts.map +1 -1
  14. package/dist/component/goals.js +17 -4
  15. package/dist/component/goals.js.map +1 -1
  16. package/dist/component/http.d.ts.map +1 -1
  17. package/dist/component/http.js +39 -7
  18. package/dist/component/http.js.map +1 -1
  19. package/dist/component/ingest.d.ts.map +1 -1
  20. package/dist/component/ingest.js +520 -207
  21. package/dist/component/ingest.js.map +1 -1
  22. package/dist/component/live.d.ts +8 -0
  23. package/dist/component/live.d.ts.map +1 -1
  24. package/dist/component/live.js +72 -8
  25. package/dist/component/live.js.map +1 -1
  26. package/dist/component/reports.d.ts +16 -16
  27. package/dist/component/reports.d.ts.map +1 -1
  28. package/dist/component/reports.js +250 -66
  29. package/dist/component/reports.js.map +1 -1
  30. package/dist/component/retention.d.ts.map +1 -1
  31. package/dist/component/retention.js +15 -10
  32. package/dist/component/retention.js.map +1 -1
  33. package/dist/component/sanitize.d.ts.map +1 -1
  34. package/dist/component/sanitize.js +11 -3
  35. package/dist/component/sanitize.js.map +1 -1
  36. package/dist/component/schema.d.ts +101 -10
  37. package/dist/component/schema.js +30 -8
  38. package/dist/component/schema.js.map +1 -1
  39. package/dist/component/sites.d.ts.map +1 -1
  40. package/dist/component/sites.js +27 -18
  41. package/dist/component/sites.js.map +1 -1
  42. package/dist/component/validators.d.ts +49 -0
  43. package/dist/component/validators.d.ts.map +1 -1
  44. package/dist/component/validators.js +11 -0
  45. package/dist/component/validators.js.map +1 -1
  46. package/dist/tracker/generated.d.ts +4 -4
  47. package/dist/tracker/generated.d.ts.map +1 -1
  48. package/dist/tracker/generated.js +4 -4
  49. package/dist/tracker/generated.js.map +1 -1
  50. package/dist/tracker/tracker.js +33 -22
  51. package/dist/tracker/tracker.js.map +1 -1
  52. package/dist/tracker.min.js +1 -1
  53. package/docs/benchmarks/2026-08-20-realistic.md +171 -0
  54. package/package.json +10 -4
  55. package/scripts/benchmark-ingest.mjs +499 -0
  56. package/src/component/_generated/api.ts +2 -0
  57. package/src/component/constants.ts +9 -6
  58. package/src/component/eventStore.ts +465 -0
  59. package/src/component/goals.ts +25 -4
  60. package/src/component/http.ts +49 -6
  61. package/src/component/ingest.ts +671 -215
  62. package/src/component/live.ts +83 -8
  63. package/src/component/reports.ts +320 -82
  64. package/src/component/retention.ts +15 -12
  65. package/src/component/sanitize.ts +10 -3
  66. package/src/component/schema.ts +33 -8
  67. package/src/component/sites.ts +31 -18
  68. package/src/component/validators.ts +12 -0
@@ -0,0 +1,465 @@
1
+ import type { Doc, Id } from "./_generated/dataModel.js";
2
+ import type { MutationCtx, QueryCtx } from "./_generated/server.js";
3
+ import {
4
+ MAX_EVENTS_PER_SESSION_WINDOW,
5
+ RATE_LIMIT_WINDOW_MS,
6
+ } from "./constants.js";
7
+ import type { TrackerEvent } from "./validators.js";
8
+
9
+ export type TelemetryMatch = {
10
+ sessionId: string;
11
+ visitorId: string;
12
+ country?: string;
13
+ city?: string;
14
+ latitude?: number;
15
+ longitude?: number;
16
+ browser?: string;
17
+ os?: string;
18
+ device?: string;
19
+ };
20
+
21
+ type BufferedEventBatch = {
22
+ siteId: Id<"sites">;
23
+ sessionId: string;
24
+ visitorId: string;
25
+ bucketStart: number;
26
+ events: TrackerEvent[];
27
+ };
28
+
29
+ export type EventBatchBuffer = Map<string, BufferedEventBatch>;
30
+
31
+ export type JourneyCursor = {
32
+ timestamp: number;
33
+ creationTime: number;
34
+ id: string;
35
+ };
36
+
37
+ export type RawJourneyEvent = Omit<Doc<"events">, "_id"> & { _id: string };
38
+
39
+ export function eventBucketStart(timestamp: number): number {
40
+ return Math.floor(timestamp / RATE_LIMIT_WINDOW_MS) * RATE_LIMIT_WINDOW_MS;
41
+ }
42
+
43
+ export function eventBatchKey(sessionId: string, bucketStart: number): string {
44
+ return JSON.stringify([sessionId, bucketStart]);
45
+ }
46
+
47
+ export async function inspectTelemetryMinute(
48
+ ctx: MutationCtx,
49
+ input: {
50
+ siteId: Id<"sites">;
51
+ sessionId: string;
52
+ bucketStart: number;
53
+ candidateEventIds: Set<string>;
54
+ },
55
+ ) {
56
+ const [legacyEvents, batches] = await loadTelemetryMinute(ctx, input);
57
+ const eventIds = new Set([
58
+ ...legacyEvents.map((event) => event.eventId),
59
+ ...batches.flatMap((batch) =>
60
+ batch.events.map((event) => event.eventId),
61
+ ),
62
+ ]);
63
+ if (legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW) {
64
+ await Promise.all(
65
+ [...input.candidateEventIds]
66
+ .filter((eventId) => !eventIds.has(eventId))
67
+ .map(async (eventId) => {
68
+ const legacy = await findLegacyTelemetry(ctx, {
69
+ ...input,
70
+ eventId,
71
+ });
72
+ if (legacy) eventIds.add(eventId);
73
+ }),
74
+ );
75
+ }
76
+ return {
77
+ eventIds,
78
+ persistedCount: telemetryMinuteCount(legacyEvents, batches),
79
+ };
80
+ }
81
+
82
+ export async function findTelemetry(
83
+ ctx: MutationCtx,
84
+ siteId: Id<"sites">,
85
+ event: TrackerEvent,
86
+ ): Promise<{ match: TelemetryMatch | null; saturated: boolean }> {
87
+ const bucketStart = eventBucketStart(event.timestamp);
88
+ const [legacyEvents, batches] = await loadTelemetryMinute(ctx, {
89
+ siteId,
90
+ sessionId: event.sessionId,
91
+ bucketStart,
92
+ });
93
+ const persistedCount = telemetryMinuteCount(legacyEvents, batches);
94
+ let legacyEvent = legacyEvents.find((candidate) =>
95
+ candidate.eventId === event.eventId
96
+ );
97
+ if (
98
+ !legacyEvent &&
99
+ legacyEvents.length > MAX_EVENTS_PER_SESSION_WINDOW
100
+ ) {
101
+ legacyEvent = await findLegacyTelemetry(ctx, {
102
+ siteId,
103
+ sessionId: event.sessionId,
104
+ bucketStart,
105
+ eventId: event.eventId,
106
+ }) ?? undefined;
107
+ }
108
+ const saturated = persistedCount >= MAX_EVENTS_PER_SESSION_WINDOW;
109
+ if (legacyEvent) return { match: legacyEvent, saturated };
110
+ const batch = batches.find((candidate) =>
111
+ candidate.events.some((nested) => nested.eventId === event.eventId)
112
+ );
113
+ return {
114
+ match: batch
115
+ ? { sessionId: batch.sessionId, visitorId: batch.visitorId }
116
+ : null,
117
+ saturated,
118
+ };
119
+ }
120
+
121
+ export function bufferEventBatch(
122
+ siteId: Id<"sites">,
123
+ event: TrackerEvent,
124
+ buffer: EventBatchBuffer,
125
+ ) {
126
+ const bucketStart = eventBucketStart(event.timestamp);
127
+ const key = eventBatchKey(event.sessionId, bucketStart);
128
+ const existing = buffer.get(key);
129
+ if (existing) {
130
+ if (existing.visitorId !== event.visitorId) {
131
+ throw new Error("event batch visitor does not match its session");
132
+ }
133
+ existing.events.push(eventForBatch(event));
134
+ return;
135
+ }
136
+ buffer.set(key, {
137
+ siteId,
138
+ sessionId: event.sessionId,
139
+ visitorId: event.visitorId,
140
+ bucketStart,
141
+ events: [eventForBatch(event)],
142
+ });
143
+ }
144
+
145
+ export async function insertEventBatch(
146
+ ctx: MutationCtx,
147
+ siteId: Id<"sites">,
148
+ event: TrackerEvent,
149
+ ) {
150
+ await ctx.db.insert("eventBatches", {
151
+ siteId,
152
+ sessionId: event.sessionId,
153
+ visitorId: event.visitorId,
154
+ bucketStart: eventBucketStart(event.timestamp),
155
+ events: [eventForBatch(event)],
156
+ });
157
+ }
158
+
159
+ export async function flushEventBatches(
160
+ ctx: MutationCtx,
161
+ buffer: EventBatchBuffer,
162
+ ) {
163
+ for (const batch of buffer.values()) {
164
+ await ctx.db.insert("eventBatches", batch);
165
+ }
166
+ }
167
+
168
+ export async function loadSessionJourneyEvents(
169
+ ctx: QueryCtx,
170
+ input: {
171
+ siteId: Id<"sites">;
172
+ sessionId: string;
173
+ from: number;
174
+ to: number;
175
+ cursor: JourneyCursor | null;
176
+ resultLimit: number;
177
+ },
178
+ ): Promise<RawJourneyEvent[]> {
179
+ const startTimestamp = input.cursor?.timestamp ?? input.from;
180
+ const firstBucket = eventBucketStart(startTimestamp);
181
+ const lastBucket = eventBucketStart(input.to);
182
+ // Nested event tiebreakers are not index fields, so the boundary minute must
183
+ // be read completely before applying the cursor.
184
+ const loadBatches = async () => {
185
+ const batches: Doc<"eventBatches">[] = [];
186
+ let eventCount = 0;
187
+ const requiredEvents = input.resultLimit + MAX_EVENTS_PER_SESSION_WINDOW;
188
+ let completedBucket: number | undefined;
189
+ const query = ctx.db
190
+ .query("eventBatches")
191
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
192
+ range
193
+ .eq("siteId", input.siteId)
194
+ .eq("sessionId", input.sessionId)
195
+ .gte("bucketStart", firstBucket)
196
+ .lte("bucketStart", lastBucket),
197
+ )
198
+ .order("asc");
199
+ for await (const batch of query) {
200
+ if (completedBucket !== undefined && batch.bucketStart !== completedBucket) {
201
+ break;
202
+ }
203
+ batches.push(batch);
204
+ eventCount += batch.events.length;
205
+ if (eventCount >= requiredEvents) completedBucket = batch.bucketStart;
206
+ }
207
+ return batches;
208
+ };
209
+ const loadLegacyEvents = async () => {
210
+ const take = input.resultLimit + 1;
211
+ if (!input.cursor) {
212
+ return ctx.db
213
+ .query("events")
214
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
215
+ range
216
+ .eq("siteId", input.siteId)
217
+ .eq("sessionId", input.sessionId)
218
+ .gte("timestamp", input.from)
219
+ .lte("timestamp", input.to),
220
+ )
221
+ .order("asc")
222
+ .take(take);
223
+ }
224
+ const rows = await ctx.db
225
+ .query("events")
226
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
227
+ range
228
+ .eq("siteId", input.siteId)
229
+ .eq("sessionId", input.sessionId)
230
+ .eq("timestamp", input.cursor!.timestamp)
231
+ .gt("_creationTime", input.cursor!.creationTime),
232
+ )
233
+ .order("asc")
234
+ .take(take);
235
+ if (rows.length < take) {
236
+ rows.push(...await ctx.db
237
+ .query("events")
238
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
239
+ range
240
+ .eq("siteId", input.siteId)
241
+ .eq("sessionId", input.sessionId)
242
+ .gt("timestamp", input.cursor!.timestamp)
243
+ .lte("timestamp", input.to),
244
+ )
245
+ .order("asc")
246
+ .take(take - rows.length));
247
+ }
248
+ return rows;
249
+ };
250
+ const [legacyEvents, batches] = await Promise.all([
251
+ loadLegacyEvents(),
252
+ loadBatches(),
253
+ ]);
254
+ const rows: RawJourneyEvent[] = legacyEvents.map((event) => ({
255
+ ...event,
256
+ _id: String(event._id),
257
+ }));
258
+ for (const batch of batches) {
259
+ for (let index = 0; index < batch.events.length; index += 1) {
260
+ const event = batch.events[index];
261
+ rows.push({
262
+ ...event,
263
+ _id: `eventBatch:${batch._id}:${index.toString().padStart(3, "0")}`,
264
+ _creationTime: batch._creationTime,
265
+ siteId: batch.siteId,
266
+ sessionId: batch.sessionId,
267
+ visitorId: batch.visitorId,
268
+ });
269
+ }
270
+ }
271
+ return rows
272
+ .filter((event) =>
273
+ event.timestamp >= input.from &&
274
+ event.timestamp <= input.to &&
275
+ (!input.cursor || compareJourneyEventToCursor(event, input.cursor) > 0)
276
+ )
277
+ .sort(compareJourneyEvents)
278
+ .slice(0, input.resultLimit);
279
+ }
280
+
281
+ export function compareJourneyEvents(
282
+ left: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
283
+ right: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
284
+ ): number {
285
+ return left.timestamp - right.timestamp ||
286
+ left._creationTime - right._creationTime ||
287
+ left._id.localeCompare(right._id);
288
+ }
289
+
290
+ export async function hydrateEventContexts(
291
+ ctx: QueryCtx,
292
+ rows: RawJourneyEvent[],
293
+ ) {
294
+ const sessionPromises = new Map<string, Promise<Doc<"sessions"> | null>>();
295
+ for (const row of rows) {
296
+ const key = `${row.siteId}:${row.sessionId}`;
297
+ if (!sessionPromises.has(key)) {
298
+ sessionPromises.set(
299
+ key,
300
+ ctx.db
301
+ .query("sessions")
302
+ .withIndex("by_siteId_and_sessionId", (range) =>
303
+ range.eq("siteId", row.siteId).eq("sessionId", row.sessionId),
304
+ )
305
+ .unique(),
306
+ );
307
+ }
308
+ }
309
+ const sessions = new Map(
310
+ await Promise.all(
311
+ [...sessionPromises].map(async ([key, session]) =>
312
+ [key, await session] as const
313
+ ),
314
+ ),
315
+ );
316
+ return rows.map((row) => {
317
+ const session = sessions.get(`${row.siteId}:${row.sessionId}`);
318
+ const country = row.country ?? session?.country;
319
+ const city = row.city ?? session?.city;
320
+ const latitude = row.latitude ?? session?.latitude;
321
+ const longitude = row.longitude ?? session?.longitude;
322
+ return {
323
+ ...row,
324
+ ...(country ? { country } : {}),
325
+ ...(city ? { city } : {}),
326
+ ...(latitude !== undefined ? { latitude } : {}),
327
+ ...(longitude !== undefined ? { longitude } : {}),
328
+ browser: row.browser ?? session?.browser ?? "Other",
329
+ os: row.os ?? session?.os ?? "Other",
330
+ device: row.device ?? session?.device ?? "unknown",
331
+ };
332
+ });
333
+ }
334
+
335
+ export async function deleteTelemetryBefore(
336
+ ctx: MutationCtx,
337
+ input: {
338
+ siteId: Id<"sites">;
339
+ before: number;
340
+ batchSize: number;
341
+ },
342
+ ) {
343
+ const batchCutoff = eventBucketStart(input.before);
344
+ const limit = input.batchSize + 1;
345
+ const [legacyEvents, batches] = await Promise.all([
346
+ ctx.db
347
+ .query("events")
348
+ .withIndex("by_siteId_and_timestamp", (range) =>
349
+ range.eq("siteId", input.siteId).lt("timestamp", input.before),
350
+ )
351
+ .take(limit),
352
+ ctx.db
353
+ .query("eventBatches")
354
+ .withIndex("by_siteId_and_bucketStart", (range) =>
355
+ range.eq("siteId", input.siteId).lt("bucketStart", batchCutoff),
356
+ )
357
+ .take(limit),
358
+ ]);
359
+ const rows = [...legacyEvents, ...batches].slice(0, input.batchSize);
360
+ for (const row of rows) {
361
+ if ("bucketStart" in row) {
362
+ await ctx.db.delete("eventBatches", row._id);
363
+ } else {
364
+ await ctx.db.delete("events", row._id);
365
+ }
366
+ }
367
+ return {
368
+ deleted: rows.length,
369
+ hasMore:
370
+ legacyEvents.length + batches.length > input.batchSize,
371
+ };
372
+ }
373
+
374
+ async function loadTelemetryMinute(
375
+ ctx: MutationCtx,
376
+ input: {
377
+ siteId: Id<"sites">;
378
+ sessionId: string;
379
+ bucketStart: number;
380
+ },
381
+ ) {
382
+ return Promise.all([
383
+ ctx.db
384
+ .query("events")
385
+ .withIndex("by_siteId_and_sessionId_and_timestamp", (range) =>
386
+ range
387
+ .eq("siteId", input.siteId)
388
+ .eq("sessionId", input.sessionId)
389
+ .gte("timestamp", input.bucketStart)
390
+ .lt("timestamp", input.bucketStart + RATE_LIMIT_WINDOW_MS),
391
+ )
392
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
393
+ ctx.db
394
+ .query("eventBatches")
395
+ .withIndex("by_siteId_and_sessionId_and_bucketStart", (range) =>
396
+ range
397
+ .eq("siteId", input.siteId)
398
+ .eq("sessionId", input.sessionId)
399
+ .eq("bucketStart", input.bucketStart),
400
+ )
401
+ .take(MAX_EVENTS_PER_SESSION_WINDOW + 1),
402
+ ]);
403
+ }
404
+
405
+ async function findLegacyTelemetry(
406
+ ctx: MutationCtx,
407
+ input: {
408
+ siteId: Id<"sites">;
409
+ sessionId: string;
410
+ bucketStart: number;
411
+ eventId: string;
412
+ },
413
+ ) {
414
+ const candidates = await ctx.db
415
+ .query("events")
416
+ .withIndex("by_siteId_and_eventId", (range) =>
417
+ range.eq("siteId", input.siteId).eq("eventId", input.eventId),
418
+ )
419
+ .take(2);
420
+ return candidates.find((candidate) =>
421
+ candidate.sessionId === input.sessionId &&
422
+ eventBucketStart(candidate.timestamp) === input.bucketStart
423
+ ) ?? null;
424
+ }
425
+
426
+ function telemetryMinuteCount(
427
+ legacyEvents: Array<{ eventId: string }>,
428
+ batches: Array<{ events: TrackerEvent[] }>,
429
+ ) {
430
+ return legacyEvents.length + batches.reduce(
431
+ (total, batch) => total + batch.events.length,
432
+ 0,
433
+ );
434
+ }
435
+
436
+ function eventForBatch(event: TrackerEvent): TrackerEvent {
437
+ return {
438
+ eventId: event.eventId,
439
+ sessionId: event.sessionId,
440
+ visitorId: event.visitorId,
441
+ type: event.type,
442
+ ...(event.name ? { name: event.name } : {}),
443
+ path: event.path,
444
+ ...(event.referrer ? { referrer: event.referrer } : {}),
445
+ timestamp: event.timestamp,
446
+ sequence: event.sequence,
447
+ ...(event.target ? { target: event.target } : {}),
448
+ ...(event.href ? { href: event.href } : {}),
449
+ ...(event.properties ? { properties: event.properties } : {}),
450
+ ...(event.revenueCents !== undefined
451
+ ? { revenueCents: event.revenueCents }
452
+ : {}),
453
+ ...(event.currency ? { currency: event.currency } : {}),
454
+ ...(event.affiliateSlug ? { affiliateSlug: event.affiliateSlug } : {}),
455
+ };
456
+ }
457
+
458
+ function compareJourneyEventToCursor(
459
+ event: Pick<RawJourneyEvent, "timestamp" | "_creationTime" | "_id">,
460
+ cursor: JourneyCursor,
461
+ ) {
462
+ return event.timestamp - cursor.timestamp ||
463
+ event._creationTime - cursor.creationTime ||
464
+ event._id.localeCompare(cursor.id);
465
+ }
@@ -62,10 +62,25 @@ export const upsert = mutation({
62
62
  if (args.goalId && existingByKey && existingByKey._id !== args.goalId) {
63
63
  fail("CONFLICT", "another goal already uses this key");
64
64
  }
65
+ const goals = await ctx.db
66
+ .query("goals")
67
+ .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
68
+ .take(MAX_GOALS_PER_SITE + 1);
65
69
  const now = Date.now();
66
70
  if (goalId) {
67
71
  const goal = await ctx.db.get("goals", goalId);
68
72
  if (!goal || goal.siteId !== args.siteId) fail("NOT_FOUND", "goal not found");
73
+ if (
74
+ (args.active ?? goal.active) &&
75
+ goals.some((candidate) =>
76
+ candidate._id !== goalId &&
77
+ candidate.active &&
78
+ candidate.matchType === args.matchType &&
79
+ candidate.matchValue === matchValue
80
+ )
81
+ ) {
82
+ fail("CONFLICT", "another active goal already uses this matcher");
83
+ }
69
84
  await ctx.db.patch("goals", goalId, {
70
85
  key,
71
86
  name,
@@ -78,10 +93,16 @@ export const upsert = mutation({
78
93
  return goalId;
79
94
  }
80
95
 
81
- const goals = await ctx.db
82
- .query("goals")
83
- .withIndex("by_siteId", (range) => range.eq("siteId", args.siteId))
84
- .take(MAX_GOALS_PER_SITE + 1);
96
+ if (
97
+ (args.active ?? true) &&
98
+ goals.some((candidate) =>
99
+ candidate.active &&
100
+ candidate.matchType === args.matchType &&
101
+ candidate.matchValue === matchValue
102
+ )
103
+ ) {
104
+ fail("CONFLICT", "another active goal already uses this matcher");
105
+ }
85
106
  if (goals.length >= MAX_GOALS_PER_SITE) {
86
107
  fail("LIMIT_EXCEEDED", "goal limit reached", { limit: MAX_GOALS_PER_SITE });
87
108
  }
@@ -24,16 +24,29 @@ http.route({
24
24
  handler: httpAction(async (ctx, request) => {
25
25
  const origin = request.headers.get("origin");
26
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);
27
+ const headers = corsHeaders(origin);
28
+ const contentLength = Number(request.headers.get("content-length"));
29
+ if (Number.isFinite(contentLength) && contentLength > MAX_BATCH_BYTES) {
30
+ return json(
31
+ { error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
32
+ 413,
33
+ headers,
34
+ );
30
35
  }
31
36
 
32
37
  let payload: { siteId: string; events: TrackerEvent[] };
33
38
  try {
39
+ const bytes = await readLimitedBody(request, MAX_BATCH_BYTES);
34
40
  const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes));
35
41
  payload = parsePayload(parsed);
36
42
  } catch (error) {
43
+ if (error instanceof PayloadTooLargeError) {
44
+ return json(
45
+ { error: { code: "BATCH_TOO_LARGE", limitBytes: MAX_BATCH_BYTES } },
46
+ 413,
47
+ headers,
48
+ );
49
+ }
37
50
  return json(
38
51
  {
39
52
  error: {
@@ -42,6 +55,7 @@ http.route({
42
55
  },
43
56
  },
44
57
  400,
58
+ headers,
45
59
  );
46
60
  }
47
61
 
@@ -61,7 +75,7 @@ http.route({
61
75
  events: payload.events,
62
76
  context,
63
77
  });
64
- return json(result, 200, corsHeaders(origin));
78
+ return json(result, 200, headers);
65
79
  } catch (error) {
66
80
  if (error instanceof ConvexError) {
67
81
  const data = error.data as { code?: string; message?: string };
@@ -71,9 +85,9 @@ http.route({
71
85
  : data.code === "RATE_LIMITED"
72
86
  ? 429
73
87
  : 400;
74
- return json({ error: data }, status);
88
+ return json({ error: data }, status, headers);
75
89
  }
76
- return json({ error: { code: "INVALID_REQUEST" } }, 400);
90
+ return json({ error: { code: "INVALID_REQUEST" } }, 400, headers);
77
91
  }
78
92
  }),
79
93
  });
@@ -136,6 +150,35 @@ function parsePayload(value: unknown): { siteId: string; events: TrackerEvent[]
136
150
  return { siteId: value.siteId, events: value.events.map(parseEvent) };
137
151
  }
138
152
 
153
+ class PayloadTooLargeError extends Error {}
154
+
155
+ async function readLimitedBody(
156
+ request: Request,
157
+ maximumBytes: number,
158
+ ): Promise<Uint8Array> {
159
+ if (!request.body) return new Uint8Array();
160
+ const reader = request.body.getReader();
161
+ const chunks: Uint8Array[] = [];
162
+ let length = 0;
163
+ while (true) {
164
+ const { done, value } = await reader.read();
165
+ if (done) break;
166
+ length += value.byteLength;
167
+ if (length > maximumBytes) {
168
+ await reader.cancel();
169
+ throw new PayloadTooLargeError();
170
+ }
171
+ chunks.push(value);
172
+ }
173
+ const result = new Uint8Array(length);
174
+ let offset = 0;
175
+ for (const chunk of chunks) {
176
+ result.set(chunk, offset);
177
+ offset += chunk.byteLength;
178
+ }
179
+ return result;
180
+ }
181
+
139
182
  function parseEvent(value: unknown): TrackerEvent {
140
183
  if (!isPlainRecord(value)) throw new Error("each event must be an object");
141
184
  const type = value.type;