@mpgd/game-services 0.30.5 → 0.31.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/README.md +96 -0
- package/dist/client.d.ts +16 -2
- package/dist/client.js +28 -3
- package/dist/commerce-reconciler.d.ts +53 -3
- package/dist/commerce-reconciler.js +240 -14
- package/dist/contract.d.ts +6 -6
- package/dist/monetization-recovery.js +1 -1
- package/dist/orpc-error-compat.d.ts +42 -0
- package/dist/orpc-error-compat.js +102 -0
- package/dist/request-headers.d.ts +22 -0
- package/dist/request-headers.js +34 -1
- package/dist/runtime.d.ts +70 -2
- package/dist/runtime.js +340 -10
- package/dist/server.d.ts +15 -6
- package/dist/server.js +26 -3
- package/package.json +9 -9
|
@@ -54,7 +54,9 @@ function isRetryableBeforeLaunch(record) {
|
|
|
54
54
|
* finish allow-list of its own (the removed v1 driver paired transactions in
|
|
55
55
|
* `finishGrantedTransaction`), so this is the finish gate for every `client`
|
|
56
56
|
* and `host` owner: the ledger entry id comes from the journal revision the
|
|
57
|
-
* backend grant wrote.
|
|
57
|
+
* backend grant wrote. The one other finish call is a `finished` consumable a
|
|
58
|
+
* `client` store reports again, finished with the same journaled ledger entry
|
|
59
|
+
* (see `finishReportedAgain` in `createCommerceReconciler`).
|
|
58
60
|
*/
|
|
59
61
|
export function assertCommerceFinishAllowed(record) {
|
|
60
62
|
if (record.state !== 'granted' && record.state !== 'finishing') {
|
|
@@ -258,6 +260,30 @@ export function createCommerceReconciler(input) {
|
|
|
258
260
|
const trigger = () => {
|
|
259
261
|
triggerPending = true;
|
|
260
262
|
};
|
|
263
|
+
/** The session check's refusal, if any; a check that throws refuses too. */
|
|
264
|
+
const sessionRefusal = () => {
|
|
265
|
+
if (input.sessionCheck === undefined) {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
return input.sessionCheck();
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return new CommerceError('ACCOUNT_BINDING_REQUIRED', {
|
|
273
|
+
retryable: true,
|
|
274
|
+
message: `The session check failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
/** True (and reported) when the reconciler must not act for its player now. */
|
|
279
|
+
const sessionBlocked = (idempotencyKey) => {
|
|
280
|
+
const refusal = sessionRefusal();
|
|
281
|
+
if (refusal === undefined) {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
fail('session', refusal, idempotencyKey);
|
|
285
|
+
return true;
|
|
286
|
+
};
|
|
261
287
|
const ownsRecord = (record) => record.playerId === playerId
|
|
262
288
|
&& record.target === target
|
|
263
289
|
&& record.deploymentTarget === deploymentTarget
|
|
@@ -551,6 +577,11 @@ export function createCommerceReconciler(input) {
|
|
|
551
577
|
if (!capabilitiesNow.lifecycle.availablePurchases) {
|
|
552
578
|
return true;
|
|
553
579
|
}
|
|
580
|
+
if (sessionBlocked()) {
|
|
581
|
+
// Another player's store account may be current: nothing is read into
|
|
582
|
+
// this player's journal, and the next permitted pass reads again.
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
554
585
|
const read = async (withRestore) => {
|
|
555
586
|
const available = await provider.getAvailablePurchases({
|
|
556
587
|
includePending: capabilitiesNow.lifecycle.pending,
|
|
@@ -609,6 +640,83 @@ export function createCommerceReconciler(input) {
|
|
|
609
640
|
...(record.origin === 'discovered' ? { intent: 'restore' } : {}),
|
|
610
641
|
};
|
|
611
642
|
};
|
|
643
|
+
/** Whether a store purchase is bound to another account than this player's (`accountBinding`). */
|
|
644
|
+
const accountOf = async (purchase) => {
|
|
645
|
+
const bound = purchase.accountBinding;
|
|
646
|
+
if (input.accountBinding === undefined || bound?.value === undefined) {
|
|
647
|
+
return 'own';
|
|
648
|
+
}
|
|
649
|
+
let own;
|
|
650
|
+
try {
|
|
651
|
+
own = await input.accountBinding();
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
own = undefined;
|
|
655
|
+
}
|
|
656
|
+
if (own?.value === undefined) {
|
|
657
|
+
// This player's binding cannot be read now: the purchase cannot be shown to be theirs yet.
|
|
658
|
+
return 'unknown';
|
|
659
|
+
}
|
|
660
|
+
if (own.kind !== bound.kind) {
|
|
661
|
+
return 'own';
|
|
662
|
+
}
|
|
663
|
+
const normalize = (value) => bound.kind === 'app-account-token' ? value.toLowerCase() : value;
|
|
664
|
+
return normalize(own.value) === normalize(bound.value) ? 'own' : 'another';
|
|
665
|
+
};
|
|
666
|
+
/** Transactions `finishReportedAgain` already finished again this session. */
|
|
667
|
+
const finishedAgain = new Set();
|
|
668
|
+
/**
|
|
669
|
+
* A client-finish store reports a consumable only while it is unfinished,
|
|
670
|
+
* so one reported again after its record finished was never finished in
|
|
671
|
+
* the store: its finish was reported done without a native call (a lost
|
|
672
|
+
* acknowledgement the store's list could not tell apart from an
|
|
673
|
+
* unverifiable entry or a list read too early), or the store re-delivered
|
|
674
|
+
* it. It is finished again with the ledger entry the grant wrote, once per
|
|
675
|
+
* session (a call that failed in transport is tried again on the next
|
|
676
|
+
* report); it is never verified or granted again (the ledger replays a key
|
|
677
|
+
* and refuses the same evidence under another one). Durables, which a
|
|
678
|
+
* store keeps listing after their finish, and other finish owners are left
|
|
679
|
+
* alone. It needs the finished record: past the journal's retention the
|
|
680
|
+
* transaction takes the derived-key path, where the reference backend
|
|
681
|
+
* answers a `client`-finish consumable with this player's recorded grant
|
|
682
|
+
* (never a second one), and the record finishes it like any grant.
|
|
683
|
+
*/
|
|
684
|
+
const finishReportedAgain = async (record, kind) => {
|
|
685
|
+
const ledgerEntryId = record.ledgerEntryId;
|
|
686
|
+
const transactionId = record.purchase?.transactionId;
|
|
687
|
+
if ((record.productKind ?? kind) !== 'consumable'
|
|
688
|
+
|| capabilities?.lifecycle.finish !== 'client'
|
|
689
|
+
|| provider.finishTransaction === undefined
|
|
690
|
+
|| ledgerEntryId === undefined
|
|
691
|
+
|| ledgerEntryId.trim() === ''
|
|
692
|
+
|| transactionId === undefined
|
|
693
|
+
|| transactionId.trim() === ''
|
|
694
|
+
|| finishedAgain.has(transactionId)) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
698
|
+
// The session changed while the report was matched: the next report asks again.
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
finishedAgain.add(transactionId);
|
|
702
|
+
try {
|
|
703
|
+
const { finished } = await provider.finishTransaction({
|
|
704
|
+
transactionId,
|
|
705
|
+
ledgerEntryId,
|
|
706
|
+
isConsumable: true,
|
|
707
|
+
});
|
|
708
|
+
if (!finished) {
|
|
709
|
+
// The store refused it (for StoreKit: revoked or another token); the
|
|
710
|
+
// next session asks again. The record stays finished.
|
|
711
|
+
fail('finish', new Error(`The store did not finish ${transactionId} it reported again.`), record.idempotencyKey);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
catch (error) {
|
|
715
|
+
// Transport-class failure: the next report of the transaction tries again.
|
|
716
|
+
finishedAgain.delete(transactionId);
|
|
717
|
+
fail('finish', error, record.idempotencyKey);
|
|
718
|
+
}
|
|
719
|
+
};
|
|
612
720
|
// Reconciler step 2: match a reported purchase to a journal record.
|
|
613
721
|
const ingestPurchase = async (purchase, restoring) => {
|
|
614
722
|
if (purchase.store !== storeId) {
|
|
@@ -638,6 +746,22 @@ export function createCommerceReconciler(input) {
|
|
|
638
746
|
record = remember(stored);
|
|
639
747
|
}
|
|
640
748
|
}
|
|
749
|
+
const account = record !== undefined || (restoring && product.kind === 'durable')
|
|
750
|
+
? 'own'
|
|
751
|
+
: await accountOf(purchase);
|
|
752
|
+
if (account === 'unknown') {
|
|
753
|
+
// Not decided yet: the report waits for the next pass, which asks again.
|
|
754
|
+
defer({ type: 'purchaseUpdated', purchase });
|
|
755
|
+
fail('match', new Error(`Purchase ${purchase.transactionId} waits: this player's store account binding could not be read.`));
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
if (account === 'another') {
|
|
759
|
+
// Another player's purchase (Play lists unacknowledged purchases of
|
|
760
|
+
// every app account on the device): neither attached to this player's
|
|
761
|
+
// open attempt nor journaled under a derived key.
|
|
762
|
+
fail('match', new Error(`Purchase ${purchase.transactionId} is bound to another store account; it is left for that player.`));
|
|
763
|
+
return;
|
|
764
|
+
}
|
|
641
765
|
if (record === undefined) {
|
|
642
766
|
const open = openRecordsFor(product.logicalId);
|
|
643
767
|
if (open.length > 1) {
|
|
@@ -697,6 +821,10 @@ export function createCommerceReconciler(input) {
|
|
|
697
821
|
return;
|
|
698
822
|
}
|
|
699
823
|
}
|
|
824
|
+
if (record.state === 'finished' && purchase.state === 'purchased') {
|
|
825
|
+
await finishReportedAgain(record, product.kind);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
700
828
|
if (purchase.state === 'pending' && record.state === 'launched') {
|
|
701
829
|
await write(record, 'pending', { purchase });
|
|
702
830
|
return;
|
|
@@ -761,6 +889,10 @@ export function createCommerceReconciler(input) {
|
|
|
761
889
|
}
|
|
762
890
|
try {
|
|
763
891
|
for (;;) {
|
|
892
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
893
|
+
// The record stays as journaled for this player's next permitted pass.
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
764
896
|
switch (record.state) {
|
|
765
897
|
case 'requested':
|
|
766
898
|
if (isRetryableBeforeLaunch(record)) {
|
|
@@ -881,12 +1013,25 @@ export function createCommerceReconciler(input) {
|
|
|
881
1013
|
retryLater(record.idempotencyKey);
|
|
882
1014
|
return;
|
|
883
1015
|
}
|
|
1016
|
+
if (sessionBlocked(record.idempotencyKey)) {
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
884
1019
|
let finished = false;
|
|
885
1020
|
try {
|
|
886
1021
|
finished = (await provider.finishTransaction({
|
|
887
1022
|
transactionId: finish.transactionId,
|
|
888
1023
|
ledgerEntryId: finish.ledgerEntryId,
|
|
889
1024
|
isConsumable: kind === 'consumable',
|
|
1025
|
+
// A record already `finishing` when this drive began was asked
|
|
1026
|
+
// to finish before (an earlier attempt or session): a `client`
|
|
1027
|
+
// store may read a consumable gone from its unfinished list as
|
|
1028
|
+
// that finish's lost acknowledgement. A first finish never is,
|
|
1029
|
+
// and a provider that does not declare the field never gets it.
|
|
1030
|
+
...(owner === 'client'
|
|
1031
|
+
&& initial.state === 'finishing'
|
|
1032
|
+
&& provider.acceptsFinishRetry === true
|
|
1033
|
+
? { retry: true }
|
|
1034
|
+
: {}),
|
|
890
1035
|
})).finished;
|
|
891
1036
|
}
|
|
892
1037
|
catch (error) {
|
|
@@ -961,6 +1106,10 @@ export function createCommerceReconciler(input) {
|
|
|
961
1106
|
};
|
|
962
1107
|
const eventPass = { explicit: false, refreshCapabilities: false };
|
|
963
1108
|
const pass = async (options) => {
|
|
1109
|
+
if (sessionBlocked()) {
|
|
1110
|
+
// Queued events wait for a pass the session permits.
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
964
1113
|
let capabilitiesNow = await readCapabilities(options.refreshCapabilities);
|
|
965
1114
|
if (options.refreshCapabilities) {
|
|
966
1115
|
await readServiceAlignment();
|
|
@@ -978,10 +1127,14 @@ export function createCommerceReconciler(input) {
|
|
|
978
1127
|
// call to journal the store answer: matched then, it is never journaled
|
|
979
1128
|
// twice, and a cancel event ends the attempt the call launched.
|
|
980
1129
|
const busy = inFlightProducts();
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1130
|
+
const queued = [...deferred.splice(0, deferred.length), ...inbox.splice(0, inbox.length)];
|
|
1131
|
+
for (const [index, event] of queued.entries()) {
|
|
1132
|
+
if (sessionBlocked()) {
|
|
1133
|
+
// A purchase reported now may be another player's: it is not
|
|
1134
|
+
// journaled under this one; the rest wait, in order.
|
|
1135
|
+
inbox.unshift(...queued.slice(index));
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
985
1138
|
try {
|
|
986
1139
|
if (event.type === 'purchaseUpdated') {
|
|
987
1140
|
const product = busy.size === 0 ? undefined : await resolveLogicalId(event.purchase);
|
|
@@ -1004,6 +1157,9 @@ export function createCommerceReconciler(input) {
|
|
|
1004
1157
|
}
|
|
1005
1158
|
}
|
|
1006
1159
|
for (const listed of await listRecoverable()) {
|
|
1160
|
+
if (sessionBlocked()) {
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1007
1163
|
const record = records.get(listed.idempotencyKey) ?? listed;
|
|
1008
1164
|
if (options.restoreTrigger !== true && waitsForRestoreTrigger(record)) {
|
|
1009
1165
|
// At the holder limit only the player's Restore Purchases asks again;
|
|
@@ -1012,6 +1168,9 @@ export function createCommerceReconciler(input) {
|
|
|
1012
1168
|
}
|
|
1013
1169
|
await drive(record, capabilitiesNow, options.explicit);
|
|
1014
1170
|
}
|
|
1171
|
+
if (sessionBlocked()) {
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1015
1174
|
await revokeReversed();
|
|
1016
1175
|
};
|
|
1017
1176
|
const runUntilIdle = async () => {
|
|
@@ -1158,6 +1317,12 @@ export function createCommerceReconciler(input) {
|
|
|
1158
1317
|
})),
|
|
1159
1318
|
};
|
|
1160
1319
|
}
|
|
1320
|
+
const lateRefusal = sessionRefusal();
|
|
1321
|
+
if (lateRefusal !== undefined) {
|
|
1322
|
+
// The session changed while the purchase was prepared: nothing is journaled.
|
|
1323
|
+
fail('session', lateRefusal, request.idempotencyKey);
|
|
1324
|
+
return { kind: 'answered', result: failedOutcome(lateRefusal) };
|
|
1325
|
+
}
|
|
1161
1326
|
const { created, record } = existing === undefined ? await reserve({
|
|
1162
1327
|
idempotencyKey: request.idempotencyKey,
|
|
1163
1328
|
store: storeId,
|
|
@@ -1263,6 +1428,12 @@ export function createCommerceReconciler(input) {
|
|
|
1263
1428
|
// Only the call that reserved the record owns its in-flight entry: a
|
|
1264
1429
|
// same-key replay must not release it while the store sheet is open.
|
|
1265
1430
|
let owned = false;
|
|
1431
|
+
const refusal = sessionRefusal();
|
|
1432
|
+
if (refusal !== undefined) {
|
|
1433
|
+
// Nothing is journaled and no store UI opens; the key stays usable.
|
|
1434
|
+
fail('session', refusal, request.idempotencyKey);
|
|
1435
|
+
return failedOutcome(refusal);
|
|
1436
|
+
}
|
|
1266
1437
|
try {
|
|
1267
1438
|
const prepared = await withLock(() => prepareRequest(request));
|
|
1268
1439
|
if (prepared.kind === 'answered') {
|
|
@@ -1281,14 +1452,39 @@ export function createCommerceReconciler(input) {
|
|
|
1281
1452
|
notStarted = event.state === 'not-started';
|
|
1282
1453
|
}
|
|
1283
1454
|
});
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
finally {
|
|
1455
|
+
// Checked again with nothing awaited before the store call: the
|
|
1456
|
+
// provider binds the purchase to the account current now, which must
|
|
1457
|
+
// still be the player this attempt is journaled under.
|
|
1458
|
+
const lateRefusal = sessionRefusal();
|
|
1459
|
+
if (lateRefusal !== undefined) {
|
|
1460
|
+
fail('session', lateRefusal, request.idempotencyKey);
|
|
1291
1461
|
unsubscribePresentation?.();
|
|
1462
|
+
// The store never opened: the reserved attempt stays retryable.
|
|
1463
|
+
answer = failedOutcome(lateRefusal);
|
|
1464
|
+
notStarted = true;
|
|
1465
|
+
}
|
|
1466
|
+
else {
|
|
1467
|
+
try {
|
|
1468
|
+
answer = await provider.requestPurchase(request);
|
|
1469
|
+
}
|
|
1470
|
+
catch (error) {
|
|
1471
|
+
answer = failedOutcome(error);
|
|
1472
|
+
}
|
|
1473
|
+
finally {
|
|
1474
|
+
unsubscribePresentation?.();
|
|
1475
|
+
}
|
|
1476
|
+
// The provider refuses a missing or foreign account binding before
|
|
1477
|
+
// its store UI opens. Refused because the session changed meanwhile,
|
|
1478
|
+
// the attempt stays retryable instead of ending.
|
|
1479
|
+
const bindingRefusal = answer.outcome === 'failed'
|
|
1480
|
+
&& answer.error.code === 'ACCOUNT_BINDING_REQUIRED'
|
|
1481
|
+
? sessionRefusal()
|
|
1482
|
+
: undefined;
|
|
1483
|
+
if (bindingRefusal !== undefined) {
|
|
1484
|
+
fail('session', bindingRefusal, request.idempotencyKey);
|
|
1485
|
+
answer = failedOutcome(bindingRefusal);
|
|
1486
|
+
notStarted = true;
|
|
1487
|
+
}
|
|
1292
1488
|
}
|
|
1293
1489
|
result = await withLock(() => recordAnswer(request, prepared.record, prepared.product, answer, notStarted));
|
|
1294
1490
|
}
|
|
@@ -1330,13 +1526,27 @@ export function createCommerceReconciler(input) {
|
|
|
1330
1526
|
if (started) {
|
|
1331
1527
|
return;
|
|
1332
1528
|
}
|
|
1529
|
+
if (input.closed?.() === true) {
|
|
1530
|
+
// Closed for good: nothing subscribes, and nothing awaits before this
|
|
1531
|
+
// check, so a start under way already set `starting` for `stop()`.
|
|
1532
|
+
return;
|
|
1533
|
+
}
|
|
1333
1534
|
if (starting !== undefined) {
|
|
1334
1535
|
return starting;
|
|
1335
1536
|
}
|
|
1336
1537
|
starting = (async () => {
|
|
1337
1538
|
try {
|
|
1338
|
-
await withLock(async () => {
|
|
1539
|
+
const subscribed = await withLock(async () => {
|
|
1339
1540
|
await loadRecords();
|
|
1541
|
+
// The owner closed while this start waited for the lock or the
|
|
1542
|
+
// journal (its `stop()` may have stopped waiting already): leave
|
|
1543
|
+
// as a refused start does, with nothing subscribed or started and
|
|
1544
|
+
// no error. The records read stay cached, as after a `reconcile()`
|
|
1545
|
+
// before any start. Nothing awaits between this check and the
|
|
1546
|
+
// subscription.
|
|
1547
|
+
if (input.closed?.() === true) {
|
|
1548
|
+
return false;
|
|
1549
|
+
}
|
|
1340
1550
|
// Subscribe first, then read: an event delivered before the listener is found either way.
|
|
1341
1551
|
unsubscribe = provider.subscribe(listener);
|
|
1342
1552
|
started = true;
|
|
@@ -1347,8 +1557,11 @@ export function createCommerceReconciler(input) {
|
|
|
1347
1557
|
initialScanPending = capabilitiesNow === undefined
|
|
1348
1558
|
|| (capabilitiesNow.lifecycle.events && !(await readAvailable(capabilitiesNow)));
|
|
1349
1559
|
await pass({ explicit: true, refreshCapabilities: false });
|
|
1560
|
+
return true;
|
|
1350
1561
|
});
|
|
1351
|
-
|
|
1562
|
+
if (subscribed) {
|
|
1563
|
+
await runUntilIdle();
|
|
1564
|
+
}
|
|
1352
1565
|
}
|
|
1353
1566
|
catch (error) {
|
|
1354
1567
|
// A failed start is retryable: nothing stays subscribed or started.
|
|
@@ -1410,6 +1623,13 @@ export function createCommerceReconciler(input) {
|
|
|
1410
1623
|
},
|
|
1411
1624
|
runUntilIdle,
|
|
1412
1625
|
async restorePurchases() {
|
|
1626
|
+
const refusal = sessionRefusal();
|
|
1627
|
+
if (refusal !== undefined) {
|
|
1628
|
+
// A store restore (StoreKit's sync, an account sign-in) would run for
|
|
1629
|
+
// the account current now, not this reconciler's player.
|
|
1630
|
+
fail('session', refusal);
|
|
1631
|
+
throw refusal;
|
|
1632
|
+
}
|
|
1413
1633
|
if (provider.restorePurchases !== undefined) {
|
|
1414
1634
|
try {
|
|
1415
1635
|
await provider.restorePurchases();
|
|
@@ -1442,6 +1662,12 @@ export function createCommerceReconciler(input) {
|
|
|
1442
1662
|
});
|
|
1443
1663
|
});
|
|
1444
1664
|
await runUntilIdle();
|
|
1665
|
+
const lateRefusal = sessionRefusal();
|
|
1666
|
+
if (lateRefusal !== undefined) {
|
|
1667
|
+
// The session changed during the restore: the passes stopped early,
|
|
1668
|
+
// so it did not finish for this player.
|
|
1669
|
+
throw lateRefusal;
|
|
1670
|
+
}
|
|
1445
1671
|
},
|
|
1446
1672
|
requestPurchase,
|
|
1447
1673
|
async fetchProducts(request = {}) {
|
package/dist/contract.d.ts
CHANGED
|
@@ -7,17 +7,17 @@ export interface GameServicesHealthResponse {
|
|
|
7
7
|
readonly version: string;
|
|
8
8
|
}
|
|
9
9
|
export declare const gameServicesContract: {
|
|
10
|
-
health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>,
|
|
10
|
+
health: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<GameServicesHealthResponse, GameServicesHealthResponse>, Record<never, never>>;
|
|
11
11
|
commerce: {
|
|
12
|
-
verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>,
|
|
13
|
-
getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>,
|
|
14
|
-
getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>,
|
|
12
|
+
verifyPurchase: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<VerifyPurchaseRequest, VerifyPurchaseRequest>, import("@orpc/contract").Schema<VerifyPurchaseResponse, VerifyPurchaseResponse>, Record<never, never>>;
|
|
13
|
+
getPurchaseLedgerStatus: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<PurchaseLedgerStatusRequest, PurchaseLedgerStatusRequest>, import("@orpc/contract").Schema<PurchaseLedgerStatusResponse, PurchaseLedgerStatusResponse>, Record<never, never>>;
|
|
14
|
+
getCapabilities: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<void, void>, import("@orpc/contract").Schema<CommerceServiceCapabilities, CommerceServiceCapabilities>, Record<never, never>>;
|
|
15
15
|
};
|
|
16
16
|
ads: {
|
|
17
|
-
claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>,
|
|
17
|
+
claimReward: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<ClaimAdRewardRequest, ClaimAdRewardRequest>, import("@orpc/contract").Schema<ClaimAdRewardResponse, ClaimAdRewardResponse>, Record<never, never>>;
|
|
18
18
|
};
|
|
19
19
|
leaderboard: {
|
|
20
|
-
recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>,
|
|
20
|
+
recordScore: import("@orpc/contract").ProcedureContract<import("@orpc/contract").Schema<RecordLeaderboardScoreRequest, RecordLeaderboardScoreRequest>, import("@orpc/contract").Schema<RecordLeaderboardScoreResponse, RecordLeaderboardScoreResponse>, Record<never, never>>;
|
|
21
21
|
};
|
|
22
22
|
};
|
|
23
23
|
export type GameServicesContract = typeof gameServicesContract;
|
|
@@ -29,7 +29,7 @@ export function createRecoverableMonetizationClient(input) {
|
|
|
29
29
|
const commerceReconciler = commerceClient.commerceReconciler;
|
|
30
30
|
// The per-operation rewarded-ad clients see a gateway without the commerce
|
|
31
31
|
// provider, so they never build a second reconciler over the journal.
|
|
32
|
-
const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, ...legacyClientInput } = input;
|
|
32
|
+
const { commerceReconciler: _sharedReconciler, operationStore: _journal, onCommerceEvent: _onCommerceEvent, commerceProducts: _commerceProducts, commerceStaleLaunchedAfterMs: _commerceStaleLaunchedAfterMs, ...legacyClientInput } = input;
|
|
33
33
|
const { commerce: _commerce, ...legacyGateway } = input.gateway;
|
|
34
34
|
async function serializeOperation(key, task) {
|
|
35
35
|
const previous = inFlight.get(key) ?? Promise.resolve();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
|
|
3
|
+
*
|
|
4
|
+
* oRPC 2.0.0-beta.14 writes RPC error bodies as
|
|
5
|
+
* `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
|
|
6
|
+
* without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
|
|
7
|
+
* omits `inferable` and the client rejects a body that has it. Either way a client on
|
|
8
|
+
* the other version reports a malformed response instead of the error code, so a game
|
|
9
|
+
* built against one kit release and a Worker built against another would lose
|
|
10
|
+
* `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
|
|
11
|
+
* detection) whenever they are deployed separately.
|
|
12
|
+
*
|
|
13
|
+
* The game-services oRPC transport therefore bridges both directions:
|
|
14
|
+
* - the server adds `inferable: false` to error bodies, so older clients still decode them;
|
|
15
|
+
* - the client removes a boolean `inferable` before oRPC validates, so it still decodes
|
|
16
|
+
* older servers' errors. Nothing else in the body changes, so a body that is malformed
|
|
17
|
+
* for any other reason is still rejected by oRPC.
|
|
18
|
+
*
|
|
19
|
+
* Success responses are passed through untouched in both directions.
|
|
20
|
+
*
|
|
21
|
+
* Remove this module when the oldest supported game-services client and every supported
|
|
22
|
+
* Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
|
|
23
|
+
* no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Server side: adds `inferable: false` to an oRPC error body that lacks it.
|
|
27
|
+
* Responses that are not oRPC JSON error bodies are returned unchanged.
|
|
28
|
+
*/
|
|
29
|
+
export declare function addLegacyOrpcErrorFlag(response: Response): Promise<Response>;
|
|
30
|
+
/**
|
|
31
|
+
* Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
|
|
32
|
+
* client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
|
|
33
|
+
* validates the rest of the body as usual.
|
|
34
|
+
*/
|
|
35
|
+
export declare function stripLegacyOrpcErrorFlag(response: Response): Promise<Response>;
|
|
36
|
+
/**
|
|
37
|
+
* Wraps an oRPC link `fetch` so every response passes through
|
|
38
|
+
* {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
|
|
39
|
+
* game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
|
|
40
|
+
* oRPC passes (client options, procedure path) are forwarded unchanged.
|
|
41
|
+
*/
|
|
42
|
+
export declare function withLegacyOrpcErrorCompat<Rest extends unknown[]>(fetcher: (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>): (url: string, init: RequestInit, ...rest: Rest) => Promise<Response>;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oRPC error-body compatibility across the 2.0.0-beta.34 `inferable` removal.
|
|
3
|
+
*
|
|
4
|
+
* oRPC 2.0.0-beta.14 writes RPC error bodies as
|
|
5
|
+
* `{ json: { defined, inferable, code, message, data? } }` and its client rejects a body
|
|
6
|
+
* without a boolean `inferable`. Since 2.0.0-beta.34 (middleapi/orpc#1987) the server
|
|
7
|
+
* omits `inferable` and the client rejects a body that has it. Either way a client on
|
|
8
|
+
* the other version reports a malformed response instead of the error code, so a game
|
|
9
|
+
* built against one kit release and a Worker built against another would lose
|
|
10
|
+
* `BAD_REQUEST` and `NOT_FOUND` (and with it the commerce reconciler's "v1 backend"
|
|
11
|
+
* detection) whenever they are deployed separately.
|
|
12
|
+
*
|
|
13
|
+
* The game-services oRPC transport therefore bridges both directions:
|
|
14
|
+
* - the server adds `inferable: false` to error bodies, so older clients still decode them;
|
|
15
|
+
* - the client removes a boolean `inferable` before oRPC validates, so it still decodes
|
|
16
|
+
* older servers' errors. Nothing else in the body changes, so a body that is malformed
|
|
17
|
+
* for any other reason is still rejected by oRPC.
|
|
18
|
+
*
|
|
19
|
+
* Success responses are passed through untouched in both directions.
|
|
20
|
+
*
|
|
21
|
+
* Remove this module when the oldest supported game-services client and every supported
|
|
22
|
+
* Worker come from `@mpgd/game-services` 0.31.0 or later (oRPC 2.0.0-beta.46), that is, once
|
|
23
|
+
* no client or Worker from 0.30.5 or older (oRPC 2.0.0-beta.14) is supported.
|
|
24
|
+
*/
|
|
25
|
+
const legacyErrorFlag = 'inferable';
|
|
26
|
+
/**
|
|
27
|
+
* Server side: adds `inferable: false` to an oRPC error body that lacks it.
|
|
28
|
+
* Responses that are not oRPC JSON error bodies are returned unchanged.
|
|
29
|
+
*/
|
|
30
|
+
export async function addLegacyOrpcErrorFlag(response) {
|
|
31
|
+
if (!isJsonErrorResponse(response)) {
|
|
32
|
+
return response;
|
|
33
|
+
}
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
const envelope = parseErrorEnvelope(text);
|
|
36
|
+
if (envelope === undefined || Object.hasOwn(envelope.json, legacyErrorFlag)) {
|
|
37
|
+
return rebuild(response, text);
|
|
38
|
+
}
|
|
39
|
+
return rebuild(response, JSON.stringify({ ...envelope.root, json: { ...envelope.json, [legacyErrorFlag]: false } }));
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Client side: removes a boolean `inferable` from an oRPC error body so a current oRPC
|
|
43
|
+
* client accepts errors from a pre-beta.34 server. Only that key is removed; oRPC still
|
|
44
|
+
* validates the rest of the body as usual.
|
|
45
|
+
*/
|
|
46
|
+
export async function stripLegacyOrpcErrorFlag(response) {
|
|
47
|
+
if (!isJsonErrorResponse(response)) {
|
|
48
|
+
return response;
|
|
49
|
+
}
|
|
50
|
+
const text = await response.text();
|
|
51
|
+
const envelope = parseErrorEnvelope(text);
|
|
52
|
+
if (envelope === undefined || typeof envelope.json[legacyErrorFlag] !== 'boolean') {
|
|
53
|
+
return rebuild(response, text);
|
|
54
|
+
}
|
|
55
|
+
const { [legacyErrorFlag]: _legacy, ...json } = envelope.json;
|
|
56
|
+
return rebuild(response, JSON.stringify({ ...envelope.root, json }));
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Wraps an oRPC link `fetch` so every response passes through
|
|
60
|
+
* {@link stripLegacyOrpcErrorFlag}. Any oRPC 2.0.0-beta.34+ client that talks to a
|
|
61
|
+
* game-services Worker must use it while the Worker writes the legacy flag. Extra arguments
|
|
62
|
+
* oRPC passes (client options, procedure path) are forwarded unchanged.
|
|
63
|
+
*/
|
|
64
|
+
export function withLegacyOrpcErrorCompat(fetcher) {
|
|
65
|
+
return async (url, init, ...rest) => stripLegacyOrpcErrorFlag(await fetcher(url, init, ...rest));
|
|
66
|
+
}
|
|
67
|
+
function isJsonErrorResponse(response) {
|
|
68
|
+
return (response.status >= 400
|
|
69
|
+
&& response.body !== null
|
|
70
|
+
&& (response.headers.get('content-type') ?? '').toLowerCase().startsWith('application/json'));
|
|
71
|
+
}
|
|
72
|
+
function parseErrorEnvelope(text) {
|
|
73
|
+
let root;
|
|
74
|
+
try {
|
|
75
|
+
root = JSON.parse(text);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
if (!isPlainObject(root) || !isPlainObject(root.json)) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
return { root, json: root.json };
|
|
84
|
+
}
|
|
85
|
+
function isPlainObject(value) {
|
|
86
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const prototype = Object.getPrototypeOf(value);
|
|
90
|
+
return prototype === Object.prototype || prototype === null;
|
|
91
|
+
}
|
|
92
|
+
function rebuild(response, body) {
|
|
93
|
+
const headers = new Headers(response.headers);
|
|
94
|
+
// The body is re-encoded as plain text: any transfer encoding or length no longer applies.
|
|
95
|
+
headers.delete('content-encoding');
|
|
96
|
+
headers.delete('content-length');
|
|
97
|
+
return new Response(body, {
|
|
98
|
+
status: response.status,
|
|
99
|
+
statusText: response.statusText,
|
|
100
|
+
headers,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
@@ -4,6 +4,28 @@
|
|
|
4
4
|
* client and the notifications client so a game gives both one resolver.
|
|
5
5
|
*/
|
|
6
6
|
export type GameServicesHeaderResolver = () => Readonly<Record<string, string>> | Promise<Readonly<Record<string, string>>>;
|
|
7
|
+
/** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
|
|
8
|
+
export declare const gameServicesRuntimeDisposedCode = "GAME_SERVICES_RUNTIME_DISPOSED";
|
|
9
|
+
/**
|
|
10
|
+
* A runtime refused an operation because `sessionPlayerId` names another
|
|
11
|
+
* player: it closed itself, as `dispose()` does, and the host creates a
|
|
12
|
+
* runtime for the signed-in player (retryable there).
|
|
13
|
+
*/
|
|
14
|
+
export declare const gameServicesPlayerChangedCode = "GAME_SERVICES_PLAYER_CHANGED";
|
|
15
|
+
/** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
|
|
16
|
+
export declare const gameServicesSessionRequiredCode = "GAME_SERVICES_SESSION_REQUIRED";
|
|
17
|
+
export type GameServicesRuntimeClosedCode = typeof gameServicesRuntimeDisposedCode | typeof gameServicesPlayerChangedCode | typeof gameServicesSessionRequiredCode;
|
|
18
|
+
/**
|
|
19
|
+
* An operation the runtime refused before it reached the backend or the
|
|
20
|
+
* store: it was disposed, its player is no longer the session's, or the
|
|
21
|
+
* session is signed out. Always retryable (with a runtime for the signed-in
|
|
22
|
+
* player); nothing was sent and no record was ended.
|
|
23
|
+
*/
|
|
24
|
+
export declare class GameServicesRuntimeClosedError extends Error {
|
|
25
|
+
readonly code: GameServicesRuntimeClosedCode;
|
|
26
|
+
readonly retryable = true;
|
|
27
|
+
constructor(code: GameServicesRuntimeClosedCode);
|
|
28
|
+
}
|
|
7
29
|
/** Credentials could not be resolved; no backend request was dispatched. */
|
|
8
30
|
export declare class GameServicesHeaderResolutionError extends Error {
|
|
9
31
|
constructor();
|
package/dist/request-headers.js
CHANGED
|
@@ -3,6 +3,35 @@
|
|
|
3
3
|
* for rotating credentials, then per-request headers. Shared by the commerce
|
|
4
4
|
* client and the notifications client so a game gives both one resolver.
|
|
5
5
|
*/
|
|
6
|
+
/** A runtime refused an operation because it was disposed (see `GameServicesRuntime.dispose`). */
|
|
7
|
+
export const gameServicesRuntimeDisposedCode = 'GAME_SERVICES_RUNTIME_DISPOSED';
|
|
8
|
+
/**
|
|
9
|
+
* A runtime refused an operation because `sessionPlayerId` names another
|
|
10
|
+
* player: it closed itself, as `dispose()` does, and the host creates a
|
|
11
|
+
* runtime for the signed-in player (retryable there).
|
|
12
|
+
*/
|
|
13
|
+
export const gameServicesPlayerChangedCode = 'GAME_SERVICES_PLAYER_CHANGED';
|
|
14
|
+
/** A runtime refused an operation because `sessionPlayerId` names no player (signed out); it stays open. */
|
|
15
|
+
export const gameServicesSessionRequiredCode = 'GAME_SERVICES_SESSION_REQUIRED';
|
|
16
|
+
/**
|
|
17
|
+
* An operation the runtime refused before it reached the backend or the
|
|
18
|
+
* store: it was disposed, its player is no longer the session's, or the
|
|
19
|
+
* session is signed out. Always retryable (with a runtime for the signed-in
|
|
20
|
+
* player); nothing was sent and no record was ended.
|
|
21
|
+
*/
|
|
22
|
+
export class GameServicesRuntimeClosedError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
retryable = true;
|
|
25
|
+
constructor(code) {
|
|
26
|
+
super(code === gameServicesPlayerChangedCode
|
|
27
|
+
? 'The session now names another player; this game services runtime is closed.'
|
|
28
|
+
: code === gameServicesSessionRequiredCode
|
|
29
|
+
? 'The session names no player; sign in before using game services.'
|
|
30
|
+
: 'This game services runtime was disposed.');
|
|
31
|
+
this.name = 'GameServicesRuntimeClosedError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
6
35
|
/** Credentials could not be resolved; no backend request was dispatched. */
|
|
7
36
|
export class GameServicesHeaderResolutionError extends Error {
|
|
8
37
|
constructor() {
|
|
@@ -16,7 +45,11 @@ export async function resolveGameServicesRequestHeaders(input) {
|
|
|
16
45
|
try {
|
|
17
46
|
dynamicHeaders = await input.getHeaders?.();
|
|
18
47
|
}
|
|
19
|
-
catch {
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (error instanceof GameServicesRuntimeClosedError) {
|
|
50
|
+
// The runtime's own refusal carries no credentials; its code says why.
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
20
53
|
// Resolver exceptions can contain credentials; expose a stable outcome.
|
|
21
54
|
throw new GameServicesHeaderResolutionError();
|
|
22
55
|
}
|