From e7594189a48c5efcdf0cb462760062d27c679e50 Mon Sep 17 00:00:00 2001 From: Jacek Date: Thu, 20 Nov 2025 22:36:57 -0600 Subject: [PATCH] poc: experimental clerk-js build --- .changeset/salty-ravens-build.md | 6 + packages/clerk-js/bundlewatch.config.json | 1 + packages/clerk-js/package.json | 1 + packages/clerk-js/rspack.config.js | 91 ++- .../src/core/auth/AuthCookieService.ts | 98 ++- .../src/core/auth/CrossTabSessionSync.ts | 589 ++++++++++++++++++ .../core/auth/SessionRefreshCoordinator.ts | 257 ++++++++ .../SessionRefreshCoordinator.test.ts | 126 ++++ .../src/core/auth/crossTabSync.worker.ts | 174 ++++++ packages/clerk-js/src/core/auth/safeLock.ts | 2 + packages/clerk-js/src/global.d.ts | 6 + 11 files changed, 1316 insertions(+), 35 deletions(-) create mode 100644 .changeset/salty-ravens-build.md create mode 100644 packages/clerk-js/src/core/auth/CrossTabSessionSync.ts create mode 100644 packages/clerk-js/src/core/auth/SessionRefreshCoordinator.ts create mode 100644 packages/clerk-js/src/core/auth/__tests__/SessionRefreshCoordinator.test.ts create mode 100644 packages/clerk-js/src/core/auth/crossTabSync.worker.ts diff --git a/.changeset/salty-ravens-build.md b/.changeset/salty-ravens-build.md new file mode 100644 index 00000000000..47c982d1a92 --- /dev/null +++ b/.changeset/salty-ravens-build.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +--- + +Add the experimental cross-tab session sync proof of concept behind a dev-only flag, including worker lifecycle safeguards, automatic fallbacks, and metric hooks for observability. + diff --git a/packages/clerk-js/bundlewatch.config.json b/packages/clerk-js/bundlewatch.config.json index 4b3ceae4b8c..69116865cc5 100644 --- a/packages/clerk-js/bundlewatch.config.json +++ b/packages/clerk-js/bundlewatch.config.json @@ -2,6 +2,7 @@ "files": [ { "path": "./dist/clerk.js", "maxSize": "840KB" }, { "path": "./dist/clerk.browser.js", "maxSize": "81KB" }, + { "path": "./dist/clerk.experimental.browser.js", "maxSize": "95KB" }, { "path": "./dist/clerk.channel.browser.js", "maxSize": "81KB" }, { "path": "./dist/clerk.legacy.browser.js", "maxSize": "123KB" }, { "path": "./dist/clerk.headless*.js", "maxSize": "65KB" }, diff --git a/packages/clerk-js/package.json b/packages/clerk-js/package.json index 2a6307712ba..810d28ce34d 100644 --- a/packages/clerk-js/package.json +++ b/packages/clerk-js/package.json @@ -44,6 +44,7 @@ "dev": "rspack serve --config rspack.config.js", "dev:channel": "rspack serve --config rspack.config.js --env variant=\"clerk.channel.browser\"", "dev:chips": "rspack serve --config rspack.config.js --env variant=\"clerk.chips.browser\"", + "dev:experimental": "rspack serve --config rspack.config.js --env variant=\"clerk.experimental.browser\"", "dev:headless": "rspack serve --config rspack.config.js --env variant=\"clerk.headless.browser\"", "dev:origin": "rspack serve --config rspack.config.js --env devOrigin=http://localhost:${PORT:-4000}", "dev:sandbox": "rspack serve --config rspack.config.js --env devOrigin=http://localhost:${PORT:-4000} --env sandbox=1", diff --git a/packages/clerk-js/rspack.config.js b/packages/clerk-js/rspack.config.js index 18e842bb3d5..7e26f127b19 100644 --- a/packages/clerk-js/rspack.config.js +++ b/packages/clerk-js/rspack.config.js @@ -11,24 +11,28 @@ const isDevelopment = mode => !isProduction(mode); const variants = { clerk: 'clerk', - clerkNoRHC: 'clerk.no-rhc', // Omit Remotely Hosted Code clerkBrowser: 'clerk.browser', + clerkBrowserNoRHC: 'clerk.browser.no-rhc', + clerkCHIPS: 'clerk.chips.browser', + clerkChannelBrowser: 'clerk.channel.browser', + clerkExperimentalBrowser: 'clerk.experimental.browser', clerkHeadless: 'clerk.headless', clerkHeadlessBrowser: 'clerk.headless.browser', clerkLegacyBrowser: 'clerk.legacy.browser', - clerkCHIPS: 'clerk.chips.browser', - clerkChannelBrowser: 'clerk.channel.browser', + clerkNoRHC: 'clerk.no-rhc', // Omit Remotely Hosted Code }; const variantToSourceFile = { [variants.clerk]: './src/index.ts', - [variants.clerkNoRHC]: './src/index.ts', [variants.clerkBrowser]: './src/index.browser.ts', + [variants.clerkBrowserNoRHC]: './src/index.browser.ts', + [variants.clerkCHIPS]: './src/index.browser.ts', + [variants.clerkChannelBrowser]: './src/index.browser.ts', + [variants.clerkExperimentalBrowser]: './src/index.browser.ts', [variants.clerkHeadless]: './src/index.headless.ts', [variants.clerkHeadlessBrowser]: './src/index.headless.browser.ts', [variants.clerkLegacyBrowser]: './src/index.legacy.browser.ts', - [variants.clerkCHIPS]: './src/index.browser.ts', - [variants.clerkChannelBrowser]: './src/index.browser.ts', + [variants.clerkNoRHC]: './src/index.ts', }; /** @@ -52,16 +56,17 @@ const common = ({ mode, variant, disableRHC = false }) => { }, plugins: [ new rspack.DefinePlugin({ - __DEV__: isDevelopment(mode), - __PKG_VERSION__: JSON.stringify(packageJSON.version), - __PKG_NAME__: JSON.stringify(packageJSON.name), + __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), /** * Build time feature flags. */ __BUILD_FLAG_KEYLESS_UI__: isDevelopment(mode), - __BUILD_DISABLE_RHC__: JSON.stringify(disableRHC), __BUILD_VARIANT_CHANNEL__: variant === variants.clerkChannelBrowser, + __BUILD_VARIANT_EXPERIMENTAL__: variant === variants.clerkExperimentalBrowser, __BUILD_VARIANT_CHIPS__: variant === variants.clerkCHIPS, + __DEV__: isDevelopment(mode), + __PKG_NAME__: JSON.stringify(packageJSON.name), + __PKG_VERSION__: JSON.stringify(packageJSON.version), }), new rspack.EnvironmentPlugin({ CLERK_ENV: mode, @@ -179,6 +184,29 @@ const svgLoader = () => { }; }; +/** @type { () => (import('@rspack/core').RuleSetRule) } */ +const workerLoader = () => { + return { + test: /\.worker\.ts$/, + type: 'asset/source', + use: { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + }, + minify: { + compress: true, + mangle: true, + }, + }, + minify: true, + }, + }, + }; +}; + /** @type { (opts?: { targets?: string, useCoreJs?: boolean }) => (import('@rspack/core').RuleSetRule[]) } */ const typescriptLoaderProd = ( { targets = packageJSON.browserslist, useCoreJs = false } = { targets: packageJSON.browserslist, useCoreJs: false }, @@ -186,7 +214,7 @@ const typescriptLoaderProd = ( return [ { test: /\.(jsx?|tsx?)$/, - exclude: /node_modules/, + exclude: [/node_modules/, /\.worker\.ts$/], use: { loader: 'builtin:swc-loader', options: { @@ -244,7 +272,7 @@ const typescriptLoaderDev = () => { return [ { test: /\.(jsx?|tsx?)$/, - exclude: /node_modules/, + exclude: [/node_modules/, /\.worker\.ts$/], loader: 'builtin:swc-loader', options: { jsc: { @@ -277,7 +305,7 @@ const commonForProdChunked = ( ) => { return { module: { - rules: [svgLoader(), ...typescriptLoaderProd({ targets, useCoreJs })], + rules: [workerLoader(), svgLoader(), ...typescriptLoaderProd({ targets, useCoreJs })], }, }; }; @@ -291,7 +319,7 @@ const commonForProdBundled = ( ) => { return { module: { - rules: [svgLoader(), ...typescriptLoaderProd({ targets, useCoreJs })], + rules: [workerLoader(), svgLoader(), ...typescriptLoaderProd({ targets, useCoreJs })], }, }; }; @@ -392,6 +420,13 @@ const prodConfig = ({ mode, env, analysis }) => { commonForProdChunked(), ); + const clerkExperimentalBrowser = merge( + entryForVariant(variants.clerkExperimentalBrowser), + common({ mode, variant: variants.clerkExperimentalBrowser }), + commonForProd(), + commonForProdChunked(), + ); + const clerkLegacyBrowser = merge( entryForVariant(variants.clerkLegacyBrowser), common({ mode, variant: variants.clerkLegacyBrowser }), @@ -550,6 +585,7 @@ const prodConfig = ({ mode, env, analysis }) => { return [ clerkBrowser, + clerkExperimentalBrowser, clerkLegacyBrowser, clerkHeadless, clerkHeadlessBrowser, @@ -581,7 +617,7 @@ const devConfig = ({ mode, env }) => { const commonForDev = () => { return { module: { - rules: [svgLoader(), ...typescriptLoaderDev()], + rules: [workerLoader(), svgLoader(), ...typescriptLoaderDev()], }, plugins: [ new ReactRefreshPlugin(/** @type {any} **/ ({ overlay: { sockHost: devUrl.host } })), @@ -645,6 +681,21 @@ const devConfig = ({ mode, env }) => { common({ mode, disableRHC: true, variant: variants.clerkBrowserNoRHC }), commonForDev(), ), + [variants.clerkCHIPS]: merge( + entryForVariant(variants.clerkCHIPS), + common({ mode, variant: variants.clerkCHIPS }), + commonForDev(), + ), + [variants.clerkChannelBrowser]: merge( + entryForVariant(variants.clerkChannelBrowser), + common({ mode, variant: variants.clerkChannelBrowser }), + commonForDev(), + ), + [variants.clerkExperimentalBrowser]: merge( + entryForVariant(variants.clerkExperimentalBrowser), + common({ mode, variant: variants.clerkExperimentalBrowser }), + commonForDev(), + ), [variants.clerkHeadless]: merge( entryForVariant(variants.clerkHeadless), common({ mode, variant: variants.clerkHeadless }), @@ -657,16 +708,6 @@ const devConfig = ({ mode, env }) => { commonForDev(), // externalsForHeadless(), ), - [variants.clerkCHIPS]: merge( - entryForVariant(variants.clerkCHIPS), - common({ mode, variant: variants.clerkCHIPS }), - commonForDev(), - ), - [variants.clerkChannelBrowser]: merge( - entryForVariant(variants.clerkChannelBrowser), - common({ mode, variant: variants.clerkChannelBrowser }), - commonForDev(), - ), }; if (!entryToConfigMap[variant]) { diff --git a/packages/clerk-js/src/core/auth/AuthCookieService.ts b/packages/clerk-js/src/core/auth/AuthCookieService.ts index 51268dc6bcd..babe3a4abf7 100644 --- a/packages/clerk-js/src/core/auth/AuthCookieService.ts +++ b/packages/clerk-js/src/core/auth/AuthCookieService.ts @@ -20,7 +20,37 @@ import { createSessionCookie } from './cookies/session'; import { getCookieSuffix } from './cookieSuffix'; import type { DevBrowser } from './devBrowser'; import { createDevBrowser } from './devBrowser'; -import { SessionCookiePoller } from './SessionCookiePoller'; +import { SessionRefreshCoordinator } from './SessionRefreshCoordinator'; + +type CrossTabSyncOverride = 'disabled' | 'enabled'; + +const CROSS_TAB_SYNC_STORAGE_KEY = 'clerk:crossTabSessionSync'; + +const readCrossTabSyncOverride = (): CrossTabSyncOverride | null => { + if (typeof window === 'undefined') { + return null; + } + + const override = (window as Window & { __clerkCrossTabSync?: CrossTabSyncOverride }).__clerkCrossTabSync; + if (override === 'enabled' || override === 'disabled') { + return override; + } + + try { + const storedValue = window.localStorage?.getItem(CROSS_TAB_SYNC_STORAGE_KEY); + if (storedValue === 'enabled' || storedValue === 'disabled') { + return storedValue; + } + } catch (error) { + debugLogger.warn( + 'AuthCookieService: Unable to read cross-tab sync preference from storage', + { error }, + 'authCookieService', + ); + } + + return null; +}; // TODO(@dimkl): make AuthCookieService singleton since it handles updating cookies using a poller // and we need to avoid updating them concurrently. @@ -41,11 +71,12 @@ import { SessionCookiePoller } from './SessionCookiePoller'; * - handleUnauthenticatedDevBrowser(): resets dev browser in case of invalid dev browser */ export class AuthCookieService { - private poller: SessionCookiePoller | null = null; - private clientUat: ClientUatCookieHandler; - private sessionCookie: SessionCookieHandler; private activeCookie: ReturnType; + private clientUat: ClientUatCookieHandler; + private crossTabSyncEnabled: boolean | null = null; private devBrowser: DevBrowser; + private refreshCoordinator: SessionRefreshCoordinator | null = null; + private sessionCookie: SessionCookieHandler; public static async create( clerk: Clerk, @@ -125,16 +156,18 @@ export class AuthCookieService { } public startPollingForToken() { - if (!this.poller) { - this.poller = new SessionCookiePoller(); - this.poller.startPollingForSessionToken(() => this.refreshSessionToken()); + if (!this.refreshCoordinator) { + this.refreshCoordinator = new SessionRefreshCoordinator(); + this.refreshCoordinator.startPollingForSessionToken(() => this.refreshSessionToken(), { + enableEventDrivenSync: this.shouldUseCrossTabSessionSync(), + }); } } public stopPollingForToken() { - if (this.poller) { - this.poller.stopPollingForSessionToken(); - this.poller = null; + if (this.refreshCoordinator) { + this.refreshCoordinator.stopPollingForSessionToken(); + this.refreshCoordinator = null; } } @@ -166,6 +199,13 @@ export class AuthCookieService { if (updateCookieImmediately) { this.updateSessionCookie(token); } + + if (this.refreshCoordinator?.isEventDriven()) { + const expiresAt = this.clerk.session.expireAt?.getTime() || null; + if (expiresAt) { + this.refreshCoordinator.notifyRefreshComplete(expiresAt); + } + } } catch (e) { return this.handleGetTokenError(e); } @@ -183,6 +223,13 @@ export class AuthCookieService { this.setActiveContextInStorage(); + if (this.refreshCoordinator?.isEventDriven()) { + const sessionId = this.clerk.session?.id || null; + const organizationId = this.clerk.organization?.id || null; + const expiresAt = this.clerk.session?.expireAt?.getTime() || null; + this.refreshCoordinator.updateSession({ expiresAt, organizationId, sessionId }); + } + return token ? this.sessionCookie.set(token) : this.sessionCookie.remove(); } @@ -222,6 +269,37 @@ export class AuthCookieService { this.activeCookie.remove(); this.sessionCookie.remove(); this.setClientUatCookieForDevelopmentInstances(); + + if (this.refreshCoordinator?.isEventDriven()) { + this.refreshCoordinator.clearSession(); + } + } + + private determineCrossTabSessionSyncPreference(): boolean { + if (typeof window === 'undefined') { + return false; + } + + const override = readCrossTabSyncOverride(); + if (override === 'enabled') { + debugLogger.info('AuthCookieService: Cross-tab sync enabled via override', {}, 'authCookieService'); + return true; + } + + if (override === 'disabled') { + debugLogger.info('AuthCookieService: Cross-tab sync disabled via override', {}, 'authCookieService'); + return false; + } + + return __BUILD_VARIANT_EXPERIMENTAL__; + } + + private shouldUseCrossTabSessionSync(): boolean { + if (this.crossTabSyncEnabled === null) { + this.crossTabSyncEnabled = this.determineCrossTabSessionSyncPreference(); + } + + return this.crossTabSyncEnabled; } /** diff --git a/packages/clerk-js/src/core/auth/CrossTabSessionSync.ts b/packages/clerk-js/src/core/auth/CrossTabSessionSync.ts new file mode 100644 index 00000000000..ce6b80ce8b0 --- /dev/null +++ b/packages/clerk-js/src/core/auth/CrossTabSessionSync.ts @@ -0,0 +1,589 @@ +import { noop } from '@clerk/shared/utils'; + +import { debugLogger } from '@/utils/debug'; + +// @ts-ignore +// eslint-disable-next-line import/default +import crossTabSyncWorkerSource from './crossTabSync.worker'; + +const LEADER_HEARTBEAT_INTERVAL = 5_000; +const LEADER_TIMEOUT = 10_000; +const TAB_ID = `tab_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`; + +type CrossTabSessionSyncFailure = 'broadcast_unavailable' | 'worker_error'; + +interface CrossTabMessage { + data?: unknown; + tabId: string; + timestamp: number; + type: string; +} + +interface SessionSyncMessage extends CrossTabMessage { + data: { + expiresAt: number | null; + organizationId: string | null; + sessionId: string | null; + }; + type: 'session:sync'; +} + +interface LeaderElectMessage extends CrossTabMessage { + type: 'leader:elect'; +} + +interface LeaderHeartbeatMessage extends CrossTabMessage { + type: 'leader:heartbeat'; +} + +interface LeaderResignMessage extends CrossTabMessage { + type: 'leader:resign'; +} + +interface RefreshRequestMessage extends CrossTabMessage { + type: 'session:refresh-needed'; +} + +interface SignoutMessage extends CrossTabMessage { + type: 'signout'; +} + +type BroadcastMessage = + | LeaderElectMessage + | LeaderHeartbeatMessage + | LeaderResignMessage + | RefreshRequestMessage + | SessionSyncMessage + | SignoutMessage; + +interface WorkerIncomingMessage { + data?: unknown; + type: string; +} + +interface SessionUpdateMessage extends WorkerIncomingMessage { + data: { + expiresAt: number | null; + organizationId: string | null; + sessionId: string | null; + }; + type: 'session:update'; +} + +interface RefreshCompleteMessage extends WorkerIncomingMessage { + data: { + expiresAt: number; + timestamp: number; + }; + type: 'session:refresh-complete'; +} + +export interface CrossTabSessionSyncOptions { + onFatalError?: (reason: CrossTabSessionSyncFailure) => void; + onRefreshNeeded?: () => Promise; + onSessionSync?: (sessionId: string | null, organizationId: string | null) => void; + onSignout?: () => void; +} + +/** + * CrossTabSessionSync manages session state synchronization across browser tabs + * without polling. Uses Worker + BroadcastChannel for event-driven updates. + * + * Features: + * - Leader election ensures only one tab manages backend sync + * - Automatic token refresh coordination + * - Graceful fallback when Worker/BroadcastChannel unavailable + * - Zero-config drop-in replacement for SessionCookiePoller + */ +export class CrossTabSessionSync { + private broadcastChannel: BroadcastChannel | null = null; + private heartbeatTimerId: ReturnType | null = null; + private isLeader = false; + private lastLeaderHeartbeat = Date.now(); + private leaderTimeoutId: ReturnType | null = null; + private options: Required; + private started = false; + private tabId = TAB_ID; + private visibilityChangeHandler: (() => void) | null = null; + private worker: Worker | null = null; + private workerScriptUrl: string | null = null; + + constructor(options: CrossTabSessionSyncOptions = {}) { + this.options = { + onFatalError: options.onFatalError || noop, + onRefreshNeeded: options.onRefreshNeeded || (() => Promise.resolve()), + onSessionSync: options.onSessionSync || noop, + onSignout: options.onSignout || noop, + }; + } + + start(): boolean { + if (this.started) { + return true; + } + + if (!this.canUseBrowserAPIs()) { + debugLogger.warn('CrossTabSessionSync: Browser APIs unavailable', {}, 'crossTabSync'); + return false; + } + + this.started = true; + + if (!this.initializeWorker()) { + debugLogger.warn('CrossTabSessionSync: Worker initialization failed', {}, 'crossTabSync'); + this.started = false; + return false; + } + + if (!this.initializeBroadcastChannel()) { + debugLogger.warn('CrossTabSessionSync: BroadcastChannel initialization failed', {}, 'crossTabSync'); + this.cleanup(); + this.started = false; + return false; + } + + this.initiateLeaderElection(); + this.setupBeforeUnloadHandler(); + this.setupVisibilityChangeHandler(); + + debugLogger.info('CrossTabSessionSync: Started', { tabId: this.tabId }, 'crossTabSync'); + return true; + } + + stop(): void { + if (!this.started) { + return; + } + + this.started = false; + + if (this.isLeader) { + this.broadcastMessage({ tabId: this.tabId, timestamp: Date.now(), type: 'leader:resign' }); + } + + this.cleanup(); + + debugLogger.info('CrossTabSessionSync: Stopped', { tabId: this.tabId }, 'crossTabSync'); + } + + /** + * Update session state across all tabs + */ + updateSession(sessionId: string | null, organizationId: string | null, expiresAt: number | null): void { + const message: SessionUpdateMessage = { + data: { + expiresAt, + organizationId, + sessionId, + }, + type: 'session:update', + }; + + this.worker?.postMessage(message); + + this.broadcastMessage({ + data: { + expiresAt, + organizationId, + sessionId, + }, + tabId: this.tabId, + timestamp: Date.now(), + type: 'session:sync', + }); + } + + /** + * Notify worker that refresh completed + */ + notifyRefreshComplete(expiresAt: number): void { + const message: RefreshCompleteMessage = { + data: { + expiresAt, + timestamp: Date.now(), + }, + type: 'session:refresh-complete', + }; + + this.worker?.postMessage(message); + } + + clearSession(): void { + this.worker?.postMessage({ type: 'session:clear' }); + + this.broadcastMessage({ + data: { expiresAt: null, organizationId: null, sessionId: null }, + tabId: this.tabId, + timestamp: Date.now(), + type: 'session:sync', + }); + } + + private initializeWorker(): boolean { + if (typeof Worker === 'undefined') { + return false; + } + + try { + const blob = new Blob([crossTabSyncWorkerSource], { type: 'application/javascript; charset=utf-8' }); + const workerScript = globalThis.URL.createObjectURL(blob); + this.workerScriptUrl = workerScript; + this.worker = new Worker(workerScript, { name: 'clerk-session-sync' }); + + this.worker.addEventListener('message', (event: MessageEvent) => { + this.handleWorkerMessage(event.data); + }); + + this.worker.addEventListener('error', error => { + debugLogger.error('CrossTabSessionSync: Worker error', { error: error.message }, 'crossTabSync'); + this.handleFatalError('worker_error', error); + }); + + return true; + } catch (error) { + debugLogger.warn( + 'CrossTabSessionSync: Cannot create worker from blob. Consider adding worker-src blob:; to your CSP', + error, + 'crossTabSync', + ); + return false; + } + } + + private initializeBroadcastChannel(): boolean { + if (typeof BroadcastChannel === 'undefined') { + debugLogger.warn('CrossTabSessionSync: BroadcastChannel not available', {}, 'crossTabSync'); + return false; + } + + try { + this.broadcastChannel = new BroadcastChannel('clerk:session_sync'); + this.broadcastChannel.addEventListener('message', (event: MessageEvent) => { + this.handleBroadcastMessage(event.data); + }); + return true; + } catch (error) { + debugLogger.warn('CrossTabSessionSync: Failed to create BroadcastChannel', error, 'crossTabSync'); + return false; + } + } + + private handleWorkerMessage(message: WorkerIncomingMessage): void { + switch (message.type) { + case 'ready': + debugLogger.info('CrossTabSessionSync: Worker ready', { tabId: this.tabId }, 'crossTabSync'); + break; + + case 'session:needs-refresh': + if (this.isLeader) { + this.emitMetric('refresh_request_handled', { role: 'leader' }); + void this.options.onRefreshNeeded(); + } else { + this.emitMetric('refresh_request_forwarded', { role: 'member' }); + this.broadcastMessage({ + tabId: this.tabId, + timestamp: Date.now(), + type: 'session:refresh-needed', + }); + } + break; + + case 'pong': + debugLogger.info('CrossTabSessionSync: Worker pong', { data: message.data, tabId: this.tabId }, 'crossTabSync'); + break; + + default: + break; + } + } + + private handleBroadcastMessage(message: BroadcastMessage): void { + if (message.tabId === this.tabId) { + return; + } + + switch (message.type) { + case 'leader:elect': + this.handleLeaderElection(message); + break; + + case 'leader:heartbeat': + this.handleLeaderHeartbeat(message); + break; + + case 'leader:resign': + this.handleLeaderResignation(message); + break; + + case 'session:sync': + this.handleSessionSync(message); + break; + + case 'session:refresh-needed': + if (this.isLeader) { + this.emitMetric('refresh_request_received', { from: message.tabId }); + void this.options.onRefreshNeeded(); + } + break; + + case 'signout': + this.options.onSignout(); + break; + + default: + break; + } + } + + private initiateLeaderElection(): void { + this.broadcastMessage({ + tabId: this.tabId, + timestamp: Date.now(), + type: 'leader:elect', + }); + + setTimeout(() => { + if (!this.isLeader && Date.now() - this.lastLeaderHeartbeat > LEADER_TIMEOUT) { + this.becomeLeader(); + } else { + this.startLeaderMonitoring(); + } + }, 100); + } + + private handleLeaderElection(message: LeaderElectMessage): void { + if (this.isLeader) { + this.broadcastMessage({ + tabId: this.tabId, + timestamp: Date.now(), + type: 'leader:heartbeat', + }); + } else if (message.tabId < this.tabId) { + this.startLeaderMonitoring(); + } + } + + private handleLeaderHeartbeat(message: LeaderHeartbeatMessage): void { + this.lastLeaderHeartbeat = message.timestamp; + + if (this.isLeader && message.tabId !== this.tabId) { + if (message.tabId < this.tabId) { + this.resignLeadership(); + } + } + + if (!this.isLeader) { + this.startLeaderMonitoring(); + } + } + + private handleLeaderResignation(_message: LeaderResignMessage): void { + if (!this.isLeader && Date.now() - this.lastLeaderHeartbeat > 1000) { + this.becomeLeader(); + } + } + + private handleSessionSync(message: SessionSyncMessage): void { + const { sessionId, organizationId, expiresAt } = message.data; + + this.worker?.postMessage({ + data: { + expiresAt, + organizationId, + sessionId, + }, + type: 'session:update', + }); + + this.options.onSessionSync(sessionId, organizationId); + this.emitMetric('session_sync', { + from: message.tabId, + hasOrganization: Boolean(organizationId), + hasSession: Boolean(sessionId), + }); + } + + private becomeLeader(): void { + if (this.isLeader) { + return; + } + + this.isLeader = true; + this.lastLeaderHeartbeat = Date.now(); + + if (this.leaderTimeoutId) { + clearTimeout(this.leaderTimeoutId); + this.leaderTimeoutId = null; + } + + this.heartbeatTimerId = setInterval(() => { + this.broadcastMessage({ + tabId: this.tabId, + timestamp: Date.now(), + type: 'leader:heartbeat', + }); + }, LEADER_HEARTBEAT_INTERVAL); + + debugLogger.info('CrossTabSessionSync: Became leader', { tabId: this.tabId }, 'crossTabSync'); + this.emitMetric('leader_elected'); + } + + private resignLeadership(): void { + if (!this.isLeader) { + return; + } + + this.isLeader = false; + + if (this.heartbeatTimerId) { + clearInterval(this.heartbeatTimerId); + this.heartbeatTimerId = null; + } + + this.startLeaderMonitoring(); + + debugLogger.info('CrossTabSessionSync: Resigned leadership', { tabId: this.tabId }, 'crossTabSync'); + this.emitMetric('leader_resigned'); + } + + private startLeaderMonitoring(): void { + if (this.leaderTimeoutId) { + clearTimeout(this.leaderTimeoutId); + } + + this.leaderTimeoutId = setTimeout(() => { + if (Date.now() - this.lastLeaderHeartbeat > LEADER_TIMEOUT) { + this.becomeLeader(); + } + }, LEADER_TIMEOUT); + } + + private broadcastMessage(message: BroadcastMessage): void { + try { + this.broadcastChannel?.postMessage(message); + } catch (error) { + debugLogger.warn('CrossTabSessionSync: Failed to broadcast message', error, 'crossTabSync'); + if (this.started) { + this.handleFatalError('broadcast_unavailable', error); + } + } + } + + private setupBeforeUnloadHandler(): void { + if (!this.canUseBrowserAPIs()) { + return; + } + + window.addEventListener('beforeunload', () => { + if (this.isLeader) { + this.broadcastMessage({ + tabId: this.tabId, + timestamp: Date.now(), + type: 'leader:resign', + }); + } + }); + } + + private canUseBrowserAPIs(): boolean { + return typeof window !== 'undefined' && typeof window.addEventListener === 'function'; + } + + private cleanup(): void { + if (this.heartbeatTimerId) { + clearInterval(this.heartbeatTimerId); + this.heartbeatTimerId = null; + } + + if (this.leaderTimeoutId) { + clearTimeout(this.leaderTimeoutId); + this.leaderTimeoutId = null; + } + + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + + this.revokeWorkerScriptUrl(); + this.teardownVisibilityChangeHandler(); + + if (this.broadcastChannel) { + this.broadcastChannel.close(); + this.broadcastChannel = null; + } + } + + private emitMetric(event: string, payload: Record = {}): void { + const detail = { ...payload, event, source: 'crossTab', tabId: this.tabId, timestamp: Date.now() }; + debugLogger.info('CrossTabSessionSync: Metric', detail, 'crossTabSync'); + + if ( + typeof window === 'undefined' || + typeof window.dispatchEvent !== 'function' || + typeof CustomEvent !== 'function' + ) { + return; + } + + try { + window.dispatchEvent(new CustomEvent('clerk:crossTabMetric', { detail })); + } catch (error) { + debugLogger.warn('CrossTabSessionSync: Failed to dispatch metric event', error, 'crossTabSync'); + } + } + + private handleFatalError(reason: CrossTabSessionSyncFailure, error?: unknown): void { + debugLogger.warn('CrossTabSessionSync: Fatal error encountered', { error, reason }, 'crossTabSync'); + this.emitMetric('fatal_error', { reason }); + if (this.started) { + this.stop(); + } else { + this.cleanup(); + } + + this.options.onFatalError(reason); + } + + private revokeWorkerScriptUrl(): void { + if (!this.workerScriptUrl) { + return; + } + + try { + globalThis.URL.revokeObjectURL(this.workerScriptUrl); + } catch (error) { + debugLogger.warn('CrossTabSessionSync: Failed to revoke worker URL', error, 'crossTabSync'); + } finally { + this.workerScriptUrl = null; + } + } + + private setupVisibilityChangeHandler(): void { + if (typeof document === 'undefined' || typeof document.addEventListener !== 'function') { + return; + } + + const handler = () => { + if (document.visibilityState === 'visible') { + this.worker?.postMessage({ type: 'ping' }); + } + }; + + document.addEventListener('visibilitychange', handler); + this.visibilityChangeHandler = handler; + } + + private teardownVisibilityChangeHandler(): void { + if ( + !this.visibilityChangeHandler || + typeof document === 'undefined' || + typeof document.removeEventListener !== 'function' + ) { + return; + } + + document.removeEventListener('visibilitychange', this.visibilityChangeHandler); + this.visibilityChangeHandler = null; + } +} diff --git a/packages/clerk-js/src/core/auth/SessionRefreshCoordinator.ts b/packages/clerk-js/src/core/auth/SessionRefreshCoordinator.ts new file mode 100644 index 00000000000..b446656ae45 --- /dev/null +++ b/packages/clerk-js/src/core/auth/SessionRefreshCoordinator.ts @@ -0,0 +1,257 @@ +import { debugLogger } from '@/utils/debug'; + +import { CrossTabSessionSync } from './CrossTabSessionSync'; +import { SafeLock } from './safeLock'; +import { SessionCookiePoller } from './SessionCookiePoller'; + +const REFRESH_SESSION_TOKEN_LOCK_KEY = 'clerk.lock.refreshSessionToken'; + +interface SessionInfo { + expiresAt: number | null; + organizationId: string | null; + sessionId: string | null; +} + +interface SessionRefreshCoordinatorOptions { + enableEventDrivenSync?: boolean; +} + +/** + * SessionRefreshCoordinator provides an event-driven alternative to SessionCookiePoller. + * It uses CrossTabSessionSync for Worker-based coordination and falls back to polling + * when Workers/BroadcastChannel are unavailable. + * + * Key features: + * - Zero-config drop-in replacement for SessionCookiePoller + * - Automatic fallback to polling for compatibility + * - Leader-based refresh coordination (only one tab refreshes) + * - Cross-tab session state synchronization + */ +export class SessionRefreshCoordinator { + private crossTabSync: CrossTabSessionSync | null = null; + private fallbackPoller: SessionCookiePoller | null = null; + private lock = SafeLock(REFRESH_SESSION_TOKEN_LOCK_KEY); + private refreshCallback: (() => Promise) | null = null; + private refreshInFlight = false; + private started = false; + private useEventDriven = false; + + /** + * Start session refresh coordination + */ + public startPollingForSessionToken(cb: () => Promise, options: SessionRefreshCoordinatorOptions = {}): void { + if (this.started) { + return; + } + + this.started = true; + this.refreshCallback = cb; + + const { enableEventDrivenSync = false } = options; + if (enableEventDrivenSync && this.tryInitializeEventDriven()) { + this.useEventDriven = true; + debugLogger.info('SessionRefreshCoordinator: Using event-driven sync', {}, 'sessionRefresh'); + this.emitMetric('mode_selected', { mode: 'event-driven' }); + } else { + const fallbackReason = enableEventDrivenSync ? 'event_driven_unavailable' : 'feature_disabled'; + this.startFallback(); + debugLogger.info( + 'SessionRefreshCoordinator: Using polling fallback', + { + reason: fallbackReason, + }, + 'sessionRefresh', + ); + this.emitMetric('mode_selected', { mode: 'polling', reason: fallbackReason }); + } + } + + /** + * Stop session refresh coordination + */ + public stopPollingForSessionToken(): void { + if (!this.started) { + return; + } + + this.started = false; + this.refreshCallback = null; + + this.stopCrossTabSync(); + + if (this.fallbackPoller) { + this.fallbackPoller.stopPollingForSessionToken(); + this.fallbackPoller = null; + } + + this.useEventDriven = false; + } + + /** + * Update session state across all tabs (event-driven mode only) + */ + public updateSession(sessionInfo: SessionInfo): void { + if (this.useEventDriven && this.crossTabSync) { + this.crossTabSync.updateSession(sessionInfo.sessionId, sessionInfo.organizationId, sessionInfo.expiresAt); + } + } + + /** + * Notify that a token refresh completed successfully + */ + public notifyRefreshComplete(expiresAt: number): void { + if (this.useEventDriven && this.crossTabSync) { + this.crossTabSync.notifyRefreshComplete(expiresAt); + } + } + + /** + * Clear session state across all tabs + */ + public clearSession(): void { + if (this.useEventDriven && this.crossTabSync) { + this.crossTabSync.clearSession(); + } + } + + /** + * Check if using event-driven mode + */ + public isEventDriven(): boolean { + return this.useEventDriven; + } + + private tryInitializeEventDriven(): boolean { + if (typeof window === 'undefined') { + debugLogger.warn('SessionRefreshCoordinator: Window not available for event-driven sync', {}, 'sessionRefresh'); + return false; + } + + if (typeof Worker === 'undefined' || typeof BroadcastChannel === 'undefined') { + return false; + } + + try { + this.crossTabSync = new CrossTabSessionSync({ + onFatalError: reason => { + this.handleFatalSyncError(reason); + }, + onRefreshNeeded: async () => { + await this.executeRefresh(); + }, + onSessionSync: (sessionId, organizationId) => { + debugLogger.info( + 'SessionRefreshCoordinator: Session synced from another tab', + { organizationId, sessionId }, + 'sessionRefresh', + ); + }, + onSignout: () => { + debugLogger.info('SessionRefreshCoordinator: Signout received from another tab', {}, 'sessionRefresh'); + }, + }); + + const started = this.crossTabSync.start(); + if (!started) { + this.stopCrossTabSync(); + return false; + } + + return true; + } catch (error) { + debugLogger.warn('SessionRefreshCoordinator: Failed to initialize event-driven sync', error, 'sessionRefresh'); + + this.stopCrossTabSync(); + + return false; + } + } + + private initializeFallback(): void { + this.fallbackPoller = new SessionCookiePoller(); + if (this.refreshCallback) { + this.fallbackPoller.startPollingForSessionToken(this.refreshCallback); + } + } + + private handleFatalSyncError(reason: string): void { + debugLogger.warn( + 'SessionRefreshCoordinator: Falling back to polling after fatal error', + { reason }, + 'sessionRefresh', + ); + this.useEventDriven = false; + this.stopCrossTabSync(); + this.startFallback(); + this.emitMetric('fallback_activated', { reason }); + } + + private startFallback(): void { + const reused = !!this.fallbackPoller; + if (!this.fallbackPoller) { + this.initializeFallback(); + } else if (this.refreshCallback) { + this.fallbackPoller.startPollingForSessionToken(this.refreshCallback); + } + + this.emitMetric('polling_started', { reused }); + } + + private stopCrossTabSync(): void { + if (this.crossTabSync) { + this.crossTabSync.stop(); + this.crossTabSync = null; + } + } + + private async executeRefresh(): Promise { + if (!this.refreshCallback) { + return; + } + + if (this.refreshInFlight) { + debugLogger.info('SessionRefreshCoordinator: Duplicate refresh attempt prevented', {}, 'sessionRefresh'); + this.emitMetric('duplicate_refresh_blocked', { mode: this.useEventDriven ? 'event-driven' : 'polling' }); + return; + } + + this.refreshInFlight = true; + + try { + const result = await this.lock.acquireLockAndRun(this.refreshCallback); + const lockAcquired = result !== false; + if (!lockAcquired) { + debugLogger.info('SessionRefreshCoordinator: Refresh skipped (lock contention)', {}, 'sessionRefresh'); + } + + this.emitMetric('refresh_attempt_completed', { + lockAcquired, + mode: this.useEventDriven ? 'event-driven' : 'polling', + }); + } catch (error) { + debugLogger.error('SessionRefreshCoordinator: Refresh failed', error, 'sessionRefresh'); + this.emitMetric('refresh_attempt_failed', { mode: this.useEventDriven ? 'event-driven' : 'polling' }); + } finally { + this.refreshInFlight = false; + } + } + + private emitMetric(event: string, payload: Record = {}): void { + const detail = { ...payload, event, source: 'sessionRefresh', timestamp: Date.now() }; + debugLogger.info('SessionRefreshCoordinator: Metric', detail, 'sessionRefresh'); + + if ( + typeof window === 'undefined' || + typeof window.dispatchEvent !== 'function' || + typeof CustomEvent !== 'function' + ) { + return; + } + + try { + window.dispatchEvent(new CustomEvent('clerk:crossTabMetric', { detail })); + } catch (error) { + debugLogger.warn('SessionRefreshCoordinator: Failed to dispatch metric event', error, 'sessionRefresh'); + } + } +} diff --git a/packages/clerk-js/src/core/auth/__tests__/SessionRefreshCoordinator.test.ts b/packages/clerk-js/src/core/auth/__tests__/SessionRefreshCoordinator.test.ts new file mode 100644 index 00000000000..31cccbec959 --- /dev/null +++ b/packages/clerk-js/src/core/auth/__tests__/SessionRefreshCoordinator.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { CrossTabSessionSyncOptions } from '../CrossTabSessionSync'; + +const crossTabStartMock = vi.fn(); +const crossTabStopMock = vi.fn(); +const pollerStartMock = vi.fn(); +const pollerStopMock = vi.fn(); +let capturedCrossTabOptions: CrossTabSessionSyncOptions | null = null; + +vi.mock('../CrossTabSessionSync', () => { + return { + CrossTabSessionSync: vi.fn().mockImplementation((options: CrossTabSessionSyncOptions) => { + capturedCrossTabOptions = options; + return { + clearSession: vi.fn(), + notifyRefreshComplete: vi.fn(), + start: crossTabStartMock, + stop: crossTabStopMock, + updateSession: vi.fn(), + }; + }), + }; +}); + +vi.mock('../SessionCookiePoller', () => { + return { + SessionCookiePoller: vi.fn().mockImplementation(() => ({ + startPollingForSessionToken: pollerStartMock, + stopPollingForSessionToken: pollerStopMock, + })), + }; +}); + +// eslint-disable-next-line import/first +import { SessionRefreshCoordinator } from '../SessionRefreshCoordinator'; + +describe('SessionRefreshCoordinator', () => { + beforeEach(() => { + crossTabStartMock.mockReset(); + crossTabStopMock.mockReset(); + pollerStartMock.mockReset(); + pollerStopMock.mockReset(); + capturedCrossTabOptions = null; + crossTabStartMock.mockReturnValue(true); + + // Minimal browser globals required by the coordinator. + // @ts-ignore + globalThis.window = { + addEventListener: vi.fn(), + dispatchEvent: vi.fn(), + removeEventListener: vi.fn(), + }; + // @ts-ignore + globalThis.CustomEvent = class CustomEvent { + detail: unknown; + constructor(_type: string, init?: CustomEventInit) { + this.detail = init?.detail; + } + }; + // @ts-ignore + globalThis.Worker = class {}; + // @ts-ignore + globalThis.BroadcastChannel = class { + addEventListener = vi.fn(); + close = vi.fn(); + postMessage = vi.fn(); + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + // @ts-ignore + delete globalThis.window; + // @ts-ignore + delete globalThis.CustomEvent; + // @ts-ignore + delete globalThis.Worker; + // @ts-ignore + delete globalThis.BroadcastChannel; + }); + + it('uses polling when event-driven mode is disabled', () => { + const coordinator = new SessionRefreshCoordinator(); + const refresh = vi.fn().mockResolvedValue(undefined); + + coordinator.startPollingForSessionToken(refresh, { enableEventDrivenSync: false }); + + expect(pollerStartMock).toHaveBeenCalledWith(refresh); + expect(crossTabStartMock).not.toHaveBeenCalled(); + }); + + it('prefers event-driven mode when supported', () => { + const coordinator = new SessionRefreshCoordinator(); + const refresh = vi.fn().mockResolvedValue(undefined); + + coordinator.startPollingForSessionToken(refresh, { enableEventDrivenSync: true }); + + expect(coordinator.isEventDriven()).toBe(true); + expect(crossTabStartMock).toHaveBeenCalledTimes(1); + expect(pollerStartMock).not.toHaveBeenCalled(); + }); + + it('falls back to polling when cross-tab sync fails to start', () => { + const coordinator = new SessionRefreshCoordinator(); + const refresh = vi.fn().mockResolvedValue(undefined); + crossTabStartMock.mockReturnValue(false); + + coordinator.startPollingForSessionToken(refresh, { enableEventDrivenSync: true }); + + expect(pollerStartMock).toHaveBeenCalledWith(refresh); + expect(coordinator.isEventDriven()).toBe(false); + }); + + it('switches to polling when cross-tab sync reports a fatal error', () => { + const coordinator = new SessionRefreshCoordinator(); + const refresh = vi.fn().mockResolvedValue(undefined); + + coordinator.startPollingForSessionToken(refresh, { enableEventDrivenSync: true }); + expect(coordinator.isEventDriven()).toBe(true); + capturedCrossTabOptions?.onFatalError?.('worker_error'); + + expect(coordinator.isEventDriven()).toBe(false); + expect(pollerStartMock).toHaveBeenCalledWith(refresh); + }); +}); diff --git a/packages/clerk-js/src/core/auth/crossTabSync.worker.ts b/packages/clerk-js/src/core/auth/crossTabSync.worker.ts new file mode 100644 index 00000000000..aec8711b6af --- /dev/null +++ b/packages/clerk-js/src/core/auth/crossTabSync.worker.ts @@ -0,0 +1,174 @@ +/** + * Cross-Tab Session Sync Worker + * + * This worker manages session state synchronization across browser tabs without polling. + * It handles token refresh scheduling, session state validation, and coordinates with + * the main thread via MessageChannel. + */ + +interface SessionState { + expiresAt: number | null; + lastRefreshed: number; + organizationId: string | null; + refreshScheduled: boolean; + sessionId: string | null; +} + +interface WorkerMessage { + data: unknown; + type: string; +} + +interface SessionUpdateMessage extends WorkerMessage { + data: { + expiresAt: number | null; + organizationId: string | null; + sessionId: string | null; + }; + type: 'session:update'; +} + +interface RefreshRequestMessage extends WorkerMessage { + type: 'session:refresh-request'; +} + +interface RefreshCompleteMessage extends WorkerMessage { + data: { + expiresAt: number; + timestamp: number; + }; + type: 'session:refresh-complete'; +} + +interface PingMessage extends WorkerMessage { + type: 'ping'; +} + +interface ClearMessage extends WorkerMessage { + type: 'session:clear'; +} + +type IncomingMessage = + | ClearMessage + | PingMessage + | RefreshCompleteMessage + | RefreshRequestMessage + | SessionUpdateMessage; + +let sessionState: SessionState = { + expiresAt: null, + lastRefreshed: Date.now(), + organizationId: null, + refreshScheduled: false, + sessionId: null, +}; + +let refreshTimerId: ReturnType | null = null; + +const REFRESH_BUFFER_MS = 60_000; +const MIN_REFRESH_INTERVAL_MS = 30_000; + +function scheduleRefresh(expiresAt: number): void { + if (refreshTimerId !== null) { + clearTimeout(refreshTimerId); + refreshTimerId = null; + } + + const now = Date.now(); + const timeUntilExpiry = expiresAt - now; + const refreshTime = Math.max(timeUntilExpiry - REFRESH_BUFFER_MS, MIN_REFRESH_INTERVAL_MS); + + if (refreshTime > 0) { + sessionState.refreshScheduled = true; + refreshTimerId = setTimeout(() => { + if (sessionState.sessionId) { + self.postMessage({ type: 'session:needs-refresh' }); + } + sessionState.refreshScheduled = false; + refreshTimerId = null; + }, refreshTime); + } +} + +function updateSession(data: SessionUpdateMessage['data']): void { + const hasSessionChanged = + sessionState.sessionId !== data.sessionId || sessionState.organizationId !== data.organizationId; + + sessionState = { + ...sessionState, + expiresAt: data.expiresAt, + organizationId: data.organizationId, + sessionId: data.sessionId, + }; + + if (hasSessionChanged && refreshTimerId) { + clearTimeout(refreshTimerId); + refreshTimerId = null; + sessionState.refreshScheduled = false; + } + + if (data.expiresAt && data.sessionId) { + scheduleRefresh(data.expiresAt); + } +} + +function clearSession(): void { + if (refreshTimerId !== null) { + clearTimeout(refreshTimerId); + refreshTimerId = null; + } + + sessionState = { + expiresAt: null, + lastRefreshed: Date.now(), + organizationId: null, + refreshScheduled: false, + sessionId: null, + }; +} + +function handleRefreshComplete(data: RefreshCompleteMessage['data']): void { + sessionState.lastRefreshed = data.timestamp; + + if (data.expiresAt) { + scheduleRefresh(data.expiresAt); + } +} + +self.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + + switch (message.type) { + case 'session:update': + updateSession(message.data); + break; + + case 'session:refresh-request': + if (sessionState.sessionId) { + self.postMessage({ type: 'session:needs-refresh' }); + } + break; + + case 'session:refresh-complete': + handleRefreshComplete(message.data); + break; + + case 'session:clear': + clearSession(); + break; + + case 'ping': + if (sessionState.expiresAt && sessionState.sessionId && !sessionState.refreshScheduled) { + scheduleRefresh(sessionState.expiresAt); + } + self.postMessage({ data: sessionState, type: 'pong' }); + break; + + default: + break; + } +}); + +self.postMessage({ type: 'ready' }); + +export {}; diff --git a/packages/clerk-js/src/core/auth/safeLock.ts b/packages/clerk-js/src/core/auth/safeLock.ts index 405190a73ff..e234e8d0322 100644 --- a/packages/clerk-js/src/core/auth/safeLock.ts +++ b/packages/clerk-js/src/core/auth/safeLock.ts @@ -31,6 +31,8 @@ export function SafeLock(key: string) { await lock.releaseLock(key); } } + + return false; }; return { acquireLockAndRun }; diff --git a/packages/clerk-js/src/global.d.ts b/packages/clerk-js/src/global.d.ts index 50a17aae822..e448dc293e3 100644 --- a/packages/clerk-js/src/global.d.ts +++ b/packages/clerk-js/src/global.d.ts @@ -3,6 +3,12 @@ declare module '*.svg' { export default value; } +declare module '*.worker.ts' { + const value: string; + export default value; +} + +declare const __BUILD_VARIANT_EXPERIMENTAL__: boolean; declare const __PKG_NAME__: string; declare const __PKG_VERSION__: string; declare const __DEV__: boolean;