Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Standalone, runnable examples demonstrating the mppx HTTP 402 payment flow.
| [charge](./charge/) | Payment-gated image generation API |
| [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel |
| [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE |
| [session/ws](./session/ws/) | Pay-per-token LLM streaming with WebSocket |
| [stripe](./stripe/) | Stripe SPT charge with automatic client |

## Running Examples
Expand Down
42 changes: 42 additions & 0 deletions examples/session/ws/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Stream: WebSocket

Pay-per-token LLM-style streaming over WebSocket using the experimental Tempo session websocket helper.

The client performs an HTTP `402` probe, opens a payment channel, upgrades to WebSocket, and then streams tokens while automatically responding to `payment-need-voucher` control frames.

## Setup

```bash
npx gitpick wevm/mppx/examples/session/ws
pnpm i
```

For local demos from this repository, use the workspace version instead:

```bash
pnpm install
pnpm dev:example
```

Then choose `session/ws`.

## Usage

Start the server:

```bash
pnpm dev
```

In a separate terminal, run the client:

```bash
pnpm client
pnpm client "What is the meaning of life?"
```

## Notes

- The WebSocket flow currently uses HTTP for the initial `402` challenge probe.
- During the stream, vouchers are sent in-band over the socket.
- After the stream ends, the demo calls `close()` to settle the channel and print the final receipt.
22 changes: 22 additions & 0 deletions examples/session/ws/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"name": "session-ws",
"private": true,
"type": "module",
"scripts": {
"check:types": "tsgo -b",
"dev": "vite",
"client": "tsx src/client.ts"
},
"dependencies": {
"@remix-run/node-fetch-server": "^0.13.0",
"@types/node": "^25.5.0",
"@types/ws": "^8.18.1",
"@typescript/native-preview": "7.0.0-dev.20260323.1",
"mppx": "workspace:*",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"viem": "^2.47.6",
"vite": "latest",
"ws": "^8.20.0"
}
}
173 changes: 173 additions & 0 deletions examples/session/ws/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// WebSocket Streaming Payment Client — Example

//
// This example demonstrates the client side of a metered WebSocket session.
// The websocket transport is still bootstrapped by an HTTP 402 challenge:
//
// 1. Client probes `/ws/chat` over HTTP and receives a `402` challenge
// 2. Client opens an on-chain channel and creates the first credential
// 3. Client opens a WebSocket and sends that credential as the first frame
// 4. Server streams tokens and emits `payment-need-voucher` frames when the
// current cumulative voucher is exhausted
// 5. Client signs and sends voucher updates over the same socket
// 6. Client sends a final `close()` credential to settle on-chain

// `tempo` from 'mppx/client' provides the session manager used for this demo.
import { tempo } from 'mppx/client'
import { createClient, type Hex, http } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { tempoModerato } from 'viem/chains'
import { Actions } from 'viem/tempo'
import { WebSocket } from 'ws'

// The server URL. The websocket URL is derived from this base.
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:5173'

// pathUSD on Tempo testnet.
const currency = '0x20c0000000000000000000000000000000000000' as const

// The per-token price configured on the server.
const PRICE_PER_TOKEN = '0.000075'

// Client Account Setup

//
// Generate a demo payer account unless the caller provides a persistent key via
// `PRIVATE_KEY`. Reusing a real key is convenient when presenting multiple demo
// runs and wanting a stable wallet address.
const account = privateKeyToAccount((process.env.PRIVATE_KEY as Hex) ?? generatePrivateKey())

// The client needs a viem client with the payer account attached because Tempo
// session credentials include signed vouchers and, on first use, a signed open
// transaction for the payment channel.
const client = createClient({
account,
chain: tempoModerato,
pollingInterval: 1_000,
transport: http(),
})

// Fund the payer account via the public testnet faucet so it has pathUSD for
// the channel deposit and enough gas to get through the open/close lifecycle.
console.log(`Client account: ${account.address}`)
console.log('Funding account via faucet...')
await Actions.faucet.fundSync(client, { account, timeout: 30_000 })

// Helper to query the payer's current pathUSD balance.
const getBalance = () => Actions.token.getBalance(client, { account, token: currency })

// Format raw 6-decimal token values for terminal output.
const fmt = (value: bigint) => `${Number(value) / 1e6} pathUSD`

const balanceBefore = await getBalance()
console.log(`Balance: ${fmt(balanceBefore)}`)

// Step 1: Create a Session Manager

//
// `tempo.session()` returns a stateful session manager. For WebSocket flows it
// still handles the hard parts: HTTP challenge probing, channel open, voucher
// creation, cumulative accounting, and final close.
//
// We pass the `ws` package's constructor explicitly because Node 18 does not
// provide a reliable global `WebSocket` in the same way browsers do.
const session = tempo.session({
account,
client,
maxDeposit: '1',
webSocket: WebSocket as any,
})

// Step 2: Build the WebSocket URL

//
// The example derives the socket URL from `BASE_URL` so the same code works
// against localhost or a remote deployment. The prompt is sent as a query
// parameter because the websocket content request itself has no HTTP body.
const prompt = process.argv[2] ?? 'Tell me something interesting'
const url = new URL(BASE_URL)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = '/ws/chat'
url.searchParams.set('prompt', prompt)

console.log(`\n--- Channel ---`)
console.log(`Max deposit: 1 pathUSD`)
console.log(`Price per token: ${PRICE_PER_TOKEN} pathUSD`)
console.log(`Endpoint: ${url}`)

// Step 3: Open the Paid WebSocket Session

//
// `session.ws()` performs the initial HTTP 402 probe, creates the first
// payment credential, opens the websocket, and sends the auth frame.
//
// The optional `onReceipt` callback gives us visibility into the voucher and
// spend progression while the stream is active.
let receiptCount = 0
const socket = await session.ws(url, {
onReceipt(receipt) {
receiptCount++
console.log(
`\n[receipt ${receiptCount}] spent=${fmt(BigInt(receipt.spent))} accepted=${fmt(BigInt(receipt.acceptedCumulative))}`,
)
},
})

// Step 4: Read Streamed Tokens

//
// Application data arrives as ordinary websocket text messages. Payment control
// frames (`payment-need-voucher`, `payment-receipt`) are intercepted internally
// by `session.ws()`, so the demo loop here only needs to print the content.
console.log(`\n--- Streaming (prompt: "${prompt}") ---`)

let tokenCount = 0
await new Promise<void>((resolve, reject) => {
socket.addEventListener('message', (event) => {
if (typeof event.data !== 'string') return
tokenCount++
process.stdout.write(event.data)
})
socket.addEventListener('close', () => resolve(), { once: true })
socket.addEventListener('error', () => reject(new Error('websocket stream failed')), {
once: true,
})
})

// Step 5: Close and Settle

//
// Once the content stream ends, we send a final `close` credential so the
// server can settle the channel and return a final receipt with the accepted
// cumulative amount and, when available, the close transaction hash.
console.log(`\n\nTokens: ${tokenCount}`)
console.log(`Voucher cumulative: ${fmt(session.cumulative)}`)

console.log(`\n--- Settlement ---`)
const closeReceipt = await session.close()
if (closeReceipt) {
console.log(` Channel: ${closeReceipt.channelId}`)
console.log(` Settled: ${fmt(BigInt(closeReceipt.acceptedCumulative))}`)
console.log(` Tokens: ${closeReceipt.units}`)
if (closeReceipt.txHash) console.log(` Settle tx: ${closeReceipt.txHash}`)
}

// Give the settlement transaction a few seconds to finalize before checking
// the post-session balance so the summary is easier to interpret live.
await new Promise((resolve) => setTimeout(resolve, 5_000))

const balanceAfter = await getBalance()
const totalSpent = balanceBefore - balanceAfter

// Step 6: Summary

//
// `session.cumulative` is the total voucher amount the client authorized.
// The balance delta is usually larger because it also includes gas for the
// open/close transactions.
console.log(`\n--- Summary ---`)
console.log(` Tokens streamed: ${tokenCount}`)
console.log(` Voucher total: ${fmt(session.cumulative)}`)
console.log(` Balance before: ${fmt(balanceBefore)}`)
console.log(` Balance after: ${fmt(balanceAfter)}`)
console.log(` Total spent: ${fmt(totalSpent)} (voucher + gas)`)
Loading
Loading