mppx 0.11.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 (126) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +22 -1
  3. package/dist/Method.d.ts +7 -1
  4. package/dist/Method.js +7 -2
  5. package/dist/Receipt.d.ts +2 -0
  6. package/dist/Receipt.js +2 -0
  7. package/dist/cli/agent.d.ts +3 -0
  8. package/dist/cli/agent.js +20 -0
  9. package/dist/cli/cli.d.ts +9 -1
  10. package/dist/cli/cli.js +134 -136
  11. package/dist/cli/plugins/index.js +12 -14
  12. package/dist/cli/plugins/tempo.js +9 -2
  13. package/dist/cli/utils.d.ts +11 -1
  14. package/dist/cli/utils.js +15 -2
  15. package/dist/cli/validate/challenge.d.ts +2 -0
  16. package/dist/cli/validate/challenge.js +19 -4
  17. package/dist/cli/validate/discovery.d.ts +1 -0
  18. package/dist/cli/validate/discovery.js +10 -1
  19. package/dist/cli/validate/payment.js +63 -24
  20. package/dist/client/Methods.d.ts +0 -2
  21. package/dist/client/Methods.js +0 -2
  22. package/dist/client/Mppx.js +1 -0
  23. package/dist/client/index.d.ts +1 -1
  24. package/dist/client/index.js +1 -1
  25. package/dist/client/internal/Fetch.d.ts +2 -0
  26. package/dist/client/internal/Fetch.js +8 -2
  27. package/dist/client/internal/MethodChallenge.d.ts +1 -0
  28. package/dist/evm/Types.js +1 -1
  29. package/dist/evm/server/Charge.js +5 -0
  30. package/dist/evm/server/Methods.d.ts +164 -3
  31. package/dist/evm/server/Methods.js +50 -2
  32. package/dist/integrations/x402/Evm.js +4 -3
  33. package/dist/internal/HeaderCodec.d.ts +2 -2
  34. package/dist/internal/version.d.ts +2 -2
  35. package/dist/internal/version.js +1 -1
  36. package/dist/mcp/client/McpClient.js +4 -7
  37. package/dist/middlewares/elysia.d.ts +11 -8
  38. package/dist/middlewares/elysia.js +67 -14
  39. package/dist/middlewares/express.js +13 -6
  40. package/dist/middlewares/hono.js +4 -4
  41. package/dist/middlewares/nextjs.js +9 -5
  42. package/dist/proxy/internal/Headers.js +65 -0
  43. package/dist/proxy/services/stripe.js +1 -0
  44. package/dist/server/Mppx.d.ts +18 -2
  45. package/dist/server/Mppx.js +204 -92
  46. package/dist/server/NodeListener.js +71 -6
  47. package/dist/server/Request.d.ts +11 -1
  48. package/dist/server/Request.js +132 -13
  49. package/dist/server/Transport.d.ts +14 -1
  50. package/dist/server/Transport.js +1 -0
  51. package/dist/server/internal/html/compose.main.gen.d.ts +1 -1
  52. package/dist/server/internal/html/serviceWorker.gen.d.ts +1 -1
  53. package/dist/stripe/server/Methods.d.ts +10 -3
  54. package/dist/stripe/server/Methods.js +19 -6
  55. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  56. package/dist/stripe/server/internal/html.gen.js +1 -1
  57. package/dist/tempo/Methods.d.ts +1 -0
  58. package/dist/tempo/Methods.js +1 -0
  59. package/dist/tempo/client/Charge.js +17 -13
  60. package/dist/tempo/client/Methods.d.ts +1 -5
  61. package/dist/tempo/client/Methods.js +10 -5
  62. package/dist/tempo/client/ResolveAccount.d.ts +3 -0
  63. package/dist/tempo/client/index.d.ts +0 -3
  64. package/dist/tempo/client/index.js +0 -2
  65. package/dist/tempo/internal/currencies.d.ts +19 -0
  66. package/dist/tempo/internal/currencies.js +41 -0
  67. package/dist/tempo/internal/defaults.d.ts +5 -3
  68. package/dist/tempo/internal/defaults.js +6 -2
  69. package/dist/tempo/internal/fee-payer.d.ts +11 -7
  70. package/dist/tempo/internal/fee-payer.js +30 -11
  71. package/dist/tempo/internal/machine-token-charge.d.ts +23 -0
  72. package/dist/tempo/internal/machine-token-charge.js +2 -1
  73. package/dist/tempo/legacy/index.d.ts +1 -3
  74. package/dist/tempo/legacy/index.js +1 -3
  75. package/dist/tempo/server/Charge.d.ts +12 -1
  76. package/dist/tempo/server/Charge.js +109 -78
  77. package/dist/tempo/server/Methods.d.ts +5048 -40
  78. package/dist/tempo/server/Methods.js +104 -13
  79. package/dist/tempo/server/Subscription.d.ts +1 -1
  80. package/dist/tempo/server/Subscription.js +31 -4
  81. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  82. package/dist/tempo/server/internal/html.gen.js +1 -1
  83. package/dist/tempo/server/internal/transport.js +28 -11
  84. package/dist/tempo/session/client/ChannelOps.js +5 -0
  85. package/dist/tempo/session/client/Session.d.ts +1 -0
  86. package/dist/tempo/session/client/Session.js +7 -4
  87. package/dist/tempo/session/client/SessionManager.js +3 -0
  88. package/dist/tempo/session/client/Transports.d.ts +8 -1
  89. package/dist/tempo/session/client/Transports.js +12 -3
  90. package/dist/tempo/session/precompile/Chain.js +16 -1
  91. package/dist/tempo/session/precompile/Protocol.d.ts +2 -0
  92. package/dist/tempo/session/precompile/Voucher.d.ts +16 -0
  93. package/dist/tempo/session/precompile/Voucher.js +48 -0
  94. package/dist/tempo/session/server/ChannelStore.d.ts +22 -2
  95. package/dist/tempo/session/server/ChannelStore.js +37 -7
  96. package/dist/tempo/session/server/CredentialVerification.d.ts +6 -1
  97. package/dist/tempo/session/server/CredentialVerification.js +56 -19
  98. package/dist/tempo/session/server/MeteredStream.d.ts +3 -2
  99. package/dist/tempo/session/server/MeteredStream.js +85 -29
  100. package/dist/tempo/session/server/RequestState.d.ts +6 -0
  101. package/dist/tempo/session/server/RequestState.js +11 -4
  102. package/dist/tempo/session/server/Session.d.ts +2 -1
  103. package/dist/tempo/session/server/Session.js +10 -2
  104. package/dist/tempo/session/server/Settlement.d.ts +23 -0
  105. package/dist/tempo/session/server/Settlement.js +105 -25
  106. package/dist/tempo/session/server/Sse.d.ts +3 -1
  107. package/dist/tempo/session/server/Sse.js +105 -32
  108. package/dist/tempo/session/server/Transports.d.ts +24 -19
  109. package/dist/tempo/session/server/Transports.js +169 -58
  110. package/dist/tempo/session/server/Ws.d.ts +5 -1
  111. package/dist/tempo/session/server/Ws.js +75 -20
  112. package/dist/tempo/subscription/KeyAuthorization.d.ts +70 -8
  113. package/dist/tempo/subscription/Store.d.ts +2 -0
  114. package/dist/tempo/subscription/Store.js +3 -0
  115. package/dist/validation/core.js +2 -1
  116. package/dist/x402/server/EvmCharge.d.ts +3 -1
  117. package/dist/x402/server/EvmCharge.js +14 -1
  118. package/package.json +8 -8
  119. package/dist/tempo/legacy/client/ChannelOps.d.ts +0 -69
  120. package/dist/tempo/legacy/client/ChannelOps.js +0 -168
  121. package/dist/tempo/legacy/client/Session.d.ts +0 -185
  122. package/dist/tempo/legacy/client/Session.js +0 -279
  123. package/dist/tempo/legacy/client/SessionManager.d.ts +0 -100
  124. package/dist/tempo/legacy/client/SessionManager.js +0 -706
  125. package/dist/tempo/legacy/client/index.d.ts +0 -7
  126. package/dist/tempo/legacy/client/index.js +0 -5
