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
@@ -0,0 +1,563 @@
1
+ import * as Credential from '../../Credential.js'
2
+ import * as ChannelStore from './ChannelStore.js'
3
+ import { deserializeSessionReceipt } from './Receipt.js'
4
+ import { createSessionReceipt } from './Receipt.js'
5
+ import type { SessionController } from './Sse.js'
6
+ import type { NeedVoucherEvent, SessionCredentialPayload, SessionReceipt } from './Types.js'
7
+
8
+ export type { SessionController } from './Sse.js'
9
+
10
+ export type SessionRouteResult =
11
+ | { status: 402; challenge: Response }
12
+ | { status: 200; withReceipt(response?: Response): Response }
13
+
14
+ export type SessionRoute = (request: Request) => Promise<SessionRouteResult>
15
+
16
+ export type Socket = {
17
+ close(code?: number, reason?: string): unknown
18
+ send(data: string): unknown
19
+ addEventListener?: (
20
+ type: 'close' | 'error' | 'message',
21
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
22
+ ) => unknown
23
+ removeEventListener?: (
24
+ type: 'close' | 'error' | 'message',
25
+ listener: ((event: any) => void) | { handleEvent(event: any): void },
26
+ ) => unknown
27
+ on?: (type: 'close' | 'error' | 'message', listener: (...args: any[]) => void) => unknown
28
+ off?: (type: 'close' | 'error' | 'message', listener: (...args: any[]) => void) => unknown
29
+ }
30
+
31
+ export type Message =
32
+ | { mpp: 'authorization'; authorization: string }
33
+ | { mpp: 'message'; data: string }
34
+ | { mpp: 'payment-close-request' }
35
+ | { mpp: 'payment-close-ready'; data: SessionReceipt }
36
+ | { mpp: 'payment-error'; status: number; message: string }
37
+ | { mpp: 'payment-need-voucher'; data: NeedVoucherEvent }
38
+ | { mpp: 'payment-receipt'; data: SessionReceipt }
39
+
40
+ export function formatAuthorizationMessage(authorization: string): string {
41
+ return JSON.stringify({ mpp: 'authorization', authorization } satisfies Message)
42
+ }
43
+
44
+ export function formatApplicationMessage(data: string): string {
45
+ return JSON.stringify({ mpp: 'message', data } satisfies Message)
46
+ }
47
+
48
+ export function formatCloseRequestMessage(): string {
49
+ return JSON.stringify({ mpp: 'payment-close-request' } satisfies Message)
50
+ }
51
+
52
+ export function formatCloseReadyMessage(receipt: SessionReceipt): string {
53
+ return JSON.stringify({ mpp: 'payment-close-ready', data: receipt } satisfies Message)
54
+ }
55
+
56
+ export function formatNeedVoucherMessage(params: NeedVoucherEvent): string {
57
+ return JSON.stringify({ mpp: 'payment-need-voucher', data: params } satisfies Message)
58
+ }
59
+
60
+ export function formatReceiptMessage(receipt: SessionReceipt): string {
61
+ return JSON.stringify({ mpp: 'payment-receipt', data: receipt } satisfies Message)
62
+ }
63
+
64
+ export function formatErrorMessage(parameters: { message: string; status: number }): string {
65
+ return JSON.stringify({ mpp: 'payment-error', ...parameters } satisfies Message)
66
+ }
67
+
68
+ export function parseMessage(raw: string): Message | null {
69
+ try {
70
+ const parsed = JSON.parse(raw) as Record<string, unknown>
71
+ if (parsed.mpp === 'authorization' && typeof parsed.authorization === 'string') {
72
+ return { mpp: 'authorization', authorization: parsed.authorization }
73
+ }
74
+ if (parsed.mpp === 'message' && typeof parsed.data === 'string') {
75
+ return { mpp: 'message', data: parsed.data }
76
+ }
77
+ if (parsed.mpp === 'payment-close-request') {
78
+ return { mpp: 'payment-close-request' }
79
+ }
80
+ if (parsed.mpp === 'payment-close-ready' && isSessionReceipt(parsed.data)) {
81
+ return { mpp: 'payment-close-ready', data: parsed.data }
82
+ }
83
+ if (
84
+ parsed.mpp === 'payment-error' &&
85
+ typeof parsed.status === 'number' &&
86
+ typeof parsed.message === 'string'
87
+ ) {
88
+ return { mpp: 'payment-error', status: parsed.status, message: parsed.message }
89
+ }
90
+ if (parsed.mpp === 'payment-need-voucher' && isNeedVoucherEvent(parsed.data)) {
91
+ return { mpp: 'payment-need-voucher', data: parsed.data }
92
+ }
93
+ if (parsed.mpp === 'payment-receipt' && isSessionReceipt(parsed.data)) {
94
+ return { mpp: 'payment-receipt', data: parsed.data }
95
+ }
96
+ return null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Bridge a WebSocket connection to a Tempo session payment flow.
104
+ *
105
+ * Credential verification is performed by routing each in-band authorization
106
+ * frame through `route` as a **synthetic `POST` request** that carries only
107
+ * the `Authorization` header. The synthetic request does not include cookies,
108
+ * bodies, query parameters, or other headers from the original WebSocket
109
+ * upgrade request. Do not wrap `route` with middleware that depends on
110
+ * HTTP-specific context beyond the `Authorization` header.
111
+ */
112
+ export async function serve(options: serve.Options): Promise<void> {
113
+ const {
114
+ amount: expectedAmount,
115
+ generate,
116
+ pollIntervalMs = 100,
117
+ route,
118
+ socket,
119
+ store: rawStore,
120
+ url,
121
+ } = options
122
+ const store = 'getChannel' in rawStore ? rawStore : ChannelStore.fromStore(rawStore)
123
+ const requestUrl = normalizeHttpUrl(url)
124
+ const maxQueuedPaymentMessages = 32
125
+
126
+ const abortController = new AbortController()
127
+ let closed = false
128
+ let closeReadySent = false
129
+ let closeRequestHandled = false
130
+ let closeRequested = false
131
+ let streamStarted = false
132
+ let streamTask: Promise<void> | null = null
133
+ let streamContext: {
134
+ challengeId: string
135
+ channelId: SessionCredentialPayload['channelId']
136
+ tickCost: bigint
137
+ } | null = null
138
+ let action = Promise.resolve()
139
+ let queuedActions = 0
140
+
141
+ const close = async (code = 1000, reason?: string) => {
142
+ if (closed) return
143
+ closed = true
144
+ abortController.abort()
145
+ unsubscribe()
146
+ await Promise.resolve(socket.close(code, reason))
147
+ }
148
+
149
+ const sendCloseReady = async () => {
150
+ if (closeReadySent || !streamContext || closed) return
151
+ closeReadySent = true
152
+
153
+ const channel = await store.getChannel(streamContext.channelId)
154
+ if (!channel) throw new Error('channel not found')
155
+
156
+ const receipt = createSessionReceipt({
157
+ challengeId: streamContext.challengeId,
158
+ channelId: streamContext.channelId,
159
+ acceptedCumulative: channel.highestVoucherAmount,
160
+ spent: channel.spent,
161
+ units: channel.units,
162
+ })
163
+ await send(socket, formatCloseReadyMessage(receipt))
164
+ }
165
+
166
+ const runStream = async (context: {
167
+ challengeId: string
168
+ channelId: SessionCredentialPayload['channelId']
169
+ tickCost: bigint
170
+ }) => {
171
+ let reservedAmount = 0n
172
+ let reservedUnits = 0
173
+
174
+ const charge = () =>
175
+ reserveChargeOrWait({
176
+ amount: context.tickCost,
177
+ channelId: context.channelId,
178
+ reservedAmount,
179
+ emit: (message) => send(socket, message),
180
+ pollIntervalMs,
181
+ signal: abortController.signal,
182
+ store,
183
+ }).then(() => {
184
+ reservedAmount += context.tickCost
185
+ reservedUnits += 1
186
+ })
187
+
188
+ const iterable: AsyncIterable<string> =
189
+ typeof generate === 'function' ? generate({ charge }) : generate
190
+
191
+ try {
192
+ for await (const value of iterable) {
193
+ if (abortController.signal.aborted) break
194
+ if (typeof generate !== 'function') await charge()
195
+ await commitReservedCharges({
196
+ store,
197
+ channelId: context.channelId,
198
+ amount: reservedAmount,
199
+ units: reservedUnits,
200
+ })
201
+ reservedAmount = 0n
202
+ reservedUnits = 0
203
+ await send(socket, formatApplicationMessage(value))
204
+ }
205
+
206
+ if (!abortController.signal.aborted) await sendCloseReady()
207
+ } catch (error) {
208
+ if (!abortController.signal.aborted) {
209
+ await send(
210
+ socket,
211
+ formatErrorMessage({
212
+ message: error instanceof Error ? error.message : 'websocket session failed',
213
+ status: 500,
214
+ }),
215
+ )
216
+ await close(1011, 'websocket session failed')
217
+ }
218
+ } finally {
219
+ streamTask = null
220
+ }
221
+ }
222
+
223
+ const requestClose = async () => {
224
+ if (closed) return
225
+ if (closeRequestHandled) return
226
+ closeRequestHandled = true
227
+ closeRequested = true
228
+ abortController.abort()
229
+ await streamTask?.catch(() => {})
230
+ await sendCloseReady()
231
+ }
232
+
233
+ const processAuthorization = async (authorization: string) => {
234
+ if (closed) return
235
+ const credential = Credential.deserialize<SessionCredentialPayload>(authorization)
236
+ const payload = credential.payload
237
+ if (payload.action === 'close') closeRequested = true
238
+
239
+ if (expectedAmount && credential.challenge.request.amount !== expectedAmount) {
240
+ await send(
241
+ socket,
242
+ formatErrorMessage({
243
+ message: 'credential amount does not match this endpoint',
244
+ status: 402,
245
+ }),
246
+ )
247
+ await close(1008, 'credential amount does not match this endpoint')
248
+ return
249
+ }
250
+
251
+ const result = await route(
252
+ new Request(requestUrl, {
253
+ method: 'POST',
254
+ headers: { Authorization: authorization },
255
+ }),
256
+ )
257
+
258
+ if (result.status === 402) {
259
+ const response = result.challenge
260
+ const message =
261
+ (await response.text().catch(() => '')) ||
262
+ response.statusText ||
263
+ 'payment verification failed'
264
+ await send(socket, formatErrorMessage({ message, status: response.status }))
265
+ await close(1008, message)
266
+ return
267
+ }
268
+
269
+ const response = result.withReceipt(new Response(null, { status: 204 }))
270
+ const receiptHeader = response.headers.get('Payment-Receipt')
271
+ if (!receiptHeader) {
272
+ throw new Error('management response missing Payment-Receipt header')
273
+ }
274
+
275
+ const receipt = deserializeSessionReceipt(receiptHeader)
276
+ await send(socket, formatReceiptMessage(receipt))
277
+
278
+ if (payload.action === 'close') {
279
+ await close(1000, 'payment session closed')
280
+ return
281
+ }
282
+
283
+ if (payload.action === 'topUp') return
284
+ if (streamStarted || closeRequested) return
285
+ streamStarted = true
286
+ streamContext = {
287
+ challengeId: credential.challenge.id,
288
+ channelId: payload.channelId,
289
+ tickCost: BigInt(credential.challenge.request.amount as string),
290
+ }
291
+ // Defer the first application frame until after the client receives the
292
+ // auth receipt and has a chance to install its own message listeners.
293
+ setTimeout(() => {
294
+ if (closeRequested || closed || !streamContext) return
295
+ streamTask = runStream(streamContext)
296
+ }, 0)
297
+ }
298
+
299
+ const onMessage = (payload: unknown) => {
300
+ if (closed) return
301
+ const raw = toText(payload)
302
+ if (!raw) return
303
+ const message = parseMessage(raw)
304
+ if (!message) return
305
+
306
+ if (message.mpp === 'payment-close-request') {
307
+ closeRequested = true
308
+ abortController.abort()
309
+ }
310
+
311
+ const work =
312
+ message.mpp === 'authorization'
313
+ ? () => processAuthorization(message.authorization)
314
+ : message.mpp === 'payment-close-request'
315
+ ? () => requestClose()
316
+ : null
317
+
318
+ if (!work) return
319
+ if (queuedActions >= maxQueuedPaymentMessages) {
320
+ void send(
321
+ socket,
322
+ formatErrorMessage({
323
+ message: 'too many queued payment messages',
324
+ status: 429,
325
+ }),
326
+ ).catch(() => {})
327
+ void close(1008, 'too many queued payment messages')
328
+ return
329
+ }
330
+
331
+ queuedActions++
332
+ action = action
333
+ .then(async () => {
334
+ try {
335
+ if (closed) return
336
+ await work()
337
+ } finally {
338
+ queuedActions--
339
+ }
340
+ })
341
+ .catch(async (error) => {
342
+ if (!closed) {
343
+ await send(
344
+ socket,
345
+ formatErrorMessage({
346
+ message: error instanceof Error ? error.message : 'invalid payment message',
347
+ status: 400,
348
+ }),
349
+ )
350
+ await close(1008, 'invalid payment message')
351
+ }
352
+ })
353
+ }
354
+
355
+ const onClose = () => {
356
+ if (closed) return
357
+ closed = true
358
+ abortController.abort()
359
+ unsubscribe()
360
+ }
361
+
362
+ const unsubscribe = subscribe(socket, {
363
+ close: onClose,
364
+ error: onClose,
365
+ message: onMessage,
366
+ })
367
+ }
368
+
369
+ export declare namespace serve {
370
+ type Options = {
371
+ /** Expected per-tick amount in raw units. When set, credentials whose
372
+ * challenge `request.amount` does not match are rejected. Use this to
373
+ * pin the price when the route is backed by `Mppx.compose()` with
374
+ * multiple offers — otherwise a client can select the cheapest offer
375
+ * and still receive the same stream. */
376
+ amount?: string | undefined
377
+ generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>)
378
+ pollIntervalMs?: number | undefined
379
+ /** Payment route handler. Receives synthetic `POST` requests with only
380
+ * the `Authorization` header — no cookies, bodies, or upgrade headers. */
381
+ route: SessionRoute
382
+ socket: Socket
383
+ store: ChannelStore.ChannelStore | import('../../Store.js').Store
384
+ url: string | URL
385
+ }
386
+ }
387
+
388
+ function normalizeHttpUrl(value: string | URL): string {
389
+ const url = new URL(value.toString())
390
+ if (url.protocol === 'ws:') url.protocol = 'http:'
391
+ if (url.protocol === 'wss:') url.protocol = 'https:'
392
+ return url.toString()
393
+ }
394
+
395
+ async function reserveChargeOrWait(options: {
396
+ amount: bigint
397
+ channelId: SessionCredentialPayload['channelId']
398
+ reservedAmount: bigint
399
+ emit: (message: string) => Promise<void>
400
+ pollIntervalMs: number
401
+ signal: AbortSignal
402
+ store: ChannelStore.ChannelStore
403
+ }): Promise<void> {
404
+ const { amount, channelId, emit, pollIntervalMs, reservedAmount, signal, store } = options
405
+
406
+ let channel = await store.getChannel(channelId)
407
+ if (!channel) throw new Error('channel not found')
408
+
409
+ const hasHeadroom = (state: ChannelStore.State) =>
410
+ state.highestVoucherAmount - state.spent - reservedAmount >= amount
411
+
412
+ if (hasHeadroom(channel)) return
413
+
414
+ await emit(
415
+ formatNeedVoucherMessage({
416
+ channelId,
417
+ requiredCumulative: (channel.spent + reservedAmount + amount).toString(),
418
+ acceptedCumulative: channel.highestVoucherAmount.toString(),
419
+ deposit: channel.deposit.toString(),
420
+ }),
421
+ )
422
+
423
+ while (!hasHeadroom(channel)) {
424
+ await waitForUpdate(store, channelId, pollIntervalMs, signal)
425
+ channel = await store.getChannel(channelId)
426
+ if (!channel) throw new Error('channel not found')
427
+ }
428
+ }
429
+
430
+ async function commitReservedCharges(options: {
431
+ amount: bigint
432
+ channelId: SessionCredentialPayload['channelId']
433
+ units: number
434
+ store: ChannelStore.ChannelStore
435
+ }): Promise<void> {
436
+ const { amount, channelId, units, store } = options
437
+ if (amount === 0n || units === 0) return
438
+
439
+ let committed = false
440
+ const channel = await store.updateChannel(channelId, (current) => {
441
+ if (!current) return null
442
+ if (current.finalized) return current
443
+ if (current.highestVoucherAmount - current.spent < amount) return current
444
+ committed = true
445
+ return {
446
+ ...current,
447
+ spent: current.spent + amount,
448
+ units: current.units + units,
449
+ }
450
+ })
451
+
452
+ if (!channel) throw new Error('channel not found')
453
+ if (!committed) throw new Error('reserved voucher coverage is no longer available')
454
+ }
455
+
456
+ async function waitForUpdate(
457
+ store: ChannelStore.ChannelStore,
458
+ channelId: SessionCredentialPayload['channelId'],
459
+ pollIntervalMs: number,
460
+ signal: AbortSignal,
461
+ ): Promise<void> {
462
+ throwIfAborted(signal)
463
+
464
+ if (store.waitForUpdate) {
465
+ await Promise.race([store.waitForUpdate(channelId), onceAborted(signal)])
466
+ return
467
+ }
468
+
469
+ await sleep(pollIntervalMs, signal)
470
+ }
471
+
472
+ function subscribe(
473
+ socket: Socket,
474
+ handlers: {
475
+ close: () => void
476
+ error: () => void
477
+ message: (payload: unknown) => void
478
+ },
479
+ ) {
480
+ if (socket.addEventListener && socket.removeEventListener) {
481
+ const onMessage = (event: Event | MessageEvent) => {
482
+ const data = (event as MessageEvent).data
483
+ handlers.message(data)
484
+ }
485
+ socket.addEventListener('message', onMessage)
486
+ socket.addEventListener('close', handlers.close)
487
+ socket.addEventListener('error', handlers.error)
488
+ return () => {
489
+ socket.removeEventListener?.('message', onMessage)
490
+ socket.removeEventListener?.('close', handlers.close)
491
+ socket.removeEventListener?.('error', handlers.error)
492
+ }
493
+ }
494
+
495
+ if (socket.on && socket.off) {
496
+ const onMessage = (data: unknown) => handlers.message(data)
497
+ socket.on('message', onMessage)
498
+ socket.on('close', handlers.close)
499
+ socket.on('error', handlers.error)
500
+ return () => {
501
+ socket.off?.('message', onMessage)
502
+ socket.off?.('close', handlers.close)
503
+ socket.off?.('error', handlers.error)
504
+ }
505
+ }
506
+
507
+ throw new Error('unsupported websocket implementation')
508
+ }
509
+
510
+ async function send(socket: Socket, data: string) {
511
+ await Promise.resolve(socket.send(data))
512
+ }
513
+
514
+ function toText(value: unknown): string | null {
515
+ if (typeof value === 'string') return value
516
+ if (value instanceof ArrayBuffer) return new TextDecoder().decode(value)
517
+ if (ArrayBuffer.isView(value)) {
518
+ return new TextDecoder().decode(value)
519
+ }
520
+ return null
521
+ }
522
+
523
+ function sleep(ms: number, signal: AbortSignal) {
524
+ return new Promise<void>((resolve, reject) => {
525
+ const timeout = setTimeout(() => {
526
+ signal.removeEventListener('abort', onAbort)
527
+ resolve()
528
+ }, ms)
529
+ const onAbort = () => {
530
+ clearTimeout(timeout)
531
+ reject(signal.reason ?? new Error('aborted'))
532
+ }
533
+ signal.addEventListener('abort', onAbort, { once: true })
534
+ })
535
+ }
536
+
537
+ function onceAborted(signal: AbortSignal) {
538
+ return new Promise<never>((_, reject) => {
539
+ if (signal.aborted) {
540
+ reject(signal.reason ?? new Error('aborted'))
541
+ return
542
+ }
543
+ signal.addEventListener('abort', () => reject(signal.reason ?? new Error('aborted')), {
544
+ once: true,
545
+ })
546
+ })
547
+ }
548
+
549
+ function throwIfAborted(signal: AbortSignal) {
550
+ if (signal.aborted) throw signal.reason ?? new Error('aborted')
551
+ }
552
+
553
+ function isSessionReceipt(value: unknown): value is SessionReceipt {
554
+ if (typeof value !== 'object' || value === null) return false
555
+ const v = value as Record<string, unknown>
556
+ return typeof v.challengeId === 'string' && typeof v.channelId === 'string'
557
+ }
558
+
559
+ function isNeedVoucherEvent(value: unknown): value is NeedVoucherEvent {
560
+ if (typeof value !== 'object' || value === null) return false
561
+ const v = value as Record<string, unknown>
562
+ return typeof v.channelId === 'string' && typeof v.requiredCumulative === 'string'
563
+ }
@@ -5,3 +5,4 @@ export * as Receipt from './Receipt.js'
5
5
  export * as Sse from './Sse.js'
6
6
  export * as Types from './Types.js'
7
7
  export * as Voucher from './Voucher.js'
8
+ export * as Ws from './Ws.js'