@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,589 @@
1
+ /**
2
+ * Hono Integration for Analytics
3
+ *
4
+ * Middleware and route handlers for Hono framework.
5
+ *
6
+ * @example
7
+ * ```ts
8
+ * import { Hono } from 'hono'
9
+ * import { analyticsMiddleware, analyticsRoutes } from '@stacksjs/analytics/integrations/hono'
10
+ *
11
+ * const app = new Hono()
12
+ * app.use('*', analyticsMiddleware())
13
+ * app.route('/api/analytics', analyticsRoutes())
14
+ * ```
15
+ */
16
+
17
+ import type { AnalyticsConfig } from '../config'
18
+ import { getConfig } from '../config'
19
+ import {
20
+ generateId,
21
+ getDailySalt,
22
+ getPeriodStart,
23
+ hashVisitorId,
24
+ KeyPatterns,
25
+ } from '../dynamodb'
26
+ import type { AggregationPeriod, PageView, Session } from '../types'
27
+
28
+ // ============================================================================
29
+ // Types
30
+ // ============================================================================
31
+
32
+ interface HonoContext {
33
+ req: {
34
+ url: string
35
+ method: string
36
+ header: (name: string) => string | undefined
37
+ param: (name: string) => string | undefined
38
+ query: (name: string) => string | undefined
39
+ json: <T>() => Promise<T>
40
+ }
41
+ json: (data: unknown, status?: number) => Response
42
+ text: (data: string, status?: number) => Response
43
+ body: (data: unknown, status?: number) => Response
44
+ set: (name: string, value: unknown) => void
45
+ get: (name: string) => unknown
46
+ }
47
+
48
+ type HonoMiddleware = (c: HonoContext, next: () => Promise<void>) => Promise<Response | void>
49
+
50
+ interface HonoApp {
51
+ use: (path: string, ...handlers: HonoMiddleware[]) => void
52
+ get: (path: string, handler: (c: HonoContext) => Promise<Response> | Response) => void
53
+ post: (path: string, handler: (c: HonoContext) => Promise<Response> | Response) => void
54
+ options: (path: string, handler: (c: HonoContext) => Promise<Response> | Response) => void
55
+ }
56
+
57
+ export interface AnalyticsMiddlewareOptions {
58
+ /** Skip analytics for certain paths */
59
+ skipPaths?: string[]
60
+ /** Custom visitor ID generator */
61
+ getVisitorId?: (c: HonoContext) => Promise<string>
62
+ /** Store for sessions */
63
+ sessionStore?: SessionStore
64
+ /** DynamoDB command executor */
65
+ executeCommand: (cmd: DynamoCommand) => Promise<unknown>
66
+ }
67
+
68
+ interface SessionStore {
69
+ get: (key: string) => Promise<Session | null>
70
+ set: (key: string, session: Session, ttlSeconds?: number) => Promise<void>
71
+ }
72
+
73
+ interface DynamoCommand {
74
+ command: string
75
+ input: Record<string, unknown>
76
+ }
77
+
78
+ interface CollectPayload {
79
+ s: string // siteId
80
+ sid: string // sessionId
81
+ e: 'pageview' | 'event' | 'outbound'
82
+ p?: Record<string, unknown> // properties
83
+ u: string // url
84
+ r?: string // referrer
85
+ t?: string // title
86
+ sw?: number // screen width
87
+ sh?: number // screen height
88
+ }
89
+
90
+ // ============================================================================
91
+ // Middleware
92
+ // ============================================================================
93
+
94
+ /**
95
+ * Create analytics middleware for Hono
96
+ *
97
+ * Adds analytics context to requests and handles CORS.
98
+ */
99
+ export function analyticsMiddleware(options?: Partial<AnalyticsMiddlewareOptions>): HonoMiddleware {
100
+ const config = getConfig()
101
+
102
+ return async (c, next) => {
103
+ // Add CORS headers
104
+ const origin = c.req.header('Origin')
105
+ const allowedOrigin = config.api.corsOrigins.includes('*')
106
+ ? '*'
107
+ : (origin && config.api.corsOrigins.includes(origin) ? origin : config.api.corsOrigins[0])
108
+
109
+ c.set('Access-Control-Allow-Origin', allowedOrigin || '*')
110
+ c.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS')
111
+ c.set('Access-Control-Allow-Headers', 'Content-Type, Authorization')
112
+
113
+ // Handle preflight
114
+ if (c.req.method === 'OPTIONS') {
115
+ return c.body(null, 204)
116
+ }
117
+
118
+ // Skip certain paths
119
+ if (options?.skipPaths) {
120
+ const url = new URL(c.req.url)
121
+ for (const skipPath of options.skipPaths) {
122
+ if (url.pathname.startsWith(skipPath)) {
123
+ return next()
124
+ }
125
+ }
126
+ }
127
+
128
+ // Add analytics context
129
+ c.set('analyticsConfig', config)
130
+ c.set('analyticsSalt', getDailySalt())
131
+
132
+ await next()
133
+ }
134
+ }
135
+
136
+ // ============================================================================
137
+ // Route Handlers
138
+ // ============================================================================
139
+
140
+ /**
141
+ * Create analytics routes for Hono
142
+ */
143
+ export function createAnalyticsRoutes(options: AnalyticsMiddlewareOptions): {
144
+ collect: (c: HonoContext) => Promise<Response>
145
+ stats: (c: HonoContext) => Promise<Response>
146
+ realtime: (c: HonoContext) => Promise<Response>
147
+ script: (c: HonoContext) => Response
148
+ sites: (c: HonoContext) => Promise<Response>
149
+ createSite: (c: HonoContext) => Promise<Response>
150
+ } {
151
+ const config = getConfig()
152
+ const { executeCommand, sessionStore } = options
153
+
154
+ return {
155
+ /**
156
+ * POST /collect - Receive tracking events
157
+ */
158
+ async collect(c) {
159
+ try {
160
+ const payload = await c.req.json<CollectPayload>()
161
+
162
+ if (!payload?.s || !payload?.e || !payload?.u) {
163
+ return c.json({ error: 'Missing required fields: s, e, u' }, 400)
164
+ }
165
+
166
+ // Get visitor ID
167
+ const ip = c.req.header('X-Forwarded-For') || c.req.header('CF-Connecting-IP') || 'unknown'
168
+ const userAgent = c.req.header('User-Agent') || 'unknown'
169
+ const salt = getDailySalt()
170
+
171
+ const visitorId = options.getVisitorId
172
+ ? await options.getVisitorId(c)
173
+ : await hashVisitorId(ip, userAgent, payload.s, salt)
174
+
175
+ // Parse URL
176
+ let parsedUrl: URL
177
+ try {
178
+ parsedUrl = new URL(payload.u)
179
+ }
180
+ catch {
181
+ return c.json({ error: 'Invalid URL' }, 400)
182
+ }
183
+
184
+ const timestamp = new Date()
185
+ const sessionId = payload.sid
186
+
187
+ // Get or create session
188
+ let session = sessionStore ? await sessionStore.get(`${payload.s}:${sessionId}`) : null
189
+ const isNewSession = !session
190
+
191
+ if (payload.e === 'pageview') {
192
+ const deviceInfo = parseUserAgent(userAgent)
193
+ const referrerSource = parseReferrerSource(payload.r)
194
+
195
+ const pageView: PageView = {
196
+ id: generateId(),
197
+ siteId: payload.s,
198
+ visitorId,
199
+ sessionId,
200
+ path: parsedUrl.pathname,
201
+ hostname: parsedUrl.hostname,
202
+ title: payload.t,
203
+ referrer: payload.r,
204
+ referrerSource,
205
+ utmSource: parsedUrl.searchParams.get('utm_source') || undefined,
206
+ utmMedium: parsedUrl.searchParams.get('utm_medium') || undefined,
207
+ utmCampaign: parsedUrl.searchParams.get('utm_campaign') || undefined,
208
+ deviceType: deviceInfo.deviceType,
209
+ browser: deviceInfo.browser,
210
+ os: deviceInfo.os,
211
+ screenWidth: payload.sw,
212
+ screenHeight: payload.sh,
213
+ isUnique: isNewSession,
214
+ isBounce: isNewSession,
215
+ timestamp,
216
+ }
217
+
218
+ // Store page view
219
+ await executeCommand({
220
+ command: 'PutItem',
221
+ input: {
222
+ TableName: config.table.tableName,
223
+ Item: marshalPageView(pageView),
224
+ },
225
+ })
226
+
227
+ // Update session
228
+ if (session) {
229
+ session.pageViewCount += 1
230
+ session.exitPath = parsedUrl.pathname
231
+ session.endedAt = timestamp
232
+ session.isBounce = false
233
+ session.duration = timestamp.getTime() - session.startedAt.getTime()
234
+ }
235
+ else {
236
+ session = {
237
+ id: sessionId,
238
+ siteId: payload.s,
239
+ visitorId,
240
+ entryPath: parsedUrl.pathname,
241
+ exitPath: parsedUrl.pathname,
242
+ referrer: payload.r,
243
+ referrerSource,
244
+ deviceType: deviceInfo.deviceType,
245
+ browser: deviceInfo.browser,
246
+ os: deviceInfo.os,
247
+ pageViewCount: 1,
248
+ eventCount: 0,
249
+ isBounce: true,
250
+ duration: 0,
251
+ startedAt: timestamp,
252
+ endedAt: timestamp,
253
+ }
254
+ }
255
+
256
+ // Store session
257
+ await executeCommand({
258
+ command: 'PutItem',
259
+ input: {
260
+ TableName: config.table.tableName,
261
+ Item: marshalSession(session),
262
+ },
263
+ })
264
+
265
+ if (sessionStore) {
266
+ await sessionStore.set(`${payload.s}:${sessionId}`, session, 1800)
267
+ }
268
+ }
269
+
270
+ return c.body(null, 204)
271
+ }
272
+ catch (error) {
273
+ console.error('Collect error:', error)
274
+ return c.json({ error: 'Internal server error' }, 500)
275
+ }
276
+ },
277
+
278
+ /**
279
+ * GET /sites/:siteId/stats - Get dashboard stats
280
+ */
281
+ async stats(c) {
282
+ try {
283
+ const siteId = c.req.param('siteId')
284
+ const start = c.req.query('start')
285
+ const end = c.req.query('end')
286
+
287
+ if (!siteId) {
288
+ return c.json({ error: 'Missing siteId' }, 400)
289
+ }
290
+
291
+ const startDate = start ? new Date(start) : new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
292
+ const endDate = end ? new Date(end) : new Date()
293
+ const period = determinePeriod(startDate, endDate)
294
+ const _periodStart = getPeriodStart(endDate, period)
295
+
296
+ const result = await executeCommand({
297
+ command: 'Query',
298
+ input: {
299
+ TableName: config.table.tableName,
300
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :skPrefix)',
301
+ ExpressionAttributeValues: {
302
+ ':pk': { S: KeyPatterns.site.pk(siteId) },
303
+ ':skPrefix': { S: `STATS#${period.toUpperCase()}` },
304
+ },
305
+ },
306
+ }) as { Items?: unknown[] }
307
+
308
+ return c.json({
309
+ stats: result.Items || [],
310
+ dateRange: {
311
+ start: startDate.toISOString(),
312
+ end: endDate.toISOString(),
313
+ period,
314
+ },
315
+ })
316
+ }
317
+ catch (error) {
318
+ console.error('Stats error:', error)
319
+ return c.json({ error: 'Failed to get stats' }, 500)
320
+ }
321
+ },
322
+
323
+ /**
324
+ * GET /sites/:siteId/realtime - Get realtime stats
325
+ */
326
+ async realtime(c) {
327
+ try {
328
+ const siteId = c.req.param('siteId')
329
+ const minutes = Number.parseInt(c.req.query('minutes') || '5', 10)
330
+
331
+ if (!siteId) {
332
+ return c.json({ error: 'Missing siteId' }, 400)
333
+ }
334
+
335
+ const now = new Date()
336
+ const startMinute = new Date(now.getTime() - minutes * 60 * 1000)
337
+
338
+ const result = await executeCommand({
339
+ command: 'Query',
340
+ input: {
341
+ TableName: config.table.tableName,
342
+ KeyConditionExpression: 'pk = :pk AND sk BETWEEN :skStart AND :skEnd',
343
+ ExpressionAttributeValues: {
344
+ ':pk': { S: KeyPatterns.site.pk(siteId) },
345
+ ':skStart': { S: KeyPatterns.realtime.sk(startMinute.toISOString().slice(0, 16)) },
346
+ ':skEnd': { S: KeyPatterns.realtime.sk(now.toISOString().slice(0, 16)) },
347
+ },
348
+ },
349
+ }) as { Items?: unknown[] }
350
+
351
+ return c.json({
352
+ realtime: result.Items || [],
353
+ timestamp: now.toISOString(),
354
+ })
355
+ }
356
+ catch (error) {
357
+ console.error('Realtime error:', error)
358
+ return c.json({ error: 'Failed to get realtime stats' }, 500)
359
+ }
360
+ },
361
+
362
+ /**
363
+ * GET /sites/:siteId/script - Get tracking script
364
+ */
365
+ script(c) {
366
+ const siteId = c.req.param('siteId')
367
+ const apiEndpoint = c.req.query('api') || `${c.req.header('Host')}${config.api.basePath}`
368
+
369
+ if (!siteId) {
370
+ return c.json({ error: 'Missing siteId' }, 400)
371
+ }
372
+
373
+ const script = generateTrackingScript(siteId, apiEndpoint)
374
+
375
+ c.set('Content-Type', 'text/html')
376
+ c.set('Cache-Control', 'public, max-age=3600')
377
+ return c.text(script)
378
+ },
379
+
380
+ /**
381
+ * GET /sites - List sites
382
+ */
383
+ async sites(c) {
384
+ try {
385
+ const ownerId = c.req.query('ownerId') || 'default'
386
+
387
+ const result = await executeCommand({
388
+ command: 'Query',
389
+ input: {
390
+ TableName: config.table.tableName,
391
+ IndexName: 'gsi1',
392
+ KeyConditionExpression: 'gsi1pk = :pk',
393
+ ExpressionAttributeValues: {
394
+ ':pk': { S: `OWNER#${ownerId}` },
395
+ },
396
+ },
397
+ }) as { Items?: unknown[] }
398
+
399
+ return c.json({ sites: result.Items || [] })
400
+ }
401
+ catch (error) {
402
+ console.error('List sites error:', error)
403
+ return c.json({ error: 'Failed to list sites' }, 500)
404
+ }
405
+ },
406
+
407
+ /**
408
+ * POST /sites - Create a site
409
+ */
410
+ async createSite(c) {
411
+ try {
412
+ const body = await c.req.json<{ name: string, domains: string[], ownerId?: string }>()
413
+
414
+ if (!body.name || !body.domains?.length) {
415
+ return c.json({ error: 'Missing required fields: name, domains' }, 400)
416
+ }
417
+
418
+ const siteId = generateId()
419
+ const now = new Date()
420
+
421
+ const site = {
422
+ id: siteId,
423
+ name: body.name,
424
+ domains: body.domains,
425
+ timezone: 'UTC',
426
+ isActive: true,
427
+ ownerId: body.ownerId || 'default',
428
+ createdAt: now.toISOString(),
429
+ updatedAt: now.toISOString(),
430
+ }
431
+
432
+ await executeCommand({
433
+ command: 'PutItem',
434
+ input: {
435
+ TableName: config.table.tableName,
436
+ Item: {
437
+ pk: { S: KeyPatterns.site.pk(siteId) },
438
+ sk: { S: KeyPatterns.site.sk(siteId) },
439
+ gsi1pk: { S: KeyPatterns.site.gsi1pk(site.ownerId) },
440
+ gsi1sk: { S: KeyPatterns.site.gsi1sk(siteId) },
441
+ ...marshalSite(site),
442
+ },
443
+ },
444
+ })
445
+
446
+ return c.json({ site }, 201)
447
+ }
448
+ catch (error) {
449
+ console.error('Create site error:', error)
450
+ return c.json({ error: 'Failed to create site' }, 500)
451
+ }
452
+ },
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Mount analytics routes on a Hono app
458
+ */
459
+ export function mountAnalyticsRoutes(app: HonoApp, basePath: string, options: AnalyticsMiddlewareOptions): void {
460
+ const routes = createAnalyticsRoutes(options)
461
+
462
+ app.post(`${basePath}/collect`, routes.collect)
463
+ app.get(`${basePath}/sites/:siteId/stats`, routes.stats)
464
+ app.get(`${basePath}/sites/:siteId/realtime`, routes.realtime)
465
+ app.get(`${basePath}/sites/:siteId/script`, routes.script)
466
+ app.get(`${basePath}/sites`, routes.sites)
467
+ app.post(`${basePath}/sites`, routes.createSite)
468
+ }
469
+
470
+ // ============================================================================
471
+ // Helpers
472
+ // ============================================================================
473
+
474
+ function determinePeriod(startDate: Date, endDate: Date): AggregationPeriod {
475
+ const diffMs = endDate.getTime() - startDate.getTime()
476
+ const diffDays = diffMs / (1000 * 60 * 60 * 24)
477
+
478
+ if (diffDays <= 2) return 'hour'
479
+ if (diffDays <= 90) return 'day'
480
+ return 'month'
481
+ }
482
+
483
+ function parseUserAgent(ua: string): { deviceType: 'desktop' | 'mobile' | 'tablet' | 'unknown', browser: string, os: string } {
484
+ const deviceType = /mobile/i.test(ua) ? 'mobile' : /tablet/i.test(ua) ? 'tablet' : 'desktop'
485
+
486
+ let browser = 'Unknown'
487
+ if (ua.includes('Chrome')) browser = 'Chrome'
488
+ else if (ua.includes('Firefox')) browser = 'Firefox'
489
+ else if (ua.includes('Safari')) browser = 'Safari'
490
+ else if (ua.includes('Edge')) browser = 'Edge'
491
+
492
+ let os = 'Unknown'
493
+ if (ua.includes('Windows')) os = 'Windows'
494
+ else if (ua.includes('Mac')) os = 'macOS'
495
+ else if (ua.includes('Linux')) os = 'Linux'
496
+ else if (ua.includes('Android')) os = 'Android'
497
+ else if (ua.includes('iOS') || ua.includes('iPhone')) os = 'iOS'
498
+
499
+ return { deviceType, browser, os }
500
+ }
501
+
502
+ function parseReferrerSource(referrer?: string): string | undefined {
503
+ if (!referrer) return 'direct'
504
+ try {
505
+ const url = new URL(referrer)
506
+ const host = url.hostname.toLowerCase()
507
+ if (host.includes('google')) return 'google'
508
+ if (host.includes('bing')) return 'bing'
509
+ if (host.includes('twitter') || host.includes('x.com')) return 'twitter'
510
+ if (host.includes('facebook')) return 'facebook'
511
+ if (host.includes('linkedin')) return 'linkedin'
512
+ return host
513
+ }
514
+ catch {
515
+ return undefined
516
+ }
517
+ }
518
+
519
+ /* eslint-disable prefer-const */
520
+ function generateTrackingScript(siteId: string, apiEndpoint: string): string {
521
+ return `<script>
522
+ (function(w,d,s,a){
523
+ w.analytics=w.analytics||function(){(w.analytics.q=w.analytics.q||[]).push(arguments)};
524
+ var f=d.getElementsByTagName(s)[0],j=d.createElement(s);
525
+ j.async=1;j.src='${apiEndpoint}/sites/${siteId}/script.js';
526
+ f.parentNode.insertBefore(j,f);
527
+ })(window,document,'script');
528
+ analytics('init','${siteId}');
529
+ analytics('track','pageview');
530
+ </script>`
531
+ }
532
+
533
+ function marshalPageView(pv: PageView): Record<string, { S: string } | { N: string } | { BOOL: boolean }> {
534
+ return {
535
+ pk: { S: KeyPatterns.pageView.pk(pv.siteId) },
536
+ sk: { S: KeyPatterns.pageView.sk(pv.timestamp, pv.id) },
537
+ id: { S: pv.id },
538
+ siteId: { S: pv.siteId },
539
+ visitorId: { S: pv.visitorId },
540
+ sessionId: { S: pv.sessionId },
541
+ path: { S: pv.path },
542
+ hostname: { S: pv.hostname },
543
+ ...(pv.title && { title: { S: pv.title } }),
544
+ ...(pv.referrer && { referrer: { S: pv.referrer } }),
545
+ ...(pv.referrerSource && { referrerSource: { S: pv.referrerSource } }),
546
+ ...(pv.deviceType && { deviceType: { S: pv.deviceType } }),
547
+ ...(pv.browser && { browser: { S: pv.browser } }),
548
+ ...(pv.os && { os: { S: pv.os } }),
549
+ isUnique: { BOOL: pv.isUnique },
550
+ isBounce: { BOOL: pv.isBounce },
551
+ timestamp: { S: pv.timestamp.toISOString() },
552
+ }
553
+ }
554
+
555
+ function marshalSession(s: Session): Record<string, { S: string } | { N: string } | { BOOL: boolean }> {
556
+ return {
557
+ pk: { S: KeyPatterns.session.pk(s.siteId) },
558
+ sk: { S: KeyPatterns.session.sk(s.id) },
559
+ id: { S: s.id },
560
+ siteId: { S: s.siteId },
561
+ visitorId: { S: s.visitorId },
562
+ entryPath: { S: s.entryPath },
563
+ exitPath: { S: s.exitPath },
564
+ ...(s.referrer && { referrer: { S: s.referrer } }),
565
+ ...(s.referrerSource && { referrerSource: { S: s.referrerSource } }),
566
+ ...(s.deviceType && { deviceType: { S: s.deviceType } }),
567
+ ...(s.browser && { browser: { S: s.browser } }),
568
+ ...(s.os && { os: { S: s.os } }),
569
+ pageViewCount: { N: String(s.pageViewCount) },
570
+ eventCount: { N: String(s.eventCount) },
571
+ isBounce: { BOOL: s.isBounce },
572
+ duration: { N: String(s.duration) },
573
+ startedAt: { S: s.startedAt.toISOString() },
574
+ endedAt: { S: s.endedAt.toISOString() },
575
+ }
576
+ }
577
+
578
+ function marshalSite(site: Record<string, unknown>): Record<string, { S: string } | { L: Array<{ S: string }> } | { BOOL: boolean }> {
579
+ return {
580
+ id: { S: site.id as string },
581
+ name: { S: site.name as string },
582
+ domains: { L: (site.domains as string[]).map(d => ({ S: d })) },
583
+ timezone: { S: site.timezone as string },
584
+ isActive: { BOOL: site.isActive as boolean },
585
+ ownerId: { S: site.ownerId as string },
586
+ createdAt: { S: site.createdAt as string },
587
+ updatedAt: { S: site.updatedAt as string },
588
+ }
589
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Framework Integrations
3
+ *
4
+ * Pre-built integrations for popular frameworks.
5
+ */
6
+
7
+ export {
8
+ analyticsMiddleware,
9
+ createAnalyticsRoutes,
10
+ mountAnalyticsRoutes,
11
+ type AnalyticsMiddlewareOptions,
12
+ } from './hono'
13
+
14
+ export {
15
+ createAnalyticsHandler,
16
+ createD1Adapter,
17
+ type CloudflareEnv,
18
+ type CloudflareHandlerOptions,
19
+ type StorageAdapter,
20
+ } from './cloudflare'
21
+
22
+ export {
23
+ type StxHeadScript,
24
+ tsAnalytics,
25
+ type TsAnalyticsOptions,
26
+ tsAnalyticsTag,
27
+ } from './stx'
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Nuxt Module — add ts-analytics to a Nuxt app the way you'd add `nuxt-fathom`.
3
+ *
4
+ * ```ts
5
+ * // nuxt.config.ts
6
+ * export default defineNuxtConfig({
7
+ * modules: ['@stacksjs/ts-analytics/nuxt'],
8
+ * tsAnalytics: {
9
+ * appId: 'APP_ID', // that's it — the endpoint is baked in (Fathom-style)
10
+ * },
11
+ * })
12
+ * ```
13
+ *
14
+ * Behind the scenes it injects ts-analytics' shared `/script.js` into `<head>`
15
+ * (keyed by your App ID via `data-site`). That tracker self-reports pageviews,
16
+ * **SPA route changes** (it hooks `history.pushState`/`replaceState` — i.e.
17
+ * vue-router/Nuxt navigation, so client-side route changes are tracked with no
18
+ * router wiring), link clicks, engagement, Core Web Vitals and JS errors,
19
+ * POSTing them to `<apiEndpoint>/collect`. It derives the collector from its own
20
+ * `src` origin and the App ID from `data-site`, so the only config a host app
21
+ * supplies is `appId` (the endpoint is baked in; override via `apiEndpoint` or
22
+ * the `TS_ANALYTICS_ENDPOINT` env var).
23
+ *
24
+ * For custom events, the auto-imported `useTsAnalytics()` composable wraps the
25
+ * tracker's global (`window.fathom`, Fathom-API compatible).
26
+ *
27
+ * Requires `@nuxt/kit` (provided by any Nuxt project; declared as an optional
28
+ * peer so non-Nuxt consumers of @stacksjs/ts-analytics don't pull it in).
29
+ */
30
+ import type { Nuxt, NuxtModule } from '@nuxt/schema'
31
+ import { addImports, createResolver, defineNuxtModule } from '@nuxt/kit'
32
+ import { resolveApiEndpoint, tsAnalytics, type TsAnalyticsOptions } from './stx'
33
+
34
+ export interface TsAnalyticsNuxtOptions extends TsAnalyticsOptions {
35
+ /** Set false to skip injecting the tracker (e.g. in local dev). Default: true. */
36
+ enabled?: boolean
37
+ }
38
+
39
+ const tsAnalyticsNuxtModule: NuxtModule<TsAnalyticsNuxtOptions> = defineNuxtModule<TsAnalyticsNuxtOptions>({
40
+ meta: {
41
+ name: '@stacksjs/ts-analytics',
42
+ configKey: 'tsAnalytics',
43
+ compatibility: { nuxt: '>=3.0.0' },
44
+ },
45
+ defaults: { enabled: true } as Partial<TsAnalyticsNuxtOptions>,
46
+ setup(options: TsAnalyticsNuxtOptions, nuxt: Nuxt) {
47
+ if (options.enabled === false)
48
+ return
49
+
50
+ // tsAnalytics() returns [] when appId is missing — so a half-configured env
51
+ // never injects a broken tag.
52
+ const scripts = tsAnalytics(options)
53
+ if (scripts.length === 0) {
54
+ console.warn('[ts-analytics] module skipped — `appId` is required in nuxt.config `tsAnalytics`.')
55
+ return
56
+ }
57
+
58
+ // Inject the shared tracker into <head> on every page.
59
+ nuxt.options.app.head.script = nuxt.options.app.head.script || []
60
+ nuxt.options.app.head.script.push(...(scripts as unknown[] as NonNullable<typeof nuxt.options.app.head.script>))
61
+
62
+ // Surface non-secret config for the composable / debugging.
63
+ ;(nuxt.options.runtimeConfig.public as Record<string, unknown>).tsAnalytics = {
64
+ appId: options.appId,
65
+ apiEndpoint: resolveApiEndpoint(options.apiEndpoint),
66
+ }
67
+
68
+ // Auto-import useTsAnalytics() for custom events.
69
+ const resolver = createResolver(import.meta.url)
70
+ addImports({
71
+ name: 'useTsAnalytics',
72
+ as: 'useTsAnalytics',
73
+ from: resolver.resolve('./runtime/use-ts-analytics'),
74
+ })
75
+ },
76
+ })
77
+
78
+ export default tsAnalyticsNuxtModule