@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,180 @@
1
+ /**
2
+ * Response utilities for the analytics API
3
+ */
4
+
5
+ export interface ApiResponse {
6
+ statusCode: number
7
+ headers: Record<string, string>
8
+ body: string
9
+ }
10
+
11
+ /**
12
+ * Create a standardized JSON response
13
+ */
14
+ export function jsonResponse(body: unknown, statusCode = 200, headers: Record<string, string> = {}): Response {
15
+ return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
16
+ status: statusCode,
17
+ headers: {
18
+ 'Content-Type': 'application/json',
19
+ 'Access-Control-Allow-Origin': '*',
20
+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
21
+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
22
+ ...headers,
23
+ },
24
+ })
25
+ }
26
+
27
+ /**
28
+ * 204 No Content with CORS — for the public collect / tracking endpoints, which
29
+ * every host site POSTs to cross-origin. Without Access-Control-Allow-Origin the
30
+ * browser blocks the beacon (and logs a CORS error) even though the write
31
+ * succeeded, so the bare `new Response(null, { status: 204 })` must not be used.
32
+ */
33
+ export function noContentResponse(request?: Request, headers: Record<string, string> = {}): Response {
34
+ return new Response(null, {
35
+ status: 204,
36
+ headers: {
37
+ ...corsOriginHeaders(request),
38
+ ...headers,
39
+ },
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Create an HTML response
45
+ */
46
+ export function htmlResponse(body: string, statusCode = 200, headers: Record<string, string> = {}): Response {
47
+ return new Response(body, {
48
+ status: statusCode,
49
+ headers: {
50
+ 'Content-Type': 'text/html; charset=utf-8',
51
+ 'Access-Control-Allow-Origin': '*',
52
+ ...headers,
53
+ },
54
+ })
55
+ }
56
+
57
+ /**
58
+ * Create a JavaScript response.
59
+ *
60
+ * Carries an ETag (#179) so tracker updates roll out on the next
61
+ * revalidation instead of a hard 1-hour cache-expiry wait: pass the request
62
+ * and a matching If-None-Match short-circuits to 304.
63
+ * stale-while-revalidate keeps page loads fast while the check happens in
64
+ * the background. Cache-bust hard with a ?v= query when needed.
65
+ */
66
+ export function jsResponse(body: string, headers: Record<string, string> = {}, request?: Request): Response {
67
+ const etag = `W/"${Bun.hash(body).toString(36)}"`
68
+ const baseHeaders = {
69
+ 'Content-Type': 'application/javascript',
70
+ 'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400',
71
+ 'Access-Control-Allow-Origin': '*',
72
+ 'ETag': etag,
73
+ ...headers,
74
+ }
75
+ if (request?.headers.get('if-none-match') === etag) {
76
+ return new Response(null, { status: 304, headers: baseHeaders })
77
+ }
78
+ return new Response(body, { status: 200, headers: baseHeaders })
79
+ }
80
+
81
+ /**
82
+ * CORS origin headers for the public collect endpoints. sendBeacon always
83
+ * sends with credentials mode 'include' (per spec), and credentialed CORS
84
+ * rejects the wildcard — so when the request carries an Origin, echo it and
85
+ * allow credentials; fall back to '*' otherwise.
86
+ */
87
+ export function corsOriginHeaders(request?: Request): Record<string, string> {
88
+ const origin = request?.headers?.get?.('origin')
89
+ if (!origin)
90
+ return { 'Access-Control-Allow-Origin': '*' }
91
+ return {
92
+ 'Access-Control-Allow-Origin': origin,
93
+ 'Access-Control-Allow-Credentials': 'true',
94
+ 'Vary': 'Origin',
95
+ }
96
+ }
97
+
98
+ /**
99
+ * CORS preflight response for the public collect endpoints (#86).
100
+ *
101
+ * Cross-domain installs preflight every beacon: the tracker posts JSON
102
+ * (non-simple content type) and the error SDK adds X-Analytics-Token, so the
103
+ * browser sends OPTIONS first. Without this, preflights 404 and the browser
104
+ * silently drops the POST. The origin is echoed (not wildcarded) because
105
+ * sendBeacon requests are always credentialed.
106
+ */
107
+ export function preflightResponse(request?: Request): Response {
108
+ return new Response(null, {
109
+ status: 204,
110
+ headers: {
111
+ ...corsOriginHeaders(request),
112
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS',
113
+ 'Access-Control-Allow-Headers': 'Content-Type, X-Analytics-Token',
114
+ 'Access-Control-Max-Age': '86400',
115
+ },
116
+ })
117
+ }
118
+
119
+ /**
120
+ * Create an error response
121
+ */
122
+ export function errorResponse(message: string, statusCode = 500): Response {
123
+ return jsonResponse({ error: message }, statusCode)
124
+ }
125
+
126
+ /**
127
+ * Create a not found response
128
+ */
129
+ export function notFoundResponse(message = 'Not found'): Response {
130
+ return jsonResponse({ error: message }, 404)
131
+ }
132
+
133
+ /**
134
+ * Legacy response helper for backwards compatibility during migration
135
+ * Returns the old format expected by the Lambda handler
136
+ */
137
+ export function legacyResponse(body: unknown, statusCode = 200, headers: Record<string, string> = {}): ApiResponse {
138
+ return {
139
+ statusCode,
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ 'Access-Control-Allow-Origin': '*',
143
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
144
+ 'Access-Control-Allow-Headers': 'Content-Type',
145
+ ...headers,
146
+ },
147
+ body: typeof body === 'string' ? body : JSON.stringify(body),
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Split-domain credentialed CORS (#118).
153
+ *
154
+ * When the dashboard runs on a DIFFERENT origin than the API
155
+ * (STX_PUBLIC_API_ENDPOINT), its credentialed fetches need the API to echo the
156
+ * request Origin with Access-Control-Allow-Credentials — browsers forbid the
157
+ * wildcard with credentials. The allowlist comes from CORS_ORIGINS
158
+ * (comma-separated origins). Unset = same-origin installs (the dev proxy,
159
+ * #116) — no echo, wildcard responses stay as-is.
160
+ */
161
+ export function allowedCorsOrigins(): string[] {
162
+ return (process.env.CORS_ORIGINS || '')
163
+ .split(',')
164
+ .map(o => o.trim().replace(/\/$/, ''))
165
+ .filter(o => o.length > 0 && o !== '*')
166
+ }
167
+
168
+ /** The echoed CORS headers for an allowlisted Origin, or null. */
169
+ export function credentialedCorsHeaders(request: Request): Record<string, string> | null {
170
+ const origin = request.headers.get('origin')
171
+ if (!origin)
172
+ return null
173
+ if (!allowedCorsOrigins().includes(origin.replace(/\/$/, '')))
174
+ return null
175
+ return {
176
+ 'Access-Control-Allow-Origin': origin,
177
+ 'Access-Control-Allow-Credentials': 'true',
178
+ 'Vary': 'Origin',
179
+ }
180
+ }
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Country from the browser's IANA timezone (Fathom-privacy geolocation).
3
+ *
4
+ * The tracker sends `Intl.DateTimeFormat().resolvedOptions().timeZone` (e.g.
5
+ * "Asia/Manila") and we map it to a country server-side. Nothing about the
6
+ * visitor leaves their machine except a coarse timezone string, no IP is used
7
+ * or shipped to a third party, and only the derived country is stored — the
8
+ * raw timezone is discarded. Works in any deployment (no CDN geo headers
9
+ * needed), so it's the default fallback after CloudFront/Cloudflare headers.
10
+ *
11
+ * Mapping data follows the IANA tz database's zone-to-country table
12
+ * (zone1970.tab) plus the common legacy aliases browsers still emit
13
+ * (Asia/Calcutta, Europe/Kiev, US/Eastern, …). Zones with no single country
14
+ * (UTC, Etc/*) intentionally resolve to nothing.
15
+ */
16
+ import { COUNTRY_NAMES } from './geolocation'
17
+
18
+ const TIMEZONE_COUNTRY: Record<string, string> = {
19
+ // Africa
20
+ 'Africa/Abidjan': 'CI', 'Africa/Accra': 'GH', 'Africa/Addis_Ababa': 'ET',
21
+ 'Africa/Algiers': 'DZ', 'Africa/Asmara': 'ER', 'Africa/Asmera': 'ER',
22
+ 'Africa/Bamako': 'ML', 'Africa/Bangui': 'CF', 'Africa/Banjul': 'GM',
23
+ 'Africa/Bissau': 'GW', 'Africa/Blantyre': 'MW', 'Africa/Brazzaville': 'CG',
24
+ 'Africa/Bujumbura': 'BI', 'Africa/Cairo': 'EG', 'Africa/Casablanca': 'MA',
25
+ 'Africa/Ceuta': 'ES', 'Africa/Conakry': 'GN', 'Africa/Dakar': 'SN',
26
+ 'Africa/Dar_es_Salaam': 'TZ', 'Africa/Djibouti': 'DJ', 'Africa/Douala': 'CM',
27
+ 'Africa/El_Aaiun': 'EH', 'Africa/Freetown': 'SL', 'Africa/Gaborone': 'BW',
28
+ 'Africa/Harare': 'ZW', 'Africa/Johannesburg': 'ZA', 'Africa/Juba': 'SS',
29
+ 'Africa/Kampala': 'UG', 'Africa/Khartoum': 'SD', 'Africa/Kigali': 'RW',
30
+ 'Africa/Kinshasa': 'CD', 'Africa/Lagos': 'NG', 'Africa/Libreville': 'GA',
31
+ 'Africa/Lome': 'TG', 'Africa/Luanda': 'AO', 'Africa/Lubumbashi': 'CD',
32
+ 'Africa/Lusaka': 'ZM', 'Africa/Malabo': 'GQ', 'Africa/Maputo': 'MZ',
33
+ 'Africa/Maseru': 'LS', 'Africa/Mbabane': 'SZ', 'Africa/Mogadishu': 'SO',
34
+ 'Africa/Monrovia': 'LR', 'Africa/Nairobi': 'KE', 'Africa/Ndjamena': 'TD',
35
+ 'Africa/Niamey': 'NE', 'Africa/Nouakchott': 'MR', 'Africa/Ouagadougou': 'BF',
36
+ 'Africa/Porto-Novo': 'BJ', 'Africa/Sao_Tome': 'ST', 'Africa/Timbuktu': 'ML',
37
+ 'Africa/Tripoli': 'LY', 'Africa/Tunis': 'TN', 'Africa/Windhoek': 'NA',
38
+
39
+ // America — US
40
+ 'America/Adak': 'US', 'America/Anchorage': 'US', 'America/Boise': 'US',
41
+ 'America/Chicago': 'US', 'America/Denver': 'US', 'America/Detroit': 'US',
42
+ 'America/Indiana/Indianapolis': 'US', 'America/Indiana/Knox': 'US',
43
+ 'America/Indiana/Marengo': 'US', 'America/Indiana/Petersburg': 'US',
44
+ 'America/Indiana/Tell_City': 'US', 'America/Indiana/Vevay': 'US',
45
+ 'America/Indiana/Vincennes': 'US', 'America/Indiana/Winamac': 'US',
46
+ 'America/Indianapolis': 'US', 'America/Fort_Wayne': 'US', 'America/Knox_IN': 'US',
47
+ 'America/Juneau': 'US', 'America/Kentucky/Louisville': 'US',
48
+ 'America/Kentucky/Monticello': 'US', 'America/Louisville': 'US',
49
+ 'America/Los_Angeles': 'US', 'America/Menominee': 'US', 'America/Metlakatla': 'US',
50
+ 'America/New_York': 'US', 'America/Nome': 'US',
51
+ 'America/North_Dakota/Beulah': 'US', 'America/North_Dakota/Center': 'US',
52
+ 'America/North_Dakota/New_Salem': 'US', 'America/Phoenix': 'US',
53
+ 'America/Shiprock': 'US', 'America/Sitka': 'US', 'America/Yakutat': 'US',
54
+ 'America/Atka': 'US',
55
+ 'US/Alaska': 'US', 'US/Aleutian': 'US', 'US/Arizona': 'US', 'US/Central': 'US',
56
+ 'US/East-Indiana': 'US', 'US/Eastern': 'US', 'US/Hawaii': 'US',
57
+ 'US/Indiana-Starke': 'US', 'US/Michigan': 'US', 'US/Mountain': 'US', 'US/Pacific': 'US',
58
+ 'Pacific/Honolulu': 'US',
59
+
60
+ // America — Canada
61
+ 'America/Atikokan': 'CA', 'America/Blanc-Sablon': 'CA', 'America/Cambridge_Bay': 'CA',
62
+ 'America/Coral_Harbour': 'CA', 'America/Creston': 'CA', 'America/Dawson': 'CA',
63
+ 'America/Dawson_Creek': 'CA', 'America/Edmonton': 'CA', 'America/Fort_Nelson': 'CA',
64
+ 'America/Glace_Bay': 'CA', 'America/Goose_Bay': 'CA', 'America/Halifax': 'CA',
65
+ 'America/Inuvik': 'CA', 'America/Iqaluit': 'CA', 'America/Moncton': 'CA',
66
+ 'America/Montreal': 'CA', 'America/Nipigon': 'CA', 'America/Pangnirtung': 'CA',
67
+ 'America/Rainy_River': 'CA', 'America/Rankin_Inlet': 'CA', 'America/Regina': 'CA',
68
+ 'America/Resolute': 'CA', 'America/St_Johns': 'CA', 'America/Swift_Current': 'CA',
69
+ 'America/Thunder_Bay': 'CA', 'America/Toronto': 'CA', 'America/Vancouver': 'CA',
70
+ 'America/Whitehorse': 'CA', 'America/Winnipeg': 'CA', 'America/Yellowknife': 'CA',
71
+ 'Canada/Atlantic': 'CA', 'Canada/Central': 'CA', 'Canada/Eastern': 'CA',
72
+ 'Canada/Mountain': 'CA', 'Canada/Newfoundland': 'CA', 'Canada/Pacific': 'CA',
73
+ 'Canada/Saskatchewan': 'CA', 'Canada/Yukon': 'CA',
74
+
75
+ // America — Mexico
76
+ 'America/Bahia_Banderas': 'MX', 'America/Cancun': 'MX', 'America/Chihuahua': 'MX',
77
+ 'America/Ciudad_Juarez': 'MX', 'America/Ensenada': 'MX', 'America/Hermosillo': 'MX',
78
+ 'America/Matamoros': 'MX', 'America/Mazatlan': 'MX', 'America/Merida': 'MX',
79
+ 'America/Mexico_City': 'MX', 'America/Monterrey': 'MX', 'America/Ojinaga': 'MX',
80
+ 'America/Santa_Isabel': 'MX', 'America/Tijuana': 'MX',
81
+ 'Mexico/BajaNorte': 'MX', 'Mexico/BajaSur': 'MX', 'Mexico/General': 'MX',
82
+
83
+ // America — Brazil
84
+ 'America/Araguaina': 'BR', 'America/Bahia': 'BR', 'America/Belem': 'BR',
85
+ 'America/Boa_Vista': 'BR', 'America/Campo_Grande': 'BR', 'America/Cuiaba': 'BR',
86
+ 'America/Eirunepe': 'BR', 'America/Fortaleza': 'BR', 'America/Maceio': 'BR',
87
+ 'America/Manaus': 'BR', 'America/Noronha': 'BR', 'America/Porto_Acre': 'BR',
88
+ 'America/Porto_Velho': 'BR', 'America/Recife': 'BR', 'America/Rio_Branco': 'BR',
89
+ 'America/Santarem': 'BR', 'America/Sao_Paulo': 'BR',
90
+ 'Brazil/Acre': 'BR', 'Brazil/DeNoronha': 'BR', 'Brazil/East': 'BR', 'Brazil/West': 'BR',
91
+
92
+ // America — Argentina
93
+ 'America/Argentina/Buenos_Aires': 'AR', 'America/Argentina/Catamarca': 'AR',
94
+ 'America/Argentina/ComodRivadavia': 'AR', 'America/Argentina/Cordoba': 'AR',
95
+ 'America/Argentina/Jujuy': 'AR', 'America/Argentina/La_Rioja': 'AR',
96
+ 'America/Argentina/Mendoza': 'AR', 'America/Argentina/Rio_Gallegos': 'AR',
97
+ 'America/Argentina/Salta': 'AR', 'America/Argentina/San_Juan': 'AR',
98
+ 'America/Argentina/San_Luis': 'AR', 'America/Argentina/Tucuman': 'AR',
99
+ 'America/Argentina/Ushuaia': 'AR', 'America/Buenos_Aires': 'AR',
100
+ 'America/Catamarca': 'AR', 'America/Cordoba': 'AR', 'America/Jujuy': 'AR',
101
+ 'America/Mendoza': 'AR', 'America/Rosario': 'AR',
102
+
103
+ // America — rest
104
+ 'America/Anguilla': 'AI', 'America/Antigua': 'AG', 'America/Aruba': 'AW',
105
+ 'America/Asuncion': 'PY', 'America/Barbados': 'BB', 'America/Belize': 'BZ',
106
+ 'America/Bogota': 'CO', 'America/Caracas': 'VE', 'America/Cayenne': 'GF',
107
+ 'America/Cayman': 'KY', 'America/Costa_Rica': 'CR', 'America/Curacao': 'CW',
108
+ 'America/Danmarkshavn': 'GL', 'America/Dominica': 'DM', 'America/El_Salvador': 'SV',
109
+ 'America/Godthab': 'GL', 'America/Grand_Turk': 'TC', 'America/Grenada': 'GD',
110
+ 'America/Guadeloupe': 'GP', 'America/Guatemala': 'GT', 'America/Guayaquil': 'EC',
111
+ 'America/Guyana': 'GY', 'America/Havana': 'CU', 'America/Jamaica': 'JM',
112
+ 'America/Kralendijk': 'BQ', 'America/La_Paz': 'BO', 'America/Lima': 'PE',
113
+ 'America/Lower_Princes': 'SX', 'America/Managua': 'NI', 'America/Marigot': 'MF',
114
+ 'America/Martinique': 'MQ', 'America/Miquelon': 'PM', 'America/Montevideo': 'UY',
115
+ 'America/Montserrat': 'MS', 'America/Nassau': 'BS', 'America/Nuuk': 'GL',
116
+ 'America/Panama': 'PA', 'America/Paramaribo': 'SR', 'America/Port-au-Prince': 'HT',
117
+ 'America/Port_of_Spain': 'TT', 'America/Puerto_Rico': 'PR', 'America/Punta_Arenas': 'CL',
118
+ 'America/Santiago': 'CL', 'America/Santo_Domingo': 'DO', 'America/Scoresbysund': 'GL',
119
+ 'America/St_Barthelemy': 'BL', 'America/St_Kitts': 'KN', 'America/St_Lucia': 'LC',
120
+ 'America/St_Thomas': 'VI', 'America/St_Vincent': 'VC', 'America/Tegucigalpa': 'HN',
121
+ 'America/Thule': 'GL', 'America/Tortola': 'VG', 'America/Virgin': 'VI',
122
+ 'America/Coyhaique': 'CL',
123
+ 'Chile/Continental': 'CL', 'Chile/EasterIsland': 'CL', 'Cuba': 'CU', 'Jamaica': 'JM',
124
+
125
+ // Antarctica (research stations — no meaningful country; map to AQ)
126
+ 'Antarctica/Casey': 'AQ', 'Antarctica/Davis': 'AQ', 'Antarctica/DumontDUrville': 'AQ',
127
+ 'Antarctica/Macquarie': 'AU', 'Antarctica/Mawson': 'AQ', 'Antarctica/McMurdo': 'AQ',
128
+ 'Antarctica/Palmer': 'AQ', 'Antarctica/Rothera': 'AQ', 'Antarctica/South_Pole': 'AQ',
129
+ 'Antarctica/Syowa': 'AQ', 'Antarctica/Troll': 'AQ', 'Antarctica/Vostok': 'AQ',
130
+
131
+ // Asia
132
+ 'Asia/Aden': 'YE', 'Asia/Almaty': 'KZ', 'Asia/Amman': 'JO', 'Asia/Anadyr': 'RU',
133
+ 'Asia/Aqtau': 'KZ', 'Asia/Aqtobe': 'KZ', 'Asia/Ashgabat': 'TM', 'Asia/Ashkhabad': 'TM',
134
+ 'Asia/Atyrau': 'KZ', 'Asia/Baghdad': 'IQ', 'Asia/Bahrain': 'BH', 'Asia/Baku': 'AZ',
135
+ 'Asia/Bangkok': 'TH', 'Asia/Barnaul': 'RU', 'Asia/Beirut': 'LB', 'Asia/Bishkek': 'KG',
136
+ 'Asia/Brunei': 'BN', 'Asia/Calcutta': 'IN', 'Asia/Chita': 'RU', 'Asia/Choibalsan': 'MN',
137
+ 'Asia/Chongqing': 'CN', 'Asia/Chungking': 'CN', 'Asia/Colombo': 'LK', 'Asia/Dacca': 'BD',
138
+ 'Asia/Damascus': 'SY', 'Asia/Dhaka': 'BD', 'Asia/Dili': 'TL', 'Asia/Dubai': 'AE',
139
+ 'Asia/Dushanbe': 'TJ', 'Asia/Famagusta': 'CY', 'Asia/Gaza': 'PS', 'Asia/Harbin': 'CN',
140
+ 'Asia/Hebron': 'PS', 'Asia/Ho_Chi_Minh': 'VN', 'Asia/Hong_Kong': 'HK', 'Asia/Hovd': 'MN',
141
+ 'Asia/Irkutsk': 'RU', 'Asia/Istanbul': 'TR', 'Asia/Jakarta': 'ID', 'Asia/Jayapura': 'ID',
142
+ 'Asia/Jerusalem': 'IL', 'Asia/Kabul': 'AF', 'Asia/Kamchatka': 'RU', 'Asia/Karachi': 'PK',
143
+ 'Asia/Kashgar': 'CN', 'Asia/Kathmandu': 'NP', 'Asia/Katmandu': 'NP', 'Asia/Khandyga': 'RU',
144
+ 'Asia/Kolkata': 'IN', 'Asia/Krasnoyarsk': 'RU', 'Asia/Kuala_Lumpur': 'MY',
145
+ 'Asia/Kuching': 'MY', 'Asia/Kuwait': 'KW', 'Asia/Macao': 'MO', 'Asia/Macau': 'MO',
146
+ 'Asia/Magadan': 'RU', 'Asia/Makassar': 'ID', 'Asia/Manila': 'PH', 'Asia/Muscat': 'OM',
147
+ 'Asia/Nicosia': 'CY', 'Asia/Novokuznetsk': 'RU', 'Asia/Novosibirsk': 'RU',
148
+ 'Asia/Omsk': 'RU', 'Asia/Oral': 'KZ', 'Asia/Phnom_Penh': 'KH', 'Asia/Pontianak': 'ID',
149
+ 'Asia/Pyongyang': 'KP', 'Asia/Qatar': 'QA', 'Asia/Qostanay': 'KZ', 'Asia/Qyzylorda': 'KZ',
150
+ 'Asia/Rangoon': 'MM', 'Asia/Riyadh': 'SA', 'Asia/Saigon': 'VN', 'Asia/Sakhalin': 'RU',
151
+ 'Asia/Samarkand': 'UZ', 'Asia/Seoul': 'KR', 'Asia/Shanghai': 'CN', 'Asia/Singapore': 'SG',
152
+ 'Asia/Srednekolymsk': 'RU', 'Asia/Taipei': 'TW', 'Asia/Tashkent': 'UZ',
153
+ 'Asia/Tbilisi': 'GE', 'Asia/Tehran': 'IR', 'Asia/Tel_Aviv': 'IL', 'Asia/Thimbu': 'BT',
154
+ 'Asia/Thimphu': 'BT', 'Asia/Tokyo': 'JP', 'Asia/Tomsk': 'RU', 'Asia/Ujung_Pandang': 'ID',
155
+ 'Asia/Ulaanbaatar': 'MN', 'Asia/Ulan_Bator': 'MN', 'Asia/Urumqi': 'CN',
156
+ 'Asia/Ust-Nera': 'RU', 'Asia/Vientiane': 'LA', 'Asia/Vladivostok': 'RU',
157
+ 'Asia/Yakutsk': 'RU', 'Asia/Yangon': 'MM', 'Asia/Yekaterinburg': 'RU', 'Asia/Yerevan': 'AM',
158
+ 'Hongkong': 'HK', 'Israel': 'IL', 'Iran': 'IR', 'Japan': 'JP', 'Singapore': 'SG',
159
+ 'ROC': 'TW', 'ROK': 'KR', 'PRC': 'CN', 'Turkey': 'TR', 'Egypt': 'EG', 'Libya': 'LY',
160
+
161
+ // Atlantic
162
+ 'Atlantic/Azores': 'PT', 'Atlantic/Bermuda': 'BM', 'Atlantic/Canary': 'ES',
163
+ 'Atlantic/Cape_Verde': 'CV', 'Atlantic/Faeroe': 'FO', 'Atlantic/Faroe': 'FO',
164
+ 'Atlantic/Jan_Mayen': 'SJ', 'Atlantic/Madeira': 'PT', 'Atlantic/Reykjavik': 'IS',
165
+ 'Atlantic/South_Georgia': 'GS', 'Atlantic/St_Helena': 'SH', 'Atlantic/Stanley': 'FK',
166
+ 'Iceland': 'IS',
167
+
168
+ // Australia
169
+ 'Australia/ACT': 'AU', 'Australia/Adelaide': 'AU', 'Australia/Brisbane': 'AU',
170
+ 'Australia/Broken_Hill': 'AU', 'Australia/Canberra': 'AU', 'Australia/Currie': 'AU',
171
+ 'Australia/Darwin': 'AU', 'Australia/Eucla': 'AU', 'Australia/Hobart': 'AU',
172
+ 'Australia/LHI': 'AU', 'Australia/Lindeman': 'AU', 'Australia/Lord_Howe': 'AU',
173
+ 'Australia/Melbourne': 'AU', 'Australia/NSW': 'AU', 'Australia/North': 'AU',
174
+ 'Australia/Perth': 'AU', 'Australia/Queensland': 'AU', 'Australia/South': 'AU',
175
+ 'Australia/Sydney': 'AU', 'Australia/Tasmania': 'AU', 'Australia/Victoria': 'AU',
176
+ 'Australia/West': 'AU', 'Australia/Yancowinna': 'AU',
177
+
178
+ // Europe
179
+ 'Europe/Amsterdam': 'NL', 'Europe/Andorra': 'AD', 'Europe/Astrakhan': 'RU',
180
+ 'Europe/Athens': 'GR', 'Europe/Belfast': 'GB', 'Europe/Belgrade': 'RS',
181
+ 'Europe/Berlin': 'DE', 'Europe/Bratislava': 'SK', 'Europe/Brussels': 'BE',
182
+ 'Europe/Bucharest': 'RO', 'Europe/Budapest': 'HU', 'Europe/Busingen': 'DE',
183
+ 'Europe/Chisinau': 'MD', 'Europe/Copenhagen': 'DK', 'Europe/Dublin': 'IE',
184
+ 'Europe/Gibraltar': 'GI', 'Europe/Guernsey': 'GG', 'Europe/Helsinki': 'FI',
185
+ 'Europe/Isle_of_Man': 'IM', 'Europe/Istanbul': 'TR', 'Europe/Jersey': 'JE',
186
+ 'Europe/Kaliningrad': 'RU', 'Europe/Kiev': 'UA', 'Europe/Kirov': 'RU',
187
+ 'Europe/Kyiv': 'UA', 'Europe/Lisbon': 'PT', 'Europe/Ljubljana': 'SI',
188
+ 'Europe/London': 'GB', 'Europe/Luxembourg': 'LU', 'Europe/Madrid': 'ES',
189
+ 'Europe/Malta': 'MT', 'Europe/Mariehamn': 'AX', 'Europe/Minsk': 'BY',
190
+ 'Europe/Monaco': 'MC', 'Europe/Moscow': 'RU', 'Europe/Nicosia': 'CY',
191
+ 'Europe/Oslo': 'NO', 'Europe/Paris': 'FR', 'Europe/Podgorica': 'ME',
192
+ 'Europe/Prague': 'CZ', 'Europe/Riga': 'LV', 'Europe/Rome': 'IT',
193
+ 'Europe/Samara': 'RU', 'Europe/San_Marino': 'SM', 'Europe/Sarajevo': 'BA',
194
+ 'Europe/Saratov': 'RU', 'Europe/Simferopol': 'UA', 'Europe/Skopje': 'MK',
195
+ 'Europe/Sofia': 'BG', 'Europe/Stockholm': 'SE', 'Europe/Tallinn': 'EE',
196
+ 'Europe/Tirane': 'AL', 'Europe/Tiraspol': 'MD', 'Europe/Ulyanovsk': 'RU',
197
+ 'Europe/Uzhgorod': 'UA', 'Europe/Vaduz': 'LI', 'Europe/Vatican': 'VA',
198
+ 'Europe/Vienna': 'AT', 'Europe/Vilnius': 'LT', 'Europe/Volgograd': 'RU',
199
+ 'Europe/Warsaw': 'PL', 'Europe/Zagreb': 'HR', 'Europe/Zaporozhye': 'UA',
200
+ 'Europe/Zurich': 'CH',
201
+ 'Arctic/Longyearbyen': 'SJ',
202
+ 'Eire': 'IE', 'GB': 'GB', 'GB-Eire': 'GB', 'Poland': 'PL', 'Portugal': 'PT',
203
+ 'W-SU': 'RU',
204
+
205
+ // Indian Ocean
206
+ 'Indian/Antananarivo': 'MG', 'Indian/Chagos': 'IO', 'Indian/Christmas': 'CX',
207
+ 'Indian/Cocos': 'CC', 'Indian/Comoro': 'KM', 'Indian/Kerguelen': 'TF',
208
+ 'Indian/Mahe': 'SC', 'Indian/Maldives': 'MV', 'Indian/Mauritius': 'MU',
209
+ 'Indian/Mayotte': 'YT', 'Indian/Reunion': 'RE',
210
+
211
+ // Pacific
212
+ 'Pacific/Apia': 'WS', 'Pacific/Auckland': 'NZ', 'Pacific/Bougainville': 'PG',
213
+ 'Pacific/Chatham': 'NZ', 'Pacific/Chuuk': 'FM', 'Pacific/Easter': 'CL',
214
+ 'Pacific/Efate': 'VU', 'Pacific/Enderbury': 'KI', 'Pacific/Fakaofo': 'TK',
215
+ 'Pacific/Fiji': 'FJ', 'Pacific/Funafuti': 'TV', 'Pacific/Galapagos': 'EC',
216
+ 'Pacific/Gambier': 'PF', 'Pacific/Guadalcanal': 'SB', 'Pacific/Guam': 'GU',
217
+ 'Pacific/Johnston': 'UM', 'Pacific/Kanton': 'KI', 'Pacific/Kiritimati': 'KI',
218
+ 'Pacific/Kosrae': 'FM', 'Pacific/Kwajalein': 'MH', 'Pacific/Majuro': 'MH',
219
+ 'Pacific/Marquesas': 'PF', 'Pacific/Midway': 'UM', 'Pacific/Nauru': 'NR',
220
+ 'Pacific/Niue': 'NU', 'Pacific/Norfolk': 'NF', 'Pacific/Noumea': 'NC',
221
+ 'Pacific/Pago_Pago': 'AS', 'Pacific/Palau': 'PW', 'Pacific/Pitcairn': 'PN',
222
+ 'Pacific/Pohnpei': 'FM', 'Pacific/Ponape': 'FM', 'Pacific/Port_Moresby': 'PG',
223
+ 'Pacific/Rarotonga': 'CK', 'Pacific/Saipan': 'MP', 'Pacific/Samoa': 'AS',
224
+ 'Pacific/Tahiti': 'PF', 'Pacific/Tarawa': 'KI', 'Pacific/Tongatapu': 'TO',
225
+ 'Pacific/Truk': 'FM', 'Pacific/Wake': 'UM', 'Pacific/Wallis': 'WF',
226
+ 'Pacific/Yap': 'FM',
227
+ 'Kwajalein': 'MH', 'NZ': 'NZ', 'NZ-CHAT': 'NZ', 'Navajo': 'US',
228
+ }
229
+
230
+ /**
231
+ * Resolve a browser IANA timezone to the stored country value (display name
232
+ * when known, ISO code otherwise — same convention as the header-based path).
233
+ * Unknown / global zones (UTC, Etc/*) resolve to undefined.
234
+ */
235
+ export function getCountryFromTimezone(tz?: string): string | undefined {
236
+ if (!tz || typeof tz !== 'string' || tz.length > 64)
237
+ return undefined
238
+ const code = TIMEZONE_COUNTRY[tz.trim()]
239
+ if (!code)
240
+ return undefined
241
+ return COUNTRY_NAMES[code] || code
242
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * User agent parsing utilities
3
+ */
4
+
5
+ export interface ParsedUserAgent {
6
+ deviceType: 'desktop' | 'mobile' | 'tablet'
7
+ browser: string
8
+ os: string
9
+ }
10
+
11
+ /**
12
+ * Parse user agent string to extract device, browser, and OS info
13
+ */
14
+ export function parseUserAgent(ua: string): ParsedUserAgent {
15
+ if (!ua || ua === 'unknown') {
16
+ return { deviceType: 'desktop', browser: 'Unknown', os: 'Unknown' }
17
+ }
18
+
19
+ // Detect device type
20
+ let deviceType: 'desktop' | 'mobile' | 'tablet' = 'desktop'
21
+ if (/mobile|android|iphone|ipod|blackberry|iemobile|opera mini/i.test(ua)) {
22
+ deviceType = 'mobile'
23
+ }
24
+ else if (/ipad|tablet|playbook|silk/i.test(ua)) {
25
+ deviceType = 'tablet'
26
+ }
27
+
28
+ // Detect browser - order matters (more specific first)
29
+ // Chromium-based browsers should be detected before Chrome
30
+ let browser = 'Unknown'
31
+ // Dia browser - check various possible formats
32
+ if (/\bdia\b|diahq|diabrowser/i.test(ua)) browser = 'Dia'
33
+ else if (/arc\//i.test(ua)) browser = 'Arc'
34
+ else if (/edg/i.test(ua)) browser = 'Edge'
35
+ else if (/opr\b|opera/i.test(ua)) browser = 'Opera'
36
+ else if (/brave/i.test(ua)) browser = 'Brave'
37
+ else if (/vivaldi/i.test(ua)) browser = 'Vivaldi'
38
+ else if (/yabrowser/i.test(ua)) browser = 'Yandex'
39
+ else if (/whale/i.test(ua)) browser = 'Whale'
40
+ else if (/puffin/i.test(ua)) browser = 'Puffin'
41
+ else if (/qqbrowser/i.test(ua)) browser = 'QQ Browser'
42
+ else if (/ucbrowser/i.test(ua)) browser = 'UC Browser'
43
+ else if (/samsungbrowser/i.test(ua)) browser = 'Samsung Internet'
44
+ else if (/silk/i.test(ua)) browser = 'Amazon Silk'
45
+ else if (/duckduckgo/i.test(ua)) browser = 'DuckDuckGo'
46
+ else if (/firefox|fxios/i.test(ua)) browser = 'Firefox'
47
+ else if (/chrome|chromium|crios/i.test(ua)) browser = 'Chrome'
48
+ else if (/safari/i.test(ua) && !/chrome|chromium/i.test(ua)) browser = 'Safari'
49
+ else if (/trident|msie/i.test(ua)) browser = 'IE'
50
+ else if (/bot|crawl|spider|slurp|bingpreview/i.test(ua)) browser = 'Bot'
51
+
52
+ // Detect OS
53
+ let os = 'Unknown'
54
+ if (/windows nt 10/i.test(ua)) os = 'Windows 10'
55
+ else if (/windows nt 11/i.test(ua)) os = 'Windows 11'
56
+ else if (/windows/i.test(ua)) os = 'Windows'
57
+ else if (/mac os x|macintosh/i.test(ua)) os = 'macOS'
58
+ else if (/iphone|ipad|ipod/i.test(ua)) os = 'iOS'
59
+ else if (/android/i.test(ua)) os = 'Android'
60
+ else if (/linux/i.test(ua)) os = 'Linux'
61
+ else if (/cros/i.test(ua)) os = 'Chrome OS'
62
+
63
+ return { deviceType, browser, os }
64
+ }
65
+
66
+ /**
67
+ * Whether a user agent is a bot / non-human client (#166).
68
+ *
69
+ * Three signals, applied at ingestion so bots never pollute the numbers
70
+ * (Fathom-style):
71
+ * 1. A MISSING or unrecognizably short UA — real browsers always send one;
72
+ * UA-less curl/scripts previously counted as human visitors.
73
+ * 2. Generic bot markers (bot/crawl/spider/preview...).
74
+ * 3. Known non-human client families: headless browsers, HTTP libraries,
75
+ * monitoring agents, and AI scrapers.
76
+ */
77
+ const BOT_UA_PATTERN = new RegExp([
78
+ // generic markers
79
+ 'bot|crawl|spider|slurp|preview|scan|probe|monitor(?:ing)?|checker|validator',
80
+ // headless / automation
81
+ 'headless|phantomjs|puppeteer|playwright|selenium|webdriver|electron',
82
+ // http libraries & CLI clients
83
+ 'curl|wget|python-requests|python-urllib|aiohttp|httpx|go-http-client|okhttp',
84
+ 'java/|libwww|lwp::|php/|guzzle|axios|node-fetch|undici|bun/',
85
+ // feed/link expanders, previews, misc known agents
86
+ 'facebookexternalhit|whatsapp|telegrambot|discordbot|slackbot|twitterbot',
87
+ 'pingdom|uptimerobot|statuscake|newrelic|datadog|site24x7',
88
+ // AI scrapers
89
+ 'gptbot|claudebot|ccbot|bytespider|amazonbot|anthropic|perplexity',
90
+ ].join('|'), 'i')
91
+
92
+ export function isBot(ua: string): boolean {
93
+ // No/garbage UA: every real browser sends a UA string. Treat absent or
94
+ // implausibly short values as non-human instead of counting them as
95
+ // visitors (previously `if (!ua) return false` let them ALL through).
96
+ if (!ua || ua === 'unknown' || ua.length < 12)
97
+ return true
98
+ return BOT_UA_PATTERN.test(ua)
99
+ }
100
+
101
+ /**
102
+ * Get browser family (simplifies browser name to family)
103
+ */
104
+ export function getBrowserFamily(browser: string): string {
105
+ const chromiumBased = ['Chrome', 'Edge', 'Opera', 'Brave', 'Vivaldi', 'Arc', 'Dia', 'Whale', 'Samsung Internet']
106
+ if (chromiumBased.includes(browser)) return 'Chromium'
107
+ if (browser === 'Firefox') return 'Firefox'
108
+ if (browser === 'Safari') return 'Safari'
109
+ return 'Other'
110
+ }
package/src/version.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Tracker/library version (#179) — embedded in the generated script, sent on
3
+ * every beacon (`v`), and aggregated into the hourly ingest counters, so
4
+ * support can tell exactly which tracker build a site is running instead of
5
+ * guessing at cache states. Kept in sync with package.json by a test.
6
+ */
7
+ export const TRACKER_VERSION: string = '0.1.0'