mppx 0.12.0 → 0.13.1

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.
Files changed (108) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/Method.d.ts +7 -1
  3. package/dist/Method.js +7 -2
  4. package/dist/Receipt.d.ts +2 -0
  5. package/dist/Receipt.js +2 -0
  6. package/dist/cli/cli.d.ts +8 -0
  7. package/dist/cli/cli.js +134 -136
  8. package/dist/cli/plugins/index.js +12 -14
  9. package/dist/cli/plugins/tempo.js +9 -2
  10. package/dist/cli/utils.d.ts +11 -1
  11. package/dist/cli/utils.js +15 -2
  12. package/dist/cli/validate/challenge.d.ts +2 -0
  13. package/dist/cli/validate/challenge.js +19 -4
  14. package/dist/cli/validate/discovery.d.ts +1 -0
  15. package/dist/cli/validate/discovery.js +10 -1
  16. package/dist/cli/validate/payment.js +34 -12
  17. package/dist/client/Methods.d.ts +0 -2
  18. package/dist/client/Methods.js +0 -2
  19. package/dist/client/Mppx.js +1 -0
  20. package/dist/client/index.d.ts +1 -1
  21. package/dist/client/index.js +1 -1
  22. package/dist/client/internal/Fetch.d.ts +2 -0
  23. package/dist/client/internal/Fetch.js +8 -2
  24. package/dist/client/internal/MethodChallenge.d.ts +1 -0
  25. package/dist/evm/Types.js +1 -1
  26. package/dist/evm/server/Charge.js +5 -0
  27. package/dist/integrations/x402/Evm.js +4 -3
  28. package/dist/internal/HeaderCodec.d.ts +2 -2
  29. package/dist/internal/version.d.ts +2 -2
  30. package/dist/internal/version.js +1 -1
  31. package/dist/mcp/client/McpClient.js +4 -7
  32. package/dist/middlewares/elysia.d.ts +11 -8
  33. package/dist/middlewares/elysia.js +67 -14
  34. package/dist/middlewares/express.js +13 -6
  35. package/dist/middlewares/hono.js +4 -4
  36. package/dist/middlewares/nextjs.js +9 -5
  37. package/dist/proxy/internal/Headers.js +65 -0
  38. package/dist/proxy/services/stripe.js +1 -0
  39. package/dist/server/Mppx.d.ts +13 -1
  40. package/dist/server/Mppx.js +85 -44
  41. package/dist/server/NodeListener.js +71 -6
  42. package/dist/server/Request.d.ts +11 -1
  43. package/dist/server/Request.js +132 -13
  44. package/dist/server/Transport.d.ts +14 -1
  45. package/dist/server/Transport.js +1 -0
  46. package/dist/stripe/server/Methods.d.ts +10 -3
  47. package/dist/stripe/server/Methods.js +17 -2
  48. package/dist/stripe/server/internal/html.gen.js +1 -1
  49. package/dist/tempo/Methods.d.ts +1 -0
  50. package/dist/tempo/Methods.js +1 -0
  51. package/dist/tempo/client/Charge.js +17 -13
  52. package/dist/tempo/client/Methods.d.ts +1 -5
  53. package/dist/tempo/client/Methods.js +10 -5
  54. package/dist/tempo/client/ResolveAccount.d.ts +3 -0
  55. package/dist/tempo/client/index.d.ts +0 -3
  56. package/dist/tempo/client/index.js +0 -2
  57. package/dist/tempo/internal/fee-payer.d.ts +6 -3
  58. package/dist/tempo/internal/fee-payer.js +23 -6
  59. package/dist/tempo/internal/machine-token-charge.d.ts +23 -0
  60. package/dist/tempo/internal/machine-token-charge.js +2 -1
  61. package/dist/tempo/legacy/index.d.ts +1 -3
  62. package/dist/tempo/legacy/index.js +1 -3
  63. package/dist/tempo/server/Charge.js +75 -46
  64. package/dist/tempo/server/Methods.d.ts +55 -10
  65. package/dist/tempo/server/Subscription.d.ts +1 -1
  66. package/dist/tempo/server/Subscription.js +31 -4
  67. package/dist/tempo/server/internal/html.gen.js +1 -1
  68. package/dist/tempo/server/internal/transport.js +28 -11
  69. package/dist/tempo/session/client/ChannelOps.js +5 -0
  70. package/dist/tempo/session/client/Session.d.ts +1 -0
  71. package/dist/tempo/session/client/Session.js +7 -4
  72. package/dist/tempo/session/client/SessionManager.js +3 -0
  73. package/dist/tempo/session/client/Transports.d.ts +8 -1
  74. package/dist/tempo/session/client/Transports.js +12 -3
  75. package/dist/tempo/session/precompile/Chain.js +16 -1
  76. package/dist/tempo/session/precompile/Protocol.d.ts +2 -0
  77. package/dist/tempo/session/precompile/Voucher.d.ts +16 -0
  78. package/dist/tempo/session/precompile/Voucher.js +48 -0
  79. package/dist/tempo/session/server/ChannelStore.d.ts +20 -2
  80. package/dist/tempo/session/server/ChannelStore.js +25 -4
  81. package/dist/tempo/session/server/CredentialVerification.d.ts +4 -1
  82. package/dist/tempo/session/server/CredentialVerification.js +34 -6
  83. package/dist/tempo/session/server/MeteredStream.d.ts +3 -2
  84. package/dist/tempo/session/server/MeteredStream.js +85 -29
  85. package/dist/tempo/session/server/Session.d.ts +2 -1
  86. package/dist/tempo/session/server/Session.js +2 -1
  87. package/dist/tempo/session/server/Settlement.d.ts +21 -0
  88. package/dist/tempo/session/server/Settlement.js +92 -17
  89. package/dist/tempo/session/server/Sse.d.ts +3 -1
  90. package/dist/tempo/session/server/Sse.js +105 -32
  91. package/dist/tempo/session/server/Transports.d.ts +24 -19
  92. package/dist/tempo/session/server/Transports.js +169 -58
  93. package/dist/tempo/session/server/Ws.d.ts +5 -1
  94. package/dist/tempo/session/server/Ws.js +75 -20
  95. package/dist/tempo/subscription/Store.d.ts +2 -0
  96. package/dist/tempo/subscription/Store.js +3 -0
  97. package/dist/validation/core.js +2 -1
  98. package/dist/x402/server/EvmCharge.d.ts +3 -1
  99. package/dist/x402/server/EvmCharge.js +14 -1
  100. package/package.json +6 -6
  101. package/dist/tempo/legacy/client/ChannelOps.d.ts +0 -69
  102. package/dist/tempo/legacy/client/ChannelOps.js +0 -168
  103. package/dist/tempo/legacy/client/Session.d.ts +0 -185
  104. package/dist/tempo/legacy/client/Session.js +0 -279
  105. package/dist/tempo/legacy/client/SessionManager.d.ts +0 -100
  106. package/dist/tempo/legacy/client/SessionManager.js +0 -706
  107. package/dist/tempo/legacy/client/index.d.ts +0 -7
  108. package/dist/tempo/legacy/client/index.js +0 -5
