@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,390 @@
1
+ /**
2
+ * View handlers for HTML pages
3
+ *
4
+ * Serves pre-built HTML templates with runtime placeholder replacement.
5
+ * Falls back to STX processing for local development.
6
+ */
7
+
8
+ import { generateTrackingScript, generateMinimalTrackingScript, getConfig } from '../index'
9
+ import { htmlResponse, jsResponse } from '../utils/response'
10
+ import { getQueryParams, getLambdaEvent } from '../../deploy/lambda-adapter'
11
+ import { injectQueryPreservationScript } from '@stacksjs/bun-router'
12
+ import { requireAuth } from './auth'
13
+ import path from 'node:path'
14
+ import fs from 'node:fs'
15
+
16
+ // Query preservation config for dashboard pages
17
+ const queryPreservationConfig = {
18
+ enabled: true,
19
+ preserve: ['siteId'],
20
+ exclude: ['_t', '_cache', 'callback'],
21
+ routes: ['/dashboard', '/dashboard/*'],
22
+ }
23
+
24
+ // Pre-built views directory - check multiple locations in order of priority
25
+ // 1. Lambda deployment: /var/task/views/ (Lambda working directory)
26
+ // 2. Bundled location: views/ relative to this file
27
+ // 3. Development: dist/views/ from project root
28
+ function findViewsDir(): string {
29
+ const candidates = [
30
+ '/var/task/views', // Lambda deployment
31
+ path.resolve(import.meta.dir, './views'), // Relative to bundled file
32
+ path.resolve(import.meta.dir, '../../dist/views'), // Development
33
+ path.resolve(process.cwd(), 'views'), // Current working directory
34
+ path.resolve(process.cwd(), 'dist/views'), // CWD dist
35
+ ]
36
+
37
+ for (const dir of candidates) {
38
+ try {
39
+ if (fs.existsSync(path.join(dir, 'dashboard/index.html'))) {
40
+ return dir
41
+ }
42
+ }
43
+ catch {
44
+ // Continue to next candidate
45
+ }
46
+ }
47
+
48
+ // Fallback to source views for STX processing
49
+ return path.resolve(import.meta.dir, '../views')
50
+ }
51
+
52
+ const PREBUILT_DIR = findViewsDir()
53
+
54
+ // Source views directory (for development fallback with STX processing)
55
+ const VIEWS_DIR = path.resolve(import.meta.dir, '../views')
56
+
57
+ // Check if pre-built views exist
58
+ const hasPrebuiltViews = fs.existsSync(path.join(PREBUILT_DIR, 'dashboard/index.html'))
59
+
60
+ // Placeholder tokens used in pre-built HTML
61
+ const PLACEHOLDERS = {
62
+ siteId: '{{__SITE_ID__}}',
63
+ apiEndpoint: '{{__API_ENDPOINT__}}',
64
+ errorId: '{{__ERROR_ID__}}',
65
+ section: '{{__SECTION__}}',
66
+ title: '{{__TITLE__}}',
67
+ iconPath: '{{__ICON_PATH__}}',
68
+ }
69
+
70
+ /**
71
+ * Replace placeholders in pre-built HTML
72
+ */
73
+ function replacePlaceholders(html: string, values: Record<string, string>): string {
74
+ let result = html
75
+ for (const [key, placeholder] of Object.entries(PLACEHOLDERS)) {
76
+ if (values[key] !== undefined) {
77
+ result = result.replaceAll(placeholder, values[key])
78
+ }
79
+ }
80
+ return result
81
+ }
82
+
83
+ /**
84
+ * Load and process a pre-built view
85
+ */
86
+ async function loadPrebuiltView(name: string, values: Record<string, string>): Promise<string> {
87
+ const filePath = path.join(PREBUILT_DIR, `${name}.html`)
88
+ const html = await Bun.file(filePath).text()
89
+ return replacePlaceholders(html, values)
90
+ }
91
+
92
+ /**
93
+ * Fallback: Render STX template directly (for development)
94
+ */
95
+ async function renderStxDirect(templateName: string, props: Record<string, unknown> = {}): Promise<string> {
96
+ // Dynamic import to avoid bundling STX in Lambda
97
+ const { processDirectives, extractVariables, defaultConfig } = await import('@stacksjs/stx')
98
+
99
+ const templatePath = path.join(VIEWS_DIR, templateName)
100
+ const content = await Bun.file(templatePath).text()
101
+
102
+ // Extract script content and template
103
+ const scriptMatch = content.match(/<script\s+server\s*>([\s\S]*?)<\/script>/i)
104
+ const scriptContent = scriptMatch ? scriptMatch[1] : ''
105
+ let templateContent = scriptMatch
106
+ ? content.replace(/<script\s+server\s*>[\s\S]*?<\/script>/i, '')
107
+ : content
108
+
109
+ // Replace <script client> with regular <script> for output
110
+ templateContent = templateContent.replace(/<script\s+client\s*>/gi, '<script>')
111
+
112
+ // Preserve external <script src> tags from STX validation (re-injected after processing)
113
+ const externalScripts: string[] = []
114
+ templateContent = templateContent.replace(/<script\s+([^>]*src\s*=\s*[^>]+)><\/script>/gi, (match) => {
115
+ externalScripts.push(match)
116
+ return `<!--STX_EXTERNAL_SCRIPT_${externalScripts.length - 1}-->`
117
+ })
118
+
119
+ // Build context with props
120
+ const context: Record<string, unknown> = {
121
+ __filename: templatePath,
122
+ __dirname: path.dirname(templatePath),
123
+ props,
124
+ ...props,
125
+ }
126
+
127
+ // Extract variables from server script
128
+ if (scriptContent) {
129
+ await extractVariables(scriptContent, context, templatePath)
130
+ }
131
+
132
+ // Process STX directives
133
+ const config = {
134
+ ...defaultConfig,
135
+ componentsDir: path.resolve(import.meta.dir, '../components'),
136
+ layoutsDir: path.join(VIEWS_DIR, 'layouts'),
137
+ partialsDir: path.join(VIEWS_DIR, 'partials'),
138
+ }
139
+
140
+ let result = await processDirectives(templateContent, context, templatePath, config, new Set())
141
+
142
+ // Re-inject external script tags
143
+ for (let i = 0; i < externalScripts.length; i++) {
144
+ result = result.replace(`<!--STX_EXTERNAL_SCRIPT_${i}-->`, externalScripts[i])
145
+ }
146
+
147
+ return result
148
+ }
149
+
150
+ /**
151
+ * GET /dashboard or /
152
+ */
153
+ export async function handleDashboard(request: Request): Promise<Response> {
154
+ const authResult = await requireAuth(request)
155
+ if (authResult instanceof Response) return authResult
156
+
157
+ const query = getQueryParams(request)
158
+ const event = getLambdaEvent(request)
159
+ const siteId = query.siteId || ''
160
+ const apiEndpoint = `https://${event?.requestContext?.domainName || 'analytics.stacksjs.com'}`
161
+
162
+ let html: string
163
+ if (hasPrebuiltViews) {
164
+ html = await loadPrebuiltView('dashboard/index', { siteId, apiEndpoint })
165
+ }
166
+ else {
167
+ html = await renderStxDirect('dashboard/index.stx', { siteId, apiEndpoint })
168
+ }
169
+
170
+ // Inject query preservation script for siteId
171
+ html = injectQueryPreservationScript(html, queryPreservationConfig)
172
+
173
+ return htmlResponse(html)
174
+ }
175
+
176
+ /**
177
+ * GET /dashboard/{tab} - Serve individual tab pages
178
+ */
179
+ export async function handleDashboardTab(request: Request, tab: string): Promise<Response> {
180
+ const authResult = await requireAuth(request)
181
+ if (authResult instanceof Response) return authResult
182
+
183
+ const query = getQueryParams(request)
184
+ const event = getLambdaEvent(request)
185
+ const siteId = query.siteId || ''
186
+ const apiEndpoint = `https://${event?.requestContext?.domainName || 'analytics.stacksjs.com'}`
187
+
188
+ // Valid tabs that have their own pages
189
+ const validTabs = [
190
+ 'errors', 'sessions', 'vitals', 'live', 'funnels', 'flow', 'insights', 'settings',
191
+ 'pages', 'referrers', 'devices', 'browsers', 'countries', 'campaigns', 'events', 'goals'
192
+ ]
193
+
194
+ if (!validTabs.includes(tab)) {
195
+ // Fallback to main dashboard for unknown tabs
196
+ return handleDashboard(request)
197
+ }
198
+
199
+ let html: string
200
+ if (hasPrebuiltViews) {
201
+ html = await loadPrebuiltView(`dashboard/${tab}`, { siteId, apiEndpoint })
202
+ }
203
+ else {
204
+ html = await renderStxDirect(`dashboard/${tab}.stx`, { siteId, apiEndpoint })
205
+ }
206
+
207
+ // Inject query preservation script for siteId
208
+ html = injectQueryPreservationScript(html, queryPreservationConfig)
209
+
210
+ return htmlResponse(html)
211
+ }
212
+
213
+ /**
214
+ * GET /test-errors
215
+ */
216
+ export async function handleTestErrors(request: Request): Promise<Response> {
217
+ const query = getQueryParams(request)
218
+ const event = getLambdaEvent(request)
219
+ const siteId = query.siteId || 'test-site'
220
+ const apiEndpoint = `https://${event?.requestContext?.domainName || 'analytics.stacksjs.com'}`
221
+
222
+ let html: string
223
+ if (hasPrebuiltViews) {
224
+ html = await loadPrebuiltView('test-errors', { siteId, apiEndpoint })
225
+ }
226
+ else {
227
+ html = await renderStxDirect('test-errors.stx', { siteId, apiEndpoint })
228
+ }
229
+
230
+ return htmlResponse(html)
231
+ }
232
+
233
+ /**
234
+ * GET /errors/{errorId}
235
+ */
236
+ export async function handleErrorDetailPage(request: Request, errorId: string): Promise<Response> {
237
+ const authResult = await requireAuth(request)
238
+ if (authResult instanceof Response) return authResult
239
+
240
+ const query = getQueryParams(request)
241
+ const event = getLambdaEvent(request)
242
+ const siteId = query.siteId || ''
243
+ const apiEndpoint = `https://${event?.requestContext?.domainName || 'analytics.stacksjs.com'}`
244
+
245
+ let html: string
246
+ if (hasPrebuiltViews) {
247
+ html = await loadPrebuiltView('error-detail', { errorId, siteId, apiEndpoint })
248
+ }
249
+ else {
250
+ html = await renderStxDirect('error-detail.stx', { errorId, siteId, apiEndpoint })
251
+ }
252
+
253
+ // Inject query preservation script for siteId
254
+ html = injectQueryPreservationScript(html, queryPreservationConfig)
255
+
256
+ return htmlResponse(html)
257
+ }
258
+
259
+ /**
260
+ * GET /dashboard/{section}
261
+ */
262
+ export async function handleDetailPage(request: Request, section: string): Promise<Response> {
263
+ const query = getQueryParams(request)
264
+ const event = getLambdaEvent(request)
265
+ const siteId = query.siteId || ''
266
+ const apiEndpoint = `https://${event?.requestContext?.domainName || 'analytics.stacksjs.com'}`
267
+
268
+ const titles: Record<string, string> = {
269
+ pages: 'Pages',
270
+ referrers: 'Referrers',
271
+ devices: 'Devices',
272
+ browsers: 'Browsers',
273
+ countries: 'Countries',
274
+ campaigns: 'Campaigns',
275
+ events: 'Events',
276
+ goals: 'Goals',
277
+ }
278
+
279
+ const icons: Record<string, string> = {
280
+ pages: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>',
281
+ referrers: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>',
282
+ devices: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>',
283
+ browsers: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>',
284
+ countries: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>',
285
+ campaigns: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z"/>',
286
+ events: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122"/>',
287
+ goals: '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>',
288
+ }
289
+
290
+ const title = titles[section] || section
291
+ const iconPath = icons[section] || icons.pages
292
+
293
+ let html: string
294
+ if (hasPrebuiltViews) {
295
+ html = await loadPrebuiltView('detail', { section, siteId, apiEndpoint, title, iconPath })
296
+ }
297
+ else {
298
+ html = await renderStxDirect('detail.stx', { section, siteId, apiEndpoint, title, iconPath })
299
+ }
300
+
301
+ // Inject query preservation script for siteId
302
+ html = injectQueryPreservationScript(html, queryPreservationConfig)
303
+
304
+ return htmlResponse(html)
305
+ }
306
+
307
+ /**
308
+ * GET /sites/{siteId}/script
309
+ */
310
+ export async function handleScript(request: Request): Promise<Response> {
311
+ const query = getQueryParams(request)
312
+ const event = getLambdaEvent(request)
313
+ const minimal = query.minimal === 'true'
314
+ const url = new URL(request.url)
315
+
316
+ // Where /collect lives: explicit ?api= override, else the deployed domain
317
+ // (Lambda), else the request origin (covers local dev + self-hosted).
318
+ const apiEndpoint = query.api
319
+ || (event?.requestContext?.domainName ? `https://${event.requestContext.domainName}` : url.origin)
320
+
321
+ // Extract siteId from path
322
+ const pathMatch = url.pathname.match(/\/sites\/([^/]+)\/script/)
323
+ const siteId = pathMatch?.[1] || 'unknown'
324
+
325
+ // Embed the project's ingest-only error key so captured errors go to the keyed
326
+ // /errors/collect (Sentry-style), not the public /collect.
327
+
328
+ // ?stealth=true → beacons go to the shorter /t path instead of /collect,
329
+ // which generic blocklists target. Pair with a first-party proxy (#85).
330
+ const stealthMode = query.stealth === 'true'
331
+
332
+ // Honor the configured DNT/GPC opt-out (defaults on) — the served scripts
333
+ // previously dropped this, so the privacy promise wasn't kept (#142).
334
+ const honorDnt = getConfig().privacy.honorDnt
335
+ let script = minimal
336
+ ? generateMinimalTrackingScript({ siteId, apiEndpoint, honorDnt })
337
+ : generateTrackingScript({ siteId, apiEndpoint, honorDnt, trackLinkClicks: true, trackSpaRoutes: true, trackEngagement: true, stealthMode })
338
+
339
+ // Raw-JS variant (for the `<script src=".../script.js">` one-liner): strip the
340
+ // HTML comment + <script> wrapper so the response is loadable JavaScript. The
341
+ // IIFE derives site/api from its own src URL, so no data-* attributes are needed.
342
+ const raw = url.pathname.endsWith('.js') || query.format === 'js' || query.raw === 'true'
343
+ if (raw) {
344
+ script = script
345
+ .replace(/^<!--[\s\S]*?-->\s*/, '')
346
+ .replace(/^<script[^>]*>\s*/, '')
347
+ .replace(/\s*<\/script>\s*$/, '')
348
+ }
349
+
350
+ return jsResponse(script, {}, request)
351
+ }
352
+
353
+ /**
354
+ * GET /script.js — the shared, cacheable analytics tracker (Fathom/Plausible
355
+ * style). One file for every site: the site is identified at runtime by the
356
+ * tag's `data-site` attribute and the collect endpoint is the script's own
357
+ * origin (the IIFE reads `currentScript.dataset.site` / `.src`). No per-site
358
+ * key is baked in, so it stays cacheable across sites; uncaught errors fall
359
+ * back to the public /collect as `error` events. For Sentry-style error
360
+ * tracking with a private DSN, use /sdk.js + the project's DSN instead.
361
+ *
362
+ * <script defer data-site="SITE_ID" src="https://your-host/script.js"></script>
363
+ */
364
+ export function handleSharedScript(request: Request): Response {
365
+ const query = getQueryParams(request)
366
+ const event = getLambdaEvent(request)
367
+ const url = new URL(request.url)
368
+
369
+ const apiEndpoint = query.api
370
+ || (event?.requestContext?.domainName ? `https://${event.requestContext.domainName}` : url.origin)
371
+ const stealthMode = query.stealth === 'true'
372
+
373
+ // No siteId / ingest key baked in — both are resolved from the visitor's tag
374
+ // at runtime, which is what keeps this one script shared and cacheable.
375
+ const script = generateTrackingScript({
376
+ siteId: '',
377
+ apiEndpoint,
378
+ honorDnt: getConfig().privacy.honorDnt,
379
+ trackLinkClicks: true,
380
+ trackSpaRoutes: true,
381
+ trackEngagement: true,
382
+ stealthMode,
383
+ })
384
+ .replace(/^<!--[\s\S]*?-->\s*/, '')
385
+ .replace(/^<script[^>]*>\s*/, '')
386
+ .replace(/\s*<\/script>\s*$/, '')
387
+
388
+ return jsResponse(script, {}, request)
389
+ }
390
+
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Webhook handlers
3
+ */
4
+
5
+ import { createHmac } from 'node:crypto'
6
+ import { generateId } from '../index'
7
+ import { dynamodb, TABLE_NAME, unmarshall, marshall } from '../lib/dynamodb'
8
+ import { jsonResponse, errorResponse } from '../utils/response'
9
+ import { getQueryParams } from '../../deploy/lambda-adapter'
10
+
11
+ /**
12
+ * POST /api/sites/{siteId}/webhooks
13
+ */
14
+ export async function handleCreateWebhook(request: Request, siteId: string): Promise<Response> {
15
+ try {
16
+ const body = await request.json() as Record<string, any>
17
+
18
+ if (!body.url || !body.events || !Array.isArray(body.events) || body.events.length === 0) {
19
+ return jsonResponse({ error: 'Missing required fields: url, events (array)' }, 400)
20
+ }
21
+
22
+ const validEvents = ['pageview', 'session.start', 'session.end', 'goal.conversion', 'error', 'alert.triggered']
23
+ const invalidEvents = body.events.filter((e: string) => !validEvents.includes(e))
24
+ if (invalidEvents.length > 0) {
25
+ return jsonResponse({ error: `Invalid events: ${invalidEvents.join(', ')}. Valid events: ${validEvents.join(', ')}` }, 400)
26
+ }
27
+
28
+ const webhookId = generateId()
29
+ const webhook = {
30
+ pk: `SITE#${siteId}`,
31
+ sk: `WEBHOOK#${webhookId}`,
32
+ id: webhookId,
33
+ siteId,
34
+ url: body.url,
35
+ events: body.events,
36
+ secret: body.secret || generateId(),
37
+ headers: body.headers || {},
38
+ isActive: body.isActive ?? true,
39
+ lastTriggered: null,
40
+ failureCount: 0,
41
+ createdAt: new Date().toISOString(),
42
+ }
43
+
44
+ await dynamodb.putItem({
45
+ TableName: TABLE_NAME,
46
+ Item: marshall(webhook),
47
+ })
48
+
49
+ return jsonResponse({ webhook }, 201)
50
+ }
51
+ catch (error) {
52
+ console.error('Create webhook error:', error)
53
+ return errorResponse('Failed to create webhook')
54
+ }
55
+ }
56
+
57
+ /**
58
+ * GET /api/sites/{siteId}/webhooks
59
+ */
60
+ export async function handleGetWebhooks(_request: Request, siteId: string): Promise<Response> {
61
+ try {
62
+ const result = await dynamodb.query({
63
+ TableName: TABLE_NAME,
64
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :prefix)',
65
+ ExpressionAttributeValues: {
66
+ ':pk': { S: `SITE#${siteId}` },
67
+ ':prefix': { S: 'WEBHOOK#' },
68
+ },
69
+ }) as { Items?: any[] }
70
+
71
+ const webhooks = (result.Items || []).map(unmarshall).map(w => ({
72
+ id: w.id,
73
+ url: w.url,
74
+ events: w.events,
75
+ isActive: w.isActive,
76
+ lastTriggered: w.lastTriggered,
77
+ failureCount: w.failureCount,
78
+ createdAt: w.createdAt,
79
+ }))
80
+
81
+ return jsonResponse({ webhooks })
82
+ }
83
+ catch (error) {
84
+ console.error('Get webhooks error:', error)
85
+ return errorResponse('Failed to fetch webhooks')
86
+ }
87
+ }
88
+
89
+ /**
90
+ * DELETE /api/sites/{siteId}/webhooks/{webhookId}
91
+ */
92
+ export async function handleDeleteWebhook(_request: Request, siteId: string, webhookId: string): Promise<Response> {
93
+ try {
94
+ await dynamodb.deleteItem({
95
+ TableName: TABLE_NAME,
96
+ Key: marshall({
97
+ pk: `SITE#${siteId}`,
98
+ sk: `WEBHOOK#${webhookId}`,
99
+ }),
100
+ })
101
+
102
+ return jsonResponse({ success: true })
103
+ }
104
+ catch (error) {
105
+ console.error('Delete webhook error:', error)
106
+ return errorResponse('Failed to delete webhook')
107
+ }
108
+ }
109
+
110
+ // ── Webhook delivery (#92) ─────────────────────────────────────────────────
111
+ // Events are enqueued to a WEBHOOK_QUEUE partition (sk = nextAttemptIso#id) and
112
+ // drained by a scheduled job: each delivery is a signed POST (HMAC-SHA256),
113
+ // retried with backoff up to MAX_WEBHOOK_ATTEMPTS, then dropped (failureCount++).
114
+ const MAX_WEBHOOK_ATTEMPTS = 5
115
+ const BACKOFF_MS = [0, 60_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000]
116
+
117
+ /** Enqueue an event for every active webhook on the site subscribed to it. */
118
+ export async function enqueueWebhookEvent(siteId: string, eventType: string, payload: Record<string, unknown>): Promise<number> {
119
+ const subs = await dynamodb.query({
120
+ TableName: TABLE_NAME,
121
+ KeyConditionExpression: 'pk = :pk AND begins_with(sk, :p)',
122
+ ExpressionAttributeValues: { ':pk': { S: `SITE#${siteId}` }, ':p': { S: 'WEBHOOK#' } },
123
+ }) as { Items?: any[] }
124
+
125
+ const now = Date.now()
126
+ const nowIso = new Date(now).toISOString()
127
+ let queued = 0
128
+ for (const raw of (subs.Items || [])) {
129
+ const wh = unmarshall(raw)
130
+ if (wh.isActive === false)
131
+ continue
132
+ const events: string[] = wh.events || []
133
+ if (!events.includes('*') && !events.includes(eventType))
134
+ continue
135
+ const id = generateId()
136
+ await dynamodb.putItem({
137
+ TableName: TABLE_NAME,
138
+ Item: marshall({
139
+ pk: 'WEBHOOK_QUEUE', sk: `${nowIso}#${id}`, id, siteId, webhookId: wh.id, url: wh.url,
140
+ secret: wh.secret, headers: wh.headers || {}, eventType, payload: JSON.stringify(payload),
141
+ attempts: 0, createdAt: nowIso, ttl: Math.floor(now / 1000) + 3 * 24 * 60 * 60,
142
+ }),
143
+ })
144
+ queued++
145
+ }
146
+ return queued
147
+ }
148
+
149
+ async function deliverOne(d: any): Promise<boolean> {
150
+ try {
151
+ let body: string = d.payload || '{}'
152
+ // Native Slack incoming-webhooks (#129): Slack requires {text: ...} and
153
+ // rejects arbitrary JSON — reshape the event into a readable message so
154
+ // pointing a webhook at hooks.slack.com just works.
155
+ if (/hooks\.slack\.com\//.test(String(d.url || ''))) {
156
+ try {
157
+ const evt = JSON.parse(body)
158
+ const fields = Object.entries(evt)
159
+ .filter(([k]) => !['event', 'siteId'].includes(k))
160
+ .map(([k, v]) => `${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`)
161
+ .join('\n')
162
+ body = JSON.stringify({ text: `*${d.eventType || evt.event || 'analytics event'}* — ${evt.siteId || ''}\n${fields}` })
163
+ }
164
+ catch {}
165
+ }
166
+ const sig = createHmac('sha256', String(d.secret || '')).update(body).digest('hex')
167
+ const res = await fetch(d.url, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json', 'X-Webhook-Event': d.eventType || '', 'X-Webhook-Signature': `sha256=${sig}`, ...(d.headers || {}) },
170
+ body,
171
+ signal: AbortSignal.timeout(10_000),
172
+ })
173
+ return res.status >= 200 && res.status < 300
174
+ }
175
+ catch {
176
+ return false
177
+ }
178
+ }
179
+
180
+ async function updateWebhook(siteId: string, webhookId: string, expr: string, values: Record<string, any>): Promise<void> {
181
+ try {
182
+ await dynamodb.updateItem({
183
+ TableName: TABLE_NAME,
184
+ Key: { pk: { S: `SITE#${siteId}` }, sk: { S: `WEBHOOK#${webhookId}` } },
185
+ UpdateExpression: expr,
186
+ ExpressionAttributeValues: values,
187
+ })
188
+ }
189
+ catch (e) {
190
+ console.error('Webhook update failed:', (e as Error).message)
191
+ }
192
+ }
193
+
194
+ /** Drain due webhook deliveries (scheduled job, #92/#90). Returns count processed. */
195
+ export async function processWebhookDeliveries(): Promise<number> {
196
+ const nowIso = new Date().toISOString()
197
+ const due = await dynamodb.query({
198
+ TableName: TABLE_NAME,
199
+ KeyConditionExpression: 'pk = :pk AND sk <= :now',
200
+ ExpressionAttributeValues: { ':pk': { S: 'WEBHOOK_QUEUE' }, ':now': { S: nowIso } },
201
+ Limit: 100,
202
+ }) as { Items?: any[] }
203
+
204
+ let processed = 0
205
+ for (const raw of (due.Items || [])) {
206
+ const d = unmarshall(raw)
207
+ const ok = await deliverOne(d)
208
+ await dynamodb.deleteItem({ TableName: TABLE_NAME, Key: { pk: { S: 'WEBHOOK_QUEUE' }, sk: { S: d.sk } } })
209
+
210
+ if (ok) {
211
+ await updateWebhook(d.siteId, d.webhookId, 'SET lastTriggered = :now', { ':now': { S: nowIso } })
212
+ }
213
+ else {
214
+ const attempts = (d.attempts || 0) + 1
215
+ if (attempts < MAX_WEBHOOK_ATTEMPTS) {
216
+ const next = Date.now() + (BACKOFF_MS[attempts] || 2 * 60 * 60_000)
217
+ await dynamodb.putItem({ TableName: TABLE_NAME, Item: marshall({ ...d, sk: `${new Date(next).toISOString()}#${d.id}`, attempts }) })
218
+ }
219
+ else {
220
+ await updateWebhook(d.siteId, d.webhookId, 'ADD failureCount :one', { ':one': { N: '1' } })
221
+ }
222
+ }
223
+ processed++
224
+ }
225
+ return processed
226
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Heatmap Tracking Module
3
+ *
4
+ * Provides client-side tracking for click heatmaps, movement heatmaps,
5
+ * and scroll depth visualization.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { generateHeatmapScript, buildHeatmapTracking } from './heatmap'
10
+ *
11
+ * // Generate a standalone heatmap script
12
+ * const script = generateHeatmapScript('my-site', 'https://api.example.com', {
13
+ * trackClicks: true,
14
+ * trackMovements: true,
15
+ * trackScrollPositions: true,
16
+ * })
17
+ *
18
+ * // Or build just the tracking code to integrate with main script
19
+ * const trackingCode = buildHeatmapTracking({
20
+ * trackClicks: true,
21
+ * trackMovements: true,
22
+ * })
23
+ * ```
24
+ */
25
+
26
+ export { buildHeatmapTracking, generateHeatmapScript } from './tracking-script'
27
+ export type {
28
+ HeatmapConfig,
29
+ HeatmapClickEvent,
30
+ HeatmapMovementBatch,
31
+ HeatmapScrollData,
32
+ } from './types'