@pixelume/sdk 0.5.0 → 0.6.0

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.
@@ -0,0 +1,51 @@
1
+ import type { AnalyticsProperties } from './events.js';
2
+ /** One validated, authorized event, ready for a transport. */
3
+ export interface AnalyticsEvent {
4
+ readonly name: string;
5
+ readonly properties: AnalyticsProperties;
6
+ /** Local time (ms since epoch) when the game reported it. */
7
+ readonly timestamp: number;
8
+ /** Waited in the module's queue while the provider loaded: deliver with `timestamp`. */
9
+ readonly delayed?: true;
10
+ }
11
+ /**
12
+ * Identity the provider should use:
13
+ * - `player`: a signed-in Pixelume account (internal, non-sensitive id);
14
+ * - `null` with `separate: true`: the account is gone (logout, expiry): start a new anonymous id;
15
+ * - `null` with `separate: false`: a visitor; keep whatever anonymous id exists.
16
+ */
17
+ export interface AnalyticsIdentity {
18
+ readonly player: string | null;
19
+ readonly separate: boolean;
20
+ }
21
+ /**
22
+ * Transport seam behind `pixelume.analytics`. The module validates events, applies consent and
23
+ * owns game sessions; a provider only delivers. Providers must never throw synchronously into the
24
+ * game: the module still guards every call. Internal: not part of the public API.
25
+ */
26
+ export interface AnalyticsProvider {
27
+ readonly name: 'posthog' | 'none';
28
+ /** Prepares the transport. Called only after consent. Rejects when it cannot run here. */
29
+ load(): Promise<void>;
30
+ /** Consent granted (again): allow capture and persistence. */
31
+ optIn(): void;
32
+ /**
33
+ * Consent revoked: stop capturing, discard anything not yet sent and forget the analytics
34
+ * identifiers stored in the browser.
35
+ */
36
+ optOut(): void;
37
+ capture(event: AnalyticsEvent): void;
38
+ identify(identity: AnalyticsIdentity): void;
39
+ /** The owning client was destroyed: stop forwarding. Shared resources may stay for reuse. */
40
+ detach(): void;
41
+ }
42
+ /** Used when analytics is disabled or not configured: does nothing, loads nothing. */
43
+ export declare class NoopAnalyticsProvider implements AnalyticsProvider {
44
+ readonly name = "none";
45
+ load(): Promise<void>;
46
+ optIn(): void;
47
+ optOut(): void;
48
+ capture(): void;
49
+ identify(): void;
50
+ detach(): void;
51
+ }
@@ -0,0 +1,23 @@
1
+ /** Used when analytics is disabled or not configured: does nothing, loads nothing. */
2
+ export class NoopAnalyticsProvider {
3
+ name = 'none';
4
+ load() {
5
+ return Promise.resolve();
6
+ }
7
+ optIn() {
8
+ // Nothing to enable.
9
+ }
10
+ optOut() {
11
+ // Nothing stored.
12
+ }
13
+ capture() {
14
+ // Dropped by design.
15
+ }
16
+ identify() {
17
+ // No identity to keep.
18
+ }
19
+ detach() {
20
+ // Nothing to release.
21
+ }
22
+ }
23
+ //# sourceMappingURL=AnalyticsProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"AnalyticsProvider.js","sourceRoot":"","sources":["../../../src/modules/analytics/AnalyticsProvider.ts"],"names":[],"mappings":"AA6CA,sFAAsF;AACtF,MAAM,OAAO,qBAAqB;IACvB,IAAI,GAAG,MAAM,CAAC;IAEvB,IAAI;QACF,OAAO,OAAO,CAAC,OAAO,EAAE,CAAC;IAC3B,CAAC;IAED,KAAK;QACH,qBAAqB;IACvB,CAAC;IAED,MAAM;QACJ,kBAAkB;IACpB,CAAC;IAED,OAAO;QACL,qBAAqB;IACvB,CAAC;IAED,QAAQ;QACN,uBAAuB;IACzB,CAAC;IAED,MAAM;QACJ,sBAAsB;IACxB,CAAC;CACF","sourcesContent":["import type { AnalyticsProperties } from './events.js';\n\n/** One validated, authorized event, ready for a transport. */\nexport interface AnalyticsEvent {\n readonly name: string;\n readonly properties: AnalyticsProperties;\n /** Local time (ms since epoch) when the game reported it. */\n readonly timestamp: number;\n /** Waited in the module's queue while the provider loaded: deliver with `timestamp`. */\n readonly delayed?: true;\n}\n\n/**\n * Identity the provider should use:\n * - `player`: a signed-in Pixelume account (internal, non-sensitive id);\n * - `null` with `separate: true`: the account is gone (logout, expiry): start a new anonymous id;\n * - `null` with `separate: false`: a visitor; keep whatever anonymous id exists.\n */\nexport interface AnalyticsIdentity {\n readonly player: string | null;\n readonly separate: boolean;\n}\n\n/**\n * Transport seam behind `pixelume.analytics`. The module validates events, applies consent and\n * owns game sessions; a provider only delivers. Providers must never throw synchronously into the\n * game: the module still guards every call. Internal: not part of the public API.\n */\nexport interface AnalyticsProvider {\n readonly name: 'posthog' | 'none';\n /** Prepares the transport. Called only after consent. Rejects when it cannot run here. */\n load(): Promise<void>;\n /** Consent granted (again): allow capture and persistence. */\n optIn(): void;\n /**\n * Consent revoked: stop capturing, discard anything not yet sent and forget the analytics\n * identifiers stored in the browser.\n */\n optOut(): void;\n capture(event: AnalyticsEvent): void;\n identify(identity: AnalyticsIdentity): void;\n /** The owning client was destroyed: stop forwarding. Shared resources may stay for reuse. */\n detach(): void;\n}\n\n/** Used when analytics is disabled or not configured: does nothing, loads nothing. */\nexport class NoopAnalyticsProvider implements AnalyticsProvider {\n readonly name = 'none';\n\n load(): Promise<void> {\n return Promise.resolve();\n }\n\n optIn(): void {\n // Nothing to enable.\n }\n\n optOut(): void {\n // Nothing stored.\n }\n\n capture(): void {\n // Dropped by design.\n }\n\n identify(): void {\n // No identity to keep.\n }\n\n detach(): void {\n // Nothing to release.\n }\n}\n"]}
@@ -0,0 +1,39 @@
1
+ import type { PostHogConfig } from 'posthog-js/dist/module.slim.no-external.js';
2
+ import type { AnalyticsEvent, AnalyticsIdentity, AnalyticsProvider } from './AnalyticsProvider.js';
3
+ /** The part of the `posthog-js` instance API the provider relies on. */
4
+ export interface PostHogInstanceLike {
5
+ init(token: string, config: Partial<PostHogConfig>): unknown;
6
+ capture(event: string, properties?: Record<string, unknown> | null, options?: {
7
+ readonly timestamp?: Date;
8
+ }): unknown;
9
+ identify(distinctId: string): void;
10
+ reset(resetDeviceId?: boolean): void;
11
+ opt_in_capturing(options?: {
12
+ readonly captureEventName?: false | null;
13
+ }): void;
14
+ opt_out_capturing(): void;
15
+ has_opted_out_capturing(): boolean;
16
+ get_distinct_id(): string;
17
+ get_property(key: string): unknown;
18
+ }
19
+ export interface PostHogModuleLike {
20
+ readonly PostHog: new () => PostHogInstanceLike;
21
+ }
22
+ export interface PostHogProviderOptions {
23
+ readonly token: string;
24
+ readonly host: string;
25
+ readonly gameId: string;
26
+ /** Test seam; defaults to a dynamic import of the slim, self-contained posthog-js bundle. */
27
+ readonly loadModule?: () => Promise<PostHogModuleLike>;
28
+ }
29
+ export declare class PostHogAnalyticsProvider implements AnalyticsProvider {
30
+ #private;
31
+ readonly name = "posthog";
32
+ constructor(options: PostHogProviderOptions);
33
+ load(): Promise<void>;
34
+ optIn(): void;
35
+ optOut(): void;
36
+ capture(event: AnalyticsEvent): void;
37
+ identify(identity: AnalyticsIdentity): void;
38
+ detach(): void;
39
+ }
@@ -0,0 +1,255 @@
1
+ /**
2
+ * Slim build without extensions (no autocapture, replay, surveys, toolbar...) and without
3
+ * `loadExternalDependency`: it never injects `<script>` tags. The dynamic import keeps posthog-js
4
+ * out of the game's main bundle and out of Node/SSR: it only runs after consent, in a browser.
5
+ */
6
+ // Typed against the real posthog-js declarations: an upgrade that changes a method we rely on
7
+ // fails the type check here.
8
+ const defaultLoadModule = () => import('posthog-js/dist/module.slim.no-external.js');
9
+ /** Provider events that may leave the browser; every other `$event` is dropped. */
10
+ const ALLOWED_PROVIDER_EVENTS = new Set(['$identify', '$set']);
11
+ /** Properties removed from every outgoing event. */
12
+ const DROPPED_PROVIDER_PROPERTIES = ['$raw_user_agent'];
13
+ /**
14
+ * posthog-js has no teardown: every initialized instance keeps its page listeners for the life of
15
+ * the page. Instances are therefore created once per (host, token, game) and reused by later
16
+ * clients (SPA remounts, hot reload) instead of being initialized again.
17
+ */
18
+ const sharedInstances = new Map();
19
+ export class PostHogAnalyticsProvider {
20
+ name = 'posthog';
21
+ #options;
22
+ #shared = null;
23
+ #instance = null;
24
+ #attached = true;
25
+ constructor(options) {
26
+ this.#options = options;
27
+ }
28
+ async load() {
29
+ if (this.#instance !== null)
30
+ return;
31
+ if (!hasBrowserGlobals())
32
+ throw new Error('PostHog analytics needs a browser environment');
33
+ const shared = this.#sharedInstance();
34
+ this.#shared = shared;
35
+ const instance = await shared.ready;
36
+ if (this.#attached)
37
+ this.#instance = instance;
38
+ }
39
+ optIn() {
40
+ const instance = this.#instance;
41
+ if (instance === null || this.#shared === null)
42
+ return;
43
+ this.#shared.gate.open = true;
44
+ // Undo a previous revocation; never capture posthog's own `$opt_in` event.
45
+ if (instance.has_opted_out_capturing())
46
+ instance.opt_in_capturing({ captureEventName: false });
47
+ }
48
+ optOut() {
49
+ // Also reached before `load()`: another client of the same game may have opened the instance.
50
+ const shared = this.#shared ?? sharedInstances.get(this.#key()) ?? null;
51
+ if (shared === null)
52
+ return;
53
+ shared.gate.open = false;
54
+ const instance = shared.instance;
55
+ if (instance === null)
56
+ return;
57
+ discardPendingRequests(instance);
58
+ // `reset()` clears posthog's consent state, so it must come first; then opting out disables
59
+ // persistence and removes what posthog stored (`opt_out_persistence_by_default`).
60
+ instance.reset(true);
61
+ instance.opt_out_capturing();
62
+ // Disabling persistence leaves the tab's window id in sessionStorage; remove it as well.
63
+ removeSessionKeys(`ph_pixelume_${this.#options.gameId}`);
64
+ }
65
+ capture(event) {
66
+ const instance = this.#instance;
67
+ if (instance === null || !this.#attached || this.#shared?.gate.open !== true)
68
+ return;
69
+ // Any options object makes posthog-js skip its batch and send at once, so live events go
70
+ // without one (posthog stamps them); only events that waited for the load keep their time.
71
+ if (event.delayed === true) {
72
+ instance.capture(event.name, { ...event.properties }, { timestamp: new Date(event.timestamp) });
73
+ }
74
+ else {
75
+ instance.capture(event.name, { ...event.properties });
76
+ }
77
+ }
78
+ identify(identity) {
79
+ const instance = this.#instance;
80
+ if (instance === null || !this.#attached || this.#shared?.gate.open !== true)
81
+ return;
82
+ const identified = instance.get_property('$user_state') === 'identified';
83
+ if (identity.player === null) {
84
+ if (identity.separate && identified)
85
+ instance.reset();
86
+ return;
87
+ }
88
+ if (identified && instance.get_distinct_id() === identity.player)
89
+ return;
90
+ // Another account was identified on this browser: never merge two players.
91
+ if (identified)
92
+ instance.reset();
93
+ // From an anonymous id, `identify` links the visitor's earlier events to the player.
94
+ instance.identify(identity.player);
95
+ }
96
+ detach() {
97
+ this.#attached = false;
98
+ this.#instance = null;
99
+ }
100
+ #key() {
101
+ const { token, host, gameId } = this.#options;
102
+ return `${host}\n${token}\n${gameId}`;
103
+ }
104
+ #sharedInstance() {
105
+ const { token, host, gameId } = this.#options;
106
+ const key = this.#key();
107
+ const existing = sharedInstances.get(key);
108
+ if (existing !== undefined)
109
+ return existing;
110
+ const gate = { open: true };
111
+ const loadModule = this.#options.loadModule ?? defaultLoadModule;
112
+ const ready = loadModule().then((module) => {
113
+ const instance = new module.PostHog();
114
+ instance.init(token, posthogConfig(host, gameId, gate));
115
+ shared.instance = instance;
116
+ return instance;
117
+ });
118
+ const shared = { ready, instance: null, gate };
119
+ sharedInstances.set(key, shared);
120
+ // A failed chunk download (offline, CSP) must not poison later attempts.
121
+ ready.catch(() => {
122
+ if (sharedInstances.get(key) === shared)
123
+ sharedInstances.delete(key);
124
+ });
125
+ return shared;
126
+ }
127
+ }
128
+ /**
129
+ * Everything implicit is off: only events sent through `pixelume.analytics` (and `$identify`)
130
+ * reach PostHog. No remote config or flags request, no external scripts, no cookies.
131
+ */
132
+ function posthogConfig(host, gameId, gate) {
133
+ return {
134
+ api_host: host,
135
+ // Separate storage from any other PostHog installation on the same site.
136
+ persistence: 'localStorage',
137
+ persistence_name: `pixelume_${gameId}`,
138
+ consent_persistence_name: `__ph_opt_in_out_pixelume_${gameId}`,
139
+ opt_out_capturing_persistence_type: 'localStorage',
140
+ opt_out_persistence_by_default: true,
141
+ cross_subdomain_cookie: false,
142
+ person_profiles: 'identified_only',
143
+ autocapture: false,
144
+ rageclick: false,
145
+ capture_pageview: false,
146
+ capture_pageleave: false,
147
+ capture_exceptions: false,
148
+ capture_performance: false,
149
+ capture_heatmaps: false,
150
+ enable_heatmaps: false,
151
+ capture_dead_clicks: false,
152
+ disable_scroll_properties: true,
153
+ disable_session_recording: true,
154
+ disable_surveys: true,
155
+ disable_surveys_automatic_display: true,
156
+ disable_product_tours: true,
157
+ disable_conversations: true,
158
+ disable_web_experiments: true,
159
+ disable_external_dependency_loading: true,
160
+ advanced_disable_flags: true,
161
+ advanced_disable_decide: true,
162
+ advanced_disable_feature_flags: true,
163
+ advanced_disable_feature_flags_on_first_load: true,
164
+ remote_config_refresh_interval_ms: 0,
165
+ save_referrer: false,
166
+ save_campaign_params: false,
167
+ mask_personal_data_properties: true,
168
+ debug: false,
169
+ before_send: (event) => {
170
+ if (event === null || !gate.open)
171
+ return null;
172
+ if (event.event.startsWith('$') && !ALLOWED_PROVIDER_EVENTS.has(event.event))
173
+ return null;
174
+ try {
175
+ const properties = event.properties;
176
+ scrub(properties);
177
+ scrub(event.$set);
178
+ scrub(event.$set_once);
179
+ if (typeof properties === 'object' && properties !== null) {
180
+ scrub(properties.$set);
181
+ scrub(properties.$set_once);
182
+ }
183
+ return event;
184
+ }
185
+ catch {
186
+ // Fail closed: an event that cannot be cleaned is not sent.
187
+ return null;
188
+ }
189
+ },
190
+ };
191
+ }
192
+ /** Strips query strings and fragments (which may carry codes or personal data) from URLs. */
193
+ function scrub(value) {
194
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
195
+ return;
196
+ const properties = value;
197
+ for (const key of DROPPED_PROVIDER_PROPERTIES)
198
+ Reflect.deleteProperty(properties, key);
199
+ for (const [key, value] of Object.entries(properties)) {
200
+ if (!key.startsWith('$') || typeof value !== 'string' || !/^https?:\/\//iu.test(value)) {
201
+ continue;
202
+ }
203
+ try {
204
+ const url = new URL(value);
205
+ properties[key] = `${url.origin}${url.pathname}`;
206
+ }
207
+ catch {
208
+ Reflect.deleteProperty(properties, key);
209
+ }
210
+ }
211
+ }
212
+ /**
213
+ * `opt_out_capturing()` stops new captures but does not empty posthog's batch and retry queues,
214
+ * which would still be sent (seen with 1.435.8). Revocation must not leak them, so they are cleared
215
+ * here. The fields are internal to posthog-js: the version is pinned and a test runs this path
216
+ * against the real library.
217
+ */
218
+ function discardPendingRequests(instance) {
219
+ for (const name of ['_requestQueue', '_retryQueue']) {
220
+ try {
221
+ const queue = instance[name];
222
+ if (queue !== undefined && Array.isArray(queue._queue))
223
+ queue._queue = [];
224
+ }
225
+ catch {
226
+ // Best effort; the gate in `before_send` already blocks new events.
227
+ }
228
+ }
229
+ }
230
+ /**
231
+ * Removes `prefix` and `prefix_*`. Game ids never contain `_`, so another game whose id merely
232
+ * starts with this one (`rail` / `rail-puzzle`) keeps its keys.
233
+ */
234
+ function removeSessionKeys(prefix) {
235
+ try {
236
+ const storage = globalThis.sessionStorage;
237
+ const keys = [];
238
+ for (let index = 0; index < storage.length; index += 1) {
239
+ const key = storage.key(index);
240
+ if (key !== null && (key === prefix || key.startsWith(`${prefix}_`)))
241
+ keys.push(key);
242
+ }
243
+ for (const key of keys)
244
+ storage.removeItem(key);
245
+ }
246
+ catch {
247
+ // Blocked storage: nothing was written there either.
248
+ }
249
+ }
250
+ function hasBrowserGlobals() {
251
+ return (typeof globalThis.window === 'object' &&
252
+ typeof globalThis.document === 'object' &&
253
+ typeof globalThis.navigator === 'object');
254
+ }
255
+ //# sourceMappingURL=PostHogAnalyticsProvider.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PostHogAnalyticsProvider.js","sourceRoot":"","sources":["../../../src/modules/analytics/PostHogAnalyticsProvider.ts"],"names":[],"mappings":"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","sourcesContent":["import type { CaptureResult, PostHogConfig } from 'posthog-js/dist/module.slim.no-external.js';\nimport type { AnalyticsEvent, AnalyticsIdentity, AnalyticsProvider } from './AnalyticsProvider.js';\n\n/** The part of the `posthog-js` instance API the provider relies on. */\nexport interface PostHogInstanceLike {\n init(token: string, config: Partial<PostHogConfig>): unknown;\n capture(\n event: string,\n properties?: Record<string, unknown> | null,\n options?: { readonly timestamp?: Date },\n ): unknown;\n identify(distinctId: string): void;\n reset(resetDeviceId?: boolean): void;\n opt_in_capturing(options?: { readonly captureEventName?: false | null }): void;\n opt_out_capturing(): void;\n has_opted_out_capturing(): boolean;\n get_distinct_id(): string;\n get_property(key: string): unknown;\n}\n\nexport interface PostHogModuleLike {\n readonly PostHog: new () => PostHogInstanceLike;\n}\n\nexport interface PostHogProviderOptions {\n readonly token: string;\n readonly host: string;\n readonly gameId: string;\n /** Test seam; defaults to a dynamic import of the slim, self-contained posthog-js bundle. */\n readonly loadModule?: () => Promise<PostHogModuleLike>;\n}\n\n/**\n * Slim build without extensions (no autocapture, replay, surveys, toolbar...) and without\n * `loadExternalDependency`: it never injects `<script>` tags. The dynamic import keeps posthog-js\n * out of the game's main bundle and out of Node/SSR: it only runs after consent, in a browser.\n */\n// Typed against the real posthog-js declarations: an upgrade that changes a method we rely on\n// fails the type check here.\nconst defaultLoadModule = (): Promise<PostHogModuleLike> =>\n import('posthog-js/dist/module.slim.no-external.js');\n\n/** Provider events that may leave the browser; every other `$event` is dropped. */\nconst ALLOWED_PROVIDER_EVENTS: ReadonlySet<string> = new Set(['$identify', '$set']);\n/** Properties removed from every outgoing event. */\nconst DROPPED_PROVIDER_PROPERTIES = ['$raw_user_agent'] as const;\n\ninterface SharedInstance {\n readonly ready: Promise<PostHogInstanceLike>;\n /** Set once `ready` resolves. */\n instance: PostHogInstanceLike | null;\n /** Shared by every provider of this instance; closed while consent is not granted. */\n readonly gate: { open: boolean };\n}\n\n/**\n * posthog-js has no teardown: every initialized instance keeps its page listeners for the life of\n * the page. Instances are therefore created once per (host, token, game) and reused by later\n * clients (SPA remounts, hot reload) instead of being initialized again.\n */\nconst sharedInstances = new Map<string, SharedInstance>();\n\nexport class PostHogAnalyticsProvider implements AnalyticsProvider {\n readonly name = 'posthog';\n readonly #options: PostHogProviderOptions;\n #shared: SharedInstance | null = null;\n #instance: PostHogInstanceLike | null = null;\n #attached = true;\n\n constructor(options: PostHogProviderOptions) {\n this.#options = options;\n }\n\n async load(): Promise<void> {\n if (this.#instance !== null) return;\n if (!hasBrowserGlobals()) throw new Error('PostHog analytics needs a browser environment');\n const shared = this.#sharedInstance();\n this.#shared = shared;\n const instance = await shared.ready;\n if (this.#attached) this.#instance = instance;\n }\n\n optIn(): void {\n const instance = this.#instance;\n if (instance === null || this.#shared === null) return;\n this.#shared.gate.open = true;\n // Undo a previous revocation; never capture posthog's own `$opt_in` event.\n if (instance.has_opted_out_capturing()) instance.opt_in_capturing({ captureEventName: false });\n }\n\n optOut(): void {\n // Also reached before `load()`: another client of the same game may have opened the instance.\n const shared = this.#shared ?? sharedInstances.get(this.#key()) ?? null;\n if (shared === null) return;\n shared.gate.open = false;\n const instance = shared.instance;\n if (instance === null) return;\n discardPendingRequests(instance);\n // `reset()` clears posthog's consent state, so it must come first; then opting out disables\n // persistence and removes what posthog stored (`opt_out_persistence_by_default`).\n instance.reset(true);\n instance.opt_out_capturing();\n // Disabling persistence leaves the tab's window id in sessionStorage; remove it as well.\n removeSessionKeys(`ph_pixelume_${this.#options.gameId}`);\n }\n\n capture(event: AnalyticsEvent): void {\n const instance = this.#instance;\n if (instance === null || !this.#attached || this.#shared?.gate.open !== true) return;\n // Any options object makes posthog-js skip its batch and send at once, so live events go\n // without one (posthog stamps them); only events that waited for the load keep their time.\n if (event.delayed === true) {\n instance.capture(\n event.name,\n { ...event.properties },\n { timestamp: new Date(event.timestamp) },\n );\n } else {\n instance.capture(event.name, { ...event.properties });\n }\n }\n\n identify(identity: AnalyticsIdentity): void {\n const instance = this.#instance;\n if (instance === null || !this.#attached || this.#shared?.gate.open !== true) return;\n const identified = instance.get_property('$user_state') === 'identified';\n if (identity.player === null) {\n if (identity.separate && identified) instance.reset();\n return;\n }\n if (identified && instance.get_distinct_id() === identity.player) return;\n // Another account was identified on this browser: never merge two players.\n if (identified) instance.reset();\n // From an anonymous id, `identify` links the visitor's earlier events to the player.\n instance.identify(identity.player);\n }\n\n detach(): void {\n this.#attached = false;\n this.#instance = null;\n }\n\n #key(): string {\n const { token, host, gameId } = this.#options;\n return `${host}\\n${token}\\n${gameId}`;\n }\n\n #sharedInstance(): SharedInstance {\n const { token, host, gameId } = this.#options;\n const key = this.#key();\n const existing = sharedInstances.get(key);\n if (existing !== undefined) return existing;\n const gate = { open: true };\n const loadModule = this.#options.loadModule ?? defaultLoadModule;\n const ready = loadModule().then((module) => {\n const instance = new module.PostHog();\n instance.init(token, posthogConfig(host, gameId, gate));\n shared.instance = instance;\n return instance;\n });\n const shared: SharedInstance = { ready, instance: null, gate };\n sharedInstances.set(key, shared);\n // A failed chunk download (offline, CSP) must not poison later attempts.\n ready.catch(() => {\n if (sharedInstances.get(key) === shared) sharedInstances.delete(key);\n });\n return shared;\n }\n}\n\n/**\n * Everything implicit is off: only events sent through `pixelume.analytics` (and `$identify`)\n * reach PostHog. No remote config or flags request, no external scripts, no cookies.\n */\nfunction posthogConfig(\n host: string,\n gameId: string,\n gate: { readonly open: boolean },\n): Partial<PostHogConfig> {\n return {\n api_host: host,\n // Separate storage from any other PostHog installation on the same site.\n persistence: 'localStorage',\n persistence_name: `pixelume_${gameId}`,\n consent_persistence_name: `__ph_opt_in_out_pixelume_${gameId}`,\n opt_out_capturing_persistence_type: 'localStorage',\n opt_out_persistence_by_default: true,\n cross_subdomain_cookie: false,\n person_profiles: 'identified_only',\n autocapture: false,\n rageclick: false,\n capture_pageview: false,\n capture_pageleave: false,\n capture_exceptions: false,\n capture_performance: false,\n capture_heatmaps: false,\n enable_heatmaps: false,\n capture_dead_clicks: false,\n disable_scroll_properties: true,\n disable_session_recording: true,\n disable_surveys: true,\n disable_surveys_automatic_display: true,\n disable_product_tours: true,\n disable_conversations: true,\n disable_web_experiments: true,\n disable_external_dependency_loading: true,\n advanced_disable_flags: true,\n advanced_disable_decide: true,\n advanced_disable_feature_flags: true,\n advanced_disable_feature_flags_on_first_load: true,\n remote_config_refresh_interval_ms: 0,\n save_referrer: false,\n save_campaign_params: false,\n mask_personal_data_properties: true,\n debug: false,\n before_send: (event: CaptureResult | null): CaptureResult | null => {\n if (event === null || !gate.open) return null;\n if (event.event.startsWith('$') && !ALLOWED_PROVIDER_EVENTS.has(event.event)) return null;\n try {\n const properties: unknown = event.properties;\n scrub(properties);\n scrub(event.$set);\n scrub(event.$set_once);\n if (typeof properties === 'object' && properties !== null) {\n scrub((properties as Record<string, unknown>).$set);\n scrub((properties as Record<string, unknown>).$set_once);\n }\n return event;\n } catch {\n // Fail closed: an event that cannot be cleaned is not sent.\n return null;\n }\n },\n };\n}\n\n/** Strips query strings and fragments (which may carry codes or personal data) from URLs. */\nfunction scrub(value: unknown): void {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return;\n const properties = value as Record<string, unknown>;\n for (const key of DROPPED_PROVIDER_PROPERTIES) Reflect.deleteProperty(properties, key);\n for (const [key, value] of Object.entries(properties)) {\n if (!key.startsWith('$') || typeof value !== 'string' || !/^https?:\\/\\//iu.test(value)) {\n continue;\n }\n try {\n const url = new URL(value);\n properties[key] = `${url.origin}${url.pathname}`;\n } catch {\n Reflect.deleteProperty(properties, key);\n }\n }\n}\n\n/**\n * `opt_out_capturing()` stops new captures but does not empty posthog's batch and retry queues,\n * which would still be sent (seen with 1.435.8). Revocation must not leak them, so they are cleared\n * here. The fields are internal to posthog-js: the version is pinned and a test runs this path\n * against the real library.\n */\nfunction discardPendingRequests(instance: PostHogInstanceLike): void {\n for (const name of ['_requestQueue', '_retryQueue']) {\n try {\n const queue = (instance as unknown as Record<string, { _queue?: unknown } | undefined>)[name];\n if (queue !== undefined && Array.isArray(queue._queue)) queue._queue = [];\n } catch {\n // Best effort; the gate in `before_send` already blocks new events.\n }\n }\n}\n\n/**\n * Removes `prefix` and `prefix_*`. Game ids never contain `_`, so another game whose id merely\n * starts with this one (`rail` / `rail-puzzle`) keeps its keys.\n */\nfunction removeSessionKeys(prefix: string): void {\n try {\n const storage = globalThis.sessionStorage;\n const keys: string[] = [];\n for (let index = 0; index < storage.length; index += 1) {\n const key = storage.key(index);\n if (key !== null && (key === prefix || key.startsWith(`${prefix}_`))) keys.push(key);\n }\n for (const key of keys) storage.removeItem(key);\n } catch {\n // Blocked storage: nothing was written there either.\n }\n}\n\nfunction hasBrowserGlobals(): boolean {\n return (\n typeof globalThis.window === 'object' &&\n typeof globalThis.document === 'object' &&\n typeof globalThis.navigator === 'object'\n );\n}\n"]}
@@ -0,0 +1,30 @@
1
+ import type { PixelumeAnalyticsIssue } from '../../core/types.js';
2
+ /**
3
+ * Stable event catalog. Names are part of the public contract: dashboards are built on them, so
4
+ * they are only ever added, never renamed.
5
+ */
6
+ export declare const ANALYTICS_EVENTS: Readonly<{
7
+ readonly sessionStarted: "game_session_started";
8
+ readonly sessionEnded: "game_session_ended";
9
+ readonly levelStarted: "level_started";
10
+ readonly levelCompleted: "level_completed";
11
+ readonly levelFailed: "level_failed";
12
+ }>;
13
+ export declare const MAX_PROPERTIES = 25;
14
+ export declare const MAX_STRING_LENGTH = 200;
15
+ /** Metadata added by the SDK to every event; games cannot override it. */
16
+ export declare const METADATA_PROPERTIES: ReadonlySet<string>;
17
+ export type AnalyticsPropertyValue = string | number | boolean | null;
18
+ export type AnalyticsProperties = Record<string, AnalyticsPropertyValue>;
19
+ export type IssueReporter = (code: PixelumeAnalyticsIssue['code'], event: string | null, property?: string | null) => void;
20
+ export declare function isValidEventName(name: unknown): name is string;
21
+ export declare function isCatalogEvent(name: string): boolean;
22
+ /** A level is a finite number or a 1–64 character string; always sent as a string. */
23
+ export declare function normalizeLevel(level: unknown): string | null;
24
+ export declare function normalizeReason(reason: unknown): string | null;
25
+ /**
26
+ * Keeps only flat, small, non-sensitive values supplied by the game: snake_case keys, strings up
27
+ * to 200 characters, finite numbers, booleans and `null`; at most 25 keys. `undefined` is removed
28
+ * silently; anything else is dropped and reported. `protectedKeys` are set by the SDK itself.
29
+ */
30
+ export declare function sanitizeProperties(event: string, input: unknown, protectedKeys: ReadonlySet<string>, report: IssueReporter): AnalyticsProperties;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Stable event catalog. Names are part of the public contract: dashboards are built on them, so
3
+ * they are only ever added, never renamed.
4
+ */
5
+ export const ANALYTICS_EVENTS = Object.freeze({
6
+ sessionStarted: 'game_session_started',
7
+ sessionEnded: 'game_session_ended',
8
+ levelStarted: 'level_started',
9
+ levelCompleted: 'level_completed',
10
+ levelFailed: 'level_failed',
11
+ });
12
+ const CATALOG = new Set(Object.values(ANALYTICS_EVENTS));
13
+ /** snake_case, starts with a letter, 1–64 characters. Excludes provider names such as `$pageview`. */
14
+ const NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u;
15
+ export const MAX_PROPERTIES = 25;
16
+ export const MAX_STRING_LENGTH = 200;
17
+ const MAX_LEVEL_LENGTH = 64;
18
+ /** Metadata added by the SDK to every event; games cannot override it. */
19
+ export const METADATA_PROPERTIES = new Set([
20
+ 'game_id',
21
+ 'game_version',
22
+ 'sdk_version',
23
+ 'session_id',
24
+ 'platform',
25
+ ]);
26
+ /** Keys the provider treats specially. */
27
+ const PROVIDER_PROPERTIES = new Set([
28
+ 'distinct_id',
29
+ 'token',
30
+ 'timestamp',
31
+ 'uuid',
32
+ 'event',
33
+ ]);
34
+ /**
35
+ * Best-effort guard against personal data and credentials. It complements, and does not replace,
36
+ * the rule that games never send them.
37
+ */
38
+ const SENSITIVE_KEYS = new Set([
39
+ 'email',
40
+ 'e_mail',
41
+ 'password',
42
+ 'secret',
43
+ 'token',
44
+ 'access_token',
45
+ 'refresh_token',
46
+ 'id_token',
47
+ 'authorization',
48
+ 'cookie',
49
+ 'ip',
50
+ 'ip_address',
51
+ 'phone',
52
+ 'cpf',
53
+ 'cnpj',
54
+ 'address',
55
+ 'name',
56
+ 'username',
57
+ 'user_name',
58
+ 'display_name',
59
+ 'full_name',
60
+ 'first_name',
61
+ 'last_name',
62
+ ]);
63
+ const SENSITIVE_SUFFIX = /_(?:email|password|secret|access_token|refresh_token|id_token)$/u;
64
+ const EMAIL_VALUE = /[^\s@]+@[^\s@]+\.[^\s@]+/u;
65
+ const CREDENTIAL_VALUE = /^(?:Bearer\s|eyJ[\w-]+\.[\w-]+\.)/u;
66
+ export function isValidEventName(name) {
67
+ return typeof name === 'string' && NAME_PATTERN.test(name);
68
+ }
69
+ export function isCatalogEvent(name) {
70
+ return CATALOG.has(name);
71
+ }
72
+ /** A level is a finite number or a 1–64 character string; always sent as a string. */
73
+ export function normalizeLevel(level) {
74
+ if (typeof level === 'number')
75
+ return Number.isFinite(level) ? String(level) : null;
76
+ if (typeof level !== 'string')
77
+ return null;
78
+ const trimmed = level.trim();
79
+ return trimmed.length === 0 || trimmed.length > MAX_LEVEL_LENGTH ? null : trimmed;
80
+ }
81
+ export function normalizeReason(reason) {
82
+ if (typeof reason !== 'string')
83
+ return null;
84
+ const trimmed = reason.trim();
85
+ return trimmed.length === 0 || trimmed.length > MAX_LEVEL_LENGTH ? null : trimmed;
86
+ }
87
+ /**
88
+ * Keeps only flat, small, non-sensitive values supplied by the game: snake_case keys, strings up
89
+ * to 200 characters, finite numbers, booleans and `null`; at most 25 keys. `undefined` is removed
90
+ * silently; anything else is dropped and reported. `protectedKeys` are set by the SDK itself.
91
+ */
92
+ export function sanitizeProperties(event, input, protectedKeys, report) {
93
+ const output = {};
94
+ if (input === undefined || input === null)
95
+ return output;
96
+ if (!isPlainObject(input)) {
97
+ report('invalid_property', event);
98
+ return output;
99
+ }
100
+ let count = 0;
101
+ for (const [key, value] of Object.entries(input)) {
102
+ if (value === undefined)
103
+ continue;
104
+ if (!NAME_PATTERN.test(key)) {
105
+ report('invalid_property', event);
106
+ continue;
107
+ }
108
+ if (METADATA_PROPERTIES.has(key) || PROVIDER_PROPERTIES.has(key) || protectedKeys.has(key)) {
109
+ report('reserved_property', event, key);
110
+ continue;
111
+ }
112
+ if (SENSITIVE_KEYS.has(key) || SENSITIVE_SUFFIX.test(key)) {
113
+ report('sensitive_property', event, key);
114
+ continue;
115
+ }
116
+ const normalized = normalizeValue(value);
117
+ if (normalized === INVALID) {
118
+ report('invalid_property', event, key);
119
+ continue;
120
+ }
121
+ if (typeof normalized === 'string' && looksSensitive(normalized)) {
122
+ report('sensitive_property', event, key);
123
+ continue;
124
+ }
125
+ if (count >= MAX_PROPERTIES) {
126
+ report('too_many_properties', event, key);
127
+ continue;
128
+ }
129
+ output[key] = normalized;
130
+ count += 1;
131
+ }
132
+ return output;
133
+ }
134
+ const INVALID = Symbol('invalid');
135
+ function normalizeValue(value) {
136
+ if (value === null || typeof value === 'boolean')
137
+ return value;
138
+ if (typeof value === 'number')
139
+ return Number.isFinite(value) ? value : INVALID;
140
+ if (typeof value === 'string')
141
+ return value.length <= MAX_STRING_LENGTH ? value : INVALID;
142
+ return INVALID;
143
+ }
144
+ function looksSensitive(value) {
145
+ return EMAIL_VALUE.test(value) || CREDENTIAL_VALUE.test(value);
146
+ }
147
+ function isPlainObject(value) {
148
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
149
+ return false;
150
+ const prototype = Object.getPrototypeOf(value);
151
+ return prototype === Object.prototype || prototype === null;
152
+ }
153
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../../../src/modules/analytics/events.ts"],"names":[],"mappings":"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","sourcesContent":["import type { PixelumeAnalyticsIssue } from '../../core/types.js';\n\n/**\n * Stable event catalog. Names are part of the public contract: dashboards are built on them, so\n * they are only ever added, never renamed.\n */\nexport const ANALYTICS_EVENTS = Object.freeze({\n sessionStarted: 'game_session_started',\n sessionEnded: 'game_session_ended',\n levelStarted: 'level_started',\n levelCompleted: 'level_completed',\n levelFailed: 'level_failed',\n} as const);\n\nconst CATALOG: ReadonlySet<string> = new Set(Object.values(ANALYTICS_EVENTS));\n/** snake_case, starts with a letter, 1–64 characters. Excludes provider names such as `$pageview`. */\nconst NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u;\nexport const MAX_PROPERTIES = 25;\nexport const MAX_STRING_LENGTH = 200;\nconst MAX_LEVEL_LENGTH = 64;\n\n/** Metadata added by the SDK to every event; games cannot override it. */\nexport const METADATA_PROPERTIES: ReadonlySet<string> = new Set([\n 'game_id',\n 'game_version',\n 'sdk_version',\n 'session_id',\n 'platform',\n]);\n/** Keys the provider treats specially. */\nconst PROVIDER_PROPERTIES: ReadonlySet<string> = new Set([\n 'distinct_id',\n 'token',\n 'timestamp',\n 'uuid',\n 'event',\n]);\n/**\n * Best-effort guard against personal data and credentials. It complements, and does not replace,\n * the rule that games never send them.\n */\nconst SENSITIVE_KEYS: ReadonlySet<string> = new Set([\n 'email',\n 'e_mail',\n 'password',\n 'secret',\n 'token',\n 'access_token',\n 'refresh_token',\n 'id_token',\n 'authorization',\n 'cookie',\n 'ip',\n 'ip_address',\n 'phone',\n 'cpf',\n 'cnpj',\n 'address',\n 'name',\n 'username',\n 'user_name',\n 'display_name',\n 'full_name',\n 'first_name',\n 'last_name',\n]);\nconst SENSITIVE_SUFFIX = /_(?:email|password|secret|access_token|refresh_token|id_token)$/u;\nconst EMAIL_VALUE = /[^\\s@]+@[^\\s@]+\\.[^\\s@]+/u;\nconst CREDENTIAL_VALUE = /^(?:Bearer\\s|eyJ[\\w-]+\\.[\\w-]+\\.)/u;\n\nexport type AnalyticsPropertyValue = string | number | boolean | null;\nexport type AnalyticsProperties = Record<string, AnalyticsPropertyValue>;\nexport type IssueReporter = (\n code: PixelumeAnalyticsIssue['code'],\n event: string | null,\n property?: string | null,\n) => void;\n\nexport function isValidEventName(name: unknown): name is string {\n return typeof name === 'string' && NAME_PATTERN.test(name);\n}\n\nexport function isCatalogEvent(name: string): boolean {\n return CATALOG.has(name);\n}\n\n/** A level is a finite number or a 1–64 character string; always sent as a string. */\nexport function normalizeLevel(level: unknown): string | null {\n if (typeof level === 'number') return Number.isFinite(level) ? String(level) : null;\n if (typeof level !== 'string') return null;\n const trimmed = level.trim();\n return trimmed.length === 0 || trimmed.length > MAX_LEVEL_LENGTH ? null : trimmed;\n}\n\nexport function normalizeReason(reason: unknown): string | null {\n if (typeof reason !== 'string') return null;\n const trimmed = reason.trim();\n return trimmed.length === 0 || trimmed.length > MAX_LEVEL_LENGTH ? null : trimmed;\n}\n\n/**\n * Keeps only flat, small, non-sensitive values supplied by the game: snake_case keys, strings up\n * to 200 characters, finite numbers, booleans and `null`; at most 25 keys. `undefined` is removed\n * silently; anything else is dropped and reported. `protectedKeys` are set by the SDK itself.\n */\nexport function sanitizeProperties(\n event: string,\n input: unknown,\n protectedKeys: ReadonlySet<string>,\n report: IssueReporter,\n): AnalyticsProperties {\n const output: AnalyticsProperties = {};\n if (input === undefined || input === null) return output;\n if (!isPlainObject(input)) {\n report('invalid_property', event);\n return output;\n }\n let count = 0;\n for (const [key, value] of Object.entries(input)) {\n if (value === undefined) continue;\n if (!NAME_PATTERN.test(key)) {\n report('invalid_property', event);\n continue;\n }\n if (METADATA_PROPERTIES.has(key) || PROVIDER_PROPERTIES.has(key) || protectedKeys.has(key)) {\n report('reserved_property', event, key);\n continue;\n }\n if (SENSITIVE_KEYS.has(key) || SENSITIVE_SUFFIX.test(key)) {\n report('sensitive_property', event, key);\n continue;\n }\n const normalized = normalizeValue(value);\n if (normalized === INVALID) {\n report('invalid_property', event, key);\n continue;\n }\n if (typeof normalized === 'string' && looksSensitive(normalized)) {\n report('sensitive_property', event, key);\n continue;\n }\n if (count >= MAX_PROPERTIES) {\n report('too_many_properties', event, key);\n continue;\n }\n output[key] = normalized;\n count += 1;\n }\n return output;\n}\n\nconst INVALID = Symbol('invalid');\n\nfunction normalizeValue(value: unknown): AnalyticsPropertyValue | typeof INVALID {\n if (value === null || typeof value === 'boolean') return value;\n if (typeof value === 'number') return Number.isFinite(value) ? value : INVALID;\n if (typeof value === 'string') return value.length <= MAX_STRING_LENGTH ? value : INVALID;\n return INVALID;\n}\n\nfunction looksSensitive(value: string): boolean {\n return EMAIL_VALUE.test(value) || CREDENTIAL_VALUE.test(value);\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;\n const prototype = Object.getPrototypeOf(value) as unknown;\n return prototype === Object.prototype || prototype === null;\n}\n"]}
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */
2
- export declare const SDK_VERSION = "0.5.0";
2
+ export declare const SDK_VERSION = "0.6.0";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */
2
- export const SDK_VERSION = '0.5.0';
2
+ export const SDK_VERSION = '0.6.0';
3
3
  //# sourceMappingURL=version.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,mHAAmH;AACnH,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC","sourcesContent":["/** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */\nexport const SDK_VERSION = '0.5.0';\n"]}
1
+ {"version":3,"file":"version.js","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,mHAAmH;AACnH,MAAM,CAAC,MAAM,WAAW,GAAG,OAAO,CAAC","sourcesContent":["/** Version of the SDK package. Kept in sync with `package.json` (checked by tests and by the release workflow). */\nexport const SDK_VERSION = '0.6.0';\n"]}