@@ -7,7 +7,7 @@ import { createSessionReceipt, uint96, } from '../precompile/Protocol.js';
7
7
  import * as Voucher from '../precompile/Voucher.js';
8
8
  import * as ChannelStore from './ChannelStore.js';
9
9
  import { getChallengePaymentFields } from './RequestState.js';
10
- import { assertSettlementSender, getClientAccount } from './Settlement.js';
10
+ import { assertSettlementSender, getClientAccount, maybeSettleScheduled, } from './Settlement.js';
11
11
  /** Returns the effective voucher signer for a TIP-1034 descriptor. */
12
12
  export function authorizedSigner(descriptor) {
13
13
  return isAddressEqual(descriptor.authorizedSigner, zeroAddress)
@@ -233,6 +233,7 @@ export function requireSessionCredentialPayload(payload) {
233
233
  descriptor: readDescriptor(candidate.descriptor),
234
234
  cumulativeAmount: readRawAmount(candidate.cumulativeAmount, 'cumulativeAmount'),
235
235
  signature: readHex(candidate.signature, 'signature'),
236
+ closeSignature: readHex(candidate.closeSignature, 'closeSignature'),
236
237
  };
237
238
  }
238
239
  }
@@ -375,6 +376,8 @@ async function validateVoucherCredential(parameters, payload) {
375
376
  escrow,
376
377
  lastOnChainVerified,
377
378
  });
379
+ if (parameters.allowPendingClose && channelState.closeRequestedAt !== 0)
380
+ return;
378
381
  await ChannelStore.validateVoucher({
379
382
  channel,
380
383
  channelState,
@@ -384,10 +387,12 @@ async function validateVoucherCredential(parameters, payload) {
384
387
  });
385
388
  }
