mppx 0.5.7 → 0.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/Challenge.d.ts +3 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +27 -9
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +32 -14
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js.map +1 -1
- package/dist/Store.d.ts +68 -2
- package/dist/Store.d.ts.map +1 -1
- package/dist/Store.js +41 -4
- package/dist/Store.js.map +1 -1
- package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
- package/dist/mcp-sdk/server/Transport.js +7 -0
- package/dist/mcp-sdk/server/Transport.js.map +1 -1
- package/dist/server/Mppx.d.ts +1 -1
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +133 -70
- package/dist/server/Mppx.js.map +1 -1
- package/dist/server/Transport.d.ts +8 -2
- package/dist/server/Transport.d.ts.map +1 -1
- package/dist/server/Transport.js +26 -1
- package/dist/server/Transport.js.map +1 -1
- package/dist/tempo/client/SessionManager.d.ts +13 -2
- package/dist/tempo/client/SessionManager.d.ts.map +1 -1
- package/dist/tempo/client/SessionManager.js +429 -4
- package/dist/tempo/client/SessionManager.js.map +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +28 -0
- package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
- package/dist/tempo/internal/fee-payer.js +89 -0
- package/dist/tempo/internal/fee-payer.js.map +1 -1
- package/dist/tempo/server/Charge.d.ts +4 -1
- package/dist/tempo/server/Charge.d.ts.map +1 -1
- package/dist/tempo/server/Charge.js +90 -66
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +3 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +3 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Session.d.ts +8 -2
- package/dist/tempo/server/Session.d.ts.map +1 -1
- package/dist/tempo/server/Session.js.map +1 -1
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/transport.d.ts.map +1 -1
- package/dist/tempo/server/internal/transport.js +16 -6
- package/dist/tempo/server/internal/transport.js.map +1 -1
- package/dist/tempo/session/ChannelStore.d.ts +12 -1
- package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
- package/dist/tempo/session/ChannelStore.js +55 -14
- package/dist/tempo/session/ChannelStore.js.map +1 -1
- package/dist/tempo/session/Sse.d.ts +11 -2
- package/dist/tempo/session/Sse.d.ts.map +1 -1
- package/dist/tempo/session/Sse.js +66 -25
- package/dist/tempo/session/Sse.js.map +1 -1
- package/dist/tempo/session/Ws.d.ts +87 -0
- package/dist/tempo/session/Ws.d.ts.map +1 -0
- package/dist/tempo/session/Ws.js +428 -0
- package/dist/tempo/session/Ws.js.map +1 -0
- package/dist/tempo/session/index.d.ts +1 -0
- package/dist/tempo/session/index.d.ts.map +1 -1
- package/dist/tempo/session/index.js +1 -0
- package/dist/tempo/session/index.js.map +1 -1
- package/package.json +1 -1
- package/src/Challenge.test.ts +1 -1
- package/src/Challenge.ts +28 -9
- package/src/Method.ts +61 -20
- package/src/Store.test-d.ts +80 -2
- package/src/Store.test.ts +150 -13
- package/src/Store.ts +140 -3
- package/src/mcp-sdk/server/Transport.test.ts +12 -0
- package/src/mcp-sdk/server/Transport.ts +8 -0
- package/src/server/Mppx.test.ts +105 -0
- package/src/server/Mppx.ts +178 -88
- package/src/server/Transport.test.ts +31 -0
- package/src/server/Transport.ts +31 -2
- package/src/tempo/client/SessionManager.ts +510 -7
- package/src/tempo/internal/fee-payer.test.ts +115 -1
- package/src/tempo/internal/fee-payer.ts +138 -1
- package/src/tempo/server/AtomicStore.test-d.ts +34 -0
- package/src/tempo/server/Charge.test.ts +128 -0
- package/src/tempo/server/Charge.ts +118 -93
- package/src/tempo/server/Methods.ts +3 -0
- package/src/tempo/server/Session.test.ts +1044 -47
- package/src/tempo/server/Session.ts +8 -2
- package/src/tempo/server/Sse.test.ts +29 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/main.ts +9 -10
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/transport.ts +19 -6
- package/src/tempo/session/ChannelStore.test.ts +20 -1
- package/src/tempo/session/ChannelStore.ts +77 -14
- package/src/tempo/session/Sse.ts +77 -24
- package/src/tempo/session/Ws.test.ts +410 -0
- package/src/tempo/session/Ws.ts +563 -0
- package/src/tempo/session/index.ts +1 -0
|
@@ -1,16 +1,44 @@
|
|
|
1
1
|
import type { Hex } from 'ox'
|
|
2
|
-
import type
|
|
2
|
+
import { parseUnits, type Address } from 'viem'
|
|
3
3
|
|
|
4
|
-
import
|
|
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
|
-
|
|
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:
|
|
748
|
+
challenge: closeChallenge as never,
|
|
248
749
|
context: {
|
|
249
750
|
action: 'close',
|
|
250
|
-
channelId:
|
|
251
|
-
cumulativeAmountRaw:
|
|
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
|
}
|