@stacksjs/ts-analytics 0.1.6

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 (237) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE.md +21 -0
  3. package/README.md +361 -0
  4. package/bin/cli.ts +169 -0
  5. package/dist/Analytics.d.ts +558 -0
  6. package/dist/api.d.ts +109 -0
  7. package/dist/batching.d.ts +93 -0
  8. package/dist/chunk-2mx7fq49.js +4 -0
  9. package/dist/chunk-3z29508k.js +204 -0
  10. package/dist/chunk-deephkz6.js +56 -0
  11. package/dist/chunk-j261vgyp.js +305 -0
  12. package/dist/chunk-xga17tz7.js +207 -0
  13. package/dist/config.d.ts +132 -0
  14. package/dist/dashboard/components/index.d.ts +80 -0
  15. package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
  16. package/dist/dashboard/index.d.ts +110 -0
  17. package/dist/dashboard/types/index.d.ts +144 -0
  18. package/dist/dashboard/utils/index.d.ts +81 -0
  19. package/dist/dynamodb.d.ts +86 -0
  20. package/dist/funnels.d.ts +104 -0
  21. package/dist/geolocation.d.ts +96 -0
  22. package/dist/index.d.ts +371 -0
  23. package/dist/index.js +10211 -0
  24. package/dist/infrastructure/cdk.d.ts +60 -0
  25. package/dist/infrastructure/cloudformation.d.ts +44 -0
  26. package/dist/infrastructure/index.d.ts +45 -0
  27. package/dist/infrastructure/setup.d.ts +133 -0
  28. package/dist/integrations/cloudflare.d.ts +76 -0
  29. package/dist/integrations/hono.d.ts +60 -0
  30. package/dist/integrations/index.d.ts +24 -0
  31. package/dist/integrations/nuxt.d.ts +7 -0
  32. package/dist/integrations/nuxt.js +42 -0
  33. package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
  34. package/dist/integrations/runtime/use-ts-analytics.js +17 -0
  35. package/dist/integrations/stx.d.ts +88 -0
  36. package/dist/integrations/stx.js +18 -0
  37. package/dist/lib/crypto-random.d.ts +4 -0
  38. package/dist/lib/salt.d.ts +16 -0
  39. package/dist/local.d.ts +56 -0
  40. package/dist/model-connector.d.ts +145 -0
  41. package/dist/models/AggregatedStats.d.ts +9 -0
  42. package/dist/models/CampaignStats.d.ts +9 -0
  43. package/dist/models/Conversion.d.ts +11 -0
  44. package/dist/models/CustomEvent.d.ts +11 -0
  45. package/dist/models/DeviceStats.d.ts +9 -0
  46. package/dist/models/EventStats.d.ts +9 -0
  47. package/dist/models/GeoStats.d.ts +9 -0
  48. package/dist/models/Goal.d.ts +9 -0
  49. package/dist/models/GoalStats.d.ts +9 -0
  50. package/dist/models/PageStats.d.ts +11 -0
  51. package/dist/models/PageView.d.ts +13 -0
  52. package/dist/models/RealtimeStats.d.ts +9 -0
  53. package/dist/models/ReferrerStats.d.ts +9 -0
  54. package/dist/models/Session.d.ts +11 -0
  55. package/dist/models/Site.d.ts +11 -0
  56. package/dist/models/index.d.ts +28 -0
  57. package/dist/models/types.d.ts +60 -0
  58. package/dist/sqs-buffering.d.ts +243 -0
  59. package/dist/stacks-integration.d.ts +159 -0
  60. package/dist/tracking-script.d.ts +71 -0
  61. package/dist/tracking.d.ts +17 -0
  62. package/dist/tracking.js +26 -0
  63. package/dist/types.d.ts +595 -0
  64. package/dist/utils/geolocation.d.ts +111 -0
  65. package/dist/utils/user-agent.d.ts +17 -0
  66. package/dist/version.d.ts +7 -0
  67. package/package.json +119 -0
  68. package/src/Analytics.ts +3349 -0
  69. package/src/api.ts +1286 -0
  70. package/src/assets/crosswind.css +2220 -0
  71. package/src/batching.ts +452 -0
  72. package/src/components/dashboard/index.ts +18 -0
  73. package/src/config.ts +456 -0
  74. package/src/dashboard/Dashboard.stx +1517 -0
  75. package/src/dashboard/components/AlertCard.stx +177 -0
  76. package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
  77. package/src/dashboard/components/AnimatedNumber.stx +86 -0
  78. package/src/dashboard/components/BarChart.stx +220 -0
  79. package/src/dashboard/components/BrowserBreakdown.stx +98 -0
  80. package/src/dashboard/components/BrowsersTable.stx +125 -0
  81. package/src/dashboard/components/CampaignBreakdown.stx +163 -0
  82. package/src/dashboard/components/CampaignTable.stx +238 -0
  83. package/src/dashboard/components/CountryList.stx +101 -0
  84. package/src/dashboard/components/DataTable.stx +226 -0
  85. package/src/dashboard/components/DateRangePicker.stx +77 -0
  86. package/src/dashboard/components/DeviceBreakdown.stx +94 -0
  87. package/src/dashboard/components/DevicesTable.stx +163 -0
  88. package/src/dashboard/components/DonutChart.stories.ts +55 -0
  89. package/src/dashboard/components/DonutChart.stx +157 -0
  90. package/src/dashboard/components/EmptyState.stx +90 -0
  91. package/src/dashboard/components/EngagementMetrics.stx +183 -0
  92. package/src/dashboard/components/EventsSection.stx +192 -0
  93. package/src/dashboard/components/FilterBar.stx +104 -0
  94. package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
  95. package/src/dashboard/components/FunnelChart.stx +142 -0
  96. package/src/dashboard/components/GeoTable.stx +337 -0
  97. package/src/dashboard/components/GoalsPanel.stx +109 -0
  98. package/src/dashboard/components/Header.stx +39 -0
  99. package/src/dashboard/components/HeatmapChart.stx +140 -0
  100. package/src/dashboard/components/LiveActivityFeed.stx +172 -0
  101. package/src/dashboard/components/MetricComparison.stx +106 -0
  102. package/src/dashboard/components/MiniStats.stx +96 -0
  103. package/src/dashboard/components/OSBreakdown.stx +124 -0
  104. package/src/dashboard/components/PageDetailCard.stx +102 -0
  105. package/src/dashboard/components/PagesTable.stx +127 -0
  106. package/src/dashboard/components/ProgressRing.stx +89 -0
  107. package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
  108. package/src/dashboard/components/RealtimeCounter.stx +46 -0
  109. package/src/dashboard/components/ReferrersTable.stx +180 -0
  110. package/src/dashboard/components/SparklineChart.stx +160 -0
  111. package/src/dashboard/components/StatCard.stories.ts +58 -0
  112. package/src/dashboard/components/StatCard.stx +90 -0
  113. package/src/dashboard/components/SummaryStats.stx +81 -0
  114. package/src/dashboard/components/TabNav.stx +66 -0
  115. package/src/dashboard/components/ThemeSwitcher.stx +124 -0
  116. package/src/dashboard/components/TimeSeriesChart.stx +106 -0
  117. package/src/dashboard/components/TopList.stories.ts +58 -0
  118. package/src/dashboard/components/TopList.stx +74 -0
  119. package/src/dashboard/components/TrendIndicator.stx +84 -0
  120. package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
  121. package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
  122. package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
  123. package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
  124. package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
  125. package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
  126. package/src/dashboard/components/heatmap/index.ts +12 -0
  127. package/src/dashboard/components/index.ts +86 -0
  128. package/src/dashboard/composables/useAnalytics.ts +465 -0
  129. package/src/dashboard/demo/DemoApp.stx +370 -0
  130. package/src/dashboard/demo/index.ts +8 -0
  131. package/src/dashboard/demo/mockData.ts +234 -0
  132. package/src/dashboard/index.ts +117 -0
  133. package/src/dashboard/stx-shim.d.ts +4 -0
  134. package/src/dashboard/types/index.ts +203 -0
  135. package/src/dashboard/utils/index.ts +426 -0
  136. package/src/dynamodb.ts +344 -0
  137. package/src/funnels.ts +534 -0
  138. package/src/geolocation.ts +515 -0
  139. package/src/handlers/alerts.ts +328 -0
  140. package/src/handlers/annotations.ts +128 -0
  141. package/src/handlers/api-keys.ts +240 -0
  142. package/src/handlers/auth.ts +1020 -0
  143. package/src/handlers/authz.ts +137 -0
  144. package/src/handlers/collect.ts +724 -0
  145. package/src/handlers/data.ts +625 -0
  146. package/src/handlers/experiments.ts +138 -0
  147. package/src/handlers/funnels.ts +216 -0
  148. package/src/handlers/goals.ts +218 -0
  149. package/src/handlers/heatmaps.ts +272 -0
  150. package/src/handlers/index.ts +56 -0
  151. package/src/handlers/lib/read-cache.ts +63 -0
  152. package/src/handlers/misc.ts +486 -0
  153. package/src/handlers/oauth.ts +233 -0
  154. package/src/handlers/performance.ts +388 -0
  155. package/src/handlers/sessions.ts +413 -0
  156. package/src/handlers/sharing.ts +198 -0
  157. package/src/handlers/stats.ts +1368 -0
  158. package/src/handlers/team.ts +161 -0
  159. package/src/handlers/uptime.ts +283 -0
  160. package/src/handlers/views.ts +390 -0
  161. package/src/handlers/webhooks.ts +226 -0
  162. package/src/heatmap/index.ts +32 -0
  163. package/src/heatmap/tracking-script.ts +452 -0
  164. package/src/heatmap/types.ts +79 -0
  165. package/src/index.ts +387 -0
  166. package/src/infrastructure/cdk.ts +496 -0
  167. package/src/infrastructure/cloudformation.ts +595 -0
  168. package/src/infrastructure/index.ts +48 -0
  169. package/src/infrastructure/setup.ts +611 -0
  170. package/src/integrations/cloudflare.ts +732 -0
  171. package/src/integrations/hono.ts +589 -0
  172. package/src/integrations/index.ts +27 -0
  173. package/src/integrations/nuxt.ts +78 -0
  174. package/src/integrations/runtime/use-ts-analytics.ts +32 -0
  175. package/src/integrations/stx.ts +138 -0
  176. package/src/jobs/index.ts +127 -0
  177. package/src/lib/crypto-random.ts +41 -0
  178. package/src/lib/ddb-errors.ts +20 -0
  179. package/src/lib/dynamodb.ts +216 -0
  180. package/src/lib/email.ts +38 -0
  181. package/src/lib/ga-import.ts +471 -0
  182. package/src/lib/ga4-api.ts +244 -0
  183. package/src/lib/goals.ts +205 -0
  184. package/src/lib/index.ts +6 -0
  185. package/src/lib/ingest-counters.ts +123 -0
  186. package/src/lib/log.ts +36 -0
  187. package/src/lib/membership.ts +98 -0
  188. package/src/lib/plans.ts +90 -0
  189. package/src/lib/quota.ts +46 -0
  190. package/src/lib/rate-limit.ts +27 -0
  191. package/src/lib/rollups.ts +472 -0
  192. package/src/lib/salt.ts +97 -0
  193. package/src/lib/scheduler.ts +97 -0
  194. package/src/lib/significance.ts +66 -0
  195. package/src/lib/site-retention.ts +56 -0
  196. package/src/local.ts +360 -0
  197. package/src/model-connector.ts +616 -0
  198. package/src/models/AggregatedStats.ts +162 -0
  199. package/src/models/CampaignStats.ts +141 -0
  200. package/src/models/Conversion.ts +135 -0
  201. package/src/models/CustomEvent.ts +123 -0
  202. package/src/models/DeviceStats.ts +105 -0
  203. package/src/models/EventStats.ts +108 -0
  204. package/src/models/GeoStats.ts +112 -0
  205. package/src/models/Goal.ts +105 -0
  206. package/src/models/GoalStats.ts +106 -0
  207. package/src/models/PageStats.ts +152 -0
  208. package/src/models/PageView.ts +277 -0
  209. package/src/models/RealtimeStats.ts +96 -0
  210. package/src/models/ReferrerStats.ts +115 -0
  211. package/src/models/Session.ts +235 -0
  212. package/src/models/Site.ts +115 -0
  213. package/src/models/index.ts +50 -0
  214. package/src/models/orm/index.ts +2043 -0
  215. package/src/models/types.ts +71 -0
  216. package/src/router.ts +521 -0
  217. package/src/sqs-buffering.ts +806 -0
  218. package/src/stacks-integration.ts +527 -0
  219. package/src/tracking-script.ts +944 -0
  220. package/src/tracking.ts +27 -0
  221. package/src/types/analytics.ts +219 -0
  222. package/src/types/api.ts +75 -0
  223. package/src/types/bun-router.d.ts +15 -0
  224. package/src/types/dashboard.ts +62 -0
  225. package/src/types/index.ts +66 -0
  226. package/src/types/stx.d.ts +27 -0
  227. package/src/types/window.d.ts +101 -0
  228. package/src/types.ts +911 -0
  229. package/src/utils/cache.ts +148 -0
  230. package/src/utils/date.ts +118 -0
  231. package/src/utils/filters.ts +71 -0
  232. package/src/utils/geolocation.ts +323 -0
  233. package/src/utils/index.ts +9 -0
  234. package/src/utils/response.ts +180 -0
  235. package/src/utils/timezone-country.ts +242 -0
  236. package/src/utils/user-agent.ts +110 -0
  237. package/src/version.ts +7 -0
