mppx 0.5.7 → 0.5.9

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 (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -1,16 +1,44 @@
1
1
  import type { Hex } from 'ox'
2
- import type { Address } from 'viem'
2
+ import { parseUnits, type Address } from 'viem'
3
3
 
4
- import type * as Challenge from '../../Challenge.js'
4
+ import * as Challenge from '../../Challenge.js'
5
5
  import * as Fetch from '../../client/internal/Fetch.js'
6
+ import * as PaymentCredential from '../../Credential.js'
6
7
  import type * as Account from '../../viem/Account.js'
7
8
  import type * as Client from '../../viem/Client.js'
8
9
  import { deserializeSessionReceipt } from '../session/Receipt.js'
9
10
  import { parseEvent } from '../session/Sse.js'
10
- import type { SessionReceipt } from '../session/Types.js'
11
+ import type { SessionCredentialPayload, SessionReceipt } from '../session/Types.js'
12
+ import * as Ws from '../session/Ws.js'
11
13
  import type { ChannelEntry } from './ChannelOps.js'
12
14
  import { session as sessionPlugin } from './Session.js'
13
15
 
16
+ type WebSocketConstructor = {
17
+ new (url: string | URL, protocols?: string | string[]): WebSocket
18
+ }
19
+
20
+ type ReceiptWaiter = {
21
+ predicate: (receipt: SessionReceipt) => boolean
22
+ reject(error: Error): void
23
+ resolve(receipt: SessionReceipt): void
24
+ }
25
+
26
+ type CloseReadyWaiter = {
27
+ reject(error: Error): void
28
+ resolve(receipt: SessionReceipt): void
29
+ }
30
+
31
+ const WebSocketReadyState = {
32
+ CONNECTING: 0,
33
+ OPEN: 1,
34
+ CLOSING: 2,
35
+ CLOSED: 3,
36
+ } as const
37
+
38
+ // Browser-style WebSocket clients may only initiate close with 1000 or 3000-4999.
39
+ // Keep protocol/policy close codes on the server side and use an app-defined code here.
40
+ const ClientWebSocketProtocolErrorCloseCode = 3008
41
+
14
42
  export type SessionManager = {
15
43
  readonly channelId: Hex.Hex | undefined
16
44
  readonly cumulative: bigint
@@ -25,6 +53,14 @@ export type SessionManager = {
25
53
  signal?: AbortSignal | undefined
26
54
  },
27
55
  ): Promise<AsyncIterable<string>>
56
+ ws(
57
+ input: string | URL,
58
+ init?: {
59
+ onReceipt?: ((receipt: SessionReceipt) => void) | undefined
60
+ protocols?: string | string[] | undefined
61
+ signal?: AbortSignal | undefined
62
+ },
63
+ ): Promise<WebSocket>
28
64
  close(): Promise<SessionReceipt | undefined>
29
65
  }
30
66
 
