@posthog/browser-common 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,26 +6,22 @@ runtime, but it is not a public API surface and does not provide compatibility
6
6
  guarantees outside PostHog SDK packages.
7
7
 
8
8
  The shared extension contract includes the interface an extension implements
9
- (`Extension`), the host services it is handed (`Client`), the core analytics
10
- capability (`CoreExtension`), and small shared runtime primitives such as
11
- `Publisher`.
9
+ (`Extension`), the host adapter it receives (`Client`), and small shared runtime
10
+ primitives such as `Publisher`.
12
11
 
13
12
  This contract is designed so an extension can run unchanged across major
14
13
  versions of the web SDK. Concrete host adapters remain owned by their SDK
15
14
  packages; browser-v1 and browser-v2 composition and loading integration are
16
15
  separate from this shared runtime.
17
16
 
18
- A conforming SDK provides a _client adapter_ that implements `Client` over its
19
- own internals, so extension code never depends on a specific SDK.
20
-
21
17
  ## Concepts
22
18
 
23
19
  ### `Extension`
24
20
 
25
- What you implement. The host calls only `setup` and `dispose`:
21
+ What you implement. The host calls `setup` once and optional `dispose` for final cleanup:
26
22
 
27
23
  ```ts
28
- import { CoreExtension, type Disposable, type Extension } from '@posthog/browser-common'
24
+ import type { Disposable, Extension } from '@posthog/browser-common'
29
25
 
30
26
  export function webContext(): Extension {
31
27
  let removeProperties: Disposable | undefined
@@ -33,11 +29,7 @@ export function webContext(): Extension {
33
29
  return {
34
30
  name: 'webContext',
35
31
  setup(client) {
36
- const core = client.getExtension(CoreExtension)
37
- if (!core) {
38
- throw new Error('CoreExtension is required')
39
- }
40
- removeProperties = core.registerDynamicEventProperties(() => ({
32
+ removeProperties = client.registerDynamicEventProperties(() => ({
41
33
  $current_url: window.location.href,
42
34
  }))
43
35
  },
@@ -48,93 +40,63 @@ export function webContext(): Extension {
48
40
  }
49
41
  ```
50
42
 
51
- `setup(client)` may be async (read async state before you're ready); `dispose()`
52
- may be async (final flush). Static config the app sets goes in your constructor,
53
- not on the `Client`.
43
+ `setup(client)` may be async to read state before the extension is ready. Async
44
+ extensions must guard work after each `await` so cleanup cannot be followed by
45
+ late listener or timer installation. `dispose()` is synchronous, optional,
46
+ idempotent, and best-effort. Static app config goes in the constructor, not on
47
+ `Client`.
54
48
 
55
49
  Anything in `setup` that returns a `Disposable` must be held by the extension
56
50
  and disposed in `dispose()`. Use `createDisposable(teardown)` when adapting a
57
- callback into idempotent teardown.
51
+ synchronous callback into idempotent teardown.
58
52
 
59
53
  ### `Client`
60
54
 
61
- What an extension is given in `setup` — the host's extension services:
62
-
63
- - **transport**: `projectToken`, `sendRequest(path, init?)`
64
- - **registry**: `getExtension(token)`
65
- - **storage & logging**: `kv`, `logger`
66
-
67
- ### `CoreExtension`
55
+ What an extension is given in `setup` — the adapter shared by extensions on that host SDK instance:
68
56
 
69
- A conforming host must register one `CoreExtension` before setting up product
70
- extensions. Resolve it through `client.getExtension(CoreExtension)` for behavior
71
- owned by the PostHog client's analytics core:
72
-
73
- - **identity & session**: `distinctId`, `anonymousId`, `groups`, `session`
57
+ - **identity and session**: `distinctId`, `anonymousId`, `groups`, `session`
74
58
  - **events**: `capture(...)`, `registerDynamicEventProperties(...)`, `onEvent(...)`
75
- - **lifecycle**: `onNewSession(...)`
76
- - **server config**: `getRemoteConfig()` (current), `onRemoteConfig(...)` (changes)
59
+ - **server config**: `getRemoteConfig()` and `onRemoteConfig(...)`
60
+ - **transport**: `projectToken`, `sendRequest(path, init?)`
61
+ - **storage and logging**: `kv`, `logger`
77
62
 
78
63
  Identity, session, and the public project token are always-ready synchronous
79
- reads. Operations that perform I/O, including `capture`, `sendRequest`, `kv`,
80
- and `getRemoteConfig`, are awaitable.
64
+ reads. Operations that may perform I/O, including `capture`, `sendRequest`,
65
+ `kv`, and `getRemoteConfig`, are awaitable. Extensions that want a named log
66
+ prefix can create a child with `client.logger.createLogger('[myExtension]')`.
81
67
 
82
68
  ### Host runtime
83
69
 
84
70
  PostHog browser SDK implementations share extension registration and teardown
85
71
  through `ExtensionRuntime`, imported from the dedicated
86
- `@posthog/browser-common/extension-runtime` subpath. It reserves names and
87
- capability tokens during setup, publishes providers only after successful
88
- readiness, and disposes extensions once in reverse registration order. Concrete
89
- SDKs still own the `Client` adapter, Core implementation, and SDK lifecycle
90
- hooks.
72
+ `@posthog/browser-common/extension-runtime` subpath. It reserves extension names
73
+ during setup, rolls back failed setup, and disposes extensions once in reverse
74
+ registration order without waiting for pending setup. Concrete SDKs still own
75
+ their `Client` adapter and SDK lifecycle hooks.
91
76
 
92
77
  `ExtensionRuntime` is host infrastructure, not part of the extension-author
93
78
  surface exported from the package root.
94
79
 
95
80
  ### `Publisher`
96
81
 
97
- Use `Publisher<T>` when an extension provides its own event stream to other
98
- extensions or to app-facing controls. Keep the publisher private, expose only its
99
- `listener`, and dispose it when the extension is torn down:
82
+ Use `Publisher<T>` when an extension exposes an event stream. Keep the publisher
83
+ private, expose only its listener, and dispose it with the extension:
100
84
 
101
85
  ```ts
102
86
  import { Publisher, type Listener } from '@posthog/browser-common'
103
87
 
104
- const changes = new Publisher<FeatureFlagsChange>()
88
+ const changes = new Publisher<{ enabled: boolean }>()
89
+ export const onChange: Listener<{ enabled: boolean }> = changes.listener
105
90
 
106
- export const onChange: Listener<FeatureFlagsChange> = changes.listener
107
-
108
- changes.publish({ flag: 'beta-ui', value: true })
91
+ changes.publish({ enabled: true })
109
92
  changes.dispose()
110
93
  ```
111
94
 
112
- ## Cross-extension dependencies
113
-
114
- Extensions depend on one another through tokens, never implementation imports:
115
-
116
- ```ts
117
- import { FeatureFlags } from './feature-flags/token'
118
-
119
- const flags = client.getExtension(FeatureFlags) // FeatureFlagsExtension | undefined
120
- if (flags && (await flags.getFeatureFlag('beta-ui'))) {
121
- /* … */
122
- }
123
- ```
124
-
125
- A token is an implementation-free branded string, so importing it never pulls
126
- the provider's code into your bundle — each extension stays independently
127
- tree-shakable and lazily loadable. Use a package-qualified runtime string, such
128
- as `posthog.featureFlags`, that is globally unique and stable so separately
129
- compiled scripts resolve the same capability. An extension that provides a
130
- capability declares its token(s) in `provides`.
131
-
132
95
  ## Utilities
133
96
 
134
97
  Reusable browser utilities are exposed through `utils/*` subpaths, but they are
135
98
  intentionally not re-exported from the package root or a utility barrel. Import
136
- the exact file you need so lazy extension bundles do not pull in unrelated
137
- helpers:
99
+ the exact file needed so lazy extension bundles do not pull in unrelated helpers:
138
100
 
139
101
  ```ts
140
102
  import { createLogger } from '@posthog/browser-common/utils/logger'
@@ -145,13 +107,10 @@ import { formDataToQuery } from '@posthog/browser-common/utils/request-utils'
145
107
 
146
108
  See the **`develop-extension`** skill
147
109
  ([`.agents/skills/develop-extension/SKILL.md`](./.agents/skills/develop-extension/SKILL.md))
148
- for the full guide: the capability cheatsheet, the rules (enrichers are
149
- synchronous, dispose your disposables, design for asynchronous readiness,
150
- cross-extension state goes through `getExtension`, not shared storage), and the
151
- v1 → `Client` porting map.
110
+ for the complete authoring and browser-v1 porting guide.
152
111
 
153
112
  ## Status
154
113
 
155
- Early and internal. The package currently defines the extension contract, the
156
- core analytics capability, a shared host runtime, shared lifecycle helpers, and
157
- directly imported browser utilities under `utils/*` subpaths.
114
+ Early and internal. The package currently defines the extension and client
115
+ contracts, a shared host runtime, lifecycle helpers, and directly imported
116
+ browser utilities under `utils/*` subpaths.
package/dist/client.d.ts CHANGED
@@ -1,6 +1,40 @@
1
1
  import type { Logger } from '@posthog/core';
2
+ import type { Properties } from '@posthog/types';
3
+ import type { Disposable } from './disposable';
2
4
  import type { KeyValueStore } from './persistence';
3
- import type { ExtensionToken } from './token';
5
+ import type { Listener } from './pubsub';
6
+ import type { RemoteConfig } from './types/remote-config';
7
+ /** Recursively marks object properties as readonly while preserving callable values. */
8
+ export type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T extends object ? {
9
+ readonly [K in keyof T]: DeepReadonly<T[K]>;
10
+ } : T;
11
+ /** The current session, stamped on events to tie them to a session and a browser tab. */
12
+ export interface SessionContext {
13
+ /** The stable session identifier attached to events captured during this session. */
14
+ readonly sessionId: string;
15
+ /** The logical browser tab/window identifier attached alongside the session id. */
16
+ readonly windowId: string;
17
+ /** When the session started, as a Unix timestamp in milliseconds. */
18
+ readonly sessionStartTimestamp: number;
19
+ }
20
+ /** A captured event, as observed by `onEvent`. */
21
+ export interface CapturedEventInfo {
22
+ /** The finalized captured event name. */
23
+ readonly event: string;
24
+ /** The final event properties after client defaults and dynamic properties are applied. */
25
+ readonly properties: DeepReadonly<Record<string, unknown>>;
26
+ }
27
+ /** Per-call capture overrides, mirroring the client's public capture options. */
28
+ export interface CaptureOptions {
29
+ /** Override the event timestamp sent to PostHog. */
30
+ timestamp?: Date;
31
+ /** Override the event UUID used for de-duplication. */
32
+ uuid?: string;
33
+ /** Person properties to set, emitted as `$set`. */
34
+ set?: Record<string, unknown>;
35
+ /** Person properties to set if unset, emitted as `$set_once`. */
36
+ setOnce?: Record<string, unknown>;
37
+ }
4
38
  /** A minimal response from {@link Client.sendRequest}. */
5
39
  export interface ApiResponse {
6
40
  /** The HTTP status code returned by the transport, or a client-defined best-effort status for sendBeacon sends. */
@@ -34,28 +68,33 @@ export interface SendRequestInit {
34
68
  timeoutMs?: number;
35
69
  }
36
70
  /**
37
- * The host SDK's capability surface as seen by an extension the client an
38
- * extension is handed in `setup`. A conforming host provides it as an adapter
39
- * over its own internals.
40
- *
41
- * Host services that may do I/O are awaitable; a host can complete them
42
- * synchronously when its underlying implementation supports that. Core
43
- * analytics behavior is provided separately by the core extension.
71
+ * The host SDK surface handed to an extension in `setup`. A conforming host
72
+ * provides it as an adapter over its own analytics, transport, persistence,
73
+ * event, and remote-config internals.
44
74
  */
45
75
  export interface Client {
76
+ /** The id events are currently attributed to. */
77
+ readonly distinctId: string;
78
+ /** The anonymous device id carried across identify calls. */
79
+ readonly anonymousId: string;
80
+ /** Active group memberships attached to events as `$groups`. */
81
+ readonly groups: DeepReadonly<Record<string, string>>;
82
+ /** The current session, created on first read if needed. */
83
+ readonly session: SessionContext;
84
+ /** Records an analytics event through the client's normal pipeline. */
85
+ capture(event: string, properties?: Properties | null, options?: CaptureOptions): Promise<void>;
86
+ /** Registers a synchronous producer of properties merged into every captured event. */
87
+ registerDynamicEventProperties(producer: () => Record<string, unknown>): Disposable;
88
+ /** Fires for every captured event through a deeply readonly view. */
89
+ readonly onEvent: Listener<CapturedEventInfo>;
90
+ /** Resolves with the current remote config, awaiting the first outcome when necessary. */
91
+ getRemoteConfig(): Promise<DeepReadonly<RemoteConfig> | undefined>;
92
+ /** Fires when server-provided config arrives or changes successfully. */
93
+ readonly onRemoteConfig: Listener<DeepReadonly<RemoteConfig>>;
46
94
  /** Public project token used to authenticate endpoint-specific requests. */
47
95
  readonly projectToken: string;
48
- /**
49
- * Sends a request through the host SDK's transport. The extension owns the
50
- * endpoint-specific path, method, authentication shape, body, and headers.
51
- */
96
+ /** Sends a request through the host SDK's transport. */
52
97
  sendRequest(path: string, init?: SendRequestInit): Promise<ApiResponse>;
53
- /**
54
- * Resolves another registered extension by a capability token it provides, or
55
- * `undefined` if nothing registered provides it (not installed, or not loaded
56
- * yet). Lets one extension use another without importing its implementation.
57
- */
58
- getExtension<T>(token: ExtensionToken<T>): T | undefined;
59
98
  /** Awaitable key-value storage backed by the host client's persistence. */
60
99
  readonly kv: KeyValueStore;
61
100
  /** Logger that follows the host client's debug/noise policy. */
package/dist/config.js CHANGED
@@ -26,7 +26,7 @@ __webpack_require__.r(__webpack_exports__);
26
26
  __webpack_require__.d(__webpack_exports__, {
27
27
  default: ()=>__WEBPACK_DEFAULT_EXPORT__
28
28
  });
29
- const packageVersion = "0.2.3";
29
+ const packageVersion = "0.2.5";
30
30
  const Config = {
31
31
  DEBUG: false,
32
32
  LIB_VERSION: packageVersion,
package/dist/config.mjs CHANGED
@@ -1,4 +1,4 @@
1
- const packageVersion = "0.2.3";
1
+ const packageVersion = "0.2.5";
2
2
  const Config = {
3
3
  DEBUG: false,
4
4
  LIB_VERSION: packageVersion,
@@ -1,15 +1,7 @@
1
- /**
2
- * Something with an async-capable teardown. `dispose` may be async so
3
- * teardown can do final work — e.g. a last flush of buffered data — that the
4
- * client can await before it finishes shutting down.
5
- */
1
+ /** A resource handle with idempotent, best-effort cleanup. */
6
2
  export interface Disposable {
7
- /**
8
- * Release resources owned by this object. Implementations should be
9
- * idempotent so callers can safely dispose during both extension teardown
10
- * and client shutdown.
11
- */
12
- dispose(): void | Promise<void>;
3
+ /** Release resources owned by this object. */
4
+ dispose(): void;
13
5
  }
14
- /** Invokes teardown at most once and returns its first result to every caller. */
15
- export declare function createDisposable(dispose: () => void | Promise<void>): Disposable;
6
+ /** Invokes teardown at most once without awaiting Promise results. */
7
+ export declare function createDisposable(dispose: () => void): Disposable;
@@ -26,16 +26,16 @@ __webpack_require__.r(__webpack_exports__);
26
26
  __webpack_require__.d(__webpack_exports__, {
27
27
  createDisposable: ()=>createDisposable
28
28
  });
29
+ const core_namespaceObject = require("@posthog/core");
29
30
  function createDisposable(dispose) {
30
31
  let active = true;
31
- let result;
32
32
  return {
33
33
  dispose: ()=>{
34
34
  if (active) {
35
35
  active = false;
36
- result = dispose();
36
+ const result = dispose();
37
+ if (result && (0, core_namespaceObject.isFunction)(result.then)) result.then(void 0, ()=>{});
37
38
  }
38
- return result;
39
39
  }
40
40
  };
41
41
  }
@@ -1,13 +1,13 @@
1
+ import { isFunction } from "@posthog/core";
1
2
  function createDisposable(dispose) {
2
3
  let active = true;
3
- let result;
4
4
  return {
5
5
  dispose: ()=>{
6
6
  if (active) {
7
7
  active = false;
8
- result = dispose();
8
+ const result = dispose();
9
+ if (result && isFunction(result.then)) result.then(void 0, ()=>{});
9
10
  }
10
- return result;
11
11
  }
12
12
  };
13
13
  }
@@ -2,33 +2,16 @@ import { type Logger } from '@posthog/core';
2
2
  import type { Client } from './client';
3
3
  import type { Disposable } from './disposable';
4
4
  import type { Extension } from './extension';
5
- import type { ExtensionToken } from './token';
6
- /**
7
- * Shared lifecycle and capability registry for browser extension hosts.
8
- *
9
- * Hosts provide the concrete Client adapter while this runtime coordinates
10
- * names, capability readiness, setup failures, and reverse-order teardown.
11
- */
5
+ /** Shared setup and lifecycle registry for browser extension hosts. */
12
6
  export declare class ExtensionRuntime implements Disposable {
13
7
  private readonly _logger;
8
+ private readonly _client;
14
9
  private readonly _extensions;
15
- private readonly _registrationOrder;
16
- private readonly _providerReservations;
17
- private readonly _providers;
18
- private _disposePromise;
19
- constructor(_logger: Logger);
20
- /**
21
- * Sets up an extension and publishes its capabilities once setup succeeds.
22
- * Names and tokens remain reserved while asynchronous setup is pending.
23
- */
24
- add(extension: Extension, client: Client): Promise<void>;
25
- /** Resolves a capability only after its provider has completed setup. */
26
- getExtension<T>(token: ExtensionToken<T>): T | undefined;
27
- /** Disposes every registered extension once, in reverse registration order. */
28
- dispose(): Promise<void>;
29
- private _disposeAll;
30
- private _handleSetupFailure;
31
- private _disposeRegistration;
32
- private _removeRegistration;
33
- private _publishRegistration;
10
+ private _disposed;
11
+ constructor(_logger: Logger, _client: Client);
12
+ /** Reserves an extension name and sets it up with the host client adapter. */
13
+ add(extension: Extension): Promise<void>;
14
+ /** Releases every registered extension once in reverse registration order without waiting for pending setup. */
15
+ dispose(): void;
16
+ private _disposeExtension;
34
17
  }
@@ -28,77 +28,43 @@ __webpack_require__.d(__webpack_exports__, {
28
28
  });
29
29
  const core_namespaceObject = require("@posthog/core");
30
30
  class ExtensionRuntime {
31
- constructor(_logger){
31
+ constructor(_logger, _client){
32
32
  this._logger = _logger;
33
+ this._client = _client;
33
34
  this._extensions = new Map();
34
- this._registrationOrder = [];
35
- this._providerReservations = new Map();
36
- this._providers = new Map();
35
+ this._disposed = false;
37
36
  }
38
- async add(extension, client) {
39
- if (this._disposePromise) throw new Error('Cannot add an extension to a disposed ExtensionRuntime');
37
+ async add(extension) {
38
+ if (this._disposed) throw new Error('Cannot add an extension to a disposed ExtensionRuntime');
40
39
  if (this._extensions.has(extension.name)) throw new Error(`Browser extension "${extension.name}" is already registered`);
41
- for (const token of extension.provides ?? [])if (this._providerReservations.has(token)) throw new Error(`Browser extension token "${token}" is already registered`);
42
- const registered = {
43
- extension,
44
- setupPromise: Promise.resolve()
45
- };
46
- this._extensions.set(extension.name, registered);
47
- this._registrationOrder.push(registered);
48
- for (const token of extension.provides ?? [])this._providerReservations.set(token, registered);
49
- let setupResult;
40
+ this._extensions.set(extension.name, extension);
50
41
  try {
51
- setupResult = extension.setup(client);
42
+ const setup = extension.setup(this._client);
43
+ if (setup) await setup;
52
44
  } catch (error) {
53
- registered.setupPromise = this._handleSetupFailure(registered, error);
54
- return registered.setupPromise;
45
+ const active = this._extensions.get(extension.name) === extension;
46
+ if (active) this._extensions.delete(extension.name);
47
+ this._logger.error(`Failed to set up browser extension "${extension.name}"`, error);
48
+ if (active) this._disposeExtension(extension);
55
49
  }
56
- if (setupResult && (0, core_namespaceObject.isFunction)(setupResult.then)) registered.setupPromise = setupResult.then(()=>this._publishRegistration(registered)).catch((error)=>this._handleSetupFailure(registered, error));
57
- else this._publishRegistration(registered);
58
- return registered.setupPromise;
59
- }
60
- getExtension(token) {
61
- return this._providers.get(token);
62
50
  }
63
51
  dispose() {
64
- if (!this._disposePromise) this._disposePromise = this._disposeAll();
65
- return this._disposePromise;
66
- }
67
- async _disposeAll() {
68
- for (const registered of this._registrationOrder.slice().reverse()){
69
- await registered.setupPromise;
70
- await this._disposeRegistration(registered);
71
- }
52
+ if (this._disposed) return;
53
+ this._disposed = true;
54
+ const extensions = Array.from(this._extensions.values()).reverse();
72
55
  this._extensions.clear();
73
- this._registrationOrder.length = 0;
74
- this._providerReservations.clear();
75
- this._providers.clear();
56
+ for (const extension of extensions)this._disposeExtension(extension);
76
57
  }
77
- async _handleSetupFailure(registered, error) {
78
- this._removeRegistration(registered);
79
- this._logger.error(`Failed to set up browser extension "${registered.extension.name}"`, error);
80
- if (this._disposePromise) return;
81
- await this._disposeRegistration(registered);
82
- const index = this._registrationOrder.indexOf(registered);
83
- if (-1 !== index) this._registrationOrder.splice(index, 1);
84
- }
85
- _disposeRegistration(registered) {
86
- if (!registered.disposalPromise) registered.disposalPromise = Promise.resolve().then(()=>registered.extension.dispose()).catch((error)=>{
87
- this._logger.error(`Failed to dispose browser extension "${registered.extension.name}"`, error);
88
- });
89
- return registered.disposalPromise;
90
- }
91
- _removeRegistration(registered) {
92
- if (this._extensions.get(registered.extension.name) === registered) this._extensions.delete(registered.extension.name);
93
- for (const token of registered.extension.provides ?? []){
94
- if (this._providerReservations.get(token) === registered) this._providerReservations.delete(token);
95
- if (this._providers.get(token) === registered.extension) this._providers.delete(token);
58
+ _disposeExtension(extension) {
59
+ try {
60
+ const result = extension.dispose?.();
61
+ if (result && (0, core_namespaceObject.isFunction)(result.then)) result.then(void 0, (error)=>{
62
+ this._logger.error(`Failed to dispose browser extension "${extension.name}"`, error);
63
+ });
64
+ } catch (error) {
65
+ this._logger.error(`Failed to dispose browser extension "${extension.name}"`, error);
96
66
  }
97
67
  }
98
- _publishRegistration(registered) {
99
- if (this._disposePromise || this._extensions.get(registered.extension.name) !== registered) return;
100
- for (const token of registered.extension.provides ?? [])this._providers.set(token, registered.extension);
101
- }
102
68
  }
103
69
  exports.ExtensionRuntime = __webpack_exports__.ExtensionRuntime;
104
70
  for(var __webpack_i__ in __webpack_exports__)if (-1 === [
@@ -1,75 +1,41 @@
1
1
  import { isFunction } from "@posthog/core";
2
2
  class ExtensionRuntime {
3
- constructor(_logger){
3
+ constructor(_logger, _client){
4
4
  this._logger = _logger;
5
+ this._client = _client;
5
6
  this._extensions = new Map();
6
- this._registrationOrder = [];
7
- this._providerReservations = new Map();
8
- this._providers = new Map();
7
+ this._disposed = false;
9
8
  }
10
- async add(extension, client) {
11
- if (this._disposePromise) throw new Error('Cannot add an extension to a disposed ExtensionRuntime');
9
+ async add(extension) {
10
+ if (this._disposed) throw new Error('Cannot add an extension to a disposed ExtensionRuntime');
12
11
  if (this._extensions.has(extension.name)) throw new Error(`Browser extension "${extension.name}" is already registered`);
13
- for (const token of extension.provides ?? [])if (this._providerReservations.has(token)) throw new Error(`Browser extension token "${token}" is already registered`);
14
- const registered = {
15
- extension,
16
- setupPromise: Promise.resolve()
17
- };
18
- this._extensions.set(extension.name, registered);
19
- this._registrationOrder.push(registered);
20
- for (const token of extension.provides ?? [])this._providerReservations.set(token, registered);
21
- let setupResult;
12
+ this._extensions.set(extension.name, extension);
22
13
  try {
23
- setupResult = extension.setup(client);
14
+ const setup = extension.setup(this._client);
15
+ if (setup) await setup;
24
16
  } catch (error) {
25
- registered.setupPromise = this._handleSetupFailure(registered, error);
26
- return registered.setupPromise;
17
+ const active = this._extensions.get(extension.name) === extension;
18
+ if (active) this._extensions.delete(extension.name);
19
+ this._logger.error(`Failed to set up browser extension "${extension.name}"`, error);
20
+ if (active) this._disposeExtension(extension);
27
21
  }
28
- if (setupResult && isFunction(setupResult.then)) registered.setupPromise = setupResult.then(()=>this._publishRegistration(registered)).catch((error)=>this._handleSetupFailure(registered, error));
29
- else this._publishRegistration(registered);
30
- return registered.setupPromise;
31
- }
32
- getExtension(token) {
33
- return this._providers.get(token);
34
22
  }
35
23
  dispose() {
36
- if (!this._disposePromise) this._disposePromise = this._disposeAll();
37
- return this._disposePromise;
38
- }
39
- async _disposeAll() {
40
- for (const registered of this._registrationOrder.slice().reverse()){
41
- await registered.setupPromise;
42
- await this._disposeRegistration(registered);
43
- }
24
+ if (this._disposed) return;
25
+ this._disposed = true;
26
+ const extensions = Array.from(this._extensions.values()).reverse();
44
27
  this._extensions.clear();
45
- this._registrationOrder.length = 0;
46
- this._providerReservations.clear();
47
- this._providers.clear();
48
- }
49
- async _handleSetupFailure(registered, error) {
50
- this._removeRegistration(registered);
51
- this._logger.error(`Failed to set up browser extension "${registered.extension.name}"`, error);
52
- if (this._disposePromise) return;
53
- await this._disposeRegistration(registered);
54
- const index = this._registrationOrder.indexOf(registered);
55
- if (-1 !== index) this._registrationOrder.splice(index, 1);
28
+ for (const extension of extensions)this._disposeExtension(extension);
56
29
  }
57
- _disposeRegistration(registered) {
58
- if (!registered.disposalPromise) registered.disposalPromise = Promise.resolve().then(()=>registered.extension.dispose()).catch((error)=>{
59
- this._logger.error(`Failed to dispose browser extension "${registered.extension.name}"`, error);
60
- });
61
- return registered.disposalPromise;
62
- }
63
- _removeRegistration(registered) {
64
- if (this._extensions.get(registered.extension.name) === registered) this._extensions.delete(registered.extension.name);
65
- for (const token of registered.extension.provides ?? []){
66
- if (this._providerReservations.get(token) === registered) this._providerReservations.delete(token);
67
- if (this._providers.get(token) === registered.extension) this._providers.delete(token);
30
+ _disposeExtension(extension) {
31
+ try {
32
+ const result = extension.dispose?.();
33
+ if (result && isFunction(result.then)) result.then(void 0, (error)=>{
34
+ this._logger.error(`Failed to dispose browser extension "${extension.name}"`, error);
35
+ });
36
+ } catch (error) {
37
+ this._logger.error(`Failed to dispose browser extension "${extension.name}"`, error);
68
38
  }
69
39
  }
70
- _publishRegistration(registered) {
71
- if (this._disposePromise || this._extensions.get(registered.extension.name) !== registered) return;
72
- for (const token of registered.extension.provides ?? [])this._providers.set(token, registered.extension);
73
- }
74
40
  }
75
41
  export { ExtensionRuntime };
@@ -1,17 +1,13 @@
1
- import type { Disposable } from './disposable';
2
1
  import type { Client } from './client';
3
- import type { ExtensionToken } from './token';
4
2
  /**
5
- * A shared browser extension. The client calls only two things: `setup(client)`
6
- * to start it and `dispose()` (from {@link Disposable}) to stop it. Everything
7
- * an extension consumes flows the other way, through {@link Client}.
3
+ * A shared browser extension. The client calls `setup(client)` once to start it
4
+ * and optional `dispose()` once for final best-effort resource cleanup.
5
+ * Everything an extension consumes flows through {@link Client}.
8
6
  *
9
- * `setup` may be async so an extension can read async-KV state or remote config
10
- * before it is ready; the client awaits it. `name` is used for de-duplication
11
- * and diagnostics. The `Disposable`s an extension creates in `setup`
12
- * enrichers, event listeners, timers are its own to release: hold them and
13
- * dispose them in `dispose`. The host disposes the extension; it does not track
14
- * the extension's individual subscriptions.
7
+ * `setup` may be async so an extension can read async KV state or remote config
8
+ * before it is ready. Async extensions must guard work after each `await` so
9
+ * cleanup cannot be followed by late listener, timer, or patch installation.
10
+ * The disposables an extension creates in setup are its own to release.
15
11
  *
16
12
  * An extension that exposes app-facing controls extends `Extension` with named
17
13
  * methods that share its state, e.g.:
@@ -23,23 +19,15 @@ import type { ExtensionToken } from './token';
23
19
  * isActive(): boolean
24
20
  * }
25
21
  * ```
26
- *
27
- * The client still only calls `setup` and `dispose`; the controls are for the
28
- * application that constructed the extension.
29
22
  */
30
- export interface Extension extends Disposable {
23
+ export interface Extension {
31
24
  /** Stable extension name used for diagnostics and de-duplication within a client instance. */
32
25
  readonly name: string;
33
- /**
34
- * Capability tokens this extension answers to, so others can resolve it via
35
- * `client.getExtension(token)`. The extension must be assignable to each
36
- * token's provided type. Most extensions provide nothing.
37
- */
38
- readonly provides?: readonly ExtensionToken<unknown>[];
39
26
  /**
40
27
  * Start the extension with the host client's capability surface. Called once
41
- * after construction; return a promise when setup needs asynchronous state
42
- * such as persisted data or remote config.
28
+ * after construction; return a promise when setup needs asynchronous state.
43
29
  */
44
30
  setup(client: Client): void | Promise<void>;
31
+ /** Release final resources synchronously. Feature-level start/stop remains extension-owned. */
32
+ dispose?(): void;
45
33
  }
package/dist/index.d.ts CHANGED
@@ -3,12 +3,9 @@
3
3
  * clients.
4
4
  */
5
5
  export type { Extension } from './extension';
6
- export { CoreExtension } from './core-extension';
7
- export type { DeepReadonly, SessionContext, NewSessionReason, NewSessionInfo, CapturedEventInfo, CaptureOptions, } from './core-extension';
8
6
  export * from './types';
9
7
  export { createDisposable, type Disposable } from './disposable';
10
- export type { ExtensionToken } from './token';
11
8
  export type { Listener } from './pubsub';
12
9
  export { Publisher } from './pubsub';
13
- export type { Client, ApiResponse, RequestTarget, RequestTransport, SendRequestInit } from './client';
10
+ export type { Client, DeepReadonly, SessionContext, CapturedEventInfo, CaptureOptions, ApiResponse, RequestTarget, RequestTransport, SendRequestInit, } from './client';
14
11
  export type { KeyValueStore } from './persistence';
package/dist/index.js CHANGED
@@ -1,8 +1,5 @@
1
1
  "use strict";
2
2
  var __webpack_modules__ = {
3
- "./core-extension": function(module) {
4
- module.exports = require("./core-extension.js");
5
- },
6
3
  "./disposable": function(module) {
7
4
  module.exports = require("./disposable.js");
8
5
  },
@@ -57,30 +54,25 @@ var __webpack_exports__ = {};
57
54
  (()=>{
58
55
  __webpack_require__.r(__webpack_exports__);
59
56
  __webpack_require__.d(__webpack_exports__, {
60
- CoreExtension: ()=>_core_extension__WEBPACK_IMPORTED_MODULE_0__.CoreExtension,
61
- Publisher: ()=>_pubsub__WEBPACK_IMPORTED_MODULE_3__.Publisher,
62
- createDisposable: ()=>_disposable__WEBPACK_IMPORTED_MODULE_2__.createDisposable
57
+ Publisher: ()=>_pubsub__WEBPACK_IMPORTED_MODULE_2__.Publisher,
58
+ createDisposable: ()=>_disposable__WEBPACK_IMPORTED_MODULE_1__.createDisposable
63
59
  });
64
- var _core_extension__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./core-extension");
65
- var _types__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./types");
60
+ var _types__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./types");
66
61
  var __WEBPACK_REEXPORT_OBJECT__ = {};
67
- for(var __WEBPACK_IMPORT_KEY__ in _types__WEBPACK_IMPORTED_MODULE_1__)if ([
68
- "createDisposable",
62
+ for(var __WEBPACK_IMPORT_KEY__ in _types__WEBPACK_IMPORTED_MODULE_0__)if ([
63
+ "Publisher",
69
64
  "default",
70
- "CoreExtension",
71
- "Publisher"
65
+ "createDisposable"
72
66
  ].indexOf(__WEBPACK_IMPORT_KEY__) < 0) __WEBPACK_REEXPORT_OBJECT__[__WEBPACK_IMPORT_KEY__] = (function(key) {
73
- return _types__WEBPACK_IMPORTED_MODULE_1__[key];
67
+ return _types__WEBPACK_IMPORTED_MODULE_0__[key];
74
68
  }).bind(0, __WEBPACK_IMPORT_KEY__);
75
69
  __webpack_require__.d(__webpack_exports__, __WEBPACK_REEXPORT_OBJECT__);
76
- var _disposable__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("./disposable");
77
- var _pubsub__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__("./pubsub");
70
+ var _disposable__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__("./disposable");
71
+ var _pubsub__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__("./pubsub");
78
72
  })();
79
- exports.CoreExtension = __webpack_exports__.CoreExtension;
80
73
  exports.Publisher = __webpack_exports__.Publisher;
81
74
  exports.createDisposable = __webpack_exports__.createDisposable;
82
75
  for(var __webpack_i__ in __webpack_exports__)if (-1 === [
83
- "CoreExtension",
84
76
  "Publisher",
85
77
  "createDisposable"
86
78
  ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
package/dist/index.mjs CHANGED
@@ -1,5 +1,4 @@
1
- import { CoreExtension } from "./core-extension.mjs";
2
1
  import { createDisposable } from "./disposable.mjs";
3
2
  import { Publisher } from "./pubsub.mjs";
4
3
  export * from "./types/index.mjs";
5
- export { CoreExtension, Publisher, createDisposable };
4
+ export { Publisher, createDisposable };
@@ -37,15 +37,26 @@ function getNativeImplementation(name, assignableWindow) {
37
37
  let impl = assignableWindow[name];
38
38
  if ((0, core_namespaceObject.isNativeFunction)(impl) && !(0, external_type_utils_js_namespaceObject.isAngularZonePresent)()) return cachedImplementations[name] = impl.bind(assignableWindow);
39
39
  const document = assignableWindow.document;
40
- if (document && (0, core_namespaceObject.isFunction)(document.createElement)) try {
41
- const sandbox = document.createElement('iframe');
42
- sandbox.hidden = true;
43
- document.head.appendChild(sandbox);
44
- const contentWindow = sandbox.contentWindow;
45
- if (contentWindow && contentWindow[name]) impl = contentWindow[name];
46
- document.head.removeChild(sandbox);
47
- } catch (e) {
48
- external_logger_js_namespaceObject.logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e);
40
+ if (document && (0, core_namespaceObject.isFunction)(document.createElement)) {
41
+ let sandbox;
42
+ let keepSandboxAttached = false;
43
+ try {
44
+ sandbox = document.createElement('iframe');
45
+ sandbox.hidden = true;
46
+ document.head.appendChild(sandbox);
47
+ const contentWindow = sandbox.contentWindow;
48
+ if (contentWindow && contentWindow[name]) {
49
+ impl = contentWindow[name];
50
+ if ('MutationObserver' === name && (0, core_namespaceObject.isWebKit)(assignableWindow.navigator?.userAgent ?? '')) {
51
+ sandbox.classList.add('rr-block', 'ph-no-capture');
52
+ keepSandboxAttached = true;
53
+ }
54
+ }
55
+ } catch (e) {
56
+ external_logger_js_namespaceObject.logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e);
57
+ } finally{
58
+ if (!keepSandboxAttached && sandbox?.parentNode) sandbox.parentNode.removeChild(sandbox);
59
+ }
49
60
  }
50
61
  if (!impl || !(0, core_namespaceObject.isFunction)(impl)) return impl;
51
62
  return cachedImplementations[name] = impl.bind(assignableWindow);
@@ -1,4 +1,4 @@
1
- import { isFunction, isNativeFunction } from "@posthog/core";
1
+ import { isFunction, isNativeFunction, isWebKit } from "@posthog/core";
2
2
  import { logger } from "./logger.mjs";
3
3
  import { isAngularZonePresent } from "./type-utils.mjs";
4
4
  const cachedImplementations = {};
@@ -8,15 +8,26 @@ function getNativeImplementation(name, assignableWindow) {
8
8
  let impl = assignableWindow[name];
9
9
  if (isNativeFunction(impl) && !isAngularZonePresent()) return cachedImplementations[name] = impl.bind(assignableWindow);
10
10
  const document = assignableWindow.document;
11
- if (document && isFunction(document.createElement)) try {
12
- const sandbox = document.createElement('iframe');
13
- sandbox.hidden = true;
14
- document.head.appendChild(sandbox);
15
- const contentWindow = sandbox.contentWindow;
16
- if (contentWindow && contentWindow[name]) impl = contentWindow[name];
17
- document.head.removeChild(sandbox);
18
- } catch (e) {
19
- logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e);
11
+ if (document && isFunction(document.createElement)) {
12
+ let sandbox;
13
+ let keepSandboxAttached = false;
14
+ try {
15
+ sandbox = document.createElement('iframe');
16
+ sandbox.hidden = true;
17
+ document.head.appendChild(sandbox);
18
+ const contentWindow = sandbox.contentWindow;
19
+ if (contentWindow && contentWindow[name]) {
20
+ impl = contentWindow[name];
21
+ if ('MutationObserver' === name && isWebKit(assignableWindow.navigator?.userAgent ?? '')) {
22
+ sandbox.classList.add('rr-block', 'ph-no-capture');
23
+ keepSandboxAttached = true;
24
+ }
25
+ }
26
+ } catch (e) {
27
+ logger.warn(`Could not create sandbox iframe for ${name} check, bailing to assignableWindow.${name}: `, e);
28
+ } finally{
29
+ if (!keepSandboxAttached && sandbox?.parentNode) sandbox.parentNode.removeChild(sandbox);
30
+ }
20
31
  }
21
32
  if (!impl || !isFunction(impl)) return impl;
22
33
  return cachedImplementations[name] = impl.bind(assignableWindow);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/browser-common",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Internal shared browser utilities and extension primitives for PostHog Browser SDKs",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -65,13 +65,14 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@posthog/core": "^1.45.1",
69
- "@posthog/types": "^1.398.0"
68
+ "@posthog/core": "^1.45.3",
69
+ "@posthog/types": "^1.399.0"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@rslib/core": "0.10.6",
73
73
  "@types/jest": "^29.5.14",
74
74
  "jest": "29.7.0",
75
+ "jest-environment-jsdom": "^29.7.0",
75
76
  "ts-jest": "29.4.11",
76
77
  "typescript": "5.8.2",
77
78
  "@posthog-tooling/tsconfig-base": "1.1.1"
@@ -1,80 +0,0 @@
1
- import type { JsonRecord, Properties } from '@posthog/types';
2
- import type { Disposable } from './disposable';
3
- import type { Extension } from './extension';
4
- import type { Listener } from './pubsub';
5
- import type { RemoteConfig } from './types/remote-config';
6
- import type { ExtensionToken } from './token';
7
- /** Recursively marks object properties as readonly while preserving callable values. */
8
- export type DeepReadonly<T> = T extends (...args: never[]) => unknown ? T : T extends object ? {
9
- readonly [K in keyof T]: DeepReadonly<T[K]>;
10
- } : T;
11
- /** The current session, stamped on events to tie them to a session and a browser tab. */
12
- export interface SessionContext {
13
- /** The stable session identifier attached to events captured during this session. */
14
- readonly sessionId: string;
15
- /** The logical browser tab/window identifier attached alongside the session id. */
16
- readonly windowId: string;
17
- /** When the session started, as a Unix timestamp in milliseconds. */
18
- readonly sessionStartTimestamp: number;
19
- }
20
- /** Why a new session started (a `reset` also starts a new session). */
21
- export type NewSessionReason = 'initial' | 'reset' | 'idleTimeout' | 'maxLength' | 'crossTabAdoption';
22
- /** Details emitted when the client starts or adopts a new session. */
23
- export interface NewSessionInfo extends SessionContext {
24
- /** The condition that caused this session to begin. */
25
- readonly reason: NewSessionReason;
26
- }
27
- /** A captured event, as observed by `onEvent`. */
28
- export interface CapturedEventInfo {
29
- /** The finalized captured event name. */
30
- readonly event: string;
31
- /** The final event properties after client defaults and dynamic properties are applied. */
32
- readonly properties: DeepReadonly<JsonRecord>;
33
- }
34
- /** Per-call capture overrides, mirroring the client's public capture options. */
35
- export interface CaptureOptions {
36
- /** Override the event timestamp sent to PostHog. */
37
- timestamp?: Date;
38
- /** Override the event UUID used for de-duplication. */
39
- uuid?: string;
40
- /** Person properties to set, emitted as `$set`. */
41
- set?: Record<string, unknown>;
42
- /** Person properties to set if unset, emitted as `$set_once`. */
43
- setOnce?: Record<string, unknown>;
44
- }
45
- /**
46
- * The host SDK's core analytics behavior, exposed as an extension so shared
47
- * extensions can depend on the event pipeline without depending on a concrete
48
- * PostHog client implementation.
49
- */
50
- export interface CoreExtension extends Extension {
51
- /** The id events are currently attributed to. */
52
- readonly distinctId: string;
53
- /** The anonymous device id carried across identify calls. */
54
- readonly anonymousId: string;
55
- /** Active group memberships attached to events as `$groups`. */
56
- readonly groups: DeepReadonly<Record<string, string>>;
57
- /** The current session, created on first read if needed. */
58
- readonly session: SessionContext;
59
- /** Records an analytics event through the client's normal pipeline. */
60
- capture(event: string, properties?: Properties | null, options?: CaptureOptions): Promise<void>;
61
- /**
62
- * Registers a producer of properties merged into every captured event.
63
- * The producer runs inline while the event is built and must be synchronous.
64
- */
65
- registerDynamicEventProperties(producer: () => Record<string, unknown>): Disposable;
66
- /** Fires for every captured event through a deeply readonly view. */
67
- readonly onEvent: Listener<CapturedEventInfo>;
68
- /** Fires when a new session starts, including on reset. */
69
- readonly onNewSession: Listener<NewSessionInfo>;
70
- /**
71
- * Resolves with the current remote config, awaiting the first outcome when
72
- * necessary. A failed outcome resolves to `undefined`; later successful
73
- * changes are published through `onRemoteConfig`.
74
- */
75
- getRemoteConfig(): Promise<DeepReadonly<RemoteConfig> | undefined>;
76
- /** Fires through a deeply readonly view when server-provided config arrives or changes successfully. */
77
- readonly onRemoteConfig: Listener<DeepReadonly<RemoteConfig>>;
78
- }
79
- /** Capability token used to resolve the host SDK's core analytics extension. */
80
- export declare const CoreExtension: ExtensionToken<CoreExtension>;
@@ -1,36 +0,0 @@
1
- "use strict";
2
- var __webpack_require__ = {};
3
- (()=>{
4
- __webpack_require__.d = (exports1, definition)=>{
5
- for(var key in definition)if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports1, key)) Object.defineProperty(exports1, key, {
6
- enumerable: true,
7
- get: definition[key]
8
- });
9
- };
10
- })();
11
- (()=>{
12
- __webpack_require__.o = (obj, prop)=>Object.prototype.hasOwnProperty.call(obj, prop);
13
- })();
14
- (()=>{
15
- __webpack_require__.r = (exports1)=>{
16
- if ('undefined' != typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
17
- value: 'Module'
18
- });
19
- Object.defineProperty(exports1, '__esModule', {
20
- value: true
21
- });
22
- };
23
- })();
24
- var __webpack_exports__ = {};
25
- __webpack_require__.r(__webpack_exports__);
26
- __webpack_require__.d(__webpack_exports__, {
27
- CoreExtension: ()=>CoreExtension
28
- });
29
- const CoreExtension = 'posthog.core';
30
- exports.CoreExtension = __webpack_exports__.CoreExtension;
31
- for(var __webpack_i__ in __webpack_exports__)if (-1 === [
32
- "CoreExtension"
33
- ].indexOf(__webpack_i__)) exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
34
- Object.defineProperty(exports, '__esModule', {
35
- value: true
36
- });
@@ -1,2 +0,0 @@
1
- const CoreExtension = 'posthog.core';
2
- export { CoreExtension };
package/dist/token.d.ts DELETED
@@ -1,22 +0,0 @@
1
- /** Phantom brand carrying the capability type without emitting runtime code. */
2
- declare const extensionTokenType: unique symbol;
3
- /**
4
- * A typed, implementation-free string for resolving an extension that provides
5
- * a capability. Declared as a shared `const` next to the providing extension's
6
- * interface:
7
- *
8
- * ```ts
9
- * export interface FeatureFlagsExtension extends Extension { … }
10
- * export const FeatureFlags = 'posthog.featureFlags' as ExtensionToken<FeatureFlagsExtension>
11
- * ```
12
- *
13
- * A token holds no implementation, so importing one never pulls the provider's
14
- * code into the consumer's bundle. Its generic brand lets `getExtension` infer
15
- * the provided type, while its runtime string value remains stable across
16
- * independently compiled scripts. Token strings must be globally unique and
17
- * stable for the lifetime of the capability contract.
18
- */
19
- export type ExtensionToken<T> = string & {
20
- readonly [extensionTokenType]: T;
21
- };
22
- export {};
package/dist/token.js DELETED
@@ -1,18 +0,0 @@
1
- "use strict";
2
- var __webpack_require__ = {};
3
- (()=>{
4
- __webpack_require__.r = (exports1)=>{
5
- if ('undefined' != typeof Symbol && Symbol.toStringTag) Object.defineProperty(exports1, Symbol.toStringTag, {
6
- value: 'Module'
7
- });
8
- Object.defineProperty(exports1, '__esModule', {
9
- value: true
10
- });
11
- };
12
- })();
13
- var __webpack_exports__ = {};
14
- __webpack_require__.r(__webpack_exports__);
15
- for(var __webpack_i__ in __webpack_exports__)exports[__webpack_i__] = __webpack_exports__[__webpack_i__];
16
- Object.defineProperty(exports, '__esModule', {
17
- value: true
18
- });
package/dist/token.mjs DELETED
File without changes