@@ -0,0 +1,732 @@
1
+ /**
2
+ * Cloudflare Workers Integration for Analytics
3
+ *
4
+ * Edge-optimized analytics handler for Cloudflare Workers.
5
+ * Uses Cloudflare D1, KV, or external DynamoDB for storage.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createAnalyticsHandler } from '@stacksjs/analytics/integrations/cloudflare'
10
+ *
11
+ * export default {
12
+ * fetch: createAnalyticsHandler({
13
+ * storage: env.D1_DATABASE,
14
+ * sessionKV: env.SESSIONS_KV,
15
+ * }),
16
+ * }
17
+ * ```
18
+ */
19
+
20
+ import type { AggregationPeriod, PageView, Session } from '../types'
21
+ import { getConfig, setConfig } from '../config'
22
+ import {
23
+ generateId,
24
+ getDailySalt,
25
+ getPeriodStart,
26
+ hashVisitorId,
27
+ KeyPatterns,
28
+ } from '../dynamodb'
29
+
30
+ // ============================================================================
31
+ // Types
32
+ // ============================================================================
33
+
34
+ export interface CloudflareEnv {
35
+ /** DynamoDB endpoint (for external DynamoDB) */
36
+ DYNAMODB_ENDPOINT?: string
37
+ /** AWS Region */
38
+ AWS_REGION?: string
39
+ /** AWS Access Key ID */
40
+ AWS_ACCESS_KEY_ID?: string
41
+ /** AWS Secret Access Key */
42
+ AWS_SECRET_ACCESS_KEY?: string
43
+ /** KV Namespace for sessions */
44
+ SESSIONS_KV?: KVNamespace
45
+ /** D1 Database (optional, for D1 storage) */
46
+ D1_DATABASE?: D1Database
47
+ /** Table name */
48
+ TABLE_NAME?: string
49
+ }
50
+
51
+ interface KVNamespace {
52
+ get: (key: string, options?: { type?: string }) => Promise<string | null>
53
+ put: (key: string, value: string, options?: { expirationTtl?: number }) => Promise<void>
54
+ delete: (key: string) => Promise<void>
55
+ }
56
+
57
+ interface D1Database {
58
+ prepare: (sql: string) => D1PreparedStatement
59
+ exec: (sql: string) => Promise<D1ExecResult>
60
+ batch: <T>(statements: D1PreparedStatement[]) => Promise<D1Result<T>[]>
61
+ }
62
+
63
+ interface D1PreparedStatement {
64
+ bind: (...values: unknown[]) => D1PreparedStatement
65
+ first: <T>() => Promise<T | null>
66
+ all: <T>() => Promise<D1Result<T>>
67
+ run: () => Promise<D1Result<unknown>>
68
+ }
69
+
70
+ interface D1Result<T> {
71
+ results?: T[]
72
+ success: boolean
73
+ meta?: Record<string, unknown>
74
+ }
75
+
76
+ interface D1ExecResult {
77
+ count: number
78
+ duration: number
79
+ }
80
+
81
+ export interface CloudflareHandlerOptions {
82
+ /** CORS allowed origins */
83
+ corsOrigins?: string[]
84
+ /** Base path for analytics routes */
85
+ basePath?: string
86
+ /** Session TTL in seconds */
87
+ sessionTtl?: number
88
+ /** Custom storage adapter */
89
+ storage?: StorageAdapter
90
+ }
91
+
92
+ export interface StorageAdapter {
93
+ putItem: (tableName: string, item: Record<string, unknown>) => Promise<void>
94
+ query: (tableName: string, params: QueryParams) => Promise<QueryResult>
95
+ }
96
+
97
+ interface QueryParams {
98
+ pk: string
99
+ skPrefix?: string
100
+ skStart?: string
101
+ skEnd?: string
102
+ indexName?: string
103
+ limit?: number
104
+ }
105
+
106
+ interface QueryResult {
107
+ items: Record<string, unknown>[]
108
+ lastKey?: string
109
+ }
110
+
111
+ interface CollectPayload {
112
+ s: string // siteId
113
+ sid: string // sessionId
114
+ e: 'pageview' | 'event' | 'outbound'
115
+ p?: Record<string, unknown> // properties
116
+ u: string // url
117
+ r?: string // referrer
118
+ t?: string // title
119
+ sw?: number // screen width
120
+ sh?: number // screen height
121
+ }
122
+
123
+ // ============================================================================
124
+ // Main Handler
125
+ // ============================================================================
126
+
127
+ /**
128
+ * Create a Cloudflare Workers fetch handler for analytics
129
+ */
130
+ export function createAnalyticsHandler(options: CloudflareHandlerOptions = {}): (
131
+ request: Request,
132
+ env: CloudflareEnv,
133
+ ctx: ExecutionContext,
134
+ ) => Promise<Response> {
135
+ const corsOrigins = options.corsOrigins ?? ['*']
136
+ const basePath = options.basePath ?? '/api/analytics'
137
+ const sessionTtl = options.sessionTtl ?? 1800
138
+
139
+ return async (request: Request, env: CloudflareEnv, ctx: ExecutionContext): Promise<Response> => {
140
+ const url = new URL(request.url)
141
+ const path = url.pathname
142
+
143
+ // Handle CORS preflight
144
+ if (request.method === 'OPTIONS') {
145
+ return handleCors(corsOrigins, request)
146
+ }
147
+
148
+ // Set up config from environment
149
+ setConfig({
150
+ table: { tableName: env.TABLE_NAME ?? 'AnalyticsTable' },
151
+ region: env.AWS_REGION ?? 'us-east-1',
152
+ endpoint: env.DYNAMODB_ENDPOINT,
153
+ })
154
+
155
+ const storage = options.storage ?? createDynamoDBAdapter(env)
156
+ const sessionStore = env.SESSIONS_KV ? createKVSessionStore(env.SESSIONS_KV, sessionTtl) : null
157
+
158
+ // Route handling
159
+ if (path === `${basePath}/collect` && request.method === 'POST') {
160
+ return handleCollect(request, env, storage, sessionStore)
161
+ }
162
+
163
+ if (path.startsWith(`${basePath}/sites/`) && path.endsWith('/stats') && request.method === 'GET') {
164
+ const siteId = extractSiteId(path, basePath, '/stats')
165
+ return handleStats(url, siteId, storage)
166
+ }
167
+
168
+ if (path.startsWith(`${basePath}/sites/`) && path.endsWith('/realtime') && request.method === 'GET') {
169
+ const siteId = extractSiteId(path, basePath, '/realtime')
170
+ return handleRealtime(url, siteId, storage)
171
+ }
172
+
173
+ if (path.startsWith(`${basePath}/sites/`) && path.endsWith('/script') && request.method === 'GET') {
174
+ const siteId = extractSiteId(path, basePath, '/script')
175
+ return handleScript(url, siteId, basePath)
176
+ }
177
+
178
+ if (path === `${basePath}/sites` && request.method === 'GET') {
179
+ return handleListSites(url, storage)
180
+ }
181
+
182
+ if (path === `${basePath}/sites` && request.method === 'POST') {
183
+ return handleCreateSite(request, storage)
184
+ }
185
+
186
+ return new Response('Not Found', { status: 404 })
187
+ }
188
+ }
189
+
190
+ // ============================================================================
191
+ // Route Handlers
192
+ // ============================================================================
193
+
194
+ async function handleCollect(
195
+ request: Request,
196
+ _env: CloudflareEnv,
197
+ storage: StorageAdapter,
198
+ sessionStore: SessionStore | null,
199
+ ): Promise<Response> {
200
+ try {
201
+ const payload = await request.json() as CollectPayload
202
+
203
+ if (!payload?.s || !payload?.e || !payload?.u) {
204
+ return jsonResponse({ error: 'Missing required fields: s, e, u' }, 400)
205
+ }
206
+
207
+ const config = getConfig()
208
+ const ip = request.headers.get('CF-Connecting-IP') ?? request.headers.get('X-Forwarded-For') ?? 'unknown'
209
+ const userAgent = request.headers.get('User-Agent') ?? 'unknown'
210
+ const salt = getDailySalt()
211
+
212
+ const visitorId = await hashVisitorId(ip, userAgent, payload.s, salt)
213
+
214
+ let parsedUrl: URL
215
+ try {
216
+ parsedUrl = new URL(payload.u)
217
+ }
218
+ catch {
219
+ return jsonResponse({ error: 'Invalid URL' }, 400)
220
+ }
221
+
222
+ const timestamp = new Date()
223
+ const sessionId = payload.sid
224
+
225
+ // Get or create session
226
+ let session = sessionStore ? await sessionStore.get(`${payload.s}:${sessionId}`) : null
227
+ const isNewSession = !session
228
+
229
+ if (payload.e === 'pageview') {
230
+ const deviceInfo = parseUserAgent(userAgent)
231
+ const referrerSource = parseReferrerSource(payload.r)
232
+
233
+ const pageView: PageView = {
234
+ id: generateId(),
235
+ siteId: payload.s,
236
+ visitorId,
237
+ sessionId,
238
+ path: parsedUrl.pathname,
239
+ hostname: parsedUrl.hostname,
240
+ title: payload.t,
241
+ referrer: payload.r,
242
+ referrerSource,
243
+ utmSource: parsedUrl.searchParams.get('utm_source') || undefined,
244
+ utmMedium: parsedUrl.searchParams.get('utm_medium') || undefined,
245
+ utmCampaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
246
+ deviceType: deviceInfo.deviceType,
247
+ browser: deviceInfo.browser,
248
+ os: deviceInfo.os,
249
+ screenWidth: payload.sw,
250
+ screenHeight: payload.sh,
251
+ isUnique: isNewSession,
252
+ isBounce: isNewSession,
253
+ timestamp,
254
+ }
255
+
256
+ // Store page view
257
+ await storage.putItem(config.table.tableName, {
258
+ pk: KeyPatterns.pageView.pk(pageView.siteId),
259
+ sk: KeyPatterns.pageView.sk(pageView.timestamp, pageView.id),
260
+ ...pageView,
261
+ timestamp: pageView.timestamp.toISOString(),
262
+ })
263
+
264
+ // Update session
265
+ if (session) {
266
+ session.pageViewCount += 1
267
+ session.exitPath = parsedUrl.pathname
268
+ session.endedAt = timestamp
269
+ session.isBounce = false
270
+ session.duration = timestamp.getTime() - new Date(session.startedAt).getTime()
271
+ }
272
+ else {
273
+ session = {
274
+ id: sessionId,
275
+ siteId: payload.s,
276
+ visitorId,
277
+ entryPath: parsedUrl.pathname,
278
+ exitPath: parsedUrl.pathname,
279
+ referrer: payload.r,
280
+ referrerSource,
281
+ deviceType: deviceInfo.deviceType,
282
+ browser: deviceInfo.browser,
283
+ os: deviceInfo.os,
284
+ pageViewCount: 1,
285
+ eventCount: 0,
286
+ isBounce: true,
287
+ duration: 0,
288
+ startedAt: timestamp,
289
+ endedAt: timestamp,
290
+ }
291
+ }
292
+
293
+ // Store session
294
+ await storage.putItem(config.table.tableName, {
295
+ pk: KeyPatterns.session.pk(session.siteId),
296
+ sk: KeyPatterns.session.sk(session.id),
297
+ ...session,
298
+ startedAt: session.startedAt instanceof Date ? session.startedAt.toISOString() : session.startedAt,
299
+ endedAt: session.endedAt instanceof Date ? session.endedAt.toISOString() : session.endedAt,
300
+ })
301
+
302
+ if (sessionStore) {
303
+ await sessionStore.set(`${payload.s}:${sessionId}`, session)
304
+ }
305
+ }
306
+
307
+ return new Response(null, { status: 204 })
308
+ }
309
+ catch (error) {
310
+ console.error('Collect error:', error)
311
+ return jsonResponse({ error: 'Internal server error' }, 500)
312
+ }
313
+ }
314
+
315
+ async function handleStats(
316
+ url: URL,
317
+ siteId: string,
318
+ storage: StorageAdapter,
319
+ ): Promise<Response> {
320
+ try {
321
+ if (!siteId) {
322
+ return jsonResponse({ error: 'Missing siteId' }, 400)
323
+ }
324
+
325
+ const config = getConfig()
326
+ const start = url.searchParams.get('start')
327
+ const end = url.searchParams.get('end')
328
+
329
+ const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
330
+ const endDate = end ? new Date(end) : new Date()
331
+ const period = determinePeriod(startDate, endDate)
332
+
333
+ const result = await storage.query(config.table.tableName, {
334
+ pk: KeyPatterns.site.pk(siteId),
335
+ skPrefix: `STATS#${period.toUpperCase()}`,
336
+ })
337
+
338
+ return jsonResponse({
339
+ stats: result.items,
340
+ dateRange: {
341
+ start: startDate.toISOString(),
342
+ end: endDate.toISOString(),
343
+ period,
344
+ },
345
+ })
346
+ }
347
+ catch (error) {
348
+ console.error('Stats error:', error)
349
+ return jsonResponse({ error: 'Failed to get stats' }, 500)
350
+ }
351
+ }
352
+
353
+ async function handleRealtime(
354
+ url: URL,
355
+ siteId: string,
356
+ storage: StorageAdapter,
357
+ ): Promise<Response> {
358
+ try {
359
+ if (!siteId) {
360
+ return jsonResponse({ error: 'Missing siteId' }, 400)
361
+ }
362
+
363
+ const config = getConfig()
364
+ const minutes = Number.parseInt(url.searchParams.get('minutes') ?? '5', 10)
365
+
366
+ const now = new Date()
367
+ const startMinute = new Date(now.getTime() - minutes * 60 * 1000)
368
+
369
+ const result = await storage.query(config.table.tableName, {
370
+ pk: KeyPatterns.site.pk(siteId),
371
+ skStart: KeyPatterns.realtime.sk(startMinute.toISOString().slice(0, 16)),
372
+ skEnd: KeyPatterns.realtime.sk(now.toISOString().slice(0, 16)),
373
+ })
374
+
375
+ return jsonResponse({
376
+ realtime: result.items,
377
+ timestamp: now.toISOString(),
378
+ })
379
+ }
380
+ catch (error) {
381
+ console.error('Realtime error:', error)
382
+ return jsonResponse({ error: 'Failed to get realtime stats' }, 500)
383
+ }
384
+ }
385
+
386
+ function handleScript(url: URL, siteId: string, basePath: string): Response {
387
+ if (!siteId) {
388
+ return jsonResponse({ error: 'Missing siteId' }, 400)
389
+ }
390
+
391
+ const apiEndpoint = url.searchParams.get('api') ?? `${url.origin}${basePath}`
392
+ const script = generateTrackingScript(siteId, apiEndpoint)
393
+
394
+ return new Response(script, {
395
+ headers: {
396
+ 'Content-Type': 'text/html',
397
+ 'Cache-Control': 'public, max-age=3600',
398
+ },
399
+ })
400
+ }
401
+
402
+ async function handleListSites(url: URL, storage: StorageAdapter): Promise<Response> {
403
+ try {
404
+ const config = getConfig()
405
+ const ownerId = url.searchParams.get('ownerId') ?? 'default'
406
+
407
+ const result = await storage.query(config.table.tableName, {
408
+ pk: `OWNER#${ownerId}`,
409
+ indexName: 'gsi1',
410
+ })
411
+
412
+ return jsonResponse({ sites: result.items })
413
+ }
414
+ catch (error) {
415
+ console.error('List sites error:', error)
416
+ return jsonResponse({ error: 'Failed to list sites' }, 500)
417
+ }
418
+ }
419
+
420
+ async function handleCreateSite(request: Request, storage: StorageAdapter): Promise<Response> {
421
+ try {
422
+ const config = getConfig()
423
+ const body = await request.json() as { name: string, domains: string[], ownerId?: string }
424
+
425
+ if (!body.name || !body.domains?.length) {
426
+ return jsonResponse({ error: 'Missing required fields: name, domains' }, 400)
427
+ }
428
+
429
+ const siteId = generateId()
430
+ const now = new Date()
431
+
432
+ const site = {
433
+ pk: KeyPatterns.site.pk(siteId),
434
+ sk: KeyPatterns.site.sk(siteId),
435
+ gsi1pk: KeyPatterns.site.gsi1pk(body.ownerId ?? 'default'),
436
+ gsi1sk: KeyPatterns.site.gsi1sk(siteId),
437
+ id: siteId,
438
+ name: body.name,
439
+ domains: body.domains,
440
+ timezone: 'UTC',
441
+ isActive: true,
442
+ ownerId: body.ownerId ?? 'default',
443
+ createdAt: now.toISOString(),
444
+ updatedAt: now.toISOString(),
445
+ }
446
+
447
+ await storage.putItem(config.table.tableName, site)
448
+
449
+ return jsonResponse({ site }, 201)
450
+ }
451
+ catch (error) {
452
+ console.error('Create site error:', error)
453
+ return jsonResponse({ error: 'Failed to create site' }, 500)
454
+ }
455
+ }
456
+
457
+ // ============================================================================
458
+ // Storage Adapters
459
+ // ============================================================================
460
+
461
+ interface SessionStore {
462
+ get: (key: string) => Promise<Session | null>
463
+ set: (key: string, session: Session) => Promise<void>
464
+ }
465
+
466
+ function createKVSessionStore(kv: KVNamespace, ttl: number): SessionStore {
467
+ return {
468
+ async get(key: string): Promise<Session | null> {
469
+ const data = await kv.get(key, { type: 'text' })
470
+ if (!data) return null
471
+ return JSON.parse(data) as Session
472
+ },
473
+ async set(key: string, session: Session): Promise<void> {
474
+ await kv.put(key, JSON.stringify(session), { expirationTtl: ttl })
475
+ },
476
+ }
477
+ }
478
+
479
+ function createDynamoDBAdapter(env: CloudflareEnv): StorageAdapter {
480
+ // Simple fetch-based DynamoDB adapter for Cloudflare Workers
481
+ const endpoint = env.DYNAMODB_ENDPOINT ?? `https://dynamodb.${env.AWS_REGION ?? 'us-east-1'}.amazonaws.com`
482
+ const _region = env.AWS_REGION ?? 'us-east-1'
483
+
484
+ return {
485
+ async putItem(tableName: string, item: Record<string, unknown>): Promise<void> {
486
+ const marshaledItem = marshalItem(item)
487
+
488
+ const response = await fetch(endpoint, {
489
+ method: 'POST',
490
+ headers: {
491
+ 'Content-Type': 'application/x-amz-json-1.0',
492
+ 'X-Amz-Target': 'DynamoDB_20120810.PutItem',
493
+ },
494
+ body: JSON.stringify({
495
+ TableName: tableName,
496
+ Item: marshaledItem,
497
+ }),
498
+ })
499
+
500
+ if (!response.ok) {
501
+ throw new Error(`DynamoDB PutItem failed: ${response.status}`)
502
+ }
503
+ },
504
+
505
+ async query(tableName: string, params: QueryParams): Promise<QueryResult> {
506
+ const expressionValues: Record<string, unknown> = {
507
+ ':pk': { S: params.pk },
508
+ }
509
+
510
+ let keyCondition = 'pk = :pk'
511
+
512
+ if (params.skPrefix) {
513
+ keyCondition += ' AND begins_with(sk, :skPrefix)'
514
+ expressionValues[':skPrefix'] = { S: params.skPrefix }
515
+ }
516
+ else if (params.skStart && params.skEnd) {
517
+ keyCondition += ' AND sk BETWEEN :skStart AND :skEnd'
518
+ expressionValues[':skStart'] = { S: params.skStart }
519
+ expressionValues[':skEnd'] = { S: params.skEnd }
520
+ }
521
+
522
+ const response = await fetch(endpoint, {
523
+ method: 'POST',
524
+ headers: {
525
+ 'Content-Type': 'application/x-amz-json-1.0',
526
+ 'X-Amz-Target': 'DynamoDB_20120810.Query',
527
+ },
528
+ body: JSON.stringify({
529
+ TableName: tableName,
530
+ KeyConditionExpression: keyCondition,
531
+ ExpressionAttributeValues: expressionValues,
532
+ ...(params.indexName && { IndexName: params.indexName }),
533
+ ...(params.limit && { Limit: params.limit }),
534
+ }),
535
+ })
536
+
537
+ if (!response.ok) {
538
+ throw new Error(`DynamoDB Query failed: ${response.status}`)
539
+ }
540
+
541
+ const result = await response.json() as { Items?: Record<string, unknown>[], LastEvaluatedKey?: Record<string, unknown> }
542
+ return {
543
+ items: (result.Items ?? []).map(unmarshalItem),
544
+ lastKey: result.LastEvaluatedKey ? JSON.stringify(result.LastEvaluatedKey) : undefined,
545
+ }
546
+ },
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Create a D1 storage adapter for SQLite-based storage
552
+ */
553
+ export function createD1Adapter(db: D1Database): StorageAdapter {
554
+ return {
555
+ async putItem(tableName: string, item: Record<string, unknown>): Promise<void> {
556
+ const keys = Object.keys(item)
557
+ const values = Object.values(item)
558
+ const placeholders = keys.map(() => '?').join(', ')
559
+
560
+ await db.prepare(
561
+ `INSERT OR REPLACE INTO ${tableName} (${keys.join(', ')}) VALUES (${placeholders})`,
562
+ ).bind(...values.map(v => typeof v === 'object' ? JSON.stringify(v) : v)).run()
563
+ },
564
+
565
+ async query(tableName: string, params: QueryParams): Promise<QueryResult> {
566
+ let sql = `SELECT * FROM ${tableName} WHERE pk = ?`
567
+ const bindings: unknown[] = [params.pk]
568
+
569
+ if (params.skPrefix) {
570
+ sql += ' AND sk LIKE ?'
571
+ bindings.push(`${params.skPrefix}%`)
572
+ }
573
+ else if (params.skStart && params.skEnd) {
574
+ sql += ' AND sk >= ? AND sk <= ?'
575
+ bindings.push(params.skStart, params.skEnd)
576
+ }
577
+
578
+ if (params.limit) {
579
+ sql += ' LIMIT ?'
580
+ bindings.push(params.limit)
581
+ }
582
+
583
+ const result = await db.prepare(sql).bind(...bindings).all<Record<string, unknown>>()
584
+
585
+ return {
586
+ items: result.results ?? [],
587
+ }
588
+ },
589
+ }
590
+ }
591
+
592
+ // ============================================================================
593
+ // Helpers
594
+ // ============================================================================
595
+
596
+ function handleCors(origins: string[], request: Request): Response {
597
+ const origin = request.headers.get('Origin')
598
+ const allowedOrigin = origins.includes('*') ? '*' : (origin && origins.includes(origin) ? origin : origins[0])
599
+
600
+ return new Response(null, {
601
+ status: 204,
602
+ headers: {
603
+ 'Access-Control-Allow-Origin': allowedOrigin ?? '*',
604
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
605
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
606
+ 'Access-Control-Max-Age': '86400',
607
+ },
608
+ })
609
+ }
610
+
611
+ function jsonResponse(data: unknown, status = 200): Response {
612
+ return new Response(JSON.stringify(data), {
613
+ status,
614
+ headers: { 'Content-Type': 'application/json' },
615
+ })
616
+ }
617
+
618
+ function extractSiteId(path: string, basePath: string, suffix: string): string {
619
+ const prefix = `${basePath}/sites/`
620
+ const start = path.indexOf(prefix) + prefix.length
621
+ const end = path.indexOf(suffix)
622
+ return path.slice(start, end)
623
+ }
624
+
625
+ function determinePeriod(startDate: Date, endDate: Date): AggregationPeriod {
626
+ const diffMs = endDate.getTime() - startDate.getTime()
627
+ const diffDays = diffMs / (1000 * 60 * 60 * 24)
628
+
629
+ if (diffDays <= 2) return 'hour'
630
+ if (diffDays <= 90) return 'day'
631
+ return 'month'
632
+ }
633
+
634
+ function parseUserAgent(ua: string): { deviceType: 'desktop' | 'mobile' | 'tablet' | 'unknown', browser: string, os: string } {
635
+ const deviceType = /mobile/i.test(ua) ? 'mobile' : /tablet/i.test(ua) ? 'tablet' : 'desktop'
636
+
637
+ let browser = 'Unknown'
638
+ if (ua.includes('Chrome')) browser = 'Chrome'
639
+ else if (ua.includes('Firefox')) browser = 'Firefox'
640
+ else if (ua.includes('Safari')) browser = 'Safari'
641
+ else if (ua.includes('Edge')) browser = 'Edge'
642
+
643
+ let os = 'Unknown'
644
+ if (ua.includes('Windows')) os = 'Windows'
645
+ else if (ua.includes('Mac')) os = 'macOS'
646
+ else if (ua.includes('Linux')) os = 'Linux'
647
+ else if (ua.includes('Android')) os = 'Android'
648
+ else if (ua.includes('iOS') || ua.includes('iPhone')) os = 'iOS'
649
+
650
+ return { deviceType, browser, os }
651
+ }
652
+
653
+ function parseReferrerSource(referrer?: string): string | undefined {
654
+ if (!referrer) return 'direct'
655
+ try {
656
+ const url = new URL(referrer)
657
+ const host = url.hostname.toLowerCase()
658
+ if (host.includes('google')) return 'google'
659
+ if (host.includes('bing')) return 'bing'
660
+ if (host.includes('twitter') || host.includes('x.com')) return 'twitter'
661
+ if (host.includes('facebook')) return 'facebook'
662
+ if (host.includes('linkedin')) return 'linkedin'
663
+ return host
664
+ }
665
+ catch {
666
+ return undefined
667
+ }
668
+ }
669
+
670
+ /* eslint-disable prefer-const */
671
+ function generateTrackingScript(siteId: string, apiEndpoint: string): string {
672
+ return `<script>
673
+ (function(w,d,s,a){
674
+ w.analytics=w.analytics||function(){(w.analytics.q=w.analytics.q||[]).push(arguments)};
675
+ var f=d.getElementsByTagName(s)[0],j=d.createElement(s);
676
+ j.async=1;j.src='${apiEndpoint}/sites/${siteId}/script.js';
677
+ f.parentNode.insertBefore(j,f);
678
+ })(window,document,'script');
679
+ analytics('init','${siteId}');
680
+ analytics('track','pageview');
681
+ </script>`
682
+ }
683
+ /* eslint-enable prefer-const */
684
+
685
+ function marshalItem(item: Record<string, unknown>): Record<string, unknown> {
686
+ const result: Record<string, unknown> = {}
687
+ for (const [key, value] of Object.entries(item)) {
688
+ if (value === undefined || value === null) continue
689
+ result[key] = marshalValue(value)
690
+ }
691
+ return result
692
+ }
693
+
694
+ function marshalValue(value: unknown): unknown {
695
+ if (typeof value === 'string') return { S: value }
696
+ if (typeof value === 'number') return { N: String(value) }
697
+ if (typeof value === 'boolean') return { BOOL: value }
698
+ if (Array.isArray(value)) return { L: value.map(marshalValue) }
699
+ if (value instanceof Date) return { S: value.toISOString() }
700
+ if (typeof value === 'object') {
701
+ const m: Record<string, unknown> = {}
702
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
703
+ m[k] = marshalValue(v)
704
+ }
705
+ return { M: m }
706
+ }
707
+ return { S: String(value) }
708
+ }
709
+
710
+ function unmarshalItem(item: Record<string, unknown>): Record<string, unknown> {
711
+ const result: Record<string, unknown> = {}
712
+ for (const [key, value] of Object.entries(item)) {
713
+ result[key] = unmarshalValue(value as Record<string, unknown>)
714
+ }
715
+ return result
716
+ }
717
+
718
+ function unmarshalValue(value: Record<string, unknown>): unknown {
719
+ if ('S' in value) return value.S
720
+ if ('N' in value) return Number(value.N)
721
+ if ('BOOL' in value) return value.BOOL
722
+ if ('L' in value) return (value.L as Record<string, unknown>[]).map(unmarshalValue)
723
+ if ('M' in value) return unmarshalItem(value.M as Record<string, unknown>)
724
+ if ('NULL' in value) return null
725
+ return value
726
+ }
727
+
728
+ // Cloudflare Workers types
729
+ interface ExecutionContext {
730
+ waitUntil: (promise: Promise<unknown>) => void
731
+ passThroughOnException: () => void
732
+ }