diff --git a/public/sw.js b/public/sw.js index 3266d21..31ad960 100644 --- a/public/sw.js +++ b/public/sw.js @@ -17,9 +17,19 @@ const sj = new ScramjetServiceWorker({ } }); +// Cache for routing decisions to avoid redundant checks +const routeCache = new Map(); +const ROUTE_CACHE_MAX_SIZE = 500; +const ROUTE_CACHE_TTL = 30000; // 30 seconds +const CACHE_EVICT_COUNT = 50; // Evict 10% of cache + +// Cache for domain checks to avoid redundant string operations +const domainCheckCache = new Map(); +const DOMAIN_CHECK_CACHE_MAX_SIZE = 500; + // Enhanced CAPTCHA and Cloudflare verification support -// List of CAPTCHA and verification domains that need special handling -const CAPTCHA_DOMAINS = [ +// Use Set for O(1) lookup instead of Array with some() +const CAPTCHA_DOMAINS = new Set([ "google.com/recaptcha", "www.google.com/recaptcha", "recaptcha.net", @@ -30,10 +40,10 @@ const CAPTCHA_DOMAINS = [ "challenges.cloudflare.com", "cloudflare.com/cdn-cgi/challenge", "turnstile.cloudflare.com" -]; +]); // Domains that use heavy cookies and complex browser services -const HEAVY_COOKIE_DOMAINS = [ +const HEAVY_COOKIE_DOMAINS = new Set([ "amazon.com", "ebay.com", "walmart.com", @@ -47,20 +57,69 @@ const HEAVY_COOKIE_DOMAINS = [ "apple.com", "netflix.com", "spotify.com" -]; +]); -// Helper function to check if URL is CAPTCHA-related +// Optimized helper function to check if URL is CAPTCHA-related function isCaptchaRequest(url) { + const cacheKey = "captcha:" + url; + if (domainCheckCache.has(cacheKey)) { + return domainCheckCache.get(cacheKey); + } + const urlStr = url.toString().toLowerCase(); - return CAPTCHA_DOMAINS.some((domain) => urlStr.includes(domain)); + let result = false; + for (const domain of CAPTCHA_DOMAINS) { + if (urlStr.includes(domain)) { + result = true; + break; + } + } + + // Cache with size limit and batch eviction + if (domainCheckCache.size >= DOMAIN_CHECK_CACHE_MAX_SIZE) { + let evicted = 0; + for (const key of domainCheckCache.keys()) { + if (evicted >= CACHE_EVICT_COUNT) break; + domainCheckCache.delete(key); + evicted++; + } + } + domainCheckCache.set(cacheKey, result); + return result; } -// Helper function to check if URL is from a site with heavy cookies +// Optimized helper function to check if URL is from a site with heavy cookies function isHeavyCookieSite(url) { + const cacheKey = "heavy:" + url; + if (domainCheckCache.has(cacheKey)) { + return domainCheckCache.get(cacheKey); + } + const urlStr = url.toString().toLowerCase(); - return HEAVY_COOKIE_DOMAINS.some((domain) => urlStr.includes(domain)); + let result = false; + for (const domain of HEAVY_COOKIE_DOMAINS) { + if (urlStr.includes(domain)) { + result = true; + break; + } + } + + // Cache with size limit and batch eviction + if (domainCheckCache.size >= DOMAIN_CHECK_CACHE_MAX_SIZE) { + let evicted = 0; + for (const key of domainCheckCache.keys()) { + if (evicted >= CACHE_EVICT_COUNT) break; + domainCheckCache.delete(key); + evicted++; + } + } + domainCheckCache.set(cacheKey, result); + return result; } +// Reusable headers for CAPTCHA requests +const CAPTCHA_HEADERS_TEMPLATE = { Accept: "*/*" }; + // Helper function to ensure proper CAPTCHA handling function enhanceCaptchaRequest(request) { // Clone the request to ensure all headers and properties are preserved @@ -68,7 +127,7 @@ function enhanceCaptchaRequest(request) { // Ensure proper headers for CAPTCHA requests if (!headers.has("Accept")) { - headers.set("Accept", "*/*"); + headers.set("Accept", CAPTCHA_HEADERS_TEMPLATE.Accept); } // Preserve credentials for CAPTCHA cookies @@ -91,29 +150,67 @@ function enhanceHeavyCookieRequest(request) { }); } +// Cache sj config loading status +let sjConfigLoaded = false; + self.addEventListener("fetch", function (event) { event.respondWith( (async () => { try { - await sj.loadConfig(); + // Only load config once per SW lifecycle + if (!sjConfigLoaded) { + await sj.loadConfig(); + sjConfigLoaded = true; + } const url = event.request.url; - const isCaptcha = isCaptchaRequest(url); - const isHeavyCookie = isHeavyCookieSite(url); - // Safely check if this is a proxied request using optional chaining - const uvPrefix = - (typeof __uv$config !== "undefined" && __uv$config?.prefix) || null; - const isUvRequest = uvPrefix ? url.startsWith(location.origin + uvPrefix) : false; - const isSjRequest = sj.route(event); - const isProxiedRequest = isUvRequest || isSjRequest; + // Check route cache for faster routing + let routeInfo = routeCache.get(url); + const now = Date.now(); + + if (!routeInfo || now - routeInfo.timestamp > ROUTE_CACHE_TTL) { + // Safely check if this is a proxied request using optional chaining + const uvPrefix = + (typeof __uv$config !== "undefined" && __uv$config?.prefix) || null; + const isUvRequest = uvPrefix + ? url.startsWith(location.origin + uvPrefix) + : false; + const isSjRequest = sj.route(event); + + routeInfo = { + isUvRequest, + isSjRequest, + isProxiedRequest: isUvRequest || isSjRequest, + timestamp: now + }; - // Enhanced handling for CAPTCHA and heavy cookie requests + // Cache with size limit and batch eviction + if (routeCache.size >= ROUTE_CACHE_MAX_SIZE) { + let evicted = 0; + for (const key of routeCache.keys()) { + if (evicted >= CACHE_EVICT_COUNT) break; + routeCache.delete(key); + evicted++; + } + } + routeCache.set(url, routeInfo); + } + + const { isUvRequest, isSjRequest, isProxiedRequest } = routeInfo; + + // Only check CAPTCHA and heavy cookie for non-proxied requests let request = event.request; - if (isCaptcha) { - request = enhanceCaptchaRequest(event.request); - } else if (isHeavyCookie) { - request = enhanceHeavyCookieRequest(event.request); + if (!isProxiedRequest) { + const isCaptcha = isCaptchaRequest(url); + if (isCaptcha) { + request = enhanceCaptchaRequest(event.request); + } else { + const isHeavyCookie = isHeavyCookieSite(url); + if (isHeavyCookie) { + request = enhanceHeavyCookieRequest(event.request); + } + } } let response; @@ -205,6 +302,11 @@ const INTERCEPTOR_SCRIPT = ` `; +// Precompiled regex patterns for better performance +const HEAD_TAG_REGEX = /]*)?>/i; +const BODY_TAG_REGEX = /]*)?>/i; +const HTML_TAG_REGEX = /]*)?>/i; + // Helper function to inject script into HTML responses async function injectInterceptorScript(response) { const contentType = response.headers.get("content-type") || ""; @@ -226,26 +328,25 @@ async function injectInterceptorScript(response) { }); } - // Inject the script just after opening tags - // Handle both normal opening tags and self-closing tags + // Inject the script just after opening tags using precompiled regex let modifiedHtml = text; let injected = false; // Try to inject after tag (normal or self-closing) - if (!injected && /]*)?>/i.test(text)) { - modifiedHtml = text.replace(/]*)?>/i, (match) => match + INTERCEPTOR_SCRIPT); + if (!injected && HEAD_TAG_REGEX.test(text)) { + modifiedHtml = text.replace(HEAD_TAG_REGEX, (match) => match + INTERCEPTOR_SCRIPT); injected = true; } // Fallback: inject after tag (normal or self-closing) - if (!injected && /]*)?>/i.test(text)) { - modifiedHtml = text.replace(/]*)?>/i, (match) => match + INTERCEPTOR_SCRIPT); + if (!injected && BODY_TAG_REGEX.test(text)) { + modifiedHtml = text.replace(BODY_TAG_REGEX, (match) => match + INTERCEPTOR_SCRIPT); injected = true; } // Last resort: inject after tag (normal or self-closing) - if (!injected && /]*)?>/i.test(text)) { - modifiedHtml = text.replace(/]*)?>/i, (match) => match + INTERCEPTOR_SCRIPT); + if (!injected && HTML_TAG_REGEX.test(text)) { + modifiedHtml = text.replace(HTML_TAG_REGEX, (match) => match + INTERCEPTOR_SCRIPT); injected = true; } diff --git a/server/index.ts b/server/index.ts index b23f01d..9a149aa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -16,6 +16,25 @@ import { handler as astroHandler } from "../dist/server/entry.mjs"; import { createServer } from "node:http"; import { Socket } from "node:net"; +// Cache for routing decisions to reduce repeated checks +const routingCache = new Map(); +const ROUTING_CACHE_MAX_SIZE = 1000; +const ROUTING_CACHE_TTL = 60000; // 1 minute + +// Helper to manage routing cache with TTL and size limit +const cacheRoutingDecision = (url: string, decision: "bare" | "wisp" | "static"): void => { + if (routingCache.size >= ROUTING_CACHE_MAX_SIZE) { + // Remove oldest entries using iterator to avoid array allocation + let evicted = 0; + for (const key of routingCache.keys()) { + if (evicted >= 100) break; + routingCache.delete(key); + evicted++; + } + } + routingCache.set(url, decision); +}; + const bareServer = createBareServer("/bare/", { connectionLimiter: { // Optimized for sites with heavy cookies and complex browser services @@ -35,13 +54,26 @@ const serverFactory: FastifyServerFactory = ( keepAlive: true, keepAliveTimeout: 65000, // 65 seconds // Increase timeout for long-running requests - requestTimeout: 120000 // 120 seconds + requestTimeout: 120000, // 120 seconds + // Enable high watermark for better throughput + highWaterMark: 65536 // 64KB buffer }); server .on("request", (req, res) => { try { + const url = req.url || ""; + + // Check routing cache first for faster routing decisions + const cachedDecision = routingCache.get(url); + if (cachedDecision === "bare") { + bareServer.routeRequest(req, res); + return; + } + + // Make routing decision if (bareServer.shouldRoute(req)) { + cacheRoutingDecision(url, "bare"); bareServer.routeRequest(req, res); } else { handler(req, res); @@ -56,10 +88,16 @@ const serverFactory: FastifyServerFactory = ( }) .on("upgrade", (req, socket, head) => { try { + const url = req.url || ""; + const isWisp = url.endsWith("/wisp/") || url.endsWith("/adblock/"); + if (bareServer.shouldRoute(req)) { bareServer.routeUpgrade(req, socket as Socket, head); - } else if (req.url?.endsWith("/wisp/") || req.url?.endsWith("/adblock/")) { - console.log("WebSocket upgrade:", req.url); + } else if (isWisp) { + // Only log in development mode to reduce overhead + if (process.env.NODE_ENV === "development") { + console.log("WebSocket upgrade:", url); + } wisp.routeRequest(req, socket as Socket, head); } } catch (error) { @@ -71,7 +109,10 @@ const serverFactory: FastifyServerFactory = ( console.error("Server error:", error); }) .on("clientError", (error, socket) => { - console.error("Client error:", error); + // Only log in development to reduce console spam + if (process.env.NODE_ENV === "development") { + console.error("Client error:", error); + } if (!socket.destroyed) { socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); } @@ -95,7 +136,13 @@ const app = Fastify({ }); await app.register(fastifyStatic, { - root: fileURLToPath(new URL("../dist/client", import.meta.url)) + root: fileURLToPath(new URL("../dist/client", import.meta.url)), + // Enable caching headers for static resources + cacheControl: true, + maxAge: 86400000, // 1 day for static assets + immutable: true, + // Enable precompressed files if available + preCompressed: true }); await app.register(fastifyMiddie); diff --git a/src/utils/captcha-handler.ts b/src/utils/captcha-handler.ts index 9ddf6ac..832960e 100644 --- a/src/utils/captcha-handler.ts +++ b/src/utils/captcha-handler.ts @@ -6,21 +6,21 @@ */ /** - * List of CAPTCHA and verification-related domains + * Use Sets for O(1) lookup instead of Array.some() */ -const CAPTCHA_DOMAINS = [ +const CAPTCHA_DOMAINS = new Set([ "google.com", "recaptcha.net", "gstatic.com", "hcaptcha.com", "cloudflare.com", "challenges.cloudflare.com" -]; +]); /** - * List of domains known to use heavy cookies and complex browser services + * Set of domains known to use heavy cookies and complex browser services */ -const HEAVY_COOKIE_DOMAINS = [ +const HEAVY_COOKIE_DOMAINS = new Set([ "amazon.com", "ebay.com", "walmart.com", @@ -34,7 +34,50 @@ const HEAVY_COOKIE_DOMAINS = [ "apple.com", "netflix.com", "spotify.com" -]; +]); + +// Cache for domain checks to avoid redundant operations +const domainCheckCache = new Map(); +const DOMAIN_CACHE_MAX_SIZE = 500; +const DOMAIN_CACHE_EVICT_COUNT = 50; // Evict 10% of cache + +// Helper to check if URL matches any domain +function matchesDomain(url: string, domains: Set): boolean { + const cacheKey = url; + if (domainCheckCache.has(cacheKey)) { + return domainCheckCache.get(cacheKey)!; + } + + const urlLower = url.toLowerCase(); + let result = false; + for (const domain of domains) { + if (urlLower.includes(domain)) { + result = true; + break; + } + } + + // Cache with size limit and batch eviction + if (domainCheckCache.size >= DOMAIN_CACHE_MAX_SIZE) { + let evicted = 0; + for (const key of domainCheckCache.keys()) { + if (evicted >= DOMAIN_CACHE_EVICT_COUNT) break; + domainCheckCache.delete(key); + evicted++; + } + } + domainCheckCache.set(cacheKey, result); + return result; +} + +// Precompiled patterns for CAPTCHA iframe detection +const CAPTCHA_IFRAME_PATTERNS = ["recaptcha", "hcaptcha", "challenges.cloudflare.com", "turnstile"]; + +// Precompiled patterns for important cookies +const IMPORTANT_COOKIE_PATTERNS = ["_GRECAPTCHA", "h-captcha", "cf_", "session", "auth", "token"]; + +// Track if handlers have been initialized to avoid duplicate setup +let handlersInitialized = false; /** * Initialize CAPTCHA handlers on page load @@ -44,39 +87,69 @@ const HEAVY_COOKIE_DOMAINS = [ export function initializeCaptchaHandlers() { if (typeof window === "undefined") return; + // Prevent duplicate initialization + if (handlersInitialized) return; + handlersInitialized = true; + // Ensure global CAPTCHA callbacks are accessible if (!window.___grecaptcha_cfg) { window.___grecaptcha_cfg = { clients: {} }; } - // Monitor for CAPTCHA iframe creation and ensure proper setup + // Debounced batch processing for mutations + let pendingNodes: Node[] = []; + let processingScheduled = false; + + const processPendingNodes = () => { + processingScheduled = false; + const nodesToProcess = pendingNodes; + pendingNodes = []; + + for (const node of nodesToProcess) { + if (node instanceof HTMLIFrameElement) { + const src = node.src || ""; + // Check if this is a CAPTCHA iframe using precompiled patterns + const isCaptchaIframe = CAPTCHA_IFRAME_PATTERNS.some((pattern) => + src.includes(pattern) + ); + + if (isCaptchaIframe) { + // Ensure the iframe has proper sandbox permissions + if (node.sandbox && node.sandbox.length > 0) { + node.sandbox.add("allow-same-origin"); + node.sandbox.add("allow-scripts"); + node.sandbox.add("allow-forms"); + } + + // Ensure credentials are included for CAPTCHA cookies + if (node.getAttribute("credentialless") !== null) { + node.removeAttribute("credentialless"); + } + } + } + } + }; + + // Monitor for CAPTCHA iframe creation with batched processing const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { if (node instanceof HTMLIFrameElement) { - const src = node.src || ""; - // Check if this is a CAPTCHA iframe - if ( - src.includes("recaptcha") || - src.includes("hcaptcha") || - src.includes("challenges.cloudflare.com") || - src.includes("turnstile") - ) { - // Ensure the iframe has proper sandbox permissions - if (node.sandbox && node.sandbox.length > 0) { - node.sandbox.add("allow-same-origin"); - node.sandbox.add("allow-scripts"); - node.sandbox.add("allow-forms"); - } - - // Ensure credentials are included for CAPTCHA cookies - if (node.getAttribute("credentialless") !== null) { - node.removeAttribute("credentialless"); - } - } + pendingNodes.push(node); } - }); - }); + } + } + + // Schedule batch processing + if (pendingNodes.length > 0 && !processingScheduled) { + processingScheduled = true; + // Use requestIdleCallback if available, otherwise setTimeout + if ("requestIdleCallback" in window) { + (window as any).requestIdleCallback(processPendingNodes, { timeout: 100 }); + } else { + setTimeout(processPendingNodes, 0); + } + } }); // Start observing the document for changes @@ -110,16 +183,10 @@ function enhanceCookieHandling() { set(value) { // Ensure SameSite=None for cookies in cross-origin contexts if (typeof value === "string") { - // Check if this is a CAPTCHA or heavy cookie site cookie - const isCaptchaCookie = - value.includes("_GRECAPTCHA") || - value.includes("h-captcha") || - value.includes("cf_"); - const isImportantCookie = - isCaptchaCookie || - value.includes("session") || - value.includes("auth") || - value.includes("token"); + // Check if this is a CAPTCHA or heavy cookie site cookie using precompiled patterns + const isImportantCookie = IMPORTANT_COOKIE_PATTERNS.some((pattern) => + value.includes(pattern) + ); if (isImportantCookie && !value.includes("SameSite")) { value += "; SameSite=None; Secure"; @@ -149,8 +216,8 @@ function enhanceNetworkRequests() { : input.toString(); // Check if this is a CAPTCHA-related or heavy cookie site request - const isCaptchaRequest = CAPTCHA_DOMAINS.some((domain) => url.includes(domain)); - const isHeavyCookieRequest = HEAVY_COOKIE_DOMAINS.some((domain) => url.includes(domain)); + const isCaptchaRequest = matchesDomain(url, CAPTCHA_DOMAINS); + const isHeavyCookieRequest = !isCaptchaRequest && matchesDomain(url, HEAVY_COOKIE_DOMAINS); if (isCaptchaRequest || isHeavyCookieRequest) { // Ensure credentials are included @@ -185,10 +252,9 @@ function enhanceNetworkRequests() { const originalOpen = xhr.open; xhr.open = function (method: string, url: string | URL, ...args: any[]) { const urlStr = url.toString(); - const isCaptchaRequest = CAPTCHA_DOMAINS.some((domain) => urlStr.includes(domain)); - const isHeavyCookieRequest = HEAVY_COOKIE_DOMAINS.some((domain) => - urlStr.includes(domain) - ); + const isCaptchaRequest = matchesDomain(urlStr, CAPTCHA_DOMAINS); + const isHeavyCookieRequest = + !isCaptchaRequest && matchesDomain(urlStr, HEAVY_COOKIE_DOMAINS); if (isCaptchaRequest || isHeavyCookieRequest) { // Ensure credentials are included diff --git a/src/utils/experimental.ts b/src/utils/experimental.ts new file mode 100644 index 0000000..70ec82c --- /dev/null +++ b/src/utils/experimental.ts @@ -0,0 +1,325 @@ +/** + * Experimental Features Module + * + * This module implements the experimental features for the proxy: + * 1. Preconfigured Sites - Automatically use optimal proxy configurations for known problematic sites + * 2. Dynamic Loading - Monitor for errors and automatically try different configurations + */ + +import { StoreManager } from "./storage"; + +// Preconfigured sites with their optimal proxy settings +// Format: { domain: { proxy: "uv" | "sj", routingMode: "wisp" | "bare", transport?: "epoxy" | "libcurl" } } +export const PRECONFIGURED_SITES: Record< + string, + { + proxy: "uv" | "sj"; + routingMode: "wisp" | "bare"; + transport?: "epoxy" | "libcurl"; + reason?: string; + } +> = { + // E-commerce sites that work better with Scramjet + Bare + "amazon.com": { + proxy: "sj", + routingMode: "bare", + reason: "Complex cookie handling and bot detection" + }, + "amazon.co.uk": { proxy: "sj", routingMode: "bare", reason: "Regional Amazon variant" }, + "amazon.de": { proxy: "sj", routingMode: "bare", reason: "Regional Amazon variant" }, + "ebay.com": { + proxy: "sj", + routingMode: "bare", + reason: "Heavy JavaScript and session management" + }, + "walmart.com": { proxy: "sj", routingMode: "bare", reason: "Bot detection systems" }, + + // Social media that works better with Ultraviolet + "twitter.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "WebSocket-heavy application" + }, + "x.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "WebSocket-heavy application" + }, + "instagram.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Media-heavy with complex API" + }, + "facebook.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Complex single-page application" + }, + + // Video streaming sites + "youtube.com": { + proxy: "uv", + routingMode: "wisp", + transport: "libcurl", + reason: "Video streaming optimization" + }, + "netflix.com": { proxy: "sj", routingMode: "bare", reason: "DRM and authentication" }, + "twitch.tv": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "WebSocket-based chat and streaming" + }, + + // Gaming sites + "discord.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "WebSocket-heavy for real-time communication" + }, + "roblox.com": { proxy: "sj", routingMode: "bare", reason: "Complex game client requirements" }, + + // Search engines + "google.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Standard web search" + }, + "bing.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Standard web search" + }, + "duckduckgo.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Privacy-focused search" + }, + + // News and content sites + "reddit.com": { + proxy: "uv", + routingMode: "wisp", + transport: "epoxy", + reason: "Single-page application with infinite scroll" + }, + "medium.com": { proxy: "uv", routingMode: "wisp", reason: "Content-focused site" }, + "wikipedia.org": { proxy: "uv", routingMode: "wisp", reason: "Simple content delivery" } +}; + +// Configuration attempt order for dynamic loading +export const CONFIGURATION_ATTEMPTS = [ + { proxy: "uv" as const, routingMode: "wisp" as const, transport: "epoxy" as const }, + { proxy: "uv" as const, routingMode: "wisp" as const, transport: "libcurl" as const }, + { proxy: "sj" as const, routingMode: "wisp" as const, transport: "epoxy" as const }, + { proxy: "sj" as const, routingMode: "bare" as const }, + { proxy: "uv" as const, routingMode: "bare" as const } +]; + +/** + * Extract the domain from a URL + */ +export function extractDomain(url: string): string | null { + try { + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); + } catch { + // Try to extract domain from partial URL + const match = url.match(/(?:https?:\/\/)?(?:www\.)?([^\/]+)/); + return match ? match[1].replace(/^www\./, "") : null; + } +} + +/** + * Get the preconfigured settings for a URL if available + */ +export function getPreconfiguredSettings(url: string): (typeof PRECONFIGURED_SITES)[string] | null { + const domain = extractDomain(url); + if (!domain) return null; + + // Check exact match first + if (PRECONFIGURED_SITES[domain]) { + return PRECONFIGURED_SITES[domain]; + } + + // Check for subdomain matches + for (const configuredDomain of Object.keys(PRECONFIGURED_SITES)) { + if (domain.endsWith("." + configuredDomain) || domain === configuredDomain) { + return PRECONFIGURED_SITES[configuredDomain]; + } + } + + return null; +} + +/** + * Check if preconfigured sites feature is enabled + */ +export function isPreconfiguredSitesEnabled(): boolean { + const storage = new StoreManager<"radius||settings">("radius||settings"); + return storage.getVal("experimentPreconfiguredSites") === "true"; +} + +/** + * Check if dynamic loading feature is enabled + */ +export function isDynamicLoadingEnabled(): boolean { + const storage = new StoreManager<"radius||settings">("radius||settings"); + return storage.getVal("experimentDynamicLoading") === "true"; +} + +// Track current configuration attempt index for dynamic loading +let currentAttemptIndex = 0; +let lastFailedUrl = ""; + +/** + * Get the next configuration to try for dynamic loading + * Returns null if all configurations have been tried + */ +export function getNextConfiguration(): (typeof CONFIGURATION_ATTEMPTS)[number] | null { + if (currentAttemptIndex >= CONFIGURATION_ATTEMPTS.length) { + return null; + } + return CONFIGURATION_ATTEMPTS[currentAttemptIndex++]; +} + +/** + * Reset the configuration attempt counter + */ +export function resetConfigurationAttempts(url?: string): void { + if (url !== lastFailedUrl) { + currentAttemptIndex = 0; + lastFailedUrl = url || ""; + } +} + +/** + * Check if all configurations have been exhausted + */ +export function allConfigurationsExhausted(): boolean { + return currentAttemptIndex >= CONFIGURATION_ATTEMPTS.length; +} + +// Error patterns that indicate a configuration issue +const ERROR_PATTERNS = [ + "Failed to fetch", + "NetworkError", + "net::ERR_", + "TypeError: Failed to fetch", + "AbortError", + "Connection refused", + "CORS error", + "blocked by CORS", + "WebSocket connection failed", + "Service Worker Error" +]; + +/** + * Check if an error message indicates a configuration issue + */ +export function isConfigurationError(errorMessage: string): boolean { + const lowerMessage = errorMessage.toLowerCase(); + return ERROR_PATTERNS.some((pattern) => lowerMessage.includes(pattern.toLowerCase())); +} + +/** + * Dynamic loading error handler class + * Monitors console errors and triggers configuration changes + */ +export class DynamicLoadingHandler { + private storage: StoreManager<"radius||settings">; + private originalConsoleError: typeof console.error; + private errorCount: number = 0; + private readonly ERROR_THRESHOLD = 3; + private readonly ERROR_WINDOW = 5000; // 5 seconds + private lastErrorTime: number = 0; + private isActive: boolean = false; + private onConfigChange?: (config: (typeof CONFIGURATION_ATTEMPTS)[number]) => Promise; + private onAllFailed?: () => void; + + constructor() { + this.storage = new StoreManager<"radius||settings">("radius||settings"); + this.originalConsoleError = console.error; + } + + /** + * Start monitoring for errors + */ + start( + onConfigChange: (config: (typeof CONFIGURATION_ATTEMPTS)[number]) => Promise, + onAllFailed: () => void + ): void { + if (this.isActive) return; + this.isActive = true; + this.onConfigChange = onConfigChange; + this.onAllFailed = onAllFailed; + + // Override console.error to catch proxy errors + console.error = (...args: any[]) => { + this.originalConsoleError.apply(console, args); + this.handleError(args.map((a) => String(a)).join(" ")); + }; + + // Listen for unhandled errors + window.addEventListener("error", this.handleWindowError); + window.addEventListener("unhandledrejection", this.handleUnhandledRejection); + } + + /** + * Stop monitoring for errors + */ + stop(): void { + if (!this.isActive) return; + this.isActive = false; + + console.error = this.originalConsoleError; + window.removeEventListener("error", this.handleWindowError); + window.removeEventListener("unhandledrejection", this.handleUnhandledRejection); + } + + private handleWindowError = (event: ErrorEvent): void => { + this.handleError(event.message); + }; + + private handleUnhandledRejection = (event: PromiseRejectionEvent): void => { + this.handleError(String(event.reason)); + }; + + private handleError(message: string): void { + if (!isConfigurationError(message)) return; + + const now = Date.now(); + + // Reset error count if outside the window + if (now - this.lastErrorTime > this.ERROR_WINDOW) { + this.errorCount = 0; + } + + this.errorCount++; + this.lastErrorTime = now; + + // If we've hit the threshold, try a new configuration + if (this.errorCount >= this.ERROR_THRESHOLD) { + this.errorCount = 0; + this.tryNextConfiguration(); + } + } + + private async tryNextConfiguration(): Promise { + const nextConfig = getNextConfiguration(); + + if (nextConfig && this.onConfigChange) { + await this.onConfigChange(nextConfig); + } else if (this.onAllFailed) { + this.onAllFailed(); + } + } +} diff --git a/src/utils/proxy.ts b/src/utils/proxy.ts index 6fd13d4..33b917e 100644 --- a/src/utils/proxy.ts +++ b/src/utils/proxy.ts @@ -1,11 +1,41 @@ import { BareMuxConnection } from "@mercuryworkshop/bare-mux"; import { StoreManager } from "./storage"; import { initializeCaptchaHandlers } from "./captcha-handler"; +import { + getPreconfiguredSettings, + isPreconfiguredSitesEnabled, + isDynamicLoadingEnabled, + DynamicLoadingHandler, + resetConfigurationAttempts +} from "./experimental"; + +// Cache for URL encoding to avoid redundant computations +const urlEncodingCache = new Map(); +const URL_CACHE_MAX_SIZE = 500; +const URL_CACHE_EVICT_COUNT = 50; // Evict 10% of cache + +// Cache for transport settings to avoid redundant storage reads +let cachedTransportSettings: { + transport?: string; + routingMode?: string; + wispServer?: string; + adBlock?: string; + lastUpdate: number; +} | null = null; +const TRANSPORT_CACHE_TTL = 5000; // 5 seconds + +const createScript = (src: string, defer?: boolean): HTMLScriptElement => { + // Check if script already exists to avoid duplicate loading + const existingScript = document.querySelector(`script[src="${src}"]`); + if (existingScript) { + return existingScript as HTMLScriptElement; + } -const createScript = (src: string, defer?: boolean) => { const script = document.createElement("script") as HTMLScriptElement; script.src = src; if (defer) script.defer = defer; + // Add async loading for better performance + script.async = false; // Keep execution order return document.body.appendChild(script); }; @@ -31,6 +61,7 @@ class SW { #serviceWorker?: ServiceWorkerRegistration; #storageManager: StoreManager<"radius||settings">; #ready: Promise; + #dynamicLoadingHandler?: DynamicLoadingHandler; static #instance = new Set(); static *getInstance() { @@ -59,20 +90,135 @@ class SW { return template.replace("%s", encodeURIComponent(input)); } + /** + * Apply preconfigured settings for a URL if available and enabled + */ + async applyPreconfiguredSettings(url: string): Promise { + if (!isPreconfiguredSitesEnabled()) return false; + + const settings = getPreconfiguredSettings(url); + if (!settings) return false; + + // Apply the preconfigured settings + this.#storageManager.setVal("proxy", settings.proxy); + + if (settings.routingMode) { + await this.routingMode(settings.routingMode, false); + } + + if (settings.transport) { + await this.setTransport(settings.transport); + } else { + await this.setTransport(); + } + + this.#invalidateTransportCache(); + return true; + } + encodeURL(string: string): string { + // Check cache first + const cacheKey = `${this.#storageManager.getVal("proxy") || "uv"}:${string}`; + const cached = urlEncodingCache.get(cacheKey); + if (cached) { + return cached; + } + const proxy = this.#storageManager.getVal("proxy") as "uv" | "sj"; const input = this.search(string, this.#storageManager.getVal("searchEngine")); - return proxy === "uv" - ? `${__uv$config.prefix}${__uv$config.encodeUrl!(input)}` - : this.#scramjetController!.encodeUrl(input); + const encoded = + proxy === "uv" + ? `${__uv$config.prefix}${__uv$config.encodeUrl!(input)}` + : this.#scramjetController!.encodeUrl(input); + + // Cache the result with iterator-based eviction + if (urlEncodingCache.size >= URL_CACHE_MAX_SIZE) { + // Remove oldest entries using iterator to avoid array allocation + let evicted = 0; + for (const key of urlEncodingCache.keys()) { + if (evicted >= URL_CACHE_EVICT_COUNT) break; + urlEncodingCache.delete(key); + evicted++; + } + } + urlEncodingCache.set(cacheKey, encoded); + + return encoded; + } + + /** + * Encode URL with experimental features support + * This method applies preconfigured settings if enabled before encoding + */ + async encodeURLWithExperiments(url: string): Promise { + // Reset configuration attempts for new URL + resetConfigurationAttempts(url); + + // Apply preconfigured settings if available + await this.applyPreconfiguredSettings(url); + + // Start dynamic loading if enabled + if (isDynamicLoadingEnabled() && !this.#dynamicLoadingHandler) { + this.#dynamicLoadingHandler = new DynamicLoadingHandler(); + this.#dynamicLoadingHandler.start( + async (config) => { + // Apply new configuration + this.#storageManager.setVal("proxy", config.proxy); + await this.routingMode(config.routingMode, false); + if (config.transport) { + await this.setTransport(config.transport); + } else { + await this.setTransport(); + } + this.#invalidateTransportCache(); + // Clear URL cache to re-encode with new settings + urlEncodingCache.clear(); + }, + () => { + // All configurations failed - redirect to 404 + window.location.href = "/404"; + } + ); + } + + return this.encodeURL(url); + } + + // Helper method to get cached transport settings + #getTransportSettings() { + const now = Date.now(); + if ( + cachedTransportSettings && + now - cachedTransportSettings.lastUpdate < TRANSPORT_CACHE_TTL + ) { + return cachedTransportSettings; + } + + cachedTransportSettings = { + transport: this.#storageManager.getVal("transport"), + routingMode: this.#storageManager.getVal("routingMode"), + wispServer: this.#storageManager.getVal("wispServer"), + adBlock: this.#storageManager.getVal("adBlock"), + lastUpdate: now + }; + + return cachedTransportSettings; + } + + // Invalidate transport cache when settings change + #invalidateTransportCache() { + cachedTransportSettings = null; } async setTransport(transport?: "epoxy" | "libcurl", get?: boolean) { - console.log("Setting transport"); - const routingMode = this.#storageManager.getVal("routingMode") || "wisp"; + const settings = this.#getTransportSettings(); + const routingMode = settings.routingMode || "wisp"; + const wispServer = (): string => { - const wispServerVal = this.#storageManager.getVal("wispServer"); - if (this.#storageManager.getVal("adBlock") === "true") { + const wispServerVal = + settings.wispServer || + (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/wisp/"; + if (settings.adBlock === "true") { return wispServerVal.replace("/wisp/", "/adblock/"); } return wispServerVal; @@ -82,37 +228,21 @@ class SW { (location.protocol === "https:" ? "https://" : "http://") + location.host + "/bare/" ); }; - if (get) return this.#storageManager.getVal("transport"); - this.#storageManager.setVal( - "transport", - transport || this.#storageManager.getVal("transport") || "epoxy" - ); + if (get) return settings.transport; + + const newTransport = transport || settings.transport || "epoxy"; + this.#storageManager.setVal("transport", newTransport); + this.#invalidateTransportCache(); if (routingMode === "bare") { // Use bare server transport await this.#baremuxConn!.setTransport("/baremod/index.mjs", [bareServer()]); } else { // Use wisp server transport (default) - switch (transport) { - case "epoxy": { - await this.#baremuxConn!.setTransport("/epoxy/index.mjs", [ - { wisp: wispServer() } - ]); - break; - } - case "libcurl": { - await this.#baremuxConn!.setTransport("/libcurl/index.mjs", [ - { wisp: wispServer() } - ]); - break; - } - default: { - await this.#baremuxConn!.setTransport("/epoxy/index.mjs", [ - { wisp: wispServer() } - ]); - break; - } - } + // Optimize transport path selection + const transportPath = + newTransport === "libcurl" ? "/libcurl/index.mjs" : "/epoxy/index.mjs"; + await this.#baremuxConn!.setTransport(transportPath, [{ wisp: wispServer() }]); } } @@ -121,25 +251,29 @@ class SW { "routingMode", mode || this.#storageManager.getVal("routingMode") || "wisp" ); + this.#invalidateTransportCache(); if (set) await this.setTransport(); } async wispServer(wispServer?: string, set?: true) { - console.log(wispServer?.replace("/wisp/", "/adblock/")); this.#storageManager.setVal( "wispServer", wispServer || this.#storageManager.getVal("wispServer") || (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/wisp/" ); + this.#invalidateTransportCache(); if (set) await this.setTransport(); } constructor() { SW.#instance.add(this); this.#storageManager = new StoreManager("radius||settings"); + + // Optimized script loading check with debounced interval const checkScripts = (): Promise => { return new Promise((resolve) => { + // Use a reasonable interval (16ms = ~60fps) instead of default const t = setInterval(() => { if ( typeof __uv$config !== "undefined" && @@ -148,9 +282,11 @@ class SW { clearInterval(t); resolve(); } - }); + }, 16); }); }; + + // Load scripts in optimal order createScript("/vu/uv.bundle.js", true); createScript("/vu/uv.config.js", true); createScript("/marcs/scramjet.all.js", true); @@ -184,7 +320,6 @@ class SW { if ("serviceWorker" in navigator) { await this.#scramjetController.init(); navigator.serviceWorker.ready.then(async (reg) => { - console.log("SW ready to go!"); this.#serviceWorker = reg; // Initialize CAPTCHA handlers for automatic verification support diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 10f24e3..8d61852 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -1,16 +1,65 @@ +// Memory cache for frequently accessed values +const storageCache = new Map(); +const STORAGE_CACHE_TTL = 5000; // 5 seconds +const STORAGE_CACHE_MAX_SIZE = 100; +const STORAGE_CACHE_EVICT_COUNT = 20; // Evict 20% of cache + class StoreManager { #prefix: string; + constructor(pref: Prefix) { this.#prefix = pref; } + getVal(key: string): string { - return localStorage.getItem(`${this.#prefix}||${key}`) as string; + const fullKey = `${this.#prefix}||${key}`; + + // Check cache first + const cached = storageCache.get(fullKey); + const now = Date.now(); + if (cached && now - cached.timestamp < STORAGE_CACHE_TTL) { + return cached.value; + } + + // Get from localStorage + const value = localStorage.getItem(fullKey) as string; + + // Update cache with batch eviction + if (storageCache.size >= STORAGE_CACHE_MAX_SIZE) { + // Remove oldest entries using iterator to avoid array allocation + let evicted = 0; + for (const cacheKey of storageCache.keys()) { + if (evicted >= STORAGE_CACHE_EVICT_COUNT) break; + storageCache.delete(cacheKey); + evicted++; + } + } + if (value !== null) { + storageCache.set(fullKey, { value, timestamp: now }); + } + + return value; } + setVal(key: string, val: string): void { - localStorage.setItem(`${this.#prefix}||${key}`, val); + const fullKey = `${this.#prefix}||${key}`; + localStorage.setItem(fullKey, val); + + // Update cache + storageCache.set(fullKey, { value: val, timestamp: Date.now() }); } + removeVal(key: string): void { - localStorage.removeItem(`${this.#prefix}||${key}`); + const fullKey = `${this.#prefix}||${key}`; + localStorage.removeItem(fullKey); + + // Remove from cache + storageCache.delete(fullKey); + } + + // Clear cache when needed (e.g., for debugging) + static clearCache(): void { + storageCache.clear(); } }