@mpgd/adapter-ait 0.12.7 → 0.13.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.
@@ -0,0 +1,18 @@
1
+ import { type AdProvider, type AdShowInput, type AdShowResult } from '@mpgd/platform/ads';
2
+ import type { AitHostDependencies } from './host.js';
3
+ import type { AitAdPlacementType } from './ad-config.js';
4
+ /** SDK event interpretation only. Game execution and backend grants are separate owners. */
5
+ export declare function createAitAdProvider(input: {
6
+ readonly dependencies: Pick<AitHostDependencies, 'showFullScreenAd'>;
7
+ readonly groups: ReadonlyMap<string, string>;
8
+ readonly types: ReadonlyMap<string, AitAdPlacementType>;
9
+ readonly isSupported: () => boolean;
10
+ readonly prepare: (group: string) => Promise<void>;
11
+ readonly consume: (group: string) => boolean;
12
+ readonly requestTimeoutMs: number;
13
+ readonly startTimeoutMs: number;
14
+ readonly displayTimeoutMs: number;
15
+ readonly onLegacyPhase?: (active: boolean) => void;
16
+ }): AdProvider & {
17
+ showLegacy(request: AdShowInput): Promise<AdShowResult>;
18
+ };
@@ -0,0 +1,295 @@
1
+ import { adProtocol, adProtocolVersion, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, } from '@mpgd/platform/ads';
2
+ const nativeOwners = new WeakMap();
3
+ /** SDK event interpretation only. Game execution and backend grants are separate owners. */
4
+ export function createAitAdProvider(input) {
5
+ const id = 'apps-in-toss-ads';
6
+ const sdk = input.dependencies.showFullScreenAd;
7
+ const listeners = new Set();
8
+ function availability(request) {
9
+ if (!input.isSupported()) {
10
+ return { state: 'unsupported', reason: 'unsupported' };
11
+ }
12
+ if (input.types.has(request.placementId) && input.types.get(request.placementId) !== request.format) {
13
+ return {
14
+ state: 'unsupported',
15
+ reason: 'unsupported',
16
+ };
17
+ }
18
+ if (!input.groups.has(request.placementId) || !input.types.has(request.placementId)) {
19
+ return {
20
+ state: 'configuration-required',
21
+ reason: 'configuration-required',
22
+ };
23
+ }
24
+ const owner = nativeOwners.get(sdk);
25
+ if (owner?.state === 'unknown') {
26
+ return {
27
+ state: 'action-required',
28
+ reason: 'action-required',
29
+ };
30
+ }
31
+ if (owner !== undefined) {
32
+ return { state: 'temporarily-unavailable', reason: 'busy' };
33
+ }
34
+ return { state: 'available' };
35
+ }
36
+ async function show(supplied, legacy) {
37
+ const request = assertAdShowInput(supplied);
38
+ if (request.idempotencyKey.length > 256 || /[\p{Cc}\p{Cf}]/u.test(request.idempotencyKey)) {
39
+ throw new TypeError('AIT ad correlation must contain 1 to 256 visible characters.');
40
+ }
41
+ const makeResult = (state) => assertAdShowResult({ providerId: id, invocationId: request.invocationId, format: request.format, ...state });
42
+ const noStart = (outcome, reason) => makeResult({
43
+ outcome,
44
+ presentation: 'not-started',
45
+ eligibility: request.format === 'rewarded' ? 'not-earned' : 'not-applicable',
46
+ ...(reason === undefined ? {} : { reason }),
47
+ });
48
+ const available = availability(request);
49
+ if (available.state !== 'available') {
50
+ return noStart('unavailable', available.reason);
51
+ }
52
+ const group = input.groups.get(request.placementId);
53
+ if (group === undefined) {
54
+ return noStart('unavailable', 'configuration-required');
55
+ }
56
+ const owned = { state: 'requested' };
57
+ nativeOwners.set(sdk, owned);
58
+ const legacyPhase = (active) => {
59
+ if (!legacy) {
60
+ return;
61
+ }
62
+ try {
63
+ input.onLegacyPhase?.(active);
64
+ }
65
+ catch { /* Isolate lifecycle observation. */ }
66
+ };
67
+ const release = () => {
68
+ if (nativeOwners.get(sdk) === owned) {
69
+ nativeOwners.delete(sdk);
70
+ }
71
+ };
72
+ try {
73
+ await input.prepare(group);
74
+ }
75
+ catch {
76
+ release();
77
+ return noStart('failed', 'transient-failure');
78
+ }
79
+ if (!input.consume(group)) {
80
+ release();
81
+ return noStart('failed', 'transient-failure');
82
+ }
83
+ let started = false;
84
+ let closed = false;
85
+ let earned;
86
+ let sequence = 0;
87
+ let cleanup;
88
+ let cleaned = false;
89
+ let timer;
90
+ let displayTimerArmed = false;
91
+ let delivered = false;
92
+ let resolve;
93
+ const result = new Promise((done) => {
94
+ resolve = done;
95
+ });
96
+ const startedAt = Date.now();
97
+ const emit = (event) => {
98
+ const safe = assertAdPresentationEvent({
99
+ ...event,
100
+ providerId: id,
101
+ invocationId: request.invocationId,
102
+ sequence: ++sequence,
103
+ });
104
+ for (const listener of [...listeners]) {
105
+ try {
106
+ void Promise.resolve(listener(safe)).catch(() => undefined);
107
+ }
108
+ catch { /* Keep native terminal observation alive. */ }
109
+ }
110
+ };
111
+ const clean = () => {
112
+ if (cleaned || cleanup === undefined || !closed || request.format === 'rewarded' && earned === undefined) {
113
+ return;
114
+ }
115
+ cleaned = true;
116
+ try {
117
+ cleanup();
118
+ }
119
+ catch { /* Cleanup does not change native closure. */ }
120
+ };
121
+ const finish = (value) => {
122
+ if (!delivered) {
123
+ delivered = true;
124
+ resolve(value);
125
+ }
126
+ };
127
+ const eligibility = () => {
128
+ if (request.format === 'interstitial') {
129
+ return 'not-applicable';
130
+ }
131
+ return earned === undefined ? 'unknown' : 'eligible';
132
+ };
133
+ const uncertain = () => {
134
+ if (closed || owned.state === 'unknown') {
135
+ return;
136
+ }
137
+ owned.state = 'unknown';
138
+ emit({ type: 'unknown' });
139
+ finish(makeResult({
140
+ outcome: 'pending',
141
+ presentation: 'unknown',
142
+ eligibility: eligibility(),
143
+ reason: 'outcome-unknown',
144
+ ...(earned === undefined ? {} : { evidence: earned }),
145
+ }));
146
+ };
147
+ const close = (failed) => {
148
+ if (closed) {
149
+ return;
150
+ }
151
+ closed = true;
152
+ if (timer !== undefined) {
153
+ clearTimeout(timer);
154
+ }
155
+ release();
156
+ legacyPhase(false);
157
+ emit(failed ? { type: 'failed', reason: 'transient-failure' } : { type: 'closed' });
158
+ if (failed && !started && earned === undefined) {
159
+ finish(noStart('failed', 'transient-failure'));
160
+ }
161
+ else {
162
+ finish(makeResult({
163
+ outcome: failed ? 'failed' : 'shown',
164
+ presentation: 'closed',
165
+ eligibility: eligibility(),
166
+ ...(failed ? { reason: 'transient-failure' } : {}),
167
+ ...(earned === undefined ? {} : { evidence: earned }),
168
+ }));
169
+ }
170
+ // Failure before presentation proves no earning is forthcoming. After a
171
+ // started presentation, retain the scoped callback for a delayed reward.
172
+ if (failed && !started && earned === undefined) {
173
+ cleaned = true;
174
+ if (cleanup !== undefined) {
175
+ try {
176
+ cleanup();
177
+ }
178
+ catch { /* Isolate cleanup. */ }
179
+ }
180
+ }
181
+ else {
182
+ clean();
183
+ }
184
+ };
185
+ const startedEvent = () => {
186
+ if (closed) {
187
+ return;
188
+ }
189
+ if (!started) {
190
+ started = true;
191
+ owned.state = 'open';
192
+ emit({ type: 'started' });
193
+ }
194
+ if (!displayTimerArmed) {
195
+ displayTimerArmed = true;
196
+ if (timer !== undefined) {
197
+ clearTimeout(timer);
198
+ }
199
+ timer = setTimeout(uncertain, input.displayTimeoutMs);
200
+ }
201
+ };
202
+ legacyPhase(true);
203
+ emit({ type: 'requested' });
204
+ timer = setTimeout(uncertain, input.requestTimeoutMs);
205
+ try {
206
+ cleanup = sdk({
207
+ options: { adGroupId: group },
208
+ onEvent(event) {
209
+ switch (event.type) {
210
+ case 'requested':
211
+ if (!closed && !started) {
212
+ if (timer !== undefined) {
213
+ clearTimeout(timer);
214
+ }
215
+ timer = setTimeout(uncertain, Math.min(input.startTimeoutMs, Math.max(0, input.requestTimeoutMs - (Date.now() - startedAt))));
216
+ }
217
+ break;
218
+ case 'show':
219
+ case 'impression':
220
+ case 'clicked':
221
+ startedEvent();
222
+ break;
223
+ case 'userEarnedReward':
224
+ if (request.format !== 'rewarded' || earned !== undefined || cleaned) {
225
+ break;
226
+ }
227
+ if (!closed) {
228
+ startedEvent();
229
+ }
230
+ earned = { schema: 'apps-in-toss.rewarded-ad.callback.v1', payload: { event: 'user-earned-reward', correlationId: request.idempotencyKey, placementId: group } };
231
+ emit({ type: 'reward-earned', evidence: earned });
232
+ clean();
233
+ break;
234
+ case 'dismissed':
235
+ close(false);
236
+ break;
237
+ case 'failedToShow':
238
+ close(true);
239
+ break;
240
+ }
241
+ },
242
+ // A bridge error does not prove the native UI never opened.
243
+ onError() { uncertain(); },
244
+ });
245
+ if (cleaned) {
246
+ try {
247
+ cleanup();
248
+ }
249
+ catch { /* Isolate synchronous terminal cleanup. */ }
250
+ }
251
+ else {
252
+ clean();
253
+ }
254
+ }
255
+ catch {
256
+ uncertain();
257
+ }
258
+ return result;
259
+ }
260
+ return {
261
+ id,
262
+ protocol: adProtocol,
263
+ protocolVersion: adProtocolVersion,
264
+ rewardSignal: 'delayed',
265
+ getAvailability: async (request) => availability(request),
266
+ async preload(request) {
267
+ const available = availability(request);
268
+ if (available.state !== 'available') {
269
+ return {
270
+ status: 'unavailable',
271
+ ...(available.reason === undefined ? {} : { reason: available.reason }),
272
+ };
273
+ }
274
+ const group = input.groups.get(request.placementId);
275
+ if (group === undefined) {
276
+ return { status: 'unavailable', reason: 'configuration-required' };
277
+ }
278
+ try {
279
+ await input.prepare(group);
280
+ return { status: 'ready' };
281
+ }
282
+ catch {
283
+ return { status: 'failed', reason: 'transient-failure' };
284
+ }
285
+ },
286
+ show: (request) => show(request, false),
287
+ showLegacy: (request) => show(request, true),
288
+ subscribe(listener) {
289
+ listeners.add(listener);
290
+ return () => {
291
+ listeners.delete(listener);
292
+ };
293
+ },
294
+ };
295
+ }
package/dist/host.d.ts CHANGED
@@ -149,7 +149,7 @@ export interface InstallAitHostBridgeOptions {
149
149
  readonly adLoadQueueTimeoutMs?: number;
150
150
  /** Upper bound for the requested-to-display portion of the total show timeout. */
151
151
  readonly adDisplayStartTimeoutMs?: number;
152
- /** Last-resort cleanup when the native SDK omits its terminal display callback. */
152
+ /** Caller wait limit; a missing native terminal callback keeps presentation unknown. */
153
153
  readonly adMaximumDisplayMs?: number;
154
154
  /** Maximum wait for an inline banner to render or report no-fill/failure. */
155
155
  readonly bannerRenderTimeoutMs?: number;
package/dist/host.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import { getTossShareLink, getUserKeyForGame, grantPromotionRewardForGame as grantPromotionReward, IAP, isMinVersionSupported, loadFullScreenAd, openGameCenterLeaderboard, requestNotificationAgreement, share, showFullScreenAd, Storage, submitGameCenterLeaderBoardScore, TossAds, } from '@apps-in-toss/web-framework';
2
2
  import { assertBridgeRequest, bridgeStorageLoadProtocol, } from '@mpgd/bridge';
3
+ import { assertAdShowInput, toAdAdapter } from '@mpgd/platform/ads';
4
+ import { createAitAdProvider } from './ad-provider.js';
3
5
  import { dispatchAitLifecycleEvent } from './lifecycle.js';
4
6
  const defaultAdTimeoutMs = 60_000;
5
7
  const defaultAdLoadQueueTimeoutMs = 5_000;
@@ -13,7 +15,6 @@ const defaultIapPurchaseSessionTimeoutMs = 30 * 60_000;
13
15
  /** A provider response is untrusted input; keep restore work bounded per launch. */
14
16
  const maximumPendingIapOrders = 20;
15
17
  const invalidBridgeRequestId = 'ait-invalid-request';
16
- const rewardedAdEvidenceSchema = 'apps-in-toss.rewarded-ad.callback.v1';
17
18
  const iapEvidenceSchema = 'apps-in-toss.iap.callback.v1';
18
19
  const minimumPromotionTossAppVersion = '5.232.0';
19
20
  /**
@@ -115,7 +116,6 @@ export function createAitHostBridge(options = {}) {
115
116
  const notificationSubscriptions = new Set();
116
117
  const loadedAdGroupIds = new Set();
117
118
  const loadingAdGroups = new Map();
118
- const activeAdGroupIds = new Set();
119
119
  const activeBannerAttachments = new Map();
120
120
  const bannerMountOwners = new Map();
121
121
  const pendingBannerMountSettlements = new Map();
@@ -131,10 +131,20 @@ export function createAitHostBridge(options = {}) {
131
131
  tone: options.bannerAppearance?.tone ?? 'blackAndWhite',
132
132
  variant: options.bannerAppearance?.variant ?? 'expanded',
133
133
  };
134
- const adLoadCoordinator = {
135
- active: undefined,
136
- waitTimeoutMs: adLoadQueueTimeoutMs,
137
- };
134
+ const adLoadCoordinator = createAdLoadCoordinator(dependencies.loadFullScreenAd, adLoadQueueTimeoutMs);
135
+ const nativeAds = createAitAdProvider({
136
+ dependencies,
137
+ groups: adGroupIds,
138
+ types: adPlacementTypes,
139
+ isSupported: () => areFullScreenAdsSupported(dependencies),
140
+ prepare: (group) => preloadAdGroup(dependencies, group, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs),
141
+ consume: (group) => consumeLoadedAd(group, loadedAdGroupIds),
142
+ requestTimeoutMs: adTimeoutMs,
143
+ startTimeoutMs: adDisplayStartTimeoutMs,
144
+ displayTimeoutMs: adMaximumDisplayMs,
145
+ onLegacyPhase: (active) => dispatchAitLifecycleEvent(active ? 'pause' : 'resume'),
146
+ });
147
+ const legacyAds = toAdAdapter({ ...nativeAds, show: nativeAds.showLegacy });
138
148
  const enqueueCompletedIapAttemptRetention = (storage, storageKey) => {
139
149
  const scheduled = completedIapAttemptRetention.then(async () => {
140
150
  await retainCompletedAitIapPurchaseAttempt(storage, storageKey, iapProductGrantTimeoutMs);
@@ -145,6 +155,13 @@ export function createAitHostBridge(options = {}) {
145
155
  return next;
146
156
  };
147
157
  return {
158
+ advertising: {
159
+ id: nativeAds.id,
160
+ protocol: nativeAds.protocol,
161
+ protocolVersion: nativeAds.protocolVersion,
162
+ rewardSignal: nativeAds.rewardSignal,
163
+ subscribe: (listener) => nativeAds.subscribe(listener),
164
+ },
148
165
  async request(input) {
149
166
  try {
150
167
  return await handleRequest(parseBridgeRequest(input));
@@ -377,54 +394,24 @@ export function createAitHostBridge(options = {}) {
377
394
  await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, adTimeoutMs, placementType);
378
395
  return ok(request, {});
379
396
  }
380
- case 'ads.showRewarded': {
381
- const placementId = readPlacementId(request.payload);
382
- const correlationId = readRewardedAdCorrelationId(request.payload, request.id);
383
- const adGroupId = adGroupIds.get(placementId);
384
- if (adGroupId === undefined
385
- || adPlacementTypes.get(placementId) !== 'rewarded'
386
- || !areFullScreenAdsSupported(dependencies)) {
387
- return ok(request, { status: 'unavailable', rewardGranted: false });
388
- }
389
- const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'rewarded', async () => {
390
- const result = await showRewardedAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
391
- return result.rewardGranted
392
- ? {
393
- ...result,
394
- // The correlationId is client-supplied evidence, not a native receipt:
395
- // the Apps in Toss show call only carries the adGroupId, so nothing
396
- // native echoes it back. game-services forwards it as
397
- // platformImpressionId; the backend rewardAuthority must verify it
398
- // (and the rest of this evidence) before granting any reward.
399
- ledgerEntryId: correlationId,
400
- evidence: {
401
- schema: rewardedAdEvidenceSchema,
402
- payload: {
403
- event: 'user-earned-reward',
404
- correlationId,
405
- placementId: adGroupId,
406
- },
407
- },
408
- }
409
- : result;
397
+ case 'ads.getAvailability':
398
+ case 'ads.prepare': {
399
+ const payload = readPayloadRecord(request.payload);
400
+ const query = assertAdShowInput({
401
+ ...payload,
402
+ invocationId: 'host-query',
403
+ idempotencyKey: 'host-query',
410
404
  });
411
- if (!shown.acquired) {
412
- return ok(request, { status: 'unavailable', rewardGranted: false });
413
- }
414
- return ok(request, shown.value);
415
- }
416
- case 'ads.showInterstitial': {
417
- const placementId = readPlacementId(request.payload);
418
- const adGroupId = adGroupIds.get(placementId);
419
- if (adGroupId === undefined
420
- || adPlacementTypes.get(placementId) !== 'interstitial'
421
- || !areFullScreenAdsSupported(dependencies)) {
422
- return ok(request, { status: 'unavailable' });
423
- }
424
- const showInterstitial = () => showInterstitialAd(dependencies, adGroupId, adTimeoutMs, adDisplayStartTimeoutMs, adMaximumDisplayMs);
425
- const shown = await withLoadedAdSlot(dependencies, adGroupId, loadedAdGroupIds, loadingAdGroups, adLoadCoordinator, activeAdGroupIds, adTimeoutMs, 'interstitial', showInterstitial);
426
- return ok(request, shown.acquired ? shown.value : { status: 'unavailable' });
405
+ return ok(request, request.method === 'ads.prepare'
406
+ ? await nativeAds.preload(query)
407
+ : await nativeAds.getAvailability(query));
427
408
  }
409
+ case 'ads.show':
410
+ return ok(request, await nativeAds.show(assertAdShowInput(request.payload)));
411
+ case 'ads.showRewarded':
412
+ return ok(request, await legacyAds.showRewarded({ placementId: readPlacementId(request.payload), idempotencyKey: readRewardedAdCorrelationId(request.payload, request.id) }));
413
+ case 'ads.showInterstitial':
414
+ return ok(request, await legacyAds.showInterstitial?.({ placementId: readPlacementId(request.payload) }));
428
415
  case 'ads.mountBanner': {
429
416
  const placementId = readPlacementId(request.payload);
430
417
  const surfaceId = readBannerSurfaceId(request.payload);
@@ -838,6 +825,24 @@ async function preloadAdGroup(dependencies, adGroupId, loaded, loading, coordina
838
825
  }
839
826
  }
840
827
  }
828
+ const nativeAdLoadScopes = new WeakMap();
829
+ function createAdLoadCoordinator(sdk, waitTimeoutMs) {
830
+ let shared = nativeAdLoadScopes.get(sdk);
831
+ if (shared === undefined) {
832
+ shared = { active: undefined };
833
+ nativeAdLoadScopes.set(sdk, shared);
834
+ }
835
+ const owned = shared;
836
+ return {
837
+ get active() {
838
+ return owned.active;
839
+ },
840
+ set active(value) {
841
+ owned.active = value;
842
+ },
843
+ waitTimeoutMs,
844
+ };
845
+ }
841
846
  async function runSerializedAdLoad(coordinator, adGroupId, startNativeLoad) {
842
847
  const waitDeadline = Date.now() + coordinator.waitTimeoutMs;
843
848
  while (coordinator.active !== undefined) {
@@ -881,22 +886,6 @@ function waitForAdLoadSettlement(active, timeoutMs, adGroupId) {
881
886
  void active.then(() => finish(), () => finish());
882
887
  });
883
888
  }
884
- async function acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType) {
885
- if (active.has(adGroupId)) {
886
- return false;
887
- }
888
- const loadedSuccessfully = await preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType);
889
- if (!loadedSuccessfully) {
890
- return false;
891
- }
892
- // Re-check after the asynchronous load so concurrent callers cannot consume
893
- // or display the same native ad slot twice.
894
- if (active.has(adGroupId) || !consumeLoadedAd(adGroupId, loaded)) {
895
- return false;
896
- }
897
- active.add(adGroupId);
898
- return true;
899
- }
900
889
  async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, loading, coordinator, timeoutMs, placementType) {
901
890
  // The first caller enters preloadAdGroup synchronously and publishes its promise
902
891
  // before yielding. Remember that ownership so concurrent waiters do not emit the
@@ -915,131 +904,6 @@ async function preloadAdGroupWithDiagnostics(dependencies, adGroupId, loaded, lo
915
904
  return false;
916
905
  }
917
906
  }
918
- async function withLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType, display) {
919
- const acquired = await acquireLoadedAdSlot(dependencies, adGroupId, loaded, loading, coordinator, active, timeoutMs, placementType);
920
- if (!acquired) {
921
- return { acquired: false };
922
- }
923
- try {
924
- return { acquired: true, value: await display() };
925
- }
926
- finally {
927
- active.delete(adGroupId);
928
- }
929
- }
930
- async function showRewardedAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
931
- let rewardGranted = false;
932
- const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, (eventType) => {
933
- if (eventType === 'userEarnedReward') {
934
- rewardGranted = true;
935
- }
936
- });
937
- let resultStatus;
938
- if (status === 'shown' && rewardGranted) {
939
- resultStatus = 'completed';
940
- }
941
- else if (status === 'failed') {
942
- resultStatus = 'failed';
943
- }
944
- else {
945
- resultStatus = 'skipped';
946
- }
947
- return {
948
- status: resultStatus,
949
- rewardGranted,
950
- };
951
- }
952
- async function showInterstitialAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs) {
953
- const status = await showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs);
954
- return { status: status === 'failed' ? 'skipped' : status };
955
- }
956
- function showAd(dependencies, adGroupId, timeoutMs, displayStartTimeoutMs, maximumDisplayMs, observe) {
957
- dispatchAitLifecycleEvent('pause');
958
- return new Promise((resolve) => {
959
- const startedAt = Date.now();
960
- let cleanup = () => { };
961
- let settled = false;
962
- let maximumDisplayTimeoutArmed = false;
963
- let adTimer;
964
- const clearAdTimer = () => {
965
- if (adTimer !== undefined) {
966
- globalThis.clearTimeout(adTimer);
967
- adTimer = undefined;
968
- }
969
- };
970
- const finish = (status) => {
971
- if (settled) {
972
- return;
973
- }
974
- settled = true;
975
- clearAdTimer();
976
- cleanup();
977
- dispatchAitLifecycleEvent('resume');
978
- resolve(status);
979
- };
980
- const armDisplayStartTimeout = () => {
981
- clearAdTimer();
982
- const elapsedMs = Math.max(0, Date.now() - startedAt);
983
- const remainingMs = Math.max(0, timeoutMs - elapsedMs);
984
- adTimer = globalThis.setTimeout(() => finish('failed'), Math.min(displayStartTimeoutMs, remainingMs));
985
- };
986
- const armMaximumDisplayTimeout = () => {
987
- if (maximumDisplayTimeoutArmed) {
988
- return;
989
- }
990
- maximumDisplayTimeoutArmed = true;
991
- clearAdTimer();
992
- adTimer = globalThis.setTimeout(() => {
993
- console.warn('AIT full-screen ad omitted its terminal callback; recovering the game lifecycle.', adGroupId);
994
- finish('shown');
995
- }, maximumDisplayMs);
996
- };
997
- const failShow = (error) => {
998
- if (settled) {
999
- return;
1000
- }
1001
- console.warn('Failed to show AIT full-screen ad.', adGroupId, error ?? 'unknown native error');
1002
- finish('failed');
1003
- };
1004
- adTimer = globalThis.setTimeout(() => finish('failed'), timeoutMs);
1005
- try {
1006
- const unregister = dependencies.showFullScreenAd({
1007
- options: { adGroupId },
1008
- onEvent: (event) => {
1009
- observe?.(event.type);
1010
- switch (event.type) {
1011
- case 'requested':
1012
- armDisplayStartTimeout();
1013
- break;
1014
- case 'show':
1015
- case 'impression':
1016
- case 'clicked':
1017
- case 'userEarnedReward':
1018
- // Native terminal callbacks remain authoritative. A long, one-shot
1019
- // recovery timeout prevents a broken SDK callback from deadlocking
1020
- // the game forever without treating ordinary end-card dwell as failure.
1021
- armMaximumDisplayTimeout();
1022
- break;
1023
- case 'dismissed':
1024
- finish('shown');
1025
- break;
1026
- case 'failedToShow':
1027
- finish('failed');
1028
- break;
1029
- }
1030
- },
1031
- onError: failShow,
1032
- });
1033
- cleanup = unregister;
1034
- if (settled) {
1035
- cleanup();
1036
- }
1037
- }
1038
- catch (error) {
1039
- failShow(error);
1040
- }
1041
- });
1042
- }
1043
907
  /**
1044
908
  * Launch and inbound-share parameters arrive from untrusted deep links. Bound
1045
909
  * each forwarded value and the nested `queryParams` JSON so a hostile link
package/dist/index.d.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { type BridgeRequest, type BridgeResponse } from '@mpgd/bridge';
2
2
  import { type PlatformGateway } from '@mpgd/platform';
3
+ import { type AdProvider } from '@mpgd/platform/ads';
3
4
  export interface GamePlatformBridge {
4
5
  request(input: BridgeRequest): Promise<BridgeResponse>;
6
+ /** Native observations are scoped to this host; SDK commands still use request envelopes. */
7
+ readonly advertising?: Pick<AdProvider, 'id' | 'protocol' | 'protocolVersion' | 'rewardSignal' | 'subscribe'>;
5
8
  }
