mppx 0.8.9 → 0.8.12

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 (124) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli/validate/challenge.d.ts +2 -0
  3. package/dist/cli/validate/challenge.d.ts.map +1 -1
  4. package/dist/cli/validate/challenge.js +21 -9
  5. package/dist/cli/validate/challenge.js.map +1 -1
  6. package/dist/cli/validate/discovery.d.ts.map +1 -1
  7. package/dist/cli/validate/discovery.js +50 -19
  8. package/dist/cli/validate/discovery.js.map +1 -1
  9. package/dist/cli/validate/helpers.d.ts +1 -0
  10. package/dist/cli/validate/helpers.d.ts.map +1 -1
  11. package/dist/cli/validate/helpers.js +11 -0
  12. package/dist/cli/validate/helpers.js.map +1 -1
  13. package/dist/cli/validate/index.d.ts +1 -0
  14. package/dist/cli/validate/index.d.ts.map +1 -1
  15. package/dist/cli/validate/index.js +4 -0
  16. package/dist/cli/validate/index.js.map +1 -1
  17. package/dist/cli/validate/payment.d.ts +1 -0
  18. package/dist/cli/validate/payment.d.ts.map +1 -1
  19. package/dist/cli/validate/payment.js +2 -2
  20. package/dist/cli/validate/payment.js.map +1 -1
  21. package/dist/client/internal/Fetch.d.ts.map +1 -1
  22. package/dist/client/internal/Fetch.js +35 -6
  23. package/dist/client/internal/Fetch.js.map +1 -1
  24. package/dist/client/internal/MethodResponse.d.ts +23 -0
  25. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  26. package/dist/client/internal/MethodResponse.js +15 -0
  27. package/dist/client/internal/MethodResponse.js.map +1 -0
  28. package/dist/tempo/internal/fee-payer.d.ts +39 -2
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +45 -1
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/internal/fee-token.d.ts +8 -0
  33. package/dist/tempo/internal/fee-token.d.ts.map +1 -1
  34. package/dist/tempo/internal/fee-token.js +19 -6
  35. package/dist/tempo/internal/fee-token.js.map +1 -1
  36. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  37. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  38. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  39. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  40. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  41. package/dist/tempo/legacy/client/Session.js +7 -0
  42. package/dist/tempo/legacy/client/Session.js.map +1 -1
  43. package/dist/tempo/legacy/session/Chain.d.ts.map +1 -1
  44. package/dist/tempo/legacy/session/Chain.js +53 -50
  45. package/dist/tempo/legacy/session/Chain.js.map +1 -1
  46. package/dist/tempo/server/Charge.d.ts +8 -0
  47. package/dist/tempo/server/Charge.d.ts.map +1 -1
  48. package/dist/tempo/server/Charge.js +49 -45
  49. package/dist/tempo/server/Charge.js.map +1 -1
  50. package/dist/tempo/server/Methods.d.ts +7 -0
  51. package/dist/tempo/server/Methods.d.ts.map +1 -1
  52. package/dist/tempo/server/Methods.js.map +1 -1
  53. package/dist/tempo/server/Subscription.d.ts.map +1 -1
  54. package/dist/tempo/server/Subscription.js +24 -16
  55. package/dist/tempo/server/Subscription.js.map +1 -1
  56. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  57. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  58. package/dist/tempo/server/internal/html.gen.js +1 -1
  59. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  60. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  61. package/dist/tempo/session/client/Session.js +53 -1
  62. package/dist/tempo/session/client/Session.js.map +1 -1
  63. package/dist/tempo/session/client/SessionManager.d.ts +3 -0
  64. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  65. package/dist/tempo/session/client/SessionManager.js +3 -0
  66. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  67. package/dist/tempo/session/client/Transports.d.ts +30 -0
  68. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  69. package/dist/tempo/session/client/Transports.js +118 -19
  70. package/dist/tempo/session/client/Transports.js.map +1 -1
  71. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  72. package/dist/tempo/session/precompile/Chain.js +25 -19
  73. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  74. package/dist/tempo/session/server/index.d.ts +2 -0
  75. package/dist/tempo/session/server/index.d.ts.map +1 -1
  76. package/dist/tempo/session/server/index.js +2 -0
  77. package/dist/tempo/session/server/index.js.map +1 -1
  78. package/dist/validation/core.d.ts +1 -0
  79. package/dist/validation/core.d.ts.map +1 -1
  80. package/dist/validation/core.js +5 -0
  81. package/dist/validation/core.js.map +1 -1
  82. package/dist/x402/Header.js +1 -1
  83. package/dist/x402/Header.js.map +1 -1
  84. package/package.json +1 -1
  85. package/src/cli/validate/challenge.ts +20 -8
  86. package/src/cli/validate/discovery.test.ts +128 -0
  87. package/src/cli/validate/discovery.ts +50 -15
  88. package/src/cli/validate/helpers.ts +10 -0
  89. package/src/cli/validate/index.ts +4 -0
  90. package/src/cli/validate/payment.ts +4 -1
  91. package/src/cli/validate.test.ts +23 -2
  92. package/src/client/Mppx.test-d.ts +3 -1
  93. package/src/client/internal/Fetch.test.ts +29 -0
  94. package/src/client/internal/Fetch.ts +51 -6
  95. package/src/client/internal/MethodResponse.ts +39 -0
  96. package/src/tempo/PublicExports.test-d.ts +5 -0
  97. package/src/tempo/internal/fee-payer.test.ts +93 -0
  98. package/src/tempo/internal/fee-payer.ts +64 -2
  99. package/src/tempo/internal/fee-token.test.ts +37 -0
  100. package/src/tempo/internal/fee-token.ts +26 -5
  101. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  102. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  103. package/src/tempo/legacy/client/Session.test.ts +102 -1
  104. package/src/tempo/legacy/client/Session.ts +10 -0
  105. package/src/tempo/legacy/session/Chain.test.ts +78 -2
  106. package/src/tempo/legacy/session/Chain.ts +56 -52
  107. package/src/tempo/server/Charge.test.ts +121 -6
  108. package/src/tempo/server/Charge.ts +61 -48
  109. package/src/tempo/server/Methods.ts +6 -0
  110. package/src/tempo/server/Subscription.test.ts +79 -4
  111. package/src/tempo/server/Subscription.ts +24 -16
  112. package/src/tempo/server/internal/html.gen.ts +1 -1
  113. package/src/tempo/session/client/Session.test.ts +83 -1
  114. package/src/tempo/session/client/Session.ts +73 -1
  115. package/src/tempo/session/client/SessionManager.test.ts +12 -1
  116. package/src/tempo/session/client/SessionManager.ts +5 -0
  117. package/src/tempo/session/client/Transports.test.ts +109 -0
  118. package/src/tempo/session/client/Transports.ts +153 -29
  119. package/src/tempo/session/precompile/Chain.test.ts +9 -0
  120. package/src/tempo/session/precompile/Chain.ts +28 -20
  121. package/src/tempo/session/server/Session.test.ts +23 -3
  122. package/src/tempo/session/server/index.ts +2 -0
  123. package/src/validation/core.ts +6 -0
  124. package/src/x402/Header.ts +1 -1
