mppx 0.5.16 → 0.6.0

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 (67) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/cli/cli.d.ts.map +1 -1
  3. package/dist/cli/cli.js +30 -1
  4. package/dist/cli/cli.js.map +1 -1
  5. package/dist/client/Mppx.d.ts +2 -0
  6. package/dist/client/Mppx.d.ts.map +1 -1
  7. package/dist/client/Mppx.js +4 -1
  8. package/dist/client/Mppx.js.map +1 -1
  9. package/dist/client/index.d.ts +1 -0
  10. package/dist/client/index.d.ts.map +1 -1
  11. package/dist/client/index.js +1 -0
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/client/internal/Fetch.d.ts +4 -0
  14. package/dist/client/internal/Fetch.d.ts.map +1 -1
  15. package/dist/client/internal/Fetch.js +43 -5
  16. package/dist/client/internal/Fetch.js.map +1 -1
  17. package/dist/server/Mppx.d.ts +38 -1
  18. package/dist/server/Mppx.d.ts.map +1 -1
  19. package/dist/server/Mppx.js +70 -1
  20. package/dist/server/Mppx.js.map +1 -1
  21. package/dist/stripe/server/Charge.d.ts.map +1 -1
  22. package/dist/stripe/server/Charge.js +8 -1
  23. package/dist/stripe/server/Charge.js.map +1 -1
  24. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  25. package/dist/stripe/server/internal/html.gen.js +1 -1
  26. package/dist/tempo/server/Charge.d.ts.map +1 -1
  27. package/dist/tempo/server/Charge.js +15 -4
  28. package/dist/tempo/server/Charge.js.map +1 -1
  29. package/dist/tempo/server/Session.d.ts +39 -38
  30. package/dist/tempo/server/Session.d.ts.map +1 -1
  31. package/dist/tempo/server/Session.js +14 -24
  32. package/dist/tempo/server/Session.js.map +1 -1
  33. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  34. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  35. package/dist/tempo/server/internal/html.gen.js +1 -1
  36. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  37. package/dist/tempo/server/internal/request-body.d.ts +8 -0
  38. package/dist/tempo/server/internal/request-body.d.ts.map +1 -0
  39. package/dist/tempo/server/internal/request-body.js +27 -0
  40. package/dist/tempo/server/internal/request-body.js.map +1 -0
  41. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  42. package/dist/tempo/server/internal/transport.js +4 -14
  43. package/dist/tempo/server/internal/transport.js.map +1 -1
  44. package/package.json +3 -3
  45. package/src/cli/cli.test.ts +36 -7
  46. package/src/cli/cli.ts +33 -1
  47. package/src/client/Mppx.ts +11 -2
  48. package/src/client/index.ts +1 -0
  49. package/src/client/internal/Fetch.browser.test.ts +58 -0
  50. package/src/client/internal/Fetch.test.ts +173 -0
  51. package/src/client/internal/Fetch.ts +62 -3
  52. package/src/server/Mppx.test-d.ts +36 -0
  53. package/src/server/Mppx.test.ts +926 -1
  54. package/src/server/Mppx.ts +141 -2
  55. package/src/server/Transport.test.ts +2 -1
  56. package/src/stripe/server/Charge.ts +7 -1
  57. package/src/stripe/server/internal/html/package.json +1 -1
  58. package/src/stripe/server/internal/html.gen.ts +1 -1
  59. package/src/tempo/server/Charge.ts +15 -4
  60. package/src/tempo/server/Session.test.ts +68 -0
  61. package/src/tempo/server/Session.ts +15 -35
  62. package/src/tempo/server/internal/html/package.json +1 -1
  63. package/src/tempo/server/internal/html.gen.ts +1 -1
  64. package/src/tempo/server/internal/request-body.test.ts +142 -0
  65. package/src/tempo/server/internal/request-body.ts +37 -0
  66. package/src/tempo/server/internal/transport.test.ts +42 -2
  67. package/src/tempo/server/internal/transport.ts +4 -16
