Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/busy-goats-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'posthog-js': minor
---

feat: Add support for pre-loaded remote-config
45 changes: 45 additions & 0 deletions packages/browser/src/__tests__/posthog-core-also.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,51 @@

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' })

Check failure on line 454 in packages/browser/src/__tests__/posthog-core-also.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Replace `⏎················expect.objectContaining({·$sdk_version_requested:·'1'·})⏎············` with `expect.objectContaining({·$sdk_version_requested:·'1'·})`
)
})
})

describe('_calculate_event_properties()', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
})
})
27 changes: 19 additions & 8 deletions packages/browser/src/entrypoints/external-scripts-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
11 changes: 11 additions & 0 deletions packages/browser/src/posthog-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
})
Expand Down
10 changes: 10 additions & 0 deletions packages/browser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

/**
Expand Down
Loading