From f7cb9f34ded228d992edae423cc6ba93f5c405eb Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 30 Mar 2026 20:39:08 -0400 Subject: [PATCH 01/28] wip: basic wiring --- examples/charge/package.json | 2 +- examples/charge/src/server.ts | 2 ++ examples/stripe/package.json | 2 +- examples/stripe/src/server.ts | 1 + src/Method.ts | 7 ++++++- src/server/Mppx.ts | 4 ++++ src/server/Transport.ts | 39 +++++++++++++++++++++++++++++------ src/server/internal/html.ts | 1 + src/stripe/server/Charge.ts | 6 ++++++ src/tempo/server/Charge.ts | 6 ++++++ 10 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 src/server/internal/html.ts diff --git a/examples/charge/package.json b/examples/charge/package.json index ce7eb3b7..8aa093da 100644 --- a/examples/charge/package.json +++ b/examples/charge/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "check:types": "tsgo -b", - "dev": "vite", + "dev": "vp dev", "build": "vite build", "preview": "vite preview" }, diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index c4b05f01..f94cb111 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -10,8 +10,10 @@ const currency = '0x20c0000000000000000000000000000000000000' as const // pathUS const mppx = Mppx.create({ methods: [ tempo({ + account, currency, feePayer: true, + html: true, recipient: account.address, testnet: true, }), diff --git a/examples/stripe/package.json b/examples/stripe/package.json index b070bf69..fd254f38 100644 --- a/examples/stripe/package.json +++ b/examples/stripe/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite", + "dev": "vp dev", "check": "biome check --fix --unsafe", "check:types": "tsgo -b", "build": "vite build", diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 0aaac6ac..aa7e701d 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -9,6 +9,7 @@ const mppx = Mppx.create({ methods: [ stripe.charge({ client: stripeClient, + html: true, // Stripe Business Network profile ID. networkId: 'internal', // Ensure only card is supported. diff --git a/src/Method.ts b/src/Method.ts index 1b196fad..9fd3aba3 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/internal/html.js' import type * as Transport from './server/Transport.js' import type * as z from './zod.js' @@ -10,6 +11,7 @@ import type * as z from './zod.js' */ export type Method = { name: string + html?: Html.Options | undefined intent: string schema: { credential: { @@ -74,6 +76,7 @@ export type Server< transportOverride = undefined, > = method & { defaults?: defaults | undefined + html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined @@ -202,10 +205,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 +224,7 @@ export declare namespace toServer { transportOverride extends Transport.AnyTransport | undefined = undefined, > = { defaults?: defaults | undefined + html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined transport?: transportOverride | undefined diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 59c5b4d2..c0ead8cb 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -304,6 +304,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.MalformedCredentialError({ reason: credentialError.message }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -314,6 +315,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R challenge, input, error: new Errors.PaymentRequiredError({ description }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -328,6 +330,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: 'challenge was not issued by this server', }), + html: method.html, }) return { challenge: response, status: 402 } } @@ -356,6 +359,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R id: credential.challenge.id, reason: `credential ${field} does not match this route's requirements`, }), + html: method.html, }) return { challenge: response, status: 402 } } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 270fc1e3..78fc1855 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -1,9 +1,12 @@ +import { Json } from 'ox' + import * as Challenge from '../Challenge.js' import * as Credential from '../Credential.js' 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 type * as Html from './internal/html.js' export { type McpSdk, mcpSdk } from '../mcp-sdk/server/Transport.js' @@ -30,6 +33,7 @@ export type Transport< respondChallenge: (options: { challenge: Challenge.Challenge error?: Errors.PaymentError | undefined + html?: Html.Options | undefined input: input }) => challengeOutput | Promise /** Attaches a receipt to a successful response. */ @@ -121,17 +125,40 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error }) { + respondChallenge({ challenge, error, html, input }) { const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } - let body: string | null = null - if (error) { - headers['Content-Type'] = 'application/problem+json' - body = JSON.stringify(error.toProblemDetails(challenge.id)) - } + const body = (() => { + if (html && input.headers.get('Accept')?.includes('text/html')) { + headers['Content-Type'] = 'text/html; charset=utf-8' + const html = String.raw + return html` + + + + + Payment Required + + + +

Payment Required