@@ -0,0 +1,142 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import {
4
+ captureRequestBodyProbe,
5
+ hasCapturedRequestBody,
6
+ isSessionContentRequest,
7
+ shouldChargePlainResponse,
8
+ } from './request-body.js'
9
+
10
+ describe('request-body', () => {
11
+ describe('hasCapturedRequestBody', () => {
12
+ test('returns true when the captured request recorded a body stream', () => {
13
+ expect(
14
+ hasCapturedRequestBody({
15
+ hasBody: true,
16
+ headers: new Headers(),
17
+ }),
18
+ ).toBe(true)
19
+ })
20
+
21
+ test('returns true when content-length is non-zero', () => {
22
+ expect(
23
+ hasCapturedRequestBody({
24
+ headers: new Headers({ 'content-length': '42' }),
25
+ }),
26
+ ).toBe(true)
27
+ })
28
+
29
+ test('returns true when transfer-encoding is present', () => {
30
+ expect(
31
+ hasCapturedRequestBody({
32
+ headers: new Headers({ 'transfer-encoding': 'chunked' }),
33
+ }),
34
+ ).toBe(true)
35
+ })
36
+
37
+ test('returns false for bodyless requests without framing headers', () => {
38
+ expect(
39
+ hasCapturedRequestBody({
40
+ hasBody: false,
41
+ headers: new Headers(),
42
+ }),
43
+ ).toBe(false)
44
+ })
45
+ })
46
+
47
+ describe('isSessionContentRequest', () => {
48
+ test('treats GET requests as content requests', () => {
49
+ expect(
50
+ isSessionContentRequest({
51
+ headers: new Headers(),
52
+ method: 'GET',
53
+ }),
54
+ ).toBe(true)
55
+ })
56
+
57
+ test('treats HEAD requests as management requests', () => {
58
+ expect(
59
+ isSessionContentRequest({
60
+ headers: new Headers(),
61
+ method: 'HEAD',
62
+ }),
63
+ ).toBe(false)
64
+ })
65
+
66
+ test('treats POST requests with a body stream and no content-length as content requests', () => {
67
+ expect(
68
+ isSessionContentRequest({
69
+ hasBody: true,
70
+ headers: new Headers(),
71
+ method: 'POST',
72
+ }),
73
+ ).toBe(true)
74
+ })
75
+
76
+ test('treats bodyless POST requests as management requests', () => {
77
+ expect(
78
+ isSessionContentRequest({
79
+ hasBody: false,
80
+ headers: new Headers(),
81
+ method: 'POST',
82
+ }),
83
+ ).toBe(false)
84
+ })
85
+ })
86
+
87
+ describe('shouldChargePlainResponse', () => {
88
+ test('does not charge close or topUp actions', () => {
89
+ const input = {
90
+ hasBody: true,
91
+ headers: new Headers(),
92
+ method: 'POST',
93
+ } as const
94
+
95
+ expect(shouldChargePlainResponse(input, { action: 'close' })).toBe(false)
96
+ expect(shouldChargePlainResponse(input, { action: 'topUp' })).toBe(false)
97
+ })
98
+
99
+ test('charges POST content requests detected via the body stream', () => {
100
+ expect(
101
+ shouldChargePlainResponse(
102
+ {
103
+ hasBody: true,
104
+ headers: new Headers(),
105
+ method: 'POST',
106
+ },
107
+ { action: 'voucher' },
108
+ ),
109
+ ).toBe(true)
110
+ })
111
+
112
+ test('does not charge bodyless POST management requests', () => {
113
+ expect(
114
+ shouldChargePlainResponse(
115
+ {
116
+ hasBody: false,
117
+ headers: new Headers(),
118
+ method: 'POST',
119
+ },
120
+ { action: 'voucher' },
121
+ ),
122
+ ).toBe(false)
123
+ })
124
+ })
125
+
126
+ describe('captureRequestBodyProbe', () => {
127
+ test('captures body presence from Request.body', () => {
128
+ const request = new Request('https://example.com', {
129
+ body: JSON.stringify({ prompt: 'hello' }),
130
+ method: 'POST',
131
+ })
132
+
133
+ const probe = captureRequestBodyProbe(request)
134
+ expect(request.headers.get('content-length')).toBeNull()
135
+ expect(probe).toEqual({
136
+ headers: request.headers,
137
+ hasBody: true,
138
+ method: 'POST',
139
+ })
140
+ })
141
+ })
142
+ })
@@ -0,0 +1,37 @@
1
+ import type * as Method from '../../../Method.js'
2
+ import type { SessionCredentialPayload } from '../../session/Types.js'
3
+
4
+ export type RequestBodyProbe = Pick<Method.CapturedRequest, 'headers' | 'hasBody' | 'method'>
5
+
6
+ export function captureRequestBodyProbe(input: Request): RequestBodyProbe {
7
+ return {
8
+ headers: input.headers,
9
+ hasBody: input.body !== null,
10
+ method: input.method,
11
+ }
12
+ }
13
+
14
+ export function hasCapturedRequestBody(
15
+ input: Pick<RequestBodyProbe, 'headers' | 'hasBody'>,
16
+ ): boolean {
17
+ const contentLength = input.headers.get('content-length')
18
+ const headerIndicatesBody =
19
+ (contentLength !== null && contentLength !== '0') || input.headers.has('transfer-encoding')
20
+
21
+ if (input.hasBody === true) return true
22
+ return headerIndicatesBody
23
+ }
24
+
25
+ export function isSessionContentRequest(input: RequestBodyProbe): boolean {
26
+ if (input.method === 'HEAD') return false
27
+ if (input.method !== 'POST') return true
28
+ return hasCapturedRequestBody(input)
29
+ }
30
+
31
+ export function shouldChargePlainResponse(
32
+ input: RequestBodyProbe,
33
+ payload: Partial<SessionCredentialPayload>,
34
+ ): boolean {
35
+ if (payload.action === 'close' || payload.action === 'topUp') return false
36
+ return isSessionContentRequest(input)
37
+ }
@@ -565,7 +565,13 @@ describe('sse transport', () => {
565
565
  const store = memoryStore()
566
566
  await seedChannel(store, 10000000n)
567
567
  const transport = sse({ store })
568
- const request = makeAuthorizedRequest()
568
+ const request = new Request('https://test.example.com/session', {
569
+ body: JSON.stringify({ prompt: 'hello' }),
570
+ headers: makeAuthorizedRequest().headers,
571
+ method: 'POST',
572
+ })
573
+
574
+ expect(request.headers.get('content-length')).toBeNull()
569
575
 
570
576
  const plainResponse = new Response(JSON.stringify({ content: 'hello' }), {
571
577
  headers: { 'Content-Type': 'application/json' },
@@ -593,11 +599,45 @@ describe('sse transport', () => {
593
599
  expect(response.headers.get('Payment-Receipt')).toBeTruthy()
594
600
  })
595
601
 
602
+ test('respondReceipt with bodyless POST management request does not deduct from channel', async () => {
603
+ const store = memoryStore()
604
+ await seedChannel(store, 10000000n)
605
+ const transport = sse({ store })
606
+
607
+ const response = transport.respondReceipt({
608
+ credential: Credential.from({
609
+ challenge: makeChallenge(),
610
+ payload: {
611
+ action: 'voucher',
612
+ channelId,
613
+ cumulativeAmount: '1000000',
614
+ signature: '0xdeadbeef',
615
+ },
616
+ }),
617
+ input: new Request('https://test.example.com/session', { method: 'POST' }),
618
+ receipt: makeReceipt(),
619
+ response: new Response(JSON.stringify({ ok: true }), {
620
+ headers: { 'Content-Type': 'application/json' },
621
+ }),
622
+ challengeId,
623
+ })
624
+
625
+ expect(await response.text()).toBe('{"ok":true}')
626
+
627
+ const channel = await store.getChannel(channelId)
628
+ expect(channel!.spent).toBe(0n)
629
+ expect(channel!.units).toBe(0)
630
+ })
631
+
596
632
  test('respondReceipt with 204 content response still deducts from channel', async () => {
597
633
  const store = memoryStore()
598
634
  await seedChannel(store, 10000000n)
599
635
  const transport = sse({ store })
600
- const request = makeAuthorizedRequest()
636
+ const request = new Request('https://test.example.com/session', {
637
+ body: JSON.stringify({ prompt: 'hello' }),
638
+ headers: makeAuthorizedRequest().headers,
639
+ method: 'POST',
640
+ })
601
641
 
602
642
  const contentResponse = new Response(null, { status: 204 })
603
643
  const response = transport.respondReceipt({
@@ -11,6 +11,7 @@ import * as Transport from '../../../server/Transport.js'
11
11
  import * as ChannelStore from '../../session/ChannelStore.js'
12
12
  import * as Sse_core from '../../session/Sse.js'
13
13
  import type { SessionCredentialPayload, SessionReceipt } from '../../session/Types.js'
14
+ import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js'
14
15
 
15
16
  /** SSE transport with Tempo session controller. */
16
17
  export type Sse = Transport.Sse<Sse_core.SessionController>
@@ -43,8 +44,8 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
43
44
  captureRequest(request) {
44
45
  return (
45
46
  base.captureRequest?.(request) ?? {
46
- hasBody: request.body !== null,
47
47
  headers: new Headers(request.headers),
48
+ hasBody: request.body !== null,
48
49
  method: request.method,
49
50
  url: new URL(request.url),
50
51
  }
@@ -106,7 +107,8 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
106
107
  challengeId: verifiedChallengeId,
107
108
  })
108
109
 
109
- if (!shouldChargePlainResponse(input, payload)) {
110
+ const request = envelope?.capturedRequest ?? captureRequestBodyProbe(input)
111
+ if (!shouldChargePlainResponse(request, payload)) {
110
112
  return baseResponse
111
113
  }
112
114
 
@@ -274,17 +276,3 @@ function resolveMeteredGenerate(
274
276
  function isNullBodyStatus(status: number): boolean {
275
277
  return [101, 204, 205, 304].includes(status)
276
278
  }
277
-
278
- function shouldChargePlainResponse(
279
- input: Request,
280
- payload: Partial<SessionCredentialPayload>,
281
- ): boolean {
282
- if (payload.action === 'close' || payload.action === 'topUp') return false
283
- if (input.method !== 'POST') return true
284
-
285
- const contentLength = input.headers.get('content-length')
286
- if (contentLength !== null && contentLength !== '0') return true
287
- if (input.headers.has('transfer-encoding')) return true
288
-
289
- return false
290
- }