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..cffe6675e10 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,200 @@ 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 => { + 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(), + }); + }); + + 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'); + } + } + + /** + * Retrieves the stored token from session storage for the current tab. + * @returns The stored token or null if not found + * @public + */ + public getStoredToken(): string | null { + try { + return sessionStorage.getItem('clerk-token'); + } catch (error) { + console.warn('Failed to retrieve token from session storage:', error); + return null; + } + } + + /** + * Clears the stored token from session storage for the current tab. + * @public + */ + public clearStoredToken(): void { + try { + sessionStorage.removeItem('clerk-token'); + } catch (error) { + console.warn('Failed to clear token from session storage:', error); + } + } + + /** + * 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 async getTokenFromSharedWorker(): Promise { + if (!this.#sharedWorkerManager?.isActive()) { + return null; + } + + 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); + } + }; + + this.#sharedWorkerManager?.getWorker()?.port.addEventListener('message', handleMessage); + + // 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; + } + } + assertComponentsReady(controls: unknown): asserts controls is ReturnType { if (!Clerk.mountComponentRenderer) { throw new Error('ClerkJS was loaded without UI components.'); @@ -2655,4 +2858,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..e4de1bdb474 --- /dev/null +++ b/packages/clerk-js/src/utils/clerk-shared-worker.js @@ -0,0 +1,542 @@ +class ClerkSharedWorkerState { + constructor() { + this.activeTabId = null; + this.connectedPorts = new Set(); + this.tabRegistry = new Map(); + } + + addPort(port, tabId = null) { + this.connectedPorts.add(port); + + if (tabId) { + this.tabRegistry.set(tabId, { + port, + lastActivity: Date.now(), + connectedAt: Date.now(), + state: null, + }); + + this.log(`Port connected. Tab: ${tabId}. Total ports: ${this.connectedPorts.size}`); + + 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(), + }, + }); + } + } + } + + removePort(port) { + this.connectedPorts.delete(port); + + for (const [tabId, tabData] of this.tabRegistry.entries()) { + if (tabData.port === port) { + this.tabRegistry.delete(tabId); + + 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; + } + break; + } + } + } + + broadcastToOtherTabs(senderTabId, message) { + for (const [tabId, tabData] of this.tabRegistry.entries()) { + if (tabId !== senderTabId) { + try { + tabData.port.postMessage({ + ...message, + sourceTabId: senderTabId, + targetTabId: tabId, + }); + } catch (error) { + this.removePort(tabData.port); + } + } + } + } + + 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) { + this.removePort(port); + } + } + } + } + } + + broadcastToAllPorts(message) { + for (const port of this.connectedPorts) { + try { + port.postMessage(message); + } catch (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) { + this.removePort(tabData.port); + return false; + } + } + return false; + } + + postLogMessage(level, message, ...args) { + this.broadcastToAllPorts({ + type: 'clerk_log_message', + payload: { + level, + message, + args, + timestamp: Date.now(), + source: 'ClerkSharedWorker', + }, + }); + } + + 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)) { + const tabData = this.tabRegistry.get(tabId); + tabData.lastActivity = Date.now(); + + if (tabData.state) { + tabData.state = { ...tabData.state, ...data }; + } else { + tabData.state = { ...data }; + } + } + + 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: + break; + } + } + + 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; + + 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; + this.lastTokenState = null; + + 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; + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'session_update', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + } + + handleTokenUpdate(port, data, sourceTabId) { + this.lastTokenState = { + token: data.token, + hasToken: data.hasToken, + timestamp: data.timestamp, + sourceTabId, + }; + + this.broadcastToOtherPorts(port, { + type: 'clerk_sync_state', + payload: { + event: 'token_update', + data, + timestamp: Date.now(), + sourceTabId, + }, + }); + } + + handleEnvironmentUpdate(port, data, sourceTabId) { + 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()) { + tabs.push({ + tabId, + lastActivity: tabData.lastActivity, + connectedAt: tabData.connectedAt, + state: tabData.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; + + this.broadcastToAllPorts({ + type: 'clerk_active_tab_changed', + payload: { + event: 'active_tab_changed', + activeTabId: tabId, + previousActiveTabId: previousActiveTab, + timestamp: Date.now(), + }, + }); + return true; + } + 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]; + + port.onmessage = messageEvent => { + const { type, payload } = messageEvent.data; + + switch (type) { + case 'clerk_init': + 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, + }; + + clerkState.log(`Sending ready response:`, responsePayload); + + port.postMessage({ + type: 'clerk_worker_ready', + payload: responsePayload, + }); + break; + + case 'clerk_event': + clerkState.handleClerkEvent(port, payload.event, payload.data); + break; + + case 'clerk_ping': + port.postMessage({ + type: 'clerk_pong', + payload: { + timestamp: Date.now(), + instances: clerkState.tabRegistry.size, + ports: clerkState.connectedPorts.size, + tabs: clerkState.tabRegistry.size, + activeTabId: clerkState.activeTabId, + tabStatus: clerkState.getTabStatus(), + }, + }); + break; + + case 'clerk_tab_focus': + const previousActive = clerkState.activeTabId; + + clerkState.setActiveTab(payload.tabId); + + port.postMessage({ + type: 'clerk_tab_focus_response', + payload: { + success: true, + activeTabId: payload.tabId, + timestamp: Date.now(), + }, + }); + break; + + case 'clerk_tab_blur': + if (clerkState.activeTabId === payload.tabId) { + clerkState.activeTabId = null; + + clerkState.broadcastToAllPorts({ + type: 'clerk_active_tab_changed', + payload: { + event: 'active_tab_changed', + activeTabId: null, + previousActiveTabId: payload.tabId, + timestamp: Date.now(), + }, + }); + } + + 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(); + port.postMessage({ + type: 'clerk_tab_status', + payload: { + timestamp: Date.now(), + ...tabStatusData, + }, + }); + break; + + case 'debug_test': + 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.tabRegistry.size, + }, + }, + }); + break; + + case 'debug_ping': + port.postMessage({ + type: 'debug_pong', + payload: { + timestamp: Date.now(), + receivedPayload: payload, + workerState: { + connectedPorts: clerkState.connectedPorts.size, + connectedTabs: clerkState.tabRegistry.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 } = payload; + + if (heartbeatTabId && clerkState.tabRegistry.has(heartbeatTabId)) { + clerkState.tabRegistry.get(heartbeatTabId).lastActivity = Date.now(); + } + break; + + 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': + 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(), + }, + }); + + break; + + default: + break; + } + }; + + port.onmessageerror = error => { + debugger; + }; + + 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); 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..32cba84e27c --- /dev/null +++ b/packages/clerk-js/src/utils/sharedWorker.ts @@ -0,0 +1,624 @@ +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 } = message.payload || {}; + logger.logOnce( + `Clerk: SharedWorker is ready for tab ${this.tabId}. ` + + `Connected tabs: ${connectedTabs ?? 'unknown'}, ` + + `Connected ports: ${connectedPorts ?? 'unknown'}, ` + + `Worker tab ID: ${tabId}, `, + ); + } 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, sessions, hasValidSession, mostRecentSession } = message.payload || {}; + logger.logOnce(`Clerk: Ping response - Tabs: ${tabs ?? 'unknown'}, Instances: ${instances ?? 'unknown'}`); + 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') { + 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}`, + ); + this.requestAndLogWorkerState('tab_connected'); + } 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}`, + ); + this.requestAndLogWorkerState('tab_disconnected'); + } 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})`); + } + // 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}`); + } + }; + + 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}`); + 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}`); + 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(), + }, + }); + } + + /** + * 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. + */ + 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. */