@makefully/adaptfully 3.14.0 → 3.15.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/CHANGELOG.md +7 -0
- package/README.md +28 -0
- package/lib/node/registrations.js +8 -0
- package/lib/runtime/analytics/_helpers.js +139 -0
- package/lib/runtime/analytics/http.js +180 -0
- package/lib/runtime/analytics/noop.js +53 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## 3.15.0 — 2026-08-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`http-analytics`** — vendor-neutral analytics plugin that batches `track` events to `config.analyticsEndpoint` via `sendBeacon` / `fetch` (works on web, Electron `file://`, and Capacitor). Config: `analyticsEndpoint`, `analyticsGameId`, `analyticsEnabled`, optional `analyticsPlatform` / `analyticsAppVersion`. API: `track`, `identify`, `setContext`, `optOut` / `optIn` / `isOptedOut`, `flush`.
|
|
10
|
+
- **`noop-analytics`** — same surface with no network I/O (local/dev and privacy-off builds).
|
|
11
|
+
|
|
5
12
|
## 3.14.0 — 2026-08-21
|
|
6
13
|
|
|
7
14
|
### Added
|
package/README.md
CHANGED
|
@@ -89,6 +89,34 @@ storage.set('playerName', 'Ada');
|
|
|
89
89
|
storage.getObject('currentGame');
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
+
### Analytics plugins
|
|
93
|
+
|
|
94
|
+
| Plugin key | Registration | Runtime |
|
|
95
|
+
|------------|--------------|---------|
|
|
96
|
+
| `http-analytics` | `adaptfully.register('analytics', adaptfully.analytics.Http())` | Batches events to a first-party collector URL |
|
|
97
|
+
| `noop-analytics` | `adaptfully.register('analytics', adaptfully.analytics.Noop())` | Same API; no network (dev / opt-out builds) |
|
|
98
|
+
|
|
99
|
+
Register under the `analytics` key (same pattern as `auth` / `storage`). Point the HTTP plugin at your collector via Adaptfully **config**:
|
|
100
|
+
|
|
101
|
+
| Config key | Purpose |
|
|
102
|
+
|------------|---------|
|
|
103
|
+
| `analyticsEndpoint` | `POST` URL for event batches (required for HTTP) |
|
|
104
|
+
| `analyticsGameId` | Game id stamped on payloads (falls back to `window.gameConfig.id`) |
|
|
105
|
+
| `analyticsEnabled` | Set `false` to disable without swapping plugins |
|
|
106
|
+
| `analyticsPlatform` / `analyticsAppVersion` | Optional overrides; otherwise `window.gameConfig` |
|
|
107
|
+
|
|
108
|
+
In-game:
|
|
109
|
+
|
|
110
|
+
```javascript
|
|
111
|
+
const analytics = adaptfully.get('analytics');
|
|
112
|
+
analytics.setContext({ channel: 'web' });
|
|
113
|
+
analytics.identify(accountId); // opaque id only — no emails
|
|
114
|
+
analytics.track('game_start', { mapId: 28, players: 1 });
|
|
115
|
+
analytics.optOut(); // persists via storage when registered
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Games should call `adaptfully.has('analytics')` before `get` during rollout if older builds may omit the registration. Prefer named product events over fake page paths. Do not branch on Steam vs Capacitor for delivery — the HTTP plugin works across shells.
|
|
119
|
+
|
|
92
120
|
### Auth plugins
|
|
93
121
|
|
|
94
122
|
| Plugin key | Registration | Runtime |
|
|
@@ -38,6 +38,14 @@ export const STANDARD_PLUGINS = {
|
|
|
38
38
|
scripts: ['core.js', 'storage/_helpers.js', 'storage/indexed-db.js'],
|
|
39
39
|
registration: (key) => `adaptfully.register('${key}', adaptfully.storage.IndexedDB());`,
|
|
40
40
|
},
|
|
41
|
+
'http-analytics': {
|
|
42
|
+
scripts: ['core.js', 'analytics/_helpers.js', 'analytics/http.js'],
|
|
43
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.analytics.Http());`,
|
|
44
|
+
},
|
|
45
|
+
'noop-analytics': {
|
|
46
|
+
scripts: ['core.js', 'analytics/_helpers.js', 'analytics/noop.js'],
|
|
47
|
+
registration: (key) => `adaptfully.register('${key}', adaptfully.analytics.Noop());`,
|
|
48
|
+
},
|
|
41
49
|
};
|
|
42
50
|
|
|
43
51
|
/** Default Wrapfully builder → config.platforms key */
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared helpers for Adaptfully analytics plugins.
|
|
5
|
+
*
|
|
6
|
+
* Config keys (via adaptfully.get('config')):
|
|
7
|
+
* analyticsEndpoint — POST URL for event batches
|
|
8
|
+
* analyticsGameId — game id stamped on every event (e.g. 'entanglement')
|
|
9
|
+
* analyticsEnabled — when false, track is a no-op (default true when endpoint set)
|
|
10
|
+
* analyticsPlatform — optional override; else window.gameConfig.platform
|
|
11
|
+
* analyticsAppVersion — optional override; else window.gameConfig.version / config.appVersion
|
|
12
|
+
*/
|
|
13
|
+
(function registerAnalyticsHelpers(ns) {
|
|
14
|
+
const OPT_OUT_KEY = 'adaptfully_analytics_opt_out';
|
|
15
|
+
|
|
16
|
+
const helpers = {
|
|
17
|
+
configValue(key, fallback) {
|
|
18
|
+
if (!ns.has('config')) {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
const config = ns.get('config');
|
|
22
|
+
if (config && config[key] != null) {
|
|
23
|
+
return config[key];
|
|
24
|
+
}
|
|
25
|
+
return fallback;
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
getStorage() {
|
|
29
|
+
return ns.has('storage') ? ns.get('storage') : null;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
readOptOut() {
|
|
33
|
+
const storage = helpers.getStorage();
|
|
34
|
+
if (!storage) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const raw = storage.get(OPT_OUT_KEY);
|
|
39
|
+
return raw === true || raw === 'true' || raw === '1';
|
|
40
|
+
} catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
writeOptOut(value) {
|
|
46
|
+
const storage = helpers.getStorage();
|
|
47
|
+
if (!storage) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
if (value) {
|
|
52
|
+
storage.set(OPT_OUT_KEY, 'true');
|
|
53
|
+
} else {
|
|
54
|
+
storage.remove(OPT_OUT_KEY);
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// ignore quota / private browsing
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
resolvePlatform() {
|
|
62
|
+
const fromConfig = helpers.configValue('analyticsPlatform', null);
|
|
63
|
+
if (fromConfig) {
|
|
64
|
+
return String(fromConfig);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
if (typeof window !== 'undefined' && window.gameConfig && window.gameConfig.platform) {
|
|
68
|
+
return String(window.gameConfig.platform);
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// ignore
|
|
72
|
+
}
|
|
73
|
+
return 'unknown';
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
resolveAppVersion() {
|
|
77
|
+
const fromConfig = helpers.configValue('analyticsAppVersion', null)
|
|
78
|
+
|| helpers.configValue('appVersion', null);
|
|
79
|
+
if (fromConfig) {
|
|
80
|
+
return String(fromConfig);
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
if (typeof window !== 'undefined' && window.gameConfig && window.gameConfig.version) {
|
|
84
|
+
return String(window.gameConfig.version);
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
// ignore
|
|
88
|
+
}
|
|
89
|
+
return '';
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
resolveGameId() {
|
|
93
|
+
const fromConfig = helpers.configValue('analyticsGameId', null);
|
|
94
|
+
if (fromConfig) {
|
|
95
|
+
return String(fromConfig);
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
if (typeof window !== 'undefined' && window.gameConfig && window.gameConfig.id) {
|
|
99
|
+
return String(window.gameConfig.id);
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// ignore
|
|
103
|
+
}
|
|
104
|
+
return 'unknown';
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
createSessionId() {
|
|
108
|
+
try {
|
|
109
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
110
|
+
return crypto.randomUUID();
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
return `s_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
sanitizeProps(props) {
|
|
119
|
+
if (!props || typeof props !== 'object' || Array.isArray(props)) {
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
const out = {};
|
|
123
|
+
for (const [key, value] of Object.entries(props)) {
|
|
124
|
+
if (value === undefined) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const t = typeof value;
|
|
128
|
+
if (t === 'string' || t === 'number' || t === 'boolean' || value === null) {
|
|
129
|
+
out[key] = value;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
ns.analytics = ns.analytics || {};
|
|
137
|
+
ns.analytics.helpers = helpers;
|
|
138
|
+
ns.analytics.OPT_OUT_KEY = OPT_OUT_KEY;
|
|
139
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/* global window, navigator, fetch, Blob */
|
|
2
|
+
|
|
3
|
+
(function registerHttpAnalytics(ns) {
|
|
4
|
+
const {helpers} = ns.analytics;
|
|
5
|
+
const MAX_QUEUE = 50;
|
|
6
|
+
const FLUSH_MS = 2000;
|
|
7
|
+
const MAX_BATCH = 20;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* HTTP analytics — batches events to config.analyticsEndpoint.
|
|
11
|
+
* Works on web (https), Electron file://, and Capacitor schemes via fetch.
|
|
12
|
+
*/
|
|
13
|
+
ns.analytics.Http = function httpAnalyticsFactory() {
|
|
14
|
+
const sessionId = helpers.createSessionId();
|
|
15
|
+
let userId = null;
|
|
16
|
+
let context = {};
|
|
17
|
+
let optedOut = helpers.readOptOut();
|
|
18
|
+
/** @type {object[]} */
|
|
19
|
+
let queue = [];
|
|
20
|
+
let flushTimer = null;
|
|
21
|
+
let flushing = false;
|
|
22
|
+
|
|
23
|
+
function isEnabled() {
|
|
24
|
+
if (optedOut) {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
const enabled = helpers.configValue('analyticsEnabled', null);
|
|
28
|
+
if (enabled === false || enabled === 'false') {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const endpoint = helpers.configValue('analyticsEndpoint', '');
|
|
32
|
+
return typeof endpoint === 'string' && endpoint.length > 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function baseProps() {
|
|
36
|
+
return {
|
|
37
|
+
game: helpers.resolveGameId(),
|
|
38
|
+
platform: helpers.resolvePlatform(),
|
|
39
|
+
appVersion: helpers.resolveAppVersion(),
|
|
40
|
+
sessionId,
|
|
41
|
+
...context,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function scheduleFlush() {
|
|
46
|
+
if (flushTimer != null || flushing) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
flushTimer = setTimeout(() => {
|
|
50
|
+
flushTimer = null;
|
|
51
|
+
flush();
|
|
52
|
+
}, FLUSH_MS);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function deliver(body) {
|
|
56
|
+
const endpoint = helpers.configValue('analyticsEndpoint', '');
|
|
57
|
+
if (!endpoint) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const payload = JSON.stringify(body);
|
|
61
|
+
try {
|
|
62
|
+
if (typeof navigator !== 'undefined'
|
|
63
|
+
&& typeof navigator.sendBeacon === 'function') {
|
|
64
|
+
const blob = new Blob([payload], {type: 'application/json'});
|
|
65
|
+
if (navigator.sendBeacon(endpoint, blob)) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// fall through to fetch
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
fetch(endpoint, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: {'Content-Type': 'application/json'},
|
|
76
|
+
body: payload,
|
|
77
|
+
keepalive: true,
|
|
78
|
+
mode: 'cors',
|
|
79
|
+
credentials: 'omit',
|
|
80
|
+
}).catch(() => {
|
|
81
|
+
// drop on failure — do not block gameplay
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function flush() {
|
|
89
|
+
if (flushing || queue.length === 0 || !isEnabled()) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
flushing = true;
|
|
93
|
+
const batch = queue.splice(0, MAX_BATCH);
|
|
94
|
+
const body = {
|
|
95
|
+
game: helpers.resolveGameId(),
|
|
96
|
+
platform: helpers.resolvePlatform(),
|
|
97
|
+
appVersion: helpers.resolveAppVersion(),
|
|
98
|
+
sessionId,
|
|
99
|
+
userId: userId || undefined,
|
|
100
|
+
events: batch,
|
|
101
|
+
};
|
|
102
|
+
deliver(body);
|
|
103
|
+
flushing = false;
|
|
104
|
+
if (queue.length > 0) {
|
|
105
|
+
scheduleFlush();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
name: 'http',
|
|
111
|
+
|
|
112
|
+
track(name, props) {
|
|
113
|
+
if (!name || typeof name !== 'string' || !isEnabled()) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const event = {
|
|
117
|
+
name: String(name).slice(0, 64),
|
|
118
|
+
ts: Date.now(),
|
|
119
|
+
props: {
|
|
120
|
+
...baseProps(),
|
|
121
|
+
...helpers.sanitizeProps(props),
|
|
122
|
+
},
|
|
123
|
+
sessionId,
|
|
124
|
+
userId: userId || undefined,
|
|
125
|
+
};
|
|
126
|
+
queue.push(event);
|
|
127
|
+
if (queue.length > MAX_QUEUE) {
|
|
128
|
+
queue = queue.slice(-MAX_QUEUE);
|
|
129
|
+
}
|
|
130
|
+
if (queue.length >= MAX_BATCH) {
|
|
131
|
+
if (flushTimer != null) {
|
|
132
|
+
clearTimeout(flushTimer);
|
|
133
|
+
flushTimer = null;
|
|
134
|
+
}
|
|
135
|
+
flush();
|
|
136
|
+
} else {
|
|
137
|
+
scheduleFlush();
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
identify(id) {
|
|
142
|
+
userId = id == null || id === '' ? null : String(id).slice(0, 128);
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
setContext(partial) {
|
|
146
|
+
if (!partial || typeof partial !== 'object') {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
context = {...context, ...helpers.sanitizeProps(partial)};
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
optOut() {
|
|
153
|
+
optedOut = true;
|
|
154
|
+
helpers.writeOptOut(true);
|
|
155
|
+
queue = [];
|
|
156
|
+
if (flushTimer != null) {
|
|
157
|
+
clearTimeout(flushTimer);
|
|
158
|
+
flushTimer = null;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
optIn() {
|
|
163
|
+
optedOut = false;
|
|
164
|
+
helpers.writeOptOut(false);
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
isOptedOut() {
|
|
168
|
+
return optedOut;
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
/** Flush pending events immediately (page hide / tests). */
|
|
172
|
+
flush,
|
|
173
|
+
|
|
174
|
+
/** @internal test/debug */
|
|
175
|
+
_getState() {
|
|
176
|
+
return {userId, context, optedOut, queue: queue.slice(), sessionId};
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
}(window.adaptfully));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* global window */
|
|
2
|
+
|
|
3
|
+
(function registerNoopAnalytics(ns) {
|
|
4
|
+
const {helpers} = ns.analytics;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* No-op analytics — same surface as http-analytics, no network.
|
|
8
|
+
* Use for local/dev platforms or privacy-off builds.
|
|
9
|
+
*/
|
|
10
|
+
ns.analytics.Noop = function noopAnalyticsFactory() {
|
|
11
|
+
let userId = null;
|
|
12
|
+
let context = {};
|
|
13
|
+
let optedOut = helpers.readOptOut();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
name: 'noop',
|
|
17
|
+
|
|
18
|
+
track(_name, _props) {
|
|
19
|
+
// intentionally empty
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
identify(id) {
|
|
23
|
+
userId = id == null || id === '' ? null : String(id);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
setContext(partial) {
|
|
27
|
+
if (!partial || typeof partial !== 'object') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
context = {...context, ...helpers.sanitizeProps(partial)};
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
optOut() {
|
|
34
|
+
optedOut = true;
|
|
35
|
+
helpers.writeOptOut(true);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
optIn() {
|
|
39
|
+
optedOut = false;
|
|
40
|
+
helpers.writeOptOut(false);
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
isOptedOut() {
|
|
44
|
+
return optedOut;
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
/** @internal test/debug */
|
|
48
|
+
_getState() {
|
|
49
|
+
return {userId, context, optedOut};
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
}(window.adaptfully));
|