+
${Json.stringify(challenge, null, 2)}
+ + ` + } + 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 }) }, diff --git a/src/server/internal/html.ts b/src/server/internal/html.ts new file mode 100644 index 00000000..0eee3408 --- /dev/null +++ b/src/server/internal/html.ts @@ -0,0 +1 @@ +export type Options = boolean diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 096f05da..76d74de7 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -3,6 +3,7 @@ import { PaymentActionRequiredError, VerificationFailedError } from '../../Error import * as Expires from '../../Expires.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' +import type * as Html from '../../server/internal/html.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' @@ -38,6 +39,7 @@ export function charge(parameters: p decimals, description, externalId, + html, metadata, networkId, paymentMethodTypes, @@ -59,6 +61,8 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, + html, + async verify({ credential }) { const { challenge } = credential const { request } = challenge @@ -108,6 +112,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'recipient'> type Parameters = { + /** Render payment page when Accept header is text/html (e.g. in browsers) */ + html?: Html.Options | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined } & Defaults & diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index bfdb88b0..6a51383d 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -12,6 +12,7 @@ import { Abis, Transaction } from 'viem/tempo' import * as Expires from '../../Expires.js' import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' +import type * as Html from '../../server/internal/html.js' import * as Store from '../../Store.js' import * as Client from '../../viem/Client.js' import * as Account from '../internal/account.js' @@ -45,6 +46,7 @@ export function charge( decimals = defaults.decimals, description, externalId, + html, memo, waitForConfirmation = true, } = parameters @@ -71,6 +73,8 @@ export function charge( recipient, } as unknown as Defaults, + html, + // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { const chainId = await (async () => { @@ -244,6 +248,8 @@ export declare namespace charge { type Defaults = LooseOmit, 'feePayer' | 'recipient'> type Parameters = { + /** Render payment page when Accept header is text/html (e.g. in browsers) */ + html?: Html.Options | undefined /** Testnet mode. */ testnet?: boolean | undefined /** From 0719e5c447e30f38b1070c954f2d4904ad489a99 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 30 Mar 2026 21:45:02 -0400 Subject: [PATCH 02/28] feat: tempo html wired --- .gitignore | 1 + package.json | 3 +- pnpm-lock.yaml | 105 ++++++++++++++++++ pnpm-workspace.yaml | 1 + scripts/build:html.ts | 35 ++++++ src/server/Transport.ts | 11 +- src/server/internal/html.ts | 4 +- src/stripe/server/Charge.ts | 6 +- src/stripe/server/internal/html/main.ts | 13 +++ src/stripe/server/internal/html/package.json | 9 ++ src/stripe/server/internal/html/tsconfig.json | 8 ++ src/tempo/server/Charge.ts | 6 +- src/tempo/server/internal/html/main.ts | 40 +++++++ src/tempo/server/internal/html/package.json | 10 ++ src/tempo/server/internal/html/tsconfig.json | 8 ++ src/tsconfig.json | 2 +- 16 files changed, 251 insertions(+), 11 deletions(-) create mode 100644 scripts/build:html.ts create mode 100644 src/stripe/server/internal/html/main.ts create mode 100644 src/stripe/server/internal/html/package.json create mode 100644 src/stripe/server/internal/html/tsconfig.json create mode 100644 src/tempo/server/internal/html/main.ts create mode 100644 src/tempo/server/internal/html/package.json create mode 100644 src/tempo/server/internal/html/tsconfig.json diff --git a/.gitignore b/.gitignore index a0951fd5..8e89b159 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ _ dist node_modules +*.gen.ts coverage *.tsbuildinfo .DS_Store diff --git a/package.json b/package.json index ddab41ff..268b934e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "scripts": { - "build": "zile", + "build": "tsx scripts/build:html.ts && zile", "changeset:publish": "zile publish:prepare && changeset publish && zile publish:post", "changeset:version": "changeset version && vp fmt .", "check": "vp lint --fix && vp fmt --write .", @@ -41,6 +41,7 @@ "hono": "^4.11.9", "playwright": "^1.58.2", "prool": "^0.2.4", + "rolldown": "1.0.0-rc.12", "tempo.ts": "^0.14.2", "testcontainers": "^11.11.0", "tsx": "^4.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4643e70c..dcc5cec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: prool: specifier: ^0.2.4 version: 0.2.4(testcontainers@11.11.0) + rolldown: + specifier: 1.0.0-rc.12 + version: 1.0.0-rc.12 tempo.ts: specifier: ^0.14.2 version: 0.14.2(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6))(zod@4.3.6) @@ -272,6 +275,27 @@ importers: specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + src/stripe/server/internal/html: + dependencies: + '@stripe/stripe-js': + specifier: 8.9.0 + version: 8.9.0 + mppx: + specifier: workspace:* + version: link:../../../../.. + + src/tempo/server/internal/html: + dependencies: + accounts: + specifier: https://pkg.pr.new/tempoxyz/accounts@d21e422 + version: https://pkg.pr.new/tempoxyz/accounts@d21e422(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + mppx: + specifier: workspace:* + version: link:../../../../.. + viem: + specifier: ^2.47.5 + version: 2.47.6(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': @@ -1299,6 +1323,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==} @@ -1650,6 +1678,21 @@ packages: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + accounts@https://pkg.pr.new/tempoxyz/accounts@d21e422: + resolution: {tarball: https://pkg.pr.new/tempoxyz/accounts@d21e422} + version: 0.4.6 + peerDependencies: + '@wagmi/core': '>=2' + react: '>=18' + viem: ^2.47.5 + peerDependenciesMeta: + '@wagmi/core': + optional: true + react: + optional: true + viem: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2450,6 +2493,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} @@ -3546,6 +3592,9 @@ packages: typescript: optional: true + webauthx@0.1.0: + resolution: {integrity: sha512-Z43YHetVeXdV5/4+YqKSz+cAflpbMmxSMz//kXEb2u3ZUqVbPG1zrM+Zp7KaME/QgUFGhGkAE2XHwqCAXnU75g==} + webextension-polyfill@0.10.0: resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} @@ -3678,6 +3727,24 @@ packages: use-sync-external-store: optional: true + zustand@5.0.12: + resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adraffy/ens-normalize@1.11.1': {} @@ -4626,6 +4693,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)': @@ -4898,6 +4967,27 @@ snapshots: mime-types: 3.0.2 negotiator: 1.0.0 + accounts@https://pkg.pr.new/tempoxyz/accounts@d21e422(@types/react@19.2.14)(@wagmi/core@3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)): + dependencies: + '@remix-run/fetch-router': 0.17.0 + idb-keyval: 6.2.2 + mipd: 0.0.7(typescript@5.9.3) + mppx: 'link:' + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + tsx: 4.21.0 + webauthx: 0.1.0(typescript@5.9.3)(zod@4.3.6) + zod: 4.3.6 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)) + optionalDependencies: + '@wagmi/core': 3.4.0(@tanstack/query-core@5.90.20)(@types/react@19.2.14)(ox@0.14.7(typescript@5.9.3)(zod@4.3.6))(react@19.2.4)(typescript@5.9.3)(use-sync-external-store@1.4.0(react@19.2.4))(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6)) + react: 19.2.4 + viem: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) + transitivePeerDependencies: + - '@types/react' + - immer + - typescript + - use-sync-external-store + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5776,6 +5866,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} + ieee754@1.2.1: {} ignore@5.3.2: {} @@ -6932,6 +7024,13 @@ snapshots: - ox - porto + webauthx@0.1.0(typescript@5.9.3)(zod@4.3.6): + dependencies: + ox: 0.14.7(typescript@5.9.3)(zod@4.3.6) + transitivePeerDependencies: + - typescript + - zod + webextension-polyfill@0.10.0: {} webidl-conversions@3.0.1: {} @@ -7031,3 +7130,9 @@ snapshots: '@types/react': 19.2.14 react: 19.2.4 use-sync-external-store: 1.4.0(react@19.2.4) + + zustand@5.0.12(@types/react@19.2.14)(react@19.2.4)(use-sync-external-store@1.4.0(react@19.2.4)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.4 + use-sync-external-store: 1.4.0(react@19.2.4) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index dca77ed3..0654bc54 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - . - examples/* - examples/session/* + - src/*/server/internal/html overrides: mppx: 'workspace:*' diff --git a/scripts/build:html.ts b/scripts/build:html.ts new file mode 100644 index 00000000..88563f79 --- /dev/null +++ b/scripts/build:html.ts @@ -0,0 +1,35 @@ +import fs from 'node:fs' +import path from 'node:path' + +import { build } from 'rolldown' + +const root = path.resolve(import.meta.dirname, '..') + +const entries = [ + 'src/tempo/server/internal/html/main.ts', + 'src/stripe/server/internal/html/main.ts', +] + +for (const entry of entries) { + const outDir = path.resolve(root, '.tmp/html-build') + const outFile = path.resolve(root, path.dirname(entry), '..', 'html.gen.ts') + + await build({ + input: path.resolve(root, entry), + output: { + dir: outDir, + format: 'iife', + minify: true, + }, + }) + + const jsFile = fs.readdirSync(outDir).find((f) => f.endsWith('.js')) + if (!jsFile) throw new Error(`No .js output found for ${entry}`) + + const code = fs.readFileSync(path.join(outDir, jsFile), 'utf8').trim() + const content = `// Generated — do not edit.\nexport const html = ${JSON.stringify(``)}\n` + + fs.writeFileSync(outFile, content) + fs.rmSync(outDir, { recursive: true }) + console.log(`wrote ${path.relative(root, outFile)}`) +} diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 78fc1855..8f5ec7e9 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -125,15 +125,17 @@ export function http(): Http { return Credential.deserialize(payment) }, - respondChallenge({ challenge, error, html, input }) { + respondChallenge(options) { + const { challenge, error, input } = options const headers: Record = { 'WWW-Authenticate': Challenge.serialize(challenge), 'Cache-Control': 'no-store', } const body = (() => { - if (html && input.headers.get('Accept')?.includes('text/html')) { + if (options.html && input.headers.get('Accept')?.includes('text/html')) { headers['Content-Type'] = 'text/html; charset=utf-8' + const data = Json.stringify({ challenge }).replace(/ @@ -150,6 +152,11 @@ export function http(): Http {

Payment Required