@@ -56,11 +92,27 @@ export type PaymentResponse = Response & {
56
92
  */
57
93
  export function sessionManager(parameters: sessionManager.Parameters): SessionManager {
58
94
  const fetchFn = parameters.fetch ?? globalThis.fetch
95
+ const WebSocketImpl =
96
+ parameters.webSocket ??
97
+ (globalThis as typeof globalThis & { WebSocket?: WebSocketConstructor }).WebSocket
98
+ const maxVoucherCumulative =
99
+ parameters.maxDeposit !== undefined
100
+ ? parseUnits(parameters.maxDeposit, parameters.decimals ?? 6)
101
+ : null
59
102
 
60
103
  let channel: ChannelEntry | null = null
61
104
  let lastChallenge: Challenge.Challenge | null = null
62
105
  let lastUrl: RequestInfo | URL | null = null
63
106
  let spent = 0n
107
+ let activeSocketChallenge: Challenge.Challenge | null = null
108
+ let activeSocketChannelId: Hex.Hex | null = null
109
+ let activeSocket: WebSocket | null = null
110
+ let closeReadyReceipt: SessionReceipt | null = null
111
+ let closeReadyWaiter: CloseReadyWaiter | null = null
112
+ let expectedSocketCloseAmount: string | null = null
113
+ let receiptWaiter: ReceiptWaiter | null = null
114
+ let wsDeliveredChunks = 0n
115
+ let wsTickCost = 0n
64
116
 
65
117
  const method = sessionPlugin({
66
118
  account: parameters.account,
@@ -90,6 +142,86 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
90
142
  spent = spent > next ? spent : next
91
143
  }
92
144
 
145
+ function waitForReceipt(predicate: (receipt: SessionReceipt) => boolean = () => true) {
146
+ if (receiptWaiter) throw new Error('receipt wait already in progress')
147
+ return new Promise<SessionReceipt>((resolve, reject) => {
148
+ receiptWaiter = { predicate, resolve, reject }
149
+ })
150
+ }
151
+
152
+ function waitForCloseReady() {
153
+ if (closeReadyReceipt) return Promise.resolve(closeReadyReceipt)
154
+ if (closeReadyWaiter) throw new Error('close-ready wait already in progress')
155
+ return new Promise<SessionReceipt>((resolve, reject) => {
156
+ closeReadyWaiter = { resolve, reject }
157
+ })
158
+ }
159
+
160
+ function settleReceipt(receipt: SessionReceipt) {
161
+ if (!receiptWaiter) return
162
+ if (!receiptWaiter.predicate(receipt)) return
163
+ const waiter = receiptWaiter
164
+ receiptWaiter = null
165
+ waiter.resolve(receipt)
166
+ }
167
+
168
+ function settleCloseReady(receipt: SessionReceipt) {
169
+ closeReadyReceipt = receipt
170
+ if (!closeReadyWaiter) return
171
+ const waiter = closeReadyWaiter
172
+ closeReadyWaiter = null
173
+ waiter.resolve(receipt)
174
+ }
175
+
176
+ function rejectReceipt(error: Error) {
177
+ if (!receiptWaiter) return
178
+ const waiter = receiptWaiter
179
+ receiptWaiter = null
180
+ waiter.reject(error)
181
+ }
182
+
183
+ function rejectCloseReady(error: Error) {
184
+ if (!closeReadyWaiter) return
185
+ const waiter = closeReadyWaiter
186
+ closeReadyWaiter = null
187
+ waiter.reject(error)
188
+ }
189
+
190
+ function getFallbackCloseAmount(challenge: Challenge.Challenge, channelId: Hex.Hex): string {
191
+ if (
192
+ closeReadyReceipt &&
193
+ closeReadyReceipt.challengeId === challenge.id &&
194
+ closeReadyReceipt.channelId === channelId
195
+ ) {
196
+ return closeReadyReceipt.spent
197
+ }
198
+
199
+ const cumulative = channel?.channelId === channelId ? channel.cumulativeAmount : 0n
200
+
201
+ // For WS sessions, use delivered chunk count × tick cost as a tight spend
202
+ // estimate. Without this, a socket death before close-ready would cause
203
+ // the client to sign for the full cumulative voucher authorization —
204
+ // potentially orders of magnitude more than what was actually consumed.
205
+ // The estimate may undercount by at most 1 chunk (if the server committed
206
+ // a charge but the socket died before delivering the message).
207
+ if (wsTickCost > 0n) {
208
+ const deliveryEstimate = wsDeliveredChunks * wsTickCost
209
+ const bestSpent = spent > deliveryEstimate ? spent : deliveryEstimate
210
+ return (bestSpent > cumulative ? cumulative : bestSpent).toString()
211
+ }
212
+
213
+ // SSE/HTTP: spent is kept in sync by inline receipts, use it directly.
214
+ return spent.toString()
215
+ }
216
+
217
+ function assertVoucherWithinLocalLimit(cumulativeAmount: bigint) {
218
+ if (maxVoucherCumulative === null) return
219
+ if (cumulativeAmount <= maxVoucherCumulative) return
220
+ throw new Error(
221
+ `requested voucher amount ${cumulativeAmount} exceeds local maxDeposit ${maxVoucherCumulative}`,
222
+ )
223
+ }
224
+
93
225
  function toPaymentResponse(response: Response): PaymentResponse {
94
226
  const receiptHeader = response.headers.get('Payment-Receipt')
95
227
  const receipt = receiptHeader ? deserializeSessionReceipt(receiptHeader) : null
@@ -108,6 +240,130 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
108
240
  return toPaymentResponse(response)
109
241
  }
110
242
 
243
+ function createManagedSocket(socket: WebSocket) {
244
+ type EventType = 'close' | 'error' | 'message' | 'open'
245
+ type MessageEvent = { data: string; type: 'message' }
246
+ type Listener = {
247
+ once: boolean
248
+ value: ((event: any) => void) | { handleEvent(event: any): void }
249
+ }
250
+ const listeners = new Map<EventType, Set<Listener>>()
251
+ let emittedClose = false
252
+ let messageBuffer: MessageEvent[] | null = []
253
+ let readyState = socket.readyState
254
+
255
+ const add = (
256
+ type: EventType,
257
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
258
+ options?: boolean | AddEventListenerOptions,
259
+ ) => {
260
+ let set = listeners.get(type)
261
+ if (!set) {
262
+ set = new Set()
263
+ listeners.set(type, set)
264
+ }
265
+ set.add({
266
+ once: typeof options === 'object' ? options.once === true : false,
267
+ value: listener,
268
+ })
269
+ if (type === 'message' && messageBuffer) {
270
+ const buffered = messageBuffer
271
+ messageBuffer = null
272
+ for (const event of buffered) emit('message', event)
273
+ }
274
+ }
275
+
276
+ const remove = (
277
+ type: EventType,
278
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
279
+ ) => {
280
+ const set = listeners.get(type)
281
+ if (!set) return
282
+ for (const entry of set) {
283
+ if (entry.value === listener) set.delete(entry)
284
+ }
285
+ }
286
+
287
+ const emit = (type: EventType, event: any) => {
288
+ if (type === 'close') {
289
+ if (emittedClose) return
290
+ emittedClose = true
291
+ readyState = WebSocketReadyState.CLOSED
292
+ messageBuffer = null
293
+ }
294
+ if (type === 'open') readyState = WebSocketReadyState.OPEN
295
+
296
+ if (type === 'message' && messageBuffer) {
297
+ messageBuffer.push(event)
298
+ return
299
+ }
300
+
301
+ const property = `on${type}` as const
302
+ const handler = (managed as Record<string, unknown>)[property]
303
+ if (typeof handler === 'function') handler(event)
304
+
305
+ const set = listeners.get(type)
306
+ if (!set) return
307
+ for (const entry of Array.from(set)) {
308
+ if (typeof entry.value === 'function') entry.value(event)
309
+ else entry.value.handleEvent(event)
310
+ if (entry.once) set.delete(entry)
311
+ }
312
+ }
313
+
314
+ const managed = {
315
+ addEventListener: add,
316
+ close(code?: number, reason?: string) {
317
+ socket.close(code, reason)
318
+ },
319
+ get bufferedAmount() {
320
+ return socket.bufferedAmount
321
+ },
322
+ get extensions() {
323
+ return socket.extensions
324
+ },
325
+ on(type: EventType, listener: (...args: any[]) => void) {
326
+ add(type, listener)
327
+ },
328
+ onclose: null as ((event: any) => void) | null,
329
+ onerror: null as ((event: any) => void) | null,
330
+ _onmessage: null as ((event: any) => void) | null,
331
+ get onmessage() {
332
+ return managed._onmessage
333
+ },
334
+ set onmessage(fn: ((event: any) => void) | null) {
335
+ managed._onmessage = fn
336
+ if (fn && messageBuffer) {
337
+ const buffered = messageBuffer
338
+ messageBuffer = null
339
+ for (const event of buffered) emit('message', event)
340
+ }
341
+ },
342
+ onopen: null as ((event: any) => void) | null,
343
+ off(type: EventType, listener: (...args: any[]) => void) {
344
+ remove(type, listener)
345
+ },
346
+ get protocol() {
347
+ return socket.protocol
348
+ },
349
+ get readyState() {
350
+ return readyState
351
+ },
352
+ removeEventListener: remove,
353
+ send(data: string) {
354
+ socket.send(data)
355
+ },
356
+ get url() {
357
+ return socket.url
358
+ },
359
+ }
360
+
361
+ return {
362
+ emit,
363
+ socket: managed as unknown as WebSocket,
364
+ }
365
+ }
366
+
111
367
  const self: SessionManager = {
112
368
  get channelId() {
113
369
  return channel?.channelId
@@ -204,6 +460,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
204
460
  case 'payment-need-voucher': {
205
461
  if (!channel || !sseChallenge) break
206
462
  const required = BigInt(event.data.requiredCumulative)
463
+ assertVoucherWithinLocalLimit(required)
207
464
  channel.cumulativeAmount =
208
465
  channel.cumulativeAmount > required ? channel.cumulativeAmount : required
209
466
 
@@ -240,15 +497,259 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
240
497
  return iterate()
241
498
  },
242
499
 
500
+ async ws(input, init) {
501
+ if (!WebSocketImpl) {
502
+ throw new Error(
503
+ 'No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.',
504
+ )
505
+ }
506
+
507
+ const { onReceipt, protocols, signal } = init ?? {}
508
+ const wsUrl = new URL(input.toString())
509
+ const httpUrl = new URL(wsUrl.toString())
510
+ if (httpUrl.protocol === 'ws:') httpUrl.protocol = 'http:'
511
+ if (httpUrl.protocol === 'wss:') httpUrl.protocol = 'https:'
512
+
513
+ lastUrl = httpUrl.toString()
514
+ const probe = await fetchFn(httpUrl, signal ? { signal } : undefined)
515
+ if (probe.status !== 402) {
516
+ throw new Error(
517
+ `Expected a 402 payment challenge from ${httpUrl}, received ${probe.status} instead.`,
518
+ )
519
+ }
520
+
521
+ const challenge = Challenge.fromResponseList(probe).find(
522
+ (item) => item.method === method.name && item.intent === method.intent,
523
+ )
524
+ if (!challenge) {
525
+ throw new Error(
526
+ 'No payment challenge received from HTTP endpoint for this WebSocket URL. The server may not require payment or did not advertise a challenge.',
527
+ )
528
+ }
529
+ lastChallenge = challenge
530
+
531
+ const credential = await method.createCredential({
532
+ challenge: challenge as never,
533
+ context: {},
534
+ })
535
+
536
+ closeReadyReceipt = null
537
+ activeSocketChallenge = challenge
538
+ wsDeliveredChunks = 0n
539
+ wsTickCost = BigInt(challenge.request.amount as string)
540
+ const openCredential = PaymentCredential.deserialize<SessionCredentialPayload>(credential)
541
+ activeSocketChannelId = openCredential.payload.channelId
542
+ const rawSocket = new WebSocketImpl(wsUrl, protocols)
543
+ activeSocket = rawSocket
544
+ const managedSocket = createManagedSocket(rawSocket)
545
+
546
+ const failSocketFlow = (message: string) => {
547
+ rejectReceipt(new Error(message))
548
+ rejectCloseReady(new Error(message))
549
+ if (
550
+ rawSocket.readyState === WebSocketReadyState.CONNECTING ||
551
+ rawSocket.readyState === WebSocketReadyState.OPEN
552
+ ) {
553
+ rawSocket.close(ClientWebSocketProtocolErrorCloseCode, message)
554
+ }
555
+ }
556
+
557
+ const isExpectedReceipt = (receipt: SessionReceipt) =>
558
+ receipt.challengeId === challenge.id && receipt.channelId === activeSocketChannelId
559
+
560
+ const socketOpened = new Promise<void>((resolve, reject) => {
561
+ const onOpen = () => {
562
+ rawSocket.removeEventListener('error', onError)
563
+ managedSocket.emit('open', { type: 'open' })
564
+ resolve()
565
+ }
566
+ const onError = () => {
567
+ rawSocket.removeEventListener('open', onOpen)
568
+ reject(new Error(`WebSocket connection to ${wsUrl} failed to open.`))
569
+ }
570
+ rawSocket.addEventListener('open', onOpen, { once: true })
571
+ rawSocket.addEventListener('error', onError, { once: true })
572
+ })
573
+
574
+ rawSocket.addEventListener('close', (event) => {
575
+ if (activeSocket === rawSocket) activeSocket = null
576
+ if (activeSocketChallenge === challenge) activeSocketChallenge = null
577
+ if (activeSocketChannelId === openCredential.payload.channelId) activeSocketChannelId = null
578
+ expectedSocketCloseAmount = null
579
+ rejectReceipt(new Error('WebSocket closed before the payment flow completed.'))
580
+ rejectCloseReady(new Error('WebSocket closed before the payment flow completed.'))
581
+ managedSocket.emit('close', {
582
+ code: (event as CloseEvent).code ?? 1000,
583
+ reason: (event as CloseEvent).reason ?? '',
584
+ type: 'close',
585
+ wasClean: true,
586
+ })
587
+ })
588
+
589
+ rawSocket.addEventListener('error', () => {
590
+ managedSocket.emit('error', { type: 'error' })
591
+ })
592
+
593
+ rawSocket.addEventListener('message', async (event) => {
594
+ const raw = typeof event.data === 'string' ? event.data : undefined
595
+ if (!raw) return
596
+
597
+ const message = Ws.parseMessage(raw)
598
+ if (!message) {
599
+ managedSocket.emit('message', { data: raw, type: 'message' })
600
+ return
601
+ }
602
+
603
+ switch (message.mpp) {
604
+ case 'authorization':
605
+ break
606
+ case 'message':
607
+ wsDeliveredChunks += 1n
608
+ managedSocket.emit('message', { data: message.data, type: 'message' })
609
+ break
610
+ case 'payment-close-ready':
611
+ if (!isExpectedReceipt(message.data)) {
612
+ failSocketFlow('received mismatched payment-close-ready frame')
613
+ break
614
+ }
615
+ if (BigInt(message.data.spent) > (channel?.cumulativeAmount ?? 0n)) {
616
+ failSocketFlow('received payment-close-ready beyond local voucher state')
617
+ break
618
+ }
619
+ updateSpentFromReceipt(message.data)
620
+ onReceipt?.(message.data)
621
+ settleCloseReady(message.data)
622
+ managedSocket.emit('close', { code: 1000, reason: 'stream complete', type: 'close' })
623
+ break
624
+ case 'payment-error':
625
+ rejectReceipt(new Error(message.message))
626
+ rejectCloseReady(new Error(message.message))
627
+ break
628
+ case 'payment-need-voucher': {
629
+ if (message.data.channelId !== activeSocketChannelId) {
630
+ failSocketFlow('received mismatched payment-need-voucher frame')
631
+ break
632
+ }
633
+ const required = BigInt(message.data.requiredCumulative)
634
+ try {
635
+ assertVoucherWithinLocalLimit(required)
636
+ } catch (error) {
637
+ failSocketFlow(
638
+ error instanceof Error
639
+ ? error.message
640
+ : 'requested voucher amount exceeds local maxDeposit',
641
+ )
642
+ break
643
+ }
644
+ const nextCumulative =
645
+ (channel?.cumulativeAmount ?? 0n) > required
646
+ ? (channel?.cumulativeAmount ?? 0n)
647
+ : required
648
+ if (channel?.channelId === activeSocketChannelId)
649
+ channel.cumulativeAmount = nextCumulative
650
+
651
+ const voucher = await method.createCredential({
652
+ challenge: challenge as never,
653
+ context: {
654
+ action: 'voucher',
655
+ channelId: activeSocketChannelId,
656
+ cumulativeAmountRaw: nextCumulative.toString(),
657
+ },
658
+ })
659
+ rawSocket.send(Ws.formatAuthorizationMessage(voucher))
660
+ break
661
+ }
662
+ case 'payment-receipt':
663
+ if (!isExpectedReceipt(message.data)) {
664
+ failSocketFlow('received mismatched payment-receipt frame')
665
+ break
666
+ }
667
+ if (
668
+ expectedSocketCloseAmount !== null &&
669
+ Boolean(message.data.txHash) &&
670
+ (message.data.acceptedCumulative !== expectedSocketCloseAmount ||
671
+ message.data.spent !== expectedSocketCloseAmount)
672
+ ) {
673
+ failSocketFlow('received mismatched payment-close receipt frame')
674
+ break
675
+ }
676
+ updateSpentFromReceipt(message.data)
677
+ onReceipt?.(message.data)
678
+ settleReceipt(message.data)
679
+ break
680
+ }
681
+ })
682
+
683
+ if (signal) {
684
+ signal.addEventListener(
685
+ 'abort',
686
+ () => {
687
+ rejectReceipt(new Error('WebSocket payment flow aborted.'))
688
+ rejectCloseReady(new Error('WebSocket payment flow aborted.'))
689
+ rawSocket.close()
690
+ },
691
+ { once: true },
692
+ )
693
+ }
694
+
695
+ await socketOpened
696
+ rawSocket.send(Ws.formatAuthorizationMessage(credential))
697
+ await waitForReceipt()
698
+ return managedSocket.socket
699
+ },
700
+
243
701
  async close() {
244
- if (!channel?.opened || !lastChallenge) return undefined
702
+ const closeChallenge = activeSocketChallenge ?? lastChallenge
703
+ const closeChannelId = activeSocketChannelId ?? channel?.channelId
704
+ if (!channel?.opened || !closeChallenge || !closeChannelId) return undefined
705
+ if (activeSocket?.readyState === WebSocketReadyState.OPEN) {
706
+ const ready =
707
+ closeReadyReceipt ??
708
+ (await (async () => {
709
+ activeSocket.send(Ws.formatCloseRequestMessage())
710
+ return waitForCloseReady()
711
+ })())
712
+ const readySpent = BigInt(ready.spent)
713
+ if (readySpent > (channel.cumulativeAmount > spent ? channel.cumulativeAmount : spent)) {
714
+ throw new Error('close-ready spent exceeds local voucher state')
715
+ }
716
+
717
+ const credential = await method.createCredential({
718
+ challenge: closeChallenge as never,
719
+ context: {
720
+ action: 'close',
721
+ channelId: closeChannelId,
722
+ cumulativeAmountRaw: readySpent.toString(),
723
+ },
724
+ })
725
+
726
+ const expectedCloseAmount = readySpent.toString()
727
+ expectedSocketCloseAmount = expectedCloseAmount
728
+ try {
729
+ const pendingReceipt = waitForReceipt(
730
+ (receipt) =>
731
+ Boolean(receipt.txHash) &&
732
+ receipt.challengeId === closeChallenge.id &&
733
+ receipt.channelId === closeChannelId &&
734
+ receipt.acceptedCumulative === expectedCloseAmount &&
735
+ receipt.spent === expectedCloseAmount,
736
+ )
737
+ activeSocket.send(Ws.formatAuthorizationMessage(credential))
738
+ const receipt = await pendingReceipt
739
+ activeSocket.close()
740
+ closeReadyReceipt = null
741
+ return receipt
742
+ } finally {
743
+ expectedSocketCloseAmount = null
744
+ }
745
+ }
245
746
 
246
747
  const credential = await method.createCredential({
247
- challenge: lastChallenge as never,
748
+ challenge: closeChallenge as never,
248
749
  context: {
249
750
  action: 'close',
250
- channelId: channel.channelId,
251
- cumulativeAmountRaw: spent.toString(),
751
+ channelId: closeChannelId,
752
+ cumulativeAmountRaw: getFallbackCloseAmount(closeChallenge, closeChannelId),
252
753
  },
253
754
  })
254
755
 
@@ -283,5 +784,7 @@ export declare namespace sessionManager {
283
784
  fetch?: typeof globalThis.fetch | undefined
284
785
  /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */
285
786
  maxDeposit?: string | undefined
787
+ /** Optional websocket constructor for runtimes without a global WebSocket. */
788
+ webSocket?: WebSocketConstructor | undefined
286
789
  }
287
790
  }