@@ -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
@@ -13,34 +13,31 @@ export type ReserveChargeParameters = {
13
13
  formatNeedVoucher(parameters: NeedVoucherEvent): string;
14
14
  /** Store polling interval when `waitForUpdate` is unavailable. */
15
15
  pollIntervalMs: number;
16
- /** Amount already reserved but not yet committed by this stream loop. */
17
- reservedAmount: bigint;
16
+ /** Identifier owned by this stream across reserve, commit, and release. */
17
+ reservationId: string;
18
+ /** Requires a previous uncommitted charge to retain its lease. */
19
+ requireExisting?: boolean | undefined;
18
20
  /** Optional abort signal for long waits. */
19
21
  signal?: AbortSignal | undefined;
20
22
  /** Channel store used for state reads and waits. */
21
23
  store: ChannelStore.ChannelStore;
22
24
  };
23
- /** Parameters for committing previously reserved stream charges. */
25
+ /** Inputs for updating the reservation owned by one stream. */
24
26
  export type CommitReservedChargesParameters = {
25
- /** Reserved amount to commit. */
26
- amount: bigint;
27
- /** Channel being metered. */
28
27
  channelId: Hex;
29
- /** Channel store used for atomic updates. */
28
+ reservationId: string;
30
29
  store: ChannelStore.ChannelStore;
31
- /** Number of charge units to add. */
32
- units: number;
33
30
  };
