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