${Json.stringify(challenge, null, 2)}
+
+ + ${options.html.content} ` } diff --git a/src/server/internal/html.ts b/src/server/internal/html.ts index 0eee3408..149ad7c4 100644 --- a/src/server/internal/html.ts +++ b/src/server/internal/html.ts @@ -1 +1,3 @@ -export type Options = boolean +export type Options = { + content: string +} diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 76d74de7..505b527e 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -3,9 +3,9 @@ import { PaymentActionRequiredError, VerificationFailedError } from '../../Error import * as Expires from '../../Expires.js' import type { LooseOmit, OneOf } from '../../internal/types.js' import * as Method from '../../Method.js' -import type * as Html from '../../server/internal/html.js' import type { StripeClient } from '../internal/types.js' import * as Methods from '../Methods.js' +import { html as htmlContent } from './internal/html.gen.js' /** * Creates a Stripe charge method intent for usage on the server. @@ -61,7 +61,7 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, - html, + html: html ? { content: htmlContent } : undefined, async verify({ credential }) { const { challenge } = credential @@ -113,7 +113,7 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ - html?: Html.Options | undefined + html?: boolean | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined } & Defaults & diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts new file mode 100644 index 00000000..a514d9b1 --- /dev/null +++ b/src/stripe/server/internal/html/main.ts @@ -0,0 +1,13 @@ +import type * as Challenge from '../../../../Challenge.js' +import type * as Methods from '../../../Methods.js' + +const data = JSON.parse(document.getElementById('__MPPX_DATA__')!.textContent!) as { + challenge: Challenge.FromMethods<[typeof Methods.charge]> +} +console.log(data.challenge) + +const root = document.getElementById('root')! + +const h2 = document.createElement('h2') +h2.textContent = 'stripe' +root.appendChild(h2) diff --git a/src/stripe/server/internal/html/package.json b/src/stripe/server/internal/html/package.json new file mode 100644 index 00000000..d2c26dfc --- /dev/null +++ b/src/stripe/server/internal/html/package.json @@ -0,0 +1,9 @@ +{ + "name": "@mppx/stripe-html", + "private": true, + "type": "module", + "dependencies": { + "@stripe/stripe-js": "8.9.0", + "mppx": "workspace:*" + } +} diff --git a/src/stripe/server/internal/html/tsconfig.json b/src/stripe/server/internal/html/tsconfig.json new file mode 100644 index 00000000..ac01827c --- /dev/null +++ b/src/stripe/server/internal/html/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["./**/*.ts"] +} diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 6a51383d..419b40fc 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -12,7 +12,6 @@ import { Abis, Transaction } from 'viem/tempo' import * as Expires from '../../Expires.js' import type { LooseOmit, NoExtraKeys } from '../../internal/types.js' import * as Method from '../../Method.js' -import type * as Html from '../../server/internal/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 as htmlContent } from './internal/html.gen.js' /** * Creates a Tempo charge method intent for usage on the server. @@ -73,7 +73,7 @@ export function charge( recipient, } as unknown as Defaults, - html, + html: html ? { content: htmlContent } : undefined, // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { @@ -249,7 +249,7 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ - html?: Html.Options | undefined + html?: boolean | undefined /** Testnet mode. */ testnet?: boolean | undefined /** diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts new file mode 100644 index 00000000..6e981232 --- /dev/null +++ b/src/tempo/server/internal/html/main.ts @@ -0,0 +1,40 @@ +import { Provider } from 'accounts' +import { Json } from 'ox' +import { createClient, custom } from 'viem' + +import type * as Challenge from '../../../../Challenge.js' +import { tempo } from '../../../../client/index.js' +import type * as Methods from '../../../Methods.js' + +const data = Json.parse(document.getElementById('__MPPX_DATA__')!.textContent) as { + challenge: Challenge.FromMethods<[typeof Methods.charge]> +} + +const provider = Provider.create() + +const root = document.getElementById('root')! + +const h2 = document.createElement('h2') +h2.textContent = 'tempo' +root.appendChild(h2) + +const button = document.createElement('button') +button.textContent = 'Continue with Tempo' +button.onclick = async () => { + const result = await provider.request({ method: 'wallet_connect' }) + const account = result.accounts[0]!.address + console.log(account) + + const chain = + provider.chains.find((x) => x.id === data.challenge.request.methodDetails?.chainId) ?? + provider.chains.at(0) + const client = createClient({ chain, transport: custom(provider) }) + const method = tempo({ account, getClient: () => client })[0] + const credential = await method.createCredential({ challenge: data.challenge, context: {} }) + + const res = await fetch(location.pathname, { + headers: { Authorization: credential }, + }) + console.log(await res.json()) +} +root.appendChild(button) diff --git a/src/tempo/server/internal/html/package.json b/src/tempo/server/internal/html/package.json new file mode 100644 index 00000000..92fd942e --- /dev/null +++ b/src/tempo/server/internal/html/package.json @@ -0,0 +1,10 @@ +{ + "name": "@mppx/tempo-html", + "private": true, + "type": "module", + "dependencies": { + "accounts": "https://pkg.pr.new/tempoxyz/accounts@d21e422", + "mppx": "workspace:*", + "viem": "2.47.5" + } +} diff --git a/src/tempo/server/internal/html/tsconfig.json b/src/tempo/server/internal/html/tsconfig.json new file mode 100644 index 00000000..ac01827c --- /dev/null +++ b/src/tempo/server/internal/html/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../../../tsconfig.base.json", + "compilerOptions": { + "lib": ["es2022", "dom"], + "types": [] + }, + "include": ["./**/*.ts"] +} diff --git a/src/tsconfig.json b/src/tsconfig.json index 924c7ddc..528ff1a6 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", "./**/internal/html/**"] } From 1e5ea788c80516d9ce4aa5b5d10bc3071b143a51 Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 30 Mar 2026 21:55:30 -0400 Subject: [PATCH 03/28] wip: stripe html wired --- src/stripe/server/internal/html/main.ts | 72 ++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index a514d9b1..9bfbd940 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,13 +1,83 @@ +import { loadStripe } from '@stripe/stripe-js' + import type * as Challenge from '../../../../Challenge.js' +import { stripe } from '../../../../client/index.js' import type * as Methods from '../../../Methods.js' const data = JSON.parse(document.getElementById('__MPPX_DATA__')!.textContent!) as { challenge: Challenge.FromMethods<[typeof Methods.charge]> } -console.log(data.challenge) const root = document.getElementById('root')! const h2 = document.createElement('h2') h2.textContent = 'stripe' root.appendChild(h2) + +;(async () => { + const stripeJs = (await loadStripe( + 'pk_test_51SzNfEGlV7dYX3N7FwZQBlWCF5wIqakWKw8lk1e4Saf0b2H4exVG5bBCAopgPQvo9QhU5TNsBuuIFbXSrvbBjut50090YN7Xip', + ))! + const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') + const getAppearance = () => ({ + theme: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe', + }) + + const elements = stripeJs.elements({ + amount: Number(data.challenge.request.amount), + appearance: getAppearance(), + currency: data.challenge.request.currency as string, + mode: 'payment', + paymentMethodCreation: 'manual', + }) + + darkQuery.addEventListener('change', () => { + elements.update({ appearance: getAppearance() }) + }) + + const form = document.createElement('form') + elements.create('payment').mount(form) + root.appendChild(form) + + const button = document.createElement('button') + button.textContent = 'Pay' + button.type = 'submit' + form.appendChild(button) + + form.onsubmit = async (event) => { + event.preventDefault() + button.disabled = true + + try { + await elements.submit() + const { paymentMethod, error } = await stripeJs.createPaymentMethod({ elements }) + if (error || !paymentMethod) throw error ?? new Error('Failed to create payment method') + + const method = stripe({ + client: stripeJs, + createToken: async (opts) => { + const res = await fetch('/api/create-spt', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentMethod, ...opts }), + }) + if (!res.ok) throw new Error('Failed to create SPT') + const { spt } = (await res.json()) as { spt: string } + return spt + }, + })[0] + + const credential = await method.createCredential({ + challenge: data.challenge, + context: { paymentMethod: paymentMethod.id }, + }) + + const res = await fetch(location.pathname, { + headers: { Authorization: credential }, + }) + console.log(await res.json()) + } finally { + button.disabled = false + } + } +})() From d23ebd5c41c3af1b5b989c6212c67df224edaaaf Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 30 Mar 2026 22:05:56 -0400 Subject: [PATCH 04/28] feat: stripe html wired --- examples/stripe/src/server.ts | 5 ++++- src/server/Transport.ts | 5 ++++- src/server/internal/html.ts | 1 + src/stripe/server/Charge.ts | 4 ++-- src/stripe/server/internal/html/main.ts | 10 ++++++---- src/tempo/server/Charge.ts | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index aa7e701d..796d22d8 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -9,7 +9,10 @@ const mppx = Mppx.create({ methods: [ stripe.charge({ client: stripeClient, - html: true, + html: { + publishableKey: process.env.VITE_STRIPE_PUBLIC_KEY!, + createTokenUrl: '/api/create-spt', + }, // Stripe Business Network profile ID. networkId: 'internal', // Ensure only card is supported. diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 8f5ec7e9..44b12fe1 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -135,7 +135,10 @@ export function http(): Http { const body = (() => { if (options.html && input.headers.get('Accept')?.includes('text/html')) { headers['Content-Type'] = 'text/html; charset=utf-8' - const data = Json.stringify({ challenge }).replace(/ diff --git a/src/server/internal/html.ts b/src/server/internal/html.ts index 149ad7c4..3b948dab 100644 --- a/src/server/internal/html.ts +++ b/src/server/internal/html.ts @@ -1,3 +1,4 @@ export type Options = { + config: Record content: string } diff --git a/src/stripe/server/Charge.ts b/src/stripe/server/Charge.ts index 505b527e..12a8dd7c 100644 --- a/src/stripe/server/Charge.ts +++ b/src/stripe/server/Charge.ts @@ -61,7 +61,7 @@ export function charge(parameters: p paymentMethodTypes, } as unknown as Defaults, - html: html ? { content: htmlContent } : undefined, + html: html ? { config: html, content: htmlContent } : undefined, async verify({ credential }) { const { challenge } = credential @@ -113,7 +113,7 @@ export declare namespace charge { type Parameters = { /** Render payment page when Accept header is text/html (e.g. in browsers) */ - html?: boolean | undefined + html?: { createTokenUrl: string; publishableKey: string } | undefined /** Optional metadata to include in SPT creation requests. */ metadata?: Record | undefined } & Defaults & diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 9bfbd940..e2ae7bf6 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -2,9 +2,11 @@ import { loadStripe } from '@stripe/stripe-js' import type * as Challenge from '../../../../Challenge.js' import { stripe } from '../../../../client/index.js' +import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' const data = JSON.parse(document.getElementById('__MPPX_DATA__')!.textContent!) as { + config: NonNullable challenge: Challenge.FromMethods<[typeof Methods.charge]> } @@ -15,9 +17,9 @@ h2.textContent = 'stripe' root.appendChild(h2) ;(async () => { - const stripeJs = (await loadStripe( - 'pk_test_51SzNfEGlV7dYX3N7FwZQBlWCF5wIqakWKw8lk1e4Saf0b2H4exVG5bBCAopgPQvo9QhU5TNsBuuIFbXSrvbBjut50090YN7Xip', - ))! + const stripeJs = (await loadStripe(data.config.publishableKey))! + if (!stripeJs) throw new Error('Failed to loadStripe') + const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') const getAppearance = () => ({ theme: (darkQuery.matches ? 'night' : 'stripe') as 'night' | 'stripe', @@ -56,7 +58,7 @@ root.appendChild(h2) const method = stripe({ client: stripeJs, createToken: async (opts) => { - const res = await fetch('/api/create-spt', { + const res = await fetch(data.config.createTokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentMethod, ...opts }), diff --git a/src/tempo/server/Charge.ts b/src/tempo/server/Charge.ts index 419b40fc..a7e6fcb3 100644 --- a/src/tempo/server/Charge.ts +++ b/src/tempo/server/Charge.ts @@ -73,7 +73,7 @@ export function charge( recipient, } as unknown as Defaults, - html: html ? { content: htmlContent } : undefined, + html: html ? { config: {}, content: htmlContent } : undefined, // TODO: dedupe `{charge,session}.request` async request({ credential, request }) { From 389cd1d765cb1e58994c19bef3b39291626705ab Mon Sep 17 00:00:00 2001 From: tmm Date: Mon, 30 Mar 2026 22:36:22 -0400 Subject: [PATCH 05/28] refactor: server html dir --- scripts/build:html.ts | 32 +++++++++++++++++-- src/Method.ts | 2 +- src/server/Transport.ts | 13 +++++++- .../internal/html/serviceWorker.client.ts | 22 +++++++++++++ src/server/internal/html/serviceWorker.ts | 22 +++++++++++++ .../internal/html/tsconfig.worker.client.json | 8 +++++ src/server/internal/html/tsconfig.worker.json | 8 +++++ .../internal/{html.ts => html/types.ts} | 0 src/stripe/server/internal/html/main.ts | 6 ++-- src/tempo/server/internal/html/main.ts | 30 ++++++++--------- tsconfig.json | 7 +++- vite.config.ts | 2 +- 12 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 src/server/internal/html/serviceWorker.client.ts create mode 100644 src/server/internal/html/serviceWorker.ts create mode 100644 src/server/internal/html/tsconfig.worker.client.json create mode 100644 src/server/internal/html/tsconfig.worker.json rename src/server/internal/{html.ts => html/types.ts} (100%) diff --git a/scripts/build:html.ts b/scripts/build:html.ts index 88563f79..2f73f467 100644 --- a/scripts/build:html.ts +++ b/scripts/build:html.ts @@ -4,14 +4,15 @@ import path from 'node:path' import { build } from 'rolldown' const root = path.resolve(import.meta.dirname, '..') +const outDir = path.resolve(root, '.tmp/html-build') -const entries = [ +// HTML entries — bundled into ${options.html.content} diff --git a/src/server/internal/html/types.ts b/src/server/internal/html/config.ts similarity index 66% rename from src/server/internal/html/types.ts rename to src/server/internal/html/config.ts index 3b948dab..3cc6da4c 100644 --- a/src/server/internal/html/types.ts +++ b/src/server/internal/html/config.ts @@ -2,3 +2,5 @@ export type Options = { config: Record content: string } + +export const dataId = '__MPPX_DATA__' diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index 57fde285..b0613e24 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -1,18 +1,23 @@ -import { loadStripe } from '@stripe/stripe-js' +import { loadStripe } from '@stripe/stripe-js/pure' import type * as Challenge from '../../../../Challenge.js' import { stripe } from '../../../../client/index.js' +import * as Html from '../../../../server/internal/html/config.js' import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' import type { charge } from '../../../../stripe/server/Charge.js' import type * as Methods from '../../../Methods.js' -const data = JSON.parse(document.getElementById('__MPPX_DATA__')!.textContent!) as { +const data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as { config: NonNullable challenge: Challenge.FromMethods<[typeof Methods.charge]> } const root = document.getElementById('root')! +// Stripe.js can enable invisible anti-bot checks. In real browsers we keep the +// default behavior, but deterministic automation benefits from opting out. +if (navigator.webdriver) loadStripe.setLoadParameters({ advancedFraudSignals: false }) + const h2 = document.createElement('h2') h2.textContent = 'stripe' root.appendChild(h2) @@ -67,7 +72,10 @@ root.appendChild(h2) headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ paymentMethod, ...opts }), }) - if (!res.ok) throw new Error('Failed to create SPT') + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(`Failed to create SPT (${res.status}): ${text}`) + } const { spt } = (await res.json()) as { spt: string } return spt }, diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 7ec18636..06daad2e 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -6,17 +6,13 @@ import { Account } from 'viem/tempo' import type * as Challenge from '../../../../Challenge.js' import { tempo } from '../../../../client/index.js' +import * as Html from '../../../../server/internal/html/config.js' import { submitCredential } from '../../../../server/internal/html/serviceWorker.client.js' import type * as Methods from '../../../Methods.js' -const data = Json.parse(document.getElementById('__MPPX_DATA__')!.textContent) as { +const data = Json.parse(document.getElementById(Html.dataId)!.textContent) as { challenge: Challenge.FromMethods<[typeof Methods.charge]> } -// Used for testing. TODO: Wire up more native way -const localTempoAccount = __LOCAL_ACCOUNT__ - ? Account.fromSecp256k1(__LOCAL_ACCOUNT__ as Hex.Hex) - : undefined -declare const __LOCAL_ACCOUNT__: string | undefined const root = document.getElementById('root')! @@ -24,23 +20,28 @@ const h2 = document.createElement('h2') h2.textContent = 'tempo' root.appendChild(h2) +// Used for testing. TODO: Wire up more native way +const localTempoAccount = __LOCAL_ACCOUNT__ + ? Account.fromSecp256k1(__LOCAL_ACCOUNT__ as Hex.Hex) + : undefined +declare const __LOCAL_ACCOUNT__: string | undefined + +const provider = (() => { + if (localTempoAccount) return undefined + return Provider.create({ + testnet: + data.challenge.request.methodDetails?.chainId === tempoModerato.id || + data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, + }) +})() + const button = document.createElement('button') button.textContent = 'Continue with Tempo' button.onclick = async () => { try { button.disabled = true - const provider = (() => { - if (localTempoAccount) return undefined - return Provider.create({ - testnet: - data.challenge.request.methodDetails?.chainId === tempoModerato.id || - data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, - }) - })() - const client = (() => { - // TODO: provider.chains const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( (x) => x.id === data.challenge.request.methodDetails?.chainId, ) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index a44683ea..bbf84042 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -11,7 +11,10 @@ export default defineConfig({ timeout: 60_000, retries: 1, use: { - headless: true, + headless: false, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + video: 'retain-on-failure', }, projects: [ { diff --git a/test/html/server.ts b/test/html/server.ts index 70095ca9..a84e2aaa 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -12,32 +12,9 @@ export async function startServer(port: number): Promise { const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY if (!stripeSecretKey) throw new Error('Missing VITE_STRIPE_SECRET_KEY') - const account = privateKeyToAccount(generatePrivateKey()) - const tempoClient = createClient({ - chain: tempoModerato, - pollingInterval: 1_000, - transport: createHttpTransport(process.env.MPPX_RPC_URL), - }) - for (let attempt = 1; ; attempt++) { - try { - await Actions.faucet.fundSync(tempoClient, { account }) - break - } catch (error) { - if (attempt >= 3) throw error - } - } - const createTokenUrl = '/stripe/create-spt' - const mppx = Mppx.create({ + const stripeMppx = Mppx.create({ methods: [ - tempo.charge({ - account, - currency: '0x20c0000000000000000000000000000000000000', - feePayer: true, - html: true, - recipient: account.address, - testnet: true, - }), stripe.charge({ html: { createTokenUrl, @@ -50,16 +27,58 @@ export async function startServer(port: number): Promise { ], secretKey: 'test-html-server-secret-key', }) + let tempoChargePromise: Promise<(request: Request) => Promise> | undefined + + async function getTempoCharge() { + if (!tempoChargePromise) { + tempoChargePromise = (async () => { + const account = privateKeyToAccount(generatePrivateKey()) + const tempoClient = createClient({ + chain: tempoModerato, + pollingInterval: 1_000, + transport: createHttpTransport(process.env.MPPX_RPC_URL), + }) + + for (let attempt = 1; ; attempt++) { + try { + await Actions.faucet.fundSync(tempoClient, { account }) + break + } catch (error) { + if (attempt >= 3) throw error + } + } + + const tempoMppx = Mppx.create({ + methods: [ + tempo.charge({ + account, + currency: '0x20c0000000000000000000000000000000000000', + feePayer: true, + html: true, + recipient: account.address, + testnet: true, + }), + ], + secretKey: 'test-html-server-secret-key', + }) + + return tempoMppx.tempo.charge({ + amount: '0.01', + description: 'Random stock photo', + }) + })() + } + + return await tempoChargePromise + } const server = http.createServer( ServerRequest.toNodeListener(async (request) => { const url = new URL(request.url) if (url.pathname === '/tempo/charge') { - const result = await mppx.tempo.charge({ - amount: '0.01', - description: 'Random stock photo', - })(request) + const tempoCharge = await getTempoCharge() + const result = await tempoCharge(request) if (result.status === 402) return result.challenge @@ -69,7 +88,7 @@ export async function startServer(port: number): Promise { if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) if (url.pathname === '/stripe/charge') { - const result = await mppx.stripe.charge({ + const result = await stripeMppx.stripe.charge({ amount: '1', currency: 'usd', decimals: 2, diff --git a/test/html/setup.ts b/test/html/setup.ts deleted file mode 100644 index a24c114a..00000000 --- a/test/html/setup.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createClient, http } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { tempoModerato } from 'viem/chains' -import { Actions } from 'viem/tempo' - -const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' -const account = privateKeyToAccount(privateKey) - -export async function setup() { - // Fund the test payer account via faucet - const client = createClient({ chain: tempoModerato, transport: http() }) - await Actions.faucet.fundSync(client, { account }) - console.log(`funded ${account.address}`) -} diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index ee062c13..ee80d7e0 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -1,19 +1,7 @@ import { expect, test } from '@playwright/test' -import { setup } from './setup.js' - -test.beforeAll(async () => { - await setup() -}) - -test('charge via stripe html payment page', async ({ page, context }) => { - const logs: string[] = [] - page.on('pageerror', (err) => logs.push(`[pageerror] ${err.message}`)) - page.on('console', (msg) => logs.push(`[console.${msg.type()}] ${msg.text()}`)) - page.on('requestfailed', (req) => - logs.push(`[requestfailed] ${req.url()} ${req.failure()?.errorText}`), - ) - context.on('serviceworker', (sw) => logs.push(`[serviceworker] registered: ${sw.url()}`)) +test('charge via stripe html payment page', async ({ page }) => { + test.slow() await page.goto('/stripe/charge', { waitUntil: 'domcontentloaded', @@ -21,17 +9,12 @@ test('charge via stripe html payment page', async ({ page, context }) => { // Verify 402 payment page rendered await expect(page.locator('h1')).toHaveText('Payment Required') - await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 30_000 }) // Wait for Stripe Payment Element iframe to load const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]').first() const cardButton = stripeFrame.locator('[data-value="card"]') - await expect(cardButton) - .toBeVisible({ timeout: 15_000 }) - .catch((e) => { - console.error('Browser logs:\n' + logs.join('\n')) - throw e - }) + await expect(cardButton).toBeVisible({ timeout: 30_000 }) // Card option is collapsed by default — click to expand, wait for inputs to render await cardButton.click() @@ -39,16 +22,15 @@ test('charge via stripe html payment page', async ({ page, context }) => { // Wait for card inputs to appear and fill test card details const numberInput = stripeFrame.locator('[name="number"]') - await expect(numberInput).toBeVisible({ timeout: 15_000 }) + await expect(numberInput).toBeVisible({ timeout: 30_000 }) await numberInput.fill('4242424242424242') await stripeFrame.locator('[name="expiry"]').fill('12/34') await stripeFrame.locator('[name="cvc"]').fill('123') // Fill postal code if visible const postalCode = stripeFrame.locator('[name="postalCode"]') - if (await postalCode.isVisible({ timeout: 2_000 }).catch(() => false)) { + if (await postalCode.isVisible({ timeout: 2_000 }).catch(() => false)) await postalCode.fill('10001') - } // Wait for Stripe Elements to settle await page.waitForTimeout(500) @@ -57,7 +39,7 @@ test('charge via stripe html payment page', async ({ page, context }) => { await page.getByRole('button', { name: 'Pay' }).click() // Wait for service worker to submit credential and page to reload with paid response - await expect(page.locator('body')).toContainText('"fortune":') + await expect(page.locator('body')).toContainText('"fortune":', { timeout: 30_000 }) }) test('service worker endpoint returns javascript', async ({ page }) => { diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts index 0cc0665e..6b06182e 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -1,9 +1,17 @@ import { expect, test } from '@playwright/test' - -import { setup } from './setup.js' +import { createClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempoModerato } from 'viem/chains' +import { Actions } from 'viem/tempo' test.beforeAll(async () => { - await setup() + const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' + const account = privateKeyToAccount(privateKey) + + // Fund the test payer account via faucet + const client = createClient({ chain: tempoModerato, transport: http() }) + await Actions.faucet.fundSync(client, { account }) + console.log(`funded ${account.address}`) }) test('charge via html payment page', async ({ page, context }) => { From c5bd01b3e3eaee34734a9fae2fd285d1bdccc233 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 15:43:18 -0400 Subject: [PATCH 19/28] chore: fix stripe pure types --- src/stripe/server/internal/html/main.ts | 2 +- src/stripe/server/internal/html/stripe-js-pure.d.ts | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 src/stripe/server/internal/html/stripe-js-pure.d.ts diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index b0613e24..b5ca49d4 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -23,7 +23,7 @@ h2.textContent = 'stripe' root.appendChild(h2) ;(async () => { - const stripeJs = (await loadStripe(data.config.publishableKey))! + const stripeJs = await loadStripe(data.config.publishableKey) if (!stripeJs) throw new Error('Failed to loadStripe') const darkQuery = window.matchMedia('(prefers-color-scheme: dark)') diff --git a/src/stripe/server/internal/html/stripe-js-pure.d.ts b/src/stripe/server/internal/html/stripe-js-pure.d.ts new file mode 100644 index 00000000..25c0e370 --- /dev/null +++ b/src/stripe/server/internal/html/stripe-js-pure.d.ts @@ -0,0 +1,7 @@ +declare module '@stripe/stripe-js/pure' { + export * from '@stripe/stripe-js' + + export const loadStripe: typeof import('@stripe/stripe-js').loadStripe & { + setLoadParameters(parameters: { advancedFraudSignals: boolean }): void + } +} From 98083ea360add2e3c572d87d1b049c0f199fdb92 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 15:45:20 -0400 Subject: [PATCH 20/28] chore: up --- test/html/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index bbf84042..d008c44f 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ timeout: 60_000, retries: 1, use: { - headless: false, + headless: true, screenshot: 'only-on-failure', trace: 'retain-on-failure', video: 'retain-on-failure', From aaa6ec62a1f1d623596191dcb78cf44015a87873 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 15:57:14 -0400 Subject: [PATCH 21/28] test(html): stripe iframe retry --- examples/charge/package.json | 2 +- examples/stripe/package.json | 2 +- pnpm-lock.yaml | 27 ++++++++++---------- pnpm-workspace.yaml | 1 - test/html/stripe.test.ts | 48 ++++++++++++++++++++++++++++++------ 5 files changed, 55 insertions(+), 25 deletions(-) diff --git a/examples/charge/package.json b/examples/charge/package.json index 8aa093da..24d2fdd1 100644 --- a/examples/charge/package.json +++ b/examples/charge/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "check:types": "tsgo -b", - "dev": "vp dev", + "dev": "vite dev", "build": "vite build", "preview": "vite preview" }, diff --git a/examples/stripe/package.json b/examples/stripe/package.json index fd254f38..d158e2f7 100644 --- a/examples/stripe/package.json +++ b/examples/stripe/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vp dev", + "dev": "vite dev", "check": "biome check --fix --unsafe", "check:types": "tsgo -b", "build": "vite build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0386fd8..030e9038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,6 @@ settings: overrides: mppx: workspace:* - vite: npm:@voidzero-dev/vite-plus-core@~0.1.14 vitest: npm:@voidzero-dev/vite-plus-test@~0.1.14 ox: ^0.14.1 viem: ^2.47.5 @@ -145,8 +144,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/charge-wagmi: dependencies: @@ -189,13 +188,13 @@ importers: version: 7.0.0-dev.20260323.1 '@vitejs/plugin-react': specifier: latest - version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)) + version: 6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3)) typescript: specifier: latest version: 5.9.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/session/multi-fetch: dependencies: @@ -221,8 +220,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/session/sse: dependencies: @@ -248,8 +247,8 @@ importers: specifier: ^2.47.5 version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.3.6) vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) examples/stripe: dependencies: @@ -275,8 +274,8 @@ importers: specifier: latest version: 5.9.3 vite: - specifier: npm:@voidzero-dev/vite-plus-core@~0.1.14 - version: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + specifier: latest + version: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) src/stripe/server/internal/html: dependencies: @@ -4859,10 +4858,10 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260323.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260323.1 - '@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3))': + '@vitejs/plugin-react@6.0.1(vite@8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)' + vite: 8.0.3(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.3) '@voidzero-dev/vite-plus-core@0.1.14(@types/node@25.5.0)(esbuild@0.27.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0654bc54..e9fee7ae 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -6,7 +6,6 @@ packages: overrides: mppx: 'workspace:*' - vite: 'npm:@voidzero-dev/vite-plus-core@~0.1.14' vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.14' ox: '^0.14.1' viem: '^2.47.5' diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index ee80d7e0..1845184b 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -1,3 +1,4 @@ +import type { Frame, Page } from '@playwright/test' import { expect, test } from '@playwright/test' test('charge via stripe html payment page', async ({ page }) => { @@ -11,17 +12,21 @@ test('charge via stripe html payment page', async ({ page }) => { await expect(page.locator('h1')).toHaveText('Payment Required') await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 30_000 }) - // Wait for Stripe Payment Element iframe to load - const stripeFrame = page.frameLocator('iframe[name^="__privateStripeFrame"]').first() - const cardButton = stripeFrame.locator('[data-value="card"]') - await expect(cardButton).toBeVisible({ timeout: 30_000 }) + // Stripe renders several private frames. Find the one that actually contains + // the payment controls instead of assuming the first frame is the card UI. + const stripeFrame = await getStripePaymentFrame(page) + const numberInput = stripeFrame.locator('[name="number"]') - // Card option is collapsed by default — click to expand, wait for inputs to render - await cardButton.click() - await page.waitForTimeout(1_000) + // Some runs render card fields immediately; others require expanding card. + if (!(await numberInput.isVisible({ timeout: 2_000 }).catch(() => false))) { + const cardButton = stripeFrame.locator('[data-value="card"]') + if (await cardButton.isVisible({ timeout: 10_000 }).catch(() => false)) { + await cardButton.click() + await page.waitForTimeout(1_000) + } + } // Wait for card inputs to appear and fill test card details - const numberInput = stripeFrame.locator('[name="number"]') await expect(numberInput).toBeVisible({ timeout: 30_000 }) await numberInput.fill('4242424242424242') await stripeFrame.locator('[name="expiry"]').fill('12/34') @@ -47,3 +52,30 @@ test('service worker endpoint returns javascript', async ({ page }) => { expect(response?.headers()['content-type']).toContain('application/javascript') expect(response?.status()).toBe(200) }) + +async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise { + const deadline = Date.now() + timeout + + while (Date.now() < deadline) { + for (const frame of page.frames()) { + if (!frame.name().startsWith('__privateStripeFrame')) continue + + const hasNumberInput = + (await frame + .locator('[name="number"]') + .count() + .catch(() => 0)) > 0 + const hasCardButton = + (await frame + .locator('[data-value="card"]') + .count() + .catch(() => 0)) > 0 + + if (hasNumberInput || hasCardButton) return frame + } + + await page.waitForTimeout(250) + } + + throw new Error('Timed out waiting for Stripe payment frame') +} From 0534a4bfee6b7474b0e8a936cd02807c1d25a5e8 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 16:25:18 -0400 Subject: [PATCH 22/28] test(html): tweaks --- src/stripe/server/internal/html/main.ts | 4 -- test/html/playwright.config.ts | 2 +- test/html/server.ts | 76 +++++++++---------------- test/html/stripe.test.ts | 6 +- test/html/tempo.test.ts | 1 - 5 files changed, 32 insertions(+), 57 deletions(-) diff --git a/src/stripe/server/internal/html/main.ts b/src/stripe/server/internal/html/main.ts index b5ca49d4..51aae8ff 100644 --- a/src/stripe/server/internal/html/main.ts +++ b/src/stripe/server/internal/html/main.ts @@ -14,10 +14,6 @@ const data = JSON.parse(document.getElementById(Html.dataId)!.textContent!) as { const root = document.getElementById('root')! -// Stripe.js can enable invisible anti-bot checks. In real browsers we keep the -// default behavior, but deterministic automation benefits from opting out. -if (navigator.webdriver) loadStripe.setLoadParameters({ advancedFraudSignals: false }) - const h2 = document.createElement('h2') h2.textContent = 'stripe' root.appendChild(h2) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index d008c44f..bbf84042 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ timeout: 60_000, retries: 1, use: { - headless: true, + headless: false, screenshot: 'only-on-failure', trace: 'retain-on-failure', video: 'retain-on-failure', diff --git a/test/html/server.ts b/test/html/server.ts index a84e2aaa..f8672d9b 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -12,8 +12,22 @@ export async function startServer(port: number): Promise { const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY if (!stripeSecretKey) throw new Error('Missing VITE_STRIPE_SECRET_KEY') + const account = privateKeyToAccount(generatePrivateKey()) + const tempoClient = createClient({ + chain: tempoModerato, + pollingInterval: 1_000, + transport: createHttpTransport(process.env.MPPX_RPC_URL), + }) + for (let attempt = 1; ; attempt++) + try { + await Actions.faucet.fundSync(tempoClient, { account }) + break + } catch (error) { + if (attempt >= 3) throw error + } + const createTokenUrl = '/stripe/create-spt' - const stripeMppx = Mppx.create({ + const mppx = Mppx.create({ methods: [ stripe.charge({ html: { @@ -24,61 +38,27 @@ export async function startServer(port: number): Promise { paymentMethodTypes: ['card'], secretKey: stripeSecretKey, }), + tempo.charge({ + account, + currency: '0x20c0000000000000000000000000000000000000', + feePayer: true, + html: true, + recipient: account.address, + testnet: true, + }), ], secretKey: 'test-html-server-secret-key', }) - let tempoChargePromise: Promise<(request: Request) => Promise> | undefined - - async function getTempoCharge() { - if (!tempoChargePromise) { - tempoChargePromise = (async () => { - const account = privateKeyToAccount(generatePrivateKey()) - const tempoClient = createClient({ - chain: tempoModerato, - pollingInterval: 1_000, - transport: createHttpTransport(process.env.MPPX_RPC_URL), - }) - - for (let attempt = 1; ; attempt++) { - try { - await Actions.faucet.fundSync(tempoClient, { account }) - break - } catch (error) { - if (attempt >= 3) throw error - } - } - - const tempoMppx = Mppx.create({ - methods: [ - tempo.charge({ - account, - currency: '0x20c0000000000000000000000000000000000000', - feePayer: true, - html: true, - recipient: account.address, - testnet: true, - }), - ], - secretKey: 'test-html-server-secret-key', - }) - - return tempoMppx.tempo.charge({ - amount: '0.01', - description: 'Random stock photo', - }) - })() - } - - return await tempoChargePromise - } const server = http.createServer( ServerRequest.toNodeListener(async (request) => { const url = new URL(request.url) if (url.pathname === '/tempo/charge') { - const tempoCharge = await getTempoCharge() - const result = await tempoCharge(request) + const result = await mppx.tempo.charge({ + amount: '0.01', + description: 'Random stock photo', + })(request) if (result.status === 402) return result.challenge @@ -88,7 +68,7 @@ export async function startServer(port: number): Promise { if (url.pathname === createTokenUrl) return createSharedPaymentToken(request, stripeSecretKey) if (url.pathname === '/stripe/charge') { - const result = await stripeMppx.stripe.charge({ + const result = await mppx.stripe.charge({ amount: '1', currency: 'usd', decimals: 2, diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index 1845184b..f2f1c2dc 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -18,16 +18,16 @@ test('charge via stripe html payment page', async ({ page }) => { const numberInput = stripeFrame.locator('[name="number"]') // Some runs render card fields immediately; others require expanding card. - if (!(await numberInput.isVisible({ timeout: 2_000 }).catch(() => false))) { + if (!(await numberInput.isVisible({ timeout: 90_000 }).catch(() => false))) { const cardButton = stripeFrame.locator('[data-value="card"]') - if (await cardButton.isVisible({ timeout: 10_000 }).catch(() => false)) { + if (await cardButton.isVisible({ timeout: 90_000 }).catch(() => false)) { await cardButton.click() await page.waitForTimeout(1_000) } } // Wait for card inputs to appear and fill test card details - await expect(numberInput).toBeVisible({ timeout: 30_000 }) + await expect(numberInput).toBeVisible({ timeout: 90_000 }) await numberInput.fill('4242424242424242') await stripeFrame.locator('[name="expiry"]').fill('12/34') await stripeFrame.locator('[name="cvc"]').fill('123') diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts index 6b06182e..78e409dd 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -11,7 +11,6 @@ test.beforeAll(async () => { // Fund the test payer account via faucet const client = createClient({ chain: tempoModerato, transport: http() }) await Actions.faucet.fundSync(client, { account }) - console.log(`funded ${account.address}`) }) test('charge via html payment page', async ({ page, context }) => { From a88582b56770d09aa3b68fb2125422b4bfc92ce2 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 16:55:40 -0400 Subject: [PATCH 23/28] Update playwright.config.ts --- test/html/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index bbf84042..d008c44f 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ timeout: 60_000, retries: 1, use: { - headless: false, + headless: true, screenshot: 'only-on-failure', trace: 'retain-on-failure', video: 'retain-on-failure', From 89386863009a95287bd3621cee8497f25cb2ae0d Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 21:51:52 -0500 Subject: [PATCH 24/28] ci: test html upload --- .github/workflows/verify.yml | 11 +++++++++++ test/html/playwright.config.ts | 3 ++- test/html/stripe.test.ts | 27 +++++++++------------------ 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index b46fd1de..cc6c5325 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -168,3 +168,14 @@ jobs: MPP_SECRET_KEY: test-secret-key VITE_STRIPE_SECRET_KEY: ${{ secrets.VITE_STRIPE_SECRET_KEY }} VITE_STRIPE_PUBLIC_KEY: ${{ secrets.VITE_STRIPE_PUBLIC_KEY }} + + - name: Upload Playwright artifacts + if: ${{ always() }} + uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1 + with: + name: playwright-artifacts + path: | + playwright-report + test-results + if-no-files-found: ignore + retention-days: 7 diff --git a/test/html/playwright.config.ts b/test/html/playwright.config.ts index d008c44f..6217d152 100644 --- a/test/html/playwright.config.ts +++ b/test/html/playwright.config.ts @@ -10,8 +10,9 @@ export default defineConfig({ testMatch: '*.test.ts', timeout: 60_000, retries: 1, + reporter: process.env.CI ? [['line'], ['html', { open: 'never' }]] : 'list', use: { - headless: true, + headless: !!process.env.CI || true, screenshot: 'only-on-failure', trace: 'retain-on-failure', video: 'retain-on-failure', diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index f2f1c2dc..77ecf887 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -10,21 +10,18 @@ test('charge via stripe html payment page', async ({ page }) => { // Verify 402 payment page rendered await expect(page.locator('h1')).toHaveText('Payment Required') - await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) // Stripe renders several private frames. Find the one that actually contains // the payment controls instead of assuming the first frame is the card UI. const stripeFrame = await getStripePaymentFrame(page) const numberInput = stripeFrame.locator('[name="number"]') - // Some runs render card fields immediately; others require expanding card. - if (!(await numberInput.isVisible({ timeout: 90_000 }).catch(() => false))) { - const cardButton = stripeFrame.locator('[data-value="card"]') - if (await cardButton.isVisible({ timeout: 90_000 }).catch(() => false)) { - await cardButton.click() - await page.waitForTimeout(1_000) - } - } + // Open card form + const cardButton = stripeFrame.locator('[data-value="card"]') + await cardButton.isVisible({ timeout: 90_000 }) + await cardButton.click() + await page.waitForTimeout(1_000) // Wait for card inputs to appear and fill test card details await expect(numberInput).toBeVisible({ timeout: 90_000 }) @@ -34,8 +31,8 @@ test('charge via stripe html payment page', async ({ page }) => { // Fill postal code if visible const postalCode = stripeFrame.locator('[name="postalCode"]') - if (await postalCode.isVisible({ timeout: 2_000 }).catch(() => false)) - await postalCode.fill('10001') + await postalCode.isVisible({ timeout: 2_000 }) + await postalCode.fill('10001') // Wait for Stripe Elements to settle await page.waitForTimeout(500) @@ -60,18 +57,12 @@ async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise 0)) > 0 const hasCardButton = (await frame .locator('[data-value="card"]') .count() .catch(() => 0)) > 0 - - if (hasNumberInput || hasCardButton) return frame + if (hasCardButton) return frame } await page.waitForTimeout(250) From a21faa92328a114197fa1ceef7f196b09b38c24c Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 22:45:22 -0500 Subject: [PATCH 25/28] test(html): tempo local adapter --- scripts/build:html.ts | 2 +- src/env.d.ts | 2 + src/stripe/server/internal/html/tsconfig.json | 2 +- src/tempo/server/internal/html/main.ts | 69 +++++++++---------- src/tempo/server/internal/html/tsconfig.json | 2 +- test/html/globalSetup.ts | 3 +- test/html/server.ts | 6 +- test/html/tempo.test.ts | 32 +-------- 8 files changed, 46 insertions(+), 72 deletions(-) diff --git a/scripts/build:html.ts b/scripts/build:html.ts index 0249a219..1f84c014 100644 --- a/scripts/build:html.ts +++ b/scripts/build:html.ts @@ -22,7 +22,7 @@ for (const entry of htmlEntries) { }, transform: { define: { - __LOCAL_ACCOUNT__: JSON.stringify(process.env.LOCAL_ACCOUNT ?? ''), + __TEST__: process.env.TEST ? 'true' : 'false', }, }, output: { diff --git a/src/env.d.ts b/src/env.d.ts index 3900fc84..fa297a97 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,5 +1,7 @@ /// +declare const __TEST__: boolean + interface ImportMetaEnv { readonly VITE_NODE_ENV: 'localnet' | 'testnet' | 'mainnet' readonly VITE_HTTP_LOG: 'true' | 'false' diff --git a/src/stripe/server/internal/html/tsconfig.json b/src/stripe/server/internal/html/tsconfig.json index ac01827c..06388472 100644 --- a/src/stripe/server/internal/html/tsconfig.json +++ b/src/stripe/server/internal/html/tsconfig.json @@ -4,5 +4,5 @@ "lib": ["es2022", "dom"], "types": [] }, - "include": ["./**/*.ts"] + "include": ["./**/*.ts", "../../../../env.d.ts"] } diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 06daad2e..40aac58c 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -1,8 +1,9 @@ -import { Provider } from 'accounts' -import { Hex, Json } from 'ox' +import { local, Provider } from 'accounts' +import { Json } from 'ox' import { createClient, custom, http } from 'viem' +import { generatePrivateKey } from 'viem/accounts' import { tempoModerato, tempoLocalnet } from 'viem/chains' -import { Account } from 'viem/tempo' +import { Account, Actions } from 'viem/tempo' import type * as Challenge from '../../../../Challenge.js' import { tempo } from '../../../../client/index.js' @@ -20,20 +21,30 @@ const h2 = document.createElement('h2') h2.textContent = 'tempo' root.appendChild(h2) -// Used for testing. TODO: Wire up more native way -const localTempoAccount = __LOCAL_ACCOUNT__ - ? Account.fromSecp256k1(__LOCAL_ACCOUNT__ as Hex.Hex) - : undefined -declare const __LOCAL_ACCOUNT__: string | undefined - -const provider = (() => { - if (localTempoAccount) return undefined - return Provider.create({ - testnet: - data.challenge.request.methodDetails?.chainId === tempoModerato.id || - data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, - }) -})() +const provider = Provider.create({ + ...(__TEST__ && { + // Dead code eliminated from production bundle + adapter: local({ + async loadAccounts() { + const privateKey = generatePrivateKey() + const account = Account.fromSecp256k1(privateKey) + const client = createClient({ + chain: [tempoModerato, tempoLocalnet].find( + (x) => x.id === data.challenge.request.methodDetails?.chainId, + ), + transport: http(), + }) + await Actions.faucet.fundSync(client, { account }) + return { + accounts: [account], + } + }, + }), + }), + testnet: + data.challenge.request.methodDetails?.chainId === tempoModerato.id || + data.challenge.request.methodDetails?.chainId === tempoLocalnet.id, +}) const button = document.createElement('button') button.textContent = 'Continue with Tempo' @@ -41,24 +52,12 @@ button.onclick = async () => { try { button.disabled = true - const client = (() => { - const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( - (x) => x.id === data.challenge.request.methodDetails?.chainId, - ) - if (localTempoAccount || !provider) - return createClient({ - account: localTempoAccount, - chain, - transport: http(chain?.rpcUrls.default.http[0]), - }) - return createClient({ chain, transport: custom(provider) }) - })() - - const account = await (async () => { - if (localTempoAccount || !provider) return localTempoAccount - const res = await provider.request({ method: 'wallet_connect' }) - return res.accounts[0]!.address - })() + const chain = [...(provider?.chains ?? []), tempoModerato, tempoLocalnet].find( + (x) => x.id === data.challenge.request.methodDetails?.chainId, + ) + const client = createClient({ chain, transport: custom(provider) }) + const result = await provider.request({ method: 'wallet_connect' }) + const account = result.accounts[0]?.address const method = tempo({ account, getClient: () => client })[0] const credential = await method.createCredential({ challenge: data.challenge, context: {} }) diff --git a/src/tempo/server/internal/html/tsconfig.json b/src/tempo/server/internal/html/tsconfig.json index ac01827c..06388472 100644 --- a/src/tempo/server/internal/html/tsconfig.json +++ b/src/tempo/server/internal/html/tsconfig.json @@ -4,5 +4,5 @@ "lib": ["es2022", "dom"], "types": [] }, - "include": ["./**/*.ts"] + "include": ["./**/*.ts", "../../../../env.d.ts"] } diff --git a/test/html/globalSetup.ts b/test/html/globalSetup.ts index 466f849a..7f6660de 100644 --- a/test/html/globalSetup.ts +++ b/test/html/globalSetup.ts @@ -1,8 +1,7 @@ import { execSync } from 'node:child_process' export default async function globalSetup() { - const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' - execSync(`LOCAL_ACCOUNT=${privateKey} pnpm build`, { + execSync(`TEST=1 pnpm build`, { cwd: new URL('../..', import.meta.url).pathname, stdio: 'inherit', }) diff --git a/test/html/server.ts b/test/html/server.ts index f8672d9b..8097be5d 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -13,14 +13,14 @@ export async function startServer(port: number): Promise { if (!stripeSecretKey) throw new Error('Missing VITE_STRIPE_SECRET_KEY') const account = privateKeyToAccount(generatePrivateKey()) - const tempoClient = createClient({ + const client = createClient({ chain: tempoModerato, pollingInterval: 1_000, - transport: createHttpTransport(process.env.MPPX_RPC_URL), + transport: createHttpTransport(), }) for (let attempt = 1; ; attempt++) try { - await Actions.faucet.fundSync(tempoClient, { account }) + await Actions.faucet.fundSync(client, { account }) break } catch (error) { if (attempt >= 3) throw error diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts index 78e409dd..07265e2b 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -1,27 +1,6 @@ import { expect, test } from '@playwright/test' -import { createClient, http } from 'viem' -import { privateKeyToAccount } from 'viem/accounts' -import { tempoModerato } from 'viem/chains' -import { Actions } from 'viem/tempo' - -test.beforeAll(async () => { - const privateKey = '0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d' - const account = privateKeyToAccount(privateKey) - - // Fund the test payer account via faucet - const client = createClient({ chain: tempoModerato, transport: http() }) - await Actions.faucet.fundSync(client, { account }) -}) - -test('charge via html payment page', async ({ page, context }) => { - const logs: string[] = [] - page.on('pageerror', (err) => logs.push(`[pageerror] ${err.message}`)) - page.on('console', (msg) => logs.push(`[console.${msg.type()}] ${msg.text()}`)) - page.on('requestfailed', (req) => - logs.push(`[requestfailed] ${req.url()} ${req.failure()?.errorText}`), - ) - context.on('serviceworker', (sw) => logs.push(`[serviceworker] registered: ${sw.url()}`)) +test('charge via html payment page', async ({ page }) => { // Navigate to the payment endpoint as a browser await page.goto('/tempo/charge', { waitUntil: 'domcontentloaded', @@ -31,16 +10,11 @@ test('charge via html payment page', async ({ page, context }) => { await expect(page.locator('h1')).toHaveText('Payment Required') await expect(page.getByText('Continue with Tempo')).toBeVisible() - // Click the pay button — local adapter signs without dialog + // Click the pay button (local adapter signs without dialog) await page.getByText('Continue with Tempo').click() // Wait for service worker to submit credential and page to reload with paid response - await expect(page.locator('body')) - .toContainText('"url":', { timeout: 30_000 }) - .catch((e) => { - console.error('Browser logs:\n' + logs.join('\n')) - throw e - }) + await expect(page.locator('body')).toContainText('"url":', { timeout: 30_000 }) }) test('service worker endpoint returns javascript', async ({ page }) => { From d6a5cf24a9d65387ff22d968118b660bcd9995f7 Mon Sep 17 00:00:00 2001 From: tmm Date: Tue, 31 Mar 2026 23:45:33 -0500 Subject: [PATCH 26/28] test: mode --- scripts/build:html.ts | 24 +++++++--- src/env.d.ts | 3 +- src/stripe/server/internal/html/main.ts | 62 ++++++++++++++++--------- src/tempo/server/internal/html/main.ts | 40 ++++++++-------- test/html/globalSetup.ts | 14 +++++- test/html/stripe.test.ts | 44 +++++++++--------- 6 files changed, 112 insertions(+), 75 deletions(-) diff --git a/scripts/build:html.ts b/scripts/build:html.ts index 1f84c014..d299f5cd 100644 --- a/scripts/build:html.ts +++ b/scripts/build:html.ts @@ -5,16 +5,24 @@ import { build } from 'rolldown' const root = path.resolve(import.meta.dirname, '..') const outDir = path.resolve(root, '.tmp/html-build') +const defaultMode = process.env.TEST ? 'test' : 'production' +const stripeMode = process.env.STRIPE_HTML_MODE ?? defaultMode // HTML entries — bundled into `)}\n` fs.writeFileSync(outFile, content) fs.rmSync(outDir, { recursive: true }) - console.log(`wrote ${path.relative(root, outFile)}`) + console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) } // Service worker (bundled as raw JS string) @@ -71,9 +74,10 @@ for (const { entry, mode, outFile } of htmlEntries) { if (!jsFile) throw new Error(`No .js output found for ${entry}`) const code = fs.readFileSync(path.join(outDir, jsFile), 'utf8').trim() + const bundleBytes = Buffer.byteLength(code) const content = `// Generated — do not edit.\nexport const serviceWorker = ${JSON.stringify(code)}\n` fs.writeFileSync(outFile, content) fs.rmSync(outDir, { recursive: true }) - console.log(`wrote ${path.relative(root, outFile)}`) + console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`) } diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 3f4881ea..21fe77c8 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -129,7 +129,7 @@ export function http(): Http { respondChallenge(options) { const { challenge, error, input } = options - if (options.html && new URL(input.url).searchParams.has('__mppx_worker')) + if (options.html && new URL(input.url).searchParams.has(Html.serviceWorkerParam)) return new Response(serviceWorker, { status: 200, headers: { @@ -146,10 +146,6 @@ export function http(): Http { const body = (() => { if (options.html && input.headers.get('Accept')?.includes('text/html')) { headers['Content-Type'] = 'text/html; charset=utf-8' - const data = Json.stringify({ config: options.html.config, challenge }).replace( - / @@ -173,7 +169,10 @@ ${Json.stringify(challenge, null, 2) >
${options.html.content} diff --git a/src/server/internal/html/config.ts b/src/server/internal/html/config.ts index 3cc6da4c..4c29208e 100644 --- a/src/server/internal/html/config.ts +++ b/src/server/internal/html/config.ts @@ -4,3 +4,5 @@ export type Options = { } export const dataId = '__MPPX_DATA__' + +export const serviceWorkerParam = '__mppx_worker' diff --git a/src/server/internal/html/serviceWorker.client.ts b/src/server/internal/html/serviceWorker.client.ts index e2f62dd0..f94de3c1 100644 --- a/src/server/internal/html/serviceWorker.client.ts +++ b/src/server/internal/html/serviceWorker.client.ts @@ -1,6 +1,8 @@ +import { serviceWorkerParam } from './config.js' + export async function submitCredential(credential: string): Promise { const url = new URL(location.href) - url.searchParams.set('__mppx_worker', '') + url.searchParams.set(serviceWorkerParam, '') const registration = await navigator.serviceWorker.register(url.pathname + url.search) From 0fc9f38d486e843238e040480a94ebc0ddbe9aca Mon Sep 17 00:00:00 2001 From: tmm Date: Wed, 1 Apr 2026 00:45:22 -0500 Subject: [PATCH 28/28] chore: tweaks --- src/tempo/server/internal/html/main.ts | 4 ++-- test/html/stripe.test.ts | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index abc9eeea..561ce010 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -1,9 +1,7 @@ import { local, Provider } from 'accounts' import { Json } from 'ox' import { createClient, custom, http } from 'viem' -import { generatePrivateKey } from 'viem/accounts' import { tempoModerato, tempoLocalnet } from 'viem/chains' -import { Account, Actions } from 'viem/tempo' import type * as Challenge from '../../../../Challenge.js' import { tempo } from '../../../../client/index.js' @@ -27,6 +25,8 @@ const provider = Provider.create({ ? { adapter: local({ async loadAccounts() { + const { generatePrivateKey } = await import('viem/accounts') + const { Account, Actions } = await import('viem/tempo') const privateKey = generatePrivateKey() const account = Account.fromSecp256k1(privateKey) const client = createClient({ diff --git a/test/html/stripe.test.ts b/test/html/stripe.test.ts index 3aed0d8f..c3ad6541 100644 --- a/test/html/stripe.test.ts +++ b/test/html/stripe.test.ts @@ -1,4 +1,4 @@ -import type { Frame, Page, TestInfo } from '@playwright/test' +import type { Frame, Page } from '@playwright/test' import { expect, test } from '@playwright/test' test('charge via stripe html payment page', async ({ page }, testInfo) => { @@ -12,7 +12,7 @@ test('charge via stripe html payment page', async ({ page }, testInfo) => { await expect(page.locator('h1')).toHaveText('Payment Required') await expect(page.getByRole('button', { name: 'Pay' })).toBeVisible({ timeout: 10_000 }) - if (!isHeadless(testInfo)) { + if (!testInfo.project.use.headless) { const stripeFrame = await getStripePaymentFrame(page) const numberInput = stripeFrame.locator('[name="number"]') const cardButton = stripeFrame.locator('[data-value="card"]') @@ -46,10 +46,6 @@ test('service worker endpoint returns javascript', async ({ page }) => { expect(response?.status()).toBe(200) }) -function isHeadless(testInfo: TestInfo) { - return testInfo.project.use.headless !== false -} - async function getStripePaymentFrame(page: Page, timeout = 30_000): Promise { const deadline = Date.now() + timeout