34
- /**
35
- * Reserves voucher headroom for a future stream emission.
36
- *
37
- * If the channel lacks headroom, emits one need-voucher frame, then waits for
38
- * a store update or polling interval until the accepted voucher covers both
39
- * already-reserved charges and the next requested amount.
40
- */
31
+ /** Lease duration for abandoned stream reservations. Active generators renew it. */
32
+ export declare const streamReservationLeaseMs = 30000;
33
+ /** Atomically reserves shared voucher coverage before billable stream work begins. */
41
34
  export declare function reserveChargeOrWait(options: ReserveChargeParameters): Promise<void>;
42
- /** Atomically commits previously reserved stream charges to channel spend and unit counters. */
35
+ /** Commits only this stream's live reservation, removing it in the same atomic update. */
43
36
  export declare function commitReservedCharges(options: CommitReservedChargesParameters): Promise<ChannelStore.State | undefined>;
37
+ /** Releases only the unused reservation owned by this stream. */
38
+ export declare function releaseReservedCharges({ store, channelId, reservationId, }: CommitReservedChargesParameters): Promise<void>;
39
+ /** Renews a live reservation without reviving an expired or released lease. */
40
+ export declare function renewReservedCharges({ store, channelId, reservationId, }: CommitReservedChargesParameters): Promise<void>;
44
41
  /** Throws when a channel can no longer be used for streaming charges. */
45
42
  export declare function throwIfChannelClosed(channel: ChannelStore.State): void;
46
43
  /** Minimal socket event map supported by browser and Node-style WebSocket runtimes. */
