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
@@ -304,4 +304,132 @@ describe('extractRequestBodyFromDiscovery', () => {
304
304
  })
305
305
  expect(JSON.parse(body!)).toEqual({ model: 'gpt-4' })
306
306
  })
307
+
308
+ test('resolves $ref to components/schemas', () => {
309
+ const doc = {
310
+ paths: {
311
+ '/api/orders': {
312
+ post: {
313
+ requestBody: {
314
+ content: {
315
+ 'application/json': {
316
+ schema: { $ref: '#/components/schemas/OrderRequest' },
317
+ },
318
+ },
319
+ },
320
+ },
321
+ },
322
+ },
323
+ components: {
324
+ schemas: {
325
+ OrderRequest: {
326
+ type: 'object',
327
+ required: ['product', 'quantity'],
328
+ properties: {
329
+ product: { type: 'string', example: 'widget' },
330
+ quantity: { type: 'integer', minimum: 1 },
331
+ },
332
+ },
333
+ },
334
+ },
335
+ }
336
+ const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
337
+ method: 'POST',
338
+ path: '/api/orders',
339
+ })
340
+ expect(JSON.parse(body!)).toEqual({ product: 'widget', quantity: 1 })
341
+ })
342
+
343
+ test('resolves nested $ref in properties', () => {
344
+ const doc = {
345
+ paths: {
346
+ '/api/orders': {
347
+ post: {
348
+ requestBody: {
349
+ content: {
350
+ 'application/json': {
351
+ schema: { $ref: '#/components/schemas/OrderRequest' },
352
+ },
353
+ },
354
+ },
355
+ },
356
+ },
357
+ },
358
+ components: {
359
+ schemas: {
360
+ OrderRequest: {
361
+ type: 'object',
362
+ required: ['customer'],
363
+ properties: {
364
+ customer: { $ref: '#/components/schemas/Customer' },
365
+ },
366
+ },
367
+ Customer: {
368
+ type: 'object',
369
+ required: ['name', 'email'],
370
+ properties: {
371
+ name: { type: 'string' },
372
+ email: { type: 'string', format: 'email' },
373
+ },
374
+ },
375
+ },
376
+ },
377
+ }
378
+ const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
379
+ method: 'POST',
380
+ path: '/api/orders',
381
+ })
382
+ expect(JSON.parse(body!)).toEqual({ customer: { name: 'test', email: 'test@example.com' } })
383
+ })
384
+
385
+ test('uses first named example from examples map', () => {
386
+ const doc = {
387
+ paths: {
388
+ '/api/test': {
389
+ post: {
390
+ requestBody: {
391
+ content: {
392
+ 'application/json': {
393
+ examples: {
394
+ basic: { value: { prompt: 'hello', model: 'gpt-4' } },
395
+ advanced: { value: { prompt: 'complex', model: 'gpt-4', temp: 0.9 } },
396
+ },
397
+ schema: { type: 'object' },
398
+ },
399
+ },
400
+ },
401
+ },
402
+ },
403
+ },
404
+ }
405
+ const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
406
+ method: 'POST',
407
+ path: '/api/test',
408
+ })
409
+ expect(JSON.parse(body!)).toEqual({ prompt: 'hello', model: 'gpt-4' })
410
+ })
411
+
412
+ test('returns undefined for unresolvable $ref', () => {
413
+ const doc = {
414
+ paths: {
415
+ '/api/test': {
416
+ post: {
417
+ requestBody: {
418
+ content: {
419
+ 'application/json': {
420
+ schema: { $ref: '#/components/schemas/DoesNotExist' },
421
+ },
422
+ },
423
+ },
424
+ },
425
+ },
426
+ },
427
+ components: { schemas: {} },
428
+ }
429
+ const body = extractRequestBodyFromDiscovery(doc as Record<string, unknown>, {
430
+ method: 'POST',
431
+ path: '/api/test',
432
+ })
433
+ expect(body).toBeUndefined()
434
+ })
307
435
  })
