@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,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The consent seam.
|
|
3
|
+
*
|
|
4
|
+
* The gate answers ONE question — is this category granted right now — and the
|
|
5
|
+
* facade asks it once per provider before resolving. No UI, no storage, no
|
|
6
|
+
* region logic: stage B ([#383](https://github.com/Omega-JS-Stack/omega/issues/383))
|
|
7
|
+
* owns the banner and the timezone heuristic, and injects its state through
|
|
8
|
+
* `createConsentGate`.
|
|
9
|
+
*
|
|
10
|
+
* The provider function is read LIVE on every event, so a visitor who accepts
|
|
11
|
+
* mid-session is counted from that moment without anything re-configuring.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// The two categories every provider belongs to (adapters export their own).
|
|
15
|
+
const CATEGORIES = ['analytics', 'marketing'];
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a consent gate from a state provider.
|
|
19
|
+
* @param {Function} providerFn - () => ({ analytics: boolean, marketing: boolean }).
|
|
20
|
+
* @returns {{ granted: Function, state: Function }}
|
|
21
|
+
*/
|
|
22
|
+
function createConsentGate(providerFn) {
|
|
23
|
+
function state() {
|
|
24
|
+
const value = providerFn() || {};
|
|
25
|
+
const resolved = {};
|
|
26
|
+
for (const category of CATEGORIES) {
|
|
27
|
+
resolved[category] = value[category] === true;
|
|
28
|
+
}
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
state,
|
|
34
|
+
/**
|
|
35
|
+
* Is this category granted?
|
|
36
|
+
* @param {string} category - 'analytics' | 'marketing'.
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
granted(category) {
|
|
40
|
+
return state()[category] === true;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The default when no host injects one: everything granted. Stage B supplies
|
|
46
|
+
// the real gate on web; desktop/extension/backend have no banner to gate on.
|
|
47
|
+
const GRANT_ALL = createConsentGate(() => ({ analytics: true, marketing: true }));
|
|
48
|
+
|
|
49
|
+
module.exports = { CATEGORIES, createConsentGate, GRANT_ALL };
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* core — the ONE place GA4 Measurement Protocol semantics live
|
|
3
|
+
* (C4 cp106b; Ian: "unify it and have it in one place").
|
|
4
|
+
*
|
|
5
|
+
* Pure functions, zero runtime assumptions: no DOM, no storage, no
|
|
6
|
+
* transport. @omega.js/client's browser engine and @omega.js/desktop's
|
|
7
|
+
* main-process lib both consume THIS module, so identity math and payload
|
|
8
|
+
* shape can never drift between surfaces again. It moved here from the client
|
|
9
|
+
* ([#382](https://github.com/Omega-JS-Stack/omega/issues/382)) so the server
|
|
10
|
+
* side reaches it without importing the frontend runtime.
|
|
11
|
+
*
|
|
12
|
+
* Cross-surface identity:
|
|
13
|
+
* deviceId = the host's stored id, its seed strategy, or a fresh uuidv4
|
|
14
|
+
* client_id = uuidv5(deviceId, namespace) — same device+surface, same GA client
|
|
15
|
+
* namespace = uuidv5(projectId, uuidv5.URL)
|
|
16
|
+
* user_id = uuidv5(firebaseUid, namespace) — same human, every surface
|
|
17
|
+
* Raw uids/device ids never leave the machine; without a namespace the
|
|
18
|
+
* user_id stays null (never the raw value).
|
|
19
|
+
*
|
|
20
|
+
* `deriveDeviceId` is where the chain starts and the only step that touches a
|
|
21
|
+
* host's world — so its persistence and its seed strategy are INJECTED
|
|
22
|
+
* ([#396](https://github.com/Omega-JS-Stack/omega/issues/396)), which keeps this
|
|
23
|
+
* module as assumption-free as the rest of it.
|
|
24
|
+
*
|
|
25
|
+
* CJS on purpose: desktop's Electron main process require()s it directly
|
|
26
|
+
* (via the package's dist exports); the ESM browser module imports it with
|
|
27
|
+
* standard interop.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const { v4: uuidv4, v5: uuidv5 } = require('uuid');
|
|
31
|
+
|
|
32
|
+
const GA_ENDPOINT = 'https://www.google-analytics.com/mp/collect';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The stable per-install device id every surface's client_id is hashed from —
|
|
36
|
+
* ONE derivation, on the `createRequest(deps)` mold: what differs per target is
|
|
37
|
+
* WHERE it persists and WHAT it seeds from, and both are handed in.
|
|
38
|
+
*
|
|
39
|
+
* The walk is stored → seed → uuidv4. Storage wins so an id survives whatever
|
|
40
|
+
* the seed does next (desktop stays put across a NIC swap or a VPN); the seed is
|
|
41
|
+
* what gives a wiped install continuity (desktop's first non-internal MAC), and
|
|
42
|
+
* a host with none — a browser, where nothing about the machine is readable —
|
|
43
|
+
* generates one and persists it. The floor is the `uuid` package's `v4`, which
|
|
44
|
+
* yields a REAL uuid in every runtime this ships to: it uses the platform's
|
|
45
|
+
* `crypto.randomUUID` where that exists and `getRandomValues` where it does not
|
|
46
|
+
* (an insecure origin), so no surface ever falls back to a random-looking string.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} deps - The host's world.
|
|
49
|
+
* @param {function(): string|null} deps.get - Read the persisted id.
|
|
50
|
+
* @param {function(string): void} deps.set - Persist a freshly derived id.
|
|
51
|
+
* @param {function(): string|null} [deps.seed] - The target's id source, asked
|
|
52
|
+
* only when nothing is stored. Anything falsy falls through to the uuid.
|
|
53
|
+
* @returns {string} The raw device id — never sent anywhere as-is.
|
|
54
|
+
*/
|
|
55
|
+
function deriveDeviceId(deps) {
|
|
56
|
+
if (typeof deps?.get !== 'function' || typeof deps?.set !== 'function') {
|
|
57
|
+
throw new Error('deriveDeviceId requires get and set deps');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const stored = deps.get();
|
|
61
|
+
|
|
62
|
+
if (stored) {
|
|
63
|
+
return stored;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const deviceId = (deps.seed ? deps.seed() : null) || uuidv4();
|
|
67
|
+
|
|
68
|
+
deps.set(deviceId);
|
|
69
|
+
|
|
70
|
+
return deviceId;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** uuidv5 namespace for a project — null in, null out. */
|
|
74
|
+
function deriveNamespace(projectId) {
|
|
75
|
+
return projectId ? uuidv5(String(projectId), uuidv5.URL) : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stable GA client_id: hashed into the namespace when one exists. */
|
|
79
|
+
function deriveClientId(deviceId, namespace) {
|
|
80
|
+
return namespace ? uuidv5(String(deviceId), namespace) : deviceId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** GA user_id from a raw uid — no namespace → null (raw uids never ship). */
|
|
84
|
+
function deriveUserId(uid, namespace) {
|
|
85
|
+
return (uid && namespace) ? uuidv5(String(uid), namespace) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** GA4 event names: letters/digits/underscore, ≤40 chars, no edge underscores. */
|
|
89
|
+
function normalizeEventName(name) {
|
|
90
|
+
if (!name || typeof name !== 'string') {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return name
|
|
95
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
96
|
+
.replace(/^_+|_+$/g, '')
|
|
97
|
+
.replace(/_+/g, '_')
|
|
98
|
+
.slice(0, 40);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** GA4 user_properties wrapping: { plan: 'pro' } → { plan: { value: 'pro' } }. */
|
|
102
|
+
function wrapUserProperties(properties = {}) {
|
|
103
|
+
const wrapped = {};
|
|
104
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
105
|
+
wrapped[key] = { value };
|
|
106
|
+
}
|
|
107
|
+
return wrapped;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Measurement Protocol collect URL. */
|
|
111
|
+
function buildCollectUrl(measurementId, secret) {
|
|
112
|
+
return `${GA_ENDPOINT}?measurement_id=${encodeURIComponent(measurementId)}&api_secret=${encodeURIComponent(secret)}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Measurement Protocol payload. userId/userProperties are omitted when
|
|
117
|
+
* empty — GA rejects nulls, and an empty user_properties block is noise.
|
|
118
|
+
*/
|
|
119
|
+
function buildPayload({ clientId, userId = null, userProperties = {}, eventName, params = {} }) {
|
|
120
|
+
return {
|
|
121
|
+
client_id: clientId,
|
|
122
|
+
...(userId ? { user_id: userId } : {}),
|
|
123
|
+
...(Object.keys(userProperties).length ? { user_properties: userProperties } : {}),
|
|
124
|
+
events: [{
|
|
125
|
+
name: eventName,
|
|
126
|
+
params,
|
|
127
|
+
}],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = {
|
|
132
|
+
GA_ENDPOINT,
|
|
133
|
+
deriveDeviceId,
|
|
134
|
+
deriveNamespace,
|
|
135
|
+
deriveClientId,
|
|
136
|
+
deriveUserId,
|
|
137
|
+
normalizeEventName,
|
|
138
|
+
wrapUserProperties,
|
|
139
|
+
buildCollectUrl,
|
|
140
|
+
buildPayload,
|
|
141
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* identity — the match keys a signed-in visitor is recognized BY, normalized the
|
|
3
|
+
* way each platform's own spec demands
|
|
4
|
+
* ([#328](https://github.com/Omega-JS-Stack/omega/issues/328)).
|
|
5
|
+
*
|
|
6
|
+
* Every ad platform matches on a SHA-256 of a NORMALIZED value, and normalized
|
|
7
|
+
* is not one rule. Meta's advanced matching wants a phone as bare digits —
|
|
8
|
+
* country code included, no `+`, no punctuation — while TikTok's pixel wants the
|
|
9
|
+
* E.164 form WITH the `+`. Same person, two digests, so the normalizer is per
|
|
10
|
+
* provider and only the hash is shared. Email is the one value everybody
|
|
11
|
+
* normalizes alike: trimmed and lowercased.
|
|
12
|
+
*
|
|
13
|
+
* RAW PII NEVER LEAVES: a caller hashes here and hands the pixel the digest.
|
|
14
|
+
* `external_id` is not PII, and it is per provider like the rest
|
|
15
|
+
* ([#410](https://github.com/Omega-JS-Stack/omega/issues/410)): META's spec only
|
|
16
|
+
* RECOMMENDS hashing and its own Pixel example passes a bare id, so Meta gets
|
|
17
|
+
* the RAW uid — exactly what the SERVER sends as `identity.externalId`
|
|
18
|
+
* (@omega.js/backend's `libraries/analytics/match-data.js`). TIKTOK's Events API
|
|
19
|
+
* REQUIRES the digest, so both halves send the SHA-256 of that same uid,
|
|
20
|
+
* normalized by `normalizeExternalId()` below. Either way the browser half and
|
|
21
|
+
* the server half of one person link only because both carry the same value.
|
|
22
|
+
*
|
|
23
|
+
* Pure and runtime-neutral like the rest of the package's pieces: no DOM, no
|
|
24
|
+
* transport, no state. CJS, so the backend can require() it and the browser
|
|
25
|
+
* bundles import it with standard interop.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// The node digest's specifier, held in a VARIABLE on purpose: the web bundle is
|
|
29
|
+
// built for the browser, where a builtin has no home, and esbuild fails the
|
|
30
|
+
// whole build on a literally-named builtin it cannot resolve. Read through a
|
|
31
|
+
// variable it is left alone — and the branch below only ever runs off-page.
|
|
32
|
+
const NODE_CRYPTO = 'crypto';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Is this runtime node? The node digest is chosen by the RUNTIME and never by
|
|
36
|
+
* `typeof require`: a bundler rewrites that name to a shim of its own, which IS
|
|
37
|
+
* a function and throws the moment it is called with a builtin — a rejection
|
|
38
|
+
* thrown at a visitor, exactly where this module promises a quiet null.
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
function isNode() {
|
|
42
|
+
return typeof process !== 'undefined' && !!process.versions && !!process.versions.node;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* SHA-256 hex — the digest every platform's match spec asks for.
|
|
47
|
+
*
|
|
48
|
+
* A page's only hash is `crypto.subtle`, which is asynchronous and absent on a
|
|
49
|
+
* non-secure origin; node's `crypto` covers the runtimes that have no secure
|
|
50
|
+
* context (the test runner, a Cloud Function, a plain-http page).
|
|
51
|
+
*
|
|
52
|
+
* @param {string} [value] - The ALREADY-normalized value.
|
|
53
|
+
* @returns {Promise<string|null>} Lowercase hex, or null when there is nothing
|
|
54
|
+
* to hash and when no digest exists to hash it with.
|
|
55
|
+
*/
|
|
56
|
+
async function sha256(value) {
|
|
57
|
+
if (!value) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const subtle = globalThis.crypto?.subtle;
|
|
62
|
+
|
|
63
|
+
if (subtle) {
|
|
64
|
+
const digest = await subtle.digest('SHA-256', new TextEncoder().encode(value));
|
|
65
|
+
|
|
66
|
+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// A page with no secure context and no node under it has no digest at all,
|
|
70
|
+
// which means no identity — and silence, never a raise at a visitor (#306).
|
|
71
|
+
if (!isNode()) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
return require(NODE_CRYPTO).createHash('sha256').update(value).digest('hex');
|
|
77
|
+
} catch (e) {
|
|
78
|
+
// A bundler that shims `require` throws here rather than resolving a
|
|
79
|
+
// builtin. Same answer as every other missing digest: no match key, no
|
|
80
|
+
// noise — the promise above holds in every shape this module is built into.
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Email, normalized the one way every platform agrees on: trimmed, lowercased.
|
|
87
|
+
* @param {string} [email]
|
|
88
|
+
* @returns {string} The normalized address, or '' when there is none.
|
|
89
|
+
*/
|
|
90
|
+
function normalizeEmail(email) {
|
|
91
|
+
return `${email || ''}`.trim().toLowerCase();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* External id: trimmed, and NOTHING else. TikTok's advanced-matching table is
|
|
96
|
+
* the only spec that states the rule ("Trim any leading and trailing spaces
|
|
97
|
+
* before hashing and ensure you are consistent with the External ID used"), and
|
|
98
|
+
* an id is case-SENSITIVE — lowercasing it the way an email is normalized would
|
|
99
|
+
* hand TikTok a digest of a uid that never existed (#410).
|
|
100
|
+
* @param {string} [uid] - The account's uid.
|
|
101
|
+
* @returns {string} The normalized id, or '' when there is none.
|
|
102
|
+
*/
|
|
103
|
+
function normalizeExternalId(uid) {
|
|
104
|
+
return `${uid || ''}`.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Phone for META: digits only, country code included, no `+` and no punctuation
|
|
109
|
+
* (Meta's advanced-matching spec hashes the bare number).
|
|
110
|
+
* @param {string} [phone] - Any written form; Auth's `phoneNumber` is E.164.
|
|
111
|
+
* @returns {string} The digits, or '' when there is no number.
|
|
112
|
+
*/
|
|
113
|
+
function metaPhone(phone) {
|
|
114
|
+
return `${phone || ''}`.replace(/\D/g, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Phone for TIKTOK: the same digits in E.164, with the leading `+` its pixel
|
|
119
|
+
* spec requires. Nothing to normalize stays empty rather than becoming a lone
|
|
120
|
+
* `+`, which would hash to a match key shared by every phoneless account.
|
|
121
|
+
* @param {string} [phone] - Any written form; Auth's `phoneNumber` is E.164.
|
|
122
|
+
* @returns {string} The E.164 number, or '' when there is no number.
|
|
123
|
+
*/
|
|
124
|
+
function tiktokPhone(phone) {
|
|
125
|
+
const digits = metaPhone(phone);
|
|
126
|
+
|
|
127
|
+
return digits ? `+${digits}` : '';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
module.exports = {
|
|
131
|
+
sha256,
|
|
132
|
+
normalizeEmail,
|
|
133
|
+
normalizeExternalId,
|
|
134
|
+
metaPhone,
|
|
135
|
+
tiktokPhone,
|
|
136
|
+
};
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @omega.js/analytics — the ONE analytics consumption surface.
|
|
3
|
+
*
|
|
4
|
+
* Client code and backend code alike call `analytics.event('<canonical>', params)`
|
|
5
|
+
* ([#328](https://github.com/Omega-JS-Stack/omega/issues/328) §Architecture).
|
|
6
|
+
* What differs underneath — page globals on a browser, the Measurement
|
|
7
|
+
* Protocol and the conversion APIs on a server — is the TRANSPORT, injected by
|
|
8
|
+
* the host and invisible to callers.
|
|
9
|
+
*
|
|
10
|
+
* One fire walks three steps per provider:
|
|
11
|
+
* 1. consent — a blocked category's providers never even resolve
|
|
12
|
+
* 2. adapter — the catalog mapping, or null when the provider has none
|
|
13
|
+
* 3. transport — the host's seam; a missing global is a silent no-op (#306)
|
|
14
|
+
*
|
|
15
|
+
* In development the whole walk prints as ONE line per fire, which is the job
|
|
16
|
+
* `setupTrackingInterceptors()` did in web core's `libs/dev.js` (they retired
|
|
17
|
+
* with the rewire).
|
|
18
|
+
*
|
|
19
|
+
* Environment is INJECTED, never sniffed — the same seam the rest of the
|
|
20
|
+
* ecosystem uses (`@omega.js/client`'s `config.environment === 'development'`).
|
|
21
|
+
* The default is 'production': an unconfigured runtime must never throw an
|
|
22
|
+
* event-name error at a visitor.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const { CATALOG, entryFor } = require('./catalog.js');
|
|
26
|
+
const { createConsentGate, GRANT_ALL, CATEGORIES } = require('./consent.js');
|
|
27
|
+
const { createLogger } = require('./logger.js');
|
|
28
|
+
const core = require('./core.js');
|
|
29
|
+
const identity = require('./identity.js');
|
|
30
|
+
const ga4 = require('./adapters/ga4.js');
|
|
31
|
+
const meta = require('./adapters/meta.js');
|
|
32
|
+
const tiktok = require('./adapters/tiktok.js');
|
|
33
|
+
const browser = require('./transports/browser.js');
|
|
34
|
+
|
|
35
|
+
const logger = createLogger('events');
|
|
36
|
+
|
|
37
|
+
// Every provider, in fire order. A new platform joins here and in the catalog.
|
|
38
|
+
const ADAPTERS = [ga4, meta, tiktok];
|
|
39
|
+
|
|
40
|
+
const DEFAULTS = {
|
|
41
|
+
// No transport = resolve and log, deliver nothing. A host that never calls
|
|
42
|
+
// configure() is inert rather than guessing at page globals.
|
|
43
|
+
transport: null,
|
|
44
|
+
consent: GRANT_ALL,
|
|
45
|
+
context: {},
|
|
46
|
+
environment: 'production',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let state = { ...DEFAULTS };
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Inject the host's seams. Merges, so a host can wire the transport at boot
|
|
53
|
+
* and swap the consent gate later.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [options]
|
|
56
|
+
* @param {object} [options.transport] - { send(descriptor) => boolean }.
|
|
57
|
+
* @param {object} [options.consent] - A gate from `createConsentGate`.
|
|
58
|
+
* @param {object} [options.context] - { attribution, consent, runtime } handed to adapters.
|
|
59
|
+
* @param {string} [options.environment] - 'development' | 'production'.
|
|
60
|
+
* @returns {object} The resolved state.
|
|
61
|
+
*/
|
|
62
|
+
function configure(options = {}) {
|
|
63
|
+
state = { ...state, ...options };
|
|
64
|
+
return { ...state };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Is this runtime in development? Mirrors the client's `config.environment` seam.
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
function isDevelopment() {
|
|
72
|
+
return state.environment === 'development';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Walk one provider. Returns { provider, outcome, descriptor? } — never throws.
|
|
76
|
+
function fire(adapter, canonicalName, params, { eventId, providers }) {
|
|
77
|
+
const provider = adapter.provider;
|
|
78
|
+
|
|
79
|
+
// Selection comes before consent: a provider this half does not own was never
|
|
80
|
+
// going to be asked, whatever the visitor consented to (the same order the
|
|
81
|
+
// backend's deliverConversion walks).
|
|
82
|
+
if (providers && !providers.includes(provider)) {
|
|
83
|
+
return { provider, outcome: 'skipped (not selected)' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!state.consent.granted(adapter.CONSENT_CATEGORY)) {
|
|
87
|
+
return { provider, outcome: `skipped (consent: ${adapter.CONSENT_CATEGORY})` };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const descriptor = adapter.resolve(canonicalName, params, state.context);
|
|
91
|
+
if (!descriptor) {
|
|
92
|
+
return { provider, outcome: 'skipped (no mapping)' };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The dedupe id rides ON the descriptor, which is what a transport executes:
|
|
96
|
+
// Meta takes it as the pixel's `eventID` option and TikTok as `event_id`, so
|
|
97
|
+
// a browser half and a server half of the same conversion collapse into one.
|
|
98
|
+
if (eventId) {
|
|
99
|
+
descriptor.eventId = eventId;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!state.transport) {
|
|
103
|
+
return { provider, outcome: 'skipped (no transport)', descriptor };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// A transport that returns false could not deliver (a blocked or missing
|
|
107
|
+
// page global). It is a no-op by contract, so the dev log is its only trace.
|
|
108
|
+
const delivered = state.transport.send(descriptor);
|
|
109
|
+
|
|
110
|
+
return { provider, outcome: delivered === false ? 'blocked (no global)' : 'sent', descriptor };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Fire a canonical event across every provider that maps it.
|
|
115
|
+
*
|
|
116
|
+
* An unknown name is a PROGRAMMER error — a typo must not silently cost a
|
|
117
|
+
* conversion — so it throws in development and is logged-and-skipped in
|
|
118
|
+
* production, where throwing would take the customer's action with it.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} canonicalName - A name declared in the catalog.
|
|
121
|
+
* @param {object} [params] - The canonical params for that event.
|
|
122
|
+
* @param {object} [options] - The fire's own options.
|
|
123
|
+
* @param {string} [options.eventId] - The platform dedupe id, for an event whose
|
|
124
|
+
* other half fires server-side. Both halves MUST name the same string.
|
|
125
|
+
* @param {string[]} [options.providers] - Restrict the fire to these providers.
|
|
126
|
+
* For a `placement: 'both'` event whose halves do not all deduplicate — this
|
|
127
|
+
* half names the providers it owns. Absent = every provider the catalog maps.
|
|
128
|
+
* @returns {{ event: string, results: object[] }} One result per provider.
|
|
129
|
+
*/
|
|
130
|
+
function event(canonicalName, params = {}, options = {}) {
|
|
131
|
+
const entry = entryFor(canonicalName);
|
|
132
|
+
|
|
133
|
+
if (!entry) {
|
|
134
|
+
if (isDevelopment()) {
|
|
135
|
+
throw new Error(`Unknown analytics event "${canonicalName}" — every event is declared in the catalog (@omega.js/analytics/catalog)`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
logger.warn(`Unknown event "${canonicalName}" — not in the catalog, skipped`);
|
|
139
|
+
return { event: canonicalName, results: [] };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const results = ADAPTERS.map((adapter) => fire(adapter, canonicalName, params, options));
|
|
143
|
+
|
|
144
|
+
if (isDevelopment()) {
|
|
145
|
+
logger.log(`${canonicalName} → ${results.map((result) => `${result.provider} ${result.outcome}`).join(', ')}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { event: canonicalName, results };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
// The consumption surface
|
|
153
|
+
event,
|
|
154
|
+
configure,
|
|
155
|
+
isDevelopment,
|
|
156
|
+
|
|
157
|
+
// The pure pieces, for the hosts that need one without the facade
|
|
158
|
+
catalog: CATALOG,
|
|
159
|
+
entryFor,
|
|
160
|
+
adapters: { ga4, meta, tiktok },
|
|
161
|
+
transports: { browser },
|
|
162
|
+
core,
|
|
163
|
+
// Identity is not an event, so it never walks the catalog: a host hashes the
|
|
164
|
+
// signed-in visitor's match keys with these and sets them on its own pixels.
|
|
165
|
+
identity,
|
|
166
|
+
|
|
167
|
+
// The consent seam
|
|
168
|
+
createConsentGate,
|
|
169
|
+
CONSENT_CATEGORIES: CATEGORIES,
|
|
170
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The analytics package's log tag — the ONE identity tag every OMEGA surface
|
|
3
|
+
* prints: `[@omega.js/analytics:<module>]` ([#12](https://github.com/Omega-JS-Stack/omega/issues/12)).
|
|
4
|
+
*
|
|
5
|
+
* This package runs on BOTH surfaces (a browser bundle and a Cloud Function),
|
|
6
|
+
* so it follows the runtime shape: no timestamp — devtools and Cloud Logging
|
|
7
|
+
* both stamp their own lines. The twin of @omega.js/client's createLogger,
|
|
8
|
+
* emitting this package's segment.
|
|
9
|
+
*
|
|
10
|
+
* CJS like the rest of the package: the desktop main process and the backend
|
|
11
|
+
* require() it, the browser bundles import it with standard interop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
// The package segment — this file IS @omega.js/analytics, so it is a literal.
|
|
15
|
+
const PACKAGE = '@omega.js/analytics';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Create a tagged console for one module, e.g. createLogger('events').
|
|
19
|
+
* @param {string} module - The module identity segment.
|
|
20
|
+
* @returns {object} A console-shaped logger whose calls carry the tag.
|
|
21
|
+
*/
|
|
22
|
+
function createLogger(module) {
|
|
23
|
+
const tag = `[${PACKAGE}:${module}]`;
|
|
24
|
+
|
|
25
|
+
// GETTERS returning a BOUND console method, not wrapper arrows: devtools
|
|
26
|
+
// attributes a line to the frame that called console, so a wrapper would make
|
|
27
|
+
// every line read as coming from this file. Resolution stays at ACCESS time,
|
|
28
|
+
// so a test (or a consumer) that swaps console[method] still sees its own stub.
|
|
29
|
+
const logger = { tag };
|
|
30
|
+
for (const method of ['log', 'info', 'warn', 'error', 'debug']) {
|
|
31
|
+
Object.defineProperty(logger, method, {
|
|
32
|
+
get: () => console[method].bind(console, tag),
|
|
33
|
+
enumerable: true,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return logger;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { createLogger };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser transport — executes descriptors against the page's own pixel
|
|
3
|
+
* globals, absorbing the guard semantics of
|
|
4
|
+
* [#306](https://github.com/Omega-JS-Stack/omega/issues/306).
|
|
5
|
+
*
|
|
6
|
+
* `gtag`, `fbq` and `ttq` are page-level snippets, and an ad blocker does not
|
|
7
|
+
* stub them: it keeps them from ever being defined, so a BARE call throws a
|
|
8
|
+
* ReferenceError. Every one of these calls sits in front of the thing the
|
|
9
|
+
* customer just pressed, so that throw takes the action with it (the bug
|
|
10
|
+
* [#283](https://github.com/Omega-JS-Stack/omega/issues/283) fixed inside the
|
|
11
|
+
* billing card, found again in 19 other files).
|
|
12
|
+
*
|
|
13
|
+
* `typeof` against an undeclared name is the one check that does not throw, and
|
|
14
|
+
* every provider is checked on its own: blockers are per-list, so a page that
|
|
15
|
+
* lost Meta still counts Google. A missing or blocked global is a SILENT no-op,
|
|
16
|
+
* never a throw — the return value is the only trace, so the facade's dev log
|
|
17
|
+
* can tell "delivered" from "blocked" without anything reaching a visitor.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Google Analytics 4 — one command function; the event command is 'event'.
|
|
21
|
+
function sendGoogle(descriptor) {
|
|
22
|
+
if (typeof gtag !== 'function') {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
gtag('event', descriptor.name, descriptor.payload);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Facebook Pixel — one command function; the kind picks the command, which is
|
|
31
|
+
// the whole point of the catalog's standard/custom distinction.
|
|
32
|
+
//
|
|
33
|
+
// A descriptor carrying an `eventId` is one half of a conversion whose other
|
|
34
|
+
// half fires server-side: the Pixel's fourth argument is where its `eventID`
|
|
35
|
+
// goes, and Meta deduplicates on the (event_name, event_id) PAIR. It is passed
|
|
36
|
+
// only when there is one — an `{ eventID: undefined }` option object reads to
|
|
37
|
+
// the Pixel as an id we tried and failed to send.
|
|
38
|
+
function sendMeta(descriptor) {
|
|
39
|
+
if (typeof fbq !== 'function') {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const command = descriptor.kind === 'custom' ? 'trackCustom' : 'track';
|
|
44
|
+
|
|
45
|
+
if (descriptor.eventId) {
|
|
46
|
+
fbq(command, descriptor.name, descriptor.payload, { eventID: descriptor.eventId });
|
|
47
|
+
} else {
|
|
48
|
+
fbq(command, descriptor.name, descriptor.payload);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// TikTok Pixel — an object of methods, so the method gets its own check. Its
|
|
55
|
+
// dedupe key is `event_id` in the third argument (the Events API half sends the
|
|
56
|
+
// same string).
|
|
57
|
+
//
|
|
58
|
+
// A descriptor carrying a `method` is the signal TikTok manages ITSELF rather
|
|
59
|
+
// than exposing as a trackable name: the page view, whose documented surface is
|
|
60
|
+
// `ttq.page()` and nothing else ([#409](https://github.com/Omega-JS-Stack/omega/issues/409)).
|
|
61
|
+
// It takes no name and no payload — the pixel reads the page — and a build that
|
|
62
|
+
// does not have the method is the same silent no-op a blocked global is (#306).
|
|
63
|
+
function sendTikTok(descriptor) {
|
|
64
|
+
if (typeof ttq === 'undefined') {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (descriptor.method) {
|
|
69
|
+
if (typeof ttq[descriptor.method] !== 'function') {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
ttq[descriptor.method]();
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (typeof ttq.track !== 'function') {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (descriptor.eventId) {
|
|
82
|
+
ttq.track(descriptor.name, descriptor.payload, { event_id: descriptor.eventId });
|
|
83
|
+
} else {
|
|
84
|
+
ttq.track(descriptor.name, descriptor.payload);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SENDERS = {
|
|
91
|
+
ga4: sendGoogle,
|
|
92
|
+
meta: sendMeta,
|
|
93
|
+
tiktok: sendTikTok,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Execute one descriptor against the page globals.
|
|
98
|
+
* @param {object} descriptor - { provider, name, kind, payload, userData }.
|
|
99
|
+
* @returns {boolean} true when the provider's global was there to take it.
|
|
100
|
+
*/
|
|
101
|
+
function send(descriptor) {
|
|
102
|
+
const sender = SENDERS[descriptor.provider];
|
|
103
|
+
if (!sender) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return sender(descriptor);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { send };
|