mppx 0.5.7 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Challenge.d.ts +3 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +27 -9
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +32 -14
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js.map +1 -1
  9. package/dist/Store.d.ts +68 -2
  10. package/dist/Store.d.ts.map +1 -1
  11. package/dist/Store.js +41 -4
  12. package/dist/Store.js.map +1 -1
  13. package/dist/mcp-sdk/server/Transport.d.ts.map +1 -1
  14. package/dist/mcp-sdk/server/Transport.js +7 -0
  15. package/dist/mcp-sdk/server/Transport.js.map +1 -1
  16. package/dist/server/Mppx.d.ts +1 -1
  17. package/dist/server/Mppx.d.ts.map +1 -1
  18. package/dist/server/Mppx.js +133 -70
  19. package/dist/server/Mppx.js.map +1 -1
  20. package/dist/server/Transport.d.ts +8 -2
  21. package/dist/server/Transport.d.ts.map +1 -1
  22. package/dist/server/Transport.js +26 -1
  23. package/dist/server/Transport.js.map +1 -1
  24. package/dist/tempo/client/SessionManager.d.ts +13 -2
  25. package/dist/tempo/client/SessionManager.d.ts.map +1 -1
  26. package/dist/tempo/client/SessionManager.js +429 -4
  27. package/dist/tempo/client/SessionManager.js.map +1 -1
  28. package/dist/tempo/internal/fee-payer.d.ts +28 -0
  29. package/dist/tempo/internal/fee-payer.d.ts.map +1 -1
  30. package/dist/tempo/internal/fee-payer.js +89 -0
  31. package/dist/tempo/internal/fee-payer.js.map +1 -1
  32. package/dist/tempo/server/Charge.d.ts +4 -1
  33. package/dist/tempo/server/Charge.d.ts.map +1 -1
  34. package/dist/tempo/server/Charge.js +90 -66
  35. package/dist/tempo/server/Charge.js.map +1 -1
  36. package/dist/tempo/server/Methods.d.ts +3 -0
  37. package/dist/tempo/server/Methods.d.ts.map +1 -1
  38. package/dist/tempo/server/Methods.js +3 -0
  39. package/dist/tempo/server/Methods.js.map +1 -1
  40. package/dist/tempo/server/Session.d.ts +8 -2
  41. package/dist/tempo/server/Session.d.ts.map +1 -1
  42. package/dist/tempo/server/Session.js.map +1 -1
  43. package/dist/tempo/server/index.d.ts +1 -0
  44. package/dist/tempo/server/index.d.ts.map +1 -1
  45. package/dist/tempo/server/index.js +1 -0
  46. package/dist/tempo/server/index.js.map +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  48. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  49. package/dist/tempo/server/internal/html.gen.js +1 -1
  50. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  51. package/dist/tempo/server/internal/transport.d.ts.map +1 -1
  52. package/dist/tempo/server/internal/transport.js +16 -6
  53. package/dist/tempo/server/internal/transport.js.map +1 -1
  54. package/dist/tempo/session/ChannelStore.d.ts +12 -1
  55. package/dist/tempo/session/ChannelStore.d.ts.map +1 -1
  56. package/dist/tempo/session/ChannelStore.js +55 -14
  57. package/dist/tempo/session/ChannelStore.js.map +1 -1
  58. package/dist/tempo/session/Sse.d.ts +11 -2
  59. package/dist/tempo/session/Sse.d.ts.map +1 -1
  60. package/dist/tempo/session/Sse.js +66 -25
  61. package/dist/tempo/session/Sse.js.map +1 -1
  62. package/dist/tempo/session/Ws.d.ts +87 -0
  63. package/dist/tempo/session/Ws.d.ts.map +1 -0
  64. package/dist/tempo/session/Ws.js +428 -0
  65. package/dist/tempo/session/Ws.js.map +1 -0
  66. package/dist/tempo/session/index.d.ts +1 -0
  67. package/dist/tempo/session/index.d.ts.map +1 -1
  68. package/dist/tempo/session/index.js +1 -0
  69. package/dist/tempo/session/index.js.map +1 -1
  70. package/package.json +2 -2
  71. package/src/Challenge.test.ts +1 -1
  72. package/src/Challenge.ts +28 -9
  73. package/src/Method.ts +61 -20
  74. package/src/Store.test-d.ts +80 -2
  75. package/src/Store.test.ts +150 -13
  76. package/src/Store.ts +140 -3
  77. package/src/mcp-sdk/server/Transport.test.ts +12 -0
  78. package/src/mcp-sdk/server/Transport.ts +8 -0
  79. package/src/server/Mppx.test.ts +105 -0
  80. package/src/server/Mppx.ts +178 -88
  81. package/src/server/Transport.test.ts +31 -0
  82. package/src/server/Transport.ts +31 -2
  83. package/src/tempo/client/SessionManager.ts +510 -7
  84. package/src/tempo/internal/fee-payer.test.ts +115 -1
  85. package/src/tempo/internal/fee-payer.ts +138 -1
  86. package/src/tempo/server/AtomicStore.test-d.ts +34 -0
  87. package/src/tempo/server/Charge.test.ts +128 -0
  88. package/src/tempo/server/Charge.ts +118 -93
  89. package/src/tempo/server/Methods.ts +3 -0
  90. package/src/tempo/server/Session.test.ts +1044 -47
  91. package/src/tempo/server/Session.ts +8 -2
  92. package/src/tempo/server/Sse.test.ts +29 -0
  93. package/src/tempo/server/index.ts +1 -0
  94. package/src/tempo/server/internal/html/main.ts +9 -10
  95. package/src/tempo/server/internal/html.gen.ts +1 -1
  96. package/src/tempo/server/internal/transport.ts +19 -6
  97. package/src/tempo/session/ChannelStore.test.ts +20 -1
  98. package/src/tempo/session/ChannelStore.ts +77 -14
  99. package/src/tempo/session/Sse.ts +77 -24
  100. package/src/tempo/session/Ws.test.ts +410 -0
  101. package/src/tempo/session/Ws.ts +563 -0
  102. package/src/tempo/session/index.ts +1 -0