386
389
  async function resolveVoucherChannelState(parameters) {
387
- const { channel, channelId, channelStateTtl, client, escrow, lastOnChainVerified } = parameters;
388
- const isStale = Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl;
389
- const state = isStale ? await Chain.getChannelState(client, channelId, escrow) : undefined;
390
- if (state)
390
+ const { channel, channelId, channelStateTtl, client, escrow, forceRefresh, lastOnChainVerified } = parameters;
391
+ const shouldRefresh = forceRefresh || Date.now() - (lastOnChainVerified.get(channelId) ?? 0) > channelStateTtl;
392
+ const state = shouldRefresh ? await Chain.getChannelState(client, channelId, escrow) : undefined;
393
+ if (state?.closeRequestedAt)
394
+ lastOnChainVerified.delete(channelId);
395
+ else if (state)
391
396
  lastOnChainVerified.set(channelId, Date.now());
392
397
  return {
393
398
  deposit: state?.deposit ?? uint96(channel.deposit),
@@ -410,6 +415,9 @@ async function validateCloseCredential(parameters, payload) {
410
415
  escrow,
411
416
  store,
412
417
  });
418
+ const closeAuthorized = Voucher.verifyCloseAuthorization(escrow, chainId, { channelId, cumulativeAmount, signature: payload.closeSignature }, [channel.payer, channel.authorizedSigner]);
419
+ if (!closeAuthorized)
420
+ throw new InvalidSignatureError({ reason: 'invalid close authorization signature' });
413
421
  if (channel.finalized)
414
422
  throw new ChannelClosedError({ reason: 'channel is already finalized' });
415
423
  const state = await Chain.getChannelState(client, channelId, escrow);
@@ -611,17 +619,34 @@ async function handleVoucherCredential(parameters) {
611
619
  channelStateTtl,
612
620
  client,
613
621
  escrow,
622
+ forceRefresh: true,
614
623
  lastOnChainVerified,
615
624
  });
