mppx 0.8.11 → 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 (43) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/client/internal/Fetch.d.ts.map +1 -1
  3. package/dist/client/internal/Fetch.js +35 -6
  4. package/dist/client/internal/Fetch.js.map +1 -1
  5. package/dist/client/internal/MethodResponse.d.ts +23 -0
  6. package/dist/client/internal/MethodResponse.d.ts.map +1 -0
  7. package/dist/client/internal/MethodResponse.js +15 -0
  8. package/dist/client/internal/MethodResponse.js.map +1 -0
  9. package/dist/tempo/legacy/client/ChannelOps.d.ts +2 -0
  10. package/dist/tempo/legacy/client/ChannelOps.d.ts.map +1 -1
  11. package/dist/tempo/legacy/client/ChannelOps.js +4 -0
  12. package/dist/tempo/legacy/client/ChannelOps.js.map +1 -1
  13. package/dist/tempo/legacy/client/Session.d.ts.map +1 -1
  14. package/dist/tempo/legacy/client/Session.js +7 -0
  15. package/dist/tempo/legacy/client/Session.js.map +1 -1
  16. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  17. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  18. package/dist/tempo/server/internal/html.gen.js +1 -1
  19. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  20. package/dist/tempo/session/client/Session.d.ts.map +1 -1
  21. package/dist/tempo/session/client/Session.js +53 -1
  22. package/dist/tempo/session/client/Session.js.map +1 -1
  23. package/dist/tempo/session/client/SessionManager.d.ts.map +1 -1
  24. package/dist/tempo/session/client/SessionManager.js +2 -0
  25. package/dist/tempo/session/client/SessionManager.js.map +1 -1
  26. package/dist/tempo/session/client/Transports.d.ts +10 -0
  27. package/dist/tempo/session/client/Transports.d.ts.map +1 -1
  28. package/dist/tempo/session/client/Transports.js +96 -9
  29. package/dist/tempo/session/client/Transports.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/client/internal/Fetch.test.ts +29 -0
  32. package/src/client/internal/Fetch.ts +51 -6
  33. package/src/client/internal/MethodResponse.ts +39 -0
  34. package/src/tempo/legacy/client/ChannelOps.test.ts +21 -0
  35. package/src/tempo/legacy/client/ChannelOps.ts +7 -0
  36. package/src/tempo/legacy/client/Session.test.ts +102 -1
  37. package/src/tempo/legacy/client/Session.ts +10 -0
  38. package/src/tempo/server/internal/html.gen.ts +1 -1
  39. package/src/tempo/session/client/Session.test.ts +83 -1
  40. package/src/tempo/session/client/Session.ts +73 -1
  41. package/src/tempo/session/client/SessionManager.ts +2 -0
  42. package/src/tempo/session/client/Transports.test.ts +75 -0
  43. package/src/tempo/session/client/Transports.ts +108 -11
@@ -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. */
@@ -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'
@@ -293,6 +294,7 @@ export function sessionManager(parameters: sessionManager.Parameters): SessionMa
293
294
  }
294
295
  },
295
296
  })
297
+ MethodResponse.unregister(method)
296
298
  const chargeMethod = chargePlugin({
297
299
  account: parameters.account,
298
300
  getClient: parameters.client ? () => parameters.client! : parameters.getClient,
@@ -32,6 +32,7 @@ import {
32
32
  validateSocketCloseReadyReceipt,
33
33
  validateSocketPaymentReceipt,
34
34
  webSocketProbeUrl,
35
+ wrapSseResponse,
35
36
  type TempoSessionChallenge,
36
37
  type TopUpRequirement,
37
38
  } from './Transports.js'
@@ -672,6 +673,80 @@ describe('SseDriver', () => {
672
673
  ])
673
674
  expect(acceptReceipt).toHaveBeenCalledWith(receipt)
674
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
+ )
675
750
  })
676
751
 
677
752
  describe('WsDriver', () => {
@@ -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
  *
@@ -822,22 +833,32 @@ export async function* driveSseResponse(
822
833
  const reader = parameters.response.body.getReader()
823
834
  const decoder = new TextDecoder()
824
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()
825
842
 
826
843
  try {
827
844
  while (true) {
828
845
  if (parameters.signal?.aborted) break
829
846
 
830
847
  const { done, value } = await reader.read()
831
- if (done) break
832
-
833
- buffer += decoder.decode(value, { stream: true })
834
- const parts = buffer.split('\n\n')
835
- buffer = parts.pop()!
836
-
837
- 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)
838
860
  if (!part.trim()) continue
839
- const event = parseEvent(part)
840
- const raw = `${part}\n\n`
861
+ const event = parseEvent(part.replace(/\r\n|\r/g, '\n'))
841
862
  if (!event) {
842
863
  yield { raw }
843
864
  continue
@@ -855,16 +876,92 @@ export async function* driveSseResponse(
855
876
  break
856
877
  }
857
878
  }
879
+ if (done) break
858
880
  }
859
881
  } finally {
882
+ parameters.signal?.removeEventListener('abort', cancel)
883
+ if (!complete) await reader.cancel().catch(() => undefined)
860
884
  reader.releaseLock()
861
885
  }
862
886
  }
863
887
 
864
- async function handleSseNeedVoucher(
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(
865
962
  parameters: {
866
963
  challenge: TempoSessionChallenge | null
867
- driver: OpenSseSessionParameters
964
+ driver: SsePaymentDriver
868
965
  input: RequestInfo | URL
869
966
  },
870
967
  event: NeedVoucherEvent,