@omega.js/client 0.1.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.
- package/LICENSE +98 -0
- package/README.md +874 -0
- package/dist/index.js +999 -0
- package/dist/modules/analytics.js +584 -0
- package/dist/modules/auth.js +469 -0
- package/dist/modules/bindings.js +319 -0
- package/dist/modules/device.js +282 -0
- package/dist/modules/dom.js +96 -0
- package/dist/modules/features.js +30 -0
- package/dist/modules/firestore.js +313 -0
- package/dist/modules/form-manager.js +1577 -0
- package/dist/modules/icon-core.js +226 -0
- package/dist/modules/icon-renderer.js +149 -0
- package/dist/modules/live-page.js +235 -0
- package/dist/modules/logger.js +36 -0
- package/dist/modules/motion.js +853 -0
- package/dist/modules/notifications.js +433 -0
- package/dist/modules/path-prefix.js +22 -0
- package/dist/modules/request.js +223 -0
- package/dist/modules/sentry.js +108 -0
- package/dist/modules/service-worker.js +237 -0
- package/dist/modules/storage.js +133 -0
- package/dist/modules/triggers.js +117 -0
- package/dist/modules/utilities.js +479 -0
- package/dist/modules/vert-document.js +354 -0
- package/dist/modules/verts.js +1133 -0
- package/dist/vendor/account/engine.js +182 -0
- package/dist/vendor/account/features.js +220 -0
- package/dist/vendor/account/index.js +53 -0
- package/dist/vendor/account/schema.js +272 -0
- package/dist/vendor/account/subscription.js +38 -0
- package/dist/vendor/analytics/adapters/ga4.js +26 -0
- package/dist/vendor/analytics/adapters/meta.js +26 -0
- package/dist/vendor/analytics/adapters/resolve.js +130 -0
- package/dist/vendor/analytics/adapters/tiktok.js +27 -0
- package/dist/vendor/analytics/catalog.js +908 -0
- package/dist/vendor/analytics/consent.js +49 -0
- package/dist/vendor/analytics/core.js +141 -0
- package/dist/vendor/analytics/identity.js +136 -0
- package/dist/vendor/analytics/index.js +170 -0
- package/dist/vendor/analytics/logger.js +40 -0
- package/dist/vendor/analytics/transports/browser.js +110 -0
- package/dist/vendor/monitoring/browser.js +207 -0
- package/dist/vendor/monitoring/core.js +180 -0
- package/dist/vendor/monitoring/logger.js +39 -0
- package/docs/architecture.md +59 -0
- package/docs/bindings.md +235 -0
- package/docs/build-system.md +32 -0
- package/docs/cdp-debugging.md +29 -0
- package/docs/code-patterns.md +96 -0
- package/docs/common-tasks.md +36 -0
- package/docs/dependencies.md +19 -0
- package/docs/index.md +159 -0
- package/docs/modules.md +180 -0
- package/docs/shared/agent-docs.md +89 -0
- package/docs/shared/analytics.md +612 -0
- package/docs/shared/brands.md +51 -0
- package/docs/shared/breaking-changes.md +497 -0
- package/docs/shared/config.md +1387 -0
- package/docs/shared/deploys.md +215 -0
- package/docs/shared/icons.md +201 -0
- package/docs/shared/local-dev.md +147 -0
- package/docs/shared/logging.md +202 -0
- package/docs/shared/monitoring.md +153 -0
- package/docs/shared/publishing.md +183 -0
- package/docs/shared/rulings.md +34 -0
- package/docs/shared/testing.md +147 -0
- package/docs/shared/theming.md +604 -0
- package/docs/shared/translation.md +291 -0
- package/docs/shared/updates.md +61 -0
- package/docs/testing.md +9 -0
- package/package.json +65 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client runtime's analytics — a HOST of `@omega.js/analytics`, never a
|
|
3
|
+
* second implementation of it ([#328](https://github.com/Omega-JS-Stack/omega/issues/328),
|
|
4
|
+
* stage E).
|
|
5
|
+
*
|
|
6
|
+
* `manager.analytics().event('<canonical>', params)` resolves through the shared
|
|
7
|
+
* catalog and adapters, exactly like a web page's call sites and the backend's
|
|
8
|
+
* webhook. What differs per runtime is the TRANSPORT, and this module is where
|
|
9
|
+
* each one is injected:
|
|
10
|
+
*
|
|
11
|
+
* web the PAGE's transport — the guarded gtag/fbq/ttq the
|
|
12
|
+
* web host wired (`core/js/libs/analytics.js`), which
|
|
13
|
+
* also owns the consent gate and the attribution
|
|
14
|
+
* context. Nothing is configured here beyond the
|
|
15
|
+
* environment: a page's seams are the page's.
|
|
16
|
+
* electron / extension the Measurement Protocol, fed by the GA4 descriptor.
|
|
17
|
+
* Meta and TikTok resolve and then skip — no pixel
|
|
18
|
+
* exists in these runtimes to receive them.
|
|
19
|
+
* desktop renderer NO transport at all: the injected IPC bridge
|
|
20
|
+
* ([#411](https://github.com/Omega-JS-Stack/omega/issues/411)).
|
|
21
|
+
* Events forward to the main process, whose sender
|
|
22
|
+
* owns the one device id, the one session id and the
|
|
23
|
+
* real engagement time — one install, one GA client.
|
|
24
|
+
*
|
|
25
|
+
* Identity is real here (#159 is closed): `setUserId` / `setUserProperties` SEND
|
|
26
|
+
* on web through the page's gtag instead of storing values only the Measurement
|
|
27
|
+
* Protocol payload ever read.
|
|
28
|
+
*/
|
|
29
|
+
import analytics from '../vendor/analytics/index.js';
|
|
30
|
+
import core from '../vendor/analytics/core.js';
|
|
31
|
+
import { createLogger } from './logger.js';
|
|
32
|
+
|
|
33
|
+
const logger = createLogger('analytics');
|
|
34
|
+
|
|
35
|
+
// Supported runtimes for analytics
|
|
36
|
+
const SUPPORTED_RUNTIMES = ['browser-extension', 'electron', 'web'];
|
|
37
|
+
|
|
38
|
+
// Raw per-install device id (a plain UUID) — client_id derives from it
|
|
39
|
+
const DEVICE_ID_KEY = '_omega_device_id';
|
|
40
|
+
|
|
41
|
+
// The GA4 session cache: one id per visit, rolled after 30 minutes of inactivity
|
|
42
|
+
const SESSION_KEY = '_ga_session_id';
|
|
43
|
+
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
class Analytics {
|
|
46
|
+
constructor(manager) {
|
|
47
|
+
this.manager = manager;
|
|
48
|
+
this.initialized = false;
|
|
49
|
+
this.devMode = false;
|
|
50
|
+
this.runtime = null;
|
|
51
|
+
this.config = null;
|
|
52
|
+
this.bridge = null;
|
|
53
|
+
this.measurementId = null;
|
|
54
|
+
this.secret = null;
|
|
55
|
+
this.projectId = null;
|
|
56
|
+
this.namespace = null;
|
|
57
|
+
this.clientId = null;
|
|
58
|
+
this.session = null;
|
|
59
|
+
this.userId = null;
|
|
60
|
+
this.userProperties = {};
|
|
61
|
+
this.authed = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Check if runtime is supported
|
|
65
|
+
_isSupported() {
|
|
66
|
+
return SUPPORTED_RUNTIMES.includes(this.runtime);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Web's transport is the page's own pixels, never the Measurement Protocol
|
|
70
|
+
_isWeb() {
|
|
71
|
+
return this.runtime === 'web';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A popup / options page / sidepanel — a fresh top-level context every open,
|
|
75
|
+
// which is why the session cache cannot live in sessionStorage here (#412)
|
|
76
|
+
_isExtension() {
|
|
77
|
+
return this.runtime === 'browser-extension';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Get extension storage API — the same seam `device` persists through
|
|
81
|
+
_getExtensionStorage() {
|
|
82
|
+
if (typeof chrome !== 'undefined' && chrome.storage?.local) {
|
|
83
|
+
return chrome.storage.local;
|
|
84
|
+
}
|
|
85
|
+
if (typeof browser !== 'undefined' && browser.storage?.local) {
|
|
86
|
+
return browser.storage.local;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Inside desktop's renderer, where the host injected the preload's IPC bridge
|
|
92
|
+
// to the main process's sender (#411). It outranks every runtime branch
|
|
93
|
+
// below: an Electron window sets no `config.runtime`, so a desktop renderer
|
|
94
|
+
// reads as the WEB runtime and the bridge is the only thing that says
|
|
95
|
+
// otherwise. Bridged means this client NEVER sends — not the Measurement
|
|
96
|
+
// Protocol, not a page pixel — so it holds no secret and no device id.
|
|
97
|
+
_isBridged() {
|
|
98
|
+
return !!this.bridge;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Initialize analytics
|
|
102
|
+
init(config = {}) {
|
|
103
|
+
// Store config
|
|
104
|
+
this.config = config;
|
|
105
|
+
|
|
106
|
+
// Get runtime
|
|
107
|
+
this.runtime = this.manager.utilities().getRuntime();
|
|
108
|
+
|
|
109
|
+
if (!this._isSupported()) {
|
|
110
|
+
logger.log(`Runtime "${this.runtime}" not supported yet, skipping`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Skip if already initialized
|
|
115
|
+
if (this.initialized) {
|
|
116
|
+
logger.log('Already initialized');
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for development mode — dev NEVER posts to a real property
|
|
121
|
+
// (consumers' dev traffic must not land in anyone's GA4; the baked-in
|
|
122
|
+
// fallback credentials are gone by design — C4 cp106a de-ITW).
|
|
123
|
+
this.devMode = this.manager.isDevelopment();
|
|
124
|
+
|
|
125
|
+
// The host's seam for a runtime whose events belong to another process
|
|
126
|
+
// (#411) — the desktop renderer's IPC bridge, null everywhere else.
|
|
127
|
+
this.bridge = config.bridge || null;
|
|
128
|
+
|
|
129
|
+
// Canonical handoff: analytics.providers.google.{id,secret} + the
|
|
130
|
+
// brand's projectId for the cross-surface identity namespace
|
|
131
|
+
this.measurementId = config.measurementId || config.id;
|
|
132
|
+
this.projectId = config.projectId || null;
|
|
133
|
+
|
|
134
|
+
// The Measurement Protocol api_secret is never read on web: the page's
|
|
135
|
+
// gtag is the transport there, and the secret must never reach a page. Nor
|
|
136
|
+
// is it read in a bridged renderer — and that is the guard behind desktop's
|
|
137
|
+
// rule that the secret must never be injected into a renderer's config: a
|
|
138
|
+
// second sender here would split one install into two GA devices (#396).
|
|
139
|
+
this.secret = (this._isWeb() || this._isBridged()) ? null : config.secret;
|
|
140
|
+
|
|
141
|
+
// Skip if no measurement ID. Web has none to require, since the gtag
|
|
142
|
+
// config is page-side (the consent-gated loader owns it), and a bridged
|
|
143
|
+
// renderer has none to require because main holds them.
|
|
144
|
+
if (!this.measurementId && !this._isWeb() && !this._isBridged()) {
|
|
145
|
+
logger.log('No measurement ID provided, skipping initialization');
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Cross-surface identity — @omega.js/analytics core (the ONE place the
|
|
150
|
+
// uuidv5 math lives; desktop's main-process lib uses the same module)
|
|
151
|
+
this.namespace = core.deriveNamespace(this.projectId);
|
|
152
|
+
|
|
153
|
+
// Generate or retrieve client ID. A bridged renderer mints none: main's
|
|
154
|
+
// sender already resolved the install's device id from its OWN storage,
|
|
155
|
+
// and a second one here is the identity fork this bridge exists to prevent.
|
|
156
|
+
this.clientId = this._isBridged() ? null : this._getClientId();
|
|
157
|
+
|
|
158
|
+
// The facade's environment seam is the brand's own `config.environment` —
|
|
159
|
+
// it decides whether an unknown event name throws and whether the fire log
|
|
160
|
+
// prints. Injected for EVERY runtime, because it is the one thing a page
|
|
161
|
+
// host cannot know before the config has loaded.
|
|
162
|
+
analytics.configure({
|
|
163
|
+
environment: this.devMode ? 'development' : 'production',
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// The transport per runtime. On web it is the package's guarded browser
|
|
167
|
+
// transport — the same object the page host wires, so a client-fired event
|
|
168
|
+
// (a vert click, a permission prompt) counts on a page whose own call sites
|
|
169
|
+
// never loaded. The page keeps the seams only a page can supply: the
|
|
170
|
+
// consent gate and the attribution context.
|
|
171
|
+
//
|
|
172
|
+
// A bridged renderer configures NONE: it never resolves a descriptor at
|
|
173
|
+
// all, because `event()` hands the canonical name to the bridge and main
|
|
174
|
+
// walks the catalog on the other side.
|
|
175
|
+
if (!this._isBridged()) {
|
|
176
|
+
analytics.configure(this._isWeb()
|
|
177
|
+
? { transport: analytics.transports.browser }
|
|
178
|
+
: {
|
|
179
|
+
transport: { send: (descriptor) => this._sendViaMeasurementProtocol(descriptor) },
|
|
180
|
+
context: { runtime: this.runtime },
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Log initialization
|
|
185
|
+
logger.log(`Initializing with ${this._isBridged() ? 'the desktop IPC bridge (main is the sender)' : `measurement ID: ${this.measurementId || 'page-side gtag'}`}${this.devMode ? ' (dev mode)' : ''} [${this.runtime}]`);
|
|
186
|
+
|
|
187
|
+
// Mark as initialized
|
|
188
|
+
this.initialized = true;
|
|
189
|
+
|
|
190
|
+
// An extension's session cache lives in chrome.storage (#412) — the only
|
|
191
|
+
// storage a popup close does not wipe. That read is async while
|
|
192
|
+
// `_getSessionId()` must stay synchronous for the payload, so it hydrates
|
|
193
|
+
// ONCE here into the in-memory mirror the getter reads, and every later
|
|
194
|
+
// update writes through. Null when there is no extension storage to read.
|
|
195
|
+
const storage = this._isExtension() ? this._getExtensionStorage() : null;
|
|
196
|
+
const hydrating = storage ? this._loadSessionFromExtensionStorage(storage) : null;
|
|
197
|
+
|
|
198
|
+
// Send initial pageview, never on web: the page's own gtag config
|
|
199
|
+
// already fired one and a second would double-count. Nor from a bridged
|
|
200
|
+
// renderer: main fires the launch events for the whole app (app_launch,
|
|
201
|
+
// once per launch), and a per-window page_view here would be its own
|
|
202
|
+
// decision to make, not a side effect of wiring the bridge.
|
|
203
|
+
//
|
|
204
|
+
// It waits for the hydrate above, because the launch event of a reopened
|
|
205
|
+
// popup is exactly the one that must carry the session it is rejoining —
|
|
206
|
+
// minting a new id here is the bug (#412), not a detail.
|
|
207
|
+
if (!this._isWeb() && !this._isBridged()) {
|
|
208
|
+
if (hydrating) {
|
|
209
|
+
hydrating.then(() => this.event('page_view'));
|
|
210
|
+
} else {
|
|
211
|
+
this.event('page_view');
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Stable per-install device id, hashed into the project namespace so every
|
|
217
|
+
// event from this browser is the same GA client. The derivation is the
|
|
218
|
+
// package's ([#396](https://github.com/Omega-JS-Stack/omega/issues/396)) —
|
|
219
|
+
// all this runtime supplies is where it persists, since a page can read
|
|
220
|
+
// nothing about the machine to seed from. Without a projectId the raw (still
|
|
221
|
+
// stable) device id is used as-is.
|
|
222
|
+
_getClientId() {
|
|
223
|
+
const deviceId = core.deriveDeviceId({
|
|
224
|
+
get: () => {
|
|
225
|
+
try {
|
|
226
|
+
return localStorage.getItem(DEVICE_ID_KEY);
|
|
227
|
+
} catch (e) {
|
|
228
|
+
// localStorage not available
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
set: (value) => {
|
|
233
|
+
try {
|
|
234
|
+
localStorage.setItem(DEVICE_ID_KEY, value);
|
|
235
|
+
} catch (e) {
|
|
236
|
+
// localStorage not available
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
return core.deriveClientId(deviceId, this.namespace);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Get page data to include with all events
|
|
245
|
+
_getPageData() {
|
|
246
|
+
return {
|
|
247
|
+
page_path: window.location.pathname,
|
|
248
|
+
page_title: document.title,
|
|
249
|
+
page_location: window.location.href,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Fire a canonical event.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} eventName - A canonical name from the catalog — the SSOT
|
|
257
|
+
* for what each provider is told and in which dialect.
|
|
258
|
+
* @param {object} [params] - That event's canonical params.
|
|
259
|
+
* @param {object} [options] - `{ eventId, providers }` for an event whose
|
|
260
|
+
* other half fires server-side.
|
|
261
|
+
* @returns {void}
|
|
262
|
+
*/
|
|
263
|
+
event(eventName, params = {}, options = {}) {
|
|
264
|
+
if (!this._isSupported()) {
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (!this.initialized) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Bridged (desktop's renderer): the canonical name and the caller's params
|
|
273
|
+
// cross to main, which resolves the catalog and enriches with ITS identity
|
|
274
|
+
// — device id, the session id minted once per launch, real engagement time
|
|
275
|
+
// — and with the app's own page context, which is main's to own and not a
|
|
276
|
+
// renderer's file:// href. `options` stays behind with the page-pixel
|
|
277
|
+
// providers it exists to deduplicate: GA4 through main is the one lane here.
|
|
278
|
+
if (this._isBridged()) {
|
|
279
|
+
// The catalog check happens HERE, on the facade's own rule (throw in
|
|
280
|
+
// development, log-and-skip in production): a typo is the CALL SITE's
|
|
281
|
+
// bug, and it must surface at that stack in that renderer's console —
|
|
282
|
+
// not as an unattributable warn in the main process's runtime.log.
|
|
283
|
+
if (!analytics.entryFor(eventName)) {
|
|
284
|
+
if (analytics.isDevelopment()) {
|
|
285
|
+
throw new Error(`Unknown analytics event "${eventName}" — every event is declared in the catalog (@omega.js/analytics/catalog)`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
logger.warn(`Unknown event "${eventName}" — not in the catalog, skipped`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
this.bridge.event(eventName, params);
|
|
294
|
+
} catch (e) {
|
|
295
|
+
// The forward crossing IPC is the one thing here that can fail on the
|
|
296
|
+
// caller's data (a param that structured-clone cannot carry). The
|
|
297
|
+
// facade never throws at a visitor mid-action, so neither does this.
|
|
298
|
+
logger.warn(`Failed to forward "${eventName}" to the main process:`, e.message);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Merge page data with provided params
|
|
305
|
+
const eventParams = {
|
|
306
|
+
...this._getPageData(),
|
|
307
|
+
...params,
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
// The facade walks consent → adapters → transport and never throws at a
|
|
311
|
+
// visitor; its own dev line is the per-fire trace.
|
|
312
|
+
analytics.event(eventName, eventParams, options);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* The Measurement Protocol transport for the runtimes with no page pixels.
|
|
317
|
+
* GA4 is the only provider it can deliver: Meta's and TikTok's browser pixels
|
|
318
|
+
* do not exist in an Electron window or an extension context, so their
|
|
319
|
+
* descriptors report "blocked" and the fire log says so.
|
|
320
|
+
*
|
|
321
|
+
* @param {object} descriptor - The resolved provider descriptor.
|
|
322
|
+
* @returns {boolean} true when the descriptor was delivered.
|
|
323
|
+
*/
|
|
324
|
+
_sendViaMeasurementProtocol(descriptor) {
|
|
325
|
+
if (descriptor.provider !== 'ga4') {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Dev mode logs only — nothing posts
|
|
330
|
+
if (this.devMode) {
|
|
331
|
+
logger.log('Dev mode: event logged locally, not sent');
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Measurement Protocol requires api_secret
|
|
336
|
+
if (!this.secret) {
|
|
337
|
+
logger.warn('No API secret provided, cannot send via Measurement Protocol');
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const url = core.buildCollectUrl(this.measurementId, this.secret);
|
|
342
|
+
|
|
343
|
+
const payload = core.buildPayload({
|
|
344
|
+
clientId: this.clientId,
|
|
345
|
+
userId: this.userId,
|
|
346
|
+
userProperties: this.userProperties,
|
|
347
|
+
eventName: descriptor.name,
|
|
348
|
+
params: {
|
|
349
|
+
...descriptor.payload,
|
|
350
|
+
engagement_time_msec: 100,
|
|
351
|
+
session_id: this._getSessionId(),
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Send via fetch (fire and forget)
|
|
356
|
+
fetch(url, {
|
|
357
|
+
method: 'POST',
|
|
358
|
+
body: JSON.stringify(payload),
|
|
359
|
+
}).catch((err) => {
|
|
360
|
+
logger.warn('Failed to send event:', err);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Get or generate session ID
|
|
367
|
+
_getSessionId() {
|
|
368
|
+
const now = Date.now();
|
|
369
|
+
const session = this._readSession();
|
|
370
|
+
|
|
371
|
+
// Check if session is still valid
|
|
372
|
+
if (session && (now - session.lastActive) < SESSION_TIMEOUT) {
|
|
373
|
+
this._writeSession({ ...session, lastActive: now });
|
|
374
|
+
return session.id;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Create new session
|
|
378
|
+
const newSession = {
|
|
379
|
+
id: `${now}`,
|
|
380
|
+
lastActive: now,
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
this._writeSession(newSession);
|
|
384
|
+
|
|
385
|
+
return newSession.id;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The session cache's two backings. An extension reads the in-memory mirror
|
|
389
|
+
// hydrated at init, since chrome.storage is async and this is not; every
|
|
390
|
+
// other runtime reads sessionStorage, which outlives nothing but its own
|
|
391
|
+
// page — exactly what a web session is.
|
|
392
|
+
_readSession() {
|
|
393
|
+
if (this._isExtension()) {
|
|
394
|
+
return this.session;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
return JSON.parse(sessionStorage.getItem(SESSION_KEY) || 'null');
|
|
399
|
+
} catch (e) {
|
|
400
|
+
// sessionStorage not available
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_writeSession(session) {
|
|
406
|
+
if (this._isExtension()) {
|
|
407
|
+
this.session = session;
|
|
408
|
+
|
|
409
|
+
const storage = this._getExtensionStorage();
|
|
410
|
+
if (storage) {
|
|
411
|
+
// Write-through, fire and forget: the mirror already carries the value
|
|
412
|
+
// the event being built is about to send
|
|
413
|
+
this._saveSessionToExtensionStorage(storage, session);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
|
421
|
+
} catch (e) {
|
|
422
|
+
// sessionStorage not available
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Load the session from extension storage (async)
|
|
427
|
+
async _loadSessionFromExtensionStorage(storage) {
|
|
428
|
+
try {
|
|
429
|
+
const result = await storage.get(SESSION_KEY);
|
|
430
|
+
const stored = result?.[SESSION_KEY];
|
|
431
|
+
|
|
432
|
+
// A session minted while this read was in flight belongs to THIS context
|
|
433
|
+
// and already rode an event — hydrating over it would rewind a sent id
|
|
434
|
+
if (!this.session && stored && typeof stored === 'object' && stored.id) {
|
|
435
|
+
this.session = stored;
|
|
436
|
+
}
|
|
437
|
+
} catch (e) {
|
|
438
|
+
logger.warn('Failed to load session from extension storage:', e);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Save the session to extension storage (async)
|
|
443
|
+
async _saveSessionToExtensionStorage(storage, session) {
|
|
444
|
+
try {
|
|
445
|
+
await storage.set({ [SESSION_KEY]: session });
|
|
446
|
+
} catch (e) {
|
|
447
|
+
logger.warn('Failed to save session to extension storage:', e);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Set user properties — GA4 wraps each value as { value } — merged into
|
|
452
|
+
// every subsequent event's user_properties block, and SENT on web through
|
|
453
|
+
// the page's own gtag (#159: they used to be stored and never sent)
|
|
454
|
+
setUserProperties(properties = {}) {
|
|
455
|
+
if (!this._isSupported()) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!this.initialized) {
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Bridged: main's sender owns the user_properties block that rides every
|
|
464
|
+
// event, so they cross raw (it does the GA4 { value } wrapping) and are
|
|
465
|
+
// never held here, where nothing would ever read them.
|
|
466
|
+
if (this._isBridged()) {
|
|
467
|
+
try {
|
|
468
|
+
this.bridge.setUserProperties(properties);
|
|
469
|
+
} catch (e) {
|
|
470
|
+
// Same rule as the event forward above: a value IPC cannot carry is
|
|
471
|
+
// never allowed to throw into the caller's action.
|
|
472
|
+
logger.warn('Failed to forward user properties to the main process:', e.message);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
this.userProperties = { ...this.userProperties, ...core.wrapUserProperties(properties) };
|
|
479
|
+
|
|
480
|
+
if (this._isWeb()) {
|
|
481
|
+
this._setOnGtag({ user_properties: this.userProperties });
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Set user ID — raw uid in, uuidv5 out (the same value desktop/backend
|
|
486
|
+
// emit for this uid). Without a namespace the raw uid is never sent.
|
|
487
|
+
setUserId(userId) {
|
|
488
|
+
if (!this._isSupported()) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (!this.initialized) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Bridged: identity is main's OWN, and this is the one place the bridge
|
|
497
|
+
// does not forward — the preload exposes no setUserId, because main's auth
|
|
498
|
+
// bridge already flips user_id off the same Firebase user (#411). Storing
|
|
499
|
+
// it here would set a value nothing in this process ever reads, so the
|
|
500
|
+
// call raises where it was made instead of quietly doing nothing (#480).
|
|
501
|
+
if (this._isBridged()) {
|
|
502
|
+
throw new Error('setUserId() is not a bridged renderer\'s to call — the main process owns identity (its auth bridge sets user_id off the same Firebase user). Call manager.analytics.setUserId(uid) in MAIN to set one by hand');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.userId = core.deriveUserId(userId, this.namespace);
|
|
506
|
+
|
|
507
|
+
// Web sends it (#159). A null userId is sent as null, which is how GA4 is
|
|
508
|
+
// told to stop attributing to the person who just signed out.
|
|
509
|
+
if (this._isWeb()) {
|
|
510
|
+
this._setOnGtag({ user_id: this.userId });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* GA4's `set` command through the page's own gtag, guarded: an ad blocker
|
|
516
|
+
* does not stub the global, it keeps it from existing, and a bare call would
|
|
517
|
+
* throw a ReferenceError (#306).
|
|
518
|
+
* @param {object} properties - The `set` payload.
|
|
519
|
+
*/
|
|
520
|
+
_setOnGtag(properties) {
|
|
521
|
+
// `typeof` against an undeclared NAME is the one check that does not throw:
|
|
522
|
+
// a blocker leaves `gtag` undefined rather than stubbed, so `window.gtag`
|
|
523
|
+
// would be a miss on any page that never made a window object of it.
|
|
524
|
+
if (typeof gtag !== 'function') {
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
gtag('set', properties);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Auth transitions → the catalog's `login` / `logout`.
|
|
533
|
+
*
|
|
534
|
+
* The audited asymmetry (#328 inventory gap 8): desktop's main-process
|
|
535
|
+
* singleton fired these off its auth bridge while web and the extension fired
|
|
536
|
+
* nothing. The wiring belongs here, in the class every runtime shares — with
|
|
537
|
+
* ONE owner per surface:
|
|
538
|
+
*
|
|
539
|
+
* web the auth pages own it (`libs/auth/tracking.js` fires `login`
|
|
540
|
+
* with the METHOD the visitor actually used, which an auth-state
|
|
541
|
+
* callback cannot know), so this wiring stays out of web's way
|
|
542
|
+
* entirely.
|
|
543
|
+
* bridged desktop's main-process singleton owns it: its auth bridge fires
|
|
544
|
+
* login/logout off the SAME Firebase user, so forwarding them
|
|
545
|
+
* from the renderer would double-count every sign-in (#411).
|
|
546
|
+
* other this is the only owner.
|
|
547
|
+
*
|
|
548
|
+
* @param {object|null} user - The auth user, or null when signed out.
|
|
549
|
+
* @returns {void}
|
|
550
|
+
*/
|
|
551
|
+
handleAuthChange(user) {
|
|
552
|
+
const uid = user?.uid || null;
|
|
553
|
+
|
|
554
|
+
// Bridged: NEITHER half is this renderer's — main's auth bridge sets the
|
|
555
|
+
// identity and fires the events off the same user, so the setUserId below
|
|
556
|
+
// would hit the throw that guards main's ownership (#480).
|
|
557
|
+
if (this._isBridged()) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Identity follows auth on every other runtime (user_id = uuidv5(uid, namespace))
|
|
562
|
+
this.setUserId(uid);
|
|
563
|
+
|
|
564
|
+
if (this._isWeb()) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (uid && !this.authed) {
|
|
569
|
+
this.authed = true;
|
|
570
|
+
this.event('login', { method: user?.providerId || 'unknown', user_id: uid });
|
|
571
|
+
} else if (!uid && this.authed) {
|
|
572
|
+
this.authed = false;
|
|
573
|
+
this.event('logout');
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export default Analytics;
|
|
579
|
+
|
|
580
|
+
// The facade itself, for the HOST that wires a runtime's seams — @omega.js/web's
|
|
581
|
+
// page module reaches the package through here, because `@omega.js/analytics` is
|
|
582
|
+
// private and exists in a consumer install only as the copy vendored into this
|
|
583
|
+
// package's dist (HARD RULE 3).
|
|
584
|
+
export { analytics };
|