diff --git a/.changeset/busy-goats-tan.md b/.changeset/busy-goats-tan.md new file mode 100644 index 0000000000..ef1e08bdbe --- /dev/null +++ b/.changeset/busy-goats-tan.md @@ -0,0 +1,5 @@ +--- +'posthog-js': minor +--- + +feat: Add support for pre-loaded remote-config diff --git a/packages/browser/src/__tests__/posthog-core-also.test.ts b/packages/browser/src/__tests__/posthog-core-also.test.ts index 42cb75c6eb..a7b5f7371c 100644 --- a/packages/browser/src/__tests__/posthog-core-also.test.ts +++ b/packages/browser/src/__tests__/posthog-core-also.test.ts @@ -409,6 +409,51 @@ describe('posthog core', () => { expect(posthog.analyticsDefaultEndpoint).toEqual('/i/v0/e/') }) + + it('sets _scriptBaseUrl when sdkVersion.scriptBaseUrl is provided', () => { + const posthog = posthogWith({}) + + posthog._onRemoteConfig({ + sdkVersion: { + requested: '1', + resolved: '1.358.0', + scriptBaseUrl: 'https://us-assets.i.posthog.com/1.358.0', + }, + } as RemoteConfig) + + expect(posthog._scriptBaseUrl).toEqual('https://us-assets.i.posthog.com/1.358.0') + }) + + it('leaves _scriptBaseUrl undefined when sdkVersion is absent', () => { + const posthog = posthogWith({}) + + posthog._onRemoteConfig({} as RemoteConfig) + + expect(posthog._scriptBaseUrl).toBeUndefined() + }) + + it('leaves _scriptBaseUrl undefined when sdkVersion has no scriptBaseUrl', () => { + const posthog = posthogWith({}) + + posthog._onRemoteConfig({ + sdkVersion: { requested: '1', resolved: '1.358.0' }, + } as RemoteConfig) + + expect(posthog._scriptBaseUrl).toBeUndefined() + }) + + it('registers $sdk_version_requested session property when sdkVersion.requested is present', () => { + const posthog = posthogWith({}) + const spy = jest.spyOn(posthog, 'register_for_session') + + posthog._onRemoteConfig({ + sdkVersion: { requested: '1', resolved: '1.358.0' }, + } as RemoteConfig) + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ $sdk_version_requested: '1' }) + ) + }) }) describe('_calculate_event_properties()', () => { diff --git a/packages/browser/src/__tests__/utils/external-scripts-loader.test.ts b/packages/browser/src/__tests__/utils/external-scripts-loader.test.ts index 7ec7bc289e..d39272b7ec 100644 --- a/packages/browser/src/__tests__/utils/external-scripts-loader.test.ts +++ b/packages/browser/src/__tests__/utils/external-scripts-loader.test.ts @@ -113,4 +113,64 @@ describe('external-scripts-loader', () => { delete mockPostHog.config.prepare_external_dependency_script }) }) + + describe('versioned script loading', () => { + const mockPostHog = { + config: { + api_host: 'https://us.posthog.com', + token: 'test-token', + external_scripts_inject_target: 'body', + }, + version: '1.0.0', + _scriptBaseUrl: 'https://us-assets.i.posthog.com/1.358.0', + } as PostHog + mockPostHog.requestRouter = new RequestRouter(mockPostHog) + + const callback = jest.fn() + beforeEach(() => { + callback.mockClear() + document!.getElementsByTagName('html')![0].innerHTML = '' + }) + + it('loads extensions from versioned base URL when _scriptBaseUrl is set', () => { + assignableWindow.__PosthogExtensions__.loadExternalDependency(mockPostHog, 'recorder', callback) + + const scripts = document!.getElementsByTagName('script') + expect(scripts.length).toBe(1) + expect(scripts[0].src).toBe('https://us-assets.i.posthog.com/1.358.0/recorder.js') + }) + + it('loads toolbar from versioned base URL with cache-busting timestamp', () => { + jest.useFakeTimers() + jest.setSystemTime(1726067100000) + + assignableWindow.__PosthogExtensions__.loadExternalDependency(mockPostHog, 'toolbar', callback) + + expect(document!.getElementsByTagName('script')[0].src).toBe( + 'https://us-assets.i.posthog.com/1.358.0/toolbar.js?t=1726067100000' + ) + }) + + it('loads remote-config from token-specific path even when _scriptBaseUrl is set', () => { + assignableWindow.__PosthogExtensions__.loadExternalDependency(mockPostHog, 'remote-config', callback) + + const scripts = document!.getElementsByTagName('script') + expect(scripts.length).toBe(1) + expect(scripts[0].src).toBe('https://us-assets.i.posthog.com/array/test-token/config.js') + }) + + it('falls back to default /static/ path when _scriptBaseUrl is not set', () => { + const noVersionPostHog = { + ...mockPostHog, + _scriptBaseUrl: undefined, + } as PostHog + noVersionPostHog.requestRouter = new RequestRouter(noVersionPostHog) + + assignableWindow.__PosthogExtensions__.loadExternalDependency(noVersionPostHog, 'recorder', callback) + + const scripts = document!.getElementsByTagName('script') + expect(scripts.length).toBe(1) + expect(scripts[0].src).toBe('https://us-assets.i.posthog.com/static/recorder.js?v=1.0.0') + }) + }) }) diff --git a/packages/browser/src/entrypoints/external-scripts-loader.ts b/packages/browser/src/entrypoints/external-scripts-loader.ts index 003d4c7d9f..eece7a2651 100644 --- a/packages/browser/src/entrypoints/external-scripts-loader.ts +++ b/packages/browser/src/entrypoints/external-scripts-loader.ts @@ -90,23 +90,34 @@ assignableWindow.__PosthogExtensions__.loadExternalDependency = ( kind: PostHogExtensionKind, callback: (error?: string | Event, event?: Event) => void ): void => { - let scriptUrlToLoad = `/static/${kind}.js` + `?v=${posthog.version}` - + // remote-config always loads from the token-specific path if (kind === 'remote-config') { - scriptUrlToLoad = `/array/${posthog.config.token}/config.js` + const url = posthog.requestRouter.endpointFor('assets', `/array/${posthog.config.token}/config.js`) + loadScript(posthog, url, callback) + return + } + + // When the server provides a versioned base URL (snippet v2), + // load extensions from the version-specific CDN path directly + if (posthog._scriptBaseUrl) { + let url = `${posthog._scriptBaseUrl}/${kind}.js` + if (kind === 'toolbar') { + const fiveMinutesInMillis = 5 * 60 * 1000 + const timestampToNearestFiveMinutes = Math.floor(Date.now() / fiveMinutesInMillis) * fiveMinutesInMillis + url = `${url}?t=${timestampToNearestFiveMinutes}` + } + loadScript(posthog, url, callback) + return } + // Default: load from /static/ via request router (V1 snippet behavior) + let scriptUrlToLoad = `/static/${kind}.js` + `?v=${posthog.version}` if (kind === 'toolbar') { - // toolbar.js is served from the PostHog CDN, this has a TTL of 24 hours. - // the toolbar asset includes a rotating "token" that is valid for 5 minutes. const fiveMinutesInMillis = 5 * 60 * 1000 - // this ensures that we bust the cache periodically const timestampToNearestFiveMinutes = Math.floor(Date.now() / fiveMinutesInMillis) * fiveMinutesInMillis - scriptUrlToLoad = `${scriptUrlToLoad}&t=${timestampToNearestFiveMinutes}` } const url = posthog.requestRouter.endpointFor('assets', scriptUrlToLoad) - loadScript(posthog, url, callback) } diff --git a/packages/browser/src/posthog-core.ts b/packages/browser/src/posthog-core.ts index 6b74eb7448..4c31ffca9c 100644 --- a/packages/browser/src/posthog-core.ts +++ b/packages/browser/src/posthog-core.ts @@ -376,6 +376,7 @@ export class PostHog implements PostHogInterface { __request_queue: QueuedRequestWithOptions[] _pendingRemoteConfig?: RemoteConfig _remoteConfigLoader?: RemoteConfigLoader + _scriptBaseUrl?: string analyticsDefaultEndpoint: string version: string = Config.LIB_VERSION _initialPersonProfilesConfig: 'always' | 'never' | 'identified_only' | null @@ -855,6 +856,16 @@ export class PostHog implements PostHogInterface { this.analyticsDefaultEndpoint = config.analytics.endpoint } + if (config.sdkVersion?.scriptBaseUrl) { + this._scriptBaseUrl = config.sdkVersion.scriptBaseUrl + } + + if (config.sdkVersion?.requested) { + this.register_for_session({ + $sdk_version_requested: config.sdkVersion.requested, + }) + } + this.set_config({ person_profiles: this._initialPersonProfilesConfig ? this._initialPersonProfilesConfig : 'identified_only', }) diff --git a/packages/browser/src/types.ts b/packages/browser/src/types.ts index 3a53fad13e..21727e1585 100644 --- a/packages/browser/src/types.ts +++ b/packages/browser/src/types.ts @@ -377,6 +377,16 @@ export interface RemoteConfig { * Conversations widget configuration */ conversations?: boolean | ConversationsRemoteConfig + + /** + * SDK version information from the server (snippet versioning). + * Present when the team has version pinning configured. + */ + sdkVersion?: { + requested: string + resolved?: string + scriptBaseUrl?: string + } } /**