From 44b164cf09f42849815437bb44a614601cb772f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:11:33 +0000 Subject: [PATCH 1/3] fix: Handle WebSocket 'invalid origin' errors gracefully Add proper error handling for WebSocket connection failures, particularly the "invalid origin" error that occurs on some browsers (especially Safari/ WebKit on iOS via DuckDuckGo Mobile). This error can be caused by: - Browser privacy/tracking protection blocking WebSocket connections - Network issues during WebSocket handshake - CORS configuration issues Changes: - Add wrapped WebSocket implementation with early error handlers to catch connection failures before graphql-ws sets up its handlers - Skip retry attempts for origin/CORS related errors since they won't resolve without configuration changes - Add Sentry beforeSend filter to prevent these known connection errors from cluttering error tracking - Improve logging with more context for origin-related failures https://claude.ai/code/session_014RG7hQPmmTu3acu8W2ApKN --- .../graphql-queue/graphql-client.ts | 133 +++++++++++++++++- packages/web/instrumentation-client.ts | 60 ++++++-- 2 files changed, 180 insertions(+), 13 deletions(-) diff --git a/packages/web/app/components/graphql-queue/graphql-client.ts b/packages/web/app/components/graphql-queue/graphql-client.ts index f8e364a4..74734736 100644 --- a/packages/web/app/components/graphql-queue/graphql-client.ts +++ b/packages/web/app/components/graphql-queue/graphql-client.ts @@ -12,6 +12,91 @@ const BACKOFF_MULTIPLIER = 2; // Double the delay each retry let clientCounter = 0; +/** + * Known WebSocket connection error messages that indicate origin/CORS issues. + * These are browser-specific error messages that can occur during WebSocket handshake. + */ +const ORIGIN_ERROR_PATTERNS = [ + 'invalid origin', + 'origin not allowed', + 'cors', + 'cross-origin', +]; + +/** + * Check if an error message indicates an origin/CORS issue + */ +function isOriginError(errorMessage: string): boolean { + const lowerMessage = errorMessage.toLowerCase(); + return ORIGIN_ERROR_PATTERNS.some(pattern => lowerMessage.includes(pattern)); +} + +/** + * Create a wrapped WebSocket class that provides better error handling + * for connection failures, especially origin/CORS related issues. + * + * This is needed because some browsers (especially Safari/WebKit on iOS) + * throw cryptic "invalid origin" errors during WebSocket handshake that + * can become unhandled rejections. + */ +function createWrappedWebSocket(clientId: number): typeof WebSocket { + return class WrappedWebSocket extends WebSocket { + constructor(url: string | URL, protocols?: string | string[]) { + try { + super(url, protocols); + + // Add an early error handler to catch connection failures + // before graphql-ws sets up its handlers + const earlyErrorHandler = (event: Event) => { + const errorEvent = event as ErrorEvent; + const errorMessage = errorEvent.message || 'Unknown WebSocket error'; + + if (DEBUG) { + console.log(`[GraphQL] Client #${clientId} early WebSocket error:`, errorMessage); + } + + // Log origin-related errors with more context + if (isOriginError(errorMessage)) { + console.warn( + `[GraphQL] Client #${clientId} WebSocket connection failed due to origin validation. ` + + `This may be caused by browser privacy settings, network issues, or CORS configuration. ` + + `Error: ${errorMessage}` + ); + } + + // Remove this handler after it fires once - graphql-ws will handle subsequent errors + this.removeEventListener('error', earlyErrorHandler); + }; + + this.addEventListener('error', earlyErrorHandler); + + // Also handle immediate close with reason + const earlyCloseHandler = (event: CloseEvent) => { + if (event.code !== 1000 && event.reason) { + if (DEBUG) { + console.log(`[GraphQL] Client #${clientId} early WebSocket close:`, event.code, event.reason); + } + + if (isOriginError(event.reason)) { + console.warn( + `[GraphQL] Client #${clientId} WebSocket connection rejected. ` + + `Reason: ${event.reason} (code: ${event.code})` + ); + } + } + this.removeEventListener('close', earlyCloseHandler); + }; + + this.addEventListener('close', earlyCloseHandler); + } catch (error) { + // WebSocket constructor can throw for invalid URLs + console.error(`[GraphQL] Client #${clientId} WebSocket construction failed:`, error); + throw error; + } + } + }; +} + // Cache for parsed operation names to avoid regex on every call const operationNameCache = new WeakMap<{ query: string }, string>(); @@ -64,10 +149,26 @@ export function createGraphQLClient( let hasConnectedOnce = false; + // Create wrapped WebSocket for better error handling + const WrappedWebSocket = createWrappedWebSocket(clientId); + const client = createClient({ url, + // Use our wrapped WebSocket for better error handling + webSocketImpl: WrappedWebSocket, retryAttempts: 10, // More attempts with exponential backoff - shouldRetry: () => true, + shouldRetry: (errOrCloseEvent) => { + // Don't retry on origin/CORS errors - these won't resolve without config changes + if (errOrCloseEvent instanceof Error && isOriginError(errOrCloseEvent.message)) { + console.warn(`[GraphQL] Client #${clientId} not retrying due to origin error`); + return false; + } + if (errOrCloseEvent instanceof CloseEvent && errOrCloseEvent.reason && isOriginError(errOrCloseEvent.reason)) { + console.warn(`[GraphQL] Client #${clientId} not retrying due to origin rejection`); + return false; + } + return true; + }, // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ... retryWait: async (retryCount) => { const delay = Math.min( @@ -94,9 +195,37 @@ export function createGraphQLClient( }, closed: (event) => { if (DEBUG) console.log(`[GraphQL] Client #${clientId} closed`, event); + + // Log meaningful close events + if (event && typeof event === 'object') { + const closeEvent = event as CloseEvent; + if (closeEvent.code !== 1000 && closeEvent.reason) { + if (isOriginError(closeEvent.reason)) { + console.error( + `[GraphQL] Client #${clientId} connection closed due to origin validation failure. ` + + `This usually indicates a CORS configuration issue or browser privacy settings blocking the connection.` + ); + } + } + } }, error: (error) => { - if (DEBUG) console.log(`[GraphQL] Client #${clientId} error`, error); + // Extract error message for better logging + const errorMessage = error instanceof Error ? error.message : String(error); + + if (isOriginError(errorMessage)) { + // Provide more context for origin-related errors + console.error( + `[GraphQL] Client #${clientId} origin validation error: ${errorMessage}. ` + + `This can be caused by:\n` + + ` 1. Browser privacy/tracking protection blocking the WebSocket connection\n` + + ` 2. Network issues during the WebSocket handshake\n` + + ` 3. Server CORS configuration not allowing this origin\n` + + `Please try refreshing the page or check your browser settings.` + ); + } else if (DEBUG) { + console.log(`[GraphQL] Client #${clientId} error`, error); + } }, }, }) as ExtendedClient; diff --git a/packages/web/instrumentation-client.ts b/packages/web/instrumentation-client.ts index f1a91f71..f7aec100 100644 --- a/packages/web/instrumentation-client.ts +++ b/packages/web/instrumentation-client.ts @@ -3,12 +3,33 @@ // https://docs.sentry.io/platforms/javascript/guides/nextjs/ import * as Sentry from "@sentry/nextjs"; +import { shouldFilterFromSentry } from "@/app/lib/websocket-errors"; // Only enable Sentry on boardsesh.com to avoid polluting error tracking const isProductionDomain = typeof window !== "undefined" && window.location.hostname.includes("boardsesh.com"); +/** + * Browser extension error patterns that should be filtered from Sentry. + * These errors are caused by third-party browser extensions, not our code. + */ +const BROWSER_EXTENSION_PATTERNS = [ + "runtime.sendMessage", + "Extension context invalidated", + "message channel closed", + "message port closed", +]; + +/** + * Check if an error is from a browser extension + */ +function isBrowserExtensionError(message: string): boolean { + return BROWSER_EXTENSION_PATTERNS.some((pattern) => + message.includes(pattern) + ); +} + Sentry.init({ dsn: "https://f55e6626faf787ae5291ad75b010ea14@o4510644927660032.ingest.us.sentry.io/4510644930150400", @@ -22,22 +43,39 @@ Sentry.init({ // https://docs.sentry.io/platforms/javascript/guides/nextjs/configuration/options/#sendDefaultPii sendDefaultPii: true, - // Filter out errors from browser extensions and third-party scripts + // Filter out errors that aren't actionable beforeSend(event, hint) { - const error = hint.originalException; + const error = hint?.originalException; const errorMessage = - error instanceof Error ? error.message : String(error); - - // Ignore browser extension errors (runtime.sendMessage, etc.) - if ( - errorMessage.includes("runtime.sendMessage") || - errorMessage.includes("Extension context invalidated") || - errorMessage.includes("message channel closed") || - errorMessage.includes("message port closed") - ) { + error instanceof Error ? error.message : String(error ?? ""); + + // Filter browser extension errors + if (isBrowserExtensionError(errorMessage)) { + return null; + } + + // Filter known WebSocket/origin errors (uses shared module) + if (errorMessage && shouldFilterFromSentry(errorMessage)) { + console.warn( + "[Sentry] Filtering known WebSocket/origin error:", + errorMessage + ); return null; } + // Also check exception values for WebSocket errors + if (event.exception?.values) { + for (const exception of event.exception.values) { + if (exception.value && shouldFilterFromSentry(exception.value)) { + console.warn( + "[Sentry] Filtering known WebSocket/origin error:", + exception.value + ); + return null; + } + } + } + return event; }, }); From 54f05ae4ed56151c166eadef858d42a0a008987b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:05:08 +0000 Subject: [PATCH 2/3] refactor: Extract WebSocket error utilities to shared module Address code review feedback: - Extract shared error patterns to packages/web/app/lib/websocket-errors.ts to avoid duplication between graphql-client.ts and instrumentation-client.ts - Make error filtering more specific: only filter WebSocket-specific errors, not generic network errors like "failed to fetch" that could indicate legitimate API failures - Add unit tests for error detection utilities (isOriginError, isWebSocketLifecycleError, shouldFilterFromSentry) https://claude.ai/code/session_014RG7hQPmmTu3acu8W2ApKN --- .../graphql-queue/graphql-client.ts | 20 +-- .../lib/__tests__/websocket-errors.test.ts | 124 ++++++++++++++++++ packages/web/app/lib/websocket-errors.ts | 79 +++++++++++ 3 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 packages/web/app/lib/__tests__/websocket-errors.test.ts create mode 100644 packages/web/app/lib/websocket-errors.ts diff --git a/packages/web/app/components/graphql-queue/graphql-client.ts b/packages/web/app/components/graphql-queue/graphql-client.ts index 74734736..cc4622de 100644 --- a/packages/web/app/components/graphql-queue/graphql-client.ts +++ b/packages/web/app/components/graphql-queue/graphql-client.ts @@ -1,4 +1,5 @@ import { createClient, Client, Sink } from 'graphql-ws'; +import { isOriginError } from '@/app/lib/websocket-errors'; export type { Client }; @@ -12,25 +13,6 @@ const BACKOFF_MULTIPLIER = 2; // Double the delay each retry let clientCounter = 0; -/** - * Known WebSocket connection error messages that indicate origin/CORS issues. - * These are browser-specific error messages that can occur during WebSocket handshake. - */ -const ORIGIN_ERROR_PATTERNS = [ - 'invalid origin', - 'origin not allowed', - 'cors', - 'cross-origin', -]; - -/** - * Check if an error message indicates an origin/CORS issue - */ -function isOriginError(errorMessage: string): boolean { - const lowerMessage = errorMessage.toLowerCase(); - return ORIGIN_ERROR_PATTERNS.some(pattern => lowerMessage.includes(pattern)); -} - /** * Create a wrapped WebSocket class that provides better error handling * for connection failures, especially origin/CORS related issues. diff --git a/packages/web/app/lib/__tests__/websocket-errors.test.ts b/packages/web/app/lib/__tests__/websocket-errors.test.ts new file mode 100644 index 00000000..3a445139 --- /dev/null +++ b/packages/web/app/lib/__tests__/websocket-errors.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { + isOriginError, + isWebSocketLifecycleError, + shouldFilterFromSentry, + ORIGIN_ERROR_PATTERNS, + WEBSOCKET_LIFECYCLE_PATTERNS, +} from '../websocket-errors'; + +describe('websocket-errors', () => { + describe('isOriginError', () => { + it('should detect "invalid origin" error', () => { + expect(isOriginError('invalid origin')).toBe(true); + expect(isOriginError('Error: invalid origin')).toBe(true); + expect(isOriginError('Invalid Origin')).toBe(true); + }); + + it('should detect "origin not allowed" error', () => { + expect(isOriginError('origin not allowed')).toBe(true); + expect(isOriginError('Origin not allowed')).toBe(true); + expect(isOriginError('Error: Origin not allowed for this request')).toBe(true); + }); + + it('should not match generic errors', () => { + expect(isOriginError('Network error')).toBe(false); + expect(isOriginError('Failed to fetch')).toBe(false); + expect(isOriginError('Connection timeout')).toBe(false); + expect(isOriginError('original')).toBe(false); // Avoid false positives + }); + + it('should be case insensitive', () => { + expect(isOriginError('INVALID ORIGIN')).toBe(true); + expect(isOriginError('Invalid Origin')).toBe(true); + expect(isOriginError('invalid ORIGIN')).toBe(true); + }); + + it('should handle edge cases safely', () => { + expect(isOriginError('')).toBe(false); + // TypeScript enforces string type, but test runtime safety + expect(isOriginError(undefined as unknown as string)).toBe(false); + expect(isOriginError(null as unknown as string)).toBe(false); + }); + }); + + describe('isWebSocketLifecycleError', () => { + it('should detect WebSocket closing state errors', () => { + expect(isWebSocketLifecycleError('WebSocket is already in CLOSING state')).toBe(true); + expect(isWebSocketLifecycleError('websocket is already in closing')).toBe(true); + }); + + it('should detect GraphQL subscription errors', () => { + expect(isWebSocketLifecycleError('GraphQL subscription error')).toBe(true); + expect(isWebSocketLifecycleError('graphql subscription terminated')).toBe(true); + }); + + it('should NOT match generic WebSocket connection errors (intentionally specific)', () => { + // These are intentionally NOT matched to avoid suppressing legitimate errors + expect(isWebSocketLifecycleError('WebSocket connection to wss://example.com failed')).toBe(false); + expect(isWebSocketLifecycleError('Connection refused')).toBe(false); + }); + + it('should not match generic errors', () => { + expect(isWebSocketLifecycleError('Network error')).toBe(false); + expect(isWebSocketLifecycleError('Failed to fetch')).toBe(false); + }); + + it('should handle edge cases safely', () => { + expect(isWebSocketLifecycleError('')).toBe(false); + expect(isWebSocketLifecycleError(undefined as unknown as string)).toBe(false); + expect(isWebSocketLifecycleError(null as unknown as string)).toBe(false); + }); + }); + + describe('shouldFilterFromSentry', () => { + it('should filter origin errors', () => { + expect(shouldFilterFromSentry('invalid origin')).toBe(true); + expect(shouldFilterFromSentry('Origin not allowed')).toBe(true); + }); + + it('should filter WebSocket lifecycle errors', () => { + expect(shouldFilterFromSentry('WebSocket is already in CLOSING state')).toBe(true); + expect(shouldFilterFromSentry('graphql subscription error')).toBe(true); + }); + + it('should NOT filter generic network errors', () => { + // These are legitimate errors that should be reported + expect(shouldFilterFromSentry('Failed to fetch')).toBe(false); + expect(shouldFilterFromSentry('Network error')).toBe(false); + expect(shouldFilterFromSentry('Connection refused')).toBe(false); + expect(shouldFilterFromSentry('connection closed')).toBe(false); + }); + + it('should NOT filter generic WebSocket errors (to catch legitimate failures)', () => { + expect(shouldFilterFromSentry('WebSocket connection to wss://example.com failed')).toBe(false); + }); + + it('should NOT filter API errors', () => { + expect(shouldFilterFromSentry('API request failed')).toBe(false); + expect(shouldFilterFromSentry('Server error: 500')).toBe(false); + expect(shouldFilterFromSentry('Unauthorized')).toBe(false); + }); + + it('should handle edge cases safely', () => { + expect(shouldFilterFromSentry('')).toBe(false); + expect(shouldFilterFromSentry(undefined as unknown as string)).toBe(false); + expect(shouldFilterFromSentry(null as unknown as string)).toBe(false); + }); + }); + + describe('exported constants', () => { + it('should export ORIGIN_ERROR_PATTERNS', () => { + expect(ORIGIN_ERROR_PATTERNS).toContain('invalid origin'); + expect(ORIGIN_ERROR_PATTERNS).toContain('origin not allowed'); + }); + + it('should export WEBSOCKET_LIFECYCLE_PATTERNS with specific patterns only', () => { + expect(WEBSOCKET_LIFECYCLE_PATTERNS).toContain('websocket is already in closing'); + expect(WEBSOCKET_LIFECYCLE_PATTERNS).toContain('graphql subscription'); + // Should NOT contain overly broad patterns + expect(WEBSOCKET_LIFECYCLE_PATTERNS).not.toContain('websocket connection to'); + expect(WEBSOCKET_LIFECYCLE_PATTERNS).not.toContain('connection closed'); + }); + }); +}); diff --git a/packages/web/app/lib/websocket-errors.ts b/packages/web/app/lib/websocket-errors.ts new file mode 100644 index 00000000..b3b3ba06 --- /dev/null +++ b/packages/web/app/lib/websocket-errors.ts @@ -0,0 +1,79 @@ +/** + * WebSocket error detection utilities + * + * Shared utilities for detecting and handling WebSocket connection errors, + * particularly origin/CORS related issues that occur on some browsers. + */ + +/** + * Known WebSocket connection error messages that indicate origin/CORS issues. + * These are browser-specific error messages that can occur during WebSocket handshake. + */ +export const ORIGIN_ERROR_PATTERNS = [ + 'invalid origin', + 'origin not allowed', +] as const; + +/** + * Check if an error message indicates an origin/CORS issue. + * These errors are typically caused by: + * - Browser privacy/tracking protection blocking WebSocket connections + * - Server CORS configuration not allowing the origin + * - Network issues during WebSocket handshake on some browsers + * + * Returns false for empty/null/undefined input to handle edge cases safely. + */ +export function isOriginError(errorMessage: string): boolean { + if (!errorMessage) { + return false; + } + const lowerMessage = errorMessage.toLowerCase(); + return ORIGIN_ERROR_PATTERNS.some(pattern => lowerMessage.includes(pattern)); +} + +/** + * Additional patterns that indicate WebSocket-specific connection errors + * that are expected during normal operation and shouldn't be reported to Sentry. + * + * These patterns are intentionally specific to avoid filtering legitimate errors: + * - 'websocket is already in closing' - WebSocket state transition (not an error) + * - 'graphql subscription' - Expected subscription lifecycle events + * + * Note: We intentionally do NOT include generic patterns like: + * - 'websocket connection to' - too broad, would match legitimate connection failures + * - 'connection closed' - too generic, could be API or other connection errors + * - 'failed to fetch' - too generic, would hide real API failures + */ +export const WEBSOCKET_LIFECYCLE_PATTERNS = [ + 'websocket is already in closing', + 'graphql subscription', +] as const; + +/** + * Check if an error is a known WebSocket lifecycle error that shouldn't be + * reported to Sentry. This is more specific than isOriginError and only + * matches errors that are clearly WebSocket-related lifecycle events. + * + * Returns false for empty/null/undefined input to handle edge cases safely. + */ +export function isWebSocketLifecycleError(errorMessage: string): boolean { + if (!errorMessage) { + return false; + } + const lowerMessage = errorMessage.toLowerCase(); + return WEBSOCKET_LIFECYCLE_PATTERNS.some(pattern => lowerMessage.includes(pattern)); +} + +/** + * Combined check for errors that should be filtered from Sentry. + * Only filters errors that are clearly WebSocket/origin related, + * not generic network errors. + * + * Returns false for empty/null/undefined input to handle edge cases safely. + */ +export function shouldFilterFromSentry(errorMessage: string): boolean { + if (!errorMessage) { + return false; + } + return isOriginError(errorMessage) || isWebSocketLifecycleError(errorMessage); +} From 45c7618eeefef11677dbdb0938dd91fd8898319c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:27:24 +0000 Subject: [PATCH 3/3] docs: Add WebSocket error handling documentation Document the error handling and filtering behavior for WebSocket connections, including which errors are filtered from Sentry and why. https://claude.ai/code/session_014RG7hQPmmTu3acu8W2ApKN --- docs/websocket-implementation.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index 8f77ebfe..f54ae549 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -911,6 +911,33 @@ This would reduce the window of stale data but is not currently implemented. | Instance heartbeat TTL | 60s | Dead instance detection | | Session members TTL | 4 hours | Matches session TTL | +### Error Handling and Filtering + +The WebSocket client includes error handling for common connection issues: + +**Origin/CORS Errors** + +Some browsers (especially Safari/WebKit on iOS) throw cryptic "invalid origin" errors during WebSocket handshake. These can be caused by: +- Browser privacy/tracking protection blocking WebSocket connections +- Network issues during the handshake phase +- Server CORS configuration mismatches + +The client: +1. Wraps WebSocket with early error handlers (`graphql-client.ts`) +2. Detects origin-related errors using patterns in `websocket-errors.ts` +3. Skips retry attempts for origin errors (they won't resolve without configuration changes) +4. Filters these errors from Sentry to reduce noise (`instrumentation-client.ts`) + +**Filtered Error Patterns** + +The following patterns are filtered from Sentry (defined in `packages/web/app/lib/websocket-errors.ts`): +- `invalid origin` - Browser origin validation error +- `origin not allowed` - Server CORS rejection +- `websocket is already in closing` - Normal lifecycle state +- `graphql subscription` - Expected subscription lifecycle events + +Generic errors like "failed to fetch" or "connection closed" are NOT filtered, as they may indicate legitimate issues that need investigation. + --- ## Related Files @@ -929,10 +956,12 @@ This would reduce the window of stale data but is not currently implemented. ### Frontend -- `packages/web/app/components/graphql-queue/graphql-client.ts` - WebSocket client +- `packages/web/app/components/graphql-queue/graphql-client.ts` - WebSocket client with error handling - `packages/web/app/components/graphql-queue/use-queue-session.ts` - Session hook - `packages/web/app/components/persistent-session/persistent-session-context.tsx` - Root-level session management - `packages/web/app/components/graphql-queue/QueueContext.tsx` - Queue state context +- `packages/web/app/lib/websocket-errors.ts` - WebSocket error detection utilities +- `packages/web/instrumentation-client.ts` - Sentry error filtering ### Shared