diff --git a/.changeset/rich-chefs-wear.md b/.changeset/rich-chefs-wear.md
new file mode 100644
index 00000000..109cacf7
--- /dev/null
+++ b/.changeset/rich-chefs-wear.md
@@ -0,0 +1,5 @@
+---
+'mppx': patch
+---
+
+Added `.compose` support to HTML payment links.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 906a1f9b..c283d3a4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -7,6 +7,7 @@ settings:
overrides:
mppx: workspace:*
vitest: npm:@voidzero-dev/vite-plus-test@~0.1.14
+ typescript: ~5.9.3
ox: 0.14.7
viem: ^2.47.5
path-to-regexp@<8.4.0: 8.4.0
@@ -139,7 +140,7 @@ importers:
specifier: workspace:*
version: link:../..
typescript:
- specifier: latest
+ specifier: ~5.9.3
version: 5.9.3
viem:
specifier: ^2.47.5
@@ -191,7 +192,7 @@ importers:
specifier: latest
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
+ specifier: ~5.9.3
version: 5.9.3
vite:
specifier: latest
@@ -215,7 +216,7 @@ importers:
specifier: latest
version: 4.21.0
typescript:
- specifier: latest
+ specifier: ~5.9.3
version: 5.9.3
viem:
specifier: ^2.47.5
@@ -242,7 +243,7 @@ importers:
specifier: latest
version: 4.21.0
typescript:
- specifier: latest
+ specifier: ~5.9.3
version: 5.9.3
viem:
specifier: ^2.47.5
@@ -272,7 +273,7 @@ importers:
specifier: ^17.7.0
version: 17.7.0
typescript:
- specifier: latest
+ specifier: ~5.9.3
version: 5.9.3
vite:
specifier: latest
@@ -291,7 +292,7 @@ importers:
dependencies:
accounts:
specifier: 0.4.12
- version: 0.4.12(@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))
+ version: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@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)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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:../../../../..
@@ -1507,7 +1508,7 @@ packages:
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
- typescript: ^5.0.0
+ typescript: ~5.9.3
unplugin-unused: ^0.5.0
yaml: '>=2.8.3'
peerDependenciesMeta:
@@ -1632,7 +1633,7 @@ packages:
'@wagmi/core': 3.4.0
'@walletconnect/ethereum-provider': ^2.21.1
porto: ~0.2.35
- typescript: '>=5.7.3'
+ typescript: ~5.9.3
viem: ^2.47.5
peerDependenciesMeta:
'@base-org/account':
@@ -1657,7 +1658,7 @@ packages:
peerDependencies:
'@tanstack/query-core': '>=5.0.0'
ox: 0.14.7
- typescript: '>=5.7.3'
+ typescript: ~5.9.3
viem: ^2.47.5
peerDependenciesMeta:
'@tanstack/query-core':
@@ -1670,7 +1671,7 @@ packages:
abitype@1.2.3:
resolution: {integrity: sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==}
peerDependencies:
- typescript: '>=5.0.4'
+ typescript: ~5.9.3
zod: ^3.22.0 || ^4.0.0
peerDependenciesMeta:
typescript:
@@ -2096,7 +2097,7 @@ packages:
exact-mirror: '>= 0.0.9'
file-type: 21.3.2
openapi-types: '>= 12.0.0'
- typescript: '>= 5.0.0'
+ typescript: ~5.9.3
peerDependenciesMeta:
'@types/bun':
optional: true
@@ -2810,7 +2811,7 @@ packages:
mipd@0.0.7:
resolution: {integrity: sha512-aAPZPNDQ3uMTdKbuO2YmAw2TxLHO0moa4YKAyETM/DTj5FloZo+a+8tU+iv4GmW+sOxKLSRwcSFuczk+Cpt6fg==}
peerDependencies:
- typescript: '>=5.0.4'
+ typescript: ~5.9.3
peerDependenciesMeta:
typescript:
optional: true
@@ -2823,6 +2824,25 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ mppx@0.4.12:
+ resolution: {integrity: sha512-qN84ijeWcm+agw+ne+xxcMJhEw3+LJGyNxTnOQePyOrubXdPiI6duMdwNL796FXjsLMm8HizATOZBWw+qBGaMA==}
+ hasBin: true
+ peerDependencies:
+ '@modelcontextprotocol/sdk': 1.26.0
+ elysia: 1.4.26
+ express: '>=5'
+ hono: 4.12.7
+ viem: ^2.47.5
+ peerDependenciesMeta:
+ '@modelcontextprotocol/sdk':
+ optional: true
+ elysia:
+ optional: true
+ express:
+ optional: true
+ hono:
+ optional: true
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -2916,7 +2936,7 @@ packages:
ox@0.14.7:
resolution: {integrity: sha512-zSQ/cfBdolj7U4++NAvH7sI+VG0T3pEohITCgcQj8KlawvTDY4vGVhDT64Atsm0d6adWfIYHDpu88iUBMMp+AQ==}
peerDependencies:
- typescript: '>=5.4.0'
+ typescript: ~5.9.3
peerDependenciesMeta:
typescript:
optional: true
@@ -3429,7 +3449,7 @@ packages:
engines: {node: ^18 || >=20}
hasBin: true
peerDependencies:
- typescript: ^5.0.0
+ typescript: ~5.9.3
peerDependenciesMeta:
typescript:
optional: true
@@ -3527,7 +3547,7 @@ packages:
viem@2.47.6:
resolution: {integrity: sha512-zExmbI99NGvMdYa7fmqSTLgkwh48dmhgEqFrUgkpL4kfG4XkVefZ8dZqIKVUhZo6Uhf0FrrEXOsHm9LUyIvI2Q==}
peerDependencies:
- typescript: '>=5.0.4'
+ typescript: ~5.9.3
peerDependenciesMeta:
typescript:
optional: true
@@ -3585,7 +3605,7 @@ packages:
peerDependencies:
'@tanstack/react-query': '>=5.0.0'
react: '>=18'
- typescript: '>=5.7.3'
+ typescript: ~5.9.3
viem: ^2.47.5
peerDependenciesMeta:
typescript:
@@ -3689,7 +3709,7 @@ packages:
hasBin: true
peerDependencies:
'@typescript/native-preview': '>=7.0.0'
- typescript: '>=5'
+ typescript: ~5.9.3
peerDependenciesMeta:
'@typescript/native-preview':
optional: true
@@ -4970,12 +4990,12 @@ snapshots:
mime-types: 3.0.2
negotiator: 1.0.0
- accounts@0.4.12(@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)):
+ accounts@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(@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)))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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:'
+ mppx: 0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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))
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)
@@ -4986,8 +5006,15 @@ snapshots:
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:
+ - '@cfworker/json-schema'
+ - '@modelcontextprotocol/sdk'
- '@types/react'
+ - elysia
+ - express
+ - hono
- immer
+ - openapi-types
+ - supports-color
- typescript
- use-sync-external-store
@@ -6124,6 +6151,25 @@ snapshots:
mkdirp@1.0.4: {}
+ mppx@0.4.12(@modelcontextprotocol/sdk@1.26.0(zod@4.3.6))(elysia@1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3))(express@5.2.1)(hono@4.12.7)(openapi-types@12.1.3)(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)):
+ dependencies:
+ '@remix-run/fetch-proxy': 0.7.1
+ '@remix-run/node-fetch-server': 0.13.0
+ incur: 0.3.1(openapi-types@12.1.3)
+ ox: 0.14.7(typescript@5.9.3)(zod@4.3.6)
+ 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
+ optionalDependencies:
+ '@modelcontextprotocol/sdk': 1.26.0(zod@4.3.6)
+ elysia: 1.4.27(@sinclair/typebox@0.34.48)(exact-mirror@0.2.7(@sinclair/typebox@0.34.48))(file-type@21.3.2)(openapi-types@12.1.3)(typescript@5.9.3)
+ express: 5.2.1
+ hono: 4.12.7
+ transitivePeerDependencies:
+ - '@cfworker/json-schema'
+ - openapi-types
+ - supports-color
+ - typescript
+
mri@1.2.0: {}
mrmime@2.0.1: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 64a5ef06..1aee9200 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -7,6 +7,7 @@ packages:
overrides:
mppx: 'workspace:*'
vitest: 'npm:@voidzero-dev/vite-plus-test@~0.1.14'
+ typescript: $typescript
ox: '0.14.7'
viem: '^2.47.5'
path-to-regexp@<8.4.0: '8.4.0'
diff --git a/scripts/build:html.ts b/scripts/build:html.ts
index a11393ad..21b866bd 100644
--- a/scripts/build:html.ts
+++ b/scripts/build:html.ts
@@ -10,6 +10,33 @@ const stripeMode = process.env.STRIPE_HTML_MODE ?? defaultMode
const formatBundleSize = (bytes: number) =>
bytes >= 1_000 ? `${(bytes / 1_000).toFixed(1)} kB` : `${bytes} B`
+// Tab script (bundled as raw JS string for compose HTML)
+// Must be built before HTML entries since they import config.ts which re-exports tabScript
+{
+ const entry = 'src/server/internal/html/compose.main.ts'
+ const outFile = path.resolve(root, 'src/server/internal/html/compose.main.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 bundleBytes = Buffer.byteLength(code)
+ const content = `// Generated — do not edit.\nexport const tabScript = ${JSON.stringify(``)}\n`
+
+ fs.writeFileSync(outFile, content)
+ fs.rmSync(outDir, { recursive: true })
+ console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`)
+}
+
// HTML entries — bundled into `)}\n`
+ // Confirm test-only dead code was eliminated for non-test builds
+ if (mode !== 'test') {
+ const markers = testOnlyMarkers[entry] ?? []
+ const leaked = markers.filter((m) => code.includes(m))
+ if (leaked.length > 0)
+ throw new Error(
+ `Dead code elimination failed for ${entry} (mode=${mode}). ` +
+ `Test-only markers found in bundle: ${leaked.join(', ')}`,
+ )
+ }
+
fs.writeFileSync(outFile, content)
fs.rmSync(outDir, { recursive: true })
console.log(`wrote ${path.relative(root, outFile)} (${formatBundleSize(bundleBytes)})`)
diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts
index ef827e8c..f7cfd0d8 100644
--- a/src/server/Mppx.test.ts
+++ b/src/server/Mppx.test.ts
@@ -1184,6 +1184,209 @@ describe('compose', () => {
expect(result.status).toBe(200)
})
+
+ describe('html', () => {
+ const htmlOptionsA = {
+ config: { providerA: true },
+ content: '',
+ formatAmount: (request: Record) => `$${request.amount}`,
+ text: undefined,
+ theme: undefined,
+ }
+
+ const htmlOptionsB = {
+ config: { providerB: true },
+ content: '',
+ formatAmount: (request: Record) => `$${request.amount}`,
+ text: undefined,
+ theme: undefined,
+ }
+
+ const alphaWithHtml = Method.toServer(mockChargeA, {
+ html: htmlOptionsA,
+ async verify() {
+ return mockReceipt('alpha')
+ },
+ })
+
+ const betaWithHtml = Method.toServer(mockChargeB, {
+ html: htmlOptionsB,
+ async verify() {
+ return mockReceipt('beta')
+ },
+ })
+
+ test('returns html with tabs when multiple methods have html', async () => {
+ const mppx = Mppx.create({
+ methods: [alphaWithHtml, betaWithHtml],
+ realm,
+ secretKey,
+ })
+
+ const handle = mppx.compose([alphaWithHtml, challengeOpts], [betaWithHtml, challengeOpts])
+
+ const result = await handle(
+ new Request('https://example.com/resource', {
+ headers: { Accept: 'text/html' },
+ }),
+ )
+
+ expect(result.status).toBe(402)
+ if (result.status !== 402) throw new Error()
+
+ const body = await result.challenge.text()
+ expect(result.challenge.headers.get('Content-Type')).toBe('text/html; charset=utf-8')
+
+ // Tab a11y markup
+ expect(body).toContain('role="tablist"')
+ expect(body).toContain('role="tab"')
+ expect(body).toContain('role="tabpanel"')
+ expect(body).toContain('aria-selected="true"')
+ expect(body).toContain('aria-controls="mppx-panel-0"')
+ expect(body).toContain('aria-controls="mppx-panel-1"')
+
+ // Tab labels from method names (capitalized via CSS)
+ expect(body).toContain('alpha')
+ expect(body).toContain('beta')
+
+ // Both method bundles included
+ expect(body).toContain('/alpha-bundle.js')
+ expect(body).toContain('/beta-bundle.js')
+
+ // Data map with both entries
+ const dataMatch = body.match(
+ /
- ${options.html.content}
-
-