@mpgd/adapter-ait 0.4.3 → 0.5.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/dist/ad-config.d.ts +5 -0
- package/dist/ad-config.js +33 -0
- package/dist/host.d.ts +38 -0
- package/dist/host.js +730 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +23 -13
- package/dist/lifecycle.d.ts +5 -0
- package/dist/lifecycle.js +45 -0
- package/dist/wrapper-url.d.ts +2 -0
- package/dist/wrapper-url.js +45 -0
- package/dist/wrapper.d.ts +1 -0
- package/dist/wrapper.js +267 -0
- package/package.json +26 -4
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export interface AitAdBridgeConfig {
|
|
2
|
+
readonly adGroupIds: Readonly<Record<string, string>>;
|
|
3
|
+
readonly adPlacementTypes: Readonly<Record<string, 'rewarded' | 'interstitial'>>;
|
|
4
|
+
}
|
|
5
|
+
export declare function extractAitAdBridgeConfig(input: unknown, sourceLabel?: string): AitAdBridgeConfig;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function extractAitAdBridgeConfig(input, sourceLabel = 'ad placements') {
|
|
2
|
+
if (!isRecord(input) || !Array.isArray(input.placements)) {
|
|
3
|
+
throw new Error(`AIT ad placements are invalid: ${sourceLabel}`);
|
|
4
|
+
}
|
|
5
|
+
const placements = input.placements;
|
|
6
|
+
const adGroupIds = {};
|
|
7
|
+
const adPlacementTypes = {};
|
|
8
|
+
const placementIds = new Set();
|
|
9
|
+
for (const [placementIndex, placement] of placements.entries()) {
|
|
10
|
+
if (!isRecord(placement)
|
|
11
|
+
|| typeof placement.id !== 'string'
|
|
12
|
+
|| (placement.type !== 'rewarded' && placement.type !== 'interstitial')) {
|
|
13
|
+
throw new Error(`AIT ad placement entry at index ${placementIndex} is invalid: ${sourceLabel}`);
|
|
14
|
+
}
|
|
15
|
+
if (placementIds.has(placement.id)) {
|
|
16
|
+
throw new Error(`Duplicate AIT ad placement ID "${placement.id}" at index `
|
|
17
|
+
+ `${placementIndex}: ${sourceLabel}`);
|
|
18
|
+
}
|
|
19
|
+
placementIds.add(placement.id);
|
|
20
|
+
const platformPlacementIds = placement.platformPlacementIds;
|
|
21
|
+
const adGroupId = isRecord(platformPlacementIds) && typeof platformPlacementIds.ait === 'string'
|
|
22
|
+
? platformPlacementIds.ait.trim()
|
|
23
|
+
: '';
|
|
24
|
+
if (adGroupId.length > 0) {
|
|
25
|
+
adGroupIds[placement.id] = adGroupId;
|
|
26
|
+
adPlacementTypes[placement.id] = placement.type;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { adGroupIds, adPlacementTypes };
|
|
30
|
+
}
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
33
|
+
}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { getTossShareLink, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore } from '@apps-in-toss/web-framework';
|
|
2
|
+
import type { ShareResult } from '@mpgd/platform';
|
|
3
|
+
import type { GamePlatformBridge } from './index.js';
|
|
4
|
+
export type AitIdentityProvider = () => Promise<unknown>;
|
|
5
|
+
export interface AitHostDependencies {
|
|
6
|
+
readonly identityProvider: AitIdentityProvider;
|
|
7
|
+
readonly storage: Pick<typeof Storage, 'getItem' | 'setItem'>;
|
|
8
|
+
readonly getTossShareLink: typeof getTossShareLink;
|
|
9
|
+
readonly share: typeof share;
|
|
10
|
+
readonly isMinVersionSupported: typeof isMinVersionSupported;
|
|
11
|
+
readonly loadFullScreenAd: typeof loadFullScreenAd;
|
|
12
|
+
readonly showFullScreenAd: typeof showFullScreenAd;
|
|
13
|
+
readonly openGameCenterLeaderboard: typeof openGameCenterLeaderboard;
|
|
14
|
+
readonly submitGameCenterLeaderBoardScore: typeof submitGameCenterLeaderBoardScore;
|
|
15
|
+
}
|
|
16
|
+
export interface InstallAitHostBridgeOptions {
|
|
17
|
+
readonly appName?: string;
|
|
18
|
+
readonly adGroupIds?: Readonly<Record<string, string>>;
|
|
19
|
+
readonly adPlacementTypes?: Readonly<Record<string, 'rewarded' | 'interstitial'>>;
|
|
20
|
+
/** Maximum total wait for the native show request before display is observed. */
|
|
21
|
+
readonly adTimeoutMs?: number;
|
|
22
|
+
/** Maximum wait to acquire the process-wide native full-screen load slot. */
|
|
23
|
+
readonly adLoadQueueTimeoutMs?: number;
|
|
24
|
+
/** Upper bound for the requested-to-display portion of the total show timeout. */
|
|
25
|
+
readonly adDisplayStartTimeoutMs?: number;
|
|
26
|
+
/** Last-resort cleanup when the native SDK omits its terminal display callback. */
|
|
27
|
+
readonly adMaximumDisplayMs?: number;
|
|
28
|
+
readonly dependencies?: Partial<AitHostDependencies>;
|
|
29
|
+
}
|
|
30
|
+
export declare function installAitHostBridge(options?: InstallAitHostBridgeOptions): GamePlatformBridge;
|
|
31
|
+
export declare function createAitHostBridge(options?: InstallAitHostBridgeOptions): GamePlatformBridge;
|
|
32
|
+
interface AitShareDependencies {
|
|
33
|
+
readonly appName: string;
|
|
34
|
+
readonly getTossShareLink: typeof getTossShareLink;
|
|
35
|
+
readonly share: typeof share;
|
|
36
|
+
}
|
|
37
|
+
export declare function shareIntent(payload: unknown, dependencies: AitShareDependencies): Promise<ShareResult>;
|
|
38
|
+
export {};
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
import { getTossShareLink, getUserKeyForGame, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, } from '@apps-in-toss/web-framework';
|
|
2
|
+
import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
|
|
3
|
+
import { dispatchAitLifecycleEvent } from './lifecycle.js';
|
|
4
|
+
const defaultAdTimeoutMs = 60000;
|
|
5
|
+
const defaultAdLoadQueueTimeoutMs = 5000;
|
|
6
|
+
const defaultAdDisplayStartTimeoutMs = 60000;
|
|
7
|
+
const defaultAdMaximumDisplayMs = 30 * 60000;
|
|
8
|
+
const invalidBridgeRequestId = 'ait-invalid-request';
|
|
9
|
+
const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
|
|
10
|
+
const launchEntries = new Set([
|
|
11
|
+
'home',
|
|
12
|
+
'daily',
|
|
13
|
+
'practice',
|
|
14
|
+
'free-play',
|
|
15
|
+
'continue',
|
|
16
|
+
'leaderboard',
|
|
17
|
+
'friend-challenge',
|
|
18
|
+
]);
|
|
19
|
+
const defaultDependencies = {
|
|
20
|
+
identityProvider: getUserKeyForGame,
|
|
21
|
+
storage: Storage,
|
|
22
|
+
getTossShareLink,
|
|
23
|
+
share,
|
|
24
|
+
isMinVersionSupported,
|
|
25
|
+
loadFullScreenAd,
|
|
26
|
+
showFullScreenAd,
|
|
27
|
+
openGameCenterLeaderboard,
|
|
28
|
+
submitGameCenterLeaderBoardScore,
|
|
29
|
+
};
|
|
30
|
+
export function installAitHostBridge(options = {}) {
|
|
31
|
+
const bridge = createAitHostBridge(options);
|
|
32
|
+
globalThis.__GAME_PLATFORM_BRIDGE__ = bridge;
|
|
33
|
+
return bridge;
|
|
34
|
+
}
|
|
35
|
+
export function createAitHostBridge(options = {}) {
|
|
36
|
+
const dependencies = { ...defaultDependencies, ...options.dependencies };
|
|
37
|
+
const appName = normalizeAppName(options.appName ?? 'mpgd-kit');
|
|
38
|
+
const adGroupIds = normalizeAdGroupIds(options.adGroupIds);
|
|
39
|
+
const adPlacementTypes = normalizeAdPlacementTypes(options.adPlacementTypes);
|
|
40
|
+
const loadedAdGroupIds = new Set();
|
|
41
|
+
const loadingAdGroups = new Map();
|
|
42
|
+
const activeAdGroupIds = new Set();
|
|
43
|
+
let warnedUnsupportedAdPreload = false;
|
|
44
|
+
const adTimeoutMs = normalizeTimeout(options.adTimeoutMs);
|
|
45
|
+
const adLoadQueueTimeoutMs = normalizeLoadQueueTimeout(options.adLoadQueueTimeoutMs);
|
|
46
|
+
const adDisplayStartTimeoutMs = normalizeDisplayStartTimeout(options.adDisplayStartTimeoutMs);
|
|
47
|
+
const adMaximumDisplayMs = normalizeMaximumDisplayTimeout(options.adMaximumDisplayMs);
|
|
48
|
+
const adLoadCoordinator = {
|
|
49
|
+
active: undefined,
|
|
50
|
+
waitTimeoutMs: adLoadQueueTimeoutMs,
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
async request(input) {
|
|
54
|
+
try {
|
|
55
|
+
return await handleRequest(parseBridgeRequest(input));
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return createBridgeError(readBridgeRequestId(input), 'AIT_BRIDGE_REQUEST_FAILED', errorMessage(error), true);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
async function handleRequest(request) {
|
|
63
|
+
switch (request.method) {
|
|
64
|
+
case 'runtime.getCapabilities': {
|
|
65
|
+
const adsSupported = dependencies.loadFullScreenAd.isSupported()
|
|
66
|
+
&& dependencies.showFullScreenAd.isSupported();
|
|
67
|
+
const rewardedAds = adsSupported
|
|
68
|
+
&& hasConfiguredAdType(adGroupIds, adPlacementTypes, 'rewarded');
|
|
69
|
+
const interstitialAds = adsSupported
|
|
70
|
+
&& hasConfiguredAdType(adGroupIds, adPlacementTypes, 'interstitial');
|
|
71
|
+
return ok(request, {
|
|
72
|
+
nativeIap: false,
|
|
73
|
+
nativeAds: rewardedAds || interstitialAds,
|
|
74
|
+
rewardedAds,
|
|
75
|
+
interstitialAds,
|
|
76
|
+
nativeLeaderboard: isGameCenterSupported(dependencies),
|
|
77
|
+
achievements: false,
|
|
78
|
+
cloudSave: false,
|
|
79
|
+
socialShare: true,
|
|
80
|
+
haptics: false,
|
|
81
|
+
localizedContent: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
case 'identity.getPlayer': {
|
|
85
|
+
const player = await resolveAitIdentity(dependencies.identityProvider);
|
|
86
|
+
return player === null
|
|
87
|
+
? createBridgeError(request.id, 'AIT_IDENTITY_UNAVAILABLE', 'AIT user identity is unavailable.')
|
|
88
|
+
: ok(request, player);
|
|
89
|
+
}
|
|
90
|
+
case 'identity.getSession': {
|
|
91
|
+
const player = await resolveAitIdentity(dependencies.identityProvider);
|
|
92
|
+
return ok(request, player === null
|
|
93
|
+
? { identityLevel: 'guest', trustLevel: 'local' }
|
|
94
|
+
: {
|
|
95
|
+
identityLevel: 'platform-anonymous',
|
|
96
|
+
playerId: player.playerId,
|
|
97
|
+
trustLevel: 'platform-asserted',
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
case 'identity.requestUpgrade':
|
|
101
|
+
return ok(request, { status: 'unavailable', reloadExpected: false });
|
|
102
|
+
case 'presentation.getLaunchIntent':
|
|
103
|
+
return ok(request, getLaunchIntent());
|
|
104
|
+
case 'presentation.requestGameSurface':
|
|
105
|
+
return ok(request, 'already-fullscreen');
|
|
106
|
+
case 'share.share':
|
|
107
|
+
return ok(request, await shareIntent(request.payload, {
|
|
108
|
+
appName,
|
|
109
|
+
getTossShareLink: dependencies.getTossShareLink,
|
|
110
|
+
share: dependencies.share,
|
|
111
|
+
}));
|
|
112
|
+
case 'share.readInboundShare':
|
|
113
|
+
return ok(request, readInboundShare());
|
|
114
|
+
case 'notifications.getStatus':
|
|
115
|
+
return ok(request, 'unsupported');
|
|
116
|
+
case 'notifications.requestSubscription':
|
|
117
|
+
return ok(request, 'unavailable');
|
|
118
|
+
// IAP must be installed with game-owned server verification. Never return demo grants.
|
|
119
|
+
case 'commerce.getProducts':
|
|
120
|
+
return ok(request, []);
|
|
121
|
+
case 'commerce.purchase':
|
|
122
|
+
return ok(request, { status: 'failed', entitlementIds: [] });
|
|
123
|
+
case 'commerce.restore':
|
|
124
|
+
return ok(request, { restoredEntitlements: [] });
|
|
125
|
+
case 'commerce.getEntitlements':
|
|
126
|
+
return ok(request, []);
|
|
127
|
+
case 'ads.preload': {
|
|
128
|
+
const placementId = readPlacementId(request.payload);
|
|
129
|
+
const adGroupId = adGroupIds.get(placementId);
|
|
130
|
+
const placementType = adPlacementTypes.get(placementId);
|
|
131
|
+
if (adGroupId === undefined || placementType === undefined) {
|
|
132
|
+
return createBridgeError(request.id, 'AIT_AD_UNAVAILABLE', `AIT ad placement is unavailable: ${placementId}`);
|
|
133
|
+
}
|
|
134
|
+
if (!dependencies.loadFullScreenAd.isSupported()) {
|
|
135
|
+
if (!warnedUnsupportedAdPreload) {
|
|
136
|
+
warnedUnsupportedAdPreload = true;
|
|
137
|
+
console.warn('AIT full-screen ads are not supported; configured preload is a no-op.', placementId);
|
|
138
|
+
}
|
|
139
|
+
return ok(request, {});
|
|
140
|
+
}
|
|
141
|
+
await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs, placementType);
|
|
142
|
+
return ok(request, {});
|
|
143
|
+
}
|
|
144
|
+
case 'ads.showRewarded': {
|
|
145
|
+
const placementId = readPlacementId(request.payload);
|
|
146
|
+
const adGroupId = adGroupIds.get(placementId);
|
|
147
|
+
if (adGroupId === undefined
|
|
148
|
+
|| adPlacementTypes.get(placementId) !== 'rewarded'
|
|
149
|
+
|| !dependencies.loadFullScreenAd.isSupported()
|
|
150
|
+
|| !dependencies.showFullScreenAd.isSupported()) {
|
|
151
|
+
return ok(request, { status: 'unavailable', rewardGranted: false });
|
|
152
|
+
}
|
|
153
|
+
const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
|
|
154
|
+
const correlationId = readIdempotencyKey(request.payload, request.id);
|
|
155
|
+
const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
|
|
156
|
+
return result.rewardGranted
|
|
157
|
+
? {
|
|
158
|
+
...result,
|
|
159
|
+
// game-services forwards this as platformImpressionId and compares it
|
|
160
|
+
// with the native callback correlationId during authority verification.
|
|
161
|
+
ledgerEntryId: correlationId,
|
|
162
|
+
evidence: {
|
|
163
|
+
schema: rewardedAdEvidenceSchema,
|
|
164
|
+
payload: {
|
|
165
|
+
event: 'user-earned-reward',
|
|
166
|
+
correlationId,
|
|
167
|
+
placementId: adGroupId,
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
: result;
|
|
172
|
+
});
|
|
173
|
+
if (!shown.acquired) {
|
|
174
|
+
return ok(request, { status: 'unavailable', rewardGranted: false });
|
|
175
|
+
}
|
|
176
|
+
return ok(request, shown.value);
|
|
177
|
+
}
|
|
178
|
+
case 'ads.showInterstitial': {
|
|
179
|
+
const placementId = readPlacementId(request.payload);
|
|
180
|
+
const adGroupId = adGroupIds.get(placementId);
|
|
181
|
+
if (adGroupId === undefined
|
|
182
|
+
|| adPlacementTypes.get(placementId) !== 'interstitial'
|
|
183
|
+
|| !dependencies.loadFullScreenAd.isSupported()
|
|
184
|
+
|| !dependencies.showFullScreenAd.isSupported()) {
|
|
185
|
+
return ok(request, { status: 'unavailable' });
|
|
186
|
+
}
|
|
187
|
+
const showInterstitial = () => showInterstitialAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
|
|
188
|
+
const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'interstitial', showInterstitial);
|
|
189
|
+
return ok(request, shown.acquired ? shown.value : { status: 'unavailable' });
|
|
190
|
+
}
|
|
191
|
+
case 'leaderboard.submitScore': {
|
|
192
|
+
if (!isGameCenterSupported(dependencies)) {
|
|
193
|
+
return ok(request, { submitted: false });
|
|
194
|
+
}
|
|
195
|
+
const score = readFiniteScore(request.payload);
|
|
196
|
+
const result = await dependencies.submitGameCenterLeaderBoardScore({
|
|
197
|
+
score: String(score),
|
|
198
|
+
});
|
|
199
|
+
return ok(request, { submitted: result?.statusCode === 'SUCCESS' });
|
|
200
|
+
}
|
|
201
|
+
case 'leaderboard.open': {
|
|
202
|
+
if (!isGameCenterSupported(dependencies)) {
|
|
203
|
+
return ok(request, {});
|
|
204
|
+
}
|
|
205
|
+
dispatchAitLifecycleEvent('pause');
|
|
206
|
+
try {
|
|
207
|
+
await dependencies.openGameCenterLeaderboard();
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
dispatchAitLifecycleEvent('resume');
|
|
211
|
+
}
|
|
212
|
+
return ok(request, {});
|
|
213
|
+
}
|
|
214
|
+
case 'storage.load': {
|
|
215
|
+
const key = readStorageKey(request.payload);
|
|
216
|
+
const serialized = await dependencies.storage.getItem(key);
|
|
217
|
+
return ok(request, decodeStoredValue(serialized));
|
|
218
|
+
}
|
|
219
|
+
case 'storage.save': {
|
|
220
|
+
const key = readStorageKey(request.payload);
|
|
221
|
+
const value = readPayloadRecord(request.payload).value;
|
|
222
|
+
const serialized = JSON.stringify(value);
|
|
223
|
+
if (serialized === undefined) {
|
|
224
|
+
throw new TypeError('Storage values must be JSON-serializable.');
|
|
225
|
+
}
|
|
226
|
+
await dependencies.storage.setItem(key, serialized);
|
|
227
|
+
return ok(request, {});
|
|
228
|
+
}
|
|
229
|
+
default:
|
|
230
|
+
return createBridgeError(request.id, 'UNSUPPORTED_METHOD', `Unsupported AIT bridge method: ${request.method}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
export async function shareIntent(payload, dependencies) {
|
|
235
|
+
const intent = readPayloadRecord(payload);
|
|
236
|
+
if (typeof intent.text !== 'string' || typeof intent.deepLink !== 'string') {
|
|
237
|
+
return { status: 'unavailable' };
|
|
238
|
+
}
|
|
239
|
+
const deepLink = toAitDeepLink(intent.deepLink, dependencies.appName);
|
|
240
|
+
if (deepLink === undefined) {
|
|
241
|
+
return { status: 'unavailable' };
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const tossLink = await dependencies.getTossShareLink(deepLink, typeof intent.previewImageUrl === 'string' && intent.previewImageUrl.startsWith('https://')
|
|
245
|
+
? intent.previewImageUrl
|
|
246
|
+
: undefined);
|
|
247
|
+
await dependencies.share({ message: `${intent.text}\n${tossLink}` });
|
|
248
|
+
return { status: 'shared', completion: 'presented' };
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
return isAbortError(error) ? { status: 'cancelled' } : { status: 'unavailable' };
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function resolveAitIdentity(provider) {
|
|
255
|
+
let result;
|
|
256
|
+
try {
|
|
257
|
+
result = await provider();
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
if (!isRecord(result) || result.type !== 'HASH') {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const hash = typeof result.hash === 'string' ? result.hash.trim() : '';
|
|
266
|
+
return hash.length === 0 ? null : { playerId: hash };
|
|
267
|
+
}
|
|
268
|
+
async function preloadAdGroup(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs) {
|
|
269
|
+
if (loaded.has(adGroupId)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const existing = loading.get(adGroupId);
|
|
273
|
+
if (existing !== undefined) {
|
|
274
|
+
await existing;
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const startNativeLoad = () => new Promise((resolve, reject) => {
|
|
278
|
+
let cleanup = () => { };
|
|
279
|
+
let settled = false;
|
|
280
|
+
const finish = (error) => {
|
|
281
|
+
if (settled) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
settled = true;
|
|
285
|
+
globalThis.clearTimeout(timer);
|
|
286
|
+
cleanup();
|
|
287
|
+
if (error === undefined) {
|
|
288
|
+
loaded.add(adGroupId);
|
|
289
|
+
resolve();
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
reject(error);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const timer = globalThis.setTimeout(() => finish(new Error(`Timed out loading AIT ad group ${adGroupId}.`)), timeoutMs);
|
|
296
|
+
try {
|
|
297
|
+
const unregister = dependencies.loadFullScreenAd({
|
|
298
|
+
options: { adGroupId },
|
|
299
|
+
onEvent: (event) => {
|
|
300
|
+
if (event.type === 'loaded') {
|
|
301
|
+
finish();
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
onError: finish,
|
|
305
|
+
});
|
|
306
|
+
cleanup = unregister;
|
|
307
|
+
if (settled) {
|
|
308
|
+
cleanup();
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
finish(error);
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
const pending = runSerializedAdLoad(coordinator, adGroupId, startNativeLoad);
|
|
316
|
+
loading.set(adGroupId, pending);
|
|
317
|
+
try {
|
|
318
|
+
await pending;
|
|
319
|
+
}
|
|
320
|
+
finally {
|
|
321
|
+
// Clean up this load attempt. The identity check also protects a future retry
|
|
322
|
+
// implementation from deleting a replacement promise.
|
|
323
|
+
if (loading.get(adGroupId) === pending) {
|
|
324
|
+
loading.delete(adGroupId);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
async function runSerializedAdLoad(coordinator, adGroupId, startNativeLoad) {
|
|
329
|
+
const waitDeadline = Date.now() + coordinator.waitTimeoutMs;
|
|
330
|
+
while (coordinator.active !== undefined) {
|
|
331
|
+
const remainingWaitMs = waitDeadline - Date.now();
|
|
332
|
+
if (remainingWaitMs <= 0) {
|
|
333
|
+
throw new Error(`Timed out waiting for the AIT ad load slot: ${adGroupId} (queue deadline exceeded).`);
|
|
334
|
+
}
|
|
335
|
+
await waitForAdLoadSettlement(coordinator.active.promise, remainingWaitMs, adGroupId);
|
|
336
|
+
}
|
|
337
|
+
// Some deployed Toss runtimes lose callbacks when different groups begin
|
|
338
|
+
// loading together. Acquire the process-wide native boundary synchronously;
|
|
339
|
+
// the actual native load still receives its complete timeout budget.
|
|
340
|
+
const pending = startNativeLoad();
|
|
341
|
+
coordinator.active = { promise: pending };
|
|
342
|
+
try {
|
|
343
|
+
await pending;
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
if (coordinator.active?.promise === pending) {
|
|
347
|
+
coordinator.active = undefined;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
function waitForAdLoadSettlement(active, timeoutMs, adGroupId) {
|
|
352
|
+
return new Promise((resolve, reject) => {
|
|
353
|
+
let settled = false;
|
|
354
|
+
const finish = (error) => {
|
|
355
|
+
if (settled) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
settled = true;
|
|
359
|
+
globalThis.clearTimeout(timer);
|
|
360
|
+
if (error === undefined) {
|
|
361
|
+
resolve();
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
reject(error);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
const timer = globalThis.setTimeout(() => finish(new Error(`Timed out waiting for the AIT ad load slot: ${adGroupId} (active load wait expired).`)), timeoutMs);
|
|
368
|
+
void active.then(() => finish(), () => finish());
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
async function acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType) {
|
|
372
|
+
if (active.has(adGroupId)) {
|
|
373
|
+
return false;
|
|
374
|
+
}
|
|
375
|
+
const loadedSuccessfully = await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType);
|
|
376
|
+
if (!loadedSuccessfully) {
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
// Re-check after the asynchronous load so concurrent callers cannot consume
|
|
380
|
+
// or display the same native ad slot twice.
|
|
381
|
+
if (active.has(adGroupId) || !consumeLoadedAd(adGroupId, loaded)) {
|
|
382
|
+
return false;
|
|
383
|
+
}
|
|
384
|
+
active.add(adGroupId);
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType) {
|
|
388
|
+
// The first caller enters preloadAdGroup synchronously and publishes its promise
|
|
389
|
+
// before yielding. Remember that ownership so concurrent waiters do not emit the
|
|
390
|
+
// same diagnostic for one native load failure.
|
|
391
|
+
const ownsLoadAttempt = !loaded.has(adGroupId) && !loading.has(adGroupId);
|
|
392
|
+
try {
|
|
393
|
+
await preloadAdGroup(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs);
|
|
394
|
+
return true;
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
// Preloading is opportunistic. A later show request may retry, so keep
|
|
398
|
+
// gameplay available while retaining one diagnostic per native attempt.
|
|
399
|
+
if (ownsLoadAttempt) {
|
|
400
|
+
console.warn(`Failed to preload ${placementType} AIT ad group.`, adGroupId, error);
|
|
401
|
+
}
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
async function withLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType, display) {
|
|
406
|
+
const acquired = await acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType);
|
|
407
|
+
if (!acquired) {
|
|
408
|
+
return { acquired: false };
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
return { acquired: true, value: await display() };
|
|
412
|
+
}
|
|
413
|
+
finally {
|
|
414
|
+
active.delete(adGroupId);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
async function showRewardedAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
|
|
418
|
+
let rewardGranted = false;
|
|
419
|
+
const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, (eventType) => {
|
|
420
|
+
if (eventType === 'userEarnedReward') {
|
|
421
|
+
rewardGranted = true;
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
let resultStatus;
|
|
425
|
+
if (status === 'shown' && rewardGranted) {
|
|
426
|
+
resultStatus = 'completed';
|
|
427
|
+
}
|
|
428
|
+
else if (status === 'failed') {
|
|
429
|
+
resultStatus = 'failed';
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
resultStatus = 'skipped';
|
|
433
|
+
}
|
|
434
|
+
return {
|
|
435
|
+
status: resultStatus,
|
|
436
|
+
rewardGranted,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
async function showInterstitialAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
|
|
440
|
+
const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs);
|
|
441
|
+
return { status: status === 'failed' ? 'skipped' : status };
|
|
442
|
+
}
|
|
443
|
+
function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, observe) {
|
|
444
|
+
dispatchAitLifecycleEvent('pause');
|
|
445
|
+
return new Promise((resolve) => {
|
|
446
|
+
const startedAt = Date.now();
|
|
447
|
+
let cleanup = () => { };
|
|
448
|
+
let settled = false;
|
|
449
|
+
let maximumDisplayTimeoutArmed = false;
|
|
450
|
+
let adTimer;
|
|
451
|
+
const clearAdTimer = () => {
|
|
452
|
+
if (adTimer !== undefined) {
|
|
453
|
+
globalThis.clearTimeout(adTimer);
|
|
454
|
+
adTimer = undefined;
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
const finish = (status) => {
|
|
458
|
+
if (settled) {
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
settled = true;
|
|
462
|
+
clearAdTimer();
|
|
463
|
+
cleanup();
|
|
464
|
+
dispatchAitLifecycleEvent('resume');
|
|
465
|
+
resolve(status);
|
|
466
|
+
};
|
|
467
|
+
const armDisplayStartTimeout = () => {
|
|
468
|
+
clearAdTimer();
|
|
469
|
+
const elapsedMs = Math.max(0, Date.now() - startedAt);
|
|
470
|
+
const remainingMs = Math.max(0, timeoutMs - elapsedMs);
|
|
471
|
+
adTimer = globalThis.setTimeout(() => finish('failed'), Math.min(displayStartTimeoutMs, remainingMs));
|
|
472
|
+
};
|
|
473
|
+
const armMaximumDisplayTimeout = () => {
|
|
474
|
+
if (maximumDisplayTimeoutArmed) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
maximumDisplayTimeoutArmed = true;
|
|
478
|
+
clearAdTimer();
|
|
479
|
+
adTimer = globalThis.setTimeout(() => {
|
|
480
|
+
console.warn('AIT full-screen ad omitted its terminal callback; recovering the game lifecycle.', adGroupId);
|
|
481
|
+
finish('shown');
|
|
482
|
+
}, maximumDisplayMs);
|
|
483
|
+
};
|
|
484
|
+
const failShow = (error) => {
|
|
485
|
+
if (settled) {
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
console.warn('Failed to show AIT full-screen ad.', adGroupId, error ?? 'unknown native error');
|
|
489
|
+
finish('failed');
|
|
490
|
+
};
|
|
491
|
+
adTimer = globalThis.setTimeout(() => finish('failed'), timeoutMs);
|
|
492
|
+
try {
|
|
493
|
+
const unregister = dependencies.showFullScreenAd({
|
|
494
|
+
options: { adGroupId },
|
|
495
|
+
onEvent: (event) => {
|
|
496
|
+
observe?.(event.type);
|
|
497
|
+
switch (event.type) {
|
|
498
|
+
case 'requested':
|
|
499
|
+
armDisplayStartTimeout();
|
|
500
|
+
break;
|
|
501
|
+
case 'show':
|
|
502
|
+
case 'impression':
|
|
503
|
+
case 'clicked':
|
|
504
|
+
case 'userEarnedReward':
|
|
505
|
+
// Native terminal callbacks remain authoritative. A long, one-shot
|
|
506
|
+
// recovery timeout prevents a broken SDK callback from deadlocking
|
|
507
|
+
// the game forever without treating ordinary end-card dwell as failure.
|
|
508
|
+
armMaximumDisplayTimeout();
|
|
509
|
+
break;
|
|
510
|
+
case 'dismissed':
|
|
511
|
+
finish('shown');
|
|
512
|
+
break;
|
|
513
|
+
case 'failedToShow':
|
|
514
|
+
finish('failed');
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
},
|
|
518
|
+
onError: failShow,
|
|
519
|
+
});
|
|
520
|
+
cleanup = unregister;
|
|
521
|
+
if (settled) {
|
|
522
|
+
cleanup();
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
failShow(error);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function getLaunchIntent() {
|
|
531
|
+
const params = inboundSearchParams();
|
|
532
|
+
const challengeToken = nonEmptyParam(params.get('challengeToken'));
|
|
533
|
+
const puzzleId = nonEmptyParam(params.get('puzzleId'));
|
|
534
|
+
const requestedEntry = nonEmptyParam(params.get('entry'));
|
|
535
|
+
let entry;
|
|
536
|
+
if (requestedEntry !== undefined && launchEntries.has(requestedEntry)) {
|
|
537
|
+
entry = requestedEntry;
|
|
538
|
+
}
|
|
539
|
+
else if (challengeToken === undefined) {
|
|
540
|
+
entry = 'home';
|
|
541
|
+
}
|
|
542
|
+
else {
|
|
543
|
+
entry = 'friend-challenge';
|
|
544
|
+
}
|
|
545
|
+
return {
|
|
546
|
+
entry,
|
|
547
|
+
...(puzzleId === undefined ? {} : { puzzleId }),
|
|
548
|
+
...(challengeToken === undefined ? {} : { referralToken: challengeToken }),
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function readInboundShare() {
|
|
552
|
+
const params = inboundSearchParams();
|
|
553
|
+
const puzzleId = nonEmptyParam(params.get('puzzleId'));
|
|
554
|
+
const challengeToken = nonEmptyParam(params.get('challengeToken'));
|
|
555
|
+
return puzzleId === undefined && challengeToken === undefined
|
|
556
|
+
? null
|
|
557
|
+
: {
|
|
558
|
+
...(puzzleId === undefined ? {} : { puzzleId }),
|
|
559
|
+
...(challengeToken === undefined ? {} : { challengeToken }),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
function inboundSearchParams() {
|
|
563
|
+
const params = new URLSearchParams(globalThis.location?.search ?? '');
|
|
564
|
+
const nested = params.get('queryParams');
|
|
565
|
+
if (nested !== null) {
|
|
566
|
+
try {
|
|
567
|
+
const parsed = JSON.parse(nested);
|
|
568
|
+
if (isRecord(parsed)) {
|
|
569
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
570
|
+
if (typeof value === 'string' && !params.has(key)) {
|
|
571
|
+
params.set(key, value);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
// Deep-link payloads are untrusted; malformed nested query data is ignored.
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return params;
|
|
581
|
+
}
|
|
582
|
+
function toAitDeepLink(input, appName) {
|
|
583
|
+
if (input.startsWith('//')) {
|
|
584
|
+
return undefined;
|
|
585
|
+
}
|
|
586
|
+
if (input.startsWith('/')) {
|
|
587
|
+
const baseUrl = new URL('https://mpgd.invalid');
|
|
588
|
+
const parsed = new URL(input, baseUrl);
|
|
589
|
+
return parsed.origin === baseUrl.origin
|
|
590
|
+
? `intoss://${appName}${parsed.pathname}${parsed.search}${parsed.hash}`
|
|
591
|
+
: undefined;
|
|
592
|
+
}
|
|
593
|
+
try {
|
|
594
|
+
const parsed = new URL(input);
|
|
595
|
+
if (parsed.protocol === 'intoss:') {
|
|
596
|
+
return parsed.hostname === appName ? input : undefined;
|
|
597
|
+
}
|
|
598
|
+
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
|
599
|
+
? `intoss://${appName}${parsed.pathname}${parsed.search}${parsed.hash}`
|
|
600
|
+
: undefined;
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function isGameCenterSupported(dependencies) {
|
|
607
|
+
return dependencies.isMinVersionSupported({ android: '5.221.0', ios: '5.221.0' });
|
|
608
|
+
}
|
|
609
|
+
function normalizeAdGroupIds(input) {
|
|
610
|
+
return new Map(Object.entries(input ?? {})
|
|
611
|
+
.map(([placementId, adGroupId]) => [placementId.trim(), adGroupId.trim()])
|
|
612
|
+
.filter(([placementId, adGroupId]) => placementId.length > 0 && adGroupId.length > 0));
|
|
613
|
+
}
|
|
614
|
+
function consumeLoadedAd(adGroupId, loaded) {
|
|
615
|
+
if (!loaded.has(adGroupId)) {
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
loaded.delete(adGroupId);
|
|
619
|
+
return true;
|
|
620
|
+
}
|
|
621
|
+
function decodeStoredValue(serialized) {
|
|
622
|
+
if (serialized === null) {
|
|
623
|
+
return { __mpgdBridgeProtocol: bridgeStorageLoadProtocol, found: false };
|
|
624
|
+
}
|
|
625
|
+
try {
|
|
626
|
+
return {
|
|
627
|
+
__mpgdBridgeProtocol: bridgeStorageLoadProtocol,
|
|
628
|
+
found: true,
|
|
629
|
+
value: JSON.parse(serialized),
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// Malformed native state is treated as absent so the game can recover with
|
|
634
|
+
// its versioned defaults instead of entering a permanent load-error loop.
|
|
635
|
+
return { __mpgdBridgeProtocol: bridgeStorageLoadProtocol, found: false };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
function hasConfiguredAdType(adGroupIds, adPlacementTypes, type) {
|
|
639
|
+
return [...adGroupIds.keys()].some((placementId) => adPlacementTypes.get(placementId) === type);
|
|
640
|
+
}
|
|
641
|
+
function normalizeAdPlacementTypes(input) {
|
|
642
|
+
return new Map(Object.entries(input ?? {})
|
|
643
|
+
.map(([placementId, type]) => [placementId.trim(), type])
|
|
644
|
+
.filter(([placementId]) => placementId.length > 0));
|
|
645
|
+
}
|
|
646
|
+
function normalizeAppName(value) {
|
|
647
|
+
const normalized = value.trim();
|
|
648
|
+
if (!/^[A-Za-z0-9-]+$/u.test(normalized)) {
|
|
649
|
+
throw new Error('AIT appName must contain only letters, numbers, and hyphens.');
|
|
650
|
+
}
|
|
651
|
+
return normalized;
|
|
652
|
+
}
|
|
653
|
+
function normalizeTimeout(value) {
|
|
654
|
+
return normalizePositiveTimeout(value, defaultAdTimeoutMs, 'AIT ad timeout must be a positive finite number.');
|
|
655
|
+
}
|
|
656
|
+
function normalizeLoadQueueTimeout(value) {
|
|
657
|
+
return normalizePositiveTimeout(value, defaultAdLoadQueueTimeoutMs, 'AIT ad load queue timeout must be a positive finite number.');
|
|
658
|
+
}
|
|
659
|
+
function normalizeDisplayStartTimeout(value) {
|
|
660
|
+
return normalizePositiveTimeout(value, defaultAdDisplayStartTimeoutMs, 'AIT ad display start timeout must be a positive finite number.');
|
|
661
|
+
}
|
|
662
|
+
function normalizeMaximumDisplayTimeout(value) {
|
|
663
|
+
return normalizePositiveTimeout(value, defaultAdMaximumDisplayMs, 'AIT ad maximum display timeout must be a positive finite number.');
|
|
664
|
+
}
|
|
665
|
+
function normalizePositiveTimeout(value, defaultValue, message) {
|
|
666
|
+
if (value === undefined) {
|
|
667
|
+
return defaultValue;
|
|
668
|
+
}
|
|
669
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
670
|
+
throw new Error(message);
|
|
671
|
+
}
|
|
672
|
+
return value;
|
|
673
|
+
}
|
|
674
|
+
function readStorageKey(payload) {
|
|
675
|
+
const key = readPayloadRecord(payload).key;
|
|
676
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
677
|
+
throw new TypeError('AIT storage key must be a non-empty string.');
|
|
678
|
+
}
|
|
679
|
+
return key;
|
|
680
|
+
}
|
|
681
|
+
function readPlacementId(payload) {
|
|
682
|
+
const placementId = readPayloadRecord(payload).placementId;
|
|
683
|
+
if (typeof placementId !== 'string' || placementId.length === 0) {
|
|
684
|
+
throw new TypeError('AIT ad placementId must be a non-empty string.');
|
|
685
|
+
}
|
|
686
|
+
return placementId;
|
|
687
|
+
}
|
|
688
|
+
function readIdempotencyKey(payload, fallback) {
|
|
689
|
+
const value = readPayloadRecord(payload).idempotencyKey;
|
|
690
|
+
return typeof value === 'string' && value.length > 0 ? value : fallback;
|
|
691
|
+
}
|
|
692
|
+
function readFiniteScore(payload) {
|
|
693
|
+
const score = readPayloadRecord(payload).score;
|
|
694
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) {
|
|
695
|
+
throw new TypeError('AIT leaderboard score must be a finite number.');
|
|
696
|
+
}
|
|
697
|
+
return score;
|
|
698
|
+
}
|
|
699
|
+
function readPayloadRecord(payload) {
|
|
700
|
+
return isRecord(payload) ? payload : {};
|
|
701
|
+
}
|
|
702
|
+
function parseBridgeRequest(input) {
|
|
703
|
+
// The production host deliberately accepts only the current bridge protocol.
|
|
704
|
+
// Legacy partial requests are rejected rather than silently inventing metadata.
|
|
705
|
+
return assertBridgeRequest(input);
|
|
706
|
+
}
|
|
707
|
+
function readBridgeRequestId(input) {
|
|
708
|
+
if (!isRecord(input) || typeof input.id !== 'string' || input.id.length === 0) {
|
|
709
|
+
return invalidBridgeRequestId;
|
|
710
|
+
}
|
|
711
|
+
return input.id;
|
|
712
|
+
}
|
|
713
|
+
function ok(request, data) {
|
|
714
|
+
return { id: request.id, ok: true, data };
|
|
715
|
+
}
|
|
716
|
+
function createBridgeError(id, code, message, retryable = false) {
|
|
717
|
+
return { id, ok: false, error: { code, message, retryable } };
|
|
718
|
+
}
|
|
719
|
+
function nonEmptyParam(value) {
|
|
720
|
+
return value === null || value.length === 0 ? undefined : value;
|
|
721
|
+
}
|
|
722
|
+
function isAbortError(error) {
|
|
723
|
+
return isRecord(error) && error.name === 'AbortError';
|
|
724
|
+
}
|
|
725
|
+
function isRecord(value) {
|
|
726
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
727
|
+
}
|
|
728
|
+
function errorMessage(error) {
|
|
729
|
+
return error instanceof Error ? error.message : String(error);
|
|
730
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
|
|
2
2
|
import type { PlatformGateway } from '@mpgd/platform';
|
|
3
3
|
export interface GamePlatformBridge {
|
|
4
4
|
request(input: BridgeRequest): Promise<BridgeResponse>;
|
|
@@ -9,4 +9,5 @@ export declare function createAitPlatformGateway(input: {
|
|
|
9
9
|
readonly bridge?: GamePlatformBridge;
|
|
10
10
|
readonly fallbackBridge?: GamePlatformBridge;
|
|
11
11
|
}): PlatformGateway;
|
|
12
|
+
export { aitLifecyclePauseEvent, aitLifecycleResumeEvent, createAitLifecycleAdapter, dispatchAitLifecycleEvent, } from './lifecycle.js';
|
|
12
13
|
export declare function createAitSandboxBridge(): GamePlatformBridge;
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
|
|
2
|
+
import { createAitLifecycleAdapter } from './lifecycle.js';
|
|
1
3
|
export function createAitPlatformGateway(input) {
|
|
2
4
|
async function request(method, payload) {
|
|
3
5
|
const bridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
|
|
@@ -22,7 +24,9 @@ export function createAitPlatformGateway(input) {
|
|
|
22
24
|
}
|
|
23
25
|
return {
|
|
24
26
|
target: 'ait',
|
|
25
|
-
getCapabilities
|
|
27
|
+
async getCapabilities() {
|
|
28
|
+
return { ...await request('runtime.getCapabilities', {}) };
|
|
29
|
+
},
|
|
26
30
|
identity: {
|
|
27
31
|
getPlayer: () => request('identity.getPlayer', {}),
|
|
28
32
|
getSession: () => request('identity.getSession', {}),
|
|
@@ -55,23 +59,16 @@ export function createAitPlatformGateway(input) {
|
|
|
55
59
|
submitScore: (payload) => request('leaderboard.submitScore', payload),
|
|
56
60
|
open: (payload) => request('leaderboard.open', payload ?? {}),
|
|
57
61
|
},
|
|
58
|
-
lifecycle:
|
|
59
|
-
onPause() {
|
|
60
|
-
return () => { };
|
|
61
|
-
},
|
|
62
|
-
onResume() {
|
|
63
|
-
return () => { };
|
|
64
|
-
},
|
|
65
|
-
},
|
|
62
|
+
lifecycle: createAitLifecycleAdapter(),
|
|
66
63
|
storage: {
|
|
67
64
|
async load(payload) {
|
|
68
|
-
|
|
69
|
-
return value === null ? null : { value };
|
|
65
|
+
return decodeBridgeStorageLoadData(await request('storage.load', payload));
|
|
70
66
|
},
|
|
71
67
|
save: (payload) => request('storage.save', payload),
|
|
72
68
|
},
|
|
73
69
|
};
|
|
74
70
|
}
|
|
71
|
+
export { aitLifecyclePauseEvent, aitLifecycleResumeEvent, createAitLifecycleAdapter, dispatchAitLifecycleEvent, } from './lifecycle.js';
|
|
75
72
|
function getBridge() {
|
|
76
73
|
return globalThis.__GAME_PLATFORM_BRIDGE__;
|
|
77
74
|
}
|
|
@@ -170,12 +167,18 @@ export function createAitSandboxBridge() {
|
|
|
170
167
|
return ok(input, {});
|
|
171
168
|
case 'storage.load': {
|
|
172
169
|
const payload = input.payload;
|
|
173
|
-
|
|
170
|
+
const found = payload.key !== undefined && storage.has(payload.key);
|
|
171
|
+
const value = found && payload.key !== undefined
|
|
172
|
+
? cloneJsonValue(storage.get(payload.key))
|
|
173
|
+
: undefined;
|
|
174
|
+
return ok(input, !found
|
|
175
|
+
? { __mpgdBridgeProtocol: bridgeStorageLoadProtocol, found: false }
|
|
176
|
+
: { __mpgdBridgeProtocol: bridgeStorageLoadProtocol, found: true, value });
|
|
174
177
|
}
|
|
175
178
|
case 'storage.save': {
|
|
176
179
|
const payload = input.payload;
|
|
177
180
|
if (payload.key !== undefined) {
|
|
178
|
-
storage.set(payload.key, payload.value);
|
|
181
|
+
storage.set(payload.key, cloneJsonValue(payload.value));
|
|
179
182
|
}
|
|
180
183
|
return ok(input, {});
|
|
181
184
|
}
|
|
@@ -193,6 +196,13 @@ export function createAitSandboxBridge() {
|
|
|
193
196
|
},
|
|
194
197
|
};
|
|
195
198
|
}
|
|
199
|
+
function cloneJsonValue(input) {
|
|
200
|
+
const serialized = JSON.stringify(input);
|
|
201
|
+
if (serialized === undefined) {
|
|
202
|
+
throw new Error('AIT sandbox storage values must be JSON-serializable.');
|
|
203
|
+
}
|
|
204
|
+
return JSON.parse(serialized);
|
|
205
|
+
}
|
|
196
206
|
function ok(input, data) {
|
|
197
207
|
return {
|
|
198
208
|
id: input.id,
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LifecycleAdapter } from '@mpgd/platform';
|
|
2
|
+
export declare const aitLifecyclePauseEvent = "mpgd:ait:pause";
|
|
3
|
+
export declare const aitLifecycleResumeEvent = "mpgd:ait:resume";
|
|
4
|
+
export declare function createAitLifecycleAdapter(): LifecycleAdapter;
|
|
5
|
+
export declare function dispatchAitLifecycleEvent(type: 'pause' | 'resume'): void;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const aitLifecyclePauseEvent = 'mpgd:ait:pause';
|
|
2
|
+
export const aitLifecycleResumeEvent = 'mpgd:ait:resume';
|
|
3
|
+
const lifecycleEventDedupeWindowMs = 500;
|
|
4
|
+
export function createAitLifecycleAdapter() {
|
|
5
|
+
return {
|
|
6
|
+
onPause(callback) {
|
|
7
|
+
return subscribeToAitLifecycle('pause', callback);
|
|
8
|
+
},
|
|
9
|
+
onResume(callback) {
|
|
10
|
+
return subscribeToAitLifecycle('resume', callback);
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function dispatchAitLifecycleEvent(type) {
|
|
15
|
+
globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
|
|
16
|
+
}
|
|
17
|
+
function subscribeToAitLifecycle(type, callback) {
|
|
18
|
+
const target = globalThis;
|
|
19
|
+
const documentTarget = target.document;
|
|
20
|
+
const eventName = type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent;
|
|
21
|
+
let lastFiredAt = Number.NEGATIVE_INFINITY;
|
|
22
|
+
const fire = () => {
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (now - lastFiredAt < lifecycleEventDedupeWindowMs) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
lastFiredAt = now;
|
|
28
|
+
callback();
|
|
29
|
+
};
|
|
30
|
+
const handleCustomEvent = () => fire();
|
|
31
|
+
const handleVisibilityChange = () => {
|
|
32
|
+
if (documentTarget?.visibilityState === (type === 'pause' ? 'hidden' : 'visible')) {
|
|
33
|
+
fire();
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const handlePageTransition = () => fire();
|
|
37
|
+
target.addEventListener?.(eventName, handleCustomEvent);
|
|
38
|
+
documentTarget?.addEventListener('visibilitychange', handleVisibilityChange);
|
|
39
|
+
target.addEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
|
|
40
|
+
return () => {
|
|
41
|
+
target.removeEventListener?.(eventName, handleCustomEvent);
|
|
42
|
+
documentTarget?.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
43
|
+
target.removeEventListener?.(type === 'pause' ? 'pagehide' : 'pageshow', handlePageTransition);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export const aitGameBundleBasePath = '/game/';
|
|
2
|
+
export function rewriteAitGameBundleUrl(url, origin) {
|
|
3
|
+
const trimmedUrl = url.trim();
|
|
4
|
+
if (trimmedUrl.length === 0) {
|
|
5
|
+
throw new Error('Game bundle asset URL must not be empty.');
|
|
6
|
+
}
|
|
7
|
+
if (trimmedUrl.startsWith('//')) {
|
|
8
|
+
throw new Error('Protocol-relative game bundle asset URLs are not supported.');
|
|
9
|
+
}
|
|
10
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl)) {
|
|
11
|
+
let parsedUrl;
|
|
12
|
+
try {
|
|
13
|
+
parsedUrl = new URL(trimmedUrl);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new Error(`Invalid game bundle asset URL: ${trimmedUrl}`);
|
|
17
|
+
}
|
|
18
|
+
if ((parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:')
|
|
19
|
+
|| parsedUrl.origin !== origin) {
|
|
20
|
+
throw new Error(`External game bundle asset URL is not supported: ${parsedUrl.protocol}`);
|
|
21
|
+
}
|
|
22
|
+
return rewriteAitGameBundleUrl(`${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`, origin);
|
|
23
|
+
}
|
|
24
|
+
return rewriteAndValidateGamePath(trimmedUrl, trimmedUrl, origin);
|
|
25
|
+
}
|
|
26
|
+
function rewriteAndValidateGamePath(path, originalUrl, origin) {
|
|
27
|
+
rejectEncodedTraversal(path, originalUrl);
|
|
28
|
+
const normalizedPath = path.startsWith('/') && !path.startsWith(aitGameBundleBasePath)
|
|
29
|
+
? path.slice(1)
|
|
30
|
+
: path;
|
|
31
|
+
const resolvedUrl = new URL(normalizedPath, `${origin}${aitGameBundleBasePath}`);
|
|
32
|
+
if (resolvedUrl.origin !== origin || !resolvedUrl.pathname.startsWith(aitGameBundleBasePath)) {
|
|
33
|
+
throw new Error(`Game bundle asset URL escapes the bundle base path: ${originalUrl}`);
|
|
34
|
+
}
|
|
35
|
+
return `${resolvedUrl.pathname}${resolvedUrl.search}${resolvedUrl.hash}`;
|
|
36
|
+
}
|
|
37
|
+
function rejectEncodedTraversal(path, originalUrl) {
|
|
38
|
+
const rawPath = path.toLowerCase();
|
|
39
|
+
if (rawPath.includes('%25')
|
|
40
|
+
|| rawPath.includes('%2e')
|
|
41
|
+
|| rawPath.includes('%2f')
|
|
42
|
+
|| rawPath.includes('%5c')) {
|
|
43
|
+
throw new Error(`Game bundle asset URL contains encoded path traversal: ${originalUrl}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function mountAitGameBundle(app: HTMLElement): Promise<void>;
|
package/dist/wrapper.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { aitGameBundleBasePath, rewriteAitGameBundleUrl } from './wrapper-url';
|
|
2
|
+
const gameBundleBasePath = aitGameBundleBasePath;
|
|
3
|
+
const gameBundleIndexPath = `${gameBundleBasePath}index.html`;
|
|
4
|
+
const gameAssetAttribute = 'data-mpgd-ait-game-asset';
|
|
5
|
+
const resourceLoadTimeoutMs = 15000;
|
|
6
|
+
const safeScriptAttributes = new Set([
|
|
7
|
+
'crossorigin',
|
|
8
|
+
'integrity',
|
|
9
|
+
'nomodule',
|
|
10
|
+
'nonce',
|
|
11
|
+
'referrerpolicy',
|
|
12
|
+
'type',
|
|
13
|
+
]);
|
|
14
|
+
export function mountAitGameBundle(app) {
|
|
15
|
+
return loadGameBundle(app);
|
|
16
|
+
}
|
|
17
|
+
async function loadGameBundle(app) {
|
|
18
|
+
try {
|
|
19
|
+
const bundleDocument = await fetchGameBundleDocument();
|
|
20
|
+
const gameMount = bundleDocument.querySelector('#game');
|
|
21
|
+
if (gameMount === null) {
|
|
22
|
+
throw new Error('Game bundle mount element was not found.');
|
|
23
|
+
}
|
|
24
|
+
const scripts = Array.from(bundleDocument.querySelectorAll('script'));
|
|
25
|
+
if (scripts.length === 0) {
|
|
26
|
+
throw new Error('Game bundle script was not found.');
|
|
27
|
+
}
|
|
28
|
+
const links = Array.from(bundleDocument.querySelectorAll('link[href]'));
|
|
29
|
+
await Promise.all(links.map((link) => appendBundleLink(link)));
|
|
30
|
+
document.title = bundleDocument.title || document.title;
|
|
31
|
+
app.replaceChildren(createGameMount(gameMount));
|
|
32
|
+
await appendBundleScripts(scripts);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
// Already-executed scripts cannot be un-run; this only removes injected DOM nodes.
|
|
36
|
+
removeInjectedGameAssets();
|
|
37
|
+
renderMissingBundleShell(app, error);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function fetchGameBundleDocument() {
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
const timer = window.setTimeout(() => {
|
|
43
|
+
controller.abort();
|
|
44
|
+
}, resourceLoadTimeoutMs);
|
|
45
|
+
try {
|
|
46
|
+
const response = await fetch(gameBundleIndexPath, {
|
|
47
|
+
cache: 'no-store',
|
|
48
|
+
signal: controller.signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Game bundle request failed with ${response.status}.`);
|
|
52
|
+
}
|
|
53
|
+
const contentType = response.headers.get('Content-Type') ?? '';
|
|
54
|
+
// The wrapper only runs over HTTP(S), where Vite and the AIT container both declare HTML.
|
|
55
|
+
// Missing metadata fails closed before any URLs from the response are injected.
|
|
56
|
+
if (!contentType.toLowerCase().startsWith('text/html')) {
|
|
57
|
+
throw new Error(`Game bundle returned unexpected Content-Type: ${contentType}.`);
|
|
58
|
+
}
|
|
59
|
+
const html = await response.text();
|
|
60
|
+
return new DOMParser().parseFromString(html, 'text/html');
|
|
61
|
+
}
|
|
62
|
+
catch (error) {
|
|
63
|
+
if (controller.signal.aborted) {
|
|
64
|
+
throw new Error(`Timed out loading ${gameBundleIndexPath}.`);
|
|
65
|
+
}
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
window.clearTimeout(timer);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function appendBundleLink(source) {
|
|
73
|
+
const rel = source.getAttribute('rel');
|
|
74
|
+
if (rel !== 'stylesheet' && rel !== 'modulepreload') {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const href = source.getAttribute('href');
|
|
78
|
+
if (href === null) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const rewrittenHref = rewriteGameBundleUrl(href);
|
|
82
|
+
if (hasMarkedElement('link', rewrittenHref)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const link = document.createElement('link');
|
|
86
|
+
link.setAttribute(gameAssetAttribute, 'true');
|
|
87
|
+
copyAttribute(source, link, 'as');
|
|
88
|
+
copyAttribute(source, link, 'crossorigin');
|
|
89
|
+
copyAttribute(source, link, 'integrity');
|
|
90
|
+
copyAttribute(source, link, 'media');
|
|
91
|
+
copyAttribute(source, link, 'nonce');
|
|
92
|
+
copyAttribute(source, link, 'referrerpolicy');
|
|
93
|
+
copyAttribute(source, link, 'type');
|
|
94
|
+
link.rel = rel;
|
|
95
|
+
link.setAttribute('href', rewrittenHref);
|
|
96
|
+
document.head.appendChild(link);
|
|
97
|
+
if (rel === 'stylesheet') {
|
|
98
|
+
await waitForElementLoad(link, rewrittenHref);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async function appendBundleScripts(scripts) {
|
|
102
|
+
const runtimeErrorTrap = createRuntimeErrorTrap();
|
|
103
|
+
try {
|
|
104
|
+
for (const script of scripts) {
|
|
105
|
+
const scriptPromise = appendBundleScript(script);
|
|
106
|
+
// The runtime trap can win while this resource is still loading. Mark its eventual
|
|
107
|
+
// rejection as handled without changing which error wins the race.
|
|
108
|
+
void scriptPromise.catch(() => undefined);
|
|
109
|
+
await Promise.race([scriptPromise, runtimeErrorTrap.promise]);
|
|
110
|
+
await waitForNextMacrotask();
|
|
111
|
+
runtimeErrorTrap.throwIfCaught();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
runtimeErrorTrap.dispose();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function appendBundleScript(source) {
|
|
119
|
+
const src = source.getAttribute('src');
|
|
120
|
+
if (src === null) {
|
|
121
|
+
throw new Error('Inline scripts are not supported in AIT wrapper game bundles.');
|
|
122
|
+
}
|
|
123
|
+
const rewrittenSrc = rewriteGameBundleUrl(src);
|
|
124
|
+
if (hasMarkedElement('script', rewrittenSrc)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const script = document.createElement('script');
|
|
128
|
+
script.setAttribute(gameAssetAttribute, 'true');
|
|
129
|
+
copyScriptAttributes(source, script);
|
|
130
|
+
script.async = false;
|
|
131
|
+
script.setAttribute('src', rewrittenSrc);
|
|
132
|
+
const loadPromise = waitForElementLoad(script, rewrittenSrc);
|
|
133
|
+
document.body.appendChild(script);
|
|
134
|
+
await loadPromise;
|
|
135
|
+
}
|
|
136
|
+
function rewriteGameBundleUrl(url) {
|
|
137
|
+
return rewriteAitGameBundleUrl(url, window.location.origin);
|
|
138
|
+
}
|
|
139
|
+
function createGameMount(source) {
|
|
140
|
+
const tagName = source.tagName.toLowerCase() === 'main' ? 'main' : 'div';
|
|
141
|
+
const mount = document.createElement(tagName);
|
|
142
|
+
for (const attribute of source.attributes) {
|
|
143
|
+
const attributeName = attribute.name.toLowerCase();
|
|
144
|
+
if (attributeName === 'class' ||
|
|
145
|
+
attributeName === 'role' ||
|
|
146
|
+
attributeName.startsWith('aria-') ||
|
|
147
|
+
attributeName.startsWith('data-')) {
|
|
148
|
+
mount.setAttribute(attribute.name, attribute.value);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
mount.id = 'game';
|
|
152
|
+
return mount;
|
|
153
|
+
}
|
|
154
|
+
function copyAttribute(source, target, attributeName) {
|
|
155
|
+
const value = source.getAttribute(attributeName);
|
|
156
|
+
if (value !== null) {
|
|
157
|
+
target.setAttribute(attributeName, value);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function copyScriptAttributes(source, target) {
|
|
161
|
+
for (const attribute of source.attributes) {
|
|
162
|
+
if (safeScriptAttributes.has(attribute.name)) {
|
|
163
|
+
target.setAttribute(attribute.name, attribute.value);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function hasMarkedElement(tagName, url) {
|
|
168
|
+
const attributeName = tagName === 'link' ? 'href' : 'src';
|
|
169
|
+
const elements = document.querySelectorAll(`${tagName}[${gameAssetAttribute}]`);
|
|
170
|
+
return Array.from(elements).some((element) => element.getAttribute(attributeName) === url);
|
|
171
|
+
}
|
|
172
|
+
function removeInjectedGameAssets() {
|
|
173
|
+
document.querySelectorAll(`[${gameAssetAttribute}]`).forEach((element) => element.remove());
|
|
174
|
+
}
|
|
175
|
+
function createRuntimeErrorTrap() {
|
|
176
|
+
let caughtError = null;
|
|
177
|
+
let rejectPromise = () => { };
|
|
178
|
+
const promise = new Promise((_resolve, reject) => {
|
|
179
|
+
rejectPromise = reject;
|
|
180
|
+
});
|
|
181
|
+
promise.catch(() => undefined);
|
|
182
|
+
const catchError = (error) => {
|
|
183
|
+
if (caughtError === null) {
|
|
184
|
+
caughtError = error;
|
|
185
|
+
rejectPromise(error);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const onError = (event) => {
|
|
189
|
+
if (!isGameBundleRuntimeError(event)) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
catchError(event.error instanceof Error ? event.error : new Error(event.message));
|
|
193
|
+
};
|
|
194
|
+
window.addEventListener('error', onError);
|
|
195
|
+
return {
|
|
196
|
+
promise,
|
|
197
|
+
throwIfCaught() {
|
|
198
|
+
if (caughtError !== null) {
|
|
199
|
+
throw caughtError;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
dispose() {
|
|
203
|
+
window.removeEventListener('error', onError);
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function isGameBundleRuntimeError(event) {
|
|
208
|
+
if (event.filename.length === 0) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
const sourceUrl = new URL(event.filename, window.location.href);
|
|
213
|
+
return sourceUrl.origin === window.location.origin && sourceUrl.pathname.startsWith(gameBundleBasePath);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function waitForNextMacrotask() {
|
|
220
|
+
await new Promise((resolve) => {
|
|
221
|
+
window.setTimeout(() => resolve(), 0);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
async function waitForElementLoad(element, url) {
|
|
225
|
+
await new Promise((resolve, reject) => {
|
|
226
|
+
const controller = new AbortController();
|
|
227
|
+
let timer = 0;
|
|
228
|
+
const settle = (callback) => {
|
|
229
|
+
window.clearTimeout(timer);
|
|
230
|
+
controller.abort();
|
|
231
|
+
callback();
|
|
232
|
+
};
|
|
233
|
+
timer = window.setTimeout(() => {
|
|
234
|
+
settle(() => reject(new Error(`Timed out loading ${url}.`)));
|
|
235
|
+
}, resourceLoadTimeoutMs);
|
|
236
|
+
element.addEventListener('load', () => {
|
|
237
|
+
settle(resolve);
|
|
238
|
+
}, { once: true, signal: controller.signal });
|
|
239
|
+
element.addEventListener('error', () => {
|
|
240
|
+
settle(() => reject(new Error(`Failed to load ${url}.`)));
|
|
241
|
+
}, {
|
|
242
|
+
once: true,
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
function renderMissingBundleShell(app, error) {
|
|
248
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
249
|
+
const section = document.createElement('section');
|
|
250
|
+
section.className = 'shell';
|
|
251
|
+
const title = document.createElement('h1');
|
|
252
|
+
title.textContent = 'MPGD AIT Target';
|
|
253
|
+
const guide = document.createElement('p');
|
|
254
|
+
guide.append('Bridge installed. Run ');
|
|
255
|
+
const command = document.createElement('code');
|
|
256
|
+
command.textContent = 'pnpm build:ait';
|
|
257
|
+
guide.append(command, ' from a generated game to copy its bundle into ');
|
|
258
|
+
const outputPath = document.createElement('code');
|
|
259
|
+
outputPath.textContent = 'public/game';
|
|
260
|
+
guide.append(outputPath, '.');
|
|
261
|
+
const errorMessage = document.createElement('p');
|
|
262
|
+
const errorCode = document.createElement('code');
|
|
263
|
+
errorCode.textContent = detail;
|
|
264
|
+
errorMessage.append(errorCode);
|
|
265
|
+
section.append(title, guide, errorMessage);
|
|
266
|
+
app.replaceChildren(section);
|
|
267
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mpgd/adapter-ait",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Apps in Toss WebView platform adapter for mpgd games.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,16 +28,38 @@
|
|
|
28
28
|
".": {
|
|
29
29
|
"types": "./dist/index.d.ts",
|
|
30
30
|
"default": "./dist/index.js"
|
|
31
|
-
}
|
|
31
|
+
},
|
|
32
|
+
"./host": {
|
|
33
|
+
"types": "./dist/host.d.ts",
|
|
34
|
+
"default": "./dist/host.js"
|
|
35
|
+
},
|
|
36
|
+
"./wrapper": {
|
|
37
|
+
"types": "./dist/wrapper.d.ts",
|
|
38
|
+
"default": "./dist/wrapper.js"
|
|
39
|
+
},
|
|
40
|
+
"./ad-config": {
|
|
41
|
+
"types": "./dist/ad-config.d.ts",
|
|
42
|
+
"default": "./dist/ad-config.js"
|
|
43
|
+
},
|
|
44
|
+
"./package.json": "./package.json"
|
|
32
45
|
},
|
|
33
46
|
"files": [
|
|
34
47
|
"dist"
|
|
35
48
|
],
|
|
36
49
|
"dependencies": {
|
|
37
|
-
"@mpgd/bridge": "0.6.
|
|
38
|
-
"@mpgd/platform": "0.
|
|
50
|
+
"@mpgd/bridge": "0.6.1",
|
|
51
|
+
"@mpgd/platform": "0.7.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@apps-in-toss/web-framework": ">=2.10.4 <3"
|
|
55
|
+
},
|
|
56
|
+
"peerDependenciesMeta": {
|
|
57
|
+
"@apps-in-toss/web-framework": {
|
|
58
|
+
"optional": true
|
|
59
|
+
}
|
|
39
60
|
},
|
|
40
61
|
"devDependencies": {
|
|
62
|
+
"@apps-in-toss/web-framework": "^2.10.6",
|
|
41
63
|
"ttsc": "0.18.4",
|
|
42
64
|
"typescript": "7.0.2",
|
|
43
65
|
"vitest": "^4.0.0"
|