@posthog/browser-common
Internal shared browser utilities and extension primitives for PostHog JavaScript
SDKs. This package is published so unbundled SDK outputs can resolve it at
runtime, but it is not a public API surface and does not provide compatibility
guarantees outside PostHog SDK packages.
The shared extension contract includes the interface an extension implements
(Extension), the host adapter it receives (Client), and small shared runtime
primitives such as Publisher.
This contract is designed so an extension can run unchanged across major
versions of the web SDK. Concrete host adapters remain owned by their SDK
packages; browser-v1 and browser-v2 composition and loading integration are
separate from this shared runtime.
Concepts
Extension
What you implement. The host calls setup once and optional dispose for final cleanup:
import type { Disposable, Extension } from '@posthog/browser-common'
export function webContext(): Extension {
let removeProperties: Disposable | undefined
return {
name: 'webContext',
setup(client) {
removeProperties = client.registerDynamicEventProperties(() => ({
$current_url: window.location.href,
}))
},
dispose() {
removeProperties?.dispose()
},
}
}
setup(client) may be async to read state before the extension is ready. Async
extensions must guard work after each await so cleanup cannot be followed by
late listener or timer installation. dispose() is synchronous, optional,
idempotent, and best-effort. Static app config goes in the constructor, not on
Client.
Anything in setup that returns a Disposable must be held by the extension
and disposed in dispose(). Use createDisposable(teardown) when adapting a
synchronous callback into idempotent teardown.
Client
What an extension is given in setup — the adapter shared by extensions on that host SDK instance:
- identity and session:
distinctId, anonymousId, deviceId, groups, session, initialPersonProperties
- SDK metadata:
library
- capture permission:
canCapture
- events:
capture(...), registerDynamicEventProperties(...), onEvent(...)
- extensions:
getExtension(token)
- server config:
onRemoteConfig(...)
- transport:
projectToken, sendRequest(path, init?), including compression and sentAt options
- storage and logging:
kv, logger
Identity, session, SDK metadata, capture permission, and the public project token are always-ready synchronous reads.
capture and sendRequest are awaitable. For sendRequest, sentAt controls sent_at placement on POST requests; GET query mode
uses the cache-busting _ parameter instead, and GET body mode has no effect. onRemoteConfig immediately replays the
latest known success or failure and then reports subsequent outcomes. Extensions that want a named log prefix can
create a child with client.logger.createLogger('[myExtension]').
Extensions that expose controls to other extensions should export a typed stable-name token:
import type { Extension, ExtensionToken } from '@posthog/browser-common'
export interface DiagnosticsExtension extends Extension {
flush(): void
}
export const DiagnosticsExtension = 'diagnostics' as ExtensionToken<DiagnosticsExtension>
const diagnostics = client.getExtension(DiagnosticsExtension)
diagnostics?.flush()
Tokens are strings at runtime and must match the installed extension's Extension.name. Lookup is per-client and optional; a
returned extension may still be running setup, so consumers must not create mandatory startup cycles.
Initialize KV during asynchronous setup before using its synchronous buffer:
await client.kv.initialize()
const state = client.kv.get<{ first: boolean; second: string }>(['first', 'second'])
client.kv.set({ ...state, first: true })
client.kv.remove(['first', 'second'])
Initialization is idempotent and may be asynchronous while a host hydrates its buffer. After it completes, reads,
writes, and removals are synchronous; batch reads, object writes, and multi-key removals operate on related values
coherently. The host owns ordered durable flushing.
KV keys map directly to shared host persistence: reset clears them, collisions can overwrite host state, and unknown
keys may be captured as event properties. Use stable extension-owned keys with an explicit exposure policy, and do not
store sensitive values unless their transmission is approved.
Host runtime
PostHog browser SDK implementations share extension registration and teardown
through ExtensionRuntime, imported from the dedicated
@posthog/browser-common/extension-runtime subpath. It reserves extension names
during setup, exposes registered extensions by typed stable name through Client,
rolls back failed setup, and disposes extensions once in reverse registration
order without waiting for pending setup. Concrete SDKs still own their Client
adapter and SDK lifecycle hooks.
ExtensionRuntime is host infrastructure, not part of the extension-author
surface exported from the package root.
Publisher
Use Publisher<T> when an extension exposes an event stream. Keep the publisher
private, expose only its listener, and dispose it with the extension:
import { Publisher, type Listener } from '@posthog/browser-common'
const changes = new Publisher<{ enabled: boolean }>()
export const onChange: Listener<{ enabled: boolean }> = changes.listener
changes.publish({ enabled: true })
changes.dispose()
Utilities
Reusable browser utilities are exposed through utils/* subpaths, but they are
intentionally not re-exported from the package root or a utility barrel. Import
the exact file needed so lazy extension bundles do not pull in unrelated helpers:
import { createLogger } from '@posthog/browser-common/utils/logger'
import { formDataToQuery } from '@posthog/browser-common/utils/request-utils'
Authoring
See the develop-extension skill
(.agents/skills/develop-extension/SKILL.md)
for the complete authoring and browser-v1 porting guide.
Status
Early and internal. The package currently defines the extension and client
contracts, a shared host runtime, lifecycle helpers, and directly imported
browser utilities under utils/* subpaths.