@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,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
|
+
|