mppx 0.17.0 → 0.17.3

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 (83) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/BodyDigest.js +13 -1
  3. package/dist/Method.d.ts +5 -0
  4. package/dist/Store.d.ts +225 -0
  5. package/dist/Store.js +358 -0
  6. package/dist/cli/cli.js +136 -135
  7. package/dist/cli/plugins/index.js +12 -11
  8. package/dist/cli/sessions/Manager.js +12 -1
  9. package/dist/cli/sessions/store.js +7 -1
  10. package/dist/cli/utils.d.ts +2 -0
  11. package/dist/cli/utils.js +6 -0
  12. package/dist/cli/validate/helpers.d.ts +1 -1
  13. package/dist/cli/validate/helpers.js +14 -2
  14. package/dist/client/Mppx.d.ts +1 -1
  15. package/dist/client/Mppx.js +18 -6
  16. package/dist/client/internal/Fetch.d.ts +3 -1
  17. package/dist/client/internal/Fetch.js +68 -3
  18. package/dist/client/internal/MethodResponse.d.ts +8 -0
  19. package/dist/client/internal/MethodResponse.js +9 -0
  20. package/dist/client/internal/ProofRealm.d.ts +3 -3
  21. package/dist/client/internal/ProofRealm.js +4 -4
  22. package/dist/client/internal/protocols/Mcp.d.ts +4 -0
  23. package/dist/client/internal/protocols/Mcp.js +27 -14
  24. package/dist/identity/Client.js +7 -1
  25. package/dist/internal/AcceptPayment.d.ts +12 -2
  26. package/dist/internal/AcceptPayment.js +12 -3
  27. package/dist/internal/sse.d.ts +21 -0
  28. package/dist/internal/sse.js +65 -0
  29. package/dist/internal/version.d.ts +2 -2
  30. package/dist/internal/version.js +1 -1
  31. package/dist/mcp/client/McpClient.d.ts +2 -2
  32. package/dist/mcp/client/McpClient.js +6 -3
  33. package/dist/proxy/Proxy.d.ts +17 -0
  34. package/dist/proxy/Proxy.js +116 -3
  35. package/dist/proxy/internal/Replay.d.ts +105 -0
  36. package/dist/proxy/internal/Replay.js +257 -0
  37. package/dist/server/Mppx.d.ts +2 -0
  38. package/dist/server/Mppx.js +7 -5
  39. package/dist/server/Transport.js +1 -1
  40. package/dist/server/internal/html/config.d.ts +7 -1
  41. package/dist/server/internal/html/config.js +4 -0
  42. package/dist/stripe/internal/types.d.ts +82 -6
  43. package/dist/stripe/server/Charge.js +1 -2
  44. package/dist/stripe/server/internal/html.gen.js +1 -1
  45. package/dist/tempo/client/Charge.d.ts +9 -0
  46. package/dist/tempo/client/Charge.js +13 -9
  47. package/dist/tempo/client/Methods.d.ts +2 -0
  48. package/dist/tempo/internal/fee-payer.d.ts +85 -10
  49. package/dist/tempo/internal/fee-payer.js +183 -19
  50. package/dist/tempo/legacy/session/Chain.js +2 -0
  51. package/dist/tempo/server/Charge.js +6 -3
  52. package/dist/tempo/server/Subscription.js +1 -0
  53. package/dist/tempo/server/internal/html.gen.js +1 -1
  54. package/dist/tempo/server/internal/transport.d.ts +6 -0
  55. package/dist/tempo/server/internal/transport.js +21 -21
  56. package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
  57. package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
  58. package/dist/tempo/session/client/ChannelStore.js +5 -0
  59. package/dist/tempo/session/client/CredentialState.js +2 -0
  60. package/dist/tempo/session/client/Session.d.ts +37 -2
  61. package/dist/tempo/session/client/Session.js +111 -7
  62. package/dist/tempo/session/client/SessionManager.d.ts +2 -1
  63. package/dist/tempo/session/client/SessionManager.js +60 -9
  64. package/dist/tempo/session/client/Transports.d.ts +4 -0
  65. package/dist/tempo/session/client/Transports.js +35 -3
  66. package/dist/tempo/session/precompile/Chain.js +1 -0
  67. package/dist/tempo/session/precompile/Voucher.js +20 -7
  68. package/dist/tempo/session/server/ChannelStore.d.ts +9 -0
  69. package/dist/tempo/session/server/ChannelStore.js +8 -1
  70. package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
  71. package/dist/tempo/session/server/MeteredStream.js +6 -1
  72. package/dist/tempo/session/server/Session.d.ts +2 -1
  73. package/dist/tempo/session/server/Session.js +2 -1
  74. package/dist/tempo/session/server/Settlement.d.ts +4 -2
  75. package/dist/tempo/session/server/Settlement.js +2 -2
  76. package/dist/tempo/session/server/Sse.d.ts +59 -0
  77. package/dist/tempo/session/server/Sse.js +175 -10
  78. package/dist/tempo/session/server/Ws.js +29 -12
  79. package/dist/zod.d.ts +2 -1
  80. package/dist/zod.js +1 -1
  81. package/package.json +1 -3
  82. package/dist/stripe/server/internal/html/types.d.ts +0 -2
  83. package/dist/stripe/server/internal/html/types.js +0 -2
