From 6c8a275486942f0f8cf3cf5f3ca60768d91a92f5 Mon Sep 17 00:00:00 2001 From: Jacek Date: Fri, 23 May 2025 14:54:09 -0500 Subject: [PATCH 1/2] poc:sw --- packages/clerk-js/rspack.config.js | 10 + packages/clerk-js/src/core/clerk.ts | 275 ++++++++ packages/clerk-js/src/index.browser.ts | 57 +- .../src/utils/__tests__/sharedWorker.test.ts | 164 +++++ .../clerk-js/src/utils/clerk-shared-worker.js | 651 ++++++++++++++++++ packages/clerk-js/src/utils/generateTabId.ts | 127 ++++ packages/clerk-js/src/utils/sharedWorker.ts | 576 ++++++++++++++++ .../src/utils/sharedWorkerDebugger.ts | 186 +++++ .../clerk-js/src/utils/sharedWorkerUtils.ts | 72 ++ packages/types/src/clerk.ts | 35 + 10 files changed, 2150 insertions(+), 3 deletions(-) create mode 100644 packages/clerk-js/src/utils/__tests__/sharedWorker.test.ts create mode 100644 packages/clerk-js/src/utils/clerk-shared-worker.js create mode 100644 packages/clerk-js/src/utils/generateTabId.ts create mode 100644 packages/clerk-js/src/utils/sharedWorker.ts create mode 100644 packages/clerk-js/src/utils/sharedWorkerDebugger.ts create mode 100644 packages/clerk-js/src/utils/sharedWorkerUtils.ts diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 9e014251cb2..5e4f8c5c2ee 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -61,6 +61,16 @@ const common = ({ mode, variant, disableRHC = false }) => { CLERK_ENV: mode, NODE_ENV: mode, }), + // Copy SharedWorker script to dist folder + isProduction(mode) && + new rspack.CopyRspackPlugin({ + patterns: [ + { + from: path.resolve(__dirname, 'src/utils/clerk-shared-worker.js'), + to: 'clerk-shared-worker.js', + }, + ], + }), process.env.RSDOCTOR && new RsdoctorRspackPlugin({ mode: process.env.RSDOCTOR === 'brief' ? 'brief' : 'normal', diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 0a094e99244..c78774a59b0 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -114,6 +114,8 @@ import { assertNoLegacyProp } from '../utils/assertNoLegacyProp'; import { CLERK_ENVIRONMENT_STORAGE_ENTRY, SafeLocalStorage } from '../utils/localStorage'; import { memoizeListenerCallback } from '../utils/memoizeStateListenerCallback'; import { RedirectUrls } from '../utils/redirectUrls'; +import { ClerkSharedWorkerManager } from '../utils/sharedWorker'; +import type { SharedWorkerOptions } from '../utils/sharedWorkerUtils'; import { AuthCookieService } from './auth/AuthCookieService'; import { CaptchaHeartbeat } from './auth/CaptchaHeartbeat'; import { CLERK_SATELLITE_URL, CLERK_SUFFIXED_COOKIES, CLERK_SYNCED, ERROR_CODES } from './constants'; @@ -218,6 +220,7 @@ export class Clerk implements ClerkInterface { #touchThrottledUntil = 0; #componentNavigationContext: __internal_ComponentNavigationContext | null = null; #publicEventBus = createClerkEventBus(); + #sharedWorkerManager?: ClerkSharedWorkerManager; public __internal_getCachedResources: | (() => Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null }>) @@ -2360,6 +2363,9 @@ export class Clerk implements ClerkInterface { this.#handleImpersonationFab(); this.#handleKeylessPrompt(); + // Initialize SharedWorker if configured + void this.#initializeSharedWorker(); + this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready'); }; @@ -2397,6 +2403,9 @@ export class Clerk implements ClerkInterface { this.#componentControls = Clerk.mountComponentRenderer(this, this.environment, this.#options); } + // Initialize SharedWorker if configured + void this.#initializeSharedWorker(); + this.#publicEventBus.emit(clerkEvents.Status, initializationDegradedCounter > 0 ? 'degraded' : 'ready'); }; @@ -2582,6 +2591,195 @@ export class Clerk implements ClerkInterface { return this.buildUrlWithAuth(url); }; + #initializeSharedWorker = async (): Promise => { + if (!this.#options.sharedWorker) { + return; + } + + const sharedWorkerOptions = this.#options.sharedWorker; + + if (sharedWorkerOptions.autoStart === false) { + logger.logOnce('Clerk: SharedWorker autoStart is disabled'); + return; + } + + try { + const clerkInstanceId = `clerk-${this.publishableKey.slice(-8)}-${Date.now()}`; + + this.#sharedWorkerManager = new ClerkSharedWorkerManager( + sharedWorkerOptions as SharedWorkerOptions, + clerkInstanceId, + ); + + const worker = await this.#sharedWorkerManager.initialize(); + + if (worker) { + this.#setupSharedWorkerEventForwarding(); + logger.logOnce('Clerk: SharedWorker initialized successfully'); + } + } catch (error) { + logger.warnOnce(`Clerk: Failed to initialize SharedWorker: ${error}`); + } + }; + + #setupSharedWorkerEventForwarding = (): void => { + if (!this.#sharedWorkerManager) { + return; + } + + this.addListener(resources => { + this.#sharedWorkerManager?.postClerkEvent('clerk:state_change', { + isSignedIn: this.isSignedIn, + user: resources.user?.id || null, + session: resources.session?.id || null, + organization: resources.organization?.id || null, + }); + }); + + eventBus.on(events.UserSignOut, () => { + this.#sharedWorkerManager?.postClerkEvent('clerk:sign_out', { + timestamp: Date.now(), + }); + }); + + eventBus.on(events.SessionTokenResolved, () => { + this.#sharedWorkerManager?.postClerkEvent('clerk:session_update', { + sessionId: this.session?.id, + timestamp: Date.now(), + }); + }); + + eventBus.on(events.TokenUpdate, payload => { + this.#sharedWorkerManager?.postClerkEvent('clerk:token_update', { + hasToken: !!payload.token, + timestamp: Date.now(), + }); + }); + + eventBus.on(events.EnvironmentUpdate, () => { + this.#sharedWorkerManager?.postClerkEvent('clerk:environment_update', { + timestamp: Date.now(), + }); + }); + }; + + /** + * Returns the SharedWorker manager instance if available. + * @public + */ + public getSharedWorkerManager(): ClerkSharedWorkerManager | undefined { + return this.#sharedWorkerManager; + } + + /** + * Manually initializes the SharedWorker if not already initialized. + * Useful when autoStart is disabled. + * @public + */ + public async initializeSharedWorker(): Promise { + if (!this.#options.sharedWorker) { + logger.warnOnce('Clerk: No SharedWorker configuration provided'); + return null; + } + + if (this.#sharedWorkerManager?.isActive()) { + logger.logOnce('Clerk: SharedWorker is already initialized'); + return this.#sharedWorkerManager.getWorker(); + } + + await this.#initializeSharedWorker(); + return this.#sharedWorkerManager?.getWorker() || null; + } + + /** + * Terminates the SharedWorker if active. + * @public + */ + public terminateSharedWorker(): void { + if (this.#sharedWorkerManager) { + this.#sharedWorkerManager.terminate(); + this.#sharedWorkerManager = undefined; + logger.logOnce('Clerk: SharedWorker terminated'); + } + } + + /** + * Gets connection information for the current tab's SharedWorker. + * @public + */ + public getSharedWorkerConnectionInfo(): { tabId: string; instanceId: string; isActive: boolean } | null { + if (this.#sharedWorkerManager) { + return this.#sharedWorkerManager.getConnectionInfo(); + } + return null; + } + + /** + * Runs debugging diagnostics on the SharedWorker connection. + * Useful for troubleshooting SharedWorker issues. + * @public + */ + public debugSharedWorker(): void { + if (this.#sharedWorkerManager) { + this.#sharedWorkerManager.debug(); + } else { + logger.warnOnce('Clerk: No SharedWorker manager available for debugging'); + } + } + + /** + * Sends a message to another tab via the SharedWorker. + * The receiving tab will console log the message. + * @param targetTabId - The ID of the tab to send the message to + * @param message - The message to send + * @public + */ + public sendTabMessage(targetTabId: string, message: any): void { + if (!this.#sharedWorkerManager) { + logger.warnOnce('Clerk: SharedWorker not initialized. Cannot send tab message.'); + return; + } + + if (!this.#sharedWorkerManager.isActive()) { + logger.warnOnce('Clerk: SharedWorker is not active. Cannot send tab message.'); + return; + } + + this.#sharedWorkerManager.sendTabMessage(targetTabId, message); + } + + /** + * Gets the current tab ID for this Clerk instance. + * @returns The tab ID string or null if SharedWorker is not initialized + * @public + */ + public getTabId(): string | null { + if (!this.#sharedWorkerManager) { + return null; + } + + return this.#sharedWorkerManager.getTabId(); + } + + /** + * Requests the status of all connected tabs from the SharedWorker. + * The response will be logged to the console. + * @public + */ + public getConnectedTabs(): void { + if (!this.#sharedWorkerManager) { + logger.warnOnce('Clerk: SharedWorker not initialized. Cannot get connected tabs.'); + return; + } + + if (!this.#sharedWorkerManager.isActive()) { + logger.warnOnce('Clerk: SharedWorker is not active. Cannot get connected tabs.'); + return; + } + + this.#sharedWorkerManager.getTabStatus(); + } + assertComponentsReady(controls: unknown): asserts controls is ReturnType { if (!Clerk.mountComponentRenderer) { throw new Error('ClerkJS was loaded without UI components.'); @@ -2655,4 +2853,81 @@ export class Clerk implements ClerkInterface { return allowedProtocols; } + + /** + * Gets access to the SharedWorker manager for tab-to-tab communication. + * Provides methods like sendTabMessage, getTabId, getTabStatus, etc. + * @returns The SharedWorker manager instance or a temporary proxy if still initializing + * @public + */ + public get sharedWorker(): ClerkSharedWorkerManager | undefined { + // If the SharedWorker manager is ready, return it + if (this.#sharedWorkerManager) { + return this.#sharedWorkerManager; + } + + // If SharedWorker is configured and initialization is in progress, + // return a temporary proxy with helpful methods + if (this.#options.sharedWorker) { + const proxy = { + getTabStatus: () => { + console.warn( + '[Clerk] SharedWorker is still initializing. Use window.Clerk.sharedWorker.getTabStatus() in a moment.', + ); + return undefined; + }, + ping: () => { + console.warn('[Clerk] SharedWorker is still initializing. Use window.Clerk.sharedWorker.ping() in a moment.'); + }, + sendTabMessage: (_targetTabId: string, _message: any) => { + console.warn('[Clerk] SharedWorker is still initializing. Cannot send tab message yet.'); + }, + getTabId: () => { + console.warn('[Clerk] SharedWorker is still initializing. Tab ID not available yet.'); + return null; + }, + getConnectionInfo: () => { + console.warn('[Clerk] SharedWorker is still initializing. Connection info not available yet.'); + return null; + }, + isActive: () => { + return false; // Not active yet during initialization + }, + debug: () => { + console.log('[Clerk] SharedWorker Debug - Status: Initializing...'); + console.log(' - SharedWorker configuration is present'); + console.log(' - Auto-initialization is in progress'); + console.log(' - Please wait a moment and try again'); + }, + getInitializationStatus: () => { + return { + isComplete: false, + isActive: false, + initializationTime: null, + tabId: null, + instanceId: `clerk-${this.publishableKey.slice(-8)}-pending`, + }; + }, + terminate: () => { + console.warn('[Clerk] SharedWorker is still initializing. Cannot terminate yet.'); + }, + getWorker: () => { + return null; // No worker available yet + }, + testConnection: () => { + console.log('🔍 [Clerk] SharedWorker Test - Status: Initializing...'); + console.log(' - Cannot test connection while initializing'); + console.log(' - Please wait for initialization to complete'); + }, + } as any; + + // Add a helpful property to indicate this is a temporary proxy + proxy._isInitializingProxy = true; + + return proxy; + } + + // No SharedWorker configuration found + return undefined; + } } diff --git a/packages/clerk-js/src/index.browser.ts b/packages/clerk-js/src/index.browser.ts index 82bd326c1a6..2f496055a73 100644 --- a/packages/clerk-js/src/index.browser.ts +++ b/packages/clerk-js/src/index.browser.ts @@ -4,9 +4,21 @@ import './utils/setWebpackChunkPublicPath'; import { Clerk } from './core/clerk'; +import { createSharedWorkerConfig } from './utils/sharedWorkerUtils'; import { mountComponentRenderer } from './ui/Components'; +declare global { + interface Window { + __clerk_publishable_key?: string; + __clerk_proxy_url?: string; + __clerk_domain?: string; + __clerk_shared_worker?: string | boolean; + __clerk_shared_worker_path?: string; + Clerk?: Clerk; + } +} + Clerk.mountComponentRenderer = mountComponentRenderer; const publishableKey = @@ -22,13 +34,52 @@ const proxyUrl = const domain = document.querySelector('script[data-clerk-domain]')?.getAttribute('data-clerk-domain') || window.__clerk_domain || ''; -// Ensure that if the script has already been injected we don't overwrite the existing Clerk instance. +const sharedWorkerEnabled = + document.querySelector('script[data-clerk-shared-worker]')?.getAttribute('data-clerk-shared-worker') || + window.__clerk_shared_worker; + +const sharedWorkerPath = + document.querySelector('script[data-clerk-shared-worker-path]')?.getAttribute('data-clerk-shared-worker-path') || + window.__clerk_shared_worker_path; + if (!window.Clerk) { - window.Clerk = new Clerk(publishableKey, { + const clerkInstance = new Clerk(publishableKey, { proxyUrl, - // @ts-expect-error + // @ts-expect-error - domain property may not be fully typed in ClerkOptions interface domain, }); + + window.Clerk = clerkInstance; + + const shouldInitializeSharedWorker = sharedWorkerEnabled !== 'false' && sharedWorkerEnabled !== false; + + if (shouldInitializeSharedWorker && typeof SharedWorker !== 'undefined') { + const baseUrl = sharedWorkerPath || ''; + + console.log('[Clerk] Auto-initializing SharedWorker for cross-tab authentication sync'); + + clerkInstance + .load({ + sharedWorker: createSharedWorkerConfig(baseUrl), + }) + .catch(error => { + console.warn('Clerk: Failed to initialize with SharedWorker:', error); + console.log('[Clerk] Falling back to standard initialization without SharedWorker'); + clerkInstance.load().catch(fallbackError => { + console.error('Clerk: Failed to initialize:', fallbackError); + }); + }); + } else if (typeof SharedWorker === 'undefined') { + console.log('[Clerk] SharedWorker not supported in this browser, loading without cross-tab sync'); + clerkInstance.load().catch(error => { + console.error('Clerk: Failed to initialize:', error); + }); + } else { + console.log('[Clerk] SharedWorker disabled, loading without cross-tab sync'); + clerkInstance.load().catch(error => { + console.error('Clerk: Failed to initialize:', error); + }); + } } if (module.hot) { diff --git a/packages/clerk-js/src/utils/__tests__/sharedWorker.test.ts b/packages/clerk-js/src/utils/__tests__/sharedWorker.test.ts new file mode 100644 index 00000000000..c97744f70e2 --- /dev/null +++ b/packages/clerk-js/src/utils/__tests__/sharedWorker.test.ts @@ -0,0 +1,164 @@ +/** + * Tests for ClerkSharedWorkerManager + */ + +import { ClerkSharedWorkerManager } from '../sharedWorker'; +import { createInlineSharedWorker } from '../createInlineSharedWorker'; + +// Mock SharedWorker since it's not available in Node.js test environment +class MockSharedWorker implements SharedWorker { + public port: MockMessagePort; + public onerror: ((this: SharedWorker, ev: ErrorEvent) => any) | null = null; + + constructor(scriptURL: string | URL, options?: string | WorkerOptions) { + this.port = new MockMessagePort(); + } +} + +class MockMessagePort implements MessagePort { + public onmessage: ((this: MessagePort, ev: MessageEvent) => any) | null = null; + public onmessageerror: ((this: MessagePort, ev: MessageEvent) => any) | null = null; + + postMessage(message: any, transfer?: Transferable[]): void { + // Mock implementation + } + + start(): void { + // Mock implementation + } + + close(): void { + // Mock implementation + } + + addEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + // Mock implementation + } + + removeEventListener(type: string, listener: EventListenerOrEventListenerObject): void { + // Mock implementation + } + + dispatchEvent(event: Event): boolean { + return true; + } +} + +// Mock global SharedWorker +(global as any).SharedWorker = MockSharedWorker; +(global as any).window = {}; + +describe('ClerkSharedWorkerManager', () => { + let manager: ClerkSharedWorkerManager; + const mockOptions = { + scriptUrl: '/test-worker.js', + enabled: true, + autoStart: true, + }; + + beforeEach(() => { + manager = new ClerkSharedWorkerManager(mockOptions, 'test-instance-id'); + }); + + afterEach(() => { + if (manager) { + manager.terminate(); + } + }); + + test('should create manager with options', () => { + expect(manager).toBeDefined(); + expect(manager.isActive()).toBe(false); + }); + + test('should initialize SharedWorker when supported', async () => { + const worker = await manager.initialize(); + expect(worker).toBeInstanceOf(MockSharedWorker); + expect(manager.isActive()).toBe(true); + }); + + test('should handle inline worker creation', async () => { + const inlineManager = new ClerkSharedWorkerManager( + { + useInline: true, + enabled: true, + }, + 'test-inline-id', + ); + + const worker = await inlineManager.initialize(); + expect(worker).toBeInstanceOf(MockSharedWorker); + expect(inlineManager.isActive()).toBe(true); + + inlineManager.terminate(); + }); + + test('should post messages to worker', async () => { + const worker = await manager.initialize(); + expect(worker).toBeDefined(); + + // Test posting a message (should not throw) + expect(() => { + manager.postMessage({ type: 'test', data: 'hello' }); + }).not.toThrow(); + }); + + test('should post Clerk events', async () => { + await manager.initialize(); + + expect(() => { + manager.postClerkEvent('clerk:test_event', { user: 'test-user' }); + }).not.toThrow(); + }); + + test('should terminate worker properly', async () => { + await manager.initialize(); + expect(manager.isActive()).toBe(true); + + manager.terminate(); + expect(manager.isActive()).toBe(false); + }); + + test('should return null when SharedWorker is not supported', async () => { + // Temporarily remove SharedWorker support + const originalSharedWorker = (global as any).SharedWorker; + delete (global as any).SharedWorker; + + const unsupportedManager = new ClerkSharedWorkerManager(mockOptions, 'test-unsupported'); + const worker = await unsupportedManager.initialize(); + + expect(worker).toBeNull(); + + // Restore SharedWorker + (global as any).SharedWorker = originalSharedWorker; + }); + + test('should return null when disabled', async () => { + const disabledManager = new ClerkSharedWorkerManager( + { + ...mockOptions, + enabled: false, + }, + 'test-disabled', + ); + + const worker = await disabledManager.initialize(); + expect(worker).toBeNull(); + }); +}); + +describe('createInlineSharedWorker', () => { + test('should create a blob URL for inline worker', () => { + // Mock URL.createObjectURL + const mockCreateObjectURL = jest.fn().mockReturnValue('blob:test-url'); + global.URL.createObjectURL = mockCreateObjectURL; + + // Mock Blob constructor + global.Blob = jest.fn().mockImplementation(() => ({})) as any; + + const blobUrl = createInlineSharedWorker(); + + expect(mockCreateObjectURL).toHaveBeenCalled(); + expect(blobUrl).toBe('blob:test-url'); + }); +}); diff --git a/packages/clerk-js/src/utils/clerk-shared-worker.js b/packages/clerk-js/src/utils/clerk-shared-worker.js new file mode 100644 index 00000000000..83585bfb91e --- /dev/null +++ b/packages/clerk-js/src/utils/clerk-shared-worker.js @@ -0,0 +1,651 @@ +// Safe console wrapper for SharedWorker environment +const safeConsole = { + log: (...args) => { + try { + if (typeof console !== 'undefined' && console.log) { + console.log(...args); + } + } catch (e) { + // Silent fail if console is not available + } + }, + warn: (...args) => { + try { + if (typeof console !== 'undefined' && console.warn) { + console.warn(...args); + } + } catch (e) { + // Silent fail if console is not available + } + }, + error: (...args) => { + try { + if (typeof console !== 'undefined' && console.error) { + console.error(...args); + } + } catch (e) { + // Silent fail if console is not available + } + }, +}; + +class ClerkSharedWorkerState { + constructor() { + this.connectedPorts = new Set(); + this.clerkInstances = new Map(); + this.tabRegistry = new Map(); + this.lastAuthState = null; + this.lastSessionState = null; + this.activeTabId = null; + } + + addPort(port, instanceId, tabId = null) { + this.connectedPorts.add(port); + + if (instanceId) { + const instanceData = { + port, + tabId, + connectedAt: Date.now(), + lastActivity: Date.now(), + state: null, + }; + + this.clerkInstances.set(instanceId, instanceData); + + if (tabId) { + this.tabRegistry.set(tabId, { + instanceId, + port, + lastActivity: Date.now(), + }); + safeConsole.log( + `[ClerkSharedWorker] ➕ Added tab ${tabId} to registry. Registry size: ${this.tabRegistry.size}`, + ); + + if (this.tabRegistry.size > 1) { + this.broadcastToOtherTabs(tabId, { + type: 'clerk_tab_connected', + payload: { + event: 'tab_connected', + newTabId: tabId, + newInstanceId: instanceId, + totalTabs: this.tabRegistry.size, + totalPorts: this.connectedPorts.size, + timestamp: Date.now(), + }, + }); + + safeConsole.log( + `[ClerkSharedWorker] đŸ“ĸ Notified ${this.tabRegistry.size - 1} existing tabs about new tab ${tabId}`, + ); + } else { + safeConsole.log(`[ClerkSharedWorker] 🏠 Tab ${tabId} is the first tab in this session`); + } + } else { + safeConsole.warn(`[ClerkSharedWorker] No tabId provided for instance ${instanceId}`); + } + } + + safeConsole.log( + `[ClerkSharedWorker] Port connected. Instance: ${instanceId}, Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`, + ); + this.logConnectionStatus(); + } + + removePort(port) { + this.connectedPorts.delete(port); + + for (const [instanceId, data] of this.clerkInstances.entries()) { + if (data.port === port) { + const tabId = data.tabId; + this.clerkInstances.delete(instanceId); + + if (tabId) { + this.tabRegistry.delete(tabId); + + if (this.tabRegistry.size > 0) { + this.broadcastToAllPorts({ + type: 'clerk_tab_disconnected', + payload: { + event: 'tab_disconnected', + disconnectedTabId: tabId, + disconnectedInstanceId: instanceId, + totalTabs: this.tabRegistry.size, + totalPorts: this.connectedPorts.size, + timestamp: Date.now(), + }, + }); + + safeConsole.log( + `[ClerkSharedWorker] đŸ“ĸ Notified ${this.tabRegistry.size} remaining tabs about disconnection of tab ${tabId}`, + ); + } else { + safeConsole.log(`[ClerkSharedWorker] 🏁 Last tab ${tabId} disconnected - no more active tabs`); + } + + if (this.activeTabId === tabId) { + safeConsole.log(`[ClerkSharedWorker] 🔄 Active tab ${tabId} disconnected - clearing active state`); + this.activeTabId = null; + } + } + + safeConsole.log( + `[ClerkSharedWorker] ➖ Port disconnected. Instance: ${instanceId}, Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`, + ); + this.logConnectionStatus(); + break; + } + } + } + + logConnectionStatus() { + const tabs = Array.from(this.tabRegistry.keys()); + safeConsole.log(`[ClerkSharedWorker] Active tabs: ${tabs.length} [${tabs.join(', ')}]`); + } + + broadcastToOtherTabs(senderTabId, message) { + let broadcastCount = 0; + + for (const [tabId, tabData] of this.tabRegistry.entries()) { + if (tabId !== senderTabId) { + try { + tabData.port.postMessage({ + ...message, + sourceTabId: senderTabId, + targetTabId: tabId, + }); + broadcastCount++; + } catch (error) { + safeConsole.warn(`[ClerkSharedWorker] Failed to send message to tab ${tabId}:`, error); + this.removePort(tabData.port); + } + } + } + + safeConsole.log(`[ClerkSharedWorker] Broadcasted message to ${broadcastCount} other tabs`); + } + + broadcastToOtherPorts(senderPort, message) { + let senderTabId = null; + + for (const [tabId, tabData] of this.tabRegistry.entries()) { + if (tabData.port === senderPort) { + senderTabId = tabId; + break; + } + } + + if (senderTabId) { + this.broadcastToOtherTabs(senderTabId, message); + } else { + for (const port of this.connectedPorts) { + if (port !== senderPort) { + try { + port.postMessage(message); + } catch (error) { + safeConsole.warn('[ClerkSharedWorker] Failed to send message to port:', error); + this.removePort(port); + } + } + } + } + } + + broadcastToAllPorts(message) { + for (const port of this.connectedPorts) { + try { + port.postMessage(message); + } catch (error) { + safeConsole.warn('[ClerkSharedWorker] Failed to send message to port:', error); + this.removePort(port); + } + } + } + + broadcastToSpecificTab(targetTabId, message) { + const tabData = this.tabRegistry.get(targetTabId); + if (tabData) { + try { + tabData.port.postMessage({ + ...message, + targetTabId, + }); + return true; + } catch (error) { + safeConsole.warn(`[ClerkSharedWorker] Failed to send message to tab ${targetTabId}:`, error); + this.removePort(tabData.port); + return false; + } + } + return false; + } + + handleClerkEvent(port, event, data, instanceId) { + const tabId = data.tabId || null; + safeConsole.log(`[ClerkSharedWorker] Received Clerk event: ${event} from tab ${tabId}`, data); + + if (instanceId && this.clerkInstances.has(instanceId)) { + const instanceData = this.clerkInstances.get(instanceId); + instanceData.lastActivity = Date.now(); + instanceData.state = data; + } + + if (tabId && this.tabRegistry.has(tabId)) { + this.tabRegistry.get(tabId).lastActivity = Date.now(); + } + + switch (event) { + case 'clerk:state_change': + this.handleStateChange(port, data, tabId); + break; + case 'clerk:sign_out': + this.handleSignOut(port, data, tabId); + break; + case 'clerk:session_update': + this.handleSessionUpdate(port, data, tabId); + break; + case 'clerk:token_update': + this.handleTokenUpdate(port, data, tabId); + break; + case 'clerk:environment_update': + this.handleEnvironmentUpdate(port, data, tabId); + break; + default: + safeConsole.log(`[ClerkSharedWorker] Unknown event: ${event}`); + } + } + + handleStateChange(port, data, sourceTabId) { + const stateChanged = + this.lastAuthState?.isSignedIn !== data.isSignedIn || + this.lastAuthState?.user !== data.user || + this.lastAuthState?.session !== data.session || + this.lastAuthState?.organization !== data.organization; + + if (stateChanged) { + this.lastAuthState = data; + safeConsole.log(`[ClerkSharedWorker] Auth state changed in tab ${sourceTabId}, syncing to other tabs`); + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'state_change', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + } + + handleSignOut(port, data, sourceTabId) { + this.lastAuthState = null; + this.lastSessionState = null; + + safeConsole.log(`[ClerkSharedWorker] Sign out event from tab ${sourceTabId}, syncing to all other tabs`); + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'sign_out', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + + handleSessionUpdate(port, data, sourceTabId) { + const sessionChanged = this.lastSessionState?.sessionId !== data.sessionId; + + if (sessionChanged) { + this.lastSessionState = data; + safeConsole.log(`[ClerkSharedWorker] Session updated in tab ${sourceTabId}, syncing to other tabs`); + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'session_update', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + } + + handleTokenUpdate(port, data, sourceTabId) { + safeConsole.log(`[ClerkSharedWorker] Token updated in tab ${sourceTabId}, syncing to other tabs`); + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'token_update', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + + handleEnvironmentUpdate(port, data, sourceTabId) { + safeConsole.log(`[ClerkSharedWorker] Environment updated in tab ${sourceTabId}, syncing to other tabs`); + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'environment_update', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + + getTabStatus() { + const tabs = []; + for (const [tabId, tabData] of this.tabRegistry.entries()) { + const instanceData = this.clerkInstances.get(tabData.instanceId); + tabs.push({ + tabId, + instanceId: tabData.instanceId, + lastActivity: tabData.lastActivity, + connectedAt: instanceData?.connectedAt, + state: instanceData?.state, + isActive: tabId === this.activeTabId, + }); + } + return { + tabs, + activeTabId: this.activeTabId, + totalTabs: this.tabRegistry.size, + }; + } + + setActiveTab(tabId) { + if (this.tabRegistry.has(tabId)) { + const previousActiveTab = this.activeTabId; + this.activeTabId = tabId; + + if (previousActiveTab && previousActiveTab !== tabId) { + safeConsole.log(`[ClerkSharedWorker] ✨ Active tab switched: ${previousActiveTab} → ${tabId}`); + } else if (!previousActiveTab) { + safeConsole.log(`[ClerkSharedWorker] 🎉 Tab ${tabId} is now the first active tab`); + } else { + safeConsole.log(`[ClerkSharedWorker] 🔄 Tab ${tabId} remains active`); + } + + this.broadcastToAllPorts({ + type: 'clerk_active_tab_changed', + payload: { + event: 'active_tab_changed', + activeTabId: tabId, + previousActiveTabId: previousActiveTab, + timestamp: Date.now(), + }, + }); + + return true; + } + safeConsole.warn(`[ClerkSharedWorker] âš ī¸ Attempted to set unknown tab ${tabId} as active`); + return false; + } +} + +const clerkState = new ClerkSharedWorkerState(); + +self.addEventListener('connect', event => { + const port = event.ports[0]; + + port.onmessage = messageEvent => { + const { type, payload } = messageEvent.data; + + switch (type) { + case 'clerk_init': + safeConsole.log(`[ClerkSharedWorker] Received init message:`, payload); + clerkState.addPort(port, payload.instanceId, payload.tabId); + + const responsePayload = { + timestamp: Date.now(), + connectedPorts: clerkState.connectedPorts.size, + connectedTabs: clerkState.tabRegistry.size, + tabId: payload.tabId, + instanceId: payload.instanceId, + }; + + safeConsole.log(`[ClerkSharedWorker] Sending ready response:`, responsePayload); + + port.postMessage({ + type: 'clerk_worker_ready', + payload: responsePayload, + }); + break; + + case 'clerk_event': + clerkState.handleClerkEvent(port, payload.event, payload.data, payload.clerkInstanceId); + break; + + case 'clerk_ping': + port.postMessage({ + type: 'clerk_pong', + payload: { + timestamp: Date.now(), + instances: clerkState.clerkInstances.size, + ports: clerkState.connectedPorts.size, + tabs: clerkState.tabRegistry.size, + activeTabId: clerkState.activeTabId, + tabStatus: clerkState.getTabStatus(), + }, + }); + break; + + case 'clerk_tab_focus': + const previousActive = clerkState.activeTabId; + safeConsole.log(`[ClerkSharedWorker] đŸŽ¯ Tab ${payload.tabId} gained focus`); + if (previousActive && previousActive !== payload.tabId) { + safeConsole.log(`[ClerkSharedWorker] 📋 Active tab changed: ${previousActive} → ${payload.tabId}`); + } else if (!previousActive) { + safeConsole.log(`[ClerkSharedWorker] 🚀 First tab became active: ${payload.tabId}`); + } + + clerkState.setActiveTab(payload.tabId); + + safeConsole.log( + `[ClerkSharedWorker] 📊 Tab status: ${clerkState.tabRegistry.size} total tabs, ${payload.tabId} is active`, + ); + + port.postMessage({ + type: 'clerk_tab_focus_response', + payload: { + success: true, + activeTabId: payload.tabId, + timestamp: Date.now(), + }, + }); + break; + + case 'clerk_tab_blur': + safeConsole.log(`[ClerkSharedWorker] 😴 Tab ${payload.tabId} lost focus`); + + if (clerkState.activeTabId === payload.tabId) { + safeConsole.log(`[ClerkSharedWorker] 🔄 Active tab ${payload.tabId} is now inactive - clearing active state`); + clerkState.activeTabId = null; + + safeConsole.log(`[ClerkSharedWorker] 📊 Tab status: ${clerkState.tabRegistry.size} total tabs, none active`); + + clerkState.broadcastToAllPorts({ + type: 'clerk_active_tab_changed', + payload: { + event: 'active_tab_changed', + activeTabId: null, + previousActiveTabId: payload.tabId, + timestamp: Date.now(), + }, + }); + } else { + safeConsole.log( + `[ClerkSharedWorker] â„šī¸ Tab ${payload.tabId} lost focus, but it wasn't the active tab (active: ${clerkState.activeTabId})`, + ); + } + + port.postMessage({ + type: 'clerk_tab_blur_response', + payload: { + success: true, + tabId: payload.tabId, + timestamp: Date.now(), + }, + }); + break; + + case 'clerk_get_tab_status': + const tabStatusData = clerkState.getTabStatus(); + safeConsole.log(`[ClerkSharedWorker] Sending tab status:`, tabStatusData); + port.postMessage({ + type: 'clerk_tab_status', + payload: { + timestamp: Date.now(), + ...tabStatusData, + }, + }); + break; + + case 'debug_test': + safeConsole.log(`[ClerkSharedWorker] Debug test received:`, payload); + port.postMessage({ + type: 'debug_test_response', + payload: { + ...payload, + response: 'Test successful', + timestamp: Date.now(), + workerStatus: { + connectedPorts: clerkState.connectedPorts.size, + connectedTabs: clerkState.tabRegistry.size, + instances: clerkState.clerkInstances.size, + }, + }, + }); + break; + + case 'debug_ping': + safeConsole.log(`[ClerkSharedWorker] Debug ping received from tab ${payload.tabId}:`, payload); + port.postMessage({ + type: 'debug_pong', + payload: { + timestamp: Date.now(), + receivedPayload: payload, + workerState: { + connectedPorts: clerkState.connectedPorts.size, + connectedTabs: clerkState.tabRegistry.size, + instances: clerkState.clerkInstances.size, + tabRegistry: Array.from(clerkState.tabRegistry.keys()), + activeTabId: clerkState.activeTabId, + lastAuthState: clerkState.lastAuthState ? 'present' : 'null', + }, + }, + }); + break; + + case 'clerk_heartbeat': + const { tabId: heartbeatTabId, instanceId: heartbeatInstanceId } = payload; + + if (heartbeatInstanceId && clerkState.clerkInstances.has(heartbeatInstanceId)) { + clerkState.clerkInstances.get(heartbeatInstanceId).lastActivity = Date.now(); + } + + if (heartbeatTabId && clerkState.tabRegistry.has(heartbeatTabId)) { + clerkState.tabRegistry.get(heartbeatTabId).lastActivity = Date.now(); + } + + safeConsole.log(`[ClerkSharedWorker] Heartbeat received from tab ${heartbeatTabId}`); + break; + + case 'send_tab_message': + safeConsole.log( + `[ClerkSharedWorker] Message forwarding request from tab ${payload.sourceTabId} to tab ${payload.targetTabId}:`, + payload.message, + ); + + let sourceTabId = payload.sourceTabId; + if (!sourceTabId) { + for (const [tabId, tabData] of clerkState.tabRegistry.entries()) { + if (tabData.port === port) { + sourceTabId = tabId; + break; + } + } + } + + const messageSent = clerkState.broadcastToSpecificTab(payload.targetTabId, { + type: 'tab_message_received', + payload: { + message: payload.message, + sourceTabId: sourceTabId, + targetTabId: payload.targetTabId, + timestamp: Date.now(), + }, + }); + + port.postMessage({ + type: 'send_tab_message_response', + payload: { + success: messageSent, + targetTabId: payload.targetTabId, + sourceTabId: sourceTabId, + message: payload.message, + timestamp: Date.now(), + }, + }); + + if (messageSent) { + safeConsole.log( + `[ClerkSharedWorker] Successfully forwarded message from tab ${sourceTabId} to tab ${payload.targetTabId}`, + ); + } else { + safeConsole.warn( + `[ClerkSharedWorker] Failed to forward message from tab ${sourceTabId} to tab ${payload.targetTabId} - target tab not found`, + ); + } + break; + + default: + safeConsole.warn(`[ClerkSharedWorker] Unknown message type: ${type}`); + } + }; + + port.onmessageerror = error => { + safeConsole.error('[ClerkSharedWorker] Message error:', error); + }; + + port.addEventListener('close', () => { + clerkState.removePort(port); + }); + + port.start(); +}); + +// setInterval(() => { +// const now = Date.now(); +// const staleThreshold = 300000; + +// for (const [instanceId, data] of clerkState.clerkInstances.entries()) { +// if (now - data.lastActivity > staleThreshold) { +// console.log(`[ClerkSharedWorker] Cleaning up stale instance: ${instanceId}, tab: ${data.tabId}`); +// clerkState.removePort(data.port); +// } +// } + +// for (const [tabId, tabData] of clerkState.tabRegistry.entries()) { +// if (now - tabData.lastActivity > staleThreshold) { +// console.log(`[ClerkSharedWorker] Cleaning up stale tab: ${tabId}`); +// if (clerkState.clerkInstances.has(tabData.instanceId)) { +// const instanceData = clerkState.clerkInstances.get(tabData.instanceId); +// clerkState.removePort(instanceData.port); +// } +// } +// } +// }, 30000); + +safeConsole.log('[ClerkSharedWorker] SharedWorker script loaded and ready for tab coordination'); + diff --git a/packages/clerk-js/src/utils/generateTabId.ts b/packages/clerk-js/src/utils/generateTabId.ts new file mode 100644 index 00000000000..85c4bc70a85 --- /dev/null +++ b/packages/clerk-js/src/utils/generateTabId.ts @@ -0,0 +1,127 @@ +/** + * Generates unique tab IDs for SharedWorker coordination + * + * This module provides a comprehensive tab identification system for Clerk's SharedWorker + * functionality. Each browser tab gets a unique identifier that persists for the duration + * of the session, enabling proper coordination between multiple tabs. + * + * Key features: + * - Unique tab IDs with multiple entropy sources (timestamp, random, performance) + * - Session storage persistence for consistency within the same tab + * - Clerk instance IDs that embed the tab ID for traceability + * - Utility functions for extracting and verifying tab ID consistency + * + * The tab ID format: tab_[timestamp]_[random]_[session]_[performance] + * The instance ID format: clerk_[keyFragment]_[tabId]_[timestamp] + */ + +/** + * Generates a random string ID + */ +function generateRandomId(length: number = 8): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +/** + * Generates a unique tab ID based on multiple factors for better uniqueness + */ +export function generateUniqueTabId(): string { + const timestamp = Date.now().toString(36); // Base36 for shorter string + const randomId = generateRandomId(6); + const sessionId = Math.random().toString(36).substr(2, 6); + + const performanceTime = typeof performance !== 'undefined' ? performance.now().toString(36).replace('.', '') : ''; + + return `tab_${timestamp}_${randomId}_${sessionId}${performanceTime ? '_' + performanceTime : ''}`; +} + +/** + * Generates a unique Clerk instance ID that includes tab identification + */ +export function generateClerkInstanceId(publishableKey: string): string { + const keyFragment = publishableKey.slice(-8); + const tabId = getCurrentTabId(); // Use the existing tab ID instead of generating a new one + const timestamp = Date.now(); + + return `clerk_${keyFragment}_${tabId}_${timestamp}`; +} + +/** + * Extracts the tab ID from a Clerk instance ID + */ +export function extractTabIdFromInstanceId(instanceId: string): string | null { + const parts = instanceId.split('_'); + if (parts.length >= 4 && parts[0] === 'clerk') { + const tabIdParts = parts.slice(2, -1); + return tabIdParts.join('_'); + } + return null; +} + +/** + * Global tab ID - generated once per tab session + */ +let globalTabId: string | null = null; + +/** + * Gets the current tab's unique ID (creates one if it doesn't exist) + */ +export function getCurrentTabId(): string { + if (!globalTabId) { + globalTabId = generateUniqueTabId(); + + if (typeof sessionStorage !== 'undefined') { + try { + sessionStorage.setItem('clerk_tab_id', globalTabId); + } catch { + console.warn(`[Clerk] Failed to set tab ID in sessionStorage`); + } + } + } + + return globalTabId; +} + +/** + * Initialize tab ID from sessionStorage if available + */ +if (typeof sessionStorage !== 'undefined') { + try { + const storedTabId = sessionStorage.getItem('clerk_tab_id'); + if (storedTabId) { + globalTabId = storedTabId; + } + } catch { + console.warn(`[Clerk] Failed to initialize tab ID from sessionStorage`); + } +} + +/** + * Verifies that the tab ID generation and extraction work correctly + * @internal + */ +export function verifyTabIdConsistency(): boolean { + try { + const tabId = getCurrentTabId(); + const instanceId = generateClerkInstanceId('pk_test_abcd1234'); + const extractedTabId = extractTabIdFromInstanceId(instanceId); + + const isConsistent = tabId === extractedTabId; + + if (!isConsistent) { + console.warn( + `[Clerk] Tab ID consistency check failed. Original: ${tabId}, Extracted: ${extractedTabId}, Instance: ${instanceId}`, + ); + } + + return isConsistent; + } catch (error) { + console.warn(`[Clerk] Tab ID verification failed:`, error); + return false; + } +} diff --git a/packages/clerk-js/src/utils/sharedWorker.ts b/packages/clerk-js/src/utils/sharedWorker.ts new file mode 100644 index 00000000000..29335273356 --- /dev/null +++ b/packages/clerk-js/src/utils/sharedWorker.ts @@ -0,0 +1,576 @@ +import { logger } from '@clerk/shared/logger'; + +import { getCurrentTabId, verifyTabIdConsistency } from './generateTabId'; +import { debugExistingSharedWorker } from './sharedWorkerDebugger'; +import type { SharedWorkerOptions } from './sharedWorkerUtils'; + +export interface ClerkSharedWorkerMessage { + type: 'clerk_event'; + payload: { + event: string; + data: any; + timestamp: number; + clerkInstanceId: string; + }; +} + +export class ClerkSharedWorkerManager { + private worker: SharedWorker | null = null; + private options: SharedWorkerOptions; + private clerkInstanceId: string; + private tabId: string; + private isSupported: boolean; + private heartbeatInterval: number | null = null; + private focusHandler: (() => void) | null = null; + private blurHandler: (() => void) | null = null; + private visibilityChangeHandler: (() => void) | null = null; + private initializationComplete: boolean = false; + private initializationStartTime: number | null = null; + + constructor(options: SharedWorkerOptions, clerkInstanceId: string) { + this.options = options; + this.clerkInstanceId = clerkInstanceId; + this.tabId = getCurrentTabId(); + this.isSupported = this.checkSharedWorkerSupport(); + + if (!verifyTabIdConsistency()) { + logger.warnOnce('Clerk: Tab ID consistency check failed during SharedWorker initialization'); + } + } + + private checkSharedWorkerSupport(): boolean { + return typeof SharedWorker !== 'undefined' && typeof window !== 'undefined'; + } + + /** + * Initializes the SharedWorker if supported and enabled. + */ + public async initialize(): Promise { + this.initializationStartTime = Date.now(); + + if (!this.isSupported) { + logger.warnOnce('Clerk: SharedWorker is not supported in this environment'); + return null; + } + + if (!this.options.enabled) { + logger.warnOnce('Clerk: SharedWorker is disabled'); + return null; + } + + const scriptUrl = this.options.scriptUrl; + + if (!scriptUrl) { + logger.warnOnce('Clerk: SharedWorker scriptUrl is required'); + return null; + } + + try { + this.worker = new SharedWorker(scriptUrl, this.options.options); + + this.setupMessageHandling(); + this.setupErrorHandling(); + this.setupFocusBlurListeners(); + + const initMessage = { + type: 'clerk_init', + payload: { + instanceId: this.clerkInstanceId, + tabId: this.tabId, + timestamp: Date.now(), + }, + }; + + logger.logOnce(`Clerk: Sending init message to SharedWorker: ${JSON.stringify(initMessage, null, 2)}`); + this.postMessage(initMessage); + + this.startHeartbeat(); + + if (this.options.onReady) { + this.options.onReady(this.worker); + } + + this.initializationComplete = true; + const initTime = Date.now() - (this.initializationStartTime || Date.now()); + logger.logOnce(`Clerk: SharedWorker initialized successfully for tab ${this.tabId} (${initTime}ms)`); + + return this.worker; + } catch (error) { + const err = error as Error; + logger.warnOnce(`Clerk: Failed to initialize SharedWorker: ${err.message}`); + + if (this.options.onError) { + this.options.onError(err); + } + + return null; + } + } + + private setupMessageHandling(): void { + if (!this.worker) return; + + this.worker.port.onmessage = event => { + try { + const message = event.data; + + if (message.type === 'clerk_worker_ready') { + const { connectedTabs, connectedPorts, tabId, instanceId } = message.payload || {}; + logger.logOnce( + `Clerk: SharedWorker is ready for tab ${this.tabId}. ` + + `Connected tabs: ${connectedTabs ?? 'unknown'}, ` + + `Connected ports: ${connectedPorts ?? 'unknown'}, ` + + `Worker tab ID: ${tabId}, ` + + `Worker instance ID: ${instanceId}`, + ); + } else if (message.type === 'clerk_sync_state') { + const { sourceTabId, event: syncEvent, data } = message.payload; + logger.logOnce(`Clerk: Received sync event '${syncEvent}' from tab ${sourceTabId} to tab ${this.tabId}`); + + this.handleSyncEvent(syncEvent, data, sourceTabId); + } else if (message.type === 'clerk_pong') { + const { tabs, instances, tabStatus } = message.payload || {}; + logger.logOnce(`Clerk: Ping response - Tabs: ${tabs ?? 'unknown'}, Instances: ${instances ?? 'unknown'}`); + if (tabStatus && tabStatus.length > 0) { + logger.logOnce(`Clerk: Active tabs: ${JSON.stringify(tabStatus, null, 2)}`); + } + } else if (message.type === 'clerk_tab_status') { + const { tabs, activeTabId, totalTabs } = message.payload || {}; + logger.logOnce(`Clerk: Tab status response: ${JSON.stringify(tabs, null, 2)}`); + logger.logOnce(`Clerk: Active tab: ${activeTabId || 'none'}, Total tabs: ${totalTabs || 0}`); + } else if (message.type === 'clerk_tab_connected') { + const { newTabId, newInstanceId, totalTabs, totalPorts } = message.payload || {}; + console.log( + `đŸŸĸ [Clerk] New tab connected! ` + + `Tab ID: ${newTabId}, ` + + `Instance: ${newInstanceId}, ` + + `Total tabs: ${totalTabs}, ` + + `Total ports: ${totalPorts}`, + ); + } else if (message.type === 'clerk_tab_disconnected') { + const { disconnectedTabId, disconnectedInstanceId, totalTabs, totalPorts } = message.payload || {}; + console.log( + `🔴 [Clerk] Tab disconnected! ` + + `Tab ID: ${disconnectedTabId}, ` + + `Instance: ${disconnectedInstanceId}, ` + + `Remaining tabs: ${totalTabs}, ` + + `Remaining ports: ${totalPorts}`, + ); + } else if (message.type === 'debug_pong') { + const { workerState, receivedPayload } = message.payload || {}; + console.log('🔍 [Clerk Debug] Debug pong received:', { + workerState, + receivedPayload, + timestamp: new Date().toISOString(), + }); + } else if (message.type === 'debug_test_response') { + console.log('🔍 [Clerk Debug] Debug test response:', message.payload); + } else if (message.type === 'tab_message_received') { + const { message: tabMessage, sourceTabId, targetTabId: _targetTabId } = message.payload || {}; + console.log(`📨 [Clerk] Message received from tab ${sourceTabId}:`, tabMessage); + } else if (message.type === 'send_tab_message_response') { + const { success, targetTabId, sourceTabId: _sourceTabId } = message.payload || {}; + console.log(`📤 [Clerk] Message send result to tab ${targetTabId}: ${success ? 'SUCCESS' : 'FAILED'}`); + } else if (message.type === 'clerk_tab_focus_response') { + const { success, activeTabId } = message.payload || {}; + console.log(`đŸŽ¯ [Clerk] Focus response: ${success ? 'SUCCESS' : 'FAILED'}, active tab: ${activeTabId}`); + } else if (message.type === 'clerk_tab_blur_response') { + const { success, tabId } = message.payload || {}; + console.log(`😴 [Clerk] Blur response: ${success ? 'SUCCESS' : 'FAILED'} for tab: ${tabId}`); + } else if (message.type === 'clerk_active_tab_changed') { + const { activeTabId, previousActiveTabId } = message.payload || {}; + if (activeTabId) { + console.log(`✨ [Clerk] Active tab changed: ${previousActiveTabId || 'none'} → ${activeTabId}`); + } else { + console.log(`🔄 [Clerk] No active tab (previous: ${previousActiveTabId})`); + } + } + } catch (error) { + logger.warnOnce(`Clerk: Error handling SharedWorker message: ${error}`); + } + }; + + this.worker.port.start(); + } + + private handleSyncEvent(event: string, _: any, sourceTabId: string): void { + switch (event) { + case 'state_change': + logger.logOnce(`Clerk: Auth state synchronized from tab ${sourceTabId}`); + break; + case 'sign_out': + logger.logOnce(`Clerk: Sign out synchronized from tab ${sourceTabId}`); + break; + case 'session_update': + logger.logOnce(`Clerk: Session update synchronized from tab ${sourceTabId}`); + break; + case 'token_update': + logger.logOnce(`Clerk: Token update synchronized from tab ${sourceTabId}`); + break; + case 'environment_update': + logger.logOnce(`Clerk: Environment update synchronized from tab ${sourceTabId}`); + break; + default: + logger.logOnce(`Clerk: Unknown sync event '${event}' from tab ${sourceTabId}`); + } + } + + private setupErrorHandling(): void { + if (!this.worker) return; + + this.worker.onerror = error => { + const errorMessage = `SharedWorker error: ${error.message || 'Unknown error'}`; + logger.warnOnce(`Clerk: ${errorMessage}`); + console.error('[Clerk] SharedWorker Error:', error); + + if (this.options.onError) { + this.options.onError(new Error(errorMessage)); + } + }; + + // Add port error handling + this.worker.port.onmessageerror = error => { + const errorMsg = error instanceof Error ? error.message : JSON.stringify(error); + logger.warnOnce(`Clerk: SharedWorker port message error: ${errorMsg}`); + }; + + // Add additional error detection for connection issues + let initTimeoutId: number | null = null; + let workerReadyReceived = false; + + // Set up a timeout to detect if the worker never responds + initTimeoutId = window.setTimeout(() => { + if (!workerReadyReceived) { + const errorMsg = 'SharedWorker failed to respond within 5 seconds - possible script loading issue'; + logger.warnOnce(`Clerk: ${errorMsg}`); + + if (this.options.onError) { + this.options.onError(new Error(errorMsg)); + } + } + }, 5000); + + // Listen for the worker ready message to clear the timeout + const originalOnMessage = this.worker.port.onmessage; + this.worker.port.onmessage = event => { + try { + const message = event.data; + if (message.type === 'clerk_worker_ready') { + workerReadyReceived = true; + if (initTimeoutId !== null) { + clearTimeout(initTimeoutId); + initTimeoutId = null; + } + } + + // Call the original handler + if (originalOnMessage && this.worker) { + originalOnMessage.call(this.worker.port, event); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : JSON.stringify(error); + logger.warnOnce(`Clerk: Error in SharedWorker message handler: ${errorMsg}`); + } + }; + } + + /** + * Sets up window focus/blur event listeners to track tab activity. + */ + private setupFocusBlurListeners(): void { + if (!this.worker || typeof window === 'undefined') return; + + this.focusHandler = () => { + console.log(`đŸŽ¯ [Clerk] Tab ${this.tabId} gained focus - notifying SharedWorker`); + this.postMessage({ + type: 'clerk_tab_focus', + payload: { + tabId: this.tabId, + timestamp: Date.now(), + }, + }); + }; + + this.blurHandler = () => { + console.log(`😴 [Clerk] Tab ${this.tabId} lost focus - notifying SharedWorker`); + this.postMessage({ + type: 'clerk_tab_blur', + payload: { + tabId: this.tabId, + timestamp: Date.now(), + }, + }); + }; + + // Listen for window focus/blur events + window.addEventListener('focus', this.focusHandler); + window.addEventListener('blur', this.blurHandler); + + // Also listen for page visibility API as backup + if (document.visibilityState) { + this.visibilityChangeHandler = () => { + if (document.visibilityState === 'visible') { + this.focusHandler?.(); + } else if (document.visibilityState === 'hidden') { + this.blurHandler?.(); + } + }; + + document.addEventListener('visibilitychange', this.visibilityChangeHandler); + } + + // Send initial focus state if tab is currently focused + if (document.hasFocus()) { + console.log(`🚀 [Clerk] Tab ${this.tabId} is initially focused - notifying SharedWorker`); + this.focusHandler(); + } + } + + /** + * Posts a message to the SharedWorker. + */ + public postMessage(message: any): void { + if (!this.worker) { + logger.warnOnce('Clerk: Cannot post message - SharedWorker not initialized'); + return; + } + + try { + this.worker.port.postMessage(message); + } catch (error) { + logger.warnOnce(`Clerk: Error posting message to SharedWorker: ${error.message}`); + } + } + + /** + * Posts a Clerk-specific event to the SharedWorker. + */ + public postClerkEvent(event: string, data: any): void { + const message: ClerkSharedWorkerMessage = { + type: 'clerk_event', + payload: { + event, + data, + timestamp: Date.now(), + clerkInstanceId: this.clerkInstanceId, + }, + }; + + this.postMessage(message); + } + + /** + * Terminates the SharedWorker. + */ + public terminate(): void { + this.stopHeartbeat(); + this.cleanupFocusBlurListeners(); + + if (this.worker) { + this.worker.port.close(); + this.worker = null; + logger.logOnce('Clerk: SharedWorker terminated'); + } + } + + /** + * Returns whether the SharedWorker is currently active. + */ + public isActive(): boolean { + return this.worker !== null; + } + + /** + * Returns the SharedWorker instance if active. + */ + public getWorker(): SharedWorker | null { + return this.worker; + } + + /** + * Gets the current tab ID for this SharedWorker instance. + */ + public getTabId(): string { + return this.tabId; + } + + /** + * Sends a ping to the SharedWorker to get status information. + */ + public ping(): void { + this.postMessage({ + type: 'clerk_ping', + payload: { + tabId: this.tabId, + timestamp: Date.now(), + }, + }); + } + + /** + * Requests tab status information from the SharedWorker. + */ + public getTabStatus(): void { + this.postMessage({ + type: 'clerk_get_tab_status', + payload: { + tabId: this.tabId, + timestamp: Date.now(), + }, + }); + } + + /** + * Sends a message to another tab via the SharedWorker. + */ + public sendTabMessage(targetTabId: string, message: any): void { + if (!this.worker) { + logger.warnOnce('Clerk: Cannot send tab message - SharedWorker not initialized'); + return; + } + + const messageData = { + type: 'send_tab_message', + payload: { + targetTabId, + sourceTabId: this.tabId, + message, + timestamp: Date.now(), + }, + }; + + this.postMessage(messageData); + } + + /** + * Gets information about the current SharedWorker connection. + */ + public getConnectionInfo(): { tabId: string; instanceId: string; isActive: boolean } { + return { + tabId: this.tabId, + instanceId: this.clerkInstanceId, + isActive: this.isActive(), + }; + } + + /** + * Runs diagnostic checks on the SharedWorker connection. + * Useful for debugging connection issues. + */ + public debug(): void { + console.log('🔍 [Clerk SharedWorker] Starting debug session...'); + console.log(` - Tab ID: ${this.tabId}`); + console.log(` - Instance ID: ${this.clerkInstanceId}`); + console.log(` - Worker active: ${this.isActive()}`); + console.log(` - Initialization complete: ${this.initializationComplete}`); + + if (this.initializationStartTime) { + const elapsed = Date.now() - this.initializationStartTime; + console.log(` - Initialization time: ${elapsed}ms`); + } + + console.log(` - Script URL: ${this.options.scriptUrl}`); + console.log(` - Options:`, this.options); + + if (this.worker) { + debugExistingSharedWorker(this.worker, this.tabId); + } else { + console.log('❌ [Clerk SharedWorker] No worker instance available for debugging'); + } + } + + /** + * Gets detailed initialization status information. + */ + public getInitializationStatus(): { + isComplete: boolean; + isActive: boolean; + initializationTime: number | null; + tabId: string; + instanceId: string; + } { + return { + isComplete: this.initializationComplete, + isActive: this.isActive(), + initializationTime: this.initializationStartTime ? Date.now() - this.initializationStartTime : null, + tabId: this.tabId, + instanceId: this.clerkInstanceId, + }; + } + + /** + * Starts a heartbeat to keep the SharedWorker connection alive. + */ + private startHeartbeat(): void { + this.heartbeatInterval = window.setInterval(() => { + if (this.worker) { + this.postMessage({ + type: 'clerk_heartbeat', + payload: { + tabId: this.tabId, + instanceId: this.clerkInstanceId, + timestamp: Date.now(), + }, + }); + } + }, 120000); + } + + /** + * Stops the heartbeat. + */ + private stopHeartbeat(): void { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + private cleanupFocusBlurListeners(): void { + if (typeof window === 'undefined') return; + + // Remove event listeners + if (this.focusHandler) { + window.removeEventListener('focus', this.focusHandler); + this.focusHandler = null; + } + + if (this.blurHandler) { + window.removeEventListener('blur', this.blurHandler); + this.blurHandler = null; + } + + if (this.visibilityChangeHandler && document.visibilityState) { + document.removeEventListener('visibilitychange', this.visibilityChangeHandler); + this.visibilityChangeHandler = null; + } + } + + /** + * Simple diagnostic test for SharedWorker functionality + */ + public testConnection(): void { + if (!this.worker) { + console.log('❌ [Clerk] SharedWorker not initialized'); + return; + } + + console.log('🔍 [Clerk] Testing SharedWorker connection...'); + console.log(` - Tab ID: ${this.tabId}`); + console.log(` - Instance ID: ${this.clerkInstanceId}`); + console.log(` - Worker exists: ${!!this.worker}`); + console.log(` - Initialization complete: ${this.initializationComplete}`); + + // Send a test ping + this.postMessage({ + type: 'debug_ping', + payload: { + tabId: this.tabId, + testMessage: 'Connection test from tab', + timestamp: Date.now(), + }, + }); + + console.log('📤 [Clerk] Test ping sent to SharedWorker'); + } +} diff --git a/packages/clerk-js/src/utils/sharedWorkerDebugger.ts b/packages/clerk-js/src/utils/sharedWorkerDebugger.ts new file mode 100644 index 00000000000..75f26a35323 --- /dev/null +++ b/packages/clerk-js/src/utils/sharedWorkerDebugger.ts @@ -0,0 +1,186 @@ +/** + * SharedWorker Debugging Utility + * Helps diagnose issues with SharedWorker initialization and communication + */ + +export interface SharedWorkerDebugInfo { + isSupported: boolean; + canCreateWorker: boolean; + workerError?: string; + messageTestPassed: boolean; + scriptUrl: string; + testResults: { + workerCreation: 'success' | 'error'; + portConnection: 'success' | 'error'; + messageRoundTrip: 'success' | 'error' | 'timeout'; + errorDetails?: string; + }; +} + +export class SharedWorkerDebugger { + private testTimeoutMs = 5000; + + /** + * Performs comprehensive SharedWorker debugging + */ + public async diagnoseSharedWorker(scriptUrl: string): Promise { + const debugInfo: SharedWorkerDebugInfo = { + isSupported: typeof SharedWorker !== 'undefined', + canCreateWorker: false, + messageTestPassed: false, + scriptUrl, + testResults: { + workerCreation: 'error', + portConnection: 'error', + messageRoundTrip: 'error', + }, + }; + + console.log('🔍 [SharedWorker Debug] Starting diagnostic...'); + console.log(`📍 [SharedWorker Debug] Script URL: ${scriptUrl}`); + console.log(`✅ [SharedWorker Debug] Browser support: ${debugInfo.isSupported}`); + + if (!debugInfo.isSupported) { + console.error('❌ [SharedWorker Debug] SharedWorker not supported in this browser'); + return debugInfo; + } + + try { + // Test 1: Worker Creation + console.log('🔄 [SharedWorker Debug] Test 1: Creating SharedWorker...'); + const worker = new SharedWorker(scriptUrl); + debugInfo.canCreateWorker = true; + debugInfo.testResults.workerCreation = 'success'; + console.log('✅ [SharedWorker Debug] Test 1: SharedWorker created successfully'); + + // Test 2: Port Connection + console.log('🔄 [SharedWorker Debug] Test 2: Testing port connection...'); + worker.port.start(); + debugInfo.testResults.portConnection = 'success'; + console.log('✅ [SharedWorker Debug] Test 2: Port connected successfully'); + + // Test 3: Message Round Trip + console.log('🔄 [SharedWorker Debug] Test 3: Testing message round trip...'); + const messageTest = await this.testMessageRoundTrip(worker); + debugInfo.messageTestPassed = messageTest.success; + debugInfo.testResults.messageRoundTrip = messageTest.success ? 'success' : 'error'; + + if (messageTest.success) { + console.log('✅ [SharedWorker Debug] Test 3: Message round trip successful'); + } else { + console.error('❌ [SharedWorker Debug] Test 3: Message round trip failed:', messageTest.error); + debugInfo.testResults.errorDetails = messageTest.error; + } + + // Clean up + worker.port.close(); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('❌ [SharedWorker Debug] Worker creation failed:', errorMessage); + debugInfo.workerError = errorMessage; + debugInfo.testResults.errorDetails = errorMessage; + } + + console.log('🏁 [SharedWorker Debug] Diagnostic complete:', debugInfo); + return debugInfo; + } + + /** + * Tests message round trip with the SharedWorker + */ + private testMessageRoundTrip(worker: SharedWorker): Promise<{ success: boolean; error?: string }> { + return new Promise(resolve => { + const timeout = setTimeout(() => { + resolve({ success: false, error: 'Message round trip timeout' }); + }, this.testTimeoutMs); + + const testMessage = { + type: 'debug_test', + payload: { + timestamp: Date.now(), + testId: Math.random().toString(36).substring(7), + }, + }; + + worker.port.onmessage = event => { + clearTimeout(timeout); + + if (event.data.type === 'debug_test_response') { + resolve({ success: true }); + } else { + resolve({ success: false, error: `Unexpected response: ${JSON.stringify(event.data)}` }); + } + }; + + worker.port.onmessageerror = error => { + clearTimeout(timeout); + resolve({ success: false, error: `Message error: ${error}` }); + }; + + worker.onerror = error => { + clearTimeout(timeout); + resolve({ success: false, error: `Worker error: ${error.message || 'Unknown worker error'}` }); + }; + + console.log('📤 [SharedWorker Debug] Sending test message:', testMessage); + worker.port.postMessage(testMessage); + }); + } + + /** + * Quick check for common SharedWorker issues + */ + public static quickDiagnosis(): void { + console.log('🔍 [SharedWorker Debug] Quick Diagnosis:'); + console.log(` - SharedWorker support: ${typeof SharedWorker !== 'undefined'}`); + console.log(` - Current origin: ${location.origin}`); + console.log(` - Protocol: ${location.protocol}`); + console.log(` - Is HTTPS: ${location.protocol === 'https:'}`); + console.log(` - Is localhost: ${location.hostname === 'localhost' || location.hostname === '127.0.0.1'}`); + + if (location.protocol === 'file:') { + console.warn('âš ī¸ [SharedWorker Debug] file:// protocol detected - SharedWorkers may not work'); + } + + if (typeof SharedWorker === 'undefined') { + console.error('❌ [SharedWorker Debug] SharedWorker is not supported in this browser'); + } + } +} + +/** + * Enhanced debugging for existing SharedWorker instances + */ +export function debugExistingSharedWorker(worker: SharedWorker | null, tabId: string): void { + console.log('🔍 [SharedWorker Debug] Analyzing existing SharedWorker...'); + console.log(` - Worker instance: ${worker ? 'exists' : 'null'}`); + console.log(` - Tab ID: ${tabId}`); + + if (worker) { + console.log('📤 [SharedWorker Debug] Sending debug ping...'); + worker.port.postMessage({ + type: 'debug_ping', + payload: { + tabId, + timestamp: Date.now(), + action: 'debug_status_check', + }, + }); + } +} + +// Add global debugging functions for easy console access +declare global { + interface Window { + clerkDebugSharedWorker?: () => void; + clerkSharedWorkerDiagnostic?: (scriptUrl?: string) => Promise; + } +} + +if (typeof window !== 'undefined') { + window.clerkDebugSharedWorker = () => SharedWorkerDebugger.quickDiagnosis(); + window.clerkSharedWorkerDiagnostic = async (scriptUrl = '/clerk-shared-worker.js') => { + const sharedWorkerDebugger = new SharedWorkerDebugger(); + return await sharedWorkerDebugger.diagnoseSharedWorker(scriptUrl); + }; +} diff --git a/packages/clerk-js/src/utils/sharedWorkerUtils.ts b/packages/clerk-js/src/utils/sharedWorkerUtils.ts new file mode 100644 index 00000000000..a9400d4ad37 --- /dev/null +++ b/packages/clerk-js/src/utils/sharedWorkerUtils.ts @@ -0,0 +1,72 @@ +/** + * Utilities for working with Clerk SharedWorker + */ + +/** + * SharedWorker configuration options for bundled scripts + */ +export interface SharedWorkerOptions { + /** + * The URL or path to the SharedWorker script. + * Required for bundled SharedWorker mode. + */ + scriptUrl: string; + /** + * Optional name for the SharedWorker. + */ + name?: string; + /** + * Whether to enable SharedWorker functionality. + * @default true + */ + enabled?: boolean; + /** + * Custom options to pass to the SharedWorker constructor. + */ + options?: WorkerOptions; + /** + * Callback function called when the SharedWorker is successfully initialized. + */ + onReady?: (worker: SharedWorker) => void; + /** + * Callback function called when SharedWorker initialization fails. + */ + onError?: (error: Error) => void; + /** + * Whether to automatically start the SharedWorker during Clerk initialization. + * @default true + */ + autoStart?: boolean; +} + +/** + * Gets the path to the bundled Clerk SharedWorker script + * @param baseUrl - The base URL where your clerk-js assets are served from (e.g., '/static/js/' or 'https://cdn.example.com/') + * @returns The full URL to the SharedWorker script + */ +export function getClerkSharedWorkerPath(baseUrl: string = ''): string { + const cleanBaseUrl = baseUrl.replace(/\/$/, ''); + return `${cleanBaseUrl}/clerk-shared-worker.js`; +} + +/** + * Creates configuration for using the bundled SharedWorker script + * @param baseUrl - The base URL where your clerk-js assets are served from + * @returns SharedWorker configuration object + */ +export function createBundledSharedWorkerConfig(baseUrl: string = ''): SharedWorkerOptions { + return { + scriptUrl: getClerkSharedWorkerPath(baseUrl), + enabled: true, + autoStart: true, + }; +} + +/** + * Creates SharedWorker configuration using the bundled script + * @param baseUrl - The base URL where your clerk-js assets are served from + * @returns SharedWorker configuration object + */ +export function createSharedWorkerConfig(baseUrl: string = ''): SharedWorkerOptions { + return createBundledSharedWorkerConfig(baseUrl); +} diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts index 2d118282ae1..bc64294e12d 100644 --- a/packages/types/src/clerk.ts +++ b/packages/types/src/clerk.ts @@ -737,6 +737,37 @@ export interface Clerk { * initiated outside of the Clerk class. */ __internal_setActiveInProgress: boolean; + + /** + * Returns the SharedWorker manager instance if available. + * Allows you to interact directly with the SharedWorker for advanced use cases. + */ + getSharedWorkerManager(): any; // Using any to avoid circular imports + + /** + * Manually initializes the SharedWorker if not already initialized. + * Useful when autoStart is disabled in SharedWorker configuration. + * @returns Promise that resolves to the SharedWorker instance or null if initialization fails + */ + initializeSharedWorker(): Promise; + + /** + * Terminates the SharedWorker if active. + * Use this method to clean up the SharedWorker when it's no longer needed. + */ + terminateSharedWorker(): void; + + /** + * Gets connection information for the current tab's SharedWorker. + * @returns Object with tab ID, instance ID, and active status, or null if no SharedWorker manager is available + */ + getSharedWorkerConnectionInfo(): { tabId: string; instanceId: string; isActive: boolean } | null; + + /** + * Runs debugging diagnostics on the SharedWorker connection. + * Useful for troubleshooting SharedWorker issues in development. + */ + debugSharedWorker(): void; } export type HandleOAuthCallbackParams = TransferableOption & @@ -890,6 +921,10 @@ export type ClerkOptions = PendingSessionOptions & * The full URL or path to the waitlist page. If `undefined`, will redirect to the [Account Portal waitlist page](https://clerk.com/docs/account-portal/overview#waitlist). */ waitlistUrl?: string; + /** + * Configuration options for SharedWorker functionality. Allows you to initialize a SharedWorker when Clerk loads. + */ + sharedWorker?: any; /** * Enable experimental flags to gain access to new features. These flags are not guaranteed to be stable and may change drastically in between patch or minor versions. */ From 0a420260d8fb35f83a64d4dec0ab0bfee6c5e6e4 Mon Sep 17 00:00:00 2001 From: Jacek Date: Tue, 27 May 2025 13:53:47 -0500 Subject: [PATCH 2/2] wip --- packages/clerk-js/src/core/clerk.ts | 113 ++++--- .../clerk-js/src/utils/clerk-shared-worker.js | 317 ++++++------------ packages/clerk-js/src/utils/sharedWorker.ts | 58 +++- 3 files changed, 216 insertions(+), 272 deletions(-) diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index c78774a59b0..cffe6675e10 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -2650,7 +2650,23 @@ export class Clerk implements ClerkInterface { }); eventBus.on(events.TokenUpdate, payload => { + const tokenString = payload.token?.getRawString() || null; + + // Store token in session storage per tab + try { + if (tokenString) { + sessionStorage.setItem('clerk-token', tokenString); + } else { + sessionStorage.removeItem('clerk-token'); + } + } catch (error) { + // Session storage might not be available or quota exceeded + console.warn('Failed to store token in session storage:', error); + } + + // Store token in shared worker state this.#sharedWorkerManager?.postClerkEvent('clerk:token_update', { + token: tokenString, hasToken: !!payload.token, timestamp: Date.now(), }); @@ -2704,80 +2720,69 @@ export class Clerk implements ClerkInterface { } /** - * Gets connection information for the current tab's SharedWorker. - * @public - */ - public getSharedWorkerConnectionInfo(): { tabId: string; instanceId: string; isActive: boolean } | null { - if (this.#sharedWorkerManager) { - return this.#sharedWorkerManager.getConnectionInfo(); - } - return null; - } - - /** - * Runs debugging diagnostics on the SharedWorker connection. - * Useful for troubleshooting SharedWorker issues. + * Retrieves the stored token from session storage for the current tab. + * @returns The stored token or null if not found * @public */ - public debugSharedWorker(): void { - if (this.#sharedWorkerManager) { - this.#sharedWorkerManager.debug(); - } else { - logger.warnOnce('Clerk: No SharedWorker manager available for debugging'); + public getStoredToken(): string | null { + try { + return sessionStorage.getItem('clerk-token'); + } catch (error) { + console.warn('Failed to retrieve token from session storage:', error); + return null; } } /** - * Sends a message to another tab via the SharedWorker. - * The receiving tab will console log the message. - * @param targetTabId - The ID of the tab to send the message to - * @param message - The message to send + * Clears the stored token from session storage for the current tab. * @public */ - public sendTabMessage(targetTabId: string, message: any): void { - if (!this.#sharedWorkerManager) { - logger.warnOnce('Clerk: SharedWorker not initialized. Cannot send tab message.'); - return; - } - - if (!this.#sharedWorkerManager.isActive()) { - logger.warnOnce('Clerk: SharedWorker is not active. Cannot send tab message.'); - return; + public clearStoredToken(): void { + try { + sessionStorage.removeItem('clerk-token'); + } catch (error) { + console.warn('Failed to clear token from session storage:', error); } - - this.#sharedWorkerManager.sendTabMessage(targetTabId, message); } /** - * Gets the current tab ID for this Clerk instance. - * @returns The tab ID string or null if SharedWorker is not initialized + * Retrieves the token from the shared worker state if available. + * This method sends a request to the shared worker to get the current token. + * @returns Promise that resolves to the token or null if not available * @public */ - public getTabId(): string | null { - if (!this.#sharedWorkerManager) { + public async getTokenFromSharedWorker(): Promise { + if (!this.#sharedWorkerManager?.isActive()) { return null; } - return this.#sharedWorkerManager.getTabId(); - } + try { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Timeout waiting for token response')); + }, 5000); + + // Set up one-time listener for the response + const handleMessage = (event: MessageEvent) => { + if (event.data.type === 'clerk_token_response') { + clearTimeout(timeout); + this.#sharedWorkerManager?.getWorker()?.port.removeEventListener('message', handleMessage); + resolve(event.data.payload.token || null); + } + }; - /** - * Requests the status of all connected tabs from the SharedWorker. - * The response will be logged to the console. - * @public - */ - public getConnectedTabs(): void { - if (!this.#sharedWorkerManager) { - logger.warnOnce('Clerk: SharedWorker not initialized. Cannot get connected tabs.'); - return; - } + this.#sharedWorkerManager?.getWorker()?.port.addEventListener('message', handleMessage); - if (!this.#sharedWorkerManager.isActive()) { - logger.warnOnce('Clerk: SharedWorker is not active. Cannot get connected tabs.'); - return; + // Request token from shared worker + this.#sharedWorkerManager?.postMessage({ + type: 'clerk_get_token', + payload: { timestamp: Date.now() }, + }); + }); + } catch (error) { + console.warn('Failed to get token from shared worker:', error); + return null; } - - this.#sharedWorkerManager.getTabStatus(); } assertComponentsReady(controls: unknown): asserts controls is ReturnType { diff --git a/packages/clerk-js/src/utils/clerk-shared-worker.js b/packages/clerk-js/src/utils/clerk-shared-worker.js index 83585bfb91e..e4de1bdb474 100644 --- a/packages/clerk-js/src/utils/clerk-shared-worker.js +++ b/packages/clerk-js/src/utils/clerk-shared-worker.js @@ -1,152 +1,66 @@ -// Safe console wrapper for SharedWorker environment -const safeConsole = { - log: (...args) => { - try { - if (typeof console !== 'undefined' && console.log) { - console.log(...args); - } - } catch (e) { - // Silent fail if console is not available - } - }, - warn: (...args) => { - try { - if (typeof console !== 'undefined' && console.warn) { - console.warn(...args); - } - } catch (e) { - // Silent fail if console is not available - } - }, - error: (...args) => { - try { - if (typeof console !== 'undefined' && console.error) { - console.error(...args); - } - } catch (e) { - // Silent fail if console is not available - } - }, -}; - class ClerkSharedWorkerState { constructor() { + this.activeTabId = null; this.connectedPorts = new Set(); - this.clerkInstances = new Map(); this.tabRegistry = new Map(); - this.lastAuthState = null; - this.lastSessionState = null; - this.activeTabId = null; } - addPort(port, instanceId, tabId = null) { + addPort(port, tabId = null) { this.connectedPorts.add(port); - if (instanceId) { - const instanceData = { + if (tabId) { + this.tabRegistry.set(tabId, { port, - tabId, - connectedAt: Date.now(), lastActivity: Date.now(), + connectedAt: Date.now(), state: null, - }; + }); - this.clerkInstances.set(instanceId, instanceData); + this.log(`Port connected. Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`); - if (tabId) { - this.tabRegistry.set(tabId, { - instanceId, - port, - lastActivity: Date.now(), + if (this.tabRegistry.size > 1) { + this.broadcastToOtherTabs(tabId, { + type: 'clerk_tab_connected', + payload: { + event: 'tab_connected', + newTabId: tabId, + totalTabs: this.tabRegistry.size, + totalPorts: this.connectedPorts.size, + timestamp: Date.now(), + }, }); - safeConsole.log( - `[ClerkSharedWorker] ➕ Added tab ${tabId} to registry. Registry size: ${this.tabRegistry.size}`, - ); - - if (this.tabRegistry.size > 1) { - this.broadcastToOtherTabs(tabId, { - type: 'clerk_tab_connected', - payload: { - event: 'tab_connected', - newTabId: tabId, - newInstanceId: instanceId, - totalTabs: this.tabRegistry.size, - totalPorts: this.connectedPorts.size, - timestamp: Date.now(), - }, - }); - - safeConsole.log( - `[ClerkSharedWorker] đŸ“ĸ Notified ${this.tabRegistry.size - 1} existing tabs about new tab ${tabId}`, - ); - } else { - safeConsole.log(`[ClerkSharedWorker] 🏠 Tab ${tabId} is the first tab in this session`); - } - } else { - safeConsole.warn(`[ClerkSharedWorker] No tabId provided for instance ${instanceId}`); } } - - safeConsole.log( - `[ClerkSharedWorker] Port connected. Instance: ${instanceId}, Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`, - ); - this.logConnectionStatus(); } removePort(port) { this.connectedPorts.delete(port); - for (const [instanceId, data] of this.clerkInstances.entries()) { - if (data.port === port) { - const tabId = data.tabId; - this.clerkInstances.delete(instanceId); - - if (tabId) { - this.tabRegistry.delete(tabId); - - if (this.tabRegistry.size > 0) { - this.broadcastToAllPorts({ - type: 'clerk_tab_disconnected', - payload: { - event: 'tab_disconnected', - disconnectedTabId: tabId, - disconnectedInstanceId: instanceId, - totalTabs: this.tabRegistry.size, - totalPorts: this.connectedPorts.size, - timestamp: Date.now(), - }, - }); - - safeConsole.log( - `[ClerkSharedWorker] đŸ“ĸ Notified ${this.tabRegistry.size} remaining tabs about disconnection of tab ${tabId}`, - ); - } else { - safeConsole.log(`[ClerkSharedWorker] 🏁 Last tab ${tabId} disconnected - no more active tabs`); - } + for (const [tabId, tabData] of this.tabRegistry.entries()) { + if (tabData.port === port) { + this.tabRegistry.delete(tabId); - if (this.activeTabId === tabId) { - safeConsole.log(`[ClerkSharedWorker] 🔄 Active tab ${tabId} disconnected - clearing active state`); - this.activeTabId = null; - } + if (this.tabRegistry.size > 0) { + this.broadcastToAllPorts({ + type: 'clerk_tab_disconnected', + payload: { + event: 'tab_disconnected', + disconnectedTabId: tabId, + totalTabs: this.tabRegistry.size, + totalPorts: this.connectedPorts.size, + timestamp: Date.now(), + }, + }); + } else { + this.log(`🏁 Last tab ${tabId} disconnected - no more active tabs`); + this.activeTabId = null; } - - safeConsole.log( - `[ClerkSharedWorker] ➖ Port disconnected. Instance: ${instanceId}, Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`, - ); - this.logConnectionStatus(); break; } } } - logConnectionStatus() { - const tabs = Array.from(this.tabRegistry.keys()); - safeConsole.log(`[ClerkSharedWorker] Active tabs: ${tabs.length} [${tabs.join(', ')}]`); - } - broadcastToOtherTabs(senderTabId, message) { - let broadcastCount = 0; - for (const [tabId, tabData] of this.tabRegistry.entries()) { if (tabId !== senderTabId) { try { @@ -155,15 +69,11 @@ class ClerkSharedWorkerState { sourceTabId: senderTabId, targetTabId: tabId, }); - broadcastCount++; } catch (error) { - safeConsole.warn(`[ClerkSharedWorker] Failed to send message to tab ${tabId}:`, error); this.removePort(tabData.port); } } } - - safeConsole.log(`[ClerkSharedWorker] Broadcasted message to ${broadcastCount} other tabs`); } broadcastToOtherPorts(senderPort, message) { @@ -184,7 +94,6 @@ class ClerkSharedWorkerState { try { port.postMessage(message); } catch (error) { - safeConsole.warn('[ClerkSharedWorker] Failed to send message to port:', error); this.removePort(port); } } @@ -197,7 +106,6 @@ class ClerkSharedWorkerState { try { port.postMessage(message); } catch (error) { - safeConsole.warn('[ClerkSharedWorker] Failed to send message to port:', error); this.removePort(port); } } @@ -213,7 +121,6 @@ class ClerkSharedWorkerState { }); return true; } catch (error) { - safeConsole.warn(`[ClerkSharedWorker] Failed to send message to tab ${targetTabId}:`, error); this.removePort(tabData.port); return false; } @@ -221,18 +128,43 @@ class ClerkSharedWorkerState { return false; } - handleClerkEvent(port, event, data, instanceId) { - const tabId = data.tabId || null; - safeConsole.log(`[ClerkSharedWorker] Received Clerk event: ${event} from tab ${tabId}`, data); + postLogMessage(level, message, ...args) { + this.broadcastToAllPorts({ + type: 'clerk_log_message', + payload: { + level, + message, + args, + timestamp: Date.now(), + source: 'ClerkSharedWorker', + }, + }); + } - if (instanceId && this.clerkInstances.has(instanceId)) { - const instanceData = this.clerkInstances.get(instanceId); - instanceData.lastActivity = Date.now(); - instanceData.state = data; - } + log(message, ...args) { + this.postLogMessage('log', message, ...args); + } + + warn(message, ...args) { + this.postLogMessage('warn', message, ...args); + } + + error(message, ...args) { + this.postLogMessage('error', message, ...args); + } + + handleClerkEvent(port, event, data) { + const tabId = data.tabId || null; if (tabId && this.tabRegistry.has(tabId)) { - this.tabRegistry.get(tabId).lastActivity = Date.now(); + const tabData = this.tabRegistry.get(tabId); + tabData.lastActivity = Date.now(); + + if (tabData.state) { + tabData.state = { ...tabData.state, ...data }; + } else { + tabData.state = { ...data }; + } } switch (event) { @@ -252,7 +184,7 @@ class ClerkSharedWorkerState { this.handleEnvironmentUpdate(port, data, tabId); break; default: - safeConsole.log(`[ClerkSharedWorker] Unknown event: ${event}`); + break; } } @@ -265,7 +197,6 @@ class ClerkSharedWorkerState { if (stateChanged) { this.lastAuthState = data; - safeConsole.log(`[ClerkSharedWorker] Auth state changed in tab ${sourceTabId}, syncing to other tabs`); this.broadcastToOtherPorts(port, { type: 'clerk_sync_state', @@ -282,8 +213,7 @@ class ClerkSharedWorkerState { handleSignOut(port, data, sourceTabId) { this.lastAuthState = null; this.lastSessionState = null; - - safeConsole.log(`[ClerkSharedWorker] Sign out event from tab ${sourceTabId}, syncing to all other tabs`); + this.lastTokenState = null; this.broadcastToOtherPorts(port, { type: 'clerk_sync_state', @@ -301,7 +231,6 @@ class ClerkSharedWorkerState { if (sessionChanged) { this.lastSessionState = data; - safeConsole.log(`[ClerkSharedWorker] Session updated in tab ${sourceTabId}, syncing to other tabs`); this.broadcastToOtherPorts(port, { type: 'clerk_sync_state', @@ -316,7 +245,12 @@ class ClerkSharedWorkerState { } handleTokenUpdate(port, data, sourceTabId) { - safeConsole.log(`[ClerkSharedWorker] Token updated in tab ${sourceTabId}, syncing to other tabs`); + this.lastTokenState = { + token: data.token, + hasToken: data.hasToken, + timestamp: data.timestamp, + sourceTabId, + }; this.broadcastToOtherPorts(port, { type: 'clerk_sync_state', @@ -330,8 +264,6 @@ class ClerkSharedWorkerState { } handleEnvironmentUpdate(port, data, sourceTabId) { - safeConsole.log(`[ClerkSharedWorker] Environment updated in tab ${sourceTabId}, syncing to other tabs`); - this.broadcastToOtherPorts(port, { type: 'clerk_sync_state', payload: { @@ -346,13 +278,11 @@ class ClerkSharedWorkerState { getTabStatus() { const tabs = []; for (const [tabId, tabData] of this.tabRegistry.entries()) { - const instanceData = this.clerkInstances.get(tabData.instanceId); tabs.push({ tabId, - instanceId: tabData.instanceId, lastActivity: tabData.lastActivity, - connectedAt: instanceData?.connectedAt, - state: instanceData?.state, + connectedAt: tabData.connectedAt, + state: tabData.state, isActive: tabId === this.activeTabId, }); } @@ -368,14 +298,6 @@ class ClerkSharedWorkerState { const previousActiveTab = this.activeTabId; this.activeTabId = tabId; - if (previousActiveTab && previousActiveTab !== tabId) { - safeConsole.log(`[ClerkSharedWorker] ✨ Active tab switched: ${previousActiveTab} → ${tabId}`); - } else if (!previousActiveTab) { - safeConsole.log(`[ClerkSharedWorker] 🎉 Tab ${tabId} is now the first active tab`); - } else { - safeConsole.log(`[ClerkSharedWorker] 🔄 Tab ${tabId} remains active`); - } - this.broadcastToAllPorts({ type: 'clerk_active_tab_changed', payload: { @@ -385,16 +307,17 @@ class ClerkSharedWorkerState { timestamp: Date.now(), }, }); - return true; } - safeConsole.warn(`[ClerkSharedWorker] âš ī¸ Attempted to set unknown tab ${tabId} as active`); return false; } } const clerkState = new ClerkSharedWorkerState(); +// Log that the worker is ready +clerkState.log('[ClerkSharedWorker] SharedWorker script loaded and ready'); + self.addEventListener('connect', event => { const port = event.ports[0]; @@ -403,18 +326,17 @@ self.addEventListener('connect', event => { switch (type) { case 'clerk_init': - safeConsole.log(`[ClerkSharedWorker] Received init message:`, payload); - clerkState.addPort(port, payload.instanceId, payload.tabId); + clerkState.log(`Received init message:`, payload); + clerkState.addPort(port, payload.tabId); const responsePayload = { timestamp: Date.now(), connectedPorts: clerkState.connectedPorts.size, connectedTabs: clerkState.tabRegistry.size, tabId: payload.tabId, - instanceId: payload.instanceId, }; - safeConsole.log(`[ClerkSharedWorker] Sending ready response:`, responsePayload); + clerkState.log(`Sending ready response:`, responsePayload); port.postMessage({ type: 'clerk_worker_ready', @@ -423,7 +345,7 @@ self.addEventListener('connect', event => { break; case 'clerk_event': - clerkState.handleClerkEvent(port, payload.event, payload.data, payload.clerkInstanceId); + clerkState.handleClerkEvent(port, payload.event, payload.data); break; case 'clerk_ping': @@ -431,7 +353,7 @@ self.addEventListener('connect', event => { type: 'clerk_pong', payload: { timestamp: Date.now(), - instances: clerkState.clerkInstances.size, + instances: clerkState.tabRegistry.size, ports: clerkState.connectedPorts.size, tabs: clerkState.tabRegistry.size, activeTabId: clerkState.activeTabId, @@ -442,19 +364,9 @@ self.addEventListener('connect', event => { case 'clerk_tab_focus': const previousActive = clerkState.activeTabId; - safeConsole.log(`[ClerkSharedWorker] đŸŽ¯ Tab ${payload.tabId} gained focus`); - if (previousActive && previousActive !== payload.tabId) { - safeConsole.log(`[ClerkSharedWorker] 📋 Active tab changed: ${previousActive} → ${payload.tabId}`); - } else if (!previousActive) { - safeConsole.log(`[ClerkSharedWorker] 🚀 First tab became active: ${payload.tabId}`); - } clerkState.setActiveTab(payload.tabId); - safeConsole.log( - `[ClerkSharedWorker] 📊 Tab status: ${clerkState.tabRegistry.size} total tabs, ${payload.tabId} is active`, - ); - port.postMessage({ type: 'clerk_tab_focus_response', payload: { @@ -466,14 +378,9 @@ self.addEventListener('connect', event => { break; case 'clerk_tab_blur': - safeConsole.log(`[ClerkSharedWorker] 😴 Tab ${payload.tabId} lost focus`); - if (clerkState.activeTabId === payload.tabId) { - safeConsole.log(`[ClerkSharedWorker] 🔄 Active tab ${payload.tabId} is now inactive - clearing active state`); clerkState.activeTabId = null; - safeConsole.log(`[ClerkSharedWorker] 📊 Tab status: ${clerkState.tabRegistry.size} total tabs, none active`); - clerkState.broadcastToAllPorts({ type: 'clerk_active_tab_changed', payload: { @@ -483,10 +390,6 @@ self.addEventListener('connect', event => { timestamp: Date.now(), }, }); - } else { - safeConsole.log( - `[ClerkSharedWorker] â„šī¸ Tab ${payload.tabId} lost focus, but it wasn't the active tab (active: ${clerkState.activeTabId})`, - ); } port.postMessage({ @@ -501,7 +404,6 @@ self.addEventListener('connect', event => { case 'clerk_get_tab_status': const tabStatusData = clerkState.getTabStatus(); - safeConsole.log(`[ClerkSharedWorker] Sending tab status:`, tabStatusData); port.postMessage({ type: 'clerk_tab_status', payload: { @@ -512,7 +414,6 @@ self.addEventListener('connect', event => { break; case 'debug_test': - safeConsole.log(`[ClerkSharedWorker] Debug test received:`, payload); port.postMessage({ type: 'debug_test_response', payload: { @@ -522,14 +423,13 @@ self.addEventListener('connect', event => { workerStatus: { connectedPorts: clerkState.connectedPorts.size, connectedTabs: clerkState.tabRegistry.size, - instances: clerkState.clerkInstances.size, + instances: clerkState.tabRegistry.size, }, }, }); break; case 'debug_ping': - safeConsole.log(`[ClerkSharedWorker] Debug ping received from tab ${payload.tabId}:`, payload); port.postMessage({ type: 'debug_pong', payload: { @@ -538,35 +438,38 @@ self.addEventListener('connect', event => { workerState: { connectedPorts: clerkState.connectedPorts.size, connectedTabs: clerkState.tabRegistry.size, - instances: clerkState.clerkInstances.size, + instances: clerkState.tabRegistry.size, tabRegistry: Array.from(clerkState.tabRegistry.keys()), activeTabId: clerkState.activeTabId, lastAuthState: clerkState.lastAuthState ? 'present' : 'null', + lastTokenState: clerkState.lastTokenState ? 'present' : 'null', }, }, }); break; case 'clerk_heartbeat': - const { tabId: heartbeatTabId, instanceId: heartbeatInstanceId } = payload; - - if (heartbeatInstanceId && clerkState.clerkInstances.has(heartbeatInstanceId)) { - clerkState.clerkInstances.get(heartbeatInstanceId).lastActivity = Date.now(); - } + const { tabId: heartbeatTabId } = payload; if (heartbeatTabId && clerkState.tabRegistry.has(heartbeatTabId)) { clerkState.tabRegistry.get(heartbeatTabId).lastActivity = Date.now(); } + break; - safeConsole.log(`[ClerkSharedWorker] Heartbeat received from tab ${heartbeatTabId}`); + case 'clerk_get_token': + port.postMessage({ + type: 'clerk_token_response', + payload: { + token: clerkState.lastTokenState?.token || null, + hasToken: clerkState.lastTokenState?.hasToken || false, + timestamp: Date.now(), + tokenTimestamp: clerkState.lastTokenState?.timestamp || null, + sourceTabId: clerkState.lastTokenState?.sourceTabId || null, + }, + }); break; case 'send_tab_message': - safeConsole.log( - `[ClerkSharedWorker] Message forwarding request from tab ${payload.sourceTabId} to tab ${payload.targetTabId}:`, - payload.message, - ); - let sourceTabId = payload.sourceTabId; if (!sourceTabId) { for (const [tabId, tabData] of clerkState.tabRegistry.entries()) { @@ -598,24 +501,15 @@ self.addEventListener('connect', event => { }, }); - if (messageSent) { - safeConsole.log( - `[ClerkSharedWorker] Successfully forwarded message from tab ${sourceTabId} to tab ${payload.targetTabId}`, - ); - } else { - safeConsole.warn( - `[ClerkSharedWorker] Failed to forward message from tab ${sourceTabId} to tab ${payload.targetTabId} - target tab not found`, - ); - } break; default: - safeConsole.warn(`[ClerkSharedWorker] Unknown message type: ${type}`); + break; } }; port.onmessageerror = error => { - safeConsole.error('[ClerkSharedWorker] Message error:', error); + debugger; }; port.addEventListener('close', () => { @@ -646,6 +540,3 @@ self.addEventListener('connect', event => { // } // } // }, 30000); - -safeConsole.log('[ClerkSharedWorker] SharedWorker script loaded and ready for tab coordination'); - diff --git a/packages/clerk-js/src/utils/sharedWorker.ts b/packages/clerk-js/src/utils/sharedWorker.ts index 29335273356..32cba84e27c 100644 --- a/packages/clerk-js/src/utils/sharedWorker.ts +++ b/packages/clerk-js/src/utils/sharedWorker.ts @@ -115,13 +115,12 @@ export class ClerkSharedWorkerManager { const message = event.data; if (message.type === 'clerk_worker_ready') { - const { connectedTabs, connectedPorts, tabId, instanceId } = message.payload || {}; + const { connectedTabs, connectedPorts, tabId } = message.payload || {}; logger.logOnce( `Clerk: SharedWorker is ready for tab ${this.tabId}. ` + `Connected tabs: ${connectedTabs ?? 'unknown'}, ` + `Connected ports: ${connectedPorts ?? 'unknown'}, ` + - `Worker tab ID: ${tabId}, ` + - `Worker instance ID: ${instanceId}`, + `Worker tab ID: ${tabId}, `, ); } else if (message.type === 'clerk_sync_state') { const { sourceTabId, event: syncEvent, data } = message.payload; @@ -129,9 +128,18 @@ export class ClerkSharedWorkerManager { this.handleSyncEvent(syncEvent, data, sourceTabId); } else if (message.type === 'clerk_pong') { - const { tabs, instances, tabStatus } = message.payload || {}; + const { tabs, instances, tabStatus, sessions, hasValidSession, mostRecentSession } = message.payload || {}; logger.logOnce(`Clerk: Ping response - Tabs: ${tabs ?? 'unknown'}, Instances: ${instances ?? 'unknown'}`); - if (tabStatus && tabStatus.length > 0) { + if (tabStatus && tabStatus.tabs && tabStatus.tabs.length > 0) { + console.log(`🔍 [Clerk] Full Worker State:`); + console.log(` 📊 Summary: ${tabStatus.totalTabs} tabs, Active: ${tabStatus.activeTabId || 'none'}`); + console.log(` 📋 All Tabs:`, tabStatus.tabs); + console.log(` đŸĒ Sessions:`, sessions || tabStatus.sessions); + console.log(` ✅ Has Valid Session: ${hasValidSession ?? tabStatus.hasValidSession}`); + if (mostRecentSession || tabStatus.mostRecentSession) { + console.log(` 🕐 Most Recent Session:`, mostRecentSession || tabStatus.mostRecentSession); + } + } else if (tabStatus && tabStatus.length > 0) { logger.logOnce(`Clerk: Active tabs: ${JSON.stringify(tabStatus, null, 2)}`); } } else if (message.type === 'clerk_tab_status') { @@ -147,6 +155,7 @@ export class ClerkSharedWorkerManager { `Total tabs: ${totalTabs}, ` + `Total ports: ${totalPorts}`, ); + this.requestAndLogWorkerState('tab_connected'); } else if (message.type === 'clerk_tab_disconnected') { const { disconnectedTabId, disconnectedInstanceId, totalTabs, totalPorts } = message.payload || {}; console.log( @@ -156,6 +165,7 @@ export class ClerkSharedWorkerManager { `Remaining tabs: ${totalTabs}, ` + `Remaining ports: ${totalPorts}`, ); + this.requestAndLogWorkerState('tab_disconnected'); } else if (message.type === 'debug_pong') { const { workerState, receivedPayload } = message.payload || {}; console.log('🔍 [Clerk Debug] Debug pong received:', { @@ -184,6 +194,24 @@ export class ClerkSharedWorkerManager { } else { console.log(`🔄 [Clerk] No active tab (previous: ${previousActiveTabId})`); } + // Auto-request and log worker state when active tab changes + this.requestAndLogWorkerState('active_tab_changed'); + } else if (message.type === 'clerk_log_message') { + const { level, message: logMessage, args, source } = message.payload || {}; + const logArgs = [`[${source}]`, logMessage, ...(args || [])]; + + switch (level) { + case 'warn': + console.warn(...logArgs); + break; + case 'error': + console.error(...logArgs); + break; + case 'log': + default: + console.log(...logArgs); + break; + } } } catch (error) { logger.warnOnce(`Clerk: Error handling SharedWorker message: ${error}`); @@ -197,15 +225,19 @@ export class ClerkSharedWorkerManager { switch (event) { case 'state_change': logger.logOnce(`Clerk: Auth state synchronized from tab ${sourceTabId}`); + this.requestAndLogWorkerState('auth_state_changed'); break; case 'sign_out': logger.logOnce(`Clerk: Sign out synchronized from tab ${sourceTabId}`); + this.requestAndLogWorkerState('user_signed_out'); break; case 'session_update': logger.logOnce(`Clerk: Session update synchronized from tab ${sourceTabId}`); + this.requestAndLogWorkerState('session_updated'); break; case 'token_update': logger.logOnce(`Clerk: Token update synchronized from tab ${sourceTabId}`); + this.requestAndLogWorkerState('token_updated'); break; case 'environment_update': logger.logOnce(`Clerk: Environment update synchronized from tab ${sourceTabId}`); @@ -420,6 +452,22 @@ export class ClerkSharedWorkerManager { }); } + /** + * Automatically requests and logs worker state for debugging purposes. + */ + private requestAndLogWorkerState(event: string): void { + console.log(`🔍 [Clerk] Auto-requesting worker state due to: ${event}`); + this.postMessage({ + type: 'clerk_ping', + payload: { + tabId: this.tabId, + timestamp: Date.now(), + autoLog: true, + triggerEvent: event, + }, + }); + } + /** * Sends a message to another tab via the SharedWorker. */