@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.
- package/CHANGELOG.md +68 -0
- package/LICENSE.md +21 -0
- package/README.md +361 -0
- package/bin/cli.ts +169 -0
- package/dist/Analytics.d.ts +558 -0
- package/dist/api.d.ts +109 -0
- package/dist/batching.d.ts +93 -0
- package/dist/chunk-2mx7fq49.js +4 -0
- package/dist/chunk-3z29508k.js +204 -0
- package/dist/chunk-deephkz6.js +56 -0
- package/dist/chunk-j261vgyp.js +305 -0
- package/dist/chunk-xga17tz7.js +207 -0
- package/dist/config.d.ts +132 -0
- package/dist/dashboard/components/index.d.ts +80 -0
- package/dist/dashboard/composables/useAnalytics.d.ts +99 -0
- package/dist/dashboard/index.d.ts +110 -0
- package/dist/dashboard/types/index.d.ts +144 -0
- package/dist/dashboard/utils/index.d.ts +81 -0
- package/dist/dynamodb.d.ts +86 -0
- package/dist/funnels.d.ts +104 -0
- package/dist/geolocation.d.ts +96 -0
- package/dist/index.d.ts +371 -0
- package/dist/index.js +10211 -0
- package/dist/infrastructure/cdk.d.ts +60 -0
- package/dist/infrastructure/cloudformation.d.ts +44 -0
- package/dist/infrastructure/index.d.ts +45 -0
- package/dist/infrastructure/setup.d.ts +133 -0
- package/dist/integrations/cloudflare.d.ts +76 -0
- package/dist/integrations/hono.d.ts +60 -0
- package/dist/integrations/index.d.ts +24 -0
- package/dist/integrations/nuxt.d.ts +7 -0
- package/dist/integrations/nuxt.js +42 -0
- package/dist/integrations/runtime/use-ts-analytics.d.ts +18 -0
- package/dist/integrations/runtime/use-ts-analytics.js +17 -0
- package/dist/integrations/stx.d.ts +88 -0
- package/dist/integrations/stx.js +18 -0
- package/dist/lib/crypto-random.d.ts +4 -0
- package/dist/lib/salt.d.ts +16 -0
- package/dist/local.d.ts +56 -0
- package/dist/model-connector.d.ts +145 -0
- package/dist/models/AggregatedStats.d.ts +9 -0
- package/dist/models/CampaignStats.d.ts +9 -0
- package/dist/models/Conversion.d.ts +11 -0
- package/dist/models/CustomEvent.d.ts +11 -0
- package/dist/models/DeviceStats.d.ts +9 -0
- package/dist/models/EventStats.d.ts +9 -0
- package/dist/models/GeoStats.d.ts +9 -0
- package/dist/models/Goal.d.ts +9 -0
- package/dist/models/GoalStats.d.ts +9 -0
- package/dist/models/PageStats.d.ts +11 -0
- package/dist/models/PageView.d.ts +13 -0
- package/dist/models/RealtimeStats.d.ts +9 -0
- package/dist/models/ReferrerStats.d.ts +9 -0
- package/dist/models/Session.d.ts +11 -0
- package/dist/models/Site.d.ts +11 -0
- package/dist/models/index.d.ts +28 -0
- package/dist/models/types.d.ts +60 -0
- package/dist/sqs-buffering.d.ts +243 -0
- package/dist/stacks-integration.d.ts +159 -0
- package/dist/tracking-script.d.ts +71 -0
- package/dist/tracking.d.ts +17 -0
- package/dist/tracking.js +26 -0
- package/dist/types.d.ts +595 -0
- package/dist/utils/geolocation.d.ts +111 -0
- package/dist/utils/user-agent.d.ts +17 -0
- package/dist/version.d.ts +7 -0
- package/package.json +119 -0
- package/src/Analytics.ts +3349 -0
- package/src/api.ts +1286 -0
- package/src/assets/crosswind.css +2220 -0
- package/src/batching.ts +452 -0
- package/src/components/dashboard/index.ts +18 -0
- package/src/config.ts +456 -0
- package/src/dashboard/Dashboard.stx +1517 -0
- package/src/dashboard/components/AlertCard.stx +177 -0
- package/src/dashboard/components/AnalyticsDashboard.stx +354 -0
- package/src/dashboard/components/AnimatedNumber.stx +86 -0
- package/src/dashboard/components/BarChart.stx +220 -0
- package/src/dashboard/components/BrowserBreakdown.stx +98 -0
- package/src/dashboard/components/BrowsersTable.stx +125 -0
- package/src/dashboard/components/CampaignBreakdown.stx +163 -0
- package/src/dashboard/components/CampaignTable.stx +238 -0
- package/src/dashboard/components/CountryList.stx +101 -0
- package/src/dashboard/components/DataTable.stx +226 -0
- package/src/dashboard/components/DateRangePicker.stx +77 -0
- package/src/dashboard/components/DeviceBreakdown.stx +94 -0
- package/src/dashboard/components/DevicesTable.stx +163 -0
- package/src/dashboard/components/DonutChart.stories.ts +55 -0
- package/src/dashboard/components/DonutChart.stx +157 -0
- package/src/dashboard/components/EmptyState.stx +90 -0
- package/src/dashboard/components/EngagementMetrics.stx +183 -0
- package/src/dashboard/components/EventsSection.stx +192 -0
- package/src/dashboard/components/FilterBar.stx +104 -0
- package/src/dashboard/components/FullAnalyticsDashboard.stx +455 -0
- package/src/dashboard/components/FunnelChart.stx +142 -0
- package/src/dashboard/components/GeoTable.stx +337 -0
- package/src/dashboard/components/GoalsPanel.stx +109 -0
- package/src/dashboard/components/Header.stx +39 -0
- package/src/dashboard/components/HeatmapChart.stx +140 -0
- package/src/dashboard/components/LiveActivityFeed.stx +172 -0
- package/src/dashboard/components/MetricComparison.stx +106 -0
- package/src/dashboard/components/MiniStats.stx +96 -0
- package/src/dashboard/components/OSBreakdown.stx +124 -0
- package/src/dashboard/components/PageDetailCard.stx +102 -0
- package/src/dashboard/components/PagesTable.stx +127 -0
- package/src/dashboard/components/ProgressRing.stx +89 -0
- package/src/dashboard/components/RealtimeCounter.stories.ts +45 -0
- package/src/dashboard/components/RealtimeCounter.stx +46 -0
- package/src/dashboard/components/ReferrersTable.stx +180 -0
- package/src/dashboard/components/SparklineChart.stx +160 -0
- package/src/dashboard/components/StatCard.stories.ts +58 -0
- package/src/dashboard/components/StatCard.stx +90 -0
- package/src/dashboard/components/SummaryStats.stx +81 -0
- package/src/dashboard/components/TabNav.stx +66 -0
- package/src/dashboard/components/ThemeSwitcher.stx +124 -0
- package/src/dashboard/components/TimeSeriesChart.stx +106 -0
- package/src/dashboard/components/TopList.stories.ts +58 -0
- package/src/dashboard/components/TopList.stx +74 -0
- package/src/dashboard/components/TrendIndicator.stx +84 -0
- package/src/dashboard/components/heatmap/ClickHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ElementClickList.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapControls.stx +125 -0
- package/src/dashboard/components/heatmap/HeatmapLegend.stx +69 -0
- package/src/dashboard/components/heatmap/PageHeatmap.stx +264 -0
- package/src/dashboard/components/heatmap/ScrollHeatmap.stx +127 -0
- package/src/dashboard/components/heatmap/index.ts +12 -0
- package/src/dashboard/components/index.ts +86 -0
- package/src/dashboard/composables/useAnalytics.ts +465 -0
- package/src/dashboard/demo/DemoApp.stx +370 -0
- package/src/dashboard/demo/index.ts +8 -0
- package/src/dashboard/demo/mockData.ts +234 -0
- package/src/dashboard/index.ts +117 -0
- package/src/dashboard/stx-shim.d.ts +4 -0
- package/src/dashboard/types/index.ts +203 -0
- package/src/dashboard/utils/index.ts +426 -0
- package/src/dynamodb.ts +344 -0
- package/src/funnels.ts +534 -0
- package/src/geolocation.ts +515 -0
- package/src/handlers/alerts.ts +328 -0
- package/src/handlers/annotations.ts +128 -0
- package/src/handlers/api-keys.ts +240 -0
- package/src/handlers/auth.ts +1020 -0
- package/src/handlers/authz.ts +137 -0
- package/src/handlers/collect.ts +724 -0
- package/src/handlers/data.ts +625 -0
- package/src/handlers/experiments.ts +138 -0
- package/src/handlers/funnels.ts +216 -0
- package/src/handlers/goals.ts +218 -0
- package/src/handlers/heatmaps.ts +272 -0
- package/src/handlers/index.ts +56 -0
- package/src/handlers/lib/read-cache.ts +63 -0
- package/src/handlers/misc.ts +486 -0
- package/src/handlers/oauth.ts +233 -0
- package/src/handlers/performance.ts +388 -0
- package/src/handlers/sessions.ts +413 -0
- package/src/handlers/sharing.ts +198 -0
- package/src/handlers/stats.ts +1368 -0
- package/src/handlers/team.ts +161 -0
- package/src/handlers/uptime.ts +283 -0
- package/src/handlers/views.ts +390 -0
- package/src/handlers/webhooks.ts +226 -0
- package/src/heatmap/index.ts +32 -0
- package/src/heatmap/tracking-script.ts +452 -0
- package/src/heatmap/types.ts +79 -0
- package/src/index.ts +387 -0
- package/src/infrastructure/cdk.ts +496 -0
- package/src/infrastructure/cloudformation.ts +595 -0
- package/src/infrastructure/index.ts +48 -0
- package/src/infrastructure/setup.ts +611 -0
- package/src/integrations/cloudflare.ts +732 -0
- package/src/integrations/hono.ts +589 -0
- package/src/integrations/index.ts +27 -0
- package/src/integrations/nuxt.ts +78 -0
- package/src/integrations/runtime/use-ts-analytics.ts +32 -0
- package/src/integrations/stx.ts +138 -0
- package/src/jobs/index.ts +127 -0
- package/src/lib/crypto-random.ts +41 -0
- package/src/lib/ddb-errors.ts +20 -0
- package/src/lib/dynamodb.ts +216 -0
- package/src/lib/email.ts +38 -0
- package/src/lib/ga-import.ts +471 -0
- package/src/lib/ga4-api.ts +244 -0
- package/src/lib/goals.ts +205 -0
- package/src/lib/index.ts +6 -0
- package/src/lib/ingest-counters.ts +123 -0
- package/src/lib/log.ts +36 -0
- package/src/lib/membership.ts +98 -0
- package/src/lib/plans.ts +90 -0
- package/src/lib/quota.ts +46 -0
- package/src/lib/rate-limit.ts +27 -0
- package/src/lib/rollups.ts +472 -0
- package/src/lib/salt.ts +97 -0
- package/src/lib/scheduler.ts +97 -0
- package/src/lib/significance.ts +66 -0
- package/src/lib/site-retention.ts +56 -0
- package/src/local.ts +360 -0
- package/src/model-connector.ts +616 -0
- package/src/models/AggregatedStats.ts +162 -0
- package/src/models/CampaignStats.ts +141 -0
- package/src/models/Conversion.ts +135 -0
- package/src/models/CustomEvent.ts +123 -0
- package/src/models/DeviceStats.ts +105 -0
- package/src/models/EventStats.ts +108 -0
- package/src/models/GeoStats.ts +112 -0
- package/src/models/Goal.ts +105 -0
- package/src/models/GoalStats.ts +106 -0
- package/src/models/PageStats.ts +152 -0
- package/src/models/PageView.ts +277 -0
- package/src/models/RealtimeStats.ts +96 -0
- package/src/models/ReferrerStats.ts +115 -0
- package/src/models/Session.ts +235 -0
- package/src/models/Site.ts +115 -0
- package/src/models/index.ts +50 -0
- package/src/models/orm/index.ts +2043 -0
- package/src/models/types.ts +71 -0
- package/src/router.ts +521 -0
- package/src/sqs-buffering.ts +806 -0
- package/src/stacks-integration.ts +527 -0
- package/src/tracking-script.ts +944 -0
- package/src/tracking.ts +27 -0
- package/src/types/analytics.ts +219 -0
- package/src/types/api.ts +75 -0
- package/src/types/bun-router.d.ts +15 -0
- package/src/types/dashboard.ts +62 -0
- package/src/types/index.ts +66 -0
- package/src/types/stx.d.ts +27 -0
- package/src/types/window.d.ts +101 -0
- package/src/types.ts +911 -0
- package/src/utils/cache.ts +148 -0
- package/src/utils/date.ts +118 -0
- package/src/utils/filters.ts +71 -0
- package/src/utils/geolocation.ts +323 -0
- package/src/utils/index.ts +9 -0
- package/src/utils/response.ts +180 -0
- package/src/utils/timezone-country.ts +242 -0
- package/src/utils/user-agent.ts +110 -0
- 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'
|