| import "dotenv/config"; |
| import express from "express"; |
| import type { NextFunction, Request, Response } from "express"; |
| import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; |
| import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; |
| import { paymentMiddlewareFromConfig } from "@x402/express"; |
| import { ExactEvmScheme } from "@x402/evm/exact/server"; |
| import type { Address } from "viem"; |
| import { getAddress } from "viem"; |
| import { |
| ARC_NETWORK, |
| ARC_PAYMENT_PRICE, |
| PRICE, |
| REFUND_AMOUNT_USDC, |
| createArcFacilitator, |
| getFacilitatorPrivateKey, |
| } from "./arc.js"; |
| import { captureUrl, closeBrowser } from "./captureEngine.js"; |
| import { createEyezMcpServer } from "./mcpTools.js"; |
| import { isFailedCapture, sendRefund } from "./refund.js"; |
|
|
| type DecodedUrlRequest = Request & { decodedUrl?: string }; |
|
|
| const PORT = Number(process.env.PORT || 3001); |
| const NETWORK = ARC_NETWORK; |
| const PAY_TO = process.env.PAY_TO ? getAddress(process.env.PAY_TO) : null; |
| const FACILITATOR_PRIVATE_KEY = getFacilitatorPrivateKey(); |
|
|
| if (!PAY_TO) { |
| console.error("ERROR: PAY_TO not set in .env"); |
| process.exit(1); |
| } |
|
|
| if (!FACILITATOR_PRIVATE_KEY) { |
| console.error("ERROR: FACILITATOR_PRIVATE_KEY not set in .env"); |
| process.exit(1); |
| } |
|
|
| function getErrorMessage(error: unknown): string { |
| return error instanceof Error ? error.message : String(error); |
| } |
|
|
| function isAllowedUrl(urlStr: string): boolean { |
| try { |
| const parsed = new URL(urlStr); |
| if (!["http:", "https:"].includes(parsed.protocol)) return false; |
| const blocked = ["localhost", "127.0.0.1", "0.0.0.0", "[::1]"]; |
| if (blocked.includes(parsed.hostname)) return false; |
| if (parsed.hostname.startsWith("[")) return false; |
| const parts = parsed.hostname.split("."); |
| const firstOctet = Number(parts[0]); |
| const secondOctet = Number(parts[1]); |
| if (firstOctet === 10) return false; |
| if (firstOctet === 172 && secondOctet >= 16 && secondOctet <= 31) { |
| return false; |
| } |
| if (firstOctet === 192 && secondOctet === 168) return false; |
| if (firstOctet === 169 && secondOctet === 254) return false; |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| function getPayerAddress(req: Request): Address | null { |
| try { |
| const header = req.get("payment-signature") || req.get("x-payment"); |
| if (!header) return null; |
|
|
| const normalized = header |
| .replace(/-/g, "+") |
| .replace(/_/g, "/") |
| .padEnd(Math.ceil(header.length / 4) * 4, "="); |
| const decoded = JSON.parse(Buffer.from(normalized, "base64").toString()); |
| const payload = decoded?.payload || decoded?.paymentPayload?.payload; |
| const payer = |
| payload?.authorization?.from || payload?.permit2Authorization?.from; |
|
|
| return payer ? getAddress(payer) : null; |
| } catch { |
| return null; |
| } |
| } |
|
|
| const app = express(); |
| const facilitator = createArcFacilitator(FACILITATOR_PRIVATE_KEY); |
|
|
| |
| app.get("/", (_: Request, res: Response) => |
| res.json({ |
| service: "eyez", |
| description: "Pay per capture headless browser API on Arc x402", |
| price: PRICE, |
| network: NETWORK, |
| usage: "GET /capture?url=<encoded_url>", |
| mcp: "POST /mcp", |
| }), |
| ); |
|
|
| |
| app.get("/health", (_: Request, res: Response) => res.json({ status: "ok" })); |
|
|
| app.use("/mcp", express.json({ limit: "1mb" })); |
| app.all("/mcp", async (req: Request, res: Response) => { |
| const mcpServer = createEyezMcpServer(); |
| const transport = new StreamableHTTPServerTransport({ |
| sessionIdGenerator: undefined, |
| } as unknown as ConstructorParameters<typeof StreamableHTTPServerTransport>[0]); |
|
|
| res.on("close", () => { |
| void transport.close(); |
| }); |
|
|
| try { |
| await mcpServer.connect(transport as unknown as Transport); |
| await transport.handleRequest(req, res, req.body); |
| } catch (err) { |
| const message = getErrorMessage(err); |
| console.error("MCP request failed:", message); |
| if (!res.headersSent) { |
| res.status(500).json({ |
| jsonrpc: "2.0", |
| error: { code: -32603, message: "Internal server error" }, |
| id: null, |
| }); |
| } |
| } |
| }); |
|
|
| |
| app.use("/capture", (req: Request, res: Response, next: NextFunction) => { |
| const url = req.query.url; |
| if (typeof url !== "string") { |
| return res.status(400).json({ error: "Missing ?url= parameter" }); |
| } |
| let decoded: string; |
| try { |
| decoded = decodeURIComponent(url); |
| } catch { |
| return res.status(400).json({ error: "Malformed URL encoding" }); |
| } |
| if (!isAllowedUrl(decoded)) { |
| return res |
| .status(400) |
| .json({ error: "URL not allowed — only public http/https URLs" }); |
| } |
| (req as DecodedUrlRequest).decodedUrl = decoded; |
| next(); |
| }); |
|
|
| |
| app.use( |
| paymentMiddlewareFromConfig( |
| { |
| "GET /capture": { |
| accepts: { |
| scheme: "exact", |
| price: ARC_PAYMENT_PRICE, |
| network: NETWORK, |
| payTo: PAY_TO, |
| maxTimeoutSeconds: 60, |
| }, |
| description: "Capture a JS-heavy webpage and return extracted content", |
| }, |
| }, |
| facilitator, |
| [{ network: NETWORK, server: new ExactEvmScheme() }], |
| ), |
| ); |
|
|
| |
| app.get("/capture", async (req: Request, res: Response) => { |
| const decoded = (req as DecodedUrlRequest).decodedUrl; |
| if (!decoded) { |
| return res.status(400).json({ error: "Missing ?url= parameter" }); |
| } |
|
|
| try { |
| console.log(`Capturing: ${decoded}`); |
| const start = Date.now(); |
| const result = await captureUrl(decoded); |
| const elapsed = Date.now() - start; |
|
|
| const failReason = isFailedCapture(result.content, result.title); |
| if (failReason) { |
| const payerAddress = getPayerAddress(req); |
| let refund = null; |
| if (payerAddress) { |
| console.log( |
| `Bad capture (${failReason}) for ${decoded} - refunding ${payerAddress}`, |
| ); |
| const refundHash = await sendRefund( |
| payerAddress, |
| REFUND_AMOUNT_USDC, |
| `refund:${failReason}`, |
| ); |
| refund = refundHash |
| ? { |
| transaction: refundHash, |
| amount: `${REFUND_AMOUNT_USDC} USDC`, |
| reason: failReason, |
| } |
| : { error: "Refund failed - contact support", reason: failReason }; |
| } |
|
|
| return res.json({ |
| ...result, |
| captureTimeMs: elapsed, |
| payment: { price: PRICE, network: NETWORK }, |
| refund, |
| }); |
| } |
|
|
| res.json({ |
| ...result, |
| captureTimeMs: elapsed, |
| payment: { price: PRICE, network: NETWORK }, |
| }); |
| } catch (err) { |
| const message = getErrorMessage(err); |
| console.error(`Capture failed for ${decoded}:`, message); |
| if (message.includes("Too many concurrent")) { |
| return res.status(503).json({ error: message }); |
| } |
| |
| const payerAddress = getPayerAddress(req); |
| let refund = null; |
| if (payerAddress) { |
| const refundHash = await sendRefund( |
| payerAddress, |
| REFUND_AMOUNT_USDC, |
| "refund:capture_crash", |
| ); |
| refund = refundHash |
| ? { |
| transaction: refundHash, |
| amount: `${REFUND_AMOUNT_USDC} USDC`, |
| reason: "capture_crash", |
| } |
| : { error: "Refund failed - contact support" }; |
| } |
| res.status(500).json({ error: "Capture failed", message, refund }); |
| } |
| }); |
|
|
| const server = app.listen(PORT, () => { |
| console.log(`eyez listening on http://localhost:${PORT}`); |
| console.log(` Pay ${PRICE} USDC on Arc Testnet (${NETWORK}) per capture`); |
| console.log(` Payments go to ${PAY_TO}`); |
| }); |
|
|
| for (const sig of ["SIGTERM", "SIGINT"] satisfies NodeJS.Signals[]) { |
| process.on(sig, async () => { |
| console.log(`${sig} received, shutting down...`); |
| server.close(); |
| await closeBrowser(); |
| process.exit(0); |
| }); |
| } |
|
|