@@ -3,7 +3,8 @@ import { privateKeyToAccount } from 'viem/accounts'
3
3
  import { Account as TempoAccount, Secp256k1, Transaction } from 'viem/tempo'
4
4
  import { describe, expect, test } from 'vp/test'
5
5
 
6
- import type { Challenge } from '../../../Challenge.js'
6
+ import { serialize as serializeChallenge, type Challenge } from '../../../Challenge.js'
7
+ import * as Fetch from '../../../client/internal/Fetch.js'
7
8
  import * as Constants from '../../../Constants.js'
8
9
  import * as Credential from '../../../Credential.js'
9
10
  import * as z from '../../../zod.js'
@@ -13,6 +14,7 @@ import { escrowAbi } from '../precompile/escrow.abi.js'
13
14
  import { tip20ChannelEscrow } from '../precompile/Protocol.js'
14
15
  import * as Types from '../precompile/Protocol.js'
15
16
  import * as Voucher from '../precompile/Voucher.js'
17
+ import { createChannelStore } from './ChannelStore.js'
16
18
  import { session } from './Session.js'
17
19
 
18
20
  const account = privateKeyToAccount(
@@ -126,6 +128,86 @@ describe('precompile client session', () => {
126
128
  ).toBe(false)
127
129
  })
128
130
 