@@ -77,10 +77,19 @@ export function extractRequestBodyFromDiscovery(
77
77
 
78
78
  if (jsonContent.example) return JSON.stringify(jsonContent.example)
79
79
 
80
- const schema = jsonContent.schema as Record<string, unknown> | undefined
80
+ if (jsonContent.examples && typeof jsonContent.examples === 'object') {
81
+ const first = Object.values(jsonContent.examples as Record<string, unknown>)[0] as
82
+ | Record<string, unknown>
83
+ | undefined
84
+ if (first?.value) return JSON.stringify(first.value)
85
+ }
86
+
87
+ let schema = jsonContent.schema as Record<string, unknown> | undefined
88
+ const seen = new Set<string>()
89
+ schema = resolveRef(schema, doc, seen)
81
90
  if (!schema || schema.type !== 'object') return undefined
82
91
 
83
- const result = generateValueFromSchema(schema)
92
+ const result = generateValueFromSchema(schema, doc, seen)
84
93
  if (result && typeof result === 'object' && Object.keys(result as object).length > 0) {
85
94
  return JSON.stringify(result)
86
95
  }
@@ -149,34 +158,60 @@ function substitutePathParams(path: string, params: PathParameter[]): string {
149
158
  })
150
159
  }
151
160
 
152
- function generateValueFromSchema(schema: Record<string, unknown>): unknown {
153
- if (schema.const !== undefined) return schema.const
154
- if (schema.example !== undefined) return schema.example
155
- if (schema.default !== undefined) return schema.default
161
+ // Dereferences a JSON Schema $ref (e.g. "#/components/schemas/Foo") against the root doc.
162
+ function resolveRef(
163
+ schema: Record<string, unknown> | undefined,
164
+ doc: Record<string, unknown>,
165
+ seen?: Set<string>,
166
+ ): Record<string, unknown> | undefined {
167
+ if (!schema || typeof schema.$ref !== 'string') return schema
168
+ if (seen?.has(schema.$ref)) return undefined
169
+ const path = schema.$ref.replace(/^#\//, '').split('/')
170
+ let resolved: unknown = doc
171
+ for (const segment of path) {
172
+ if (resolved && typeof resolved === 'object') resolved = (resolved as any)[segment]
173
+ else return undefined
174
+ }
175
+ return resolved as Record<string, unknown> | undefined
176
+ }
156
177
 
157
- switch (schema.type) {
178
+ // Generates a plausible value for a JSON Schema node (required fields only for objects).
179
+ function generateValueFromSchema(
180
+ schema: Record<string, unknown>,
181
+ doc: Record<string, unknown>,
182
+ seen?: Set<string>,
183
+ ): unknown {
184
+ const resolved = resolveRef(schema, doc, seen) ?? schema
185
+ if (resolved.const !== undefined) return resolved.const
186
+ if (resolved.example !== undefined) return resolved.example
187
+ if (resolved.default !== undefined) return resolved.default
188
+
189
+ switch (resolved.type) {
158
190
  case 'string': {
159
- if (schema.enum && Array.isArray(schema.enum)) return schema.enum[0]
160
- if (schema.format === 'email') return 'test@example.com'
161
- if (schema.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
162
- if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com'
191
+ if (resolved.enum && Array.isArray(resolved.enum)) return resolved.enum[0]
192
+ if (resolved.format === 'email') return 'test@example.com'
193
+ if (resolved.format === 'uuid') return '00000000-0000-0000-0000-000000000000'
194
+ if (resolved.format === 'uri' || resolved.format === 'url') return 'https://example.com'
195
+ if (resolved.format === 'date') return '2026-01-01'
196
+ if (resolved.pattern === '^\\d{5}(?:-\\d{4})?$') return '10001'
197
+ if (resolved.pattern === '^[A-Z]{2}$') return 'US'
163
198
  return 'test'
164
199
  }
165
200
  case 'number':
166
201
  case 'integer':
167
- return (schema.minimum as number) ?? 1
202
+ return (resolved.minimum as number) ?? 1
168
203
  case 'boolean':
169
204
  return true
170
205
  case 'array':
171
206
  return []
172
207
  case 'object': {
173
- const properties = schema.properties as Record<string, Record<string, unknown>> | undefined
208
+ const properties = resolved.properties as Record<string, Record<string, unknown>> | undefined
174
209
  if (!properties) return {}
175
- const required = (schema.required as string[]) || []
210
+ const required = (resolved.required as string[]) || []
176
211
  const obj: Record<string, unknown> = {}
177
212
  for (const key of required) {
178
213
  const prop = properties[key]
179
- if (prop) obj[key] = generateValueFromSchema(prop)
214
+ if (prop) obj[key] = generateValueFromSchema(prop, doc, seen)
180
215
  }
181
216
  return obj
182
217
  }
@@ -154,3 +154,13 @@ export function resolveBodyForEndpoint(
154
154
  } catch {}
155
155
  return rawBody
156
156
  }
157
+
158
+ export function parseHeaders(raw: string[] | undefined): Record<string, string> {
159
+ if (!raw) return {}
160
+ const headers: Record<string, string> = {}
161
+ for (const h of raw) {
162
+ const idx = h.indexOf(':')
163
+ if (idx > 0) headers[h.slice(0, idx).trim().toLowerCase()] = h.slice(idx + 1).trim()
164
+ }
165
+ return headers
166
+ }
@@ -19,12 +19,14 @@ const validate = Cli.create('validate', {
19
19
  'Request body. With --endpoint, used directly. In discovery mode, JSON with / keys is a per-path mapping.',
20
20
  ),
21
21
  query: z.array(z.string()).optional().describe('Query parameter (key=value, repeatable)'),
22
+ header: z.array(z.string()).optional().describe('Request header (key:value, repeatable)'),
22
23
  verbose: z.number().default(0).meta({ count: true }).describe('Verbosity level'),
23
24
  yes: z.boolean().default(false).describe('Auto-approve mainnet payments'),
24
25
  outputJson: z.boolean().default(false).describe('Output results as JSON'),
25
26
  }),
26
27
  alias: {
27
28
  endpoint: 'e',
29
+ header: 'H',
28
30
  verbose: 'v',
29
31
  yes: 'y',
30
32
  outputJson: 'j',
@@ -37,6 +39,7 @@ const validate = Cli.create('validate', {
37
39
  endpoint: c.options.endpoint,
38
40
  body: c.options.body,
39
41
  query: c.options.query,
42
+ headers: c.options.header,
40
43
  verbose: c.options.verbose > 0,
41
44
  yes: c.options.yes,
42
45
  interactive: false,
@@ -66,6 +69,7 @@ const validate = Cli.create('validate', {
66
69
  endpoint: c.options.endpoint,
67
70
  body: c.options.body,
68
71
  query: c.options.query,
72
+ headers: c.options.header,
69
73
  verbose: c.options.verbose > 0,
70
74
  yes: c.options.yes,
71
75
  interactive: !!process.stdin.isTTY,
@@ -26,6 +26,7 @@ import {
26
26
  fetchWithTimeout,
27
27
  formatBytes,
28
28
  isValidIntegerAmount,
29
+ parseHeaders,
29
30
  skip,
30
31
  warn,
31
32
  } from './helpers.js'
@@ -129,6 +130,7 @@ export async function validatePaymentFlow(
129
130
  options: {
130
131
  body?: string | undefined
131
132
  query?: string[] | undefined
133
+ extraHeaders?: string[] | undefined
132
134
  yes?: boolean | undefined
133
135
  silent?: boolean | undefined
134
136
  interactive?: boolean | undefined
@@ -137,7 +139,7 @@ export async function validatePaymentFlow(
137
139
  ): Promise<CheckResult[]> {
138
140
  const results: CheckResult[] = []
139
141
  const url = buildUrl(baseUrl, endpoint, options.query)
140
- const fetchHeaders: Record<string, string> = {}
142
+ const fetchHeaders: Record<string, string> = parseHeaders(options.extraHeaders)
141
143
  let fetchBody: string | undefined
142
144
  if (options.body) {
143
145
  fetchBody = options.body
@@ -293,6 +295,7 @@ type PaymentContext = {
293
295
  options: {
294
296
  body?: string | undefined
295
297
  query?: string[] | undefined
298
+ extraHeaders?: string[] | undefined
296
299
  yes?: boolean | undefined
297
300
  silent?: boolean | undefined
298
301
  interactive?: boolean | undefined
@@ -1,12 +1,32 @@
1
1
  import type * as http from 'node:http'
2
2
 
3
- import { afterEach, describe, expect, test } from 'vp/test'
3
+ import { afterEach, describe, expect, test, vi } from 'vp/test'
4
4
  import * as Http from '~test/Http.js'
5
5
 
6
6
  import * as Challenge from '../Challenge.js'
7
7
  import * as Constants from '../Constants.js'
8
8
  import * as Receipt from '../Receipt.js'
9
- import validate from './validate/index.js'
9
+
10
+ // Keep CLI orchestration tests independent of the public Tempo RPC and faucet.
11
+ vi.doMock('viem/tempo', async () => {
12
+ const actual = await vi.importActual<typeof import('viem/tempo')>('viem/tempo')
13
+ return {
14
+ ...actual,
15
+ Actions: {
16
+ ...actual.Actions,
17
+ faucet: { ...actual.Actions.faucet, fund: vi.fn(async () => []) },
18
+ },
19
+ }
20
+ })
21
+
22
+ vi.doMock('viem/actions', async () => {
23
+ const actual = await vi.importActual<typeof import('viem/actions')>('viem/actions')
24
+ return {
25
+ ...actual,
26
+ prepareTransactionRequest: vi.fn(async () => ({})),
27
+ signTransaction: vi.fn(async () => '0xdeadbeef'),
28
+ }
29
+ })
10
30
 
11
31
  // Auto-cleanup for test servers
12
32
  const servers: Http.TestServer[] = []
@@ -22,6 +42,7 @@ async function testServer(handler: http.RequestListener) {
22
42
  }
23
43
 
24
44
  async function serve(argv: string[]) {
45
+ const { default: validate } = await import('./validate/index.js')
25
46
  let output = ''
26
47
  let exitCode: number | undefined
27
48
  const origLog = console.log
@@ -19,7 +19,9 @@ describe('Mppx', () => {
19
19
  test('exports low-level session method and explicit managed session helper', () => {
20
20
  expectTypeOf(session({ account: {} as Account })).toMatchTypeOf<Method.AnyClient>()
21
21
  expectTypeOf(session.manager({ account: {} as Account })).toHaveProperty('fetch')
22
- expectTypeOf(sessionManager({ account: {} as Account })).toHaveProperty('fetch')
22
+ expectTypeOf(
23
+ sessionManager({ account: {} as Account, resolveAccount: ({ account }) => account }),
24
+ ).toHaveProperty('fetch')
23
25
  expectTypeOf(sessionMethod({ account: {} as Account })).toMatchTypeOf<Method.AnyClient>()
24
26
  expectTypeOf(tempo.session({ account: {} as Account })).toMatchTypeOf<Method.AnyClient>()
25
27
  expectTypeOf(tempo.session.manager({ account: {} as Account })).toHaveProperty('fetch')
@@ -10,6 +10,7 @@ import { rpcUrl } from '~test/tempo/rpc.js'
10
10
  import { accounts, asset, chain, client, http } from '~test/tempo/viem.js'
11
11
 
12
12
  import * as Fetch from './Fetch.js'
13
+ import * as MethodResponse from './MethodResponse.js'
13
14
 
14
15
  const realm = 'api.example.com'
15
16
  const secretKey = 'test-secret-key-test-secret-key-32'
@@ -391,6 +392,34 @@ function encodeRawPaymentRequiredHeader(value: unknown): string {
391
392
  return Base64.fromString(JSON.stringify(value))
392
393
  }
393
394
 
395
+ describe('Fetch.from: method responses', () => {
396
+ test('handles successful paid responses without touching free responses', async () => {
397
+ const method = { ...noopMethod }
398
+ const handle = vi.fn(
399
+ async ({ response }: MethodResponse.HandlerParameters) =>
400
+ new Response(`handled:${await response.text()}`, response),
401
+ )
402
+ MethodResponse.register(method, handle)
403
+
404
+ let paidResponse: Response | undefined
405
+ const mockFetch: typeof globalThis.fetch = async (input, init) => {
406
+ if (input.toString().endsWith('/free')) return new Response('free')
407
+ if (!new Headers(init?.headers).has('Authorization')) return make402()
408
+ paidResponse = new Response('paid')
409
+ vi.spyOn(paidResponse, 'clone')
410
+ return paidResponse
411
+ }
412
+ const fetch = Fetch.from({ fetch: mockFetch, methods: [method] })
413
+
414
+ expect(await (await fetch('https://example.com/free')).text()).toBe('free')
415
+ expect(handle).not.toHaveBeenCalled()
416
+ expect(await (await fetch('https://example.com/paid')).text()).toBe('handled:paid')
417
+ expect(handle).toHaveBeenCalledOnce()
418
+ expect(handle).toHaveBeenCalledWith(expect.objectContaining({ credential: 'credential' }))
419
+ expect(paidResponse?.clone).not.toHaveBeenCalled()
420
+ })
421
+ })
422
+
394
423
  describe('Fetch.from: init passthrough (non-402)', () => {
395
424
  test('preserves init object identity while adding Accept-Payment', async () => {
396
425
  const receivedInits: (RequestInit | undefined)[] = []
@@ -6,6 +6,7 @@ import type { MaybePromise } from '../../internal/types.js'
6
6
  import type * as Method from '../../Method.js'
7
7
  import type * as z from '../../zod.js'
8
8
  import * as Transport from '../Transport.js'
9
+ import * as MethodResponse from './MethodResponse.js'
9
10
 
10
11
  // We tag wrappers with a global symbol so we can recognize wrappers created by mppx,
11
12
  // even across multiple module instances/bundles. This lets restore() avoid clobbering
@@ -18,6 +19,7 @@ type WrappedFetch = typeof globalThis.fetch & {
18
19
  }
19
20
 
20
21
  let originalFetch: typeof globalThis.fetch | undefined
22
+ const eventObserverChecks = new WeakMap<object, (name: keyof ClientEventMap) => boolean>()
21
23
 
22
24
  export type ClientEventMap<
23
25
  methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[],
@@ -174,7 +176,11 @@ export function from<const methods extends readonly Method.AnyClient[]>(
174
176
  // which can duplicate retries and make restore semantics fragile.
175
177
  const baseFetch = unwrapFetch(fetch)
176
178
 
177
- const wrappedFetch = async (input: RequestInfo | URL, init?: from.RequestInit<methods>) => {
179
+ const request = async (
180
+ input: RequestInfo | URL,
181
+ init: from.RequestInit<methods> | undefined,
182
+ canRefetch: boolean,
183
+ ): Promise<Response> => {
178
184
  const callerHeaders = getCallerHeaders(input, init?.headers)
179
185
  const hasExplicitAcceptPayment = callerHeaders.has(Constants.Headers.acceptPayment)
180
186
  const paymentPreferences = resolvePaymentPreferences(callerHeaders, resolvedAcceptPayment)
@@ -272,8 +278,13 @@ export function from<const methods extends readonly Method.AnyClient[]>(
272
278
  }),
273
279
  )
274
280
 
281
+ const paymentInput = resolvePaymentRetryInput(
282
+ response,
283
+ initialRequest.input,
284
+ initialRequest.input,
285
+ )
275
286
  response = await baseFetch(
276
- resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input),
287
+ paymentInput,
277
288
  transport.setCredential(
278
289
  {
279
290
  ...fetchInit,
@@ -283,7 +294,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
283
294
  { challenge: selectedChallenge },
284
295
  ),
285
296
  )
286
- if (response.ok)
297
+ // Cloning an unobserved stream would tee and buffer it indefinitely.
298
+ if (response.ok && hasEventObservers(events, 'payment.response'))
287
299
  await events.emit(
288
300
  'payment.response',
289
301
  createPaymentResponsePayload({
@@ -295,8 +307,23 @@ export function from<const methods extends readonly Method.AnyClient[]>(
295
307
  response,
296
308
  }),
297
309
  )
298
- if (!(await transport.isPaymentRequired(response, transportRequest as never)))
299
- return response
310
+ if (!(await transport.isPaymentRequired(response, transportRequest as never))) {
311
+ if (!response.ok) return response
312
+ return MethodResponse.handle(selected.method, {
313
+ challenge: selectedChallenge,
314
+ credential,
315
+ fetch: baseFetch,
316
+ headers: initialRequest.headers,
317
+ input: paymentInput,
318
+ ...(canRefetch
319
+ ? {
320
+ refetch: () => request(cloneRequestInput(initialRequest.input), init, false),
321
+ }
322
+ : {}),
323
+ response,
324
+ signal: resolveRequestSignal(input, init),
325
+ })
326
+ }
300
327
  }
301
328
  return response
302
329
  } catch (error) {
@@ -315,6 +342,8 @@ export function from<const methods extends readonly Method.AnyClient[]>(
315
342
  throw error
316
343
  }
317
344
  }
345
+ const wrappedFetch = (input: RequestInfo | URL, init?: from.RequestInit<methods>) =>
346
+ request(input, init, true)
318
347
 
319
348
  // Record the wrapped target so future polyfill() / restore() calls can detect origin
320
349
  // and safely unwrap only mppx-installed wrappers.
@@ -491,7 +520,7 @@ export function createEventDispatcher<
491
520
  }
492
521
  }
493
522
 
494
- return {
523
+ const dispatcher: ClientEventDispatcher<methods, response> = {
495
524
  async emit(name, payload) {
496
525
  switch (name) {
497
526
  case 'challenge.received': {
@@ -525,6 +554,15 @@ export function createEventDispatcher<
525
554
  },
526
555
  on,
527
556
  }
557
+ eventObserverChecks.set(
558
+ dispatcher,
559
+ (name) => handlers[name as keyof typeof handlers].size > 0 || handlers['*'].size > 0,
560
+ )
561
+ return dispatcher
562
+ }
563
+
564
+ function hasEventObservers(dispatcher: ClientEventDispatcher, name: keyof ClientEventMap): boolean {
565
+ return eventObserverChecks.get(dispatcher)?.(name) ?? true
528
566
  }
529
567
 
530
568
  async function emitChallengeReceived<methods extends readonly Method.AnyClient[], response>(
@@ -812,6 +850,13 @@ function cloneRequestInput(input: RequestInfo | URL): RequestInfo | URL {
812
850
  }
813
851
  }
814
852
 
853
+ function resolveRequestSignal(
854
+ input: RequestInfo | URL,
855
+ init: RequestInit | undefined,
856
+ ): AbortSignal | undefined {
857
+ return init?.signal ?? (input instanceof Request ? input.signal : undefined)
858
+ }
859
+
815
860
  /** @internal */
816
861
  function isWrappedFetch(fetch: typeof globalThis.fetch): fetch is WrappedFetch {
817
862
  return Boolean((fetch as WrappedFetch)[MPPX_FETCH_WRAPPER])
@@ -0,0 +1,39 @@
1
+ import type * as Challenge from '../../Challenge.js'
2
+ import type { MaybePromise } from '../../internal/types.js'
3
+ import type * as Method from '../../Method.js'
4
+
5
+ const handlers = new WeakMap<Method.AnyClient, Handler>()
6
+
7
+ /** Inputs available when a client method handles a successful paid response. */
8
+ export type HandlerParameters = {
9
+ challenge: Challenge.Challenge
10
+ credential: string
11
+ fetch: typeof globalThis.fetch
12
+ headers: Headers
13
+ input: RequestInfo | URL
14
+ refetch?: (() => Promise<Response>) | undefined
15
+ response: Response
16
+ signal?: AbortSignal | undefined
17
+ }
18
+
19
+ /** Internal client-method response adapter. */
20
+ export type Handler = (parameters: HandlerParameters) => MaybePromise<Response>
21
+
22
+ /** Registers an internal response adapter without changing the public method shape. */
23
+ export function register<const method extends Method.AnyClient>(
24
+ method: method,
25
+ handler: Handler,
26
+ ): method {
27
+ handlers.set(method, handler)
28
+ return method
29
+ }
30
+
31
+ /** Removes response handling from a method whose caller owns the response lifecycle. */
32
+ export function unregister(method: Method.AnyClient): void {
33
+ handlers.delete(method)
34
+ }
35
+
36
+ /** Lets the selected client method handle a successful paid response. */
37
+ export function handle(method: Method.AnyClient, parameters: HandlerParameters): Promise<Response> {
38
+ return Promise.resolve(handlers.get(method)?.(parameters) ?? parameters.response)
39
+ }
@@ -1,3 +1,4 @@
1
+ import { tempo as serverTempo } from 'mppx/server'
1
2
  import { expectTypeOf, test } from 'vp/test'
2
3
 
3
4
  import type {
@@ -22,6 +23,7 @@ import type { SessionTransition as MachineSessionTransition } from './session/cl
22
23
  import type { ChannelTransactionOptions } from './session/precompile/Chain.js'
23
24
  import type { RawAmountString } from './session/precompile/index.js'
24
25
  import type { SettlementSchedule as SessionSettlementSchedule } from './session/server/index.js'
26
+ import type { SessionController } from './session/server/Sse.js'
25
27
 
26
28
  test('tempo session public barrels expose manager and schedule interfaces', () => {
27
29
  expectTypeOf(Tempo.Session).toBeObject()
@@ -43,6 +45,8 @@ test('tempo session public barrels expose manager and schedule interfaces', () =
43
45
  units?: number | undefined
44
46
  }>()
45
47
  expectTypeOf<RawAmountString>().toEqualTypeOf<string>()
48
+ expectTypeOf<serverTempo.Sse.SessionController>().toEqualTypeOf<SessionController>()
49
+ expectTypeOf<Tempo.Session.Server.Sse.SessionController>().toEqualTypeOf<SessionController>()
46
50
  expectTypeOf<ActiveSessionState['status']>().toEqualTypeOf<'active'>()
47
51
  expectTypeOf<VoucherNeededSessionState['status']>().toEqualTypeOf<'voucherNeeded'>()
48
52
  expectTypeOf<ClosedSessionState['status']>().toEqualTypeOf<'closed'>()
@@ -83,6 +87,7 @@ test('tempo session public barrel hides internal session drivers', () => {
83
87
  expectTypeOf<SessionPublic>().not.toHaveProperty('settle')
84
88
  expectTypeOf<SessionClientPublic>().not.toHaveProperty('ChannelOps')
85
89
  expectTypeOf<SessionClientPublic>().not.toHaveProperty('Chain')
90
+ expectTypeOf<SessionClientPublic>().not.toHaveProperty('driveSseResponse')
86
91
  expectTypeOf<SessionServerPublic>().not.toHaveProperty('ChannelOps')
87
92
  expectTypeOf<SessionServerPublic>().not.toHaveProperty('Chain')
88
93
  })