@@ -64,6 +61,8 @@ export type SocketEventListener<type extends keyof SocketEventMap> = ((event: So
64
61
  };
65
62
  /** Minimal socket shape required by the session WebSocket adapter. */
66
63
  export type Socket = {
64
+ /** Bytes queued by the runtime but not yet written to the network. */
65
+ readonly bufferedAmount?: number | undefined;
67
66
  close(code?: number, reason?: string): unknown;
68
67
  send(data: string): unknown;
69
68
  addEventListener?: <type extends keyof SocketEventMap>(type: type, listener: SocketEventListener<type>) => unknown;
@@ -71,6 +70,10 @@ export type Socket = {
71
70
  on?: <type extends keyof SocketEventMap>(type: type, listener: (event: SocketEventMap[type]) => void) => unknown;
72
71
  off?: <type extends keyof SocketEventMap>(type: type, listener: (event: SocketEventMap[type]) => void) => unknown;
73
72
  };
73
+ /** Raised after a socket is closed to keep its outbound queue bounded. */
74
+ export declare class WebSocketOutboundBufferLimitError extends Error {
75
+ readonly name = "WebSocketOutboundBufferLimitError";
76
+ }
74
77
  /** Handlers for socket lifecycle and message events. */
75
78
  export type SocketHandlers = {
76
79
  /** Called when the socket closes. */
@@ -82,8 +85,10 @@ export type SocketHandlers = {
82
85
  };
83
86
  /** Subscribes to browser or Node-style socket events and returns an unsubscribe callback. */
84
87
  export declare function subscribe(socket: Socket, handlers: SocketHandlers): () => void;
85
- /** Sends a text frame through sync or async socket implementations. */
86
- export declare function send(socket: Socket, data: string): Promise<void>;
88
+ /** Sends a text frame while bounding runtime-managed outbound buffering. */
89
+ export declare function send(socket: Socket, data: string, options?: {
90
+ maxBufferedAmount?: number;
91
+ }): Promise<void>;
87
92
  /** Converts socket message payloads into text frames when possible. */
88
93
  export declare function toText(value: unknown): string | null;
89
94
  //# sourceMappingURL=Transports.d.ts.map
@@ -1,62 +1,133 @@
1
1
  import { ChannelClosedError } from '../../../Errors.js';
2
- /**
3
- * Reserves voucher headroom for a future stream emission.
4
- *
5
- * If the channel lacks headroom, emits one need-voucher frame, then waits for
6
- * a store update or polling interval until the accepted voucher covers both
7
- * already-reserved charges and the next requested amount.
8
- */
2
+ import * as ChannelStore from './ChannelStore.js';
3
+ /** Lease duration for abandoned stream reservations. Active generators renew it. */
4
+ export const streamReservationLeaseMs = 30_000;
5
+ /** Atomically reserves shared voucher coverage before billable stream work begins. */
9
6
  export async function reserveChargeOrWait(options) {
10
- const { amount, channelId, emit, formatNeedVoucher, pollIntervalMs, reservedAmount, signal, store, } = options;
11
- let channel = await store.getChannel(channelId);
12
- if (!channel)
13
- throw new Error('channel not found');
14
- throwIfChannelClosed(channel);
15
- const hasHeadroom = (state) => state.highestVoucherAmount - state.spent - reservedAmount >= amount;
16
- if (hasHeadroom(channel))
17
- return;
18
- await Promise.resolve(emit(formatNeedVoucher({
19
- channelId,
20
- requiredCumulative: (channel.spent + reservedAmount + amount).toString(),
21
- acceptedCumulative: channel.highestVoucherAmount.toString(),
22
- deposit: channel.deposit.toString(),
23
- })));
24
- while (!hasHeadroom(channel)) {
25
- await waitForUpdate(store, channelId, pollIntervalMs, signal);
26
- channel = await store.getChannel(channelId);
7
+ const { amount, channelId, emit, formatNeedVoucher, pollIntervalMs, reservationId, signal, store, } = options;
8
+ if (amount < 0n)
9
+ throw new Error('stream charge must be non-negative');
10
+ if (store.atomic === false)
11
+ throw new Error('stream reservations require an atomic store');
12
+ let lastRequired;
13
+ while (true) {
14
+ throwIfAborted(signal);
15
+ let channel = await store.getChannel(channelId);
27
16
  if (!channel)
28
17
  throw new Error('channel not found');
29
18
  throwIfChannelClosed(channel);
19
+ if (options.requireExisting &&
20
+ (channel.streamReservations?.[reservationId]?.expiresAt ?? 0) <= Date.now())
21
+ throw new Error('stream reservation was lost');
22
+ if (channel.highestVoucherAmount - channel.spent - ChannelStore.reservedStreamAmount(channel) >=
23
+ amount) {
24
+ let reserved = false;
25
+ channel = await store.updateChannel(channelId, (current) => {
26
+ reserved = false;
27
+ if (!current)
28
+ return current;
29
+ throwIfChannelClosed(current);
30
+ throwIfAborted(signal);
31
+ const now = Date.now();
32
+ const previous = current.streamReservations?.[reservationId];
33
+ if (options.requireExisting && !previous)
34
+ throw new Error('stream reservation was lost');
35
+ if (previous && previous.expiresAt <= now)
36
+ throw new Error('stream reservation expired');
37
+ if (current.highestVoucherAmount -
38
+ current.spent -
39
+ ChannelStore.reservedStreamAmount(current) <
40
+ amount)
41
+ return current;
42
+ const reservations = Object.fromEntries(Object.entries(current.streamReservations ?? {}).filter(([, item]) => item.expiresAt > now));
43
+ reserved = true;
44
+ return {
45
+ ...current,
46
+ streamReservations: {
47
+ ...reservations,
48
+ [reservationId]: {
49
+ amount: (previous?.amount ?? 0n) + amount,
50
+ units: (previous?.units ?? 0) + 1,
51
+ expiresAt: now + streamReservationLeaseMs,
52
+ },
53
+ },
54
+ };
55
+ });
56
+ if (reserved)
57
+ return;
58
+ if (!channel)
59
+ throw new Error('channel not found');
60
+ }
61
+ const required = channel.spent + ChannelStore.reservedStreamAmount(channel) + amount;
62
+ if (required !== lastRequired) {
63
+ lastRequired = required;
64
+ await emit(formatNeedVoucher({
65
+ channelId,
66
+ requiredCumulative: required.toString(),
67
+ acceptedCumulative: channel.highestVoucherAmount.toString(),
68
+ deposit: channel.deposit.toString(),
69
+ }));
70
+ }
71
+ await waitForUpdate(store, channelId, pollIntervalMs, signal);
30
72
  }
31
73
  }
32
- /** Atomically commits previously reserved stream charges to channel spend and unit counters. */
74
+ /** Commits only this stream's live reservation, removing it in the same atomic update. */
33
75
  export async function commitReservedCharges(options) {
34
- const { amount, channelId, store, units } = options;
35
- if (amount === 0n || units === 0)
36
- return;
76
+ const { channelId, reservationId, store } = options;
37
77
  let committed = false;
38
78
  const channel = await store.updateChannel(channelId, (current) => {
79
+ committed = false;
39
80
  if (!current)
40
- return null;
41
- if (current.finalized)
42
- return current;
43
- if (current.closeRequestedAt !== 0n)
44
81
  return current;
45
- if (current.highestVoucherAmount - current.spent < amount)
82
+ throwIfChannelClosed(current);
83
+ const reservation = current.streamReservations?.[reservationId];
84
+ if (!reservation)
46
85
  return current;
86
+ if (reservation.expiresAt <= Date.now())
87
+ throw new Error('stream reservation expired');
88
+ if (current.highestVoucherAmount - current.spent < reservation.amount)
89
+ throw new Error('reserved voucher coverage is no longer available');
90
+ const { [reservationId]: _, ...remaining } = current.streamReservations;
47
91
  committed = true;
48
92
  return {
49
93
  ...current,
50
- spent: current.spent + amount,
51
- units: current.units + units,
94
+ streamReservations: remaining,
95
+ spent: current.spent + reservation.amount,
96
+ units: current.units + reservation.units,
52
97
  };
53
98
  });
54
99
  if (!channel)
55
100
  throw new Error('channel not found');
56
- throwIfChannelClosed(channel);
57
- if (!committed)
58
- throw new Error('reserved voucher coverage is no longer available');
59
- return channel;
101
+ return committed ? channel : undefined;
102
+ }
103
+ /** Releases only the unused reservation owned by this stream. */
104
+ export async function releaseReservedCharges({ store, channelId, reservationId, }) {
105
+ await store.updateChannel(channelId, (current) => {
106
+ if (!current?.streamReservations?.[reservationId])
107
+ return current;
108
+ const { [reservationId]: _, ...remaining } = current.streamReservations;
109
+ return { ...current, streamReservations: remaining };
110
+ });
111
+ }
112
+ /** Renews a live reservation without reviving an expired or released lease. */
113
+ export async function renewReservedCharges({ store, channelId, reservationId, }) {
114
+ await store.updateChannel(channelId, (current) => {
115
+ if (!current)
116
+ throw new Error('channel not found');
117
+ throwIfChannelClosed(current);
118
+ const reservation = current.streamReservations?.[reservationId];
119
+ if (!reservation)
120
+ return current;
121
+ if (reservation.expiresAt <= Date.now())
122
+ throw new Error('stream reservation expired');
123
+ return {
124
+ ...current,
125
+ streamReservations: {
126
+ ...current.streamReservations,
127
+ [reservationId]: { ...reservation, expiresAt: Date.now() + streamReservationLeaseMs },
128
+ },
129
+ };
130
+ });
60
131
  }
61
132
  /** Throws when a channel can no longer be used for streaming charges. */
62
133
  export function throwIfChannelClosed(channel) {
@@ -68,11 +139,19 @@ export function throwIfChannelClosed(channel) {
68
139
  async function waitForUpdate(store, channelId, pollIntervalMs, signal) {
69
140
  throwIfAborted(signal);
70
141
  if (store.waitForUpdate) {
71
- await Promise.race([
72
- store.waitForUpdate(channelId),
73
- sleep(pollIntervalMs, signal),
74
- ...(signal ? [onceAborted(signal)] : []),
75
- ]);
142
+ const controller = new AbortController();
143
+ const onAbort = () => controller.abort(signal?.reason);
144
+ signal?.addEventListener('abort', onAbort, { once: true });
145
+ try {
146
+ await Promise.race([
147
+ store.waitForUpdate(channelId, controller.signal),
148
+ sleep(pollIntervalMs, controller.signal),
149
+ ]);
150
+ }
151
+ finally {
152
+ signal?.removeEventListener('abort', onAbort);
153
+ controller.abort();
154
+ }
76
155
  }
77
156
  else {
78
157
  await sleep(pollIntervalMs, signal);
@@ -92,21 +171,14 @@ function sleep(ms, signal) {
92
171
  signal?.addEventListener('abort', onAbort, { once: true });
93
172
  });
94
173
  }
95
- function onceAborted(signal) {
96
- return new Promise((_, reject) => {
97
- if (signal.aborted) {
98
- reject(signal.reason ?? new Error('aborted'));
99
- return;
100
- }
101
- signal.addEventListener('abort', () => reject(signal.reason ?? new Error('aborted')), {
102
- once: true,
103
- });
104
- });
105
- }
106
174
  function throwIfAborted(signal) {
107
175
  if (signal?.aborted)
108
176
  throw signal.reason ?? new Error('aborted');
109
177
  }
178
+ /** Raised after a socket is closed to keep its outbound queue bounded. */
179
+ export class WebSocketOutboundBufferLimitError extends Error {
180
+ name = 'WebSocketOutboundBufferLimitError';
181
+ }
110
182
  /** Subscribes to browser or Node-style socket events and returns an unsubscribe callback. */
111
183
  export function subscribe(socket, handlers) {
112
184
  if (socket.addEventListener && socket.removeEventListener) {
@@ -133,9 +205,48 @@ export function subscribe(socket, handlers) {
133
205
  }
134
206
  throw new Error('unsupported websocket implementation');
135
207
  }
136
- /** Sends a text frame through sync or async socket implementations. */
137
- export async function send(socket, data) {
138
- await Promise.resolve(socket.send(data));
208
+ const socketSendQueues = new WeakMap();
209
+ /** Sends a text frame while bounding runtime-managed outbound buffering. */
210
+ export async function send(socket, data, options = {}) {
211
+ const previous = socketSendQueues.get(socket) ?? Promise.resolve();
212
+ const current = previous
213
+ .catch(() => { })
214
+ .then(async () => {
215
+ const maxBufferedAmount = options.maxBufferedAmount ?? 1024 * 1024;
216
+ if ((socket.bufferedAmount ?? 0) + textByteLength(data) > maxBufferedAmount) {
217
+ try {
218
+ await Promise.resolve(socket.close(4008, 'outbound buffer limit exceeded'));
219
+ }
220
+ catch { }
221
+ throw new WebSocketOutboundBufferLimitError('websocket outbound buffer limit exceeded');
222
+ }
223
+ await Promise.resolve(socket.send(data));
224
+ });
225
+ socketSendQueues.set(socket, current);
226
+ try {
227
+ await current;
228
+ }
229
+ finally {
230
+ if (socketSendQueues.get(socket) === current)
231
+ socketSendQueues.delete(socket);
232
+ }
233
+ }
234
+ function textByteLength(value) {
235
+ let bytes = 0;
236
+ for (let index = 0; index < value.length; index++) {
237
+ const codePoint = value.codePointAt(index);
238
+ if (codePoint <= 0x7f)
239
+ bytes += 1;
240
+ else if (codePoint <= 0x7ff)
241
+ bytes += 2;
242
+ else if (codePoint <= 0xffff)
243
+ bytes += 3;
244
+ else {
245
+ bytes += 4;
246
+ index += 1;
247
+ }
248
+ }
249
+ return bytes;
139
250
  }
140
251
  /** Converts socket message payloads into text frames when possible. */
141
252
  export function toText(value) {
@@ -12,7 +12,7 @@ export type SessionRouteResult = {
12
12
  challenge: Response;
13
13
  } | {
14
14
  status: 200;
15
- withReceipt(response?: Response): Response;
15
+ withReceipt(response?: Response): Response | Promise<Response>;
16
16
  };
17
17
  /** HTTP route used to perform a WebSocket payment probe. */
18
18
  export type SessionRoute = (request: Request) => Promise<SessionRouteResult>;
@@ -39,6 +39,10 @@ export declare namespace serve {
39
39
  /** Application stream. A manual stream can call `charge(amount)` with a
40
40
  * per-message raw-unit amount; omitting it uses the challenge tick cost. */
41
41
  generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
42
+ /** Maximum accepted inbound WebSocket frame size in bytes. @default 65536 */
43
+ maxIncomingMessageBytes?: number | undefined;
44
+ /** Maximum queued outbound bytes before the socket is closed. @default 1048576 */
45
+ maxBufferedAmount?: number | undefined;
42
46
  pollIntervalMs?: number | undefined;
43
47
  /** Payment route handler. Receives synthetic `POST` requests with only
44
48
  * the `Authorization` header — no cookies, bodies, or upgrade headers. */