@@ -38,6 +38,16 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
38
38
  return Transport.from<Request, Response, Transport.ReceiptResponseOf<Sse>, Response>({
39
39
  name: 'sse',
40
40
 
41
+ captureRequest(request) {
42
+ return (
43
+ base.captureRequest?.(request) ?? {
44
+ headers: new Headers(request.headers),
45
+ method: request.method,
46
+ url: new URL(request.url),
47
+ }
48
+ )
49
+ },
50
+
41
51
  getCredential(request) {
42
52
  return base.getCredential(request)
43
53
  },
@@ -46,11 +56,13 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
46
56
  return base.respondChallenge(options) as Response
47
57
  },
48
58
 
49
- respondReceipt({ credential, receipt, response, challengeId, input }) {
50
- const payload = credential.payload as Partial<SessionCredentialPayload>
59
+ respondReceipt({ credential, envelope, receipt, response, challengeId, input }) {
60
+ const verifiedCredential = envelope?.credential ?? credential
61
+ const verifiedChallengeId = envelope?.challenge.id ?? challengeId
62
+ const payload = verifiedCredential.payload as Partial<SessionCredentialPayload>
51
63
  if (!payload.channelId) throw new Error('No SSE context available')
52
64
  const channelId = payload.channelId
53
- const tickCost = BigInt(credential.challenge.request.amount as string)
65
+ const tickCost = BigInt(verifiedCredential.challenge.request.amount as string)
54
66
 
55
67
  // Auto-detect upstream SSE responses and parse them into an
56
68
  // AsyncIterable so they flow through the metered pipeline.
@@ -71,7 +83,7 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
71
83
  const stream = Sse_core.serve({
72
84
  store,
73
85
  channelId,
74
- challengeId,
86
+ challengeId: verifiedChallengeId,
75
87
  tickCost,
76
88
  pollIntervalMs: pollingInterval,
77
89
  generate,
@@ -81,11 +93,12 @@ export function sse(options: sse.Options & { store: ChannelStore.ChannelStore })
81
93
  }
82
94
 
83
95
  const baseResponse = base.respondReceipt({
84
- credential,
96
+ credential: verifiedCredential,
97
+ envelope,
85
98
  input,
86
99
  receipt,
87
100
  response: response as Response,
88
- challengeId,
101
+ challengeId: verifiedChallengeId,
89
102
  })
90
103
 
91
104
  // Non-SSE response (e.g. upstream returned JSON instead of event-stream).
@@ -37,7 +37,7 @@ function seedChannel(
37
37
  return store.updateChannel(channelId, () => makeChannel(overrides))
38
38
  }
39
39
 
40
- function stripUpdateMethod(store: Store.Store): Store.Store {
40
+ function stripUpdateMethod(store: Store.Store | Store.AtomicStore): Store.Store {
41
41
  return {
42
42
  get: store.get.bind(store),
43
43
  put: store.put.bind(store),
@@ -209,6 +209,25 @@ describe('channelStore', () => {
209
209
  await sleep(10)
210
210
  expect(ch1Resolved).toBe(false)
211
211
  })
212
+
213
+ test('resolves on successful deductFromChannel with atomic store.update', async () => {
214
+ const cs = ChannelStore.fromStore(Store.memory())
215
+ await seedChannel(cs, { highestVoucherAmount: 5_000_000n, spent: 0n })
216
+
217
+ let resolved = false
218
+ const waiter = cs.waitForUpdate!(channelId).then(() => {
219
+ resolved = true
220
+ })
221
+
222
+ await sleep(10)
223
+ expect(resolved).toBe(false)
224
+
225
+ const result = await ChannelStore.deductFromChannel(cs, channelId, 1_000_000n)
226
+ expect(result.ok).toBe(true)
227
+
228
+ await waiter
229
+ expect(resolved).toBe(true)
230
+ })
212
231
  })
213
232
  })
214
233
 
@@ -65,6 +65,9 @@ export interface State {
65
65
  * guarantee that no concurrent mutation occurs between reading `current`
66
66
  * and writing the return value.
67
67
  *
68
+ * Callbacks should be synchronous and deterministic. When a `ChannelStore`
69
+ * is backed by `Store.update()`, adapters may retry them internally.
70
+ *
68
71
  * Backends implement this via their native mechanisms:
69
72
  * - **In-memory / JS single-thread**: Synchronous callback execution
70
73
  * - **Durable Objects**: Single-threaded execution model
@@ -90,6 +93,18 @@ export type ChannelStore = {
90
93
  * When not implemented, callers fall back to polling.
91
94
  */
92
95
  waitForUpdate?(channelId: Hex): Promise<void>
96
+
97
+ /**
98
+ * Atomic read-modify-write that returns the callback's `result` directly.
99
+ *
100
+ * Used by {@link deductFromChannel} to atomically compute the deduction
101
+ * outcome. When backed by `Store.update()`, this delegates to the store's
102
+ * native atomic primitive.
103
+ */
104
+ updateChannelResult?<result>(
105
+ channelId: Hex,
106
+ fn: (current: State | null) => Store.Change<State, result>,
107
+ ): Promise<result>
93
108
  }
94
109
 
95
110
  export type DeductResult = { ok: true; channel: State } | { ok: false; channel: State }
@@ -106,19 +121,41 @@ export async function deductFromChannel(
106
121
  channelId: Hex,
107
122
  amount: bigint,
108
123
  ): Promise<DeductResult> {
109
- let deducted = false
124
+ if (store.updateChannelResult) {
125
+ const result = await store.updateChannelResult<DeductResult | null>(
126
+ channelId,
127
+ (current): Store.Change<State, DeductResult | null> => {
128
+ if (!current) return { op: 'noop', result: null }
129
+ if (current.finalized)
130
+ return { op: 'noop', result: { ok: false, channel: current } as const }
131
+ if (current.highestVoucherAmount - current.spent >= amount) {
132
+ const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
133
+ return { op: 'set', value: next, result: { ok: true, channel: next } as const }
134
+ }
135
+ return { op: 'noop', result: { ok: false, channel: current } as const }
136
+ },
137
+ )
138
+ if (!result) throw new Error('channel not found')
139
+ return result
140
+ }
141
+
142
+ let result: DeductResult | null = null
110
143
  const channel = await store.updateChannel(channelId, (current) => {
111
- deducted = false
112
144
  if (!current) return null
113
- if (current.finalized) return current
145
+ if (current.finalized) {
146
+ result = { ok: false, channel: current }
147
+ return current
148
+ }
114
149
  if (current.highestVoucherAmount - current.spent >= amount) {
115
- deducted = true
116
- return { ...current, spent: current.spent + amount, units: current.units + 1 }
150
+ const next = { ...current, spent: current.spent + amount, units: current.units + 1 }
151
+ result = { ok: true, channel: next }
152
+ return next
117
153
  }
154
+ result = { ok: false, channel: current }
118
155
  return current
119
156
  })
120
157
  if (!channel) throw new Error('channel not found')
121
- return { ok: deducted, channel }
158
+ return result ?? { ok: false, channel }
122
159
  }
123
160
 
124
161
  /**
@@ -140,10 +177,12 @@ export async function deductFromChannel(
140
177
  */
141
178
  const storeCache = new WeakMap<Store.Store, ChannelStore>()
142
179
 
143
- export function fromStore(store: Store.Store): ChannelStore {
180
+ export function fromStore(store: Store.Store | Store.AtomicStore): ChannelStore {
144
181
  const cached = storeCache.get(store)
145
182
  if (cached) return cached
146
183
 
184
+ const atomicUpdate = 'update' in store ? (store as Store.AtomicStore).update : undefined
185
+
147
186
  const waiters = new Map<string, Set<() => void>>()
148
187
  const locks = new Map<string, Promise<void>>()
149
188
 
@@ -158,6 +197,29 @@ export function fromStore(store: Store.Store): ChannelStore {
158
197
  channelId: Hex,
159
198
  fn: (current: State | null) => State | null,
160
199
  ): Promise<State | null> {
200
+ return updateResult(channelId, (current) => {
201
+ const next = fn(current)
202
+ if (next) return { op: 'set', value: next, result: next }
203
+ return { op: 'delete', result: null }
204
+ })
205
+ }
206
+
207
+ async function updateResult<result>(
208
+ channelId: Hex,
209
+ fn: (current: State | null) => Store.Change<State, result>,
210
+ ): Promise<result> {
211
+ let change: Store.Change<State, result> | undefined
212
+
213
+ if (atomicUpdate) {
214
+ const result = await atomicUpdate(channelId, (current) => {
215
+ change = fn((current as State | null) ?? null)
216
+ if (change.op !== 'set') return change
217
+ return { ...change, value: change.value as never }
218
+ })
219
+ if (change?.op !== 'noop') notify(channelId)
220
+ return result
221
+ }
222
+
161
223
  while (locks.has(channelId)) await locks.get(channelId)
162
224
 
163
225
  let release!: () => void
@@ -170,10 +232,11 @@ export function fromStore(store: Store.Store): ChannelStore {
170
232
 
171
233
  try {
172
234
  const current = (await store.get(channelId)) as State | null
173
- const next = fn(current)
174
- if (next) await store.put(channelId, next as never)
175
- else await store.delete(channelId)
176
- return next
235
+ change = fn(current)
236
+ if (change.op === 'set') await store.put(channelId, change.value as never)
237
+ if (change.op === 'delete') await store.delete(channelId)
238
+ if (change.op !== 'noop') notify(channelId)
239
+ return change.result
177
240
  } finally {
178
241
  locks.delete(channelId)
179
242
  release()
@@ -185,9 +248,7 @@ export function fromStore(store: Store.Store): ChannelStore {
185
248
  return (await store.get(channelId)) as State | null
186
249
  },
187
250
  async updateChannel(channelId, fn) {
188
- const result = await update(channelId, fn)
189
- notify(channelId)
190
- return result
251
+ return update(channelId, fn)
191
252
  },
192
253
  waitForUpdate(channelId) {
193
254
  return new Promise<void>((resolve) => {
@@ -201,6 +262,8 @@ export function fromStore(store: Store.Store): ChannelStore {
201
262
  },
202
263
  }
203
264
 
265
+ cs.updateChannelResult = updateResult
266
+
204
267
  storeCache.set(store, cs)
205
268
  return cs
206
269
  }
@@ -80,6 +80,13 @@ export function parseEvent(raw: string): SseEvent | null {
80
80
  }
81
81
 
82
82
  export type SessionController = {
83
+ /**
84
+ * Reserve voucher coverage for the next emitted chunk.
85
+ *
86
+ * The reservation blocks until sufficient voucher headroom exists, but the
87
+ * charge is only committed once a chunk is actually emitted. If the stream
88
+ * ends or aborts before that emission, the reservation is dropped.
89
+ */
83
90
  charge(): Promise<void>
84
91
  }
85
92
 
@@ -93,11 +100,13 @@ export type SessionController = {
93
100
  * generator controls when charges happen by calling `stream.charge()`.
94
101
  *
95
102
  * For each emitted value the stream:
96
- * 1. Deducts `tickCost` from the channel balance atomically (auto or manual).
103
+ * 1. Reserves `tickCost` from the channel's available voucher headroom
104
+ * (auto or manual).
97
105
  * 2. If balance is sufficient, emits `event: message` with the value.
98
106
  * 3. If balance is exhausted, emits `event: payment-need-voucher`
99
107
  * and polls store until the client tops up the channel.
100
- * 4. On generator completion, emits a final `event: payment-receipt`.
108
+ * 4. Commits the reserved charge immediately before the chunk is emitted.
109
+ * 5. On generator completion, emits a final `event: payment-receipt`.
101
110
  *
102
111
  * Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
103
112
  */
@@ -118,15 +127,21 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
118
127
  async start(controller) {
119
128
  const aborted = () => signal?.aborted ?? false
120
129
  const emit = (event: string) => controller.enqueue(encoder.encode(event))
130
+ let reservedAmount = 0n
131
+ let reservedUnits = 0
121
132
 
122
133
  const charge = () =>
123
- chargeOrWait({
134
+ reserveChargeOrWait({
124
135
  store,
125
136
  channelId,
126
137
  amount: tickCost,
138
+ reservedAmount,
127
139
  emit,
128
140
  pollIntervalMs,
129
141
  signal,
142
+ }).then(() => {
143
+ reservedAmount += tickCost
144
+ reservedUnits += 1
130
145
  })
131
146
 
132
147
  const iterable: AsyncIterable<string> =
@@ -137,7 +152,14 @@ export function serve(options: serve.Options): ReadableStream<Uint8Array> {
137
152
  if (aborted()) break
138
153
 
139
154
  if (typeof generate !== 'function') await charge()
140
-
155
+ await commitReservedCharges({
156
+ store,
157
+ channelId,
158
+ amount: reservedAmount,
159
+ units: reservedUnits,
160
+ })
161
+ reservedAmount = 0n
162
+ reservedUnits = 0
141
163
  controller.enqueue(encoder.encode(`event: message\ndata: ${value}\n\n`))
142
164
  }
143
165
 
@@ -221,45 +243,76 @@ export declare namespace fromRequest {
221
243
  }
222
244
 
223
245
  /**
224
- * Atomically deduct `amount` from a channel, retrying when balance is
225
- * insufficient. Uses `store.waitForUpdate()` when available for
246
+ * Reserve `amount` of voucher headroom for a future emission, retrying when
247
+ * balance is insufficient. Uses `store.waitForUpdate()` when available for
226
248
  * event-driven wakeups, falling back to polling otherwise. Emits
227
249
  * `payment-need-voucher` events via `emit` while waiting.
228
250
  */
229
- async function chargeOrWait(options: {
251
+ async function reserveChargeOrWait(options: {
230
252
  store: ChannelStore.ChannelStore
231
253
  channelId: Hex
232
254
  amount: bigint
233
- emit: (event: string) => void
255
+ reservedAmount: bigint
256
+ emit: (event: string) => void | Promise<void>
234
257
  pollIntervalMs: number
235
258
  signal?: AbortSignal | undefined
236
259
  }): Promise<void> {
237
- const { store, channelId, amount, emit, pollIntervalMs, signal } = options
260
+ const { store, channelId, amount, emit, pollIntervalMs, reservedAmount, signal } = options
261
+
262
+ let channel = await store.getChannel(channelId)
263
+ if (!channel) throw new Error('channel not found')
238
264
 
239
- let result = await ChannelStore.deductFromChannel(store, channelId, amount)
265
+ const hasHeadroom = (state: ChannelStore.State) =>
266
+ state.highestVoucherAmount - state.spent - reservedAmount >= amount
240
267
 
241
- if (!result.ok) {
242
- // Emit a single need-voucher event, then poll/wait until the client
243
- // sends an updated voucher. The requiredCumulative is constant here:
244
- // `spent` only changes on successful deduction (which exits the loop),
245
- // so re-emitting on every poll cycle would just cause redundant
246
- // voucher POSTs from the client.
268
+ if (hasHeadroom(channel)) return
269
+
270
+ // Emit a single need-voucher event, then wait until the accepted voucher
271
+ // headroom covers both already-reserved units and the next requested unit.
272
+ await Promise.resolve(
247
273
  emit(
248
274
  formatNeedVoucherEvent({
249
275
  channelId,
250
- requiredCumulative: (result.channel.spent + amount).toString(),
251
- acceptedCumulative: result.channel.highestVoucherAmount.toString(),
252
- deposit: result.channel.deposit.toString(),
276
+ requiredCumulative: (channel.spent + reservedAmount + amount).toString(),
277
+ acceptedCumulative: channel.highestVoucherAmount.toString(),
278
+ deposit: channel.deposit.toString(),
253
279
  }),
254
- )
280
+ ),
281
+ )
255
282
 
256
- while (!result.ok) {
257
- await waitForUpdate(store, channelId, pollIntervalMs, signal)
258
- result = await ChannelStore.deductFromChannel(store, channelId, amount)
259
- }
283
+ while (!hasHeadroom(channel)) {
284
+ await waitForUpdate(store, channelId, pollIntervalMs, signal)
285
+ channel = await store.getChannel(channelId)
286
+ if (!channel) throw new Error('channel not found')
260
287
  }
261
288
  }
262
289
 
290
+ async function commitReservedCharges(options: {
291
+ store: ChannelStore.ChannelStore
292
+ channelId: Hex
293
+ amount: bigint
294
+ units: number
295
+ }): Promise<void> {
296
+ const { store, channelId, amount, units } = options
297
+ if (amount === 0n || units === 0) return
298
+
299
+ let committed = false
300
+ const channel = await store.updateChannel(channelId, (current) => {
301
+ if (!current) return null
302
+ if (current.finalized) return current
303
+ if (current.highestVoucherAmount - current.spent < amount) return current
304
+ committed = true
305
+ return {
306
+ ...current,
307
+ spent: current.spent + amount,
308
+ units: current.units + units,
309
+ }
310
+ })
311
+
312
+ if (!channel) throw new Error('channel not found')
313
+ if (!committed) throw new Error('reserved voucher coverage is no longer available')
314
+ }
315
+
263
316
  async function waitForUpdate(
264
317
  store: ChannelStore.ChannelStore,
265
318
  channelId: Hex,