@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,515 @@
1
+ /**
2
+ * IP Geolocation Module
3
+ *
4
+ * Provides IP-to-location resolution for analytics tracking.
5
+ * Uses ts-countries for geographic data enrichment.
6
+ */
7
+
8
+ // ts-countries loaded dynamically
9
+ export type GeoLocation = any
10
+ const loadTsCountries = async (): Promise<any> => await (import('ts-countries' as string) as Promise<any>)
11
+
12
+ // ============================================================================
13
+ // Types
14
+ // ============================================================================
15
+
16
+ /**
17
+ * IP geolocation result
18
+ */
19
+ export interface IPGeoResult {
20
+ /** IP address that was looked up */
21
+ ip: string
22
+ /** ISO 3166-1 alpha-2 country code */
23
+ countryCode: string
24
+ /** Country name */
25
+ country: string
26
+ /** Region/state code */
27
+ regionCode?: string
28
+ /** Region/state name */
29
+ region?: string
30
+ /** City name */
31
+ city?: string
32
+ /** Metro area */
33
+ metro?: string
34
+ /** Postal/ZIP code */
35
+ postalCode?: string
36
+ /** Latitude */
37
+ latitude?: number
38
+ /** Longitude */
39
+ longitude?: number
40
+ /** Timezone identifier */
41
+ timezone?: string
42
+ /** Accuracy radius in km */
43
+ accuracyRadius?: number
44
+ /** ISP name */
45
+ isp?: string
46
+ /** Organization name */
47
+ organization?: string
48
+ /** Autonomous System Number */
49
+ asn?: number
50
+ }
51
+
52
+ /**
53
+ * Geolocation provider interface
54
+ */
55
+ export interface GeoProvider {
56
+ name: string
57
+ lookup: (ip: string) => Promise<IPGeoResult | null>
58
+ }
59
+
60
+ /**
61
+ * Geolocation service configuration
62
+ */
63
+ export interface GeoServiceConfig {
64
+ /** Primary provider */
65
+ provider?: GeoProvider
66
+ /** Fallback providers */
67
+ fallbackProviders?: GeoProvider[]
68
+ /** Cache TTL in seconds */
69
+ cacheTtlSeconds?: number
70
+ /** Enable caching */
71
+ enableCache?: boolean
72
+ /** Privacy mode - anonymize city-level data */
73
+ privacyMode?: boolean
74
+ }
75
+
76
+ // ============================================================================
77
+ // Built-in Providers
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Cloudflare headers provider - uses CF-* headers from Cloudflare
82
+ */
83
+ export function createCloudflareProvider(): GeoProvider {
84
+ return {
85
+ name: 'cloudflare',
86
+ lookup: async (ip: string): Promise<IPGeoResult | null> => {
87
+ // This provider expects CF headers to be passed in a special format
88
+ // In practice, you'd extract these from request headers
89
+ return null
90
+ },
91
+ }
92
+ }
93
+
94
+ /**
95
+ * MaxMind GeoLite2 provider
96
+ * Requires maxmind package and GeoLite2-City.mmdb database
97
+ */
98
+ export function createMaxMindProvider(databasePath?: string): GeoProvider {
99
+ return {
100
+ name: 'maxmind',
101
+ lookup: async (ip: string): Promise<IPGeoResult | null> => {
102
+ try {
103
+ // Dynamic import to avoid requiring maxmind if not used
104
+ const { Reader } = await (import('maxmind' as string) as Promise<any>)
105
+ const dbPath = databasePath || process.env.MAXMIND_DB_PATH || './GeoLite2-City.mmdb'
106
+
107
+ const reader = await Reader.open(dbPath)
108
+ const result = reader.get(ip) as any
109
+
110
+ if (!result) {
111
+ return null
112
+ }
113
+
114
+ return {
115
+ ip,
116
+ countryCode: result.country?.iso_code || '',
117
+ country: result.country?.names?.en || '',
118
+ regionCode: result.subdivisions?.[0]?.iso_code,
119
+ region: result.subdivisions?.[0]?.names?.en,
120
+ city: result.city?.names?.en,
121
+ postalCode: result.postal?.code,
122
+ latitude: result.location?.latitude,
123
+ longitude: result.location?.longitude,
124
+ timezone: result.location?.time_zone,
125
+ accuracyRadius: result.location?.accuracy_radius,
126
+ }
127
+ }
128
+ catch {
129
+ return null
130
+ }
131
+ },
132
+ }
133
+ }
134
+
135
+ /**
136
+ * IP-API.com free provider (limited to 45 req/min)
137
+ */
138
+ export function createIpApiProvider(): GeoProvider {
139
+ return {
140
+ name: 'ip-api',
141
+ lookup: async (ip: string): Promise<IPGeoResult | null> => {
142
+ try {
143
+ const response = await fetch(`http://ip-api.com/json/${ip}?fields=66846719`)
144
+
145
+ if (!response.ok) {
146
+ return null
147
+ }
148
+
149
+ const data = await response.json() as any
150
+
151
+ if (data.status !== 'success') {
152
+ return null
153
+ }
154
+
155
+ return {
156
+ ip,
157
+ countryCode: data.countryCode || '',
158
+ country: data.country || '',
159
+ regionCode: data.region,
160
+ region: data.regionName,
161
+ city: data.city,
162
+ postalCode: data.zip,
163
+ latitude: data.lat,
164
+ longitude: data.lon,
165
+ timezone: data.timezone,
166
+ isp: data.isp,
167
+ organization: data.org,
168
+ asn: data.as ? Number.parseInt(data.as.split(' ')[0].replace('AS', '')) : undefined,
169
+ }
170
+ }
171
+ catch {
172
+ return null
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Get country name safely (handles missing data files)
180
+ */
181
+ function getCountryName(countryCode: string): string {
182
+ try {
183
+ return countryCode
184
+ }
185
+ catch {
186
+ // Country data files not available, return code as name
187
+ return countryCode
188
+ }
189
+ }
190
+
191
+ /**
192
+ * Cloudflare request headers extractor
193
+ */
194
+ export function extractCloudflareGeo(headers: Record<string, string>): IPGeoResult | null {
195
+ const ip = headers['cf-connecting-ip'] || headers['x-forwarded-for']?.split(',')[0]?.trim()
196
+ const countryCode = headers['cf-ipcountry']
197
+
198
+ if (!ip || !countryCode) {
199
+ return null
200
+ }
201
+
202
+ return {
203
+ ip,
204
+ countryCode,
205
+ country: getCountryName(countryCode),
206
+ regionCode: headers['cf-region-code'],
207
+ region: headers['cf-region'],
208
+ city: headers['cf-ipcity'],
209
+ latitude: headers['cf-iplat'] ? Number.parseFloat(headers['cf-iplat']) : undefined,
210
+ longitude: headers['cf-iplon'] ? Number.parseFloat(headers['cf-iplon']) : undefined,
211
+ timezone: headers['cf-timezone'],
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Vercel request headers extractor
217
+ */
218
+ export function extractVercelGeo(headers: Record<string, string>): IPGeoResult | null {
219
+ const ip = headers['x-forwarded-for']?.split(',')[0]?.trim() || headers['x-real-ip']
220
+ const countryCode = headers['x-vercel-ip-country']
221
+
222
+ if (!ip || !countryCode) {
223
+ return null
224
+ }
225
+
226
+ return {
227
+ ip,
228
+ countryCode,
229
+ country: getCountryName(countryCode),
230
+ regionCode: headers['x-vercel-ip-country-region'],
231
+ city: headers['x-vercel-ip-city'],
232
+ latitude: headers['x-vercel-ip-latitude'] ? Number.parseFloat(headers['x-vercel-ip-latitude']) : undefined,
233
+ longitude: headers['x-vercel-ip-longitude'] ? Number.parseFloat(headers['x-vercel-ip-longitude']) : undefined,
234
+ }
235
+ }
236
+
237
+ // ============================================================================
238
+ // Geolocation Service
239
+ // ============================================================================
240
+
241
+ /**
242
+ * Main geolocation service
243
+ */
244
+ export class GeolocationService {
245
+ private config: Required<GeoServiceConfig>
246
+ private cache: Map<string, { result: IPGeoResult, expires: number }> = new Map()
247
+
248
+ constructor(config: GeoServiceConfig = {}) {
249
+ this.config = {
250
+ provider: config.provider || createIpApiProvider(),
251
+ fallbackProviders: config.fallbackProviders || [],
252
+ cacheTtlSeconds: config.cacheTtlSeconds || 3600,
253
+ enableCache: config.enableCache ?? true,
254
+ privacyMode: config.privacyMode ?? false,
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Lookup IP address
260
+ */
261
+ async lookup(ip: string): Promise<IPGeoResult | null> {
262
+ // Skip private/local IPs
263
+ if (this.isPrivateIP(ip)) {
264
+ return null
265
+ }
266
+
267
+ // Check cache
268
+ if (this.config.enableCache) {
269
+ const cached = this.cache.get(ip)
270
+ if (cached && cached.expires > Date.now()) {
271
+ return cached.result
272
+ }
273
+ }
274
+
275
+ // Try primary provider
276
+ let result = await this.config.provider.lookup(ip)
277
+
278
+ // Try fallback providers
279
+ if (!result) {
280
+ for (const provider of this.config.fallbackProviders) {
281
+ result = await provider.lookup(ip)
282
+ if (result) {
283
+ break
284
+ }
285
+ }
286
+ }
287
+
288
+ if (!result) {
289
+ return null
290
+ }
291
+
292
+ // Enrich with ts-countries data
293
+ result = await this.enrichResult(result)
294
+
295
+ // Apply privacy mode
296
+ if (this.config.privacyMode) {
297
+ result = this.applyPrivacy(result)
298
+ }
299
+
300
+ // Cache result
301
+ if (this.config.enableCache) {
302
+ this.cache.set(ip, {
303
+ result,
304
+ expires: Date.now() + this.config.cacheTtlSeconds * 1000,
305
+ })
306
+ }
307
+
308
+ return result
309
+ }
310
+
311
+ /**
312
+ * Lookup from request headers (Cloudflare, Vercel, etc.)
313
+ */
314
+ async lookupFromHeaders(headers: Record<string, string>): Promise<IPGeoResult | null> {
315
+ // Try Cloudflare first
316
+ let result = extractCloudflareGeo(headers)
317
+
318
+ // Try Vercel
319
+ if (!result) {
320
+ result = extractVercelGeo(headers)
321
+ }
322
+
323
+ if (!result) {
324
+ return null
325
+ }
326
+
327
+ // Enrich with ts-countries data
328
+ result = await this.enrichResult(result)
329
+
330
+ // Apply privacy mode
331
+ if (this.config.privacyMode) {
332
+ result = this.applyPrivacy(result)
333
+ }
334
+
335
+ return result
336
+ }
337
+
338
+ /**
339
+ * Enrich result with ts-countries data
340
+ */
341
+ private async enrichResult(result: IPGeoResult): Promise<IPGeoResult> {
342
+ try {
343
+ const tsCountries = await loadTsCountries();
344
+ // Try to enhance city/region info using ts-countries
345
+ if (result.countryCode && result.latitude && result.longitude) {
346
+ const geoLocation = tsCountries.GeoResolver.resolveCoordinates(
347
+ result.latitude,
348
+ result.longitude,
349
+ { countryCodeHint: result.countryCode },
350
+ )
351
+
352
+ if (geoLocation) {
353
+ // Use ts-countries data for more accurate city/metro info
354
+ if (geoLocation.city && !result.city) {
355
+ result.city = geoLocation.city
356
+ }
357
+ if (geoLocation.metro && !result.metro) {
358
+ result.metro = geoLocation.metro
359
+ }
360
+ if (geoLocation.region && !result.region) {
361
+ result.region = geoLocation.region
362
+ }
363
+ if (geoLocation.regionCode && !result.regionCode) {
364
+ result.regionCode = geoLocation.regionCode
365
+ }
366
+ if (geoLocation.timezone && !result.timezone) {
367
+ result.timezone = geoLocation.timezone
368
+ }
369
+ }
370
+ }
371
+
372
+ // Try to find city by name if we have city but no lat/lon
373
+ if (result.city && result.countryCode && !result.latitude) {
374
+ const city = tsCountries.findCity(result.city, result.countryCode, result.regionCode)
375
+ if (city) {
376
+ result.latitude = city.getLatitude()
377
+ result.longitude = city.getLongitude()
378
+ result.metro = city.getMetro()
379
+ result.timezone = city.getTimezone()
380
+ }
381
+ }
382
+ }
383
+ catch {
384
+ // ts-countries data not available, return result as-is
385
+ }
386
+
387
+ return result
388
+ }
389
+
390
+ /**
391
+ * Apply privacy mode - remove city-level precision
392
+ */
393
+ private applyPrivacy(result: IPGeoResult): IPGeoResult {
394
+ return {
395
+ ...result,
396
+ city: undefined,
397
+ postalCode: undefined,
398
+ // Round coordinates to region level (~10km)
399
+ latitude: result.latitude ? Math.round(result.latitude * 10) / 10 : undefined,
400
+ longitude: result.longitude ? Math.round(result.longitude * 10) / 10 : undefined,
401
+ }
402
+ }
403
+
404
+ /**
405
+ * Check if IP is private/local
406
+ */
407
+ private isPrivateIP(ip: string): boolean {
408
+ // IPv4 private ranges
409
+ const privateRanges = [
410
+ /^10\./,
411
+ /^172\.(1[6-9]|2[0-9]|3[0-1])\./,
412
+ /^192\.168\./,
413
+ /^127\./,
414
+ /^0\./,
415
+ /^::1$/,
416
+ /^fc00:/i,
417
+ /^fd00:/i,
418
+ /^fe80:/i,
419
+ ]
420
+
421
+ return privateRanges.some(range => range.test(ip))
422
+ }
423
+
424
+ /**
425
+ * Clear cache
426
+ */
427
+ clearCache(): void {
428
+ this.cache.clear()
429
+ }
430
+
431
+ /**
432
+ * Get cache stats
433
+ */
434
+ getCacheStats(): { size: number, hitRate: number } {
435
+ return {
436
+ size: this.cache.size,
437
+ hitRate: 0, // Would need to track hits/misses
438
+ }
439
+ }
440
+ }
441
+
442
+ // ============================================================================
443
+ // Convenience Functions
444
+ // ============================================================================
445
+
446
+ let defaultService: GeolocationService | null = null
447
+
448
+ /**
449
+ * Get or create default geolocation service
450
+ */
451
+ export function getGeolocationService(config?: GeoServiceConfig): GeolocationService {
452
+ if (!defaultService) {
453
+ defaultService = new GeolocationService(config)
454
+ }
455
+ return defaultService
456
+ }
457
+
458
+ /**
459
+ * Lookup IP address using default service
460
+ */
461
+ export async function lookupIP(ip: string): Promise<IPGeoResult | null> {
462
+ return getGeolocationService().lookup(ip)
463
+ }
464
+
465
+ /**
466
+ * Lookup from request headers using default service
467
+ */
468
+ export async function lookupFromHeaders(headers: Record<string, string>): Promise<IPGeoResult | null> {
469
+ return getGeolocationService().lookupFromHeaders(headers)
470
+ }
471
+
472
+ /**
473
+ * Format location for display
474
+ */
475
+ export function formatGeoLocation(result: IPGeoResult): string {
476
+ const parts: string[] = []
477
+
478
+ if (result.city) {
479
+ parts.push(result.city)
480
+ }
481
+
482
+ if (result.region) {
483
+ parts.push(result.region)
484
+ }
485
+ else if (result.regionCode) {
486
+ parts.push(result.regionCode)
487
+ }
488
+
489
+ if (result.country) {
490
+ parts.push(result.country)
491
+ }
492
+
493
+ return parts.join(', ')
494
+ }
495
+
496
+ /**
497
+ * Format short location (City, State/Region)
498
+ */
499
+ export function formatGeoLocationShort(result: IPGeoResult): string {
500
+ if (result.city && result.regionCode) {
501
+ return `${result.city}, ${result.regionCode}`
502
+ }
503
+
504
+ if (result.city) {
505
+ return result.city
506
+ }
507
+
508
+ if (result.region) {
509
+ return result.region
510
+ }
511
+
512
+ return result.country || result.countryCode
513
+ }
514
+
515
+