616
625
  if (channelState.closeRequestedAt !== 0) {
617
- await store.updateChannel(channelId, (current) => current
626
+ const closing = await store.updateChannel(channelId, (current) => current
618
627
  ? {
619
628
  ...current,
629
+ settledOnChain: channelState.settled > current.settledOnChain
630
+ ? channelState.settled
631
+ : current.settledOnChain,
620
632
  closeRequestedAt: BigInt(channelState.closeRequestedAt) > current.closeRequestedAt
621
633
  ? BigInt(channelState.closeRequestedAt)
622
634
  : current.closeRequestedAt,
623
635
  }
624
636
  : current);
637
+ if (closing && !closing.finalized && channelState.deposit > 0n)
638
+ await maybeSettleScheduled({
639
+ account: parameters.account,
640
+ channel: closing,
641
+ client,
642
+ feePayer: parameters.feePayer,
643
+ feePayerPolicy: parameters.feePayerPolicy,
644
+ feeToken: parameters.feeToken,
645
+ onSessionSettlement: parameters.onSessionSettlement,
646
+ schedule: {},
647
+ store,
648
+ });
649
+ throw new ChannelClosedError({ reason: 'channel has a pending close request' });
625
650
  }
626
651
  return ChannelStore.verifyAndAcceptVoucher({
627
652
  store,
@@ -649,6 +674,9 @@ async function handleCloseCredential(parameters) {
649
674
  escrow,
650
675
  store,
651
676
  });
677
+ const closeAuthorized = Voucher.verifyCloseAuthorization(escrow, chainId, { channelId, cumulativeAmount, signature: payload.closeSignature }, [channel.payer, channel.authorizedSigner]);
678
+ if (!closeAuthorized)
679
+ throw new InvalidSignatureError({ reason: 'invalid close authorization signature' });
652
680
  if (channel.finalized)
653
681
  throw new ChannelClosedError({ reason: 'channel is already finalized' });
654
682
  const state = await Chain.getChannelState(client, channelId, escrow);
@@ -8,8 +8,9 @@ export type SessionController = {
8
8
  * Reserve voucher coverage for the next emitted chunk.
9
9
  *
10
10
  * The reservation blocks until sufficient voucher headroom exists, but the
11
- * charge is only committed once a chunk is actually emitted. If the stream
12
- * ends or aborts before that emission, the reservation is dropped.
11
+ * charge is committed when a chunk is emitted or when the generator finishes
12
+ * successfully. A reservation is only dropped when the stream aborts or the
13
+ * generator fails before the charge can be committed.
13
14
  *
14
15
  * Pass an explicit raw-unit `amount` for request-aware or otherwise dynamic
15
16
  * pricing. When omitted, the session challenge's configured tick cost is
@@ -1,46 +1,102 @@
1
- import { commitReservedCharges, reserveChargeOrWait } from './Transports.js';
1
+ import { commitReservedCharges, releaseReservedCharges, renewReservedCharges, reserveChargeOrWait, streamReservationLeaseMs, } from './Transports.js';
2
2
  /** Applies voucher reservation and spend commits to an async session stream. */
3
3
  export async function* meterIterable(options) {
4
4
  let prepaidUnits = options.prepaidUnits ?? 0;
5
- let reservedAmount = 0n;
6
- let reservedUnits = 0;
5
+ const controller = new AbortController();
6
+ const signal = controller.signal;
7
+ const reservation = {
8
+ store: options.store,
9
+ channelId: options.channelId,
10
+ reservationId: globalThis.crypto.randomUUID(),
11
+ };
12
+ let pending = false;
13
+ let cancelled = false;
14
+ const onAbort = () => {
15
+ if (signal.aborted)
16
+ return;
17
+ cancelled = true;
18
+ controller.abort(options.signal?.reason);
19
+ };
20
+ options.signal?.addEventListener('abort', onAbort, { once: true });
21
+ if (options.signal?.aborted)
22
+ onAbort();
23
+ const renewal = setInterval(() => {
24
+ if (pending)
25
+ void renewReservedCharges(reservation).catch((error) => controller.abort(error));
26
+ }, streamReservationLeaseMs / 3);
27
+ renewal.unref?.();
28
+ const releaseOnAbort = () => {
29
+ clearInterval(renewal);
30
+ void releaseReservedCharges(reservation).catch(() => { });
31
+ };
32
+ signal.addEventListener('abort', releaseOnAbort, { once: true });
33
+ if (signal.aborted)
34
+ releaseOnAbort();
7
35
  const charge = async (amount) => {
36
+ signal.throwIfAborted();
8
37
  if (amount === undefined && prepaidUnits > 0) {
9
38
  prepaidUnits -= 1;
10
39
  return;
11
40
  }
12
- const resolvedAmount = amount ?? options.tickCost;
41
+ if ((amount ?? options.tickCost) === 0n)
42
+ return;
13
43
  await reserveChargeOrWait({
14
- store: options.store,
15
- channelId: options.channelId,
16
- amount: resolvedAmount,
17
- reservedAmount,
44
+ ...reservation,
45
+ requireExisting: pending,
46
+ amount: amount ?? options.tickCost,
18
47
  emit: options.emitNeedVoucher,
19
48
  formatNeedVoucher: options.formatNeedVoucher,
20
49
  pollIntervalMs: options.pollIntervalMs,
21
- signal: options.signal,
50
+ signal,
22
51
  });
23
- reservedAmount += resolvedAmount;
24
- reservedUnits += 1;
52
+ pending = true;
53
+ signal.throwIfAborted();
25
54
  };
26
- const signal = options.signal ?? new AbortController().signal;
27
- const iterable = typeof options.generate === 'function' ? options.generate({ charge, signal }) : options.generate;
28
- for await (const value of iterable) {
29
- if (options.signal?.aborted)
30
- break;
31
- if (typeof options.generate !== 'function')
32
- await charge();
33
- const channel = await commitReservedCharges({
34
- store: options.store,
35
- channelId: options.channelId,
36
- amount: reservedAmount,
37
- units: reservedUnits,
38
- });
39
- reservedAmount = 0n;
40
- reservedUnits = 0;
41
- if (channel)
42
- await options.onChargeCommitted?.(channel);
43
- yield value;
55
+ const commit = async () => {
56
+ signal.throwIfAborted();
57
+ if (!pending)
58
+ return;
59
+ const channel = await commitReservedCharges(reservation);
60
+ if (!channel)
61
+ throw new Error('stream reservation was lost');
62
+ pending = false;
63
+ await options.onChargeCommitted?.(channel);
64
+ };
65
+ try {
66
+ const manual = typeof options.generate === 'function';
67
+ const iterable = typeof options.generate === 'function'
68
+ ? options.generate({ charge, signal })
69
+ : options.generate;
70
+ const iterator = iterable[Symbol.asyncIterator]();
71
+ try {
72
+ while (!signal.aborted) {
73
+ const { done, value } = await iterator.next();
74
+ if (signal.aborted)
75
+ break;
76
+ if (done) {
77
+ if (manual)
78
+ await commit();
79
+ break;
80
+ }
81
+ if (!manual)
82
+ await charge();
83
+ await commit();
84
+ yield value;
85
+ }
86
+ if (!cancelled)
87
+ signal.throwIfAborted();
88
+ }
89
+ finally {
90
+ clearInterval(renewal);
91
+ await releaseReservedCharges(reservation);
92
+ await iterator.return?.();
93
+ }
94
+ }
95
+ finally {
96
+ clearInterval(renewal);
97
+ options.signal?.removeEventListener('abort', onAbort);
98
+ signal.removeEventListener('abort', releaseOnAbort);
99
+ await releaseReservedCharges(reservation);
44
100
  }
45
101
  }
46
102
  //# sourceMappingURL=MeteredStream.js.map
@@ -59,6 +59,7 @@ export declare function session<const parameters extends session.Parameters>(p?:
59
59
  }, import("zod/v4/core").$strip>, import("zod/mini").ZodMiniObject<{
60
60
  action: import("zod/mini").ZodMiniLiteral<"close">;
61
61
  channelId: import("zod/mini").ZodMiniString<string>;
62
+ closeSignature: import("zod/mini").ZodMiniString<string>;
62
63
  cumulativeAmount: import("zod/mini").ZodMiniString<string>;
63
64
  descriptor: import("zod/mini").ZodMiniOptional<import("zod/mini").ZodMiniCustom<import("../precompile/Protocol.js").ChannelDescriptor, import("../precompile/Protocol.js").ChannelDescriptor>>;
64
65
  signature: import("zod/mini").ZodMiniString<string>;
@@ -137,7 +138,7 @@ export declare namespace session {
137
138
  type FeePayerPolicy = Partial<FeePayer.Policy>;
138
139
  /** Parameters accepted by the TIP-1034 server session payment method. */
139
140
  type Parameters = {
140
- /** TTL in milliseconds for cached on-chain channel state. After this duration, the server re-queries on-chain state during voucher handling to detect forced close requests. @default 5_000 */
141
+ /** TTL in milliseconds for cached on-chain channel state during non-mutating validation. Voucher acceptance always refreshes on-chain state. @default 5_000 */
141
142
  channelStateTtl?: number | undefined;
142
143
  /** Override the fee-sponsor policy used for sponsored open/topUp transactions and server-driven close transactions. */
143
144
  feePayerPolicy?: FeePayerPolicy | undefined;
@@ -243,7 +243,7 @@ export function session(p) {
243
243
  ...(typeof parameters.sse === 'object' ? parameters.sse : undefined),
244
244
  })
245
245
  : undefined;
246
- const validateCredential = async ({ credential, request, }) => {
246
+ const validateCredential = async ({ credential, operation, request, }) => {
247
247
  const payload = requireSessionCredentialPayload(credential.payload);
248
248
  const context = await resolveCredentialVerificationContext({
249
249
  decimals,
@@ -254,6 +254,7 @@ export function session(p) {
254
254
  });
255
255
  const details = await validateCredentialPayload({
256
256
  account,
257
+ allowPendingClose: operation === 'broadcast',
257
258
  challenge: credential.challenge,
258
259
  channelStateTtl,
259
260
  chainId: context.chainId,
@@ -89,6 +89,7 @@ export type OnSessionSettlement = (context: SessionSettlementContext) => MaybePr
89
89
  /** Inputs used to mark a channel after automatic scheduled settlement succeeds. */
90
90
  export type MarkSettlementCompleteParameters = {
91
91
  channelId: ChannelStore.State['channelId'];
92
+ leaseOwner: string;
92
93
  settledAt?: string | undefined;
93
94
  store: ChannelStore.ChannelStore;
94
95
  };
@@ -106,6 +107,26 @@ export declare function resolveSettlementProgress(channel: ChannelStore.State):
106
107
  export declare function isSettlementDue(channel: ChannelStore.State, schedule: ResolvedSettlementSchedule | undefined): boolean;
107
108
  /** Records the channel spend/unit counters that a scheduled settlement captured. */
108
109
  export declare function markSettlementComplete(parameters: MarkSettlementCompleteParameters): Promise<void>;
110
+ /** Atomically claims one due scheduled settlement across server workers. */
111
+ export declare function claimScheduledSettlement(parameters: {
112
+ channelId: Hex;
113
+ leaseMs?: number | undefined;
114
+ schedule: ResolvedSettlementSchedule;
115
+ store: ChannelStore.ChannelStore;
116
+ }): Promise<string | undefined>;
117
+ /** Extends an owned scheduled settlement lease while its transaction is in flight. */
118
+ export declare function renewScheduledSettlement(parameters: {
119
+ channelId: Hex;
120
+ leaseMs?: number | undefined;
121
+ leaseOwner: string;
122
+ store: ChannelStore.ChannelStore;
123
+ }): Promise<void>;
124
+ /** Releases a scheduled settlement claim after a failed attempt. */
125
+ export declare function releaseScheduledSettlement(parameters: {
126
+ channelId: Hex;
127
+ leaseOwner: string;
128
+ store: ChannelStore.ChannelStore;
129
+ }): Promise<void>;
109
130
  /** Callback used by post-verification accounting to deduct spend from a channel. */
110
131
  export type ChargeSessionChannel = (channelId: Hex, amount: bigint) => Promise<ChannelStore.State>;
111
132
  /** Callback used by post-verification accounting to run server-owned settlement policy. */
@@ -48,6 +48,7 @@ export function resolveCredentialFeePayer(parameters) {
48
48
  return feePayer;
49
49
  return feePayer === true || typeof feePayer === 'string' ? true : undefined;
50
50
  }
51
+ const scheduledSettlementLeaseMs = 5 * 60_000;
51
52
  /** Converts a public settlement schedule into raw-unit thresholds. */
52
53
  export function resolveSettlementSchedule(schedule, decimals) {
53
54
  if (!schedule)
@@ -93,6 +94,9 @@ export function isSettlementDue(channel, schedule) {
93
94
  const progress = resolveSettlementProgress(channel);
94
95
  if (!progress)
95
96
  return false;
97
+ // A detected force-close makes outstanding vouchers immediately due.
98
+ if (!channel.finalized && channel.closeRequestedAt > 0n)
99
+ return true;
96
100
  if (schedule.units !== undefined && progress.units >= schedule.units)
97
101
  return true;
98
102
  if (schedule.amount !== undefined && progress.amount >= schedule.amount)
@@ -103,15 +107,63 @@ export function isSettlementDue(channel, schedule) {
103
107
  }
104
108
  /** Records the channel spend/unit counters that a scheduled settlement captured. */
105
109
  export async function markSettlementComplete(parameters) {
106
- const { channelId, store, settledAt = new Date().toISOString() } = parameters;
107
- await store.updateChannel(channelId, (current) => current
108
- ? {
109
- ...current,
110
+ const { channelId, leaseOwner, store, settledAt = new Date().toISOString() } = parameters;
111
+ await store.updateChannel(channelId, (current) => {
112
+ if (!current)
113
+ return current;
114
+ if (current.scheduledSettlementLease?.owner !== leaseOwner)
115
+ return current;
116
+ const { scheduledSettlementLease: _, ...channel } = current;
117
+ return {
118
+ ...channel,
110
119
  lastSettlementAt: settledAt,
111
120
  lastSettlementSpent: current.spent,
112
121
  lastSettlementUnits: current.units,
113
- }
114
- : current);
122
+ };
123
+ });
124
+ }
125
+ /** Atomically claims one due scheduled settlement across server workers. */
126
+ export async function claimScheduledSettlement(parameters) {
127
+ const { channelId, leaseMs = scheduledSettlementLeaseMs, schedule, store } = parameters;
128
+ const now = Date.now();
129
+ const owner = globalThis.crypto.randomUUID();
130
+ let claimed = false;
131
+ await store.updateChannel(channelId, (current) => {
132
+ claimed = false;
133
+ if (!current || !isSettlementDue(current, schedule))
134
+ return current;
135
+ const lease = current.scheduledSettlementLease;
136
+ if (lease && lease.expiresAt > now)
137
+ return current;
138
+ claimed = true;
139
+ return {
140
+ ...current,
141
+ scheduledSettlementLease: { expiresAt: now + leaseMs, owner },
142
+ };
143
+ });
144
+ return claimed ? owner : undefined;
145
+ }
146
+ /** Extends an owned scheduled settlement lease while its transaction is in flight. */
147
+ export async function renewScheduledSettlement(parameters) {
148
+ const { channelId, leaseMs = scheduledSettlementLeaseMs, leaseOwner, store } = parameters;
149
+ await store.updateChannel(channelId, (current) => {
150
+ if (!current || current.scheduledSettlementLease?.owner !== leaseOwner)
151
+ return current;
152
+ return {
153
+ ...current,
154
+ scheduledSettlementLease: { expiresAt: Date.now() + leaseMs, owner: leaseOwner },
155
+ };
156
+ });
157
+ }
158
+ /** Releases a scheduled settlement claim after a failed attempt. */
159
+ export async function releaseScheduledSettlement(parameters) {
160
+ const { channelId, leaseOwner, store } = parameters;
161
+ await store.updateChannel(channelId, (current) => {
162
+ if (!current || current.scheduledSettlementLease?.owner !== leaseOwner)
163
+ return current;
164
+ const { scheduledSettlementLease: _, ...channel } = current;
165
+ return channel;
166
+ });
115
167
  }
116
168
  /** Returns whether an accepted credential will authorize and charge an HTTP content response. */
117
169
  export function shouldApplyVerifiedHttpAccounting(parameters) {
@@ -190,19 +242,42 @@ export function assertSettlementSender(parameters) {
190
242
  /** Applies automatic settlement when the server-owned schedule is due. */
191
243
  export async function maybeSettleScheduled(parameters) {
192
244
  const { channel, schedule, store } = parameters;
193
- if (!isSettlementDue(channel, schedule))
245
+ if (!schedule || !isSettlementDue(channel, schedule))
194
246
  return undefined;
195
- const txHash = await settle(store, parameters.client, channel.channelId, {
196
- account: parameters.account,
197
- ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
198
- ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
199
- ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
200
- onSessionSettlement: parameters.onSessionSettlement
201
- ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
202
- : undefined,
247
+ const leaseOwner = await claimScheduledSettlement({
248
+ channelId: channel.channelId,
249
+ schedule,
250
+ store,
203
251
  });
204
- await markSettlementComplete({ channelId: channel.channelId, store });
205
- return txHash;
252
+ if (!leaseOwner)
253
+ return undefined;
254
+ const renewal = setInterval(() => {
255
+ void renewScheduledSettlement({
256
+ channelId: channel.channelId,
257
+ leaseOwner,
258
+ store,
259
+ }).catch(() => undefined);
260
+ }, scheduledSettlementLeaseMs / 2);
261
+ try {
262
+ const txHash = await settle(store, parameters.client, channel.channelId, {
263
+ account: parameters.account,
264
+ ...(parameters.feePayer ? { feePayer: parameters.feePayer } : {}),
265
+ ...(parameters.feePayerPolicy ? { feePayerPolicy: parameters.feePayerPolicy } : {}),
266
+ ...(parameters.feeToken ? { feeToken: parameters.feeToken } : {}),
267
+ onSessionSettlement: parameters.onSessionSettlement
268
+ ? (ctx) => parameters.onSessionSettlement({ ...ctx, trigger: 'scheduled' })
269
+ : undefined,
270
+ });
271
+ await markSettlementComplete({ channelId: channel.channelId, leaseOwner, store });
272
+ return txHash;
273
+ }
274
+ catch (error) {
275
+ await releaseScheduledSettlement({ channelId: channel.channelId, leaseOwner, store }).catch(() => undefined);
276
+ throw error;
277
+ }
278
+ finally {
279
+ clearInterval(renewal);
280
+ }
206
281
  }
207
282
  /** Settles the highest accepted voucher for a precompile-backed session channel. */
208
283
  export async function settle(store_, client, channelId_, options) {
@@ -27,7 +27,9 @@ export type { SessionController } from './MeteredStream.js';
27
27
  * 2. If balance is sufficient, emits `event: message` with the value.
28
28
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
29
29
  * and polls store until the client tops up the channel.
30
- * 4. Commits the reserved charge immediately before the chunk is emitted.
30
+ * 4. Commits the reserved charge immediately before the chunk is emitted. If a
31
+ * manual generator completes after charging without yielding another chunk,
32
+ * commits that terminal charge on successful completion.
31
33
  * 5. On generator completion, emits a final `event: payment-receipt`.
32
34
  *
33
35
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -26,7 +26,9 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
26
26
  * 2. If balance is sufficient, emits `event: message` with the value.
27
27
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
28
28
  * and polls store until the client tops up the channel.
29
- * 4. Commits the reserved charge immediately before the chunk is emitted.
29
+ * 4. Commits the reserved charge immediately before the chunk is emitted. If a
30
+ * manual generator completes after charging without yielding another chunk,
31
+ * commits that terminal charge on successful completion.
30
32
  * 5. On generator completion, emits a final `event: payment-receipt`.
31
33
  *
32
34
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
@@ -34,28 +36,59 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
34
36
  export function serve(options) {
35
37
  const { store, channelId, challengeId, tickCost, generate, pollIntervalMs = 100, signal, } = options;
36
38
  const encoder = new TextEncoder();
39
+ const abortController = new AbortController();
40
+ let streamController;
41
+ let terminal = false;
42
+ const iterator = meterIterable({
43
+ store,
44
+ channelId,
45
+ tickCost,
46
+ generate,
47
+ onChargeCommitted: options.onChargeCommitted,
48
+ pollIntervalMs,
49
+ prepaidUnits: options.prepaidUnits,
50
+ signal: abortController.signal,
51
+ emitNeedVoucher: (event) => streamController?.enqueue(encoder.encode(event)),
52
+ formatNeedVoucher: formatNeedVoucherEvent,
53
+ })[Symbol.asyncIterator]();
54
+ const cleanup = () => signal?.removeEventListener('abort', abort);
55
+ const terminate = (reason) => {
56
+ if (terminal)
57
+ return;
58
+ terminal = true;
59
+ abortController.abort(reason);
60
+ cleanup();
61
+ void Promise.resolve()
62
+ .then(() => iterator.return?.(undefined))
63
+ .catch(() => { });
64
+ try {
65
+ streamController?.close();
66
+ }
67
+ catch { }
68
+ };
69
+ const abort = () => terminate(signal?.reason);
70
+ signal?.addEventListener('abort', abort, { once: true });
37
71
  return new ReadableStream({
38
- async start(controller) {
39
- const aborted = () => signal?.aborted ?? false;
40
- const emit = (event) => controller.enqueue(encoder.encode(event));
72
+ start(controller) {
73
+ streamController = controller;
74
+ if (signal?.aborted)
75
+ abort();
76
+ },
77
+ async pull(controller) {
78
+ if (terminal)
79
+ return;
41
80
  try {
42
- for await (const value of meterIterable({
43
- store,
44
- channelId,
45
- tickCost,
46
- generate,
47
- onChargeCommitted: options.onChargeCommitted,
48
- pollIntervalMs,
49
- prepaidUnits: options.prepaidUnits,
50
- signal,
51
- emitNeedVoucher: emit,
52
- formatNeedVoucher: formatNeedVoucherEvent,
53
- })) {
54
- if (aborted())
55
- break;
81
+ const next = await iterator.next();
82
+ if (terminal)
83
+ return;
84
+ if (!next.done) {
85
+ if (abortController.signal.aborted)
86
+ return;
87
+ const value = next.value;
56
88
  controller.enqueue(encoder.encode(formatMessageEvent(value)));
89
+ return;
57
90
  }
58
- if (!aborted()) {
91
+ if (!abortController.signal.aborted) {
59
92
  const channel = await store.getChannel(channelId);
60
93
  if (channel) {
61
94
  const receipt = createSessionReceipt({
@@ -66,16 +99,29 @@ export function serve(options) {
66
99
  units: channel.units,
67
100
  });
68
101
  controller.enqueue(encoder.encode(formatReceiptEvent(receipt)));
102
+ terminal = true;
103
+ cleanup();
104
+ controller.close();
105
+ return;
69
106
  }
70
107
  }
108
+ terminal = true;
109
+ cleanup();
110
+ controller.close();
71
111
  }
72
112
  catch (e) {
73
- if (!aborted())
113
+ if (terminal)
114
+ return;
115
+ terminal = true;
116
+ cleanup();
117
+ if (!abortController.signal.aborted)
74
118
  controller.error(e);
119
+ else
120
+ controller.close();
75
121
  }
76
- finally {
77
- controller.close();
78
- }
122
+ },
123
+ cancel(reason) {
124
+ terminate(reason);
79
125
  },
80
126
  });
81
127
  }
@@ -154,26 +200,30 @@ export async function* iterateData(response, options) {
154
200
  while (true) {
155
201
  const { value, done } = await reader.read();
156
202
  if (done)
157
- break;
158
- buffer += decoder.decode(value, { stream: true });
159
- // Split on double-newline SSE event boundaries.
160
- const events = buffer.split('\n\n');
161
- // Last element may be incomplete — keep in buffer.
162
- buffer = events.pop() ?? '';
163
- for (const event of events) {
203
+ buffer += decoder.decode();
204
+ else
205
+ buffer += decoder.decode(value, { stream: true });
206
+ while (true) {
207
+ const separator = findSseSeparator(buffer, done);
208
+ if (!separator)
209
+ break;
210
+ const event = buffer.slice(0, separator.index);
211
+ buffer = buffer.slice(separator.index + separator.length);
164
212
  if (!event.trim())
165
213
  continue;
166
- const data = extractData(event);
214
+ const data = extractData(event.replace(/\r\n|\r/g, '\n'));
167
215
  if (data === null)
168
216
  continue;
169
217
  if (skip?.(data))
170
218
  continue;
171
219
  yield data;
172
220
  }
221
+ if (done)
222
+ break;
173
223
  }
174
224
  // Flush remaining buffer.
175
225
  if (buffer.trim()) {
176
- const data = extractData(buffer);
226
+ const data = extractData(buffer.replace(/\r\n|\r/g, '\n'));
177
227
  if (data !== null && !skip?.(data))
178
228
  yield data;
179
229
  }
@@ -182,4 +232,27 @@ export async function* iterateData(response, options) {
182
232
  reader.releaseLock();
183
233
  }
184
234
  }
235
+ function findSseSeparator(value, complete) {
236
+ for (let index = 0; index < value.length; index++) {
237
+ const first = lineEndingLength(value, index, complete);
238
+ if (!first)
239
+ continue;
240
+ const second = lineEndingLength(value, index + first, complete);
241
+ if (second)
242
+ return { index, length: first + second };
243
+ index += first - 1;
244
+ }
245
+ return undefined;
246
+ }
247
+ function lineEndingLength(value, index, complete) {
248
+ if (value[index] === '\n')
249
+ return 1;
250
+ if (value[index] !== '\r')
251
+ return 0;
252
+ if (value[index + 1] === '\n')
253
+ return 2;
254
+ if (index + 1 === value.length && !complete)
255
+ return 0;
256
+ return 1;
257
+ }
185
258
  //# sourceMappingURL=Sse.js.map