diff --git a/.fernignore b/.fernignore index 084a8eb..5919867 100644 --- a/.fernignore +++ b/.fernignore @@ -1 +1,4 @@ # Specify files that shouldn't be modified by Fern +src/wrapper/ +src/index.ts +tests/custom.test.ts diff --git a/src/index.ts b/src/index.ts index 82a86f4..aa95d2d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ export * as AgentMail from "./api/index.js"; export type { BaseClientOptions, BaseRequestOptions } from "./BaseClient.js"; -export { AgentMailClient } from "./Client.js"; +export { AgentMailClient } from "./wrapper/AgentMailClient.js"; export { AgentMailEnvironment, type AgentMailEnvironmentUrls } from "./environments.js"; export { AgentMailError, AgentMailTimeoutError } from "./errors/index.js"; export * from "./exports.js"; diff --git a/src/wrapper/AgentMailClient.ts b/src/wrapper/AgentMailClient.ts new file mode 100644 index 0000000..346aae0 --- /dev/null +++ b/src/wrapper/AgentMailClient.ts @@ -0,0 +1,66 @@ +import { AgentMailClient as FernAgentMailClient } from "../Client.js"; +import type { BaseClientOptions, BaseRequestOptions } from "../BaseClient.js"; +import { WebsocketsClient } from "../api/resources/websockets/client/Client.js"; +import { X402WebsocketsClient } from "./X402WebsocketsClient.js"; + +export declare namespace AgentMailClient { + export interface Options extends BaseClientOptions { + /** + * An x402Client instance for automatic payment handling on HTTP and WebSocket calls. + * + * @example + * ```typescript + * import { x402Client } from "@x402/fetch"; + * import { registerExactEvmScheme } from "@x402/evm/exact/client"; + * import { privateKeyToAccount } from "viem/accounts"; + * + * const x402 = new x402Client(); + * registerExactEvmScheme(x402, { signer: privateKeyToAccount("0x...") }); + * + * const client = new AgentMailClient({ + * environment: AgentMailEnvironment.ProdX402, + * x402, + * }); + * ``` + */ + x402?: unknown; + } + + export type RequestOptions = BaseRequestOptions; +} + +export class AgentMailClient extends FernAgentMailClient { + private readonly _x402Client?: unknown; + + constructor(options: AgentMailClient.Options = {}) { + if (options.x402) { + let wrappedFetch: typeof fetch | undefined; + const x402Client = options.x402; + + super({ + apiKey: "", + ...options, + fetch: (async (input: RequestInfo | URL, init?: RequestInit) => { + if (!wrappedFetch) { + const { wrapFetchWithPayment } = await import("@x402/fetch"); + wrappedFetch = wrapFetchWithPayment(fetch, x402Client as never); + } + return wrappedFetch(input, init); + }) as typeof fetch, + }); + + this._x402Client = x402Client; + } else { + super(options); + } + } + + public get websockets(): WebsocketsClient { + if (!this._websockets) { + this._websockets = this._x402Client + ? new X402WebsocketsClient(this._options, this._x402Client) + : new WebsocketsClient(this._options); + } + return this._websockets; + } +} diff --git a/src/wrapper/X402WebsocketsClient.ts b/src/wrapper/X402WebsocketsClient.ts new file mode 100644 index 0000000..f143e4e --- /dev/null +++ b/src/wrapper/X402WebsocketsClient.ts @@ -0,0 +1,30 @@ +import { WebsocketsClient } from "../api/resources/websockets/client/Client.js"; +import type { WebsocketsSocket } from "../api/resources/websockets/client/Socket.js"; +import * as core from "../core/index.js"; +import * as environments from "../environments.js"; +import { getPaymentQueryParams } from "./x402.js"; + +export class X402WebsocketsClient extends WebsocketsClient { + private readonly _x402Client: unknown; + + constructor(options: WebsocketsClient.Options, x402Client: unknown) { + super(options); + this._x402Client = x402Client; + } + + public async connect(args: WebsocketsClient.ConnectArgs = {}): Promise { + const wsUrl = core.url.join( + (await core.Supplier.get(this._options.baseUrl)) ?? + ((await core.Supplier.get(this._options.environment)) ?? environments.AgentMailEnvironment.Prod) + .websockets, + "/v0", + ); + + const paymentQueryParams = await getPaymentQueryParams(wsUrl, this._x402Client); + + return super.connect({ + ...args, + queryParams: { ...paymentQueryParams, ...args.queryParams }, + }); + } +} diff --git a/src/wrapper/index.ts b/src/wrapper/index.ts new file mode 100644 index 0000000..a839795 --- /dev/null +++ b/src/wrapper/index.ts @@ -0,0 +1 @@ +export { AgentMailClient } from "./AgentMailClient.js"; diff --git a/src/wrapper/x402-modules.d.ts b/src/wrapper/x402-modules.d.ts new file mode 100644 index 0000000..21a80e8 --- /dev/null +++ b/src/wrapper/x402-modules.d.ts @@ -0,0 +1,13 @@ +// Type declarations for @x402/fetch (optional peer dependency, dynamically imported) + +declare module "@x402/fetch" { + export function wrapFetchWithPayment( + fetch: typeof globalThis.fetch, + client: unknown, + ): typeof globalThis.fetch; + export class x402HTTPClient { + constructor(client: unknown); + getPaymentRequiredResponse(getHeader: (name: string) => string | null, body: unknown): unknown; + encodePaymentSignatureHeader(payload: unknown): Record; + } +} diff --git a/src/wrapper/x402.ts b/src/wrapper/x402.ts new file mode 100644 index 0000000..28ebed8 --- /dev/null +++ b/src/wrapper/x402.ts @@ -0,0 +1,47 @@ +/** + * Probes a WebSocket endpoint over HTTP to get a 402 response, + * then signs a payment and returns the payment-signature as a query parameter. + * + * Uses query params instead of headers because many WebSocket clients + * (including browser WebSocket) don't support custom headers on the upgrade request. + */ +export async function getPaymentQueryParams(wsUrl: string, x402Client: unknown): Promise> { + let x402Fetch: typeof import("@x402/fetch"); + try { + x402Fetch = await import("@x402/fetch"); + } catch { + throw new Error( + 'x402 WebSocket support requires @x402/fetch to be installed. Run: npm install @x402/fetch', + ); + } + + const httpClient = new x402Fetch.x402HTTPClient(x402Client as never); + const payClient = x402Client as { createPaymentPayload(req: unknown): Promise }; + + const httpUrl = wsUrl.replace(/^wss:\/\//, "https://").replace(/^ws:\/\//, "http://"); + + const response = await fetch(httpUrl); + if (response.status !== 402) { + const body = await response.text(); + throw new Error(`x402: expected 402 from ${httpUrl} but got ${response.status}: ${body || "(empty)"}`); + } + + let body: unknown; + try { + body = JSON.parse(await response.text()); + } catch { + body = undefined; + } + + const getHeader = (name: string): string | null => response.headers.get(name); + const paymentRequired = httpClient.getPaymentRequiredResponse(getHeader, body); + const paymentPayload = await payClient.createPaymentPayload(paymentRequired); + const headers = httpClient.encodePaymentSignatureHeader(paymentPayload); + + const paymentSignature = headers["PAYMENT-SIGNATURE"] ?? headers["payment-signature"]; + if (!paymentSignature) { + throw new Error("x402: encodePaymentSignatureHeader did not return a payment-signature"); + } + + return { "payment-signature": paymentSignature }; +}