Skip to content
Draft
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
4 changes: 3 additions & 1 deletion examples/charge-wagmi/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mppx, tempo } from 'mppx/server'
import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server'
import { createClient, http } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { tempoModerato } from 'viem/chains'
Expand All @@ -20,6 +20,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
const url = new URL(request.url)

if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse()

// Free
if (url.pathname === '/api/health') return Response.json({ status: 'ok' })

Expand Down
4 changes: 3 additions & 1 deletion examples/charge/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mppx, tempo } from 'mppx/server'
import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server'
import { createClient, http } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { tempoModerato } from 'viem/chains'
Expand All @@ -21,6 +21,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
const url = new URL(request.url)

if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse()

// Free
if (url.pathname === '/api/health') return Response.json({ status: 'ok' })

Expand Down
6 changes: 5 additions & 1 deletion examples/stripe/src/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Mppx, stripe } from 'mppx/server'
import { Mppx, serviceWorkerResponse, stripe } from 'mppx/server'
import Stripe from 'stripe'

const secretKey = process.env.VITE_STRIPE_SECRET_KEY!
Expand All @@ -13,6 +13,8 @@ const mppx = Mppx.create({
networkId: 'internal',
// Ensure only card is supported.
paymentMethodTypes: ['card'],
// Publishable key for browser HTML payment form.
publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY,
}),
],
})
Expand All @@ -23,6 +25,8 @@ const mppx = Mppx.create({
export async function handler(request: Request): Promise<Response | null> {
const url = new URL(request.url)

if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse()

if (url.pathname === '/api/create-spt') {
const { paymentMethod, amount, currency, expiresAt, networkId, metadata } =
(await request.json()) as {
Expand Down
5 changes: 4 additions & 1 deletion src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export type Server<
transportOverride = undefined,
> = method & {
defaults?: defaults | undefined
html?: string | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
transport?: transportOverride | undefined
Expand Down Expand Up @@ -202,10 +203,11 @@ export function toServer<
method: method,
options: toServer.Options<method, defaults, transportOverride>,
): Server<method, defaults, transportOverride> {
const { defaults, request, respond, transport, verify } = options
const { defaults, html, request, respond, transport, verify } = options
return {
...method,
defaults,
html,
request,
respond,
transport,
Expand All @@ -220,6 +222,7 @@ export declare namespace toServer {
transportOverride extends Transport.AnyTransport | undefined = undefined,
> = {
defaults?: defaults | undefined
html?: string | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
transport?: transportOverride | undefined
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/elysia.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Context } from 'elysia'

import { serviceWorkerResponse } from '../server/Html.js'
import * as Mppx_core from '../server/Mppx.js'
import * as Mppx_internal from './internal/mppx.js'

Expand Down Expand Up @@ -61,6 +62,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
options: intent extends (options: infer options) => any ? options : never,
): ElysiaHook {
return async ({ request, set }) => {
if (new URL(request.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse()
const result = await intent(options)(request)
if (result.status === 402) return result.challenge
const receipt = result.withReceipt(new Response())
Expand Down
7 changes: 7 additions & 0 deletions src/middlewares/express.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
RequestHandler,
} from 'express'

import { serviceWorkerResponse } from '../server/Html.js'
import * as Mppx_core from '../server/Mppx.js'
import * as Mppx_internal from './internal/mppx.js'

Expand Down Expand Up @@ -60,6 +61,12 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
options: intent extends (options: infer options) => any ? options : never,
): RequestHandler {
return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
if (req.originalUrl === '/__mppx_sw.js') {
const swRes = serviceWorkerResponse()
res.setHeader('Content-Type', 'application/javascript')
res.send(await swRes.text())
return
}
const request = new Request(`${req.protocol}://${req.hostname}${req.originalUrl}`, {
method: req.method,
headers: req.headers as Record<string, string>,
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/hono.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { MiddlewareHandler } from 'hono'

import { serviceWorkerResponse } from '../server/Html.js'
import * as Mppx_core from '../server/Mppx.js'
import * as Mppx_internal from './internal/mppx.js'

Expand Down Expand Up @@ -55,6 +56,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
options: intent extends (options: infer options) => any ? options : never,
): MiddlewareHandler {
return async (c, next) => {
if (new URL(c.req.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse()
const result = await intent(options)(c.req.raw)
if (result.status === 402) return result.challenge
await next()
Expand Down
2 changes: 2 additions & 0 deletions src/middlewares/nextjs.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { serviceWorkerResponse } from '../server/Html.js'
import * as Mppx_core from '../server/Mppx.js'
import * as Mppx_internal from './internal/mppx.js'

Expand Down Expand Up @@ -58,6 +59,7 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
handler: RouteHandler,
): RouteHandler {
return async (request) => {
if (new URL(request.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse()
const result = await intent(options)(request)
if (result.status === 402) return result.challenge
const response = await handler(request)
Expand Down
1 change: 1 addition & 0 deletions src/proxy/Service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ function resolvePayment(endpoint: Endpoint): Record<string, unknown> | null {
name,
intent,
defaults: _,
html: _h,
schema: _s,
_canonicalRequest,
...rest
Expand Down
142 changes: 142 additions & 0 deletions src/server/Html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import type * as Challenge from '../Challenge.js'

/** Service Worker script that injects a one-shot Authorization header on the next navigation. */
export const serviceWorkerScript = [
'var cred=null;',
'self.addEventListener("install",function(){self.skipWaiting()});',
'self.addEventListener("activate",function(e){e.waitUntil(self.clients.claim())});',
'self.addEventListener("message",function(e){cred=e.data});',
'self.addEventListener("fetch",function(e){',
' if(!cred)return;',
' var h=new Headers(e.request.headers);',
' h.set("Authorization",cred);',
' cred=null;',
' e.respondWith(fetch(new Request(e.request,{headers:h})));',
'});',
].join('')

/** Returns a Response serving the mppx service worker script. */
export function serviceWorkerResponse(): Response {
return new Response(serviceWorkerScript, {
headers: { 'Content-Type': 'application/javascript' },
})
}

/** Tagged template for syntax highlighting in editors. */
export function html(strings: TemplateStringsArray, ...values: unknown[]): string {
return String.raw(strings, ...values)
}

/**
* Renders a self-contained HTML payment page for a 402 challenge.
*
* The page has three sections:
* 1. **Info** — amount, description, method, realm, expiry from the challenge
* 2. **Core script** — `window.mppx` (challenge + serializeCredential) and `mppx:complete` listener
* 3. **Method HTML** — injected payment-method UI (or a fallback message)
*/
export function render(challenge: Challenge.Challenge, methodHtml?: string | undefined): string {
const challengeJson = JSON.stringify(challenge)

return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Required</title>
<style>html{color-scheme:light dark}</style>
</head>
<body>
<header>
<h1>Payment Required</h1>
${challenge.description ? ` <p>${esc(challenge.description)}</p>\n` : ''}\
</header>

<main>
<section>
<pre>${esc(JSON.stringify(challenge, null, 2))}</pre>
</section>
</main>

<script>
window.mppx = Object.freeze({
challenge: ${challengeJson},

serializeCredential: function(payload, source) {
var wire = {
challenge: Object.assign({}, mppx.challenge, {
request: base64url(JSON.stringify(sortKeys(mppx.challenge.request)))
}),
payload: payload
};
if (source) wire.source = source;
return 'Payment ' + base64url(JSON.stringify(wire));
}
});

function base64url(str) {
return btoa(str).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
}

function sortKeys(obj) {
var sorted = {};
Object.keys(obj).sort().forEach(function(k) {
var v = obj[k];
sorted[k] = (v && typeof v === 'object' && !Array.isArray(v)) ? sortKeys(v) : v;
});
return sorted;
}

function activateSw(reg) {
var sw = reg.installing || reg.waiting || reg.active;
return new Promise(function(resolve) {
if (sw.state === 'activated') return resolve();
sw.addEventListener('statechange', function() {
if (sw.state === 'activated') resolve();
});
});
}

addEventListener('mppx:complete', function(e) {
var statusEl = document.getElementById('status');
var authorization = e.detail;
statusEl.textContent = 'Verifying payment...';
statusEl.style.color = '';

// Try server-hosted SW, then fetch+blob fallback
navigator.serviceWorker.register('/__mppx_sw.js').then(activateSw).then(function() {
navigator.serviceWorker.controller.postMessage(authorization);
window.location.reload();
}).catch(function() {
fetch(window.location.href, {
headers: { 'Authorization': authorization }
}).then(function(res) {
if (!res.ok) {
statusEl.textContent = 'Verification failed (' + res.status + ')';
statusEl.style.color = 'red';
return;
}
return res.blob().then(function(blob) {
window.location = URL.createObjectURL(blob);
});
}).catch(function(err) {
statusEl.textContent = err.message || 'Request failed';
statusEl.style.color = 'red';
});
});
});
</script>

${methodHtml ?? ' <p>This payment method does not support browser payments.</p>'}
</body>
</html>`
}

/** @internal */
function esc(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
21 changes: 20 additions & 1 deletion src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,9 +153,10 @@ export function create<
const transport extends Transport.AnyTransport = Transport.Http,
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
const {
html = true,
realm = Env.get('realm') ?? 'MPP Payment',
secretKey = Env.get('secretKey'),
transport = Transport.http() as transport,
transport = Transport.http({ html }) as transport,
} = config

if (!secretKey) {
Expand Down Expand Up @@ -233,6 +234,15 @@ export declare namespace create {
methods extends Methods = Methods,
transport extends Transport.AnyTransport = Transport.Http,
> = {
/**
* Serve an HTML payment page to browsers (requests with `Accept: text/html`).
*
* - `true` — use the built-in payment page
* - `(challenge) => string` — custom HTML renderer
*
* Only applies when using the default HTTP transport.
*/
html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined
/** Array of configured methods. @example [tempo()] */
methods: methods
/** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
Expand All @@ -254,6 +264,7 @@ function createMethodFn<
// biome-ignore lint/correctness/noUnusedVariables: _
function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
const { defaults, method, realm, respond, secretKey, transport, verify } = parameters
const methodHtml = (method as Method.AnyServer).html

return (options) => {
const { description, meta, ...rest } = options
Expand Down Expand Up @@ -301,6 +312,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error: new Errors.MalformedCredentialError({ reason: credentialError.message }),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -311,6 +323,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error: new Errors.PaymentRequiredError({ description }),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -325,6 +338,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
id: credential.challenge.id,
reason: 'challenge was not issued by this server',
}),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand Down Expand Up @@ -353,6 +367,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
id: credential.challenge.id,
reason: `credential ${field} does not match this route's requirements`,
}),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand Down Expand Up @@ -385,6 +400,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
id: credential.challenge.id,
reason: `credential ${field} does not match this route's requirements`,
}),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -400,6 +416,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
error: new Errors.PaymentExpiredError({
expires: credential.challenge.expires,
}),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -411,6 +428,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error: new Errors.InvalidPayloadError({ reason: (e as Error).message }),
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand All @@ -429,6 +447,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
challenge,
input,
error,
methodHtml,
})
return { challenge: response, status: 402 }
}
Expand Down
Loading
Loading