131
+ test('drives paid SSE responses with a supplied credential', async () => {
132
+ const challenge = makeChallenge({
133
+ suggestedDeposit: '100',
134
+ methodDetails: {
135
+ chainId,
136
+ escrowContract: tip20ChannelEscrow,
137
+ sessionProtocol: Constants.SessionProtocols.v2,
138
+ },
139
+ })
140
+ const channelStore = createChannelStore()
141
+ const parameters = {
142
+ account,
143
+ channelStore,
144
+ decimals: 0,
145
+ getClient: () => client,
146
+ maxDeposit: '300',
147
+ } as const
148
+ // The response handler must follow the credential selected by `onChallenge`.
149
+ const externalCredential = await session(parameters).createCredential({
150
+ challenge,
151
+ context: {},
152
+ })
153
+ let useExternalCredential = true
154
+ const actions: Types.SessionCredentialPayload['action'][] = []
155
+ const rawFetch: typeof globalThis.fetch = async (_input, init) => {
156
+ const authorization = new Headers(init?.headers).get(Constants.Headers.authorization)
157
+ if (!authorization)
158
+ return new Response(null, {
159
+ status: 402,
160
+ headers: { [Constants.Headers.wwwAuthenticate]: serializeChallenge(challenge) },
161
+ })
162
+
163
+ const payload = deserialize(authorization)
164
+ actions.push(payload.action)
165
+ if (init?.method === 'POST' || payload.action === 'open')
166
+ return new Response(null, { status: 204 })
167
+ if (payload.action !== 'voucher') throw new Error('expected voucher')
168
+
169
+ const receipt = Types.createSessionReceipt({
170
+ acceptedCumulative: 200n,
171
+ challengeId: challenge.id,
172
+ channelId: payload.channelId,
173
+ spent: 200n,
174
+ })
175
+ return new Response(
176
+ [
177
+ Types.formatMessageEvent('first'),
178
+ Types.formatNeedVoucherEvent({
179
+ acceptedCumulative: '100',
180
+ channelId: payload.channelId,
181
+ deposit: '100',
182
+ requiredCumulative: '200',
183
+ }),
184
+ Types.formatMessageEvent('second'),
185
+ Types.formatReceiptEvent(receipt),
186
+ ].join(''),
187
+ { headers: { 'content-type': 'text/event-stream' } },
188
+ )
189
+ }
190
+ const fetch = Fetch.from({
191
+ fetch: rawFetch,
192
+ methods: [session(parameters)],
193
+ async onChallenge() {
194
+ if (!useExternalCredential) return undefined
195
+ useExternalCredential = false
196
+ return externalCredential
197
+ },
198
+ })
199
+
200
+ const response = await fetch('https://example.com/stream', {
201
+ headers: { accept: 'text/event-stream' },
202
+ })
203
+
204
+ expect(response.headers.get('content-type')).toBe('text/event-stream')
205
+ expect(await response.text()).toBe(
206
+ `${Types.formatMessageEvent('first')}${Types.formatMessageEvent('second')}`,
207
+ )
208
+ expect(actions).toEqual(['open', 'voucher', 'topUp', 'voucher'])
209
+ })
210
+
129
211
  test('opens for the current amount without client deposit configuration', async () => {
130
212
  const method = session({ account, getClient: () => client })
131
213
  const payload = deserialize(
@@ -1,7 +1,9 @@
1
1
  import { type Address, parseUnits } from 'viem'
2
2
  import { tempo as tempo_chain } from 'viem/chains'
3
3
 
4
+ import * as MethodResponse from '../../../client/internal/MethodResponse.js'
4
5
  import * as Constants from '../../../Constants.js'
6
+ import * as Credential from '../../../Credential.js'
5
7
  import * as Method from '../../../Method.js'
6
8
  import * as Account from '../../../viem/Account.js'
7
9
  import * as Client from '../../../viem/Client.js'
@@ -11,6 +13,7 @@ import type {
11
13
  } from '../../client/ResolveAccount.js'
12
14
  import * as defaults from '../../internal/defaults.js'
13
15
  import * as Methods from '../../Methods.js'
16
+ import { isEventStream, requireSessionCredentialContext } from '../precompile/Protocol.js'
14
17
  import { serializeCredential, type ChannelEntry } from './ChannelOps.js'
15
18
  import { createChannelStore, type ChannelStore } from './ChannelStore.js'
16
19
  import {
@@ -20,6 +23,15 @@ import {
20
23
  resolveRecoverContext,
21
24
  sessionContextSchema,
22
25
  } from './CredentialState.js'
26
+ import { assertWithinMaxDeposit } from './Runtime.js'
27
+ import {
28
+ handleSseNeedVoucher,
29
+ isTempoSessionChallenge,
30
+ managementInput,
31
+ postTopUp,
32
+ wrapSseResponse,
33
+ type SsePaymentDriver,
34
+ } from './Transports.js'
23
35
 
24
36
  export { sessionContextSchema, type SessionContext } from './CredentialState.js'
25
37
 
@@ -52,7 +64,7 @@ export function session(parameters: session.Parameters = {}) {
52
64
  const store = channelStore ?? createChannelStore()
53
65
  const sink = { store, notifyUpdate: (entry: ChannelEntry) => onChannelUpdate?.(entry) }
54
66
 
55
- return Method.toClient(Methods.session, {
67
+ const method = Method.toClient(Methods.session, {
56
68
  canHandleChallenge({ challenge }) {
57
69
  return (
58
70
  Constants.getMethodDetail(
@@ -98,6 +110,66 @@ export function session(parameters: session.Parameters = {}) {
98
110
  return serializeCredential(challenge, payload, resolved.chainId, account)
99
111
  },
100
112
  })
113
+
114
+ return MethodResponse.register(
115
+ method,
116
+ async ({ challenge, credential, fetch, headers, input, refetch, response, signal }) => {
117
+ if (!isTempoSessionChallenge(challenge)) return response
118
+ if (!isEventStream(response)) {
119
+ const credentialContext = requireSessionCredentialContext(
120
+ Credential.deserialize(credential).payload,
121
+ )
122
+ if (
123
+ credentialContext.action === 'open' &&
124
+ headers.get('accept')?.toLowerCase().includes('text/event-stream')
125
+ )
126
+ return (await refetch?.()) ?? response
127
+ return response
128
+ }
129
+
130
+ const channelKey = (
131
+ await resolveChallengeContext({
132
+ challenge,
133
+ escrowOverride,
134
+ getClient,
135
+ })
136
+ ).key
137
+ let channel = await store.get(channelKey)
138
+ const driver = {
139
+ assertVoucherWithinLocalLimit: (cumulativeAmount) =>
140
+ assertWithinMaxDeposit(cumulativeAmount, maxDeposit),
141
+ createSessionCredential: (challenge, context) =>
142
+ method.createCredential({ challenge, context }),
143
+ fetch,
144
+ getChannel: () => channel ?? null,
145
+ managementInput,
146
+ async topUpIfNeeded({ channelId, deposit, requiredCumulative }) {
147
+ if (requiredCumulative <= deposit || !channel) return
148
+ const additionalDeposit = requiredCumulative - deposit
149
+ await postTopUp({
150
+ additionalDeposit,
151
+ challenge,
152
+ channel,
153
+ channelId,
154
+ createSessionCredential: (challenge, context) =>
155
+ method.createCredential({ challenge, context }),
156
+ fetch,
157
+ input,
158
+ })
159
+ channel.deposit += additionalDeposit
160
+ await store.set(channel)
161
+ sink.notifyUpdate(channel)
162
+ },
163
+ } satisfies SsePaymentDriver
164
+
165
+ return wrapSseResponse({
166
+ onNeedVoucher: (event) => handleSseNeedVoucher({ challenge, driver, input }, event),
167
+ onReceipt() {},
168
+ response,
169
+ signal,
170
+ })
171
+ },
172
+ )
101
173
  }
102
174
 
103
175
  /** Type helpers for the low-level TIP-1034 session client method. */
@@ -329,9 +329,13 @@ describe('Session', () => {
329
329
  expect(posted[0]).toMatchObject({ action: 'voucher', channelId: storedChannelId })
330
330
  })
331
331
 
332
- test('seeds a same-route HEAD snapshot into the entry index and resumes it', async () => {
332
+ test('seeds a same-route HEAD snapshot and resolves the account when resuming it', async () => {
333
333
  const { store, set } = makeChannelStore()
334
334
  const posted: SessionCredentialPayload[] = []
335
+ const resolveAccount = vi.fn(
336
+ (info: Parameters<NonNullable<sessionManager.Parameters['resolveAccount']>>[0]) =>
337
+ info.account,
338
+ )
335
339
  const mockFetch = vi.fn().mockImplementation((_input, init?: RequestInit) => {
336
340
  const headers = new Headers(init?.headers)
337
341
  if (init?.method === 'HEAD' && !headers.get(Constants.Headers.authorization)) {
@@ -377,6 +381,7 @@ describe('Session', () => {
377
381
  client,
378
382
  fetch: mockFetch as typeof globalThis.fetch,
379
383
  channelStore: store,
384
+ resolveAccount,
380
385
  })
381
386
 
382
387
  const response = await s.fetch('https://api.example.com/data')
@@ -384,6 +389,12 @@ describe('Session', () => {
384
389
  expect(response.status).toBe(200)
385
390
  expect(set).toHaveBeenCalledWith(expect.objectContaining({ channelId: storedChannelId }))
386
391
  expect(posted[0]).toMatchObject({ action: 'voucher', channelId: storedChannelId })
392
+ expect(resolveAccount).toHaveBeenCalledOnce()
393
+ expect(resolveAccount).toHaveBeenCalledWith({
394
+ account,
395
+ chainId: 4217,
396
+ operation: { authority: account.address, kind: 'authorizePaymentChannel' },
397
+ })
387
398
  const contentCall = mockFetch.mock.calls.find((call) => call[1]?.method !== 'HEAD')
388
399
  expect(new Headers(contentCall?.[1]?.headers).get('Payment-Session')).toBeNull()
389
400
  })
@@ -3,6 +3,7 @@ import { parseUnits, type Address } from 'viem'
3
3
 
4
4
  import * as Challenge from '../../../Challenge.js'
5
5
  import * as Fetch from '../../../client/internal/Fetch.js'
6
+ import * as MethodResponse from '../../../client/internal/MethodResponse.js'
6
7
  import * as Constants from '../../../Constants.js'
7
8
  import type * as Account from '../../../viem/Account.js'
8
9
  import type * as Client from '../../../viem/Client.js'
@@ -274,6 +275,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
274
275
  const method = sessionPlugin({
275
276
  account: parameters.account,
276
277
  getClient: parameters.client ? () => parameters.client! : parameters.getClient,
278
+ resolveAccount: parameters.resolveAccount,
277
279
  escrow: parameters.escrow,
278
280
  decimals: config.decimals,
279
281
  maxDeposit: parameters.maxDeposit,
@@ -292,6 +294,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
292
294
  }
293
295
  },
294
296
  })
297
+ MethodResponse.unregister(method)
295
298
  const chargeMethod = chargePlugin({
296
299
  account: parameters.account,
297
300
  getClient: parameters.client ? () => parameters.client! : parameters.getClient,
@@ -787,6 +790,8 @@ export namespace sessionManager {
787
790
  fetch?: typeof globalThis.fetch | undefined
788
791
  /** Maximum deposit in human-readable units (e.g. `'10'` for 10 tokens). Converted to raw units via `decimals`. */
789
792
  maxDeposit?: string | undefined
793
+ /** Selects the account that signs session credentials. */
794
+ resolveAccount?: sessionPlugin.ResolveAccount | undefined
790
795
  /** Store for reusable session channels. Defaults to in-memory. */
791
796
  channelStore?: ChannelStore | undefined
792
797
  /** Optional websocket constructor for runtimes without a global WebSocket. */
@@ -19,6 +19,7 @@ import {
19
19
  applyTopUpResult,
20
20
  closeHttpSession,
21
21
  createActiveSocketSession,
22
+ driveSseResponse,
22
23
  isExpectedSocketReceipt,
23
24
  managementInput,
24
25
  postTopUp,
@@ -31,6 +32,7 @@ import {
31
32
  validateSocketCloseReadyReceipt,
32
33
  validateSocketPaymentReceipt,
33
34
  webSocketProbeUrl,
35
+ wrapSseResponse,
34
36
  type TempoSessionChallenge,
35
37
  type TopUpRequirement,
36
38
  } from './Transports.js'
@@ -640,6 +642,113 @@ describe('VoucherManagement', () => {
640
642
  })
641
643
  })
642
644
 
645
+ describe('SseDriver', () => {
646
+ test('preserves application frames and consumes payment receipts', async () => {
647
+ const receipt = createSessionReceipt({
648
+ acceptedCumulative: 2n,
649
+ challengeId: 'challenge-1',
650
+ channelId: `0x${'01'.repeat(32)}`,
651
+ spent: 2n,
652
+ })
653
+ const acceptReceipt = vi.fn()
654
+ const response = new Response(
655
+ [
656
+ ': keepalive\n\n',
657
+ 'event: custom\ndata: hello\n\n',
658
+ `event: payment-receipt\ndata: ${JSON.stringify(receipt)}\n\n`,
659
+ ].join(''),
660
+ )
661
+
662
+ const frames = []
663
+ for await (const frame of driveSseResponse({
664
+ async onNeedVoucher() {},
665
+ onReceipt: acceptReceipt,
666
+ response,
667
+ }))
668
+ frames.push(frame)
669
+
670
+ expect(frames).toEqual([
671
+ { raw: ': keepalive\n\n' },
672
+ { data: 'hello', raw: 'event: custom\ndata: hello\n\n' },
673
+ ])
674
+ expect(acceptReceipt).toHaveBeenCalledWith(receipt)
675
+ })
676
+
677
+ test('wraps standard CRLF streams and preserves response metadata', async () => {
678
+ const receipt = createSessionReceipt({
679
+ acceptedCumulative: 2n,
680
+ challengeId: 'challenge-1',
681
+ channelId: `0x${'01'.repeat(32)}`,
682
+ spent: 2n,
683
+ })
684
+ const encoder = new TextEncoder()
685
+ const source = new Response(
686
+ new ReadableStream({
687
+ start(controller) {
688
+ controller.enqueue(encoder.encode(': keepalive\r\n\r'))
689
+ controller.enqueue(
690
+ encoder.encode(
691
+ [
692
+ '\nevent: custom\r\ndata: hello\r\n\r\n',
693
+ `event: payment-receipt\r\ndata: ${JSON.stringify(receipt)}\r\n\r\n`,
694
+ ].join(''),
695
+ ),
696
+ )
697
+ controller.close()
698
+ },
699
+ }),
700
+ {
701
+ headers: { 'content-length': '100', 'content-type': 'text/event-stream' },
702
+ status: 201,
703
+ statusText: 'Created',
704
+ },
705
+ )
706
+ Object.defineProperty(source, 'url', { value: 'https://example.com/stream' })
707
+
708
+ const response = wrapSseResponse({
709
+ async onNeedVoucher() {},
710
+ onReceipt() {},
711
+ response: source,
712
+ })
713
+
714
+ expect(response.status).toBe(201)
715
+ expect(response.statusText).toBe('Created')
716
+ expect(response.url).toBe('https://example.com/stream')
717
+ expect(response.headers.get('content-type')).toBe('text/event-stream')
718
+ expect(response.headers.has('content-length')).toBe(false)
719
+ expect(await response.text()).toBe(': keepalive\r\n\r\nevent: custom\r\ndata: hello\r\n\r\n')
720
+ })
721
+
722
+ test.each(['consumer', 'signal'] as const)(
723
+ 'cancels the source stream from %s',
724
+ async (source) => {
725
+ const abortController = new AbortController()
726
+ const cancel = vi.fn()
727
+ const sourceResponse = new Response(
728
+ new ReadableStream({
729
+ cancel,
730
+ start(controller) {
731
+ controller.enqueue(new TextEncoder().encode('data: first\n\n'))
732
+ },
733
+ }),
734
+ )
735
+ const response = wrapSseResponse({
736
+ async onNeedVoucher() {},
737
+ onReceipt() {},
738
+ response: sourceResponse,
739
+ signal: abortController.signal,
740
+ })
741
+
742
+ const reader = response.body!.getReader()
743
+ await reader.read()
744
+ if (source === 'signal') abortController.abort()
745
+ else await reader.cancel()
746
+
747
+ await vi.waitFor(() => expect(cancel).toHaveBeenCalledOnce())
748
+ },
749
+ )
750
+ })
751
+
643
752
  describe('WsDriver', () => {
644
753
  const channelId = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex.Hex
645
754
  const challenge: TempoSessionChallenge = {
@@ -748,6 +748,17 @@ export type OpenSseSessionParameters = {
748
748
  topUpIfNeeded(parameters: TopUpRequirement): Promise<void>
749
749
  }
750
750
 
751
+ /** Session payment operations shared with fetch-backed streams. */
752
+ export type SsePaymentDriver = Pick<
753
+ OpenSseSessionParameters,
754
+ | 'assertVoucherWithinLocalLimit'
755
+ | 'createSessionCredential'
756
+ | 'fetch'
757
+ | 'getChannel'
758
+ | 'managementInput'
759
+ | 'topUpIfNeeded'
760
+ >
761
+
751
762
  /**
752
763
  * Opens an auto-driving paid SSE stream.
753
764
  *
@@ -777,69 +788,182 @@ export async function openSseSession(
777
788
  if (!isEventStream(response)) throw new Error('SSE response is not an event stream.')
778
789
  if (!response.body) throw new Error('Response has no body.')
779
790
 
780
- return iterateSseResponse({
781
- challenge,
782
- driver,
783
- input,
784
- onReceipt,
791
+ return iterateSseMessages({
792
+ onNeedVoucher: (event) => handleSseNeedVoucher({ challenge, driver, input }, event),
793
+ onReceipt(receipt) {
794
+ driver.acceptReceipt(receipt)
795
+ onReceipt?.(receipt)
796
+ },
785
797
  response,
786
798
  signal,
787
799
  })
788
800
  }
789
801
 
790
- type IterateSseResponseParameters = {
791
- challenge: TempoSessionChallenge | null
792
- driver: OpenSseSessionParameters
793
- input: RequestInfo | URL
794
- onReceipt?: ((receipt: SessionReceipt) => void) | undefined
802
+ /** Application SSE frame emitted after session payment events are handled. */
803
+ export type SseResponseFrame = {
804
+ /** Parsed application data, when present. */
805
+ data?: string | undefined
806
+ /** Original frame text, including its separator. */
807
+ raw: string
808
+ }
809
+
810
+ /** Inputs for driving an open paid SSE response. */
811
+ export type DriveSseResponseParameters = {
812
+ /** Handles a request for the stream's next cumulative voucher. */
813
+ onNeedVoucher(event: NeedVoucherEvent): Promise<void>
814
+ /** Handles a payment receipt emitted by the stream. */
815
+ onReceipt(receipt: SessionReceipt): void
816
+ /** Open SSE response to drive. */
795
817
  response: Response
818
+ /** Abort signal used to stop reading the response. */
796
819
  signal?: AbortSignal | undefined
797
820
  }
798
821
 
799
- async function* iterateSseResponse(
800
- parameters: IterateSseResponseParameters,
801
- ): AsyncGenerator<string> {
802
- const reader = parameters.response.body!.getReader()
822
+ async function* iterateSseMessages(parameters: DriveSseResponseParameters): AsyncGenerator<string> {
823
+ for await (const frame of driveSseResponse(parameters)) {
824
+ if (frame.data !== undefined) yield frame.data
825
+ }
826
+ }
827
+
828
+ /** Handles session payment events and yields application SSE frames unchanged. */
829
+ export async function* driveSseResponse(
830
+ parameters: DriveSseResponseParameters,
831
+ ): AsyncGenerator<SseResponseFrame> {
832
+ if (!parameters.response.body) throw new Error('Response has no body.')
833
+ const reader = parameters.response.body.getReader()
803
834
  const decoder = new TextDecoder()
804
835
  let buffer = ''
836
+ let complete = false
837
+ const cancel = () => {
838
+ void reader.cancel(parameters.signal?.reason).catch(() => undefined)
839
+ }
840
+ parameters.signal?.addEventListener('abort', cancel, { once: true })
841
+ if (parameters.signal?.aborted) cancel()
805
842
 
806
843
  try {
807
844
  while (true) {
808
845
  if (parameters.signal?.aborted) break
809
846
 
810
847
  const { done, value } = await reader.read()
811
- if (done) break
812
-
813
- buffer += decoder.decode(value, { stream: true })
814
- const parts = buffer.split('\n\n')
815
- buffer = parts.pop()!
816
-
817
- for (const part of parts) {
848
+ if (done) {
849
+ complete = true
850
+ buffer += decoder.decode()
851
+ } else buffer += decoder.decode(value, { stream: true })
852
+
853
+ while (true) {
854
+ const separator = findSseSeparator(buffer, done)
855
+ if (!separator) break
856
+ const end = separator.index + separator.length
857
+ const part = buffer.slice(0, separator.index)
858
+ const raw = buffer.slice(0, end)
859
+ buffer = buffer.slice(end)
818
860
  if (!part.trim()) continue
819
- const event = parseEvent(part)
820
- if (!event) continue
861
+ const event = parseEvent(part.replace(/\r\n|\r/g, '\n'))
862
+ if (!event) {
863
+ yield { raw }
864
+ continue
865
+ }
821
866
 
822
867
  switch (event.type) {
823
868
  case 'message':
824
- yield event.data
869
+ yield { data: event.data, raw }
825
870
  break
826
871
  case 'payment-need-voucher':
827
- await handleSseNeedVoucher(parameters, event.data)
872
+ await parameters.onNeedVoucher(event.data)
828
873
  break
829
874
  case 'payment-receipt':
830
- parameters.driver.acceptReceipt(event.data)
831
- parameters.onReceipt?.(event.data)
875
+ parameters.onReceipt(event.data)
832
876
  break
833
877
  }
834
878
  }
879
+ if (done) break
835
880
  }
836
881
  } finally {
882
+ parameters.signal?.removeEventListener('abort', cancel)
883
+ if (!complete) await reader.cancel().catch(() => undefined)
837
884
  reader.releaseLock()
838
885
  }
839
886
  }
840
887
 
841
- async function handleSseNeedVoucher(
842
- parameters: IterateSseResponseParameters,
888
+ function findSseSeparator(
889
+ value: string,
890
+ complete: boolean,
891
+ ): { index: number; length: number } | undefined {
892
+ for (let index = 0; index < value.length; index++) {
893
+ const first = lineEndingLength(value, index, complete)
894
+ if (!first) continue
895
+ const second = lineEndingLength(value, index + first, complete)
896
+ if (second) return { index, length: first + second }
897
+ index += first - 1
898
+ }
899
+ return undefined
900
+ }
901
+
902
+ function lineEndingLength(value: string, index: number, complete: boolean): number {
903
+ if (value[index] === '\n') return 1
904
+ if (value[index] !== '\r') return 0
905
+ if (value[index + 1] === '\n') return 2
906
+ if (index + 1 === value.length && !complete) return 0
907
+ return 1
908
+ }
909
+
910
+ /** Wraps an SSE response while handling session payment frames in-band. */
911
+ export function wrapSseResponse(parameters: DriveSseResponseParameters): Response {
912
+ const abortController = new AbortController()
913
+ const abort = () => abortController.abort(parameters.signal?.reason)
914
+ parameters.signal?.addEventListener('abort', abort, { once: true })
915
+ if (parameters.signal?.aborted) abort()
916
+
917
+ const iterator = driveSseResponse({ ...parameters, signal: abortController.signal })
918
+ const encoder = new TextEncoder()
919
+ const headers = new Headers(parameters.response.headers)
920
+ headers.delete('content-length')
921
+ const cleanup = () => parameters.signal?.removeEventListener('abort', abort)
922
+
923
+ const response = new Response(
924
+ new ReadableStream({
925
+ async pull(controller) {
926
+ try {
927
+ const frame = await iterator.next()
928
+ if (frame.done) {
929
+ cleanup()
930
+ return controller.close()
931
+ }
932
+ controller.enqueue(encoder.encode(frame.value.raw))
933
+ } catch (error) {
934
+ cleanup()
935
+ throw error
936
+ }
937
+ },
938
+ async cancel() {
939
+ cleanup()
940
+ abortController.abort()
941
+ await iterator.return(undefined)
942
+ },
943
+ }),
944
+ {
945
+ headers,
946
+ status: parameters.response.status,
947
+ statusText: parameters.response.statusText,
948
+ },
949
+ )
950
+
951
+ for (const property of ['redirected', 'type', 'url'] as const)
952
+ Object.defineProperty(response, property, {
953
+ configurable: true,
954
+ get: () => parameters.response[property],
955
+ })
956
+
957
+ return response
958
+ }
959
+
960
+ /** Handles an in-band voucher request using the shared SSE payment driver. */
961
+ export async function handleSseNeedVoucher(
962
+ parameters: {
963
+ challenge: TempoSessionChallenge | null
964
+ driver: SsePaymentDriver
965
+ input: RequestInfo | URL
966
+ },
843
967
  event: NeedVoucherEvent,
844
968
  ) {
845
969
  const channel = parameters.driver.getChannel()
@@ -611,6 +611,9 @@ describe('precompile broadcastOpenTransaction', () => {
611
611
  expect(broadcastIndex).toBeGreaterThan(-1)
612
612
  expect(simulationIndex).toBeGreaterThan(-1)
613
613
  expect(simulationIndex).toBeLessThan(broadcastIndex)
614
+ expect(
615
+ rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
616
+ ).toHaveLength(2)
614
617
  })
615
618
 
616
619
  test('hosted fee-payer relays a sender-signed open without local co-signing', async () => {
@@ -653,6 +656,9 @@ describe('precompile broadcastOpenTransaction', () => {
653
656
 
654
657
  expect(rpcMethods).toContain('eth_sendRawTransaction')
655
658
  expect(rpcMethods).not.toContain('eth_sendRawTransactionSync')
659
+ expect(rpcMethods.indexOf('eth_call')).toBeLessThan(
660
+ rpcMethods.indexOf('eth_sendRawTransaction'),
661
+ )
656
662
  })
657
663
 
658
664
  test('rejects expiring nonce hash mismatches before broadcasting', async () => {
@@ -971,6 +977,9 @@ describe('precompile broadcastTopUpTransaction', () => {
971
977
  expect(broadcastIndex).toBeGreaterThan(-1)
972
978
  expect(simulationIndex).toBeGreaterThan(-1)
973
979
  expect(simulationIndex).toBeLessThan(broadcastIndex)
980
+ expect(
981
+ rpcMethods.slice(0, broadcastIndex).filter((method) => method === 'eth_call'),
982
+ ).toHaveLength(2)
974
983
  })
975
984
 
976
985
  test('rejects top-up calldata amount mismatches before broadcasting', async () => {