6
9
  export interface AitSandboxStorage {
7
10
  readonly getItem: (key: string) => string | null;
package/dist/index.js CHANGED
@@ -1,7 +1,48 @@
1
- import { bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
1
+ import { assertBridgeResponse, bridgeStorageLoadProtocol, decodeBridgeStorageLoadData, } from '@mpgd/bridge';
2
2
  import { PlatformOperationError, } from '@mpgd/platform';
3
+ import { assertAdAvailability, assertAdPreparationResult, assertAdPresentationEvent, assertAdShowInput, assertAdShowResult, toAdAdapter, } from '@mpgd/platform/ads';
3
4
  import { createAitLifecycleAdapter } from './lifecycle.js';
4
5
  export function createAitPlatformGateway(input) {
6
+ const advertisingBridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
7
+ const advertising = advertisingBridge?.advertising;
8
+ async function requestAdvertising(method, payload) {
9
+ if (advertisingBridge === undefined) {
10
+ throw new PlatformOperationError({
11
+ code: 'AIT_BRIDGE_NOT_INSTALLED',
12
+ });
13
+ }
14
+ const id = crypto.randomUUID();
15
+ const response = assertBridgeResponse(await advertisingBridge.request({ id, method, payload, meta: { target: 'ait', appVersion: input.appVersion, buildId: input.buildId, sentAt: new Date().toISOString() } }));
16
+ if (response.id !== id) {
17
+ throw new TypeError('Advertising response belongs to another bridge request.');
18
+ }
19
+ if (!response.ok) {
20
+ throw new PlatformOperationError(response.error);
21
+ }
22
+ return response.data;
23
+ }
24
+ const provider = advertising === undefined
25
+ ? undefined
26
+ : {
27
+ id: advertising.id,
28
+ protocol: advertising.protocol,
29
+ protocolVersion: advertising.protocolVersion,
30
+ rewardSignal: advertising.rewardSignal,
31
+ getAvailability: async (payload) => assertAdAvailability(await requestAdvertising('ads.getAvailability', payload)),
32
+ preload: async (payload) => assertAdPreparationResult(await requestAdvertising('ads.prepare', payload)),
33
+ show: async (supplied) => {
34
+ const request = assertAdShowInput(supplied);
35
+ const result = assertAdShowResult(await requestAdvertising('ads.show', request));
36
+ if (result.providerId !== advertising.id || result.invocationId !== request.invocationId || result.format !== request.format) {
37
+ throw new TypeError('Advertising result belongs to another invocation.');
38
+ }
39
+ return result;
40
+ },
41
+ subscribe: (listener) => advertising.subscribe((event) => listener(assertAdPresentationEvent(event))),
42
+ };
43
+ if (provider !== undefined) {
44
+ toAdAdapter(provider);
45
+ }
5
46
  async function request(method, payload) {
6
47
  const bridge = input.bridge ?? getBridge() ?? input.fallbackBridge;
7
48
  if (bridge === undefined) {
@@ -60,6 +101,7 @@ export function createAitPlatformGateway(input) {
60
101
  getEntitlements: () => request('commerce.getEntitlements', {}),
61
102
  },
62
103
  ads: {
104
+ ...(provider === undefined ? {} : { provider }),
63
105
  preload: (payload) => request('ads.preload', payload),
64
106
  showRewarded: (payload) => request('ads.showRewarded', payload),
65
107
  showInterstitial: (payload) => request('ads.showInterstitial', payload),
@@ -1,5 +1,6 @@
1
1
  import type { LifecycleAdapter } from '@mpgd/platform';
2
2
  export declare const aitLifecyclePauseEvent = "mpgd:ait:pause";
3
3
  export declare const aitLifecycleResumeEvent = "mpgd:ait:resume";
4
+ /** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
4
5
  export declare function createAitLifecycleAdapter(): LifecycleAdapter;
5
6
  export declare function dispatchAitLifecycleEvent(type: 'pause' | 'resume'): void;
package/dist/lifecycle.js CHANGED
@@ -1,45 +1,92 @@
1
1
  export const aitLifecyclePauseEvent = 'mpgd:ait:pause';
2
2
  export const aitLifecycleResumeEvent = 'mpgd:ait:resume';
3
- const lifecycleEventDedupeWindowMs = 500;
3
+ /** Legacy SDK presentation, page lifetime, and visibility are independent owners. */
4
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
5
  const target = globalThis;
19
6
  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;
7
+ const pauses = new Set();
8
+ const resumes = new Set();
9
+ let nativePaused = false;
10
+ let pageActive = true;
11
+ let attached = false;
12
+ let active = documentTarget?.visibilityState !== 'hidden';
13
+ const notify = (callback) => {
14
+ try {
15
+ void Promise.resolve(callback()).catch(() => undefined);
26
16
  }
27
- lastFiredAt = now;
28
- callback();
17
+ catch { /* Keep lifecycle observation alive. */ }
29
18
  };
30
- const handleCustomEvent = () => fire();
31
- const handleVisibilityChange = () => {
32
- if (documentTarget?.visibilityState === (type === 'pause' ? 'hidden' : 'visible')) {
33
- fire();
19
+ function reconcile() {
20
+ const next = !nativePaused && pageActive && documentTarget?.visibilityState !== 'hidden';
21
+ if (next === active) {
22
+ return;
23
+ }
24
+ active = next;
25
+ for (const callback of [...(active ? resumes : pauses)]) {
26
+ notify(callback);
34
27
  }
28
+ }
29
+ const nativePause = () => {
30
+ nativePaused = true;
31
+ reconcile();
32
+ };
33
+ const nativeResume = () => {
34
+ nativePaused = false;
35
+ reconcile();
35
36
  };
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);
37
+ const pageHide = () => {
38
+ pageActive = false;
39
+ reconcile();
44
40
  };
41
+ const pageShow = () => {
42
+ pageActive = true;
43
+ reconcile();
44
+ };
45
+ function attach() {
46
+ if (attached) {
47
+ return;
48
+ }
49
+ attached = true;
50
+ target.addEventListener?.(aitLifecyclePauseEvent, nativePause);
51
+ target.addEventListener?.(aitLifecycleResumeEvent, nativeResume);
52
+ target.addEventListener?.('pagehide', pageHide);
53
+ target.addEventListener?.('pageshow', pageShow);
54
+ documentTarget?.addEventListener('visibilitychange', reconcile);
55
+ }
56
+ function detach() {
57
+ if (!attached) {
58
+ return;
59
+ }
60
+ attached = false;
61
+ target.removeEventListener?.(aitLifecyclePauseEvent, nativePause);
62
+ target.removeEventListener?.(aitLifecycleResumeEvent, nativeResume);
63
+ target.removeEventListener?.('pagehide', pageHide);
64
+ target.removeEventListener?.('pageshow', pageShow);
65
+ documentTarget?.removeEventListener('visibilitychange', reconcile);
66
+ }
67
+ function subscribe(callbacks, callback) {
68
+ callbacks.add(callback);
69
+ attach();
70
+ if (callbacks === pauses && !active) {
71
+ notify(callback);
72
+ }
73
+ return () => {
74
+ callbacks.delete(callback);
75
+ if (pauses.size + resumes.size === 0) {
76
+ detach();
77
+ }
78
+ };
79
+ }
80
+ return {
81
+ onPause: (callback) => subscribe(pauses, callback),
82
+ onResume: (callback) => subscribe(resumes, callback),
83
+ async dispose() {
84
+ pauses.clear();
85
+ resumes.clear();
86
+ detach();
87
+ },
88
+ };
89
+ }
90
+ export function dispatchAitLifecycleEvent(type) {
91
+ globalThis.dispatchEvent?.(new Event(type === 'pause' ? aitLifecyclePauseEvent : aitLifecycleResumeEvent));
45
92
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mpgd/adapter-ait",
3
- "version": "0.12.7",
3
+ "version": "0.13.0",
4
4
  "description": "Apps in Toss WebView platform adapter for mpgd games.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -59,8 +59,8 @@
59
59
  "dist"
60
60
  ],
61
61
  "dependencies": {
62
- "@mpgd/bridge": "0.10.0",
63
- "@mpgd/platform": "0.14.0"
62
+ "@mpgd/platform": "0.15.0",
63
+ "@mpgd/bridge": "0.11.0"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@apps-in-toss/web-framework": ">=3.0.0 <4"
@@ -71,10 +71,12 @@
71
71
  }
72
72
  },
73
73
  "devDependencies": {
74
+ "@types/node": "^24.0.0",
74
75
  "@apps-in-toss/web-framework": "3.5.0",
75
76
  "ttsc": "0.30.4",
76
77
  "typescript": "7.0.2",
77
- "vitest": "^4.0.0"
78
+ "vitest": "^4.0.0",
79
+ "@mpgd/game-runtime": "0.3.0"
78
80
  },
79
81
  "main": "./dist/index.js",
80
82
  "types": "./dist/index.d.ts",