package/dist/Store.js CHANGED
@@ -52,6 +52,28 @@ function isReplayMarker(value) {
52
52
  'type' in value &&
53
53
  value.type === 'mppx:replay');
54
54
  }
55
+ /**
56
+ * Runs `fn` on the stored value and commits its change with `swap`, a
57
+ * compare-and-swap. When another writer won, retries from the value `swap`
58
+ * returned (or a fresh read) after a jittered backoff.
59
+ */
60
+ async function compareAndSwap(parameters) {
61
+ const { fn, key, label, maxAttempts, read, swap } = parameters;
62
+ let raw = await read();
63
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
64
+ const change = fn(raw == null ? null : Json.parse(raw));
65
+ if (change.op === 'noop')
66
+ return change.result;
67
+ const swapped = await swap(raw, change.op === 'set' ? Json.stringify(change.value) : null);
68
+ if (swapped.applied)
69
+ return change.result;
70
+ if (attempt + 1 === maxAttempts)
71
+ break;
72
+ await new Promise((resolve) => setTimeout(resolve, Math.random() * Math.min(2 ** (attempt + 1), 200)));
73
+ raw = swapped.current !== undefined ? swapped.current : await read();
74
+ }
75
+ throw new Error(`${label} update of "${key}" conflicted ${maxAttempts} times.`);
76
+ }
55
77
  const keyPrefixCache = new WeakMap();
56
78
  export function from(store, options) {
57
79
  return withKeyPrefix(store, options?.keyPrefix);
@@ -126,6 +148,342 @@ export function cloudflare(kv, options) {
126
148
  ...wrapJsonUpdate(kv.update),
127
149
  }, options);
128
150
  }
