@jx3box/jx3box-ui 2.4.2 → 2.4.4

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.
@@ -0,0 +1,504 @@
1
+ import {
2
+ createAnalyticsCore,
3
+ createCompositeRuleResolver,
4
+ createEventQueue,
5
+ createIdentity,
6
+ createQueueStorage,
7
+ createRemoteRuleResolver,
8
+ createTrafficSink,
9
+ defaultAckDecoder,
10
+ installVueRouterAnalytics,
11
+ } from "@jx3box/jx3box-common/js/analytics.js";
12
+
13
+ const DEFAULT_CONFIG_ENDPOINT = "/api/cms/system/traffic/config";
14
+ const DEFAULT_BATCH_ENDPOINT = "/api/cms/system/traffic/visits/batch";
15
+ const DEFAULT_QUEUE_STORAGE_KEY = "jx3box:analytics:traffic:queue:v1";
16
+ const DEFAULT_BLOCK_STORAGE_KEY = "jx3box:analytics:traffic:block:v1";
17
+ const EMBEDDED_SURFACES = new Set(["app", "miniprogram", "pc_game", "mobile_game"]);
18
+ const COMMON_HEADER_INSTALLATIONS = new WeakMap();
19
+ const COMMON_HEADER_TRAFFIC_PROJECT = "jx3box-ui";
20
+
21
+ function queryValue(runtime, key) {
22
+ const location = (runtime && runtime.location) || {};
23
+ const sources = [location.search, String(location.hash || "").split("?")[1]];
24
+ for (const source of sources) {
25
+ if (!source) continue;
26
+ const value = new URLSearchParams(String(source).replace(/^\?/, "")).get(key);
27
+ if (value !== null) return value;
28
+ }
29
+ return "";
30
+ }
31
+
32
+ function resolvePcTrafficGameClient(runtime) {
33
+ const explicitClient = String(queryValue(runtime, "client") || "").toLowerCase();
34
+ if (explicitClient === "std" || explicitClient === "origin") return explicitClient;
35
+ const hostname = String(runtime?.location?.hostname || "").toLowerCase();
36
+ if (hostname === "origin.jx3box.com") return "origin";
37
+ return "std";
38
+ }
39
+
40
+ function safeReason(value, fallback) {
41
+ const reason = String(value || fallback || "traffic_disabled")
42
+ .trim()
43
+ .slice(0, 128);
44
+ return /^[a-zA-Z0-9][a-zA-Z0-9_.:-]*$/.test(reason) ? reason : "traffic_disabled";
45
+ }
46
+
47
+ function safeStorage(storage) {
48
+ if (!storage || typeof storage.getItem !== "function") return null;
49
+ return storage;
50
+ }
51
+
52
+ function normalizeRecipientDomain(domain, surface) {
53
+ const normalizedSurface = String(surface || "")
54
+ .trim()
55
+ .toLowerCase();
56
+ if (EMBEDDED_SURFACES.has(normalizedSurface)) return "embedded";
57
+ const normalizedDomain = String(domain || "")
58
+ .trim()
59
+ .toLowerCase()
60
+ .slice(0, 253);
61
+ return /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/.test(normalizedDomain) ? normalizedDomain : "";
62
+ }
63
+
64
+ function createBlockStorage(storage, key, now) {
65
+ const target = safeStorage(storage);
66
+ let loaded = false;
67
+ let currentBlock = null;
68
+
69
+ function read() {
70
+ if (loaded) return currentBlock ? Object.assign({}, currentBlock) : null;
71
+ loaded = true;
72
+ if (!target) return null;
73
+ try {
74
+ const value = JSON.parse(target.getItem(key) || "null");
75
+ if (!value || value.blocked !== true) return null;
76
+ currentBlock = {
77
+ reason: safeReason(value.reason),
78
+ clear: true,
79
+ };
80
+ return Object.assign({}, currentBlock);
81
+ } catch (error) {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ function write(reason) {
87
+ const block = {
88
+ blocked: true,
89
+ reason: safeReason(reason),
90
+ blocked_at: typeof now === "function" ? now() : Date.now(),
91
+ };
92
+ loaded = true;
93
+ currentBlock = { reason: block.reason, clear: true };
94
+ if (target) {
95
+ try {
96
+ target.setItem(key, JSON.stringify(block));
97
+ } catch (error) {
98
+ // Privacy controls remain effective in memory when storage is unavailable.
99
+ }
100
+ }
101
+ return Object.assign({}, currentBlock);
102
+ }
103
+
104
+ function clear() {
105
+ loaded = true;
106
+ currentBlock = null;
107
+ if (!target) return;
108
+ try {
109
+ target.removeItem(key);
110
+ } catch (error) {
111
+ // Best effort only. The current queue is still explicitly unblocked below.
112
+ }
113
+ }
114
+
115
+ return { clear, read, write };
116
+ }
117
+
118
+ function normalizeTrafficPermission(value) {
119
+ if (value === true) return { known: true, allow: true };
120
+ if (value === false) {
121
+ return { known: true, allow: false, reason: "traffic_disabled" };
122
+ }
123
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
124
+ return { known: false, allow: false };
125
+ }
126
+
127
+ const source = value.data && typeof value.data === "object" ? value.data : value;
128
+ const rawBlock = source.blocked || source.block;
129
+ const blockReason = typeof rawBlock === "string" ? rawBlock : rawBlock && (rawBlock.reason || rawBlock.code);
130
+ if (blockReason || source.is_robot === true) {
131
+ return {
132
+ known: true,
133
+ allow: false,
134
+ reason: safeReason(blockReason || "robot"),
135
+ };
136
+ }
137
+
138
+ const allowed =
139
+ typeof source.traffic_allowed === "boolean"
140
+ ? source.traffic_allowed
141
+ : typeof source.allowed === "boolean"
142
+ ? source.allowed
143
+ : source.allow;
144
+ if (allowed === true) return { known: true, allow: true };
145
+ if (allowed === false) {
146
+ return {
147
+ known: true,
148
+ allow: false,
149
+ reason: safeReason(
150
+ source.collection_blocked_reason || source.block_reason || source.reason || "traffic_disabled"
151
+ ),
152
+ };
153
+ }
154
+ return { known: false, allow: false };
155
+ }
156
+
157
+ /**
158
+ * Creates an inert, headless Traffic collector. Hosts must explicitly call
159
+ * `init()` after their router and heartbeat permission are ready.
160
+ */
161
+ function createJx3boxTrafficAnalytics(options) {
162
+ const settings = options || {};
163
+ const runtime = settings.runtime || (typeof window !== "undefined" ? window : {});
164
+ const now = typeof settings.now === "function" ? settings.now : Date.now;
165
+ const recipientDomain = normalizeRecipientDomain(
166
+ settings.domain || (runtime.location && runtime.location.hostname),
167
+ settings.surface
168
+ );
169
+ const blockStorage = createBlockStorage(
170
+ settings.blockStorage || runtime.localStorage,
171
+ settings.blockStorageKey || DEFAULT_BLOCK_STORAGE_KEY,
172
+ now
173
+ );
174
+ const permissionProvider =
175
+ settings.resolveTrafficPermission !== undefined ? settings.resolveTrafficPermission : settings.trafficAllowed;
176
+ let hasPermissionOverride = false;
177
+ let permissionOverride;
178
+ let client = null;
179
+ let queue = null;
180
+ let routerHandle = null;
181
+ let initPromise = null;
182
+ let initialized = false;
183
+ let destroyed = false;
184
+
185
+ function resolvePermissionInput() {
186
+ if (hasPermissionOverride) return Promise.resolve(permissionOverride);
187
+ if (typeof permissionProvider === "function") {
188
+ try {
189
+ return Promise.resolve(permissionProvider()).catch(function () {
190
+ return undefined;
191
+ });
192
+ } catch (error) {
193
+ return Promise.resolve(undefined);
194
+ }
195
+ }
196
+ return Promise.resolve(permissionProvider);
197
+ }
198
+
199
+ function persistAndBlock(reason) {
200
+ const block = blockStorage.write(reason);
201
+ if (client) client.block(block, { clear: true, cancelInflight: true });
202
+ return block;
203
+ }
204
+
205
+ function applyPermission(value) {
206
+ const permission = normalizeTrafficPermission(value);
207
+ if (!permission.known) {
208
+ // Unknown heartbeat/consent state is a transient fail-closed pause.
209
+ // Keep already-sanitized Journal entries for a later explicit allow,
210
+ // but cancel any request that may have started under an older grant.
211
+ if (client) {
212
+ client.block(
213
+ { reason: "traffic_permission_unknown", clear: false },
214
+ {
215
+ clear: false,
216
+ cancelInflight: true,
217
+ }
218
+ );
219
+ }
220
+ return permission;
221
+ }
222
+ if (!permission.allow) {
223
+ persistAndBlock(permission.reason);
224
+ return permission;
225
+ }
226
+ blockStorage.clear();
227
+ if (client) client.unblock();
228
+ return permission;
229
+ }
230
+
231
+ async function guardBeforeFlush(context) {
232
+ const permission = applyPermission(await resolvePermissionInput());
233
+ if (!permission.known) return { allow: false };
234
+ if (!permission.allow) {
235
+ return {
236
+ blocked: { reason: permission.reason, clear: true },
237
+ clear: true,
238
+ cancelInflight: true,
239
+ };
240
+ }
241
+ if (typeof settings.beforeFlush !== "function") return { allow: true };
242
+ const result = await settings.beforeFlush(context);
243
+ if (result && typeof result === "object" && (result.blocked || result.block)) {
244
+ const blocked = normalizeTrafficPermission(result);
245
+ if (!blocked.allow) persistAndBlock(blocked.reason);
246
+ }
247
+ return result;
248
+ }
249
+
250
+ function buildClient() {
251
+ if (!settings.router || typeof settings.router.afterEach !== "function") {
252
+ throw new Error("jx3box traffic analytics requires a Vue Router instance");
253
+ }
254
+ if (!settings.project || !settings.surface || !settings.client || !settings.gameClient) {
255
+ throw new Error("jx3box traffic analytics requires project, surface, client and gameClient");
256
+ }
257
+
258
+ const identity =
259
+ settings.identity ||
260
+ createIdentity({
261
+ runtime,
262
+ now,
263
+ instanceId: settings.instanceId,
264
+ sessionNamespace: settings.sessionNamespace || "analytics",
265
+ sessionTimeoutMs: settings.sessionTimeoutMs,
266
+ });
267
+ const baseAckDecoder = typeof settings.ackDecoder === "function" ? settings.ackDecoder : defaultAckDecoder;
268
+ const trafficSink = createTrafficSink({
269
+ runtime,
270
+ endpoint: settings.batchEndpoint || DEFAULT_BATCH_ENDPOINT,
271
+ fetch: settings.fetch,
272
+ navigator: settings.navigator,
273
+ credentials: settings.credentials,
274
+ headersProvider: settings.headersProvider,
275
+ ackDecoder: function (payload, context) {
276
+ const decoded = baseAckDecoder(payload, context);
277
+ if (decoded && typeof decoded === "object" && (decoded.blocked || decoded.block)) {
278
+ const blocked = normalizeTrafficPermission(decoded);
279
+ if (!blocked.allow) blockStorage.write(blocked.reason);
280
+ }
281
+ return decoded;
282
+ },
283
+ retryPolicy: settings.retryPolicy,
284
+ });
285
+ const storage = createQueueStorage({
286
+ storage: settings.queueStorage || runtime.localStorage,
287
+ key: settings.queueStorageKey || DEFAULT_QUEUE_STORAGE_KEY,
288
+ maxEvents: settings.maxPersistedEvents || 200,
289
+ maxBytes: settings.maxPersistedBytes || 256 * 1024,
290
+ ttlMs: settings.persistTtlMs || 7 * 24 * 60 * 60 * 1000,
291
+ now,
292
+ });
293
+ queue = createEventQueue({
294
+ runtime,
295
+ storage,
296
+ sinks: [trafficSink],
297
+ // A v1 entry has no sink ownership. It belongs to legacy Tracking;
298
+ // since this adapter deliberately has no Tracking sink, it is dropped.
299
+ legacySinkKey: "tracking",
300
+ beforeFlush: guardBeforeFlush,
301
+ batchSize: settings.batchSize || 20,
302
+ maxEvents: settings.maxQueueEvents || 200,
303
+ maxBatchBytes: settings.maxBatchBytes || 60 * 1024,
304
+ flushIntervalMs: settings.flushIntervalMs || 10000,
305
+ maxRetries: settings.maxRetries === undefined ? 5 : settings.maxRetries,
306
+ retryBaseMs: settings.retryBaseMs || 1000,
307
+ now,
308
+ random: settings.random,
309
+ setTimeout: settings.queueSetTimeout,
310
+ clearTimeout: settings.queueClearTimeout,
311
+ onDrop: settings.onDrop,
312
+ });
313
+ const trafficRuleResolver =
314
+ settings.trafficRuleResolver ||
315
+ createRemoteRuleResolver({
316
+ runtime,
317
+ endpoint: settings.configEndpoint || DEFAULT_CONFIG_ENDPOINT,
318
+ fetch: settings.fetch,
319
+ credentials: settings.credentials,
320
+ });
321
+ const compositeRuleResolver = createCompositeRuleResolver({ traffic: trafficRuleResolver });
322
+ const ruleResolver = {
323
+ resolve: async function (input) {
324
+ const resolved = await compositeRuleResolver.resolve(
325
+ Object.assign({}, input || {}, { domain: recipientDomain })
326
+ );
327
+ return resolved ? Object.assign({}, resolved, { domain: recipientDomain }) : null;
328
+ },
329
+ };
330
+ client = createAnalyticsCore({
331
+ runtime,
332
+ identity,
333
+ queue,
334
+ ruleResolver,
335
+ now,
336
+ product: settings.product || "jx3box",
337
+ project: settings.project,
338
+ client: settings.client,
339
+ surface: settings.surface,
340
+ gameClient: settings.gameClient,
341
+ platform: settings.platform,
342
+ channel: settings.channel,
343
+ appVersion: settings.appVersion,
344
+ appBuild: settings.appBuild,
345
+ webVersion: settings.webVersion,
346
+ displayMode: settings.displayMode || "browser",
347
+ sampleSalt: settings.sampleSalt || "jx3box-analytics-v2",
348
+ });
349
+ }
350
+
351
+ async function init() {
352
+ if (destroyed) throw new Error("jx3box traffic analytics has been destroyed");
353
+ if (initialized) return api;
354
+ if (initPromise) return initPromise;
355
+ initPromise = Promise.resolve()
356
+ .then(async function () {
357
+ buildClient();
358
+ const persistedBlock = blockStorage.read();
359
+ if (persistedBlock) client.block(persistedBlock, { clear: true, cancelInflight: true });
360
+ const permission = await resolvePermissionInput();
361
+ if (destroyed) {
362
+ if (client) client.destroy();
363
+ throw new Error("jx3box traffic analytics has been destroyed");
364
+ }
365
+ applyPermission(permission);
366
+ routerHandle = installVueRouterAnalytics(client, settings.router, {
367
+ runtime,
368
+ captureInitial: settings.captureInitial !== false,
369
+ flushBeaconOnPagehide: settings.flushBeaconOnPagehide !== false,
370
+ project: settings.project,
371
+ product: settings.product || "jx3box",
372
+ client: settings.client,
373
+ surface: settings.surface,
374
+ gameClient: settings.gameClient,
375
+ domain: settings.domain,
376
+ });
377
+ initialized = true;
378
+ return api;
379
+ })
380
+ .catch(function (error) {
381
+ if (routerHandle) routerHandle.destroy();
382
+ else if (client) client.destroy();
383
+ routerHandle = null;
384
+ client = null;
385
+ queue = null;
386
+ initPromise = null;
387
+ throw error;
388
+ });
389
+ return initPromise;
390
+ }
391
+
392
+ function setTrafficPermission(value) {
393
+ hasPermissionOverride = true;
394
+ permissionOverride = value;
395
+ if (destroyed) return normalizeTrafficPermission(value);
396
+ return applyPermission(value);
397
+ }
398
+
399
+ function block(reason) {
400
+ return setTrafficPermission({
401
+ traffic_allowed: false,
402
+ collection_blocked_reason: safeReason(reason, "host_block"),
403
+ });
404
+ }
405
+
406
+ function unblock() {
407
+ return setTrafficPermission({ traffic_allowed: true });
408
+ }
409
+
410
+ function getState() {
411
+ return {
412
+ initialized,
413
+ destroyed,
414
+ permission_block: blockStorage.read(),
415
+ analytics: client ? client.getState() : null,
416
+ };
417
+ }
418
+
419
+ function destroy() {
420
+ if (destroyed) return;
421
+ destroyed = true;
422
+ if (routerHandle) routerHandle.destroy();
423
+ if (client) client.destroy();
424
+ routerHandle = null;
425
+ client = null;
426
+ queue = null;
427
+ initialized = false;
428
+ }
429
+
430
+ const api = {
431
+ init,
432
+ destroy,
433
+ block,
434
+ unblock,
435
+ setTrafficPermission,
436
+ getState,
437
+ flush: function (optionsForFlush) {
438
+ return client ? client.flush(optionsForFlush) : Promise.resolve({ sent: 0, pending: 0 });
439
+ },
440
+ flushBeacon: function (optionsForFlush) {
441
+ return client ? client.flushBeacon(optionsForFlush) : false;
442
+ },
443
+ finalize: function (optionsForFinalize) {
444
+ return client ? client.finalizePage(optionsForFinalize) : false;
445
+ },
446
+ clear: function (reason) {
447
+ return client ? client.clear(reason || "host_clear") : 0;
448
+ },
449
+ cancelInflight: function (reason) {
450
+ return client ? client.cancelInflight(reason || "host_cancel") : 0;
451
+ },
452
+ };
453
+ return api;
454
+ }
455
+
456
+ function installCommonHeaderTrafficAnalytics(options) {
457
+ const settings = options || {};
458
+ const router = settings.router;
459
+ if (!router || typeof router.afterEach !== "function") return Promise.resolve(null);
460
+ if (router.__jx3boxAnalyticsRouterOwner__) return Promise.resolve(null);
461
+ const existing = COMMON_HEADER_INSTALLATIONS.get(router);
462
+ if (existing) return existing;
463
+
464
+ const runtime = settings.runtime || (typeof window !== "undefined" ? window : {});
465
+ const surface = settings.surface || "pc_web";
466
+ if (surface !== "pc_web" && surface !== "mobile_web") return Promise.resolve(null);
467
+ const project = COMMON_HEADER_TRAFFIC_PROJECT;
468
+
469
+ const task = Promise.resolve(typeof router.isReady === "function" ? router.isReady() : undefined)
470
+ .then(function () {
471
+ const collector = createJx3boxTrafficAnalytics({
472
+ runtime,
473
+ router,
474
+ project,
475
+ product: "jx3box",
476
+ client: surface,
477
+ surface,
478
+ gameClient: function () { return resolvePcTrafficGameClient(runtime); },
479
+ platform: settings.platform || "web",
480
+ webVersion: settings.webVersion,
481
+ instanceId: settings.instanceId,
482
+ resolveTrafficPermission: settings.resolveTrafficPermission,
483
+ });
484
+ return collector.init().then(function () { return collector; });
485
+ })
486
+ .catch(function (error) {
487
+ COMMON_HEADER_INSTALLATIONS.delete(router);
488
+ throw error;
489
+ });
490
+ COMMON_HEADER_INSTALLATIONS.set(router, task);
491
+ return task;
492
+ }
493
+
494
+ export {
495
+ DEFAULT_BATCH_ENDPOINT,
496
+ DEFAULT_BLOCK_STORAGE_KEY,
497
+ DEFAULT_CONFIG_ENDPOINT,
498
+ DEFAULT_QUEUE_STORAGE_KEY,
499
+ createJx3boxTrafficAnalytics,
500
+ installCommonHeaderTrafficAnalytics,
501
+ normalizeRecipientDomain,
502
+ normalizeTrafficPermission,
503
+ resolvePcTrafficGameClient,
504
+ };
@@ -11,12 +11,23 @@ function assert(condition, message) {
11
11
  }
12
12
 
13
13
  assert(commonHeader.includes("installClientStatReporting()"), "CommonHeader should install client statistics");
14
+ assert(
15
+ commonHeader.includes("installCommonHeaderTrafficAnalytics"),
16
+ "CommonHeader should automatically install the shared Traffic collector"
17
+ );
14
18
  assert(
15
19
  commonHeader.includes("checkClientStatOnVisible()") && commonHeader.includes("visibilitychange"),
16
20
  "CommonHeader should retry statistics when the page becomes visible"
17
21
  );
18
22
  assert(clientStat.includes('const INSTANCE_ID_KEY = "jx3box:device_id"'), "PC should reuse the client instance id key");
19
23
  assert(clientStat.includes('product: "jx3box"'), "heartbeat should identify the JX3BOX product");
24
+ assert(clientStat.includes('app_version: "jx3box-ui"'), "PC heartbeat should identify the shared header reporter");
25
+ assert(clientStat.includes("app_build: PACKAGE_VERSION"), "PC heartbeat should report jx3box-ui package.json.version");
26
+ assert(
27
+ clientStat.includes("const WEB_VERSION = `jx3box-ui@${PACKAGE_VERSION}`"),
28
+ "PC heartbeat should namespace its web version"
29
+ );
30
+ assert(clientStat.includes("web_version: WEB_VERSION"), "PC heartbeat should report the namespaced web version");
20
31
  assert(clientStat.includes('const STAT_SDK_VERSION = "v0.0.2"'), "statistics SDK version should remain explicit");
21
32
  assert(clientStat.includes('return "pc_web"'), "desktop browser traffic should report pc_web");
22
33
  assert(clientStat.includes('return "mobile_web"'), "mobile browsers visiting PC pages should report mobile_web");
@@ -30,10 +41,6 @@ assert(
30
41
  clientStat.includes("/ArkWeb|HarmonyOS|OpenHarmony/i") && clientStat.includes('os_name: "OpenHarmony"'),
31
42
  "PC statistics should normalize OpenHarmony before reporting"
32
43
  );
33
- assert(
34
- clientStat.includes("window.__APP_VERSION__ || process.env.VUE_APP_VERSION || process.env.VITE_APP_VERSION"),
35
- "web version should use the shared app version fallback chain"
36
- );
37
44
  assert(!clientStat.includes("__JX3BOX_VERSION__"), "shared statistics must not depend on a JX3BOX-specific version global");
38
45
  assert(!clientStat.includes("VUE_APP_BUILD_VERSION"), "web version should not use the deprecated build-version fallback");
39
46
  assert(clientStat.includes("/api/cms/system/stat/heartbeat"), "heartbeat should use the new statistics endpoint");