From a4144b5c3c5762b7093a58515886aaae8f5345fa Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 23 Mar 2026 19:45:10 -0400 Subject: [PATCH 1/4] feat: add html payment pages for browser 402 responses --- examples/charge-wagmi/server.ts | 4 +- examples/charge-wagmi/vite.config.ts | 2 +- examples/charge/src/server.ts | 4 +- examples/charge/vite.config.ts | 2 +- examples/session/multi-fetch/vite.config.ts | 2 +- examples/session/sse/vite.config.ts | 2 +- examples/stripe/src/server.ts | 6 +- examples/stripe/vite.config.ts | 3 +- src/Method.ts | 5 +- src/middlewares/elysia.ts | 2 + src/middlewares/express.ts | 7 + src/middlewares/hono.ts | 2 + src/middlewares/nextjs.ts | 2 + src/proxy/Service.ts | 1 + src/server/Html.ts | 142 ++++++++++++++++++ src/server/Mppx.ts | 21 ++- src/server/Transport.ts | 33 ++++- src/server/index.ts | 1 + src/stripe/server/Charge.ts | 74 ++++++++++ src/tempo/server/Charge.ts | 156 ++++++++++++++++++++ 20 files changed, 458 insertions(+), 13 deletions(-) create mode 100644 src/server/Html.ts diff --git a/examples/charge-wagmi/server.ts b/examples/charge-wagmi/server.ts index 0b709d93..5654fb61 100644 --- a/examples/charge-wagmi/server.ts +++ b/examples/charge-wagmi/server.ts @@ -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' @@ -20,6 +20,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { 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' }) diff --git a/examples/charge-wagmi/vite.config.ts b/examples/charge-wagmi/vite.config.ts index c6494794..4257be21 100644 --- a/examples/charge-wagmi/vite.config.ts +++ b/examples/charge-wagmi/vite.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ typeof addr === 'object' && addr ? `localhost:${addr.port}` : 'localhost:5173' const pm = process.env.npm_config_user_agent?.split('/')[0] ?? 'npx' setTimeout( - () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx ${host}/api/photo\n`), + () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx http://${host}/api/photo\n`), 100, ) }) diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index c4b05f01..6f00b47c 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -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' @@ -21,6 +21,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { 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' }) diff --git a/examples/charge/vite.config.ts b/examples/charge/vite.config.ts index afe16dbb..b97ddc50 100644 --- a/examples/charge/vite.config.ts +++ b/examples/charge/vite.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ typeof addr === 'object' && addr ? `localhost:${addr.port}` : 'localhost:5173' const pm = process.env.npm_config_user_agent?.split('/')[0] ?? 'npx' setTimeout( - () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx ${host}/api/photo\n`), + () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx http://${host}/api/photo\n`), 100, ) }) diff --git a/examples/session/multi-fetch/vite.config.ts b/examples/session/multi-fetch/vite.config.ts index 00688d71..0e9ca39d 100644 --- a/examples/session/multi-fetch/vite.config.ts +++ b/examples/session/multi-fetch/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ typeof addr === 'object' && addr ? `localhost:${addr.port}` : 'localhost:5173' const pm = process.env.npm_config_user_agent?.split('/')[0] ?? 'npx' setTimeout( - () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx ${host}/api/scrape\n`), + () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx http://${host}/api/scrape\n`), 100, ) }) diff --git a/examples/session/sse/vite.config.ts b/examples/session/sse/vite.config.ts index 744ee7d1..684cec5e 100644 --- a/examples/session/sse/vite.config.ts +++ b/examples/session/sse/vite.config.ts @@ -21,7 +21,7 @@ export default defineConfig({ typeof addr === 'object' && addr ? `localhost:${addr.port}` : 'localhost:5173' const pm = process.env.npm_config_user_agent?.split('/')[0] ?? 'npx' setTimeout( - () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx ${host}/api/chat\n`), + () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx http://${host}/api/chat\n`), 100, ) }) diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 0aaac6ac..c3521995 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -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! @@ -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, }), ], }) @@ -23,6 +25,8 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { 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 { diff --git a/examples/stripe/vite.config.ts b/examples/stripe/vite.config.ts index 5c565471..bc362464 100644 --- a/examples/stripe/vite.config.ts +++ b/examples/stripe/vite.config.ts @@ -24,7 +24,8 @@ export default defineConfig(({ mode }) => { typeof addr === 'object' && addr ? `localhost:${addr.port}` : 'localhost:5173' const pm = process.env.npm_config_user_agent?.split('/')[0] ?? 'npx' setTimeout( - () => console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx ${host}/api/fortune\n`), + () => + console.log(`\n ${pm === 'npm' ? 'npx' : pm} mppx http://${host}/api/fortune\n`), 100, ) }) diff --git a/src/Method.ts b/src/Method.ts index 1b196fad..49881459 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -74,6 +74,7 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined + html?: string | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -202,10 +203,11 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, request, respond, transport, verify } = options + const { defaults, html, request, respond, transport, verify } = options return { ...method, defaults, + html, request, respond, transport, @@ -220,6 +222,7 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined + html?: string | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index d26a6a0c..7b1e4657 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -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' @@ -61,6 +62,7 @@ export function payment( 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()) diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index e7de1231..b04157de 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -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' @@ -60,6 +61,12 @@ export function payment( 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, diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index 75c1d1ba..1576e6a5 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -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' @@ -55,6 +56,7 @@ export function payment( 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() diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index da85bf3e..688841d0 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -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' @@ -58,6 +59,7 @@ export function payment( 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) diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 82ec7d9e..2d27c785 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -271,6 +271,7 @@ function resolvePayment(endpoint: Endpoint): Record | null { name, intent, defaults: _, + html: _h, schema: _s, _canonicalRequest, ...rest diff --git a/src/server/Html.ts b/src/server/Html.ts new file mode 100644 index 00000000..9a18eaf6 --- /dev/null +++ b/src/server/Html.ts @@ -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 ` + + + + + Payment Required + + + +
+

Payment Required

+${challenge.description ? `

${esc(challenge.description)}

\n` : ''}\ +
+ +
+
+
${esc(JSON.stringify(challenge, null, 2))}
+
+
+ + + +${methodHtml ?? '

This payment method does not support browser payments.

'} + +` +} + +/** @internal */ +function esc(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') +} diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 9884b3c4..b0252353 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -153,9 +153,10 @@ export function create< const transport extends Transport.AnyTransport = Transport.Http, >(config: create.Config): Mppx { 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) { @@ -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"`. */ @@ -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 @@ -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 } } @@ -311,6 +323,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), + methodHtml, }) return { challenge: response, status: 402 } } @@ -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 } } @@ -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 } } @@ -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 } } @@ -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 } } @@ -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 } } @@ -429,6 +447,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error, + methodHtml, }) return { challenge: response, status: 402 } } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 270fc1e3..431ad4da 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -4,6 +4,7 @@ import * as Errors from '../Errors.js' import type { Distribute, UnionToIntersection } from '../internal/types.js' import * as core_Mcp from '../Mcp.js' import * as Receipt from '../Receipt.js' +import * as Html from './Html.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -31,6 +32,7 @@ export type Transport< challenge: Challenge.Challenge error?: Errors.PaymentError | undefined input: input + methodHtml?: string | undefined }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ respondReceipt: (options: { @@ -109,7 +111,15 @@ export function from< * - Issues challenges via `WWW-Authenticate` header with 402 status * - Attaches receipts via `Payment-Receipt` header */ -export function http(): Http { +export function http(options?: http.Options): Http { + const htmlOption = options?.html + const renderHtml = + htmlOption === true + ? (challenge: Challenge.Challenge, methodHtml?: string) => Html.render(challenge, methodHtml) + : typeof htmlOption === 'function' + ? htmlOption + : undefined + return from({ name: 'http', @@ -121,14 +131,19 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error }) { + respondChallenge({ challenge, error, input, methodHtml }) { const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } + const acceptsHtml = renderHtml && input.headers.get('Accept')?.includes('text/html') + let body: string | null = null - if (error) { + if (acceptsHtml) { + headers['Content-Type'] = 'text/html; charset=utf-8' + body = renderHtml(challenge, methodHtml) + } else if (error) { headers['Content-Type'] = 'application/problem+json' body = JSON.stringify(error.toProblemDetails(challenge.id)) } @@ -207,6 +222,18 @@ export function mcp() { }) } +export declare namespace http { + type Options = { + /** + * Serve an HTML payment page to browsers (requests with `Accept: text/html`). + * + * - `true` — use the built-in payment page + * - `(challenge) => string` — custom HTML renderer + */ + html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined + } +} + /** @internal */ function mcpErrorCode(error?: Errors.PaymentError): number { if (!error) return core_Mcp.paymentRequiredCode diff --git a/src/server/index.ts b/src/server/index.ts index f87db1c4..42d64970 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,4 +1,5 @@ export * as Expires from '../Expires.js' +export { html, serviceWorkerResponse } from './Html.js' export * as Store from '../Store.js' export { stripe, tempo } from './Methods.js' export * as Mppx from './Mppx.js' diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 51e16fd2..1a134f5e 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -6,6 +6,7 @@ import { } from '../../Errors.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' +import { html } from '../../server/Html.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' @@ -44,6 +45,7 @@ export function charge(parameters: p metadata, networkId, paymentMethodTypes, + publishableKey, } = parameters const client = 'client' in parameters ? parameters.client : undefined @@ -62,6 +64,76 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, + ...(publishableKey + ? { + html: html` +
+
+ + +
+ + + `, + } + : {}), + async verify({ credential }) { const { challenge } = credential const { request } = challenge @@ -111,6 +183,8 @@ export declare namespace charge { type Parameters = { /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined + /** Stripe publishable key for browser-based HTML payment form. Required when using `html: true` on Mppx.create. */ + publishableKey?: string | undefined } & Defaults & OneOf< | { diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 645e6965..9f407ea1 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -13,6 +13,7 @@ import { Abis, Transaction } from 'viem/tempo' import { PaymentExpiredError } from '../../Errors.js' import type { LooseOmit } from '../../internal/types.js' import * as Method from '../../Method.js' +import { html } from '../../server/Html.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' import * as Account from '../internal/account.js' @@ -68,6 +69,161 @@ export function charge( recipient, } as unknown as Defaults, + html: html` +
+ + + `, + // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { const chainId = await (async () => { From 7f390e60ce127bce94f5682255f7e9ac2c1c6db8 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 25 Mar 2026 12:42:24 -0400 Subject: [PATCH 2/4] chore: wip --- .gitignore | 1 + .oxlintrc.json | 5 +- package.json | 10 +- pnpm-lock.yaml | 308 +++++++++++++++++++++-- pnpm-workspace.yaml | 3 + src/Method.ts | 9 +- src/html/env.d.ts | 16 ++ src/html/page/package.json | 5 + src/html/page/src/page.html | 22 ++ src/html/page/src/page.ts | 109 ++++++++ src/html/page/src/serviceWorker.ts | 26 ++ src/html/page/tsconfig.sw.json | 10 + src/html/page/vite.config.ts | 7 + src/html/stripe/package.json | 11 + src/html/stripe/playwright.config.ts | 10 + src/html/stripe/src/charge.html | 5 + src/html/stripe/src/charge.ts | 73 ++++++ src/html/stripe/vite.config.ts | 74 ++++++ src/html/tempo/package.json | 12 + src/html/tempo/src/charge.html | 8 + src/html/tempo/src/charge.test.ts | 49 ++++ src/html/tempo/src/charge.ts | 135 ++++++++++ src/html/tempo/test/env.d.ts | 6 + src/html/tempo/test/playwright-utils.ts | 99 ++++++++ src/html/tempo/test/playwright.config.ts | 11 + src/html/tempo/test/setup.global.ts | 40 +++ src/html/tempo/test/tsconfig.json | 9 + src/html/tempo/vite.config.ts | 23 ++ src/html/tsconfig.browser.json | 10 + src/html/tsconfig.json | 9 + src/html/vite.ts | 236 +++++++++++++++++ src/middlewares/elysia.ts | 4 +- src/middlewares/express.ts | 4 +- src/middlewares/hono.ts | 4 +- src/middlewares/nextjs.ts | 4 +- src/server/Html.ts | 142 ----------- src/server/Mppx.ts | 23 +- src/server/Transport.ts | 24 +- src/server/index.ts | 2 +- src/server/internal/Html.ts | 48 ++++ src/server/internal/page.ts | 111 ++++++++ src/stripe/server/Charge.ts | 78 +----- src/stripe/server/internal/html.ts | 222 ++++++++++++++++ src/tempo/server/Charge.ts | 159 +----------- src/tempo/server/internal/html.ts | 48 ++++ src/tsconfig.json | 2 +- tsconfig.json | 9 +- 47 files changed, 1820 insertions(+), 415 deletions(-) create mode 100644 src/html/env.d.ts create mode 100644 src/html/page/package.json create mode 100644 src/html/page/src/page.html create mode 100644 src/html/page/src/page.ts create mode 100644 src/html/page/src/serviceWorker.ts create mode 100644 src/html/page/tsconfig.sw.json create mode 100644 src/html/page/vite.config.ts create mode 100644 src/html/stripe/package.json create mode 100644 src/html/stripe/playwright.config.ts create mode 100644 src/html/stripe/src/charge.html create mode 100644 src/html/stripe/src/charge.ts create mode 100644 src/html/stripe/vite.config.ts create mode 100644 src/html/tempo/package.json create mode 100644 src/html/tempo/src/charge.html create mode 100644 src/html/tempo/src/charge.test.ts create mode 100644 src/html/tempo/src/charge.ts create mode 100644 src/html/tempo/test/env.d.ts create mode 100644 src/html/tempo/test/playwright-utils.ts create mode 100644 src/html/tempo/test/playwright.config.ts create mode 100644 src/html/tempo/test/setup.global.ts create mode 100644 src/html/tempo/test/tsconfig.json create mode 100644 src/html/tempo/vite.config.ts create mode 100644 src/html/tsconfig.browser.json create mode 100644 src/html/tsconfig.json create mode 100644 src/html/vite.ts delete mode 100644 src/server/Html.ts create mode 100644 src/server/internal/Html.ts create mode 100644 src/server/internal/page.ts create mode 100644 src/stripe/server/internal/html.ts create mode 100644 src/tempo/server/internal/html.ts diff --git a/.gitignore b/.gitignore index a0951fd5..d9ed1d06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ coverage .env .env.* !.env.example +test-results/ diff --git a/.oxlintrc.json b/.oxlintrc.json index 6f7d5765..b889c036 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -22,7 +22,10 @@ // Used only in server-side proxy code (Node 18+ has URLPattern) "URLPattern", // globalThis.crypto available from Node 18.4+; gated on modern runtimes - "crypto" + "crypto", + // Browser-only APIs used in src/html/ method UIs and shell + "CustomEvent", + "navigator" ] }, "overrides": [ diff --git a/package.json b/package.json index cf847fd3..9788d635 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { "build": "zile", + "build:html": "vite build src/html/page && vite build src/html/tempo && vite build src/html/stripe", "changeset:publish": "zile publish:prepare && changeset publish && zile publish:post", "changeset:version": "changeset version", "check": "oxlint --fix && oxfmt --write .", @@ -11,8 +12,13 @@ "deps:ci": "pnpx actions-up", "dev": "zile dev", "dev:example": "node scripts/dev:example.ts", + "dev:html:stripe": "vite dev src/html/stripe", + "dev:html:tempo": "vite dev src/html/tempo", "mppx": "node --import tsx src/bin.ts", - "test": "vitest" + "test": "vitest", + "test:html": "pnpm test:html:stripe && pnpm test:html:tempo", + "test:html:stripe": "playwright test -c src/html/stripe/playwright.config.ts", + "test:html:tempo": "playwright test -c src/html/tempo/test/playwright.config.ts" }, "browserslist": [ "defaults", @@ -44,8 +50,10 @@ "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", + "@playwright/test": "^1.58.2", "playwright": "^1.58.2", "prool": "^0.2.3", + "vite": "^8.0.0", "simple-git-hooks": "^2.13.1", "tempo.ts": "^0.14.2", "testcontainers": "^11.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34b6b258..fd8bac07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@modelcontextprotocol/sdk': specifier: 1.26.0 version: 1.26.0(zod@4.3.6) + '@playwright/test': + specifier: ^1.58.2 + version: 1.58.2 '@types/express': specifier: ^5.0.6 version: 5.0.6 @@ -63,10 +66,10 @@ importers: version: 7.0.0-dev.20260323.1 '@vitest/browser-playwright': specifier: ^4.1.0 - version: 4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) + version: 4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) '@vitest/coverage-v8': specifier: ^4.1.0 - version: 4.1.0(@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0) + version: 4.1.0(@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0) browserslist: specifier: ^4.28.1 version: 4.28.1 @@ -121,9 +124,12 @@ importers: viem: specifier: ^2.47.5 version: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + vite: + specifier: ^8.0.0 + version: 8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) zile: specifier: ^0.0.19 version: 0.0.19(@typescript/native-preview@7.0.0-dev.20260323.1)(typescript@5.9.3) @@ -285,6 +291,26 @@ importers: specifier: latest version: 8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) + src/html/page: {} + + src/html/stripe: + devDependencies: + '@stripe/stripe-js': + specifier: 8.9.0 + version: 8.9.0 + + src/html/tempo: + devDependencies: + mipd: + specifier: 0.0.7 + version: 0.0.7(typescript@5.9.3) + ox: + specifier: ^0.14.1 + version: 0.14.1(typescript@5.9.3)(zod@4.3.6) + viem: + specifier: ^2.47.5 + version: 2.47.5(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + packages: '@adraffy/ens-normalize@1.11.1': @@ -876,6 +902,9 @@ packages: '@oxc-project/types@0.115.0': resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==} + '@oxc-project/types@0.122.0': + resolution: {integrity: sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==} + '@oxfmt/binding-android-arm-eabi@0.41.0': resolution: {integrity: sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1112,6 +1141,11 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.58.2': + resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==} + engines: {node: '>=18'} + hasBin: true + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -1179,95 +1213,187 @@ packages: '@remix-run/session@0.4.1': resolution: {integrity: sha512-Bm6aKYgutb/raHZ3laloz8g/Qu7f3CeK3o4gUVDMxtEiAdWCzJamwHoTpGOc5+g1Kuy7z85v4M6nGrF06MFDSg==} + '@rolldown/binding-android-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.9': resolution: {integrity: sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': resolution: {integrity: sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.11': + resolution: {integrity: sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.9': resolution: {integrity: sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': + resolution: {integrity: sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': resolution: {integrity: sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': + resolution: {integrity: sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': resolution: {integrity: sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': + resolution: {integrity: sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': + resolution: {integrity: sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': + resolution: {integrity: sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': + resolution: {integrity: sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': + resolution: {integrity: sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': resolution: {integrity: sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==} engines: {node: '>=14.0.0'} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': + resolution: {integrity: sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': + resolution: {integrity: sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': resolution: {integrity: sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] + '@rolldown/pluginutils@1.0.0-rc.11': + resolution: {integrity: sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==} + '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -1312,6 +1438,10 @@ packages: resolution: {integrity: sha512-tNUerSstwNC1KuHgX4CASGO0Md3CB26IJzSXmVlSuFvhsBP4ZaEPpY4jxWOn9tfdDscuVT4Kqb8cZ2o9nLCgRQ==} engines: {node: '>=12.16'} + '@stripe/stripe-js@8.9.0': + resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==} + engines: {node: '>=12.16'} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -3166,6 +3296,11 @@ packages: rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + rolldown@1.0.0-rc.11: + resolution: {integrity: sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.0.0-rc.9: resolution: {integrity: sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3601,6 +3736,49 @@ packages: yaml: optional: true + vite@8.0.2: + resolution: {integrity: sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.0 + esbuild: ^0.27.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@4.1.0: resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -4470,6 +4648,8 @@ snapshots: '@oxc-project/types@0.115.0': {} + '@oxc-project/types@0.122.0': {} + '@oxfmt/binding-android-arm-eabi@0.41.0': optional: true @@ -4589,6 +4769,10 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.58.2': + dependencies: + playwright: 1.58.2 + '@polka/url@1.0.0-next.29': {} '@protobufjs/aspromise@1.1.2': {} @@ -4653,53 +4837,102 @@ snapshots: '@remix-run/session@0.4.1': {} + '@rolldown/binding-android-arm64@1.0.0-rc.11': + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.9': optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.11': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.9': optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.11': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.9': optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.11': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.11': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.11': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.11': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.9': dependencies: '@napi-rs/wasm-runtime': 1.1.1 optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.11': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.9': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.11': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.9': optional: true + '@rolldown/pluginutils@1.0.0-rc.11': {} + '@rolldown/pluginutils@1.0.0-rc.7': {} '@rolldown/pluginutils@1.0.0-rc.9': {} @@ -4742,6 +4975,8 @@ snapshots: '@stripe/stripe-js@8.7.0': {} + '@stripe/stripe-js@8.9.0': {} + '@tanstack/query-core@5.90.20': {} '@tanstack/react-query@5.90.21(react@19.2.4)': @@ -4899,29 +5134,29 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) - '@vitest/browser-playwright@4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)': + '@vitest/browser-playwright@4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)': dependencies: - '@vitest/browser': 4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) - '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/browser': 4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) + '@vitest/mocker': 4.1.0(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) playwright: 1.58.2 tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)': + '@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.0(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/utils': 4.1.0 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) ws: 8.19.0(bufferutil@4.1.0)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -4929,7 +5164,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@4.1.0(@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0)': + '@vitest/coverage-v8@4.1.0(@vitest/browser@4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0))(vitest@4.1.0)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -4941,9 +5176,9 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + vitest: 4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) optionalDependencies: - '@vitest/browser': 4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) + '@vitest/browser': 4.1.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) '@vitest/expect@4.1.0': dependencies: @@ -4954,13 +5189,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.1.0(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.1.0 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.1.0': dependencies: @@ -6624,6 +6859,27 @@ snapshots: rfdc@1.4.1: {} + rolldown@1.0.0-rc.11: + dependencies: + '@oxc-project/types': 0.122.0 + '@rolldown/pluginutils': 1.0.0-rc.11 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-rc.11 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.11 + '@rolldown/binding-darwin-x64': 1.0.0-rc.11 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.11 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.11 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.11 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.11 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.11 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.11 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.11 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.11 + rolldown@1.0.0-rc.9: dependencies: '@oxc-project/types': 0.115.0 @@ -7117,10 +7373,24 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)): + vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.3 + postcss: 8.5.8 + rolldown: 1.0.0-rc.11 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + esbuild: 0.27.2 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.2 + + vitest@4.1.0(@types/node@25.5.0)(@vitest/browser-playwright@4.1.0)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)): dependencies: '@vitest/expect': 4.1.0 - '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.1.0(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.1.0 '@vitest/runner': 4.1.0 '@vitest/snapshot': 4.1.0 @@ -7137,11 +7407,11 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.5.0 - '@vitest/browser-playwright': 4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) + '@vitest/browser-playwright': 4.1.0(bufferutil@4.1.0)(playwright@1.58.2)(utf-8-validate@5.0.10)(vite@8.0.2(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.1.0) transitivePeerDependencies: - msw diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 70927fa8..18c3fd6e 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,9 @@ packages: - . - examples/* - examples/session/* + - src/html/page + - src/html/tempo + - src/html/stripe onlyBuiltDependencies: - simple-git-hooks diff --git a/src/Method.ts b/src/Method.ts index 49881459..9b8d4560 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -74,7 +74,8 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined - html?: string | undefined + html?: string | false | undefined + htmlConfig?: Record | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -203,11 +204,12 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, html, request, respond, transport, verify } = options + const { defaults, html, htmlConfig, request, respond, transport, verify } = options return { ...method, defaults, html, + htmlConfig, request, respond, transport, @@ -222,7 +224,8 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined - html?: string | undefined + html?: string | false | undefined + htmlConfig?: Record | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/html/env.d.ts b/src/html/env.d.ts new file mode 100644 index 00000000..7e1c235f --- /dev/null +++ b/src/html/env.d.ts @@ -0,0 +1,16 @@ +interface MppxGlobal { + readonly challenge: { + readonly id: string + readonly realm: string + readonly method: string + readonly intent: string + readonly request: Record + readonly expires?: string + readonly description?: string + [key: string]: unknown + } + readonly config: Record + serializeCredential(payload: unknown, source?: string): string +} + +declare var mppx: MppxGlobal diff --git a/src/html/page/package.json b/src/html/page/package.json new file mode 100644 index 00000000..72ef72a8 --- /dev/null +++ b/src/html/page/package.json @@ -0,0 +1,5 @@ +{ + "name": "@mppx/html-page", + "private": true, + "type": "module" +} diff --git a/src/html/page/src/page.html b/src/html/page/src/page.html new file mode 100644 index 00000000..96802cd2 --- /dev/null +++ b/src/html/page/src/page.html @@ -0,0 +1,22 @@ + + + + + + + + + +
+

Payment Required

+
+
+
+

+      
+
+ + + + + diff --git a/src/html/page/src/page.ts b/src/html/page/src/page.ts new file mode 100644 index 00000000..992e2386 --- /dev/null +++ b/src/html/page/src/page.ts @@ -0,0 +1,109 @@ +const dataEl = document.getElementById('mppx-data') as HTMLScriptElement +const { challenge, config } = JSON.parse(dataEl.textContent!) as { + challenge: MppxGlobal['challenge'] + config: Record +} + +// --- Globals --- + +function base64url(str: string): string { + return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +function sortKeys(obj: Record): Record { + const sorted: Record = {} + Object.keys(obj) + .sort() + .forEach((k) => { + const v = obj[k] + sorted[k] = + v && typeof v === 'object' && !Array.isArray(v) ? sortKeys(v as Record) : v + }) + return sorted +} + +;(window as any).mppx = Object.freeze({ + challenge, + config, + serializeCredential(payload: unknown, source?: string): string { + const wire: Record = { + challenge: Object.assign({}, mppx.challenge, { + request: base64url( + JSON.stringify(sortKeys(mppx.challenge.request as Record)), + ), + }), + payload, + } + if (source) wire.source = source + return 'Payment ' + base64url(JSON.stringify(wire)) + }, +}) + +// --- Populate challenge display --- + +const challengeEl = document.getElementById('mppx-challenge')! +challengeEl.textContent = JSON.stringify(challenge, null, 2) + +// --- Description --- + +if (challenge.description) { + const p = document.createElement('p') + p.textContent = challenge.description + document.querySelector('header')!.appendChild(p) +} + +// --- Service worker & mppx:complete --- + +function activateSw(reg: ServiceWorkerRegistration): Promise { + const sw = reg.installing || reg.waiting || reg.active + return new Promise((resolve) => { + if (sw!.state === 'activated') return resolve() + sw!.addEventListener('statechange', () => { + if (sw!.state === 'activated') resolve() + }) + }) +} + +addEventListener('mppx:complete', ((e: CustomEvent) => { + const statusEl = document.getElementById('status') + const authorization = e.detail + if (statusEl) { + statusEl.textContent = 'Verifying payment...' + statusEl.style.color = '' + } + + navigator.serviceWorker + .register('/__mppx_serviceWorker.js') + .then(activateSw) + .then(() => { + function sendAndReload() { + navigator.serviceWorker.controller!.postMessage(authorization) + window.location.reload() + } + if (navigator.serviceWorker.controller) sendAndReload() + else navigator.serviceWorker.addEventListener('controllerchange', sendAndReload) + }) + .catch(() => { + fetch(window.location.href, { + headers: { Authorization: authorization }, + }) + .then((res) => { + if (!res.ok) { + if (statusEl) { + statusEl.textContent = 'Verification failed (' + res.status + ')' + statusEl.style.color = 'red' + } + return + } + return res.blob().then((blob) => { + window.location = URL.createObjectURL(blob) as any + }) + }) + .catch((err) => { + if (statusEl) { + statusEl.textContent = err.message || 'Request failed' + statusEl.style.color = 'red' + } + }) + }) +}) as EventListener) diff --git a/src/html/page/src/serviceWorker.ts b/src/html/page/src/serviceWorker.ts new file mode 100644 index 00000000..32fad5ca --- /dev/null +++ b/src/html/page/src/serviceWorker.ts @@ -0,0 +1,26 @@ +/// + +let cred: string | null = null + +;(self as unknown as ServiceWorkerGlobalScope).addEventListener('install', () => { + ;(self as unknown as ServiceWorkerGlobalScope).skipWaiting() +}) +;(self as unknown as ServiceWorkerGlobalScope).addEventListener( + 'activate', + (e: ExtendableEvent) => { + e.waitUntil((self as unknown as ServiceWorkerGlobalScope).clients.claim()) + }, +) +;(self as unknown as ServiceWorkerGlobalScope).addEventListener( + 'message', + (e: ExtendableMessageEvent) => { + cred = e.data + }, +) +;(self as unknown as ServiceWorkerGlobalScope).addEventListener('fetch', (e: FetchEvent) => { + if (!cred) return + const h = new Headers(e.request.headers) + h.set('Authorization', cred) + cred = null + e.respondWith(fetch(new Request(e.request, { headers: h }))) +}) diff --git a/src/html/page/tsconfig.sw.json b/src/html/page/tsconfig.sw.json new file mode 100644 index 00000000..f8eb967f --- /dev/null +++ b/src/html/page/tsconfig.sw.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2022", "WebWorker"], + "types": [] + }, + "include": ["src/serviceWorker.ts"] +} diff --git a/src/html/page/vite.config.ts b/src/html/page/vite.config.ts new file mode 100644 index 00000000..06abea0e --- /dev/null +++ b/src/html/page/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' + +import { buildPage } from '../vite.js' + +export default defineConfig({ + plugins: [buildPage()], +}) diff --git a/src/html/stripe/package.json b/src/html/stripe/package.json new file mode 100644 index 00000000..2e79ef38 --- /dev/null +++ b/src/html/stripe/package.json @@ -0,0 +1,11 @@ +{ + "name": "@mppx/html-stripe", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev" + }, + "devDependencies": { + "@stripe/stripe-js": "8.9.0" + } +} diff --git a/src/html/stripe/playwright.config.ts b/src/html/stripe/playwright.config.ts new file mode 100644 index 00000000..cb595705 --- /dev/null +++ b/src/html/stripe/playwright.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: './src', + testMatch: '*.test.ts', + timeout: 30_000, + use: { + headless: true, + }, +}) diff --git a/src/html/stripe/src/charge.html b/src/html/stripe/src/charge.html new file mode 100644 index 00000000..b9e6c2da --- /dev/null +++ b/src/html/stripe/src/charge.html @@ -0,0 +1,5 @@ +
+
+ + +
diff --git a/src/html/stripe/src/charge.ts b/src/html/stripe/src/charge.ts new file mode 100644 index 00000000..7ddaca95 --- /dev/null +++ b/src/html/stripe/src/charge.ts @@ -0,0 +1,73 @@ +import { loadStripe } from '@stripe/stripe-js/pure' + +const request = mppx.challenge.request as Record + +const stripe = (await loadStripe(mppx.config.publishableKey as string))! +const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches +const elements = stripe.elements({ + mode: 'payment', + amount: Number(request.amount), + currency: request.currency as string, + appearance: { theme: isDark ? 'night' : 'stripe', variables: { spacingUnit: '3px' } }, + paymentMethodTypes: ['card'], + paymentMethodCreation: 'manual', +}) +elements + .create('payment', { + layout: 'tabs', + fields: { billingDetails: { address: { postalCode: 'never', country: 'never' } } }, + wallets: { link: 'never' }, + }) + .mount('#payment-element') + +const payBtn = document.getElementById('pay') as HTMLButtonElement +const statusEl = document.getElementById('status') as HTMLOutputElement + +window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + elements.update({ appearance: { theme: e.matches ? 'night' : 'stripe' } }) +}) + +payBtn.onclick = async () => { + payBtn.disabled = true + const submitResult = await elements.submit() + if (submitResult.error) { + statusEl.textContent = submitResult.error.message! + statusEl.style.color = 'red' + payBtn.disabled = false + return + } + const result = await stripe.createPaymentMethod({ + elements, + params: { billing_details: { address: { postal_code: '10001', country: 'US' } } }, + }) + if (result.error) { + statusEl.textContent = result.error.message! + statusEl.style.color = 'red' + payBtn.disabled = false + return + } + + const res = await fetch('/api/create-spt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + paymentMethod: result.paymentMethod.id, + amount: String(request.amount), + currency: request.currency as string, + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }), + }) + if (!res.ok) { + const err = await res.json() + statusEl.textContent = err.error || 'SPT creation failed' + statusEl.style.color = 'red' + payBtn.disabled = false + return + } + const data = await res.json() + dispatchEvent( + new CustomEvent('mppx:complete', { + detail: mppx.serializeCredential({ spt: data.spt }), + }), + ) +} diff --git a/src/html/stripe/vite.config.ts b/src/html/stripe/vite.config.ts new file mode 100644 index 00000000..44bf230e --- /dev/null +++ b/src/html/stripe/vite.config.ts @@ -0,0 +1,74 @@ +import { defineConfig } from 'vite' + +import * as Methods from '../../stripe/Methods.js' +import { build, dev } from '../vite.js' + +export default defineConfig({ + plugins: [ + { + name: 'stripe-spt', + configureServer(server) { + // oxlint-disable-next-line no-async-endpoint-handlers + server.middlewares.use('/api/create-spt', async (req, res) => { + const secretKey = process.env.VITE_STRIPE_SECRET_KEY + if (!secretKey) { + res.statusCode = 500 + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ error: 'VITE_STRIPE_SECRET_KEY not set in .env' })) + return + } + + const chunks: Buffer[] = [] + for await (const chunk of req) chunks.push(chunk as Buffer) + const { paymentMethod, amount, currency, expiresAt } = JSON.parse( + Buffer.concat(chunks).toString(), + ) as { paymentMethod: string; amount: string; currency: string; expiresAt: number } + + const body = new URLSearchParams({ + payment_method: paymentMethod, + 'usage_limits[currency]': currency, + 'usage_limits[max_amount]': amount, + 'usage_limits[expires_at]': expiresAt.toString(), + }) + + const response = await fetch( + 'https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens', + { + method: 'POST', + headers: { + Authorization: `Basic ${btoa(`${secretKey}:`)}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body, + }, + ) + + res.setHeader('Content-Type', 'application/json') + if (!response.ok) { + const error = (await response.json()) as { error: { message: string } } + res.statusCode = 500 + res.end(JSON.stringify({ error: error.error.message })) + return + } + + const { id: spt } = (await response.json()) as { id: string } + res.end(JSON.stringify({ spt })) + }) + }, + }, + dev({ + method: Methods.charge, + request: { + amount: '10', + currency: 'usd', + decimals: 2, + networkId: 'acct_dev', + paymentMethodTypes: ['card'], + }, + config: { + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY ?? 'pk_test_example', + }, + }), + build('charge'), + ], +}) diff --git a/src/html/tempo/package.json b/src/html/tempo/package.json new file mode 100644 index 00000000..4ffa5d83 --- /dev/null +++ b/src/html/tempo/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mppx/html-tempo", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev" + }, + "devDependencies": { + "mipd": "0.0.7", + "viem": "2.47.5" + } +} diff --git a/src/html/tempo/src/charge.html b/src/html/tempo/src/charge.html new file mode 100644 index 00000000..50c92df5 --- /dev/null +++ b/src/html/tempo/src/charge.html @@ -0,0 +1,8 @@ +
+ diff --git a/src/html/tempo/src/charge.test.ts b/src/html/tempo/src/charge.test.ts new file mode 100644 index 00000000..cd8e756a --- /dev/null +++ b/src/html/tempo/src/charge.test.ts @@ -0,0 +1,49 @@ +import { expect } from '@playwright/test' + +import { test } from '../test/playwright-utils.js' + +test('renders the payment page with challenge info', async ({ baseUrl, page }) => { + const response = await page.goto(baseUrl) + expect(response!.status()).toBe(402) + + await expect(page.locator('h1')).toHaveText('Payment Required') + await expect(page.locator('#mppx-challenge')).toContainText('"method": "tempo"') + await expect(page.locator('#mppx-challenge')).toContainText('"intent": "charge"') +}) + +test('displays "No wallets detected" when no provider is injected', async ({ baseUrl, page }) => { + await page.goto(baseUrl) + await expect(page.locator('#wallets')).toContainText('No wallets detected') +}) + +test('formats the pay button amount from challenge', async ({ baseUrl, page }) => { + await page.goto(baseUrl) + // amount=1000000, decimals=6 → "Pay $1" + await expect(page.locator('#pay-btn')).toHaveText('Pay $1') +}) + +test('shows wallet and connects', async ({ wallet: _wallet, page }) => { + await expect(page.locator('#wallets button')).toHaveText('Connect Test Wallet') + await page.locator('#wallets button').click() + + await expect(page.locator('#connected')).toBeVisible() + await expect(page.locator('#wallets')).toBeHidden() +}) + +test('completes payment against local Tempo chain', async ({ wallet: _wallet, page }) => { + await page.locator('#wallets button').click() + await expect(page.locator('#connected')).toBeVisible() + + await page.locator('#pay-btn').click() + + await expect(page.locator('h1')).toHaveText('Payment verified!') +}) + +test('disconnect resets to wallet selection', async ({ wallet: _wallet, page }) => { + await page.locator('#wallets button').click() + await expect(page.locator('#connected')).toBeVisible() + + await page.locator('#disconnect-btn').click() + await expect(page.locator('#wallets')).toBeVisible() + await expect(page.locator('#connected')).toBeHidden() +}) diff --git a/src/html/tempo/src/charge.ts b/src/html/tempo/src/charge.ts new file mode 100644 index 00000000..8009fe8b --- /dev/null +++ b/src/html/tempo/src/charge.ts @@ -0,0 +1,135 @@ +import { createStore } from 'mipd' +import type { EIP1193Provider } from 'mipd' +import { createClient, custom, encodeFunctionData, parseAbi } from 'viem' +import { sendTransactionSync } from 'viem/actions' +import { tempo as tempoMainnet, tempoLocalnet, tempoModerato } from 'viem/chains' + +const request = mppx.challenge.request as Record + +const store = createStore() +const walletsEl = document.getElementById('wallets')! +const connectedEl = document.getElementById('connected')! +const accountDisplay = document.getElementById('account-display')! +const payBtn = document.getElementById('pay-btn') as HTMLButtonElement +let activeProvider: EIP1193Provider | null = null +let activeAccount: string | null = null + +function renderWallets() { + if (activeAccount) return + const providers = store.getProviders() + if (!providers.length) { + walletsEl.innerHTML = '

No wallets detected.

' + return + } + walletsEl.innerHTML = '' + for (const p of providers) { + const btn = document.createElement('button') + btn.textContent = 'Connect ' + p.info.name + btn.onclick = () => connect(p.provider) + walletsEl.appendChild(btn) + } +} + +function showConnected(account: string) { + activeAccount = account + accountDisplay.textContent = account.slice(0, 6) + '...' + account.slice(-4) + walletsEl.hidden = true + connectedEl.hidden = false +} + +function disconnect() { + activeProvider = null + activeAccount = null + walletsEl.hidden = false + connectedEl.hidden = true + renderWallets() +} + +document.getElementById('disconnect-btn')!.onclick = disconnect +payBtn.onclick = () => pay() + +// Set pay button label from challenge amount +const rawAmount = request.amount as string +const decimals = (request.decimals as number) || 6 +const whole = rawAmount.slice(0, -decimals) || '0' +const frac = rawAmount.slice(-decimals).padStart(decimals, '0').replace(/0+$/, '') +const formatted = frac ? whole + '.' + frac : whole +payBtn.textContent = 'Pay $' + formatted + +store.subscribe(renderWallets) +renderWallets() + +async function connect(provider: EIP1193Provider) { + const accounts = (await provider.request({ method: 'eth_requestAccounts' })) as string[] + const account = accounts[0] + if (!account) throw new Error('No account selected') + activeProvider = provider + showConnected(account) +} + +async function pay() { + if (!activeProvider || !activeAccount) return + payBtn.disabled = true + + try { + const chainId = (request.methodDetails?.chainId as number) || 42432 + + const chain = (() => { + if (chainId === tempoMainnet.id) return tempoMainnet + if (chainId === tempoModerato.id) return tempoModerato + if (chainId === tempoLocalnet.id) return tempoLocalnet + throw new Error('Unsupported chain: ' + chainId) + })() + const hexChainId = '0x' + chainId.toString(16) + const currentChain = (await activeProvider.request({ method: 'eth_chainId' })) as string + if (parseInt(currentChain, 16) !== chainId) { + try { + await activeProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: hexChainId }], + }) + } catch (e: any) { + if (e.code === 4902) { + await activeProvider.request({ + method: 'wallet_addEthereumChain', + params: [ + { + chainId: hexChainId, + chainName: chain.name, + nativeCurrency: { name: 'USD', symbol: 'USD', decimals: 18 }, + rpcUrls: [chain.rpcUrls.default.http[0]], + }, + ], + }) + } else { + throw e + } + } + } + + const client = createClient({ + account: activeAccount as `0x${string}`, + chain, + transport: custom(activeProvider), + }) + + const receipt = await sendTransactionSync(client, { + to: request.currency as `0x${string}`, + data: encodeFunctionData({ + abi: parseAbi(['function transfer(address to, uint256 amount)']), + args: [request.recipient as `0x${string}`, BigInt(request.amount as string)], + }), + }) + + dispatchEvent( + new CustomEvent('mppx:complete', { + detail: mppx.serializeCredential( + { hash: receipt.transactionHash, type: 'hash' }, + 'did:pkh:eip155:' + chainId + ':' + activeAccount, + ), + }), + ) + } catch { + payBtn.disabled = false + } +} diff --git a/src/html/tempo/test/env.d.ts b/src/html/tempo/test/env.d.ts new file mode 100644 index 00000000..38671a2f --- /dev/null +++ b/src/html/tempo/test/env.d.ts @@ -0,0 +1,6 @@ +declare namespace NodeJS { + interface ProcessEnv { + TEMPO_CHAIN_ID: string + TEMPO_RPC_URL: string + } +} diff --git a/src/html/tempo/test/playwright-utils.ts b/src/html/tempo/test/playwright-utils.ts new file mode 100644 index 00000000..cd200a7b --- /dev/null +++ b/src/html/tempo/test/playwright-utils.ts @@ -0,0 +1,99 @@ +import * as path from 'node:path' + +import { test as base } from '@playwright/test' +import { createClient, defineChain, http, numberToHex } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { sendTransactionSync } from 'viem/actions' +import { tempoLocalnet } from 'viem/chains' +import { Actions, Addresses } from 'viem/tempo' +import { createServer } from 'vite' + +export const test = base.extend<{ wallet: void }, { baseUrl: string }>({ + baseUrl: [ + // oxlint-disable-next-line no-empty-pattern + async ({}, use) => { + const server = await createServer({ + root: path.resolve(import.meta.dirname, '..'), + configFile: path.resolve(import.meta.dirname, '..', 'vite.config.ts'), + server: { port: 24678 + Math.floor(Math.random() * 1000), strictPort: false }, + }) + await server.listen() + process.on('exit', server.close) + const address = server.httpServer?.address() + const port = typeof address === 'object' && address ? address.port : 5173 + await use(`http://localhost:${port}`) + process.off('exit', server.close) + await server.close() + }, + { scope: 'worker' }, + ], + + wallet: async ({ baseUrl, page }, use) => { + const privateKey = generatePrivateKey() + const account = privateKeyToAccount(privateKey) + + const chain = defineChain({ + ...tempoLocalnet, + rpcUrls: { default: { http: [process.env.TEMPO_RPC_URL!] } }, + }) + + { + const funderAccount = privateKeyToAccount( + '0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', + ) + const client = createClient({ account: funderAccount, chain, transport: http() }) + await Actions.token.transferSync(client, { + account: funderAccount, + chain, + token: Addresses.pathUsd, + to: account.address, + amount: 10_000_000n, + }) + } + + const client = createClient({ account, chain, transport: http() }) + + await page.exposeFunction('__mockRequest', async (method: string, params?: unknown) => { + if (method === 'eth_requestAccounts') return [account.address] + if (method === 'eth_chainId') return numberToHex(chain.id) + if (method === 'wallet_switchEthereumChain') return null + if (method === 'wallet_addEthereumChain') return null + + if (method === 'eth_sendTransactionSync' || method === 'eth_sendTransaction') { + const [tx] = params as [{ to: `0x${string}`; data: `0x${string}` }] + const receipt = await sendTransactionSync(client, { + to: tx.to, + data: tx.data, + }) + if (method === 'eth_sendTransactionSync') return receipt + return receipt.transactionHash + } + + return client.transport.request({ method, params } as any) + }) + + await page.goto(baseUrl) + await page.evaluate(() => { + window.dispatchEvent( + new CustomEvent('eip6963:announceProvider', { + detail: Object.freeze({ + info: { + uuid: 'test-wallet-uuid', + name: 'Test Wallet', + icon: 'data:image/svg+xml,', + rdns: 'com.test.wallet', + }, + provider: { + request: async ({ method, params }: { method: string; params?: unknown }) => + (window as any).__mockRequest(method, params), + on() {}, + removeListener() {}, + }, + }), + }), + ) + }) + + await use() + }, +}) diff --git a/src/html/tempo/test/playwright.config.ts b/src/html/tempo/test/playwright.config.ts new file mode 100644 index 00000000..8e0b30a4 --- /dev/null +++ b/src/html/tempo/test/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from '@playwright/test' + +export default defineConfig({ + testDir: '../src', + testMatch: '*.test.ts', + globalSetup: './setup.global.ts', + timeout: 60_000, + use: { + headless: true, + }, +}) diff --git a/src/html/tempo/test/setup.global.ts b/src/html/tempo/test/setup.global.ts new file mode 100644 index 00000000..02b59074 --- /dev/null +++ b/src/html/tempo/test/setup.global.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert' + +import { RpcTransport } from 'ox' +import { Server } from 'prool' +import * as TestContainers from 'prool/testcontainers' +import { tempoLocalnet } from 'viem/chains' + +export default async function () { + if (process.env.TEMPO_RPC_URL) return + + const tag = await (async () => { + if (!process.env.VITE_TEMPO_TAG?.startsWith('http')) return process.env.VITE_TEMPO_TAG + const transport = RpcTransport.fromHttp(process.env.VITE_TEMPO_TAG) + const result = (await transport.request({ + method: 'web3_clientVersion', + })) as string + const sha = result.match(/tempo\/v[\d.]+-([a-f0-9]+)\//)?.[1] + return `sha-${sha}` + })() + + const server = Server.create({ + instance: TestContainers.Instance.tempo({ + blockTime: '200ms', + mnemonic: 'test test test test test test test test test test test junk', + image: `ghcr.io/tempoxyz/tempo:${tag ?? 'latest'}`, + }), + }) + + await server.start() + + const address = server.address() + assert(address?.port) + const rpcUrl = `http://localhost:${address.port}/1` + await fetch(`${rpcUrl}/start`) + + process.env.TEMPO_CHAIN_ID = String(tempoLocalnet.id) + process.env.TEMPO_RPC_URL = rpcUrl + + return () => server.stop() +} diff --git a/src/html/tempo/test/tsconfig.json b/src/html/tempo/test/tsconfig.json new file mode 100644 index 00000000..99560fc2 --- /dev/null +++ b/src/html/tempo/test/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["*.ts", "env.d.ts"] +} diff --git a/src/html/tempo/vite.config.ts b/src/html/tempo/vite.config.ts new file mode 100644 index 00000000..abf31885 --- /dev/null +++ b/src/html/tempo/vite.config.ts @@ -0,0 +1,23 @@ +import { tempoModerato } from 'viem/chains' +import { Addresses } from 'viem/tempo' +import { defineConfig } from 'vite' + +import * as Methods from '../../tempo/Methods.js' +import { build, dev } from '../vite.js' + +export default defineConfig({ + plugins: [ + dev({ + method: Methods.charge, + request: { + amount: '1', + currency: Addresses.pathUsd, + decimals: 6, + description: 'Test payment', + recipient: '0x0000000000000000000000000000000000000002', + chainId: Number(process.env.TEMPO_CHAIN_ID ?? tempoModerato.id), + }, + }), + build('charge'), + ], +}) diff --git a/src/html/tsconfig.browser.json b/src/html/tsconfig.browser.json new file mode 100644 index 00000000..cd05d85b --- /dev/null +++ b/src/html/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "esnext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [] + }, + "include": ["env.d.ts", "tempo/src/*.ts", "stripe/src/*.ts", "page/src/page.ts"] +} diff --git a/src/html/tsconfig.json b/src/html/tsconfig.json new file mode 100644 index 00000000..5dac0500 --- /dev/null +++ b/src/html/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "types": ["node"] + }, + "include": ["build.ts", "vite.ts", "*/vite.config.ts"] +} diff --git a/src/html/vite.ts b/src/html/vite.ts new file mode 100644 index 00000000..9b6920ef --- /dev/null +++ b/src/html/vite.ts @@ -0,0 +1,236 @@ +import * as crypto from 'node:crypto' +import * as fs from 'node:fs/promises' +import * as path from 'node:path' + +import type { Plugin } from 'vite' + +import * as Challenge from '../Challenge.js' +import * as Credential from '../Credential.js' +import * as Expires from '../Expires.js' +import type * as Method from '../Method.js' +import type * as z from '../zod.js' + +const html = String.raw +const pageDir = path.resolve(import.meta.dirname, 'page') +const defaultHead = html` + + Payment Required + +` + +export function dev(options: { + method: method + request: z.input + config?: Record + secretKey?: string +}): Plugin { + const secretKey = options.secretKey ?? 'mppx-dev-secret' + return { + name: 'mppx:dev', + configureServer(server) { + const intent = options.method.intent + + // oxlint-disable-next-line no-async-endpoint-handlers + server.middlewares.use(async (req, res, next) => { + if (req.url === '/__mppx_serviceWorker.js') { + const sw = await fs.readFile(path.resolve(pageDir, 'src/serviceWorker.ts'), 'utf-8') + res.setHeader('Content-Type', 'application/javascript') + const transformed = await server.transformRequest( + '/@fs/' + path.resolve(pageDir, 'src/serviceWorker.ts'), + ) + res.end(transformed?.code ?? sw) + return + } + + if (req.url !== '/' || !req.headers.accept?.includes('text/html')) return next() + + try { + const request = (await import('../server/Request.js')).fromNodeListener(req, res) + const credential = Credential.fromRequest(request) + if (Challenge.verify(credential.challenge, { secretKey })) { + res.setHeader('Content-Type', 'text/html') + res.end( + '

Payment verified!

This is the protected content.

', + ) + return + } + } catch {} + + const challenge = Challenge.fromMethod(options.method, { + secretKey, + realm: 'localhost', + request: options.request, + expires: Expires.minutes(5), + }) + + const dataJson = JSON.stringify({ challenge, config: options.config ?? {} }) + const page = await fs.readFile(path.resolve(pageDir, 'src/page.html'), 'utf-8') + const methodContent = ( + await fs.readFile(path.resolve(server.config.root, `src/${intent}.html`), 'utf-8') + ).trimEnd() + + const html = page + .replace('', defaultHead) + .replace( + '', + ``, + ) + .replace( + '', + ``, + ) + .replace( + '', + `${methodContent}\n `, + ) + + const transformed = await server.transformIndexHtml(req.url, html) + res.setHeader('Content-Type', 'text/html; charset=utf-8') + res.setHeader('WWW-Authenticate', Challenge.serialize(challenge)) + res.setHeader('Cache-Control', 'no-store') + res.statusCode = 402 + res.end(transformed) + }) + }, + } +} + +export function build(names: string | string[]): Plugin { + const items = Array.isArray(names) ? names : [names] + let root: string + return { + name: 'mppx:emit', + apply: 'build', + config: () => ({ + build: { + outDir: 'dist', + emptyOutDir: true, + rolldownOptions: { + input: Object.fromEntries(items.map((name) => [name, `src/${name}.ts`])), + output: { entryFileNames: '[name].js', format: 'iife' as const }, + }, + modulePreload: false, + minify: true, + }, + }), + configResolved(config) { + root = config.root + }, + async closeBundle() { + // e.g. root = src/html/tempo → method = tempo + const method = path.basename(root) + const output = path.resolve(root, `../../${method}/server/internal/html.ts`) + + for (const name of items) { + const content = ( + await fs.readFile(path.resolve(root, `src/${name}.html`), 'utf-8') + ).trimEnd() + const bundledScript = ( + await fs.readFile(path.resolve(root, `dist/${name}.js`), 'utf-8') + ).trim() + const code = escapeTemplateLiteral(bundledScript) + const scriptBlock = `\n ` + + const body = [`export const html =`, ` \`\n${content}${scriptBlock}\n \``].join('\n') + const file = [comment(body), ``, body].join('\n') + + await fs.mkdir(path.dirname(output), { recursive: true }) + await fs.writeFile(output, file + '\n') + console.log(` Wrote ${output}`) + } + }, + } +} + +export function buildPage(): Plugin { + let root: string + return { + name: 'mppx:page_emit', + apply: 'build', + config: () => ({ + build: { + outDir: 'dist', + emptyOutDir: true, + rolldownOptions: { + input: 'src/page.ts', + output: { entryFileNames: '[name].js', format: 'es' as const }, + }, + modulePreload: false, + minify: false, + }, + }), + configResolved(config) { + root = config.root + }, + async closeBundle() { + const output = path.resolve(root, '../../server/internal/page.ts') + // Build service worker separately (different global scope) + const { build } = await import('vite') + await build({ + root, + logLevel: 'warn', + configFile: false, + build: { + outDir: 'dist', + emptyOutDir: false, + rolldownOptions: { + input: path.resolve(root, 'src/serviceWorker.ts'), + output: { entryFileNames: 'serviceWorker.js', format: 'es' }, + }, + minify: true, + modulePreload: false, + }, + }) + + const pageContent = ( + await fs.readFile(path.resolve(root, 'src/page.html'), 'utf-8') + ).trimEnd() + const pageBundledScript = escapeTemplateLiteral( + (await fs.readFile(path.resolve(root, 'dist/page.js'), 'utf-8')).trim(), + ) + const pageScript = `\n ` + const serviceWorkerScript = ( + await fs.readFile(path.resolve(root, 'dist/serviceWorker.js'), 'utf-8') + ).trim() + + const body = [ + `export const content = \`\n${pageContent}\``, + ``, + `export const script = \`${pageScript}\n \``, + ``, + `export const serviceWorker = ${JSON.stringify(serviceWorkerScript)}`, + ].join('\n') + const file = [comment(body), ``, body].join('\n') + + await fs.mkdir(path.dirname(output), { recursive: true }) + await fs.writeFile(output, file + '\n') + console.log(` Wrote ${output}`) + }, + } +} + +function comment(body: string): string { + const hash = crypto.createHash('md5').update(body).digest('hex') + return `/* oxlint-disable */\n// Generated by \`pnpm build:html\` (hash: ${hash})` +} + +function escapeTemplateLiteral(str: string): string { + return str + .replace(/\/\/# sourceMappingURL=.*$/m, '') + .trim() + .replaceAll('\\', '\\\\') + .replaceAll('`', '\\`') + .replaceAll('${', '\\${') +} + +function indent(str: string, spaces: number): string { + const pad = ' '.repeat(spaces) + return str + .split('\n') + .map((line) => (line.trim() ? pad + line : line)) + .join('\n') +} diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 7b1e4657..8e2bde7b 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -1,6 +1,6 @@ import type { Context } from 'elysia' -import { serviceWorkerResponse } from '../server/Html.js' +import { serviceWorkerResponse } from '../server/internal/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -62,7 +62,7 @@ export function payment( 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() + if (new URL(request.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() const result = await intent(options)(request) if (result.status === 402) return result.challenge const receipt = result.withReceipt(new Response()) diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index b04157de..b0c5a3d2 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -5,7 +5,7 @@ import type { RequestHandler, } from 'express' -import { serviceWorkerResponse } from '../server/Html.js' +import { serviceWorkerResponse } from '../server/internal/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -61,7 +61,7 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): RequestHandler { return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { - if (req.originalUrl === '/__mppx_sw.js') { + if (req.originalUrl === '/__mppx_serviceWorker.js') { const swRes = serviceWorkerResponse() res.setHeader('Content-Type', 'application/javascript') res.send(await swRes.text()) diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index 1576e6a5..4f967315 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono' -import { serviceWorkerResponse } from '../server/Html.js' +import { serviceWorkerResponse } from '../server/internal/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -56,7 +56,7 @@ export function payment( 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() + if (new URL(c.req.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() const result = await intent(options)(c.req.raw) if (result.status === 402) return result.challenge await next() diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index 688841d0..eb56a969 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -1,4 +1,4 @@ -import { serviceWorkerResponse } from '../server/Html.js' +import { serviceWorkerResponse } from '../server/internal/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -59,7 +59,7 @@ export function payment( handler: RouteHandler, ): RouteHandler { return async (request) => { - if (new URL(request.url).pathname === '/__mppx_sw.js') return serviceWorkerResponse() + if (new URL(request.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() const result = await intent(options)(request) if (result.status === 402) return result.challenge const response = await handler(request) diff --git a/src/server/Html.ts b/src/server/Html.ts deleted file mode 100644 index 9a18eaf6..00000000 --- a/src/server/Html.ts +++ /dev/null @@ -1,142 +0,0 @@ -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 ` - - - - - Payment Required - - - -
-

Payment Required

-${challenge.description ? `

${esc(challenge.description)}

\n` : ''}\ -
- -
-
-
${esc(JSON.stringify(challenge, null, 2))}
-
-
- - - -${methodHtml ?? '

This payment method does not support browser payments.

'} - -` -} - -/** @internal */ -function esc(str: string): string { - return str - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') -} diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index b0252353..b659f95d 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -238,11 +238,18 @@ export declare namespace create { * Serve an HTML payment page to browsers (requests with `Accept: text/html`). * * - `true` — use the built-in payment page - * - `(challenge) => string` — custom HTML renderer + * - `(props) => string` — custom HTML renderer * * Only applies when using the default HTTP transport. */ - html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined + html?: + | boolean + | ((props: { + challenge: Challenge.Challenge + method?: string | undefined + config?: Record | undefined + }) => 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"`. */ @@ -264,7 +271,9 @@ 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 + const methodHtmlValue = (method as Method.AnyServer).html + const methodHtml = methodHtmlValue === false ? undefined : methodHtmlValue + const htmlConfig = (method as Method.AnyServer).htmlConfig as Record | undefined return (options) => { const { description, meta, ...rest } = options @@ -312,6 +321,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.MalformedCredentialError({ reason: credentialError.message }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -323,6 +333,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -338,6 +349,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: 'challenge was not issued by this server', }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -367,6 +379,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -400,6 +413,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -416,6 +430,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R error: new Errors.PaymentExpiredError({ expires: credential.challenge.expires, }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -428,6 +443,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.InvalidPayloadError({ reason: (e as Error).message }), + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } @@ -447,6 +463,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error, + htmlConfig, methodHtml, }) return { challenge: response, status: 402 } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 431ad4da..ec6b363a 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -4,7 +4,7 @@ import * as Errors from '../Errors.js' import type { Distribute, UnionToIntersection } from '../internal/types.js' import * as core_Mcp from '../Mcp.js' import * as Receipt from '../Receipt.js' -import * as Html from './Html.js' +import * as Html from './internal/Html.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -32,6 +32,7 @@ export type Transport< challenge: Challenge.Challenge error?: Errors.PaymentError | undefined input: input + htmlConfig?: Record | undefined methodHtml?: string | undefined }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ @@ -114,11 +115,7 @@ export function from< export function http(options?: http.Options): Http { const htmlOption = options?.html const renderHtml = - htmlOption === true - ? (challenge: Challenge.Challenge, methodHtml?: string) => Html.render(challenge, methodHtml) - : typeof htmlOption === 'function' - ? htmlOption - : undefined + htmlOption === true ? Html.render : typeof htmlOption === 'function' ? htmlOption : undefined return from({ name: 'http', @@ -131,7 +128,7 @@ export function http(options?: http.Options): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error, input, methodHtml }) { + respondChallenge({ challenge, error, htmlConfig, input, methodHtml }) { const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', @@ -142,7 +139,7 @@ export function http(options?: http.Options): Http { let body: string | null = null if (acceptsHtml) { headers['Content-Type'] = 'text/html; charset=utf-8' - body = renderHtml(challenge, methodHtml) + body = renderHtml({ challenge, method: methodHtml, config: htmlConfig }) } else if (error) { headers['Content-Type'] = 'application/problem+json' body = JSON.stringify(error.toProblemDetails(challenge.id)) @@ -228,9 +225,16 @@ export declare namespace 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 + * - `(props) => string` — custom HTML renderer */ - html?: boolean | ((challenge: Challenge.Challenge, methodHtml?: string) => string) | undefined + html?: + | boolean + | ((props: { + challenge: Challenge.Challenge + method?: string | undefined + config?: Record | undefined + }) => string) + | undefined } } diff --git a/src/server/index.ts b/src/server/index.ts index 42d64970..c0467444 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,5 @@ export * as Expires from '../Expires.js' -export { html, serviceWorkerResponse } from './Html.js' +export { serviceWorkerResponse } from './internal/Html.js' export * as Store from '../Store.js' export { stripe, tempo } from './Methods.js' export * as Mppx from './Mppx.js' diff --git a/src/server/internal/Html.ts b/src/server/internal/Html.ts new file mode 100644 index 00000000..9b511dec --- /dev/null +++ b/src/server/internal/Html.ts @@ -0,0 +1,48 @@ +import type * as Challenge from '../../Challenge.js' +import { content, script, serviceWorker } from './page.js' + +/** Service Worker script that injects a one-shot Authorization header on the next navigation. */ +export const serviceWorkerScript: string = serviceWorker + +/** Returns a Response serving the mppx service worker script. */ +export function serviceWorkerResponse(): Response { + return new Response(serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) +} + +/** + * Renders a self-contained HTML payment page for a 402 challenge. + * + * Replaces comment slots in the page template: + * - `` — viewport, title, and styles + * - `` — challenge + config JSON + * - `` — bundled page script + * - `` — method-specific HTML + */ +export function render(props: { + challenge: Challenge.Challenge + method?: string | undefined + config?: Record | undefined +}): string { + const data = JSON.stringify({ challenge: props.challenge, config: props.config ?? {} }) + return content + .replace('', head) + .replace('', ``) + .replace('', script) + .replace( + '', + props.method ?? '

This payment method does not support browser payments.

', + ) +} + +const html = String.raw +const head = html` + + Payment Required + +` diff --git a/src/server/internal/page.ts b/src/server/internal/page.ts new file mode 100644 index 00000000..a3719236 --- /dev/null +++ b/src/server/internal/page.ts @@ -0,0 +1,111 @@ +/* oxlint-disable */ +// Generated by `pnpm build:html` (hash: 8b107539b0993a03460ec9278d48ea61) + +export const content = ` + + + + + + + + + +
+

Payment Required

+
+
+
+

+  
+
+ + + + +` + +export const script = ` + + ` + +export const serviceWorker = + 'var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});' diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 1a134f5e..27d7e139 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -6,9 +6,9 @@ import { } from '../../Errors.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' -import { html } from '../../server/Html.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' +import { html } from './internal/html.js' /** * Creates a Stripe charge method intent for usage on the server. @@ -64,75 +64,11 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, - ...(publishableKey - ? { - html: html` -
-
- - -
- - - `, - } - : {}), + ...(parameters.html === false + ? { html: false } + : publishableKey + ? { html, htmlConfig: { publishableKey } } + : {}), async verify({ credential }) { const { challenge } = credential @@ -181,6 +117,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'recipient'> type Parameters = { + /** Disable the built-in HTML payment page for this method. @default true */ + html?: false | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined /** Stripe publishable key for browser-based HTML payment form. Required when using `html: true` on Mppx.create. */ diff --git a/src/stripe/server/internal/html.ts b/src/stripe/server/internal/html.ts new file mode 100644 index 00000000..104176a3 --- /dev/null +++ b/src/stripe/server/internal/html.ts @@ -0,0 +1,222 @@ +/* oxlint-disable */ +// Generated by `pnpm build:html` (4da8d2d80d820812e7be9c984b94d53e) + +export const html = ` +
+
+ + +
+ + ` diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 9f407ea1..71affe1d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -13,7 +13,6 @@ import { Abis, Transaction } from 'viem/tempo' import { PaymentExpiredError } from '../../Errors.js' import type { LooseOmit } from '../../internal/types.js' import * as Method from '../../Method.js' -import { html } from '../../server/Html.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' import * as Account from '../internal/account.js' @@ -23,6 +22,7 @@ import * as FeePayer from '../internal/fee-payer.js' import * as Selectors from '../internal/selectors.js' import type * as types from '../internal/types.js' import * as Methods from '../Methods.js' +import { html } from './internal/html.js' /** * Creates a Tempo charge method intent for usage on the server. @@ -69,160 +69,7 @@ export function charge( recipient, } as unknown as Defaults, - html: html` -
- - - `, + html: parameters.html === false ? false : html, // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { @@ -446,6 +293,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'feePayer' | 'recipient'> type Parameters = { + /** Disable the built-in HTML payment page for this method. @default true */ + html?: false | undefined /** Testnet mode. */ testnet?: boolean | undefined /** diff --git a/src/tempo/server/internal/html.ts b/src/tempo/server/internal/html.ts new file mode 100644 index 00000000..7ef099d4 --- /dev/null +++ b/src/tempo/server/internal/html.ts @@ -0,0 +1,48 @@ +/* oxlint-disable */ +// Generated by `pnpm build:html` (059a2c30f8fc1fb76abe415409b6bc14) + +export const html = ` +
+ + + ` diff --git a/src/tsconfig.json b/src/tsconfig.json index 924c7ddc..5ab5c92b 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -6,5 +6,5 @@ "types": ["node"] }, "include": ["./**/*.ts"], - "exclude": ["./**/*.test.ts", "./**/*.test-d.ts"] + "exclude": ["./**/*.test.ts", "./**/*.test-d.ts", "./html/**/*.ts"] } diff --git a/tsconfig.json b/tsconfig.json index bba8fd6d..2a7ee86b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,5 +6,12 @@ "strict": true }, "files": [], - "references": [{ "path": "./src" }, { "path": "./test" }] + "references": [ + { "path": "./src" }, + { "path": "./test" }, + { "path": "./src/html" }, + { "path": "./src/html/tsconfig.browser.json" }, + { "path": "./src/html/page/tsconfig.sw.json" }, + { "path": "./src/html/tempo/test" } + ] } From 6884389cf61b5a640a1142650d01b6fed3f20f47 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 25 Mar 2026 13:29:42 -0400 Subject: [PATCH 3/4] refactor: setup --- examples/charge-wagmi/server.ts | 7 +- examples/charge/src/server.ts | 10 +- examples/stripe/src/server.ts | 7 +- src/Method.ts | 10 +- src/html/vite.ts | 26 +++- src/middlewares/elysia.ts | 7 +- src/middlewares/express.ts | 7 +- src/middlewares/hono.ts | 5 +- src/middlewares/nextjs.ts | 7 +- src/server/Html.ts | 49 +++++++ src/server/Mppx.ts | 40 ++---- src/server/Transport.ts | 45 +++--- src/server/index.ts | 2 +- src/server/internal/Html.ts | 149 +++++++++++++------ src/server/internal/page.ts | 39 +++-- src/stripe/server/Charge.ts | 2 +- src/stripe/server/internal/html.ts | 220 ++--------------------------- src/tempo/server/Charge.ts | 2 +- src/tempo/server/internal/html.ts | 75 +++++----- 19 files changed, 314 insertions(+), 395 deletions(-) create mode 100644 src/server/Html.ts diff --git a/examples/charge-wagmi/server.ts b/examples/charge-wagmi/server.ts index 5654fb61..87389ce5 100644 --- a/examples/charge-wagmi/server.ts +++ b/examples/charge-wagmi/server.ts @@ -1,4 +1,4 @@ -import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server' +import { Html, Mppx, tempo } from 'mppx/server' import { createClient, http } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' @@ -20,7 +20,10 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) - if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + if (url.pathname === Html.serviceWorkerPathname) + return new Response(Html.serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) // Free if (url.pathname === '/api/health') return Response.json({ status: 'ok' }) diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index 6f00b47c..510663e2 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -1,16 +1,15 @@ -import { Mppx, serviceWorkerResponse, tempo } from 'mppx/server' +import { Html, Mppx, tempo } from 'mppx/server' import { createClient, http } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' import { Actions } from 'viem/tempo' const account = privateKeyToAccount(generatePrivateKey()) -const currency = '0x20c0000000000000000000000000000000000000' as const // pathUSD const mppx = Mppx.create({ methods: [ tempo({ - currency, + currency: '0x20c0000000000000000000000000000000000000', feePayer: true, recipient: account.address, testnet: true, @@ -21,7 +20,10 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) - if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + if (url.pathname === Html.serviceWorkerPathname) + return new Response(Html.serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) // Free if (url.pathname === '/api/health') return Response.json({ status: 'ok' }) diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index c3521995..ab7a0ead 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -1,4 +1,4 @@ -import { Mppx, serviceWorkerResponse, stripe } from 'mppx/server' +import { Html, Mppx, stripe } from 'mppx/server' import Stripe from 'stripe' const secretKey = process.env.VITE_STRIPE_SECRET_KEY! @@ -25,7 +25,10 @@ const mppx = Mppx.create({ export async function handler(request: Request): Promise { const url = new URL(request.url) - if (url.pathname === '/__mppx_sw.js') return serviceWorkerResponse() + if (url.pathname === Html.serviceWorkerPathname) + return new Response(Html.serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) if (url.pathname === '/api/create-spt') { const { paymentMethod, amount, currency, expiresAt, networkId, metadata } = diff --git a/src/Method.ts b/src/Method.ts index 9b8d4560..c2c35dad 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -2,6 +2,7 @@ import type * as Challenge from './Challenge.js' import type * as Credential from './Credential.js' import type { ExactPartial, LooseOmit, MaybePromise } from './internal/types.js' import type * as Receipt from './Receipt.js' +import type * as Html from './server/Html.js' import type * as Transport from './server/Transport.js' import type * as z from './zod.js' @@ -74,8 +75,7 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined - html?: string | false | undefined - htmlConfig?: Record | undefined + html?: Html.Options | false | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -204,12 +204,11 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, html, htmlConfig, request, respond, transport, verify } = options + const { defaults, html, request, respond, transport, verify } = options return { ...method, defaults, html, - htmlConfig, request, respond, transport, @@ -224,8 +223,7 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined - html?: string | false | undefined - htmlConfig?: Record | undefined + html?: Html.Options | false | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/html/vite.ts b/src/html/vite.ts index 9b6920ef..5a636e34 100644 --- a/src/html/vite.ts +++ b/src/html/vite.ts @@ -8,6 +8,7 @@ import * as Challenge from '../Challenge.js' import * as Credential from '../Credential.js' import * as Expires from '../Expires.js' import type * as Method from '../Method.js' +import { serviceWorkerPathname } from '../server/Html.js' import type * as z from '../zod.js' const html = String.raw @@ -36,7 +37,7 @@ export function dev(options: { // oxlint-disable-next-line no-async-endpoint-handlers server.middlewares.use(async (req, res, next) => { - if (req.url === '/__mppx_serviceWorker.js') { + if (req.url === serviceWorkerPathname) { const sw = await fs.readFile(path.resolve(pageDir, 'src/serviceWorker.ts'), 'utf-8') res.setHeader('Content-Type', 'application/javascript') const transformed = await server.transformRequest( @@ -111,7 +112,9 @@ export function build(names: string | string[]): Plugin { emptyOutDir: true, rolldownOptions: { input: Object.fromEntries(items.map((name) => [name, `src/${name}.ts`])), - output: { entryFileNames: '[name].js', format: 'iife' as const }, + output: { entryFileNames: '[name].js', format: 'es' as const }, + // Not yet in Vite's types but supported by Rolldown + ...({ codeSplitting: false } as {}), }, modulePreload: false, minify: true, @@ -125,15 +128,28 @@ export function build(names: string | string[]): Plugin { const method = path.basename(root) const output = path.resolve(root, `../../${method}/server/internal/html.ts`) + // Read shared chunks (if code splitting produced any) + const assetsDir = path.resolve(root, 'dist/assets') + const chunks: string[] = [] + try { + for (const file of await fs.readdir(assetsDir)) { + if (file.endsWith('.js')) + chunks.push((await fs.readFile(path.resolve(assetsDir, file), 'utf-8')).trim()) + } + } catch {} + for (const name of items) { const content = ( await fs.readFile(path.resolve(root, `src/${name}.html`), 'utf-8') ).trimEnd() - const bundledScript = ( + const entryScript = ( await fs.readFile(path.resolve(root, `dist/${name}.js`), 'utf-8') ).trim() - const code = escapeTemplateLiteral(bundledScript) - const scriptBlock = `\n ` + // Strip chunk imports — their contents are inlined below + const cleanedEntry = entryScript.replace(/^import\s.*?;\n?/gm, '') + const allScripts = [...chunks, cleanedEntry].join('\n') + const code = escapeTemplateLiteral(allScripts) + const scriptBlock = `\n ` const body = [`export const html =`, ` \`\n${content}${scriptBlock}\n \``].join('\n') const file = [comment(body), ``, body].join('\n') diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 8e2bde7b..3982f095 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -1,6 +1,6 @@ import type { Context } from 'elysia' -import { serviceWorkerResponse } from '../server/internal/Html.js' +import { serviceWorkerPathname, serviceWorkerScript } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -62,7 +62,10 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): ElysiaHook { return async ({ request, set }) => { - if (new URL(request.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() + if (new URL(request.url).pathname === serviceWorkerPathname) + return new Response(serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) const result = await intent(options)(request) if (result.status === 402) return result.challenge const receipt = result.withReceipt(new Response()) diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index b0c5a3d2..279dc698 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -5,7 +5,7 @@ import type { RequestHandler, } from 'express' -import { serviceWorkerResponse } from '../server/internal/Html.js' +import { serviceWorkerPathname, serviceWorkerScript } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -61,10 +61,9 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): RequestHandler { return async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => { - if (req.originalUrl === '/__mppx_serviceWorker.js') { - const swRes = serviceWorkerResponse() + if (req.originalUrl === serviceWorkerPathname) { res.setHeader('Content-Type', 'application/javascript') - res.send(await swRes.text()) + res.send(serviceWorkerScript) return } const request = new Request(`${req.protocol}://${req.hostname}${req.originalUrl}`, { diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index 4f967315..89a83d1c 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -1,6 +1,6 @@ import type { MiddlewareHandler } from 'hono' -import { serviceWorkerResponse } from '../server/internal/Html.js' +import { serviceWorkerPathname, serviceWorkerScript } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -56,7 +56,8 @@ export function payment( options: intent extends (options: infer options) => any ? options : never, ): MiddlewareHandler { return async (c, next) => { - if (new URL(c.req.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() + if (new URL(c.req.url).pathname === serviceWorkerPathname) + return c.body(serviceWorkerScript, { headers: { 'Content-Type': 'application/javascript' } }) const result = await intent(options)(c.req.raw) if (result.status === 402) return result.challenge await next() diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index eb56a969..fa719a47 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -1,4 +1,4 @@ -import { serviceWorkerResponse } from '../server/internal/Html.js' +import { serviceWorkerPathname, serviceWorkerScript } from '../server/Html.js' import * as Mppx_core from '../server/Mppx.js' import * as Mppx_internal from './internal/mppx.js' @@ -59,7 +59,10 @@ export function payment( handler: RouteHandler, ): RouteHandler { return async (request) => { - if (new URL(request.url).pathname === '/__mppx_serviceWorker.js') return serviceWorkerResponse() + if (new URL(request.url).pathname === serviceWorkerPathname) + return new Response(serviceWorkerScript, { + headers: { 'Content-Type': 'application/javascript' }, + }) const result = await intent(options)(request) if (result.status === 402) return result.challenge const response = await handler(request) diff --git a/src/server/Html.ts b/src/server/Html.ts new file mode 100644 index 00000000..0e6d4f7f --- /dev/null +++ b/src/server/Html.ts @@ -0,0 +1,49 @@ +import type * as Challenge from '../Challenge.js' +import { content, script, serviceWorker } from './internal/html.js' + +/** Pathname for the service worker script endpoint. */ +export const serviceWorkerPathname = '/__mppx_serviceWorker.js' + +/** Service Worker script that injects a one-shot Authorization header on the next navigation. */ +export const serviceWorkerScript = serviceWorker as string + +/** + * Renders a self-contained HTML payment page for a 402 challenge. + * + * Replaces comment slots in the page template: + * - `` — viewport, title, and styles + * - `` — challenge + config JSON + * - `` — bundled page script + * - `` — method-specific HTML + */ +export type Options = { + method?: string | undefined + config?: Record | undefined +} + +export type Props = Options & { + challenge: Challenge.Challenge +} + +export function render(props: Props): string { + const data = JSON.stringify({ challenge: props.challenge, config: props.config ?? {} }) + return content + .replace('', head) + .replace('', ``) + .replace('', script) + .replace( + '', + props.method ?? '

This payment method does not support browser payments.

', + ) +} + +const html = String.raw +const head = html` + + Payment Required + +` diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index b659f95d..296a88b0 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -9,6 +9,7 @@ import type * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' import type * as z from '../zod.js' +import type * as Html from './Html.js' import * as NodeListener from './NodeListener.js' import * as Request from './Request.js' import * as Transport from './Transport.js' @@ -235,21 +236,14 @@ export declare namespace create { transport extends Transport.AnyTransport = Transport.Http, > = { /** - * Serve an HTML payment page to browsers (requests with `Accept: text/html`). + * Serve HTML payment page to browsers (requests with `Accept: text/html`). * * - `true` — use the built-in payment page * - `(props) => string` — custom HTML renderer * * Only applies when using the default HTTP transport. */ - html?: - | boolean - | ((props: { - challenge: Challenge.Challenge - method?: string | undefined - config?: Record | undefined - }) => string) - | undefined + html?: boolean | ((props: Html.Props) => 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"`. */ @@ -271,9 +265,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 methodHtmlValue = (method as Method.AnyServer).html - const methodHtml = methodHtmlValue === false ? undefined : methodHtmlValue - const htmlConfig = (method as Method.AnyServer).htmlConfig as Record | undefined + const html = 'html' in method && method.html !== false ? (method.html as Html.Options) : undefined return (options) => { const { description, meta, ...rest } = options @@ -321,8 +313,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.MalformedCredentialError({ reason: credentialError.message }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -333,8 +324,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -349,8 +339,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: 'challenge was not issued by this server', }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -379,8 +368,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -413,8 +401,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -430,8 +417,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R error: new Errors.PaymentExpiredError({ expires: credential.challenge.expires, }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -443,8 +429,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.InvalidPayloadError({ reason: (e as Error).message }), - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } @@ -463,8 +448,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error, - htmlConfig, - methodHtml, + html, }) return { challenge: response, status: 402 } } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index ec6b363a..7ed08a94 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -4,7 +4,7 @@ import * as Errors from '../Errors.js' import type { Distribute, UnionToIntersection } from '../internal/types.js' import * as core_Mcp from '../Mcp.js' import * as Receipt from '../Receipt.js' -import * as Html from './internal/Html.js' +import * as Html from './Html.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -31,9 +31,8 @@ export type Transport< respondChallenge: (options: { challenge: Challenge.Challenge error?: Errors.PaymentError | undefined + html?: Html.Options | undefined input: input - htmlConfig?: Record | undefined - methodHtml?: string | undefined }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ respondReceipt: (options: { @@ -113,9 +112,11 @@ export function from< * - Attaches receipts via `Payment-Receipt` header */ export function http(options?: http.Options): Http { - const htmlOption = options?.html - const renderHtml = - htmlOption === true ? Html.render : typeof htmlOption === 'function' ? htmlOption : undefined + const renderHtml = (() => { + if (!options?.html) return + if (options.html === true) return Html.render + return options.html + })() return from({ name: 'http', @@ -128,22 +129,23 @@ export function http(options?: http.Options): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error, htmlConfig, input, methodHtml }) { + respondChallenge({ challenge, error, html, input }) { const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } - const acceptsHtml = renderHtml && input.headers.get('Accept')?.includes('text/html') - - let body: string | null = null - if (acceptsHtml) { - headers['Content-Type'] = 'text/html; charset=utf-8' - body = renderHtml({ challenge, method: methodHtml, config: htmlConfig }) - } else if (error) { - headers['Content-Type'] = 'application/problem+json' - body = JSON.stringify(error.toProblemDetails(challenge.id)) - } + const body = (() => { + if (renderHtml && input.headers.get('Accept')?.includes('text/html')) { + headers['Content-Type'] = 'text/html; charset=utf-8' + return renderHtml({ challenge, method: html?.method, config: html?.config }) + } + if (error) { + headers['Content-Type'] = 'application/problem+json' + return JSON.stringify(error.toProblemDetails(challenge.id)) + } + return null + })() return new Response(body, { status: error?.status ?? 402, headers }) }, @@ -227,14 +229,7 @@ export declare namespace http { * - `true` — use the built-in payment page * - `(props) => string` — custom HTML renderer */ - html?: - | boolean - | ((props: { - challenge: Challenge.Challenge - method?: string | undefined - config?: Record | undefined - }) => string) - | undefined + html?: boolean | ((props: Html.Props) => string) | undefined } } diff --git a/src/server/index.ts b/src/server/index.ts index c0467444..9781e477 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,5 @@ export * as Expires from '../Expires.js' -export { serviceWorkerResponse } from './internal/Html.js' +export * as Html from './Html.js' export * as Store from '../Store.js' export { stripe, tempo } from './Methods.js' export * as Mppx from './Mppx.js' diff --git a/src/server/internal/Html.ts b/src/server/internal/Html.ts index 9b511dec..030112ea 100644 --- a/src/server/internal/Html.ts +++ b/src/server/internal/Html.ts @@ -1,48 +1,111 @@ -import type * as Challenge from '../../Challenge.js' -import { content, script, serviceWorker } from './page.js' +/* oxlint-disable */ +// Generated by `pnpm build:html` (hash: 2d228199c7bf81af370abc61fcf46054) -/** Service Worker script that injects a one-shot Authorization header on the next navigation. */ -export const serviceWorkerScript: string = serviceWorker +export const content = ` + + + + + + + + -/** Returns a Response serving the mppx service worker script. */ -export function serviceWorkerResponse(): Response { - return new Response(serviceWorkerScript, { - headers: { 'Content-Type': 'application/javascript' }, - }) -} +
+

Payment Required

+
+
+
+

+      
+
+ -/** - * Renders a self-contained HTML payment page for a 402 challenge. - * - * Replaces comment slots in the page template: - * - `` — viewport, title, and styles - * - `` — challenge + config JSON - * - `` — bundled page script - * - `` — method-specific HTML - */ -export function render(props: { - challenge: Challenge.Challenge - method?: string | undefined - config?: Record | undefined -}): string { - const data = JSON.stringify({ challenge: props.challenge, config: props.config ?? {} }) - return content - .replace('', head) - .replace('', ``) - .replace('', script) - .replace( - '', - props.method ?? '

This payment method does not support browser payments.

', - ) -} + + +` -const html = String.raw -const head = html` - - Payment Required - -` + function sortKeys(obj) { + const sorted = {}; + Object.keys(obj).sort().forEach((k) => { + const v = obj[k]; + sorted[k] = v && typeof v === "object" && !Array.isArray(v) ? sortKeys(v) : v; + }); + return sorted; + } + window.mppx = Object.freeze({ + challenge, + config, + serializeCredential(payload, source) { + const wire = { + challenge: Object.assign({}, mppx.challenge, { request: base64url(JSON.stringify(sortKeys(mppx.challenge.request))) }), + payload + }; + if (source) wire.source = source; + return "Payment " + base64url(JSON.stringify(wire)); + } + }); + var challengeEl = document.getElementById("mppx-challenge"); + challengeEl.textContent = JSON.stringify(challenge, null, 2); + if (challenge.description) { + const p = document.createElement("p"); + p.textContent = challenge.description; + document.querySelector("header").appendChild(p); + } + function activateSw(reg) { + const sw = reg.installing || reg.waiting || reg.active; + return new Promise((resolve) => { + if (sw.state === "activated") return resolve(); + sw.addEventListener("statechange", () => { + if (sw.state === "activated") resolve(); + }); + }); + } + addEventListener("mppx:complete", ((e) => { + const statusEl = document.getElementById("status"); + const authorization = e.detail; + if (statusEl) { + statusEl.textContent = "Verifying payment..."; + statusEl.style.color = ""; + } + navigator.serviceWorker.register("/__mppx_serviceWorker.js").then(activateSw).then(() => { + function sendAndReload() { + navigator.serviceWorker.controller.postMessage(authorization); + window.location.reload(); + } + if (navigator.serviceWorker.controller) sendAndReload(); + else navigator.serviceWorker.addEventListener("controllerchange", sendAndReload); + }).catch(() => { + fetch(window.location.href, { headers: { Authorization: authorization } }).then((res) => { + if (!res.ok) { + if (statusEl) { + statusEl.textContent = "Verification failed (" + res.status + ")"; + statusEl.style.color = "red"; + } + return; + } + return res.blob().then((blob) => { + window.location = URL.createObjectURL(blob); + }); + }).catch((err) => { + if (statusEl) { + statusEl.textContent = err.message || "Request failed"; + statusEl.style.color = "red"; + } + }); + }); + })); + //#endregion + + ` + +export const serviceWorker = + 'var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});' diff --git a/src/server/internal/page.ts b/src/server/internal/page.ts index a3719236..2b006c5d 100644 --- a/src/server/internal/page.ts +++ b/src/server/internal/page.ts @@ -1,28 +1,28 @@ /* oxlint-disable */ -// Generated by `pnpm build:html` (hash: 8b107539b0993a03460ec9278d48ea61) +// Generated by `pnpm build:html` (hash: 2d228199c7bf81af370abc61fcf46054) export const content = ` - - - - - - + + + + + + -
-

Payment Required

-
-
-
-

-  
-
- +
+

Payment Required

+
+
+
+

+      
+
+ - - + + ` export const script = ` @@ -107,5 +107,4 @@ export const script = ` ` -export const serviceWorker = - 'var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});' +export const serviceWorker = "var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});" diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 27d7e139..61fc9567 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -67,7 +67,7 @@ export function charge(parameters: p ...(parameters.html === false ? { html: false } : publishableKey - ? { html, htmlConfig: { publishableKey } } + ? { html: { method: html, config: { publishableKey } } } : {}), async verify({ credential }) { diff --git a/src/stripe/server/internal/html.ts b/src/stripe/server/internal/html.ts index 104176a3..4a3643d2 100644 --- a/src/stripe/server/internal/html.ts +++ b/src/stripe/server/internal/html.ts @@ -1,5 +1,5 @@ /* oxlint-disable */ -// Generated by `pnpm build:html` (4da8d2d80d820812e7be9c984b94d53e) +// Generated by `pnpm build:html` (hash: 3364ccfcadbc03797358feee8c3f2a08) export const html = `
@@ -8,215 +8,13 @@ export const html = `
` diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 71affe1d..78c96884 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -69,7 +69,7 @@ export function charge( recipient, } as unknown as Defaults, - html: parameters.html === false ? false : html, + html: parameters.html ? { method: html } : false, // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { diff --git a/src/tempo/server/internal/html.ts b/src/tempo/server/internal/html.ts index 7ef099d4..a1a44c37 100644 --- a/src/tempo/server/internal/html.ts +++ b/src/tempo/server/internal/html.ts @@ -1,7 +1,8 @@ /* oxlint-disable */ -// Generated by `pnpm build:html` (059a2c30f8fc1fb76abe415409b6bc14) +// Generated by `pnpm build:html` (hash: 2a0557e2f50a33ecf73d972c2e189f47) -export const html = ` +export const html = + `
- ` From d22a758a84ba992fdc3b6a56b25d386bebfef408 Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 25 Mar 2026 13:29:58 -0400 Subject: [PATCH 4/4] chore: up --- src/server/internal/page.ts | 3 ++- src/tempo/server/internal/html.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/internal/page.ts b/src/server/internal/page.ts index 2b006c5d..030112ea 100644 --- a/src/server/internal/page.ts +++ b/src/server/internal/page.ts @@ -107,4 +107,5 @@ export const script = ` ` -export const serviceWorker = "var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});" +export const serviceWorker = + 'var e=null;self.addEventListener(`install`,()=>{self.skipWaiting()}),self.addEventListener(`activate`,e=>{e.waitUntil(self.clients.claim())}),self.addEventListener(`message`,t=>{e=t.data}),self.addEventListener(`fetch`,t=>{if(!e)return;let n=new Headers(t.request.headers);n.set(`Authorization`,e),e=null,t.respondWith(fetch(new Request(t.request,{headers:n})))});' diff --git a/src/tempo/server/internal/html.ts b/src/tempo/server/internal/html.ts index a1a44c37..02d6f12d 100644 --- a/src/tempo/server/internal/html.ts +++ b/src/tempo/server/internal/html.ts @@ -1,8 +1,7 @@ /* oxlint-disable */ // Generated by `pnpm build:html` (hash: 2a0557e2f50a33ecf73d972c2e189f47) -export const html = - ` +export const html = `