151
+ /**
152
+ * Atomic store backed by Cloudflare Durable Objects.
153
+ *
154
+ * Each key lives in its own Durable Object instance (`idFromName(key)`), so
155
+ * reads and writes of one key are linearizable across every Worker isolate and
156
+ * location, while different keys (such as different payment channels) scale
157
+ * out across instances. {@link AtomicStore.update} runs `fn` in the Worker and
158
+ * commits with a compare-and-swap in the object. When another writer changed
159
+ * the key in between, the swap returns the stored value and the update retries
160
+ * from it after a jittered backoff; {@link AtomicStore.tryClaim} runs in a single
161
+ * object call.
162
+ *
163
+ * Bind {@link DurableObjectStore} as a SQLite-backed Durable Object and pass
164
+ * its namespace.
165
+ *
166
+ * @example
167
+ * ```ts
168
+ * import { Store } from 'mppx/server'
169
+ *
170
+ * export class MppxStore extends Store.DurableObjectStore {}
171
+ *
172
+ * export default {
173
+ * fetch(request: Request, env: Env) {
174
+ * const store = Store.durableObject(env.MPPX_STORE)
175
+ * // ...
176
+ * },
177
+ * }
178
+ * ```
179
+ */
180
+ export function durableObject(namespace, options) {
181
+ const maxAttempts = options?.maxAttempts ?? 32;
182
+ async function call(key, body) {
183
+ const stub = namespace.get(namespace.idFromName(key));
184
+ const response = await stub.fetch('https://mppx-store/', {
185
+ body: JSON.stringify(body),
186
+ headers: { 'content-type': 'application/json' },
187
+ method: 'POST',
188
+ });
189
+ if (!response.ok)
190
+ throw new Error(`Durable Object store request failed (${response.status}): ${await response.text()}`);
191
+ return (await response.json());
192
+ }
193
+ return from({
194
+ async get(key) {
195
+ const raw = await call(key, { op: 'get', key });
196
+ if (raw == null)
197
+ return null;
198
+ return Json.parse(raw);
199
+ },
200
+ async put(key, value) {
201
+ await call(key, { op: 'put', key, value: Json.stringify(value) });
202
+ },
203
+ async delete(key) {
204
+ await call(key, { op: 'delete', key });
205
+ },
206
+ update(key, fn) {
207
+ return compareAndSwap({
208
+ fn,
209
+ key,
210
+ label: 'Durable Object store',
211
+ maxAttempts,
212
+ read: () => call(key, { op: 'get', key }),
213
+ swap: (expected, value) => call(key, { op: 'swap', key, expected, value }),
214
+ });
215
+ },
216
+ async tryClaim(key, expires) {
217
+ if (!Number.isFinite(expires) || expires <= Date.now())
218
+ return false;
219
+ return call(key, { op: 'claim', key, expires });
220
+ },
221
+ }, options);
222
+ }
223
+ /**
224
+ * Durable Object class that holds the values of {@link durableObject}.
225
+ *
226
+ * Export a subclass of it from your Worker and bind it in
227
+ * `wrangler.jsonc` with a `new_sqlite_classes` migration. It needs no
228
+ * `cloudflare:workers` import, so it bundles anywhere.
229
+ *
230
+ * @example
231
+ * ```jsonc
232
+ * {
233
+ * "durable_objects": { "bindings": [{ "name": "MPPX_STORE", "class_name": "MppxStore" }] },
234
+ * "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MppxStore"] }]
235
+ * }
236
+ * ```
237
+ */
238
+ export class DurableObjectStore {
239
+ #storage;
240
+ constructor(state, _env) {
241
+ this.#storage = state.storage;
242
+ }
243
+ /** Handles one store operation. Each runs without awaiting other I/O between its read and write, so the object's input gate makes it atomic. */
244
+ async fetch(request) {
245
+ if (request.method !== 'POST')
246
+ return new Response('Method not allowed', { status: 405 });
247
+ const body = (await request.json());
248
+ const storage = this.#storage;
249
+ const read = async () => (await storage.get(body.key)) ?? null;
250
+ const write = async (value) => {
251
+ if (value === null)
252
+ await storage.delete(body.key);
253
+ else
254
+ await storage.put(body.key, value);
255
+ };
256
+ switch (body.op) {
257
+ case 'get':
258
+ return Response.json(await read());
259
+ case 'put':
260
+ await write(body.value);
261
+ return Response.json(true);
262
+ case 'delete':
263
+ await write(null);
264
+ return Response.json(true);
265
+ case 'swap': {
266
+ const current = await read();
267
+ if (current !== body.expected)
268
+ return Response.json({ applied: false, current });
269
+ await write(body.value);
270
+ return Response.json({ applied: true });
271
+ }
272
+ case 'claim': {
273
+ const { expires } = body;
274
+ if (!Number.isFinite(expires) || expires <= Date.now())
275
+ return Response.json(false);
276
+ const raw = await read();
277
+ const current = raw == null ? null : Json.parse(raw);
278
+ if (current !== null && (!isReplayMarker(current) || current.expires > Date.now()))
279
+ return Response.json(false);
280
+ await write(Json.stringify({ expires, type: 'mppx:replay' }));
281
+ return Response.json(true);
282
+ }
283
+ default:
284
+ return new Response('Unknown operation', { status: 400 });
285
+ }
286
+ }
287
+ }
288
+ /**
289
+ * Atomic store backed by a SQL database: Postgres, SQLite or Cloudflare D1.
290
+ *
291
+ * Values live as JSON text in one table (`mppx_store` by default, created on
292
+ * first use). Every write is a single statement, and {@link AtomicStore.update}
293
+ * commits with a compare-and-swap on the stored value
294
+ * (`UPDATE … WHERE key = ? AND value = ? RETURNING`, or
295
+ * `INSERT … ON CONFLICT DO NOTHING RETURNING` for a new key), retrying from the
296
+ * current value after a jittered backoff when another writer won. It needs no
297
+ * interactive transactions, so it works over HTTP drivers and D1, and only
298
+ * `ON CONFLICT` and `RETURNING` (Postgres, SQLite 3.35+, D1, libSQL).
299
+ *
300
+ * Use {@link postgres}, {@link sqlite} or {@link d1} for common drivers, or pass
301
+ * any driver as a `query` function.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * import postgres from 'postgres'
306
+ * import { Store } from 'mppx/server'
307
+ *
308
+ * const db = postgres(process.env.DATABASE_URL!)
309
+ * const store = Store.sql({ dialect: 'postgres', query: (text, params) => db.unsafe(text, params) })
310
+ * ```
311
+ */
312
+ export function sql(driver, options) {
313
+ const table = options?.table ?? 'mppx_store';
314
+ if (!/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(table))
315
+ throw new Error(`Invalid SQL store table name: "${table}".`);
316
+ const maxAttempts = options?.maxAttempts ?? 32;
317
+ // Statements are written with `?`; Postgres numbers its placeholders.
318
+ const prepare = (text) => {
319
+ if (driver.dialect !== 'postgres')
320
+ return text;
321
+ let index = 0;
322
+ return text.replace(/\?/g, () => `$${++index}`);
323
+ };
324
+ let ready;
325
+ const ensureTable = () => {
326
+ if (options?.createTable === false)
327
+ return;
328
+ ready ??= Promise.resolve(driver.query(
329
+ // `expires` mirrors a replay marker's expiry, so a claim needs no JSON in SQL.
330
+ `CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires DOUBLE PRECISION)`, [])).then(() => { }, async (error) => {
331
+ // Concurrent `CREATE TABLE IF NOT EXISTS` from other processes can fail on
332
+ // Postgres (duplicate catalog row) although the table now exists: probe it.
333
+ const exists = await Promise.resolve(driver.query(`SELECT 1 FROM ${table} WHERE 1 = 0`, [])).then(() => true, () => false);
334
+ if (exists)
335
+ return;
336
+ ready = undefined;
337
+ throw error;
338
+ });
339
+ return ready;
340
+ };
341
+ async function query(text, params) {
342
+ await ensureTable();
343
+ return driver.query(prepare(text), params);
344
+ }
345
+ async function read(key) {
346
+ const [row] = await query(`SELECT value FROM ${table} WHERE key = ?`, [key]);
347
+ return row ? row.value : null;
348
+ }
349
+ /** The `expires` column for a serialized value: the expiry of a replay marker, else null. */
350
+ const expiresOf = (raw) => {
351
+ const value = Json.parse(raw);
352
+ return isReplayMarker(value) ? value.expires : null;
353
+ };
354
+ /** Writes `value` (or deletes, for `null`) only if the key still holds `expected`. */
355
+ async function swap(key, expected, value) {
356
+ let rows;
357
+ if (expected === null) {
358
+ if (value === null)
359
+ return { applied: true };
360
+ rows = await query(`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO NOTHING RETURNING key`, [key, value, expiresOf(value)]);
361
+ }
362
+ else if (value === null)
363
+ rows = await query(`DELETE FROM ${table} WHERE key = ? AND value = ? RETURNING key`, [
364
+ key,
365
+ expected,
366
+ ]);
367
+ else
368
+ rows = await query(`UPDATE ${table} SET value = ?, expires = ? WHERE key = ? AND value = ? RETURNING key`, [value, expiresOf(value), key, expected]);
369
+ return { applied: rows.length > 0 };
370
+ }
371
+ return from({
372
+ async get(key) {
373
+ const raw = await read(key);
374
+ if (raw == null)
375
+ return null;
376
+ return Json.parse(raw);
377
+ },
378
+ async put(key, value) {
379
+ const raw = Json.stringify(value);
380
+ await query(`INSERT INTO ${table} (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires = excluded.expires`, [key, raw, expiresOf(raw)]);
381
+ },
382
+ async delete(key) {
383
+ await query(`DELETE FROM ${table} WHERE key = ?`, [key]);
384
+ },
385
+ update(key, fn) {
386
+ return compareAndSwap({
387
+ fn,
388
+ key,
389
+ label: 'SQL store',
390
+ maxAttempts,
391
+ read: () => read(key),
392
+ swap: (expected, value) => swap(key, expected, value),
393
+ });
394
+ },
395
+ // One statement: insert the marker, or replace one whose expiry has passed.
396
+ async tryClaim(key, expires) {
397
+ if (!Number.isFinite(expires) || expires <= Date.now())
398
+ return false;
399
+ const rows = await query(`INSERT INTO ${table} AS existing (key, value, expires) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, expires = excluded.expires WHERE existing.expires IS NOT NULL AND existing.expires <= ? RETURNING key`, [
400
+ key,
401
+ Json.stringify({ expires, type: 'mppx:replay' }),
402
+ expires,
403
+ Date.now(),
404
+ ]);
405
+ // The claim's own expiry may have passed while the statement ran.
406
+ return rows.length > 0 && expires > Date.now();
407
+ },
408
+ }, options);
409
+ }
410
+ /**
411
+ * Atomic SQL store on a Postgres client: `pg` (`Pool` or `Client`),
412
+ * `@neondatabase/serverless` (`Pool` or `neon()`), `@vercel/postgres`,
413
+ * Supabase's Postgres connection or PGlite. See {@link sql}.
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * import { Pool } from 'pg'
418
+ * import { Store } from 'mppx/server'
419
+ *
420
+ * const store = Store.postgres(new Pool({ connectionString: process.env.DATABASE_URL }))
421
+ * ```
422
+ */
423
+ export function postgres(client, options) {
424
+ return sql({
425
+ dialect: 'postgres',
426
+ async query(text, params) {
427
+ const result = await client.query(text, params);
428
+ return Array.isArray(result) ? result : result.rows;
429
+ },
430
+ }, options);
431
+ }
432
+ /**
433
+ * Atomic SQL store on a SQLite database: `node:sqlite` (`DatabaseSync`),
434
+ * `better-sqlite3` or `bun:sqlite`. See {@link sql}. For libSQL or Turso, use
435
+ * {@link sql} with dialect `sqlite` and return the result set's rows:
436
+ * `Store.sql({ dialect: 'sqlite', query: async (sql, args) => (await client.execute({ sql, args: args as InArgs })).rows })`.
437
+ *
438
+ * @example
439
+ * ```ts
440
+ * import { DatabaseSync } from 'node:sqlite'
441
+ * import { Store } from 'mppx/server'
442
+ *
443
+ * const store = Store.sqlite(new DatabaseSync('mppx.db'))
444
+ * ```
445
+ */
446
+ export function sqlite(database, options) {
447
+ return sql({
448
+ dialect: 'sqlite',
449
+ async query(text, params) {
450
+ const statement = database.prepare(text);
451
+ // better-sqlite3 allows all() only on statements that return rows.
452
+ if (statement.reader === false && statement.run) {
453
+ await statement.run(...params);
454
+ return [];
455
+ }
456
+ return (await statement.all(...params));
457
+ },
458
+ }, options);
459
+ }
460
+ /**
461
+ * Atomic SQL store on a Cloudflare D1 database binding. See {@link sql}.
462
+ *
463
+ * @example
464
+ * ```ts
465
+ * import { Store } from 'mppx/server'
466
+ *
467
+ * export default {
468
+ * fetch(request: Request, env: { DB: D1Database }) {
469
+ * const store = Store.d1(env.DB)
470
+ * // ...
471
+ * },
472
+ * }
473
+ * ```
474
+ */
475
+ export function d1(database, options) {
476
+ return sql({
477
+ dialect: 'sqlite',
478
+ async query(text, params) {
479
+ const { results } = await database
480
+ .prepare(text)
481
+ .bind(...params)
482
+ .all();
483
+ return results;
484
+ },
485
+ }, options);
486
+ }
129
487
  /** In-memory store backed by a `Map`. JSON-roundtrips values to match production behavior. */
130
488
  export function memory(options) {
131
489
  const store = new Map();