@virjay/playscope-cocos 0.0.0-stage → 0.2.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 +30 -0
- package/README.md +314 -2
- package/USAGE.md +372 -0
- package/VALIDATION.md +93 -0
- package/dist/adapters/levelplay/index.d.ts +15 -0
- package/dist/adapters/levelplay/index.js +14 -0
- package/dist/cocos/Storage.d.ts +16 -0
- package/dist/cocos/Storage.js +16 -0
- package/dist/cocos/XhrJsonTransport.d.ts +8 -0
- package/dist/cocos/XhrJsonTransport.js +65 -0
- package/dist/cocos/index.d.ts +48 -0
- package/dist/cocos/index.js +134 -0
- package/dist/core/ClientOwnership.d.ts +6 -0
- package/dist/core/ClientOwnership.js +39 -0
- package/dist/core/ClientScope.d.ts +5 -0
- package/dist/core/ClientScope.js +34 -0
- package/dist/core/ConfigurationSync.d.ts +18 -0
- package/dist/core/ConfigurationSync.js +66 -0
- package/dist/core/FactEncoding.d.ts +10 -0
- package/dist/core/FactEncoding.js +29 -0
- package/dist/core/HttpError.d.ts +10 -0
- package/dist/core/HttpError.js +17 -0
- package/dist/core/OutboxStore.d.ts +31 -0
- package/dist/core/OutboxStore.js +228 -0
- package/dist/core/PersistentState.d.ts +33 -0
- package/dist/core/PersistentState.js +92 -0
- package/dist/core/PlayScopeClient.d.ts +65 -0
- package/dist/core/PlayScopeClient.js +422 -0
- package/dist/core/QueueAdmission.d.ts +4 -0
- package/dist/core/QueueAdmission.js +29 -0
- package/dist/core/index.d.ts +7 -0
- package/dist/core/index.js +27 -0
- package/dist/core/revenue.d.ts +33 -0
- package/dist/core/revenue.js +23 -0
- package/dist/core/types.d.ts +155 -0
- package/dist/core/types.js +2 -0
- package/dist/core/uuid.d.ts +1 -0
- package/dist/core/uuid.js +9 -0
- package/dist/esm/adapters/levelplay/index.js +11 -0
- package/dist/esm/cocos/Storage.js +12 -0
- package/dist/esm/cocos/XhrJsonTransport.js +61 -0
- package/dist/esm/cocos/index.js +115 -0
- package/dist/esm/core/ClientOwnership.js +34 -0
- package/dist/esm/core/ClientScope.js +30 -0
- package/dist/esm/core/ConfigurationSync.js +62 -0
- package/dist/esm/core/FactEncoding.js +24 -0
- package/dist/esm/core/HttpError.js +12 -0
- package/dist/esm/core/OutboxStore.js +224 -0
- package/dist/esm/core/PersistentState.js +85 -0
- package/dist/esm/core/PlayScopeClient.js +415 -0
- package/dist/esm/core/QueueAdmission.js +25 -0
- package/dist/esm/core/index.js +7 -0
- package/dist/esm/core/revenue.js +20 -0
- package/dist/esm/core/types.js +1 -0
- package/dist/esm/core/uuid.js +6 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/protocols/game-v1/index.js +21 -0
- package/dist/protocols/game-v1/index.d.ts +8 -0
- package/dist/protocols/game-v1/index.js +25 -0
- package/doc/README.md +4 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227/README.md +585 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/adapters/levelplay/index.d.ts +15 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/Storage.d.ts +13 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/XhrJsonTransport.d.ts +8 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/cocos/index.d.ts +38 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/PlayScopeClient.d.ts +46 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/index.d.ts +3 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/revenue.d.ts +25 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/0.1.1-types/core/types.d.ts +81 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/exports.json +89 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231/public-api.d.ts +281 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/client-context.schema.json +103 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-ad-properties.json +765 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-builtin-properties.json +479 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/game-event.json +278 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/product-events.json +2878 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257/standard-contract.json +183 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/234/215/345/212/241/347/253/257//344/272/247/345/223/201/344/272/213/344/273/266/347/264/242/345/274/225.md +87 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//345/217/202/350/200/203/350/265/204/346/226/231//346/235/245/346/272/220/344/270/216/347/211/210/346/234/254.json +35 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/CoreIntegration.ts +13 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/LevelPlayTelemetry.ts +119 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/PlayScopeExample.ts +46 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/QuestGarden.ts +129 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/README.md +23 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/project-fixture.json +188 -0
- package/doc//344/275/277/347/224/250/346/214/207/345/215/227//347/244/272/344/276/213/tsconfig.json +20 -0
- package/examples/cocos-minimal/PlayScopeExample.ts +27 -0
- package/examples/quest-garden/QuestGarden.ts +129 -0
- package/examples/quest-garden/README.md +180 -0
- package/examples/quest-garden/project-fixture.json +188 -0
- package/examples/quest-garden/tsconfig.json +12 -0
- package/package.json +73 -4
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.CocosPlayScope = void 0;
|
|
18
|
+
exports.init = init;
|
|
19
|
+
const core_1 = require("../core");
|
|
20
|
+
const game_v1_1 = require("../protocols/game-v1");
|
|
21
|
+
const Storage_1 = require("./Storage");
|
|
22
|
+
const XhrJsonTransport_1 = require("./XhrJsonTransport");
|
|
23
|
+
__exportStar(require("../core"), exports);
|
|
24
|
+
__exportStar(require("./Storage"), exports);
|
|
25
|
+
__exportStar(require("./XhrJsonTransport"), exports);
|
|
26
|
+
/** Host owns event semantics; this wrapper only schedules flush and releases its subscriptions. */
|
|
27
|
+
class CocosPlayScope {
|
|
28
|
+
constructor(options, runtime) {
|
|
29
|
+
this.off = [];
|
|
30
|
+
this.elapsed = 0;
|
|
31
|
+
this.disposed = false;
|
|
32
|
+
this.revenueEncoder = options.adRevenueEncoder ?? game_v1_1.encodeGameV1AdRevenue;
|
|
33
|
+
this.client = options.collectionEnabled ? new core_1.PlayScopeClient(options, new Storage_1.CocosStorage(runtime.storage), runtime.transport ?? new XhrJsonTransport_1.XhrJsonTransport(), runtime.now) : null;
|
|
34
|
+
if (this.client && runtime.lifecycle) {
|
|
35
|
+
try {
|
|
36
|
+
this.off.push(runtime.lifecycle.onHide(() => { void this.flush(); }));
|
|
37
|
+
this.off.push(runtime.lifecycle.onShow(() => { void this.flush(); }));
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
// Preserve the registration error while releasing any partially acquired resources.
|
|
41
|
+
try {
|
|
42
|
+
this.dispose();
|
|
43
|
+
}
|
|
44
|
+
catch { /* All cleanup steps have already been attempted. */ }
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
start() { if (!this.disposed) {
|
|
50
|
+
this.client?.start();
|
|
51
|
+
void this.flush();
|
|
52
|
+
} }
|
|
53
|
+
get installId() { return this.client?.installId ?? null; }
|
|
54
|
+
getDiagnostics() {
|
|
55
|
+
return this.client?.diagnostics ??
|
|
56
|
+
{ pending: 0, rejected: 0, droppedEvents: 0, lastError: null, blocked: false, quarantined: 0,
|
|
57
|
+
pendingBytes: 0, quarantinedBytes: 0, maxQueueBytes: 0, persistence: 'snapshot', cleanupPending: false };
|
|
58
|
+
}
|
|
59
|
+
getQuarantinedEvents() { return this.client?.getQuarantinedEvents() ?? []; }
|
|
60
|
+
get status() {
|
|
61
|
+
if (this.client)
|
|
62
|
+
return this.client.status;
|
|
63
|
+
return { collection: this.disposed ? 'disposed' : 'disabled', delivery: this.disposed ? 'disposed' : 'disabled',
|
|
64
|
+
pendingDurability: 'persisted', retryAt: 0,
|
|
65
|
+
configuration: { state: this.disposed ? 'disposed' : 'disabled', nextAttemptAt: 0, lastError: null } };
|
|
66
|
+
}
|
|
67
|
+
track(name, properties = {}, context = {}) {
|
|
68
|
+
return this.trackWithResult(name, properties, context).status === 'accepted';
|
|
69
|
+
}
|
|
70
|
+
trackWithResult(name, properties = {}, context = {}) {
|
|
71
|
+
if (this.disposed)
|
|
72
|
+
return { status: 'disposed' };
|
|
73
|
+
return this.client?.trackWithResult(name, properties, context) ?? { status: 'collection_disabled' };
|
|
74
|
+
}
|
|
75
|
+
trackAdRevenue(fact, context = {}) {
|
|
76
|
+
return !this.disposed && !!this.client && (0, core_1.enqueueAdRevenue)(this.client, fact, context, this.revenueEncoder);
|
|
77
|
+
}
|
|
78
|
+
newSession() { if (!this.disposed)
|
|
79
|
+
this.client?.newSession(); }
|
|
80
|
+
/** Call from a persistent Cocos component's update(dt), in seconds. */
|
|
81
|
+
update(dt) {
|
|
82
|
+
if (this.disposed || !Number.isFinite(dt) || dt <= 0 || dt > 1)
|
|
83
|
+
return;
|
|
84
|
+
this.elapsed += dt;
|
|
85
|
+
if (this.elapsed >= 5) {
|
|
86
|
+
this.elapsed = 0;
|
|
87
|
+
void this.flush();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async flush() { if (!this.disposed)
|
|
91
|
+
await this.client?.flush(); }
|
|
92
|
+
flushWithResult() {
|
|
93
|
+
return this.client?.flushWithResult() ?? Promise.resolve(this.inactiveFlushResult());
|
|
94
|
+
}
|
|
95
|
+
retryUploads() {
|
|
96
|
+
return this.client?.retryUploads() ?? Promise.resolve(this.inactiveFlushResult());
|
|
97
|
+
}
|
|
98
|
+
refreshConfiguration() {
|
|
99
|
+
return this.client?.refreshConfiguration() ?? Promise.resolve({ status: this.disposed ? 'disposed' : 'disabled',
|
|
100
|
+
nextAttemptAt: 0, error: null });
|
|
101
|
+
}
|
|
102
|
+
inactiveFlushResult() {
|
|
103
|
+
return { status: this.disposed ? 'disposed' : 'collection_disabled', accepted: 0, rejected: 0, quarantined: 0,
|
|
104
|
+
pending: 0, durable: true, retryAt: 0, error: null };
|
|
105
|
+
}
|
|
106
|
+
dispose() {
|
|
107
|
+
if (this.disposed)
|
|
108
|
+
return;
|
|
109
|
+
this.disposed = true;
|
|
110
|
+
let failed = false, firstError;
|
|
111
|
+
const cleanup = (release) => {
|
|
112
|
+
try {
|
|
113
|
+
release();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
if (!failed) {
|
|
117
|
+
failed = true;
|
|
118
|
+
firstError = error;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
for (const off of this.off.splice(0))
|
|
123
|
+
cleanup(off);
|
|
124
|
+
cleanup(() => this.client?.dispose());
|
|
125
|
+
if (failed)
|
|
126
|
+
throw firstError;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
exports.CocosPlayScope = CocosPlayScope;
|
|
130
|
+
function init(options, runtime) {
|
|
131
|
+
const sdk = new CocosPlayScope(options, runtime);
|
|
132
|
+
sdk.start();
|
|
133
|
+
return sdk;
|
|
134
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PlayScopeOwnershipError = void 0;
|
|
4
|
+
exports.claimStorage = claimStorage;
|
|
5
|
+
// Symbol.for shares ownership across this package's ESM and CommonJS entries.
|
|
6
|
+
// The registry covers one JS runtime, not other tabs or native processes.
|
|
7
|
+
const registryKey = Symbol.for('playscope.storageOwners.v1');
|
|
8
|
+
const runtime = globalThis;
|
|
9
|
+
const owners = runtime[registryKey] ?? (runtime[registryKey] = new WeakMap());
|
|
10
|
+
class PlayScopeOwnershipError extends Error {
|
|
11
|
+
constructor() {
|
|
12
|
+
super('PlayScope already has an active writer for this storage scope');
|
|
13
|
+
this.code = 'duplicate_client';
|
|
14
|
+
this.name = 'PlayScopeOwnershipError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
exports.PlayScopeOwnershipError = PlayScopeOwnershipError;
|
|
18
|
+
function claimStorage(storage, key) {
|
|
19
|
+
const scope = storage.ownershipScope ?? storage;
|
|
20
|
+
let keys = owners.get(scope);
|
|
21
|
+
if (!keys) {
|
|
22
|
+
keys = new Map();
|
|
23
|
+
owners.set(scope, keys);
|
|
24
|
+
}
|
|
25
|
+
if (keys.has(key))
|
|
26
|
+
throw new PlayScopeOwnershipError();
|
|
27
|
+
const token = {};
|
|
28
|
+
keys.set(key, token);
|
|
29
|
+
let released = false;
|
|
30
|
+
return () => {
|
|
31
|
+
if (released)
|
|
32
|
+
return;
|
|
33
|
+
released = true;
|
|
34
|
+
if (keys.get(key) === token)
|
|
35
|
+
keys.delete(key);
|
|
36
|
+
if (!keys.size && owners.get(scope) === keys)
|
|
37
|
+
owners.delete(scope);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ClientScope, PlayScopeOptions } from './types';
|
|
2
|
+
import type { State } from './PersistentState';
|
|
3
|
+
export declare function resolveClientScope(options: PlayScopeOptions): ClientScope;
|
|
4
|
+
/** Never change an installation identity, token, queue, or storage key to repair a scope conflict. */
|
|
5
|
+
export declare function bindClientScope(state: State, expected: ClientScope, existed: boolean, legacy?: ClientScope): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveClientScope = resolveClientScope;
|
|
4
|
+
exports.bindClientScope = bindClientScope;
|
|
5
|
+
const PersistentState_1 = require("./PersistentState");
|
|
6
|
+
function resolveClientScope(options) {
|
|
7
|
+
const scope = { baseUrl: options.baseUrl, projectId: options.projectId,
|
|
8
|
+
environment: options.client.environment ?? 'production' };
|
|
9
|
+
return normalize(scope);
|
|
10
|
+
}
|
|
11
|
+
function normalize(scope) {
|
|
12
|
+
if (!scope || typeof scope.baseUrl !== 'string' || !scope.baseUrl.replace(/\/+$/, '') ||
|
|
13
|
+
typeof scope.projectId !== 'string' || !scope.projectId || !['production', 'test'].includes(scope.environment))
|
|
14
|
+
throw new TypeError('Invalid client scope');
|
|
15
|
+
return { baseUrl: scope.baseUrl.replace(/\/+$/, ''), projectId: scope.projectId, environment: scope.environment };
|
|
16
|
+
}
|
|
17
|
+
function equal(a, b) {
|
|
18
|
+
return a.baseUrl === b.baseUrl && a.projectId === b.projectId && a.environment === b.environment;
|
|
19
|
+
}
|
|
20
|
+
/** Never change an installation identity, token, queue, or storage key to repair a scope conflict. */
|
|
21
|
+
function bindClientScope(state, expected, existed, legacy) {
|
|
22
|
+
if (state.scope) {
|
|
23
|
+
if (!equal(normalize(state.scope), expected))
|
|
24
|
+
throw new PersistentState_1.PlayScopeInitializationError('scope_mismatch');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (existed) {
|
|
28
|
+
if (!legacy)
|
|
29
|
+
throw new PersistentState_1.PlayScopeInitializationError('scope_migration_required');
|
|
30
|
+
if (!equal(normalize(legacy), expected))
|
|
31
|
+
throw new PersistentState_1.PlayScopeInitializationError('scope_mismatch');
|
|
32
|
+
}
|
|
33
|
+
state.scope = { ...expected };
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ConfigurationContext, ConfigurationResult, ConfigurationStatus } from './types';
|
|
2
|
+
/** Optional scheduler with its own in-flight task, failures and Retry-After budget. */
|
|
3
|
+
export declare class ConfigurationSync {
|
|
4
|
+
private readonly resolve;
|
|
5
|
+
private readonly context;
|
|
6
|
+
private readonly now;
|
|
7
|
+
private task;
|
|
8
|
+
private nextAttemptAt;
|
|
9
|
+
private failures;
|
|
10
|
+
private lastError;
|
|
11
|
+
private disposed;
|
|
12
|
+
constructor(resolve: ((context: ConfigurationContext) => Promise<number>) | undefined, context: () => Promise<ConfigurationContext>, now: () => number);
|
|
13
|
+
get status(): ConfigurationStatus;
|
|
14
|
+
refresh(): Promise<ConfigurationResult>;
|
|
15
|
+
private result;
|
|
16
|
+
private run;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ConfigurationSync = void 0;
|
|
4
|
+
const HttpError_1 = require("./HttpError");
|
|
5
|
+
/** Optional scheduler with its own in-flight task, failures and Retry-After budget. */
|
|
6
|
+
class ConfigurationSync {
|
|
7
|
+
constructor(resolve, context, now) {
|
|
8
|
+
this.resolve = resolve;
|
|
9
|
+
this.context = context;
|
|
10
|
+
this.now = now;
|
|
11
|
+
this.task = null;
|
|
12
|
+
this.nextAttemptAt = 0;
|
|
13
|
+
this.failures = 0;
|
|
14
|
+
this.lastError = null;
|
|
15
|
+
this.disposed = false;
|
|
16
|
+
}
|
|
17
|
+
get status() {
|
|
18
|
+
return { state: this.disposed ? 'disposed' : !this.resolve ? 'disabled' : this.task ? 'syncing' :
|
|
19
|
+
this.lastError && this.now() < this.nextAttemptAt ? 'backoff' : 'idle',
|
|
20
|
+
nextAttemptAt: this.nextAttemptAt, lastError: this.lastError };
|
|
21
|
+
}
|
|
22
|
+
refresh() {
|
|
23
|
+
if (this.disposed)
|
|
24
|
+
return Promise.resolve(this.result('disposed'));
|
|
25
|
+
if (!this.resolve)
|
|
26
|
+
return Promise.resolve(this.result('disabled'));
|
|
27
|
+
if (this.task)
|
|
28
|
+
return this.task;
|
|
29
|
+
if (this.now() < this.nextAttemptAt)
|
|
30
|
+
return Promise.resolve(this.result('not_due'));
|
|
31
|
+
// Defer execution until task ownership is established, including synchronous host callbacks.
|
|
32
|
+
const task = Promise.resolve().then(() => this.run());
|
|
33
|
+
this.task = task;
|
|
34
|
+
void task.then(() => { if (this.task === task)
|
|
35
|
+
this.task = null; });
|
|
36
|
+
return task;
|
|
37
|
+
}
|
|
38
|
+
result(status) {
|
|
39
|
+
return { status, nextAttemptAt: this.nextAttemptAt, error: this.lastError };
|
|
40
|
+
}
|
|
41
|
+
async run() {
|
|
42
|
+
try {
|
|
43
|
+
const context = await this.context();
|
|
44
|
+
if (this.disposed)
|
|
45
|
+
return this.result('disposed');
|
|
46
|
+
const refresh = await this.resolve(context);
|
|
47
|
+
if (this.disposed)
|
|
48
|
+
return this.result('disposed');
|
|
49
|
+
this.nextAttemptAt = this.now() + Math.max(60, Math.min(86400, Number.isFinite(refresh) && refresh > 0 ? refresh : 3600)) * 1000;
|
|
50
|
+
this.failures = 0;
|
|
51
|
+
this.lastError = null;
|
|
52
|
+
return this.result('refreshed');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (this.disposed)
|
|
56
|
+
return this.result('disposed');
|
|
57
|
+
const http = error instanceof HttpError_1.PlayScopeHttpError ? error : null;
|
|
58
|
+
const storageUnavailable = error instanceof HttpError_1.StorageUnavailableError;
|
|
59
|
+
this.lastError = http ? `http_${http.status}` : storageUnavailable ? 'storage_unavailable' : 'configuration_error';
|
|
60
|
+
this.nextAttemptAt = this.now() + Math.max(http?.retryAfterMs ?? 0, Math.min(3600000, 60000 * 2 ** Math.min(this.failures++, 6)));
|
|
61
|
+
return this.result(storageUnavailable ? 'storage_unavailable' : 'failed');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
dispose() { this.disposed = true; }
|
|
65
|
+
}
|
|
66
|
+
exports.ConfigurationSync = ConfigurationSync;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** JSB-compatible UTF-8 accounting without TextEncoder. */
|
|
2
|
+
export declare function utf8Bytes(value: string): number;
|
|
3
|
+
/** Only immutable, SDK-owned snapshots may be cached here. */
|
|
4
|
+
export declare class FactEncoding {
|
|
5
|
+
private readonly cache;
|
|
6
|
+
get(value: object): {
|
|
7
|
+
json: string;
|
|
8
|
+
bytes: number;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.FactEncoding = void 0;
|
|
4
|
+
exports.utf8Bytes = utf8Bytes;
|
|
5
|
+
/** JSB-compatible UTF-8 accounting without TextEncoder. */
|
|
6
|
+
function utf8Bytes(value) {
|
|
7
|
+
let bytes = 0;
|
|
8
|
+
for (const ch of value) {
|
|
9
|
+
const n = ch.codePointAt(0);
|
|
10
|
+
bytes += n <= 0x7f ? 1 : n <= 0x7ff ? 2 : n <= 0xffff ? 3 : 4;
|
|
11
|
+
}
|
|
12
|
+
return bytes;
|
|
13
|
+
}
|
|
14
|
+
/** Only immutable, SDK-owned snapshots may be cached here. */
|
|
15
|
+
class FactEncoding {
|
|
16
|
+
constructor() {
|
|
17
|
+
this.cache = new WeakMap();
|
|
18
|
+
}
|
|
19
|
+
get(value) {
|
|
20
|
+
let encoded = this.cache.get(value);
|
|
21
|
+
if (!encoded) {
|
|
22
|
+
const json = JSON.stringify(value);
|
|
23
|
+
encoded = { json, bytes: utf8Bytes(json) };
|
|
24
|
+
this.cache.set(value, encoded);
|
|
25
|
+
}
|
|
26
|
+
return encoded;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
exports.FactEncoding = FactEncoding;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class PlayScopeHttpError extends Error {
|
|
2
|
+
readonly status: number;
|
|
3
|
+
readonly retryAfterMs: number;
|
|
4
|
+
readonly path?: string | undefined;
|
|
5
|
+
constructor(status: number, retryAfterMs?: number, path?: string | undefined);
|
|
6
|
+
}
|
|
7
|
+
/** Internal storage barrier, distinct from transport/protocol failures. */
|
|
8
|
+
export declare class StorageUnavailableError extends Error {
|
|
9
|
+
constructor();
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.StorageUnavailableError = exports.PlayScopeHttpError = void 0;
|
|
4
|
+
class PlayScopeHttpError extends Error {
|
|
5
|
+
constructor(status, retryAfterMs = 0, path) {
|
|
6
|
+
super(`PlayScope HTTP ${status}`);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.retryAfterMs = retryAfterMs;
|
|
9
|
+
this.path = path;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.PlayScopeHttpError = PlayScopeHttpError;
|
|
13
|
+
/** Internal storage barrier, distinct from transport/protocol failures. */
|
|
14
|
+
class StorageUnavailableError extends Error {
|
|
15
|
+
constructor() { super('storage_unavailable'); }
|
|
16
|
+
}
|
|
17
|
+
exports.StorageUnavailableError = StorageUnavailableError;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { StoragePort } from './types';
|
|
2
|
+
import type { State } from './PersistentState';
|
|
3
|
+
import { FactEncoding } from './FactEncoding';
|
|
4
|
+
/** Copy-on-write segments and a single atomic manifest commit. The journal bounds failed-write garbage. */
|
|
5
|
+
export declare class OutboxStore {
|
|
6
|
+
private readonly storage;
|
|
7
|
+
private readonly key;
|
|
8
|
+
private readonly encoding;
|
|
9
|
+
private segmented;
|
|
10
|
+
private segments;
|
|
11
|
+
private cleanupPending_;
|
|
12
|
+
private existed_;
|
|
13
|
+
private readonly prefix;
|
|
14
|
+
private readonly journalKey;
|
|
15
|
+
constructor(storage: StoragePort, key: string, mode: 'snapshot' | 'segmented' | undefined, encoding: FactEncoding);
|
|
16
|
+
private requireDeletion;
|
|
17
|
+
get mode(): 'snapshot' | 'segmented';
|
|
18
|
+
get cleanupPending(): boolean;
|
|
19
|
+
get hasStoredState(): boolean;
|
|
20
|
+
private read;
|
|
21
|
+
private decode;
|
|
22
|
+
private manifest;
|
|
23
|
+
private isSegmentKey;
|
|
24
|
+
load(now: () => number, restoreCache?: (value: unknown) => unknown): State;
|
|
25
|
+
private refs;
|
|
26
|
+
private write;
|
|
27
|
+
private remove;
|
|
28
|
+
private cleanup;
|
|
29
|
+
private partition;
|
|
30
|
+
save(state: State): void;
|
|
31
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OutboxStore = void 0;
|
|
4
|
+
const PersistentState_1 = require("./PersistentState");
|
|
5
|
+
const FactEncoding_1 = require("./FactEncoding");
|
|
6
|
+
const uuid_1 = require("./uuid");
|
|
7
|
+
const KINDS = ['queue', 'quarantined'];
|
|
8
|
+
const SEGMENT_BYTES = 32 * 1024;
|
|
9
|
+
const SEGMENT_EVENTS = 32;
|
|
10
|
+
function object(v) { return !!v && typeof v === 'object' && !Array.isArray(v); }
|
|
11
|
+
function corrupt() { throw new PersistentState_1.PlayScopeInitializationError('state_corrupt'); }
|
|
12
|
+
function checksum(value) {
|
|
13
|
+
let hash = 2166136261;
|
|
14
|
+
for (let i = 0; i < value.length; i++)
|
|
15
|
+
hash = Math.imul(hash ^ value.charCodeAt(i), 16777619);
|
|
16
|
+
return (hash >>> 0).toString(16);
|
|
17
|
+
}
|
|
18
|
+
/** Copy-on-write segments and a single atomic manifest commit. The journal bounds failed-write garbage. */
|
|
19
|
+
class OutboxStore {
|
|
20
|
+
constructor(storage, key, mode, encoding) {
|
|
21
|
+
this.storage = storage;
|
|
22
|
+
this.key = key;
|
|
23
|
+
this.encoding = encoding;
|
|
24
|
+
this.segments = [];
|
|
25
|
+
this.cleanupPending_ = false;
|
|
26
|
+
this.existed_ = false;
|
|
27
|
+
if (mode !== undefined && mode !== 'snapshot' && mode !== 'segmented')
|
|
28
|
+
throw new TypeError('Invalid persistence mode');
|
|
29
|
+
this.segmented = mode === 'segmented';
|
|
30
|
+
if (this.segmented)
|
|
31
|
+
this.requireDeletion();
|
|
32
|
+
this.prefix = `${key}:outbox:v2:`;
|
|
33
|
+
this.journalKey = `${key}:outbox:journal:v2`;
|
|
34
|
+
}
|
|
35
|
+
requireDeletion() {
|
|
36
|
+
if (typeof this.storage.removeString !== 'function')
|
|
37
|
+
throw new PersistentState_1.PlayScopeInitializationError('storage_capability_missing');
|
|
38
|
+
}
|
|
39
|
+
get mode() { return this.segmented ? 'segmented' : 'snapshot'; }
|
|
40
|
+
get cleanupPending() { return this.cleanupPending_; }
|
|
41
|
+
get hasStoredState() { return this.existed_; }
|
|
42
|
+
read(key) {
|
|
43
|
+
try {
|
|
44
|
+
return this.storage.getString(key);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new PersistentState_1.PlayScopeInitializationError('storage_unavailable');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
decode(raw) { try {
|
|
51
|
+
return JSON.parse(raw);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return corrupt();
|
|
55
|
+
} }
|
|
56
|
+
manifest(raw) {
|
|
57
|
+
if (raw === null)
|
|
58
|
+
return null;
|
|
59
|
+
const value = this.decode(raw);
|
|
60
|
+
if (!object(value) || value.schemaVersion !== 2)
|
|
61
|
+
return null;
|
|
62
|
+
if (!object(value.outbox))
|
|
63
|
+
return corrupt();
|
|
64
|
+
const keys = new Set();
|
|
65
|
+
for (const kind of KINDS) {
|
|
66
|
+
const refs = value.outbox[kind];
|
|
67
|
+
if (!Array.isArray(refs))
|
|
68
|
+
return corrupt();
|
|
69
|
+
for (const ref of refs) {
|
|
70
|
+
if (!object(ref) || typeof ref.key !== 'string' || !this.isSegmentKey(ref.key, kind) || keys.has(ref.key) ||
|
|
71
|
+
!Number.isSafeInteger(ref.count) || ref.count < 1 || ref.count > SEGMENT_EVENTS ||
|
|
72
|
+
!Number.isSafeInteger(ref.bytes) || ref.bytes < 1 || typeof ref.checksum !== 'string')
|
|
73
|
+
return corrupt();
|
|
74
|
+
keys.add(ref.key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
isSegmentKey(key, kind) {
|
|
80
|
+
if (!key.startsWith(this.prefix))
|
|
81
|
+
return false;
|
|
82
|
+
const suffix = key.slice(this.prefix.length);
|
|
83
|
+
return new RegExp(`^${kind ?? '(queue|quarantined)'}:[0-9a-f-]{36}$`, 'i').test(suffix);
|
|
84
|
+
}
|
|
85
|
+
load(now, restoreCache) {
|
|
86
|
+
const raw = this.read(this.key), manifest = this.manifest(raw);
|
|
87
|
+
this.existed_ = raw !== null;
|
|
88
|
+
if (!manifest)
|
|
89
|
+
return (0, PersistentState_1.parseState)(raw, now, restoreCache);
|
|
90
|
+
this.requireDeletion();
|
|
91
|
+
this.segmented = true;
|
|
92
|
+
const lists = { queue: [], quarantined: [] };
|
|
93
|
+
for (const kind of KINDS)
|
|
94
|
+
for (const ref of manifest.outbox[kind]) {
|
|
95
|
+
const payload = this.read(ref.key);
|
|
96
|
+
if (payload === null || (0, FactEncoding_1.utf8Bytes)(payload) !== ref.bytes || checksum(payload) !== ref.checksum)
|
|
97
|
+
return corrupt();
|
|
98
|
+
const segment = this.decode(payload);
|
|
99
|
+
if (!object(segment) || segment.schemaVersion !== 2 || segment.kind !== kind ||
|
|
100
|
+
!Array.isArray(segment.items) || segment.items.length !== ref.count || !segment.items.every(object))
|
|
101
|
+
return corrupt();
|
|
102
|
+
lists[kind].push(...segment.items);
|
|
103
|
+
this.segments.push({ kind, ref, items: segment.items });
|
|
104
|
+
}
|
|
105
|
+
// Reuse the explicit v1 metadata/fact validator; no persisted fact is transformed by migration.
|
|
106
|
+
const state = (0, PersistentState_1.parseState)(JSON.stringify({ ...manifest, schemaVersion: 1, ...lists }), now, restoreCache);
|
|
107
|
+
// Bind descriptors to the validated objects returned by the legacy parser.
|
|
108
|
+
for (const kind of KINDS) {
|
|
109
|
+
let offset = 0;
|
|
110
|
+
for (const segment of this.segments.filter(segment => segment.kind === kind)) {
|
|
111
|
+
segment.items = state[kind].slice(offset, offset + segment.ref.count);
|
|
112
|
+
offset += segment.ref.count;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return state;
|
|
116
|
+
}
|
|
117
|
+
refs(manifest) {
|
|
118
|
+
return manifest ? KINDS.flatMap(kind => manifest.outbox[kind].map(ref => ref.key)) : [];
|
|
119
|
+
}
|
|
120
|
+
write(key, value) {
|
|
121
|
+
this.storage.setString(key, value);
|
|
122
|
+
this.storage.flush();
|
|
123
|
+
if (this.storage.getString(key) !== value)
|
|
124
|
+
throw new Error('storage_unavailable');
|
|
125
|
+
}
|
|
126
|
+
remove(key) {
|
|
127
|
+
this.storage.removeString(key);
|
|
128
|
+
this.storage.flush();
|
|
129
|
+
if (this.storage.getString(key) !== null)
|
|
130
|
+
throw new Error('storage_unavailable');
|
|
131
|
+
}
|
|
132
|
+
cleanup(keep) {
|
|
133
|
+
const raw = this.read(this.journalKey);
|
|
134
|
+
if (raw === null) {
|
|
135
|
+
this.cleanupPending_ = false;
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
this.cleanupPending_ = true;
|
|
139
|
+
const journal = this.decode(raw);
|
|
140
|
+
if (!object(journal) || journal.schemaVersion !== 2 || !Array.isArray(journal.keys) ||
|
|
141
|
+
!journal.keys.every(key => typeof key === 'string' && this.isSegmentKey(key)))
|
|
142
|
+
return corrupt();
|
|
143
|
+
for (const key of journal.keys)
|
|
144
|
+
if (!keep.has(key))
|
|
145
|
+
this.remove(key);
|
|
146
|
+
this.remove(this.journalKey);
|
|
147
|
+
this.cleanupPending_ = false;
|
|
148
|
+
}
|
|
149
|
+
partition(kind, items) {
|
|
150
|
+
const old = this.segments.filter(segment => segment.kind === kind);
|
|
151
|
+
const byFirst = new Map(old.map(segment => [segment.items[0], segment]));
|
|
152
|
+
const segments = [], writes = new Map();
|
|
153
|
+
let index = 0;
|
|
154
|
+
const match = (at) => {
|
|
155
|
+
const segment = byFirst.get(items[at]);
|
|
156
|
+
return segment && segment.items.every((item, offset) => items[at + offset] === item) ? segment : undefined;
|
|
157
|
+
};
|
|
158
|
+
while (index < items.length) {
|
|
159
|
+
const previous = match(index);
|
|
160
|
+
// Rebuild a small tail when appending, so one extra fact does not create a new tiny segment.
|
|
161
|
+
if (previous && (previous !== old[old.length - 1] || index + previous.items.length === items.length || previous.items.length === SEGMENT_EVENTS ||
|
|
162
|
+
previous.ref.bytes >= SEGMENT_BYTES)) {
|
|
163
|
+
segments.push(previous);
|
|
164
|
+
index += previous.items.length;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const chunk = [], encoded = [];
|
|
168
|
+
let bytes = 0;
|
|
169
|
+
while (index < items.length && chunk.length < SEGMENT_EVENTS) {
|
|
170
|
+
if (chunk.length && match(index))
|
|
171
|
+
break;
|
|
172
|
+
const fact = this.encoding.get(items[index]);
|
|
173
|
+
if (chunk.length && bytes + fact.bytes + 1 > SEGMENT_BYTES)
|
|
174
|
+
break;
|
|
175
|
+
chunk.push(items[index++]);
|
|
176
|
+
encoded.push(fact.json);
|
|
177
|
+
bytes += fact.bytes + 1;
|
|
178
|
+
}
|
|
179
|
+
const payload = `{"schemaVersion":2,"kind":"${kind}","items":[${encoded.join(',')}]}`;
|
|
180
|
+
const ref = { key: `${this.prefix}${kind}:${(0, uuid_1.telemetryUuid)()}`, count: chunk.length,
|
|
181
|
+
bytes: (0, FactEncoding_1.utf8Bytes)(payload), checksum: checksum(payload) };
|
|
182
|
+
writes.set(ref.key, payload);
|
|
183
|
+
segments.push({ kind, ref, items: chunk });
|
|
184
|
+
}
|
|
185
|
+
return { segments, writes };
|
|
186
|
+
}
|
|
187
|
+
save(state) {
|
|
188
|
+
if (!this.segmented) {
|
|
189
|
+
this.write(this.key, JSON.stringify(state));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
// An earlier failure may have committed the root before its read-back failed.
|
|
194
|
+
// Always consult the actual root before collecting any staged or retired key.
|
|
195
|
+
const committed = this.manifest(this.read(this.key));
|
|
196
|
+
this.cleanup(new Set(this.refs(committed)));
|
|
197
|
+
const next = KINDS.map(kind => this.partition(kind, state[kind]));
|
|
198
|
+
const segments = next.flatMap(part => part.segments);
|
|
199
|
+
const writes = new Map(next.flatMap(part => [...part.writes]));
|
|
200
|
+
const retained = new Set(segments.map(segment => segment.ref.key));
|
|
201
|
+
const garbage = this.refs(committed).filter(key => !retained.has(key));
|
|
202
|
+
if (writes.size || garbage.length) {
|
|
203
|
+
// Persist intent before the first new key, retaining every old referenced key until commit.
|
|
204
|
+
this.write(this.journalKey, JSON.stringify({ schemaVersion: 2, keys: [...writes.keys(), ...garbage] }));
|
|
205
|
+
this.cleanupPending_ = true;
|
|
206
|
+
for (const [key, value] of writes)
|
|
207
|
+
this.write(key, value);
|
|
208
|
+
}
|
|
209
|
+
const { queue: _queue, quarantined: _quarantined, ...metadata } = state;
|
|
210
|
+
const root = JSON.stringify({ ...metadata, schemaVersion: 2, outbox: {
|
|
211
|
+
queue: next[0].segments.map(segment => segment.ref), quarantined: next[1].segments.map(segment => segment.ref)
|
|
212
|
+
} });
|
|
213
|
+
this.write(this.key, root);
|
|
214
|
+
this.segments = segments;
|
|
215
|
+
// GC is not part of fact durability. A failed cleanup remains journaled and must finish before another write.
|
|
216
|
+
try {
|
|
217
|
+
this.cleanup(retained);
|
|
218
|
+
}
|
|
219
|
+
catch { /* Retry cleanup before the next persistence operation. */ }
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
// Reconciliation after an uncertain commit must not reuse references that GC might have retired.
|
|
223
|
+
this.segments = [];
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
exports.OutboxStore = OutboxStore;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EventFact, QuarantinedEvent, StoragePort, ClientScope } from './types';
|
|
2
|
+
export interface State {
|
|
3
|
+
schemaVersion: 1;
|
|
4
|
+
installId: string;
|
|
5
|
+
firstOpenedAt: string;
|
|
6
|
+
firstOpenTracked: boolean;
|
|
7
|
+
sequence: number;
|
|
8
|
+
token: {
|
|
9
|
+
accessToken: string;
|
|
10
|
+
expiresAt: string;
|
|
11
|
+
} | null;
|
|
12
|
+
cache: unknown;
|
|
13
|
+
queue: EventFact[];
|
|
14
|
+
quarantined: QuarantinedEvent[];
|
|
15
|
+
rejected: {
|
|
16
|
+
eventId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
code: string;
|
|
19
|
+
}[];
|
|
20
|
+
droppedEvents: number;
|
|
21
|
+
deduplicationKeys?: string[];
|
|
22
|
+
scope?: ClientScope;
|
|
23
|
+
}
|
|
24
|
+
export type InitializationErrorCode = 'storage_unavailable' | 'state_corrupt' | 'state_version_unsupported' | 'cache_restore_failed' | 'storage_capability_missing' | 'scope_mismatch' | 'scope_migration_required';
|
|
25
|
+
/** Construction fails without overwriting stored data. Retry by constructing a new client. */
|
|
26
|
+
export declare class PlayScopeInitializationError extends Error {
|
|
27
|
+
readonly code: InitializationErrorCode;
|
|
28
|
+
readonly retryable: boolean;
|
|
29
|
+
constructor(code: InitializationErrorCode);
|
|
30
|
+
}
|
|
31
|
+
export declare function isEventFact(value: unknown): value is EventFact;
|
|
32
|
+
export declare function loadState(storage: StoragePort, key: string, now: () => number, restoreCache?: (value: unknown) => unknown): State;
|
|
33
|
+
export declare function parseState(raw: string | null, now: () => number, restoreCache?: (value: unknown) => unknown): State;
|