-
Notifications
You must be signed in to change notification settings - Fork 0
Add IP geolocation auto-detection for timezone and coordinates #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
📝 WalkthroughWalkthroughThis PR adds an IP geolocation service and integrates it into profile creation to detect exit IPs via proxies and persist timezone, coordinates, country code, and lastKnownIP. The Changes
Sequence Diagram(s)sequenceDiagram
participant Client as RoverfoxClient
participant ProxySvc as Proxy Service
participant GeoSvc as IPGeolocationService
participant IpApi as ip-api.com
participant DB as Database
participant Storage as StorageManager
Client->>ProxySvc: Request proxy details (proxyId)
ProxySvc-->>Client: Return ProxyConfig (host,port,cred)
Client->>GeoSvc: lookupThroughProxy(ProxyConfig)
GeoSvc->>GeoSvc: Check per-proxy IP cache
alt cache miss
GeoSvc->>GeoSvc: Resolve exit IP via proxy (https-proxy-agent)
GeoSvc->>IpApi: Query geolocation for exit IP
IpApi-->>GeoSvc: Return geo data (timezone, lat, lon, countryCode,...)
GeoSvc->>GeoSvc: Cache IP & geo result
end
GeoSvc-->>Client: { ip, geo }
Client->>DB: Insert/Update profile (proxyId, timezone, lat, lon, countryCode, lastKnownIP)
DB-->>Client: Confirm saved
Client->>Storage: setFingerprintingProperties(page, profile)
Storage->>Storage: Call page setters: setScreenDimensions, setAudioFingerprintSeed, setGeolocation, setTimezone
Storage-->>Client: Fingerprinting applied
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/client.ts (1)
78-103: Guard against empty proxyId results before querying proxies.
proxyIdis an array; an empty array is truthy and leads to.eq("id", undefined). This can trigger a bad query and skip proxy setup.🐛 Proposed fix
- const { data: proxyId } = await this.supabaseClient + const { data: account } = await this.supabaseClient .from("accounts") .select("proxyId") - .eq("browserId", browserId); + .eq("browserId", browserId) + .single(); + const proxyId = account?.proxyId ?? null; - if (proxyId) { + if (proxyId != null) { const { data: proxyData } = await this.supabaseClient .from("proxies") .select("entry, port, username, password") - .eq("id", proxyId?.[0]?.proxyId) + .eq("id", proxyId) .single();
🤖 Fix all issues with AI agents
In `@src/client.ts`:
- Around line 113-136: Before overwriting profile.data.lastKnownIP, save the
existing value to a temp variable and use that saved value in the debug log so
the "old -> new" message is accurate; specifically, in the block that handles
currentIP changes (the code referencing profile.data.lastKnownIP, currentIP,
geoData and calling this.supabaseClient.from("redrover_profile_data").update),
capture previousIP = profile.data.lastKnownIP before assigning
profile.data.lastKnownIP = currentIP and then use previousIP in the console.log
when changed is true.
In `@src/ip-geolocation.ts`:
- Around line 52-67: In getExitIP, the cache key currently uses only
proxy.host:proxy.port which can collide across different credentials and the
proxy URL uses raw credentials that can break with special chars; update the
cacheKey to incorporate the proxy.username and proxy.password (e.g., include
encodedUsername:encodedPassword) and URL-encode the credential parts when
building auth (use encodeURIComponent on proxy.username and proxy.password)
before composing proxyUrl and creating the HttpsProxyAgent so cached entries and
the proxy URL are both credential-aware and safe for special characters.
- Around line 69-95: The fetch call in the ipify lookup currently passes an
`agent` (and uses `AbortController`/`IPIFY_TIMEOUT_MS`) but Node's built-in
fetch ignores `agent`, causing proxy bypass; replace the `agent` usage with
undici's `ProxyAgent` (or `ProxyAgent` instance) and pass it to fetch as the
`dispatcher` option instead of `agent` (create the ProxyAgent where `agent` is
currently set and use `dispatcher: proxyAgent` in the fetch call), and move
`clearTimeout(timeout)` into a `finally` block surrounding the try/catch so the
timer is always cleared even when fetch throws; refer to the existing symbols
`agent`, `AbortController`, `IPIFY_TIMEOUT_MS`, and the fetch call to locate the
changes.
🧹 Nitpick comments (1)
src/ip-geolocation.ts (1)
152-175: Add a timeout to the ip-api request as well.
fetchFromAPIcan hang indefinitely on network stalls. Mirror the ipify timeout to keep profile creation/launch resilient.⏱️ Suggested pattern
- const response = await fetch(url); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), IPIFY_TIMEOUT_MS); + const response = await fetch(url, { signal: controller.signal }); + clearTimeout(timeout);
| if (currentIP && (changed || !profile.data.timezone)) { | ||
| const geoData = await this.geoService.lookup(currentIP); | ||
| if (geoData) { | ||
| profile.data.timezone = geoData.timezone; | ||
| profile.data.geolocation = { lat: geoData.lat, lon: geoData.lon }; | ||
| profile.data.countryCode = geoData.countryCode; | ||
| profile.data.lastKnownIP = currentIP; | ||
|
|
||
| // Persist to database | ||
| await this.supabaseClient | ||
| .from("redrover_profile_data") | ||
| .update({ data: profile.data }) | ||
| .eq("browser_id", browserId); | ||
|
|
||
| if (this.debug) { | ||
| if (changed) { | ||
| console.log( | ||
| `IP changed for ${browserId}: ${profile.data.lastKnownIP} -> ${currentIP}, updated geolocation to ${geoData.timezone}`, | ||
| ); | ||
| } else { | ||
| console.log( | ||
| `Geolocation set for ${browserId}: ${geoData.timezone}`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve the previous IP for accurate debug logs.
profile.data.lastKnownIP is overwritten before logging, so the “old → new” message becomes misleading.
💡 Suggested tweak
- if (geoData) {
+ if (geoData) {
+ const previousIP = profile.data.lastKnownIP ?? null;
profile.data.timezone = geoData.timezone;
profile.data.geolocation = { lat: geoData.lat, lon: geoData.lon };
profile.data.countryCode = geoData.countryCode;
profile.data.lastKnownIP = currentIP;
...
- `IP changed for ${browserId}: ${profile.data.lastKnownIP} -> ${currentIP}, updated geolocation to ${geoData.timezone}`,
+ `IP changed for ${browserId}: ${previousIP} -> ${currentIP}, updated geolocation to ${geoData.timezone}`,📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (currentIP && (changed || !profile.data.timezone)) { | |
| const geoData = await this.geoService.lookup(currentIP); | |
| if (geoData) { | |
| profile.data.timezone = geoData.timezone; | |
| profile.data.geolocation = { lat: geoData.lat, lon: geoData.lon }; | |
| profile.data.countryCode = geoData.countryCode; | |
| profile.data.lastKnownIP = currentIP; | |
| // Persist to database | |
| await this.supabaseClient | |
| .from("redrover_profile_data") | |
| .update({ data: profile.data }) | |
| .eq("browser_id", browserId); | |
| if (this.debug) { | |
| if (changed) { | |
| console.log( | |
| `IP changed for ${browserId}: ${profile.data.lastKnownIP} -> ${currentIP}, updated geolocation to ${geoData.timezone}`, | |
| ); | |
| } else { | |
| console.log( | |
| `Geolocation set for ${browserId}: ${geoData.timezone}`, | |
| ); | |
| } | |
| if (currentIP && (changed || !profile.data.timezone)) { | |
| const geoData = await this.geoService.lookup(currentIP); | |
| if (geoData) { | |
| const previousIP = profile.data.lastKnownIP ?? null; | |
| profile.data.timezone = geoData.timezone; | |
| profile.data.geolocation = { lat: geoData.lat, lon: geoData.lon }; | |
| profile.data.countryCode = geoData.countryCode; | |
| profile.data.lastKnownIP = currentIP; | |
| // Persist to database | |
| await this.supabaseClient | |
| .from("redrover_profile_data") | |
| .update({ data: profile.data }) | |
| .eq("browser_id", browserId); | |
| if (this.debug) { | |
| if (changed) { | |
| console.log( | |
| `IP changed for ${browserId}: ${previousIP} -> ${currentIP}, updated geolocation to ${geoData.timezone}`, | |
| ); | |
| } else { | |
| console.log( | |
| `Geolocation set for ${browserId}: ${geoData.timezone}`, | |
| ); | |
| } |
🤖 Prompt for AI Agents
In `@src/client.ts` around lines 113 - 136, Before overwriting
profile.data.lastKnownIP, save the existing value to a temp variable and use
that saved value in the debug log so the "old -> new" message is accurate;
specifically, in the block that handles currentIP changes (the code referencing
profile.data.lastKnownIP, currentIP, geoData and calling
this.supabaseClient.from("redrover_profile_data").update), capture previousIP =
profile.data.lastKnownIP before assigning profile.data.lastKnownIP = currentIP
and then use previousIP in the console.log when changed is true.
| async getExitIP(proxy: ProxyConfig): Promise<string | null> { | ||
| const cacheKey = `${proxy.host}:${proxy.port}`; | ||
|
|
||
| // Check IP cache (shorter TTL since IPs can rotate) | ||
| const cached = this.ipCache.get(cacheKey); | ||
| if (cached && Date.now() - cached.timestamp < IP_CACHE_TTL_MS) { | ||
| return cached.ip; | ||
| } | ||
|
|
||
| try { | ||
| // Build proxy URL for the agent | ||
| const auth = proxy.username && proxy.password | ||
| ? `${proxy.username}:${proxy.password}@` | ||
| : ""; | ||
| const proxyUrl = `http://${auth}${proxy.host}:${proxy.port}`; | ||
| const agent = new HttpsProxyAgent(proxyUrl); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Include proxy credentials in the cache key and URL-encode auth.
Caching only by host:port can collide across different proxy credentials (common with session-based usernames), and raw credentials in the URL will break on special characters.
🐛 Proposed fix
- const cacheKey = `${proxy.host}:${proxy.port}`;
+ const user = proxy.username ?? "";
+ const pass = proxy.password ?? "";
+ const cacheKey = `${proxy.host}:${proxy.port}:${user}:${pass}`;
- const auth = proxy.username && proxy.password
- ? `${proxy.username}:${proxy.password}@`
+ const auth = proxy.username && proxy.password
+ ? `${encodeURIComponent(proxy.username)}:${encodeURIComponent(proxy.password)}@`
: "";🤖 Prompt for AI Agents
In `@src/ip-geolocation.ts` around lines 52 - 67, In getExitIP, the cache key
currently uses only proxy.host:proxy.port which can collide across different
credentials and the proxy URL uses raw credentials that can break with special
chars; update the cacheKey to incorporate the proxy.username and proxy.password
(e.g., include encodedUsername:encodedPassword) and URL-encode the credential
parts when building auth (use encodeURIComponent on proxy.username and
proxy.password) before composing proxyUrl and creating the HttpsProxyAgent so
cached entries and the proxy URL are both credential-aware and safe for special
characters.
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), IPIFY_TIMEOUT_MS); | ||
|
|
||
| const response = await fetch("https://api.ipify.org?format=json", { | ||
| // @ts-ignore - Node.js fetch supports agent | ||
| agent, | ||
| signal: controller.signal, | ||
| }); | ||
|
|
||
| clearTimeout(timeout); | ||
|
|
||
| if (!response.ok) { | ||
| console.warn(`ipify request failed: ${response.status}`); | ||
| return null; | ||
| } | ||
|
|
||
| const data = await response.json(); | ||
| const ip = data.ip; | ||
|
|
||
| if (ip) { | ||
| this.ipCache.set(cacheKey, { ip, timestamp: Date.now() }); | ||
| } | ||
|
|
||
| return ip || null; | ||
| } catch (error) { | ||
| console.error(`Failed to get exit IP through proxy:`, error); | ||
| return null; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
Node.js built-in fetch agent option proxy support undici ProxyAgent
💡 Result:
Node’s built-in fetch (Node 18+) is implemented on top of undici, and it intentionally does not support the classic { agent } option from http(s).request / node-fetch. Instead, undici uses a Dispatcher model, and undici’s fetch supports a non-standard dispatcher option. [1]
Use a proxy with built-in fetch (recommended: set a global dispatcher)
Install undici and set a ProxyAgent as the global dispatcher:
import { ProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new ProxyAgent('http://user:pass@proxy-host:8080'));
const res = await fetch('https://example.com');
console.log(await res.text());ProxyAgent is a Dispatcher implementation designed for proxying. [2] setGlobalDispatcher() sets the dispatcher used by undici APIs (and is shared with Node’s bundled undici used by global fetch). [1]
Per-request proxy (works with undici’s fetch option shape)
import { ProxyAgent } from 'undici';
const proxy = new ProxyAgent('http://proxy-host:8080');
const res = await fetch('https://example.com', {
dispatcher: proxy,
});The dispatcher option is part of undici’s fetch API. [1]
Proxy via environment variables (HTTP_PROXY / HTTPS_PROXY / NO_PROXY)
Use EnvHttpProxyAgent, which reads http_proxy / https_proxy / no_proxy (and uppercase variants) and routes accordingly:
import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici';
setGlobalDispatcher(new EnvHttpProxyAgent());
const res = await fetch('https://example.com');Behavior and env var support are documented in EnvHttpProxyAgent. [3]
Note (TypeScript): because dispatcher is not part of the web-standard fetch types, you may need to cast the init object (or call undici.fetch directly) when doing per-request dispatcher usage. [1]
Sources:
[1] https://github.com/nodejs/undici (docs: fetch(..., { dispatcher }), setGlobalDispatcher)
[2] https://undici-docs.vramana.dev/docs/next/api/ProxyAgent
[3] https://undici-docs.vramana.dev/docs/api/EnvHttpProxyAgent
Node's built-in fetch doesn't support the agent option; proxy request will bypass the proxy.
Node.js's built-in fetch intentionally ignores the agent option and instead uses undici's Dispatcher model. This means the current HttpsProxyAgent won't be honored, and the request will connect directly, leaking the server's IP instead of your proxy's IP. Use undici's ProxyAgent with the dispatcher option instead. Also move clearTimeout to a finally block to prevent timer leaks if fetch fails.
Fix using undici ProxyAgent
-import { HttpsProxyAgent } from "https-proxy-agent";
+import { ProxyAgent } from "undici";
- const agent = new HttpsProxyAgent(proxyUrl);
+ const dispatcher = new ProxyAgent(proxyUrl);
- const response = await fetch("https://api.ipify.org?format=json", {
- // `@ts-ignore` - Node.js fetch supports agent
- agent,
- signal: controller.signal,
- });
-
- clearTimeout(timeout);
+ let response;
+ try {
+ response = await fetch("https://api.ipify.org?format=json", {
+ dispatcher,
+ signal: controller.signal,
+ });
+ } finally {
+ clearTimeout(timeout);
+ }🤖 Prompt for AI Agents
In `@src/ip-geolocation.ts` around lines 69 - 95, The fetch call in the ipify
lookup currently passes an `agent` (and uses
`AbortController`/`IPIFY_TIMEOUT_MS`) but Node's built-in fetch ignores `agent`,
causing proxy bypass; replace the `agent` usage with undici's `ProxyAgent` (or
`ProxyAgent` instance) and pass it to fetch as the `dispatcher` option instead
of `agent` (create the ProxyAgent where `agent` is currently set and use
`dispatcher: proxyAgent` in the fetch call), and move `clearTimeout(timeout)`
into a `finally` block surrounding the try/catch so the timer is always cleared
even when fetch throws; refer to the existing symbols `agent`,
`AbortController`, `IPIFY_TIMEOUT_MS`, and the fetch call to locate the changes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/client.ts (1)
105-130:⚠️ Potential issue | 🟡 MinorGuard against empty proxyId results before querying proxies.
The first
.select()returns an array; when no rows match, it returns an empty array[]which is truthy in JavaScript. This causes theif (proxyId)branch to execute withproxyIdas[], makingproxyId?.[0]?.proxyIdresolve toundefined. The subsequent proxy query then runs with.eq("id", undefined), which is semantically incorrect.Use
.single()or.maybeSingle()on the initial query to normalize the result (or checkproxyId.length > 0instead).🔧 Suggested fix
- const { data: proxyId } = await this.supabaseClient + const { data: account } = await this.supabaseClient .from("accounts") .select("proxyId") - .eq("browserId", browserId); + .eq("browserId", browserId) + .single(); - let proxyObject: RoverfoxProxyObject = null; + const proxyId = account?.proxyId ?? null; + let proxyObject: RoverfoxProxyObject = null; let proxyConfig: ProxyConfig | null = null; - if (proxyId) { + if (proxyId !== null) { const { data: proxyData } = await this.supabaseClient .from("proxies") .select("entry, port, username, password") - .eq("id", proxyId?.[0]?.proxyId) + .eq("id", proxyId) .single();
🧹 Nitpick comments (1)
src/storage-manager.ts (1)
95-106: Consider explicit undefined checks for numeric fields.
Using truthy checks can skip valid values like0.♻️ Suggested tweak
- if (audioFingerprintSeed && _window.setAudioFingerprintSeed) { + if (audioFingerprintSeed !== undefined && _window.setAudioFingerprintSeed) { _window.setAudioFingerprintSeed(audioFingerprintSeed); } @@ - if (screenDimensions.colorDepth && _window.setScreenColorDepth) { + if (screenDimensions.colorDepth !== undefined && _window.setScreenColorDepth) { _window.setScreenColorDepth(screenDimensions.colorDepth); }
Summary
IPGeolocationServicethat automatically detects timezone and coordinates from proxy exit IPsetFingerprintingProperties()to setwindow.setTimezone()andwindow.setGeolocation()Changes
ip-geolocation.tsservice using ip-api.com Pro APIlaunchProfile()now checks for IP changes and updates geo data automaticallycreateProfile()now accepts optionalproxyStateparameter (for proxy rotation compatibility)Summary by CodeRabbit
New Features
Chores