@makefully/adaptfully 3.14.0 → 3.15.1

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## 3.15.1 — 2026-09-01
6
+
7
+ ### Fixed
8
+
9
+ - **Capacitor/Cordova HTML injection** — packager `<meta>` tags (CSP, viewport) are injected into `<head>`; script tags stay in the body `<!-- adaptfully -->` slot. Fixes browsers ignoring CSP delivered outside `<head>`.
10
+
11
+ ## 3.15.0 — 2026-08-24
12
+
13
+ ### Added
14
+
15
+ - **`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`.
16
+ - **`noop-analytics`** — same surface with no network I/O (local/dev and privacy-off builds).
17
+
5
18
  ## 3.14.0 — 2026-08-21
6
19
 
7
20
  ### 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 |
@@ -11,6 +11,8 @@ export const VALID_PACKAGERS = ['web', 'electron', 'cordova', 'capacitor'];
11
11
 
12
12
  const PACKAGER_MARKER = '<!-- adaptfully-packager -->';
13
13
  const PACKAGER_END_MARKER = '<!-- /adaptfully-packager -->';
14
+ const HEAD_PACKAGER_MARKER = '<!-- adaptfully-packager-head -->';
15
+ const HEAD_PACKAGER_END_MARKER = '<!-- /adaptfully-packager-head -->';
14
16
 
15
17
  /**
16
18
  * @typedef {Object} PackagerOptions
@@ -58,21 +60,64 @@ function writeGameConfig(dest, platformKey, pkg, log) {
58
60
 
59
61
  /**
60
62
  * @param {string} html
63
+ * @param {string} marker
64
+ * @param {string} endMarker
61
65
  * @param {string} injection
62
66
  */
63
- function injectPackagerExtras(html, injection) {
64
- if (!injection) {
65
- return html;
66
- }
67
-
67
+ function replaceMarkedBlock(html, marker, endMarker, injection) {
68
68
  const markerPattern = new RegExp(
69
- `${escapeRegExp(PACKAGER_MARKER)}[\\s\\S]*?${escapeRegExp(PACKAGER_END_MARKER)}\\n?`,
69
+ `${escapeRegExp(marker)}[\\s\\S]*?${escapeRegExp(endMarker)}\\n?`,
70
70
  );
71
71
 
72
72
  if (markerPattern.test(html)) {
73
73
  return html.replace(markerPattern, injection);
74
74
  }
75
75
 
76
+ if (html.includes(marker)) {
77
+ return html.replace(marker, `${injection}${marker}`);
78
+ }
79
+
80
+ return html;
81
+ }
82
+
83
+ /**
84
+ * @param {string} html
85
+ * @param {string} injection
86
+ */
87
+ function injectHeadPackagerExtras(html, injection) {
88
+ if (!injection) {
89
+ return html;
90
+ }
91
+
92
+ let updated = replaceMarkedBlock(html, HEAD_PACKAGER_MARKER, HEAD_PACKAGER_END_MARKER, injection);
93
+
94
+ if (updated === html && html.includes('</head>')) {
95
+ updated = html.replace('</head>', `${injection}</head>`);
96
+ } else if (updated === html) {
97
+ throw new Error(
98
+ 'Cannot inject packager head extras: HTML needs '
99
+ + `${HEAD_PACKAGER_MARKER}…${HEAD_PACKAGER_END_MARKER} or </head>`,
100
+ );
101
+ }
102
+
103
+ return updated;
104
+ }
105
+
106
+ /**
107
+ * @param {string} html
108
+ * @param {string} injection
109
+ */
110
+ function injectBodyPackagerExtras(html, injection) {
111
+ if (!injection) {
112
+ return html;
113
+ }
114
+
115
+ let updated = replaceMarkedBlock(html, PACKAGER_MARKER, PACKAGER_END_MARKER, injection);
116
+
117
+ if (updated !== html) {
118
+ return updated;
119
+ }
120
+
76
121
  if (html.includes(PACKAGER_MARKER)) {
77
122
  return html.replace(PACKAGER_MARKER, `${injection}${PACKAGER_MARKER}`);
78
123
  }
@@ -85,17 +130,36 @@ function injectPackagerExtras(html, injection) {
85
130
  return html.replace('<!-- scripts -->', `${injection}<!-- scripts -->`);
86
131
  }
87
132
 
88
- if (html.includes('</head>')) {
89
- return html.replace('</head>', `${injection}</head>`);
133
+ if (html.includes('</body>')) {
134
+ return html.replace('</body>', `${injection}</body>`);
90
135
  }
91
136
 
92
137
  throw new Error(
93
- 'Cannot inject packager extras: HTML needs '
138
+ 'Cannot inject packager body extras: HTML needs '
94
139
  + '<!-- adaptfully-packager -->…<!-- /adaptfully-packager -->, '
95
- + '<!-- adaptfully -->, <!-- scripts -->, or </head>',
140
+ + '<!-- adaptfully -->, <!-- scripts -->, or </body>',
96
141
  );
97
142
  }
98
143
 
144
+ /**
145
+ * @param {string} html
146
+ * @param {{ head?: string, body?: string }} injection
147
+ */
148
+ function injectPackagerExtras(html, injection) {
149
+ if (!injection?.head && !injection?.body) {
150
+ return html;
151
+ }
152
+
153
+ let updated = html;
154
+ if (injection.head) {
155
+ updated = injectHeadPackagerExtras(updated, injection.head);
156
+ }
157
+ if (injection.body) {
158
+ updated = injectBodyPackagerExtras(updated, injection.body);
159
+ }
160
+ return updated;
161
+ }
162
+
99
163
  function escapeRegExp(value) {
100
164
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
101
165
  }
@@ -243,22 +307,28 @@ export class Packager {
243
307
  /**
244
308
  * @param {string[]} headExtras
245
309
  * @param {string[]} bodyScripts
246
- * @returns {string}
310
+ * @returns {{ head: string, body: string }}
247
311
  */
248
312
  formatHtmlInjection(headExtras, bodyScripts) {
249
- if (headExtras.length === 0 && bodyScripts.length === 0) {
250
- return '';
313
+ let head = '';
314
+ if (headExtras.length > 0) {
315
+ head = `${HEAD_PACKAGER_MARKER}\n`;
316
+ for (const extra of headExtras) {
317
+ head += `${extra}\n`;
318
+ }
319
+ head += `${HEAD_PACKAGER_END_MARKER}\n`;
251
320
  }
252
321
 
253
- let block = `${PACKAGER_MARKER}\n`;
254
- for (const extra of headExtras) {
255
- block += `${extra}\n`;
256
- }
257
- for (const script of bodyScripts) {
258
- block += `${script}\n`;
322
+ let body = '';
323
+ if (bodyScripts.length > 0) {
324
+ body = `${PACKAGER_MARKER}\n`;
325
+ for (const script of bodyScripts) {
326
+ body += `${script}\n`;
327
+ }
328
+ body += `${PACKAGER_END_MARKER}\n`;
259
329
  }
260
- block += `${PACKAGER_END_MARKER}\n`;
261
- return block;
330
+
331
+ return { head, body };
262
332
  }
263
333
 
264
334
  /** @param {string} dest */
@@ -268,7 +338,7 @@ export class Packager {
268
338
  }
269
339
  }
270
340
 
271
- /** @returns {string} */
341
+ /** @returns {{ head: string, body: string }} */
272
342
  buildHtmlInjection() {
273
343
  const bodyScripts = [];
274
344
  if (this.needsGameConfig()) {
@@ -283,7 +353,7 @@ export class Packager {
283
353
  */
284
354
  applyHtmlExtras(dest, htmlPaths) {
285
355
  const injection = this.buildHtmlInjection();
286
- if (!injection) {
356
+ if (!injection.head && !injection.body) {
287
357
  return;
288
358
  }
289
359
 
@@ -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));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "3.14.0",
3
+ "version": "3.15.1",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",