@makefully/adaptfully 3.13.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 CHANGED
@@ -2,6 +2,20 @@
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
+
12
+ ## 3.14.0 — 2026-08-21
13
+
14
+ ### Added
15
+
16
+ - **`platform.supportsLogout()`** — whether `logout()` yields a durable signed-out state. Google, social, and dev return `true`; Steam returns `false` (Steamworks identity returns on `autoLogin`). Defaults to `false` when the auth plugin omits the method. Games should hide Sign out UI when this is false.
17
+ - **`platform.requiresEmail()`** — whether a complete identity needs email in addition to id. Google, social, and dev return `true`; Steam returns `false`. Defaults to `true` when unimplemented. Use instead of branching on `auth.name === 'steam'` for session completeness and account-sync routing.
18
+
5
19
  ## 3.13.0 — 2026-07-24
6
20
 
7
21
  ### 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 |
@@ -185,7 +213,7 @@ When `steam-auth` is registered on an **`electron`** platform, Adaptfully prebui
185
213
  - **`main.js`** — Electron shell with Steam overlay enabled and a preload script wired in
186
214
  - **`preload.js`** — initializes [steamworks.js](https://github.com/ceifa/steamworks.js) with `platforms.<name>.steamId` and exposes `window.__ADAPTFULLY_STEAMWORKS__`
187
215
 
188
- The renderer `steam-auth` plugin reads the Steam ID from that bridge. When Steam is available, `autoLogin()` succeeds immediately with `{ id: steamId64, email: '' }`. Optional config keys:
216
+ The renderer `steam-auth` plugin reads the Steam ID from that bridge. When Steam is available, `autoLogin()` succeeds immediately with `{ id: steamId64, email: '' }`. `supportsLogout()` is `false` and `requiresEmail()` is `false` — games should not offer Sign out, and should treat id-only identity as complete. Optional config keys:
189
217
 
190
218
  | Key | Default | Purpose |
191
219
  |-----|---------|---------|
@@ -229,6 +257,9 @@ import {
229
257
  - **`runAdaptfullyStage('prebuild' | 'build' | 'deploy', platformKey, options)`** — run a pipeline stage programmatically.
230
258
  - **`platform.autoLogin(callback)`** — restore a prior session without UI when the auth plugin supports it (Google uses `lastLoggedIn` in storage and a cached OAuth token).
231
259
  - **`platform.supportsAutoLogin()`** — whether the active auth plugin can attempt automatic sign-in.
260
+ - **`platform.logout(callback)`** — clear / revoke the platform session when supported.
261
+ - **`platform.supportsLogout()`** — whether logout is a durable user-facing action. Steam returns `false` (identity is bound to the Steam client); Google, social, and dev return `true`. Prefer this over checking `auth.name` when showing Sign out UI.
262
+ - **`platform.requiresEmail()`** — whether `getUser()` must include email for a complete session. Steam returns `false` (SteamID64 alone); Google, social, and dev return `true`. Prefer this over checking `auth.name` for session sync and account-link routing.
232
263
 
233
264
  ---
234
265
 
@@ -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));
@@ -11,6 +11,8 @@
11
11
  * @property {(callback: AuthCallback) => void} login
12
12
  * @property {(callback: AuthCallback) => void} autoLogin
13
13
  * @property {() => boolean} [supportsAutoLogin]
14
+ * @property {() => boolean} [supportsLogout] — durable signed-out state (false for Steamworks-bound identity)
15
+ * @property {() => boolean} [requiresEmail] — whether getUser() must include email for a complete session
14
16
  * @property {(callback: () => void) => void} logout
15
17
  * @property {() => AuthUser | null} getUser
16
18
  * @property {() => boolean} isAuthenticated
@@ -58,6 +58,14 @@
58
58
  return true;
59
59
  }
60
60
 
61
+ supportsLogout() {
62
+ return true;
63
+ }
64
+
65
+ requiresEmail() {
66
+ return true;
67
+ }
68
+
61
69
  logout(callback) {
62
70
  const storage = getStorage();
63
71
  this.authenticated = false;
@@ -21,6 +21,14 @@
21
21
  return true;
22
22
  }
23
23
 
24
+ supportsLogout() {
25
+ return true;
26
+ }
27
+
28
+ requiresEmail() {
29
+ return true;
30
+ }
31
+
24
32
  #tokenKey() {
25
33
  return configValue('googleTokenKey', DEFAULT_TOKEN_KEY);
26
34
  }
@@ -74,6 +74,14 @@
74
74
  return true;
75
75
  }
76
76
 
77
+ supportsLogout() {
78
+ return true;
79
+ }
80
+
81
+ requiresEmail() {
82
+ return true;
83
+ }
84
+
77
85
  #autoLoginStorageKey() {
78
86
  return configValue('autoLoginStorageKey', DEFAULT_AUTO_LOGIN_KEY);
79
87
  }
@@ -118,6 +118,14 @@
118
118
  return true;
119
119
  }
120
120
 
121
+ supportsLogout() {
122
+ return false;
123
+ }
124
+
125
+ requiresEmail() {
126
+ return false;
127
+ }
128
+
121
129
  #autoLoginStorageKey() {
122
130
  return configValue('autoLoginStorageKey', DEFAULT_AUTO_LOGIN_KEY);
123
131
  }
@@ -78,6 +78,27 @@
78
78
  : typeof this.auth.autoLogin === 'function';
79
79
  }
80
80
 
81
+ /**
82
+ * Whether logout() yields a durable signed-out state for this auth backend.
83
+ * Games should hide Sign out UI when this is false (e.g. Steam).
84
+ * Defaults to false when the plugin does not implement the method.
85
+ */
86
+ supportsLogout() {
87
+ return typeof this.auth.supportsLogout === 'function'
88
+ ? this.auth.supportsLogout()
89
+ : false;
90
+ }
91
+
92
+ /**
93
+ * Whether a complete identity requires email in addition to id.
94
+ * Id-only backends (Steam) return false. Defaults to true when unimplemented.
95
+ */
96
+ requiresEmail() {
97
+ return typeof this.auth.requiresEmail === 'function'
98
+ ? this.auth.requiresEmail()
99
+ : true;
100
+ }
101
+
81
102
  logout(callback) {
82
103
  this.whenReady(() => {
83
104
  this.auth.logout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makefully/adaptfully",
3
- "version": "3.13.0",
3
+ "version": "3.15.0",
4
4
  "description": "Platform abstraction and Wrapfully deploy client for Makefully games",
5
5
  "type": "module",
6
6
  "main": "./lib/node/index.js",