UNPKG

run402-mcp

Version:

MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.

231 lines • 10.3 kB
/** * The client-owned HTTP dispatcher (vault-owned-dispatcher). * * Node's built-in fetch pool has the one behavior no prewarm can beat: a * request issued while a socket is still CONNECTING dials a second socket * instead of queueing, so the first real operation of every invocation pays * the full DNS+TCP+TLS premium no matter how early the prewarm fired * (measured: ~800 ms first-op wire on a no-op fetch whose server work is * ~33 ms). Owning the dispatcher fixes both halves: * * - the API origin is served by a SINGLE undici `Client` with `allowH2` * (api.run402.com's ALB negotiates HTTP/2): requests queue on the one * — possibly still connecting — socket and multiplex once it is up, so * the prewarm's dial IS the verb's dial; * - its connector persists the TLS session ticket under the profile config * dir, so the NEXT process resumes the session instead of performing a * full handshake. * * Every other origin (presigned S3/edge GETs) keeps a default `Pool` — * those are HTTP/1.1 and the bounded-concurrency pack fetches want real * parallel sockets. * * Version coherence (design D1): this module uses npm undici's OWN `fetch` * with an explicit dispatcher — never the cross-realm global-dispatcher * symbol, which would marry npm undici 7's dispatch contract to whatever * undici the running Node bundles (6.x on Node 22, the engines floor). * `globalThis.fetch` is untouched for everything that is not the SDK. * * Test seam (design D4): suites deliberately monkeypatch `globalThis.fetch` * and must keep seeing every request. `sdkFetch` captures the original * global fetch at module load and defers to the CURRENT global whenever it * is not that original; the owned dispatcher serves only the unpatched case. */ import { readFileSync, lstatSync, mkdirSync, writeFileSync, renameSync } from "node:fs"; import { join } from "node:path"; import { connect as tlsConnect } from "node:tls"; import { Agent, Client, Pool, fetch as undiciFetch } from "undici"; import { getApiBase, getConfigDir } from "../../core-dist/config.js"; /** Session tickets above this size are refused (sanity, not a protocol bound). */ const MAX_TICKET_BYTES = 16 * 1024; /** * Best-effort TLS session persistence for ONE origin (the API's). A missing, * stale, rejected, oversized, symlinked, or unreadable ticket degrades to a * full handshake; a failed write is silently dropped. Raw ticket bytes, * owner-only file, atomic rename — the keystore file discipline. */ class TicketStore { #path; #loaded = false; #ticket = null; constructor(path) { this.#path = path; } get() { if (!this.#loaded) { this.#loaded = true; try { const st = lstatSync(this.#path, { throwIfNoEntry: false }); if (st && st.isFile() && !st.isSymbolicLink() && st.size > 0 && st.size <= MAX_TICKET_BYTES) { this.#ticket = readFileSync(this.#path); } } catch { /* silent by contract */ } } return this.#ticket ?? undefined; } put(session) { if (session.length === 0 || session.length > MAX_TICKET_BYTES) return; this.#ticket = session; try { mkdirSync(join(this.#path, ".."), { recursive: true, mode: 0o700 }); const tmp = `${this.#path}.tmp-${process.pid}`; writeFileSync(tmp, session, { mode: 0o600, flag: "w" }); renameSync(tmp, this.#path); } catch { /* silent by contract */ } } } /** * A connector for the API origin only: offers the stored session for * resumption, captures the fresh ticket, negotiates h2 via ALPN. Shape and * error discipline mirror undici's buildConnector (callback exactly once). */ function apiConnector(store) { return ((options, callback) => { let done = false; const fin = (err, socket) => { if (done) return; done = true; callback(err, socket); }; try { apiDialCount += 1; if (process.env.RUN402_VAULT_TRACE === "1") { process.stderr.write(`vault-trace: dispatcher-dial #${apiDialCount} ${options.servername ?? options.hostname}\n`); } const socket = tlsConnect({ host: options.host ?? options.hostname, port: options.port ? Number(options.port) : 443, servername: options.servername || options.hostname, ALPNProtocols: ["h2", "http/1.1"], session: store.get(), }); socket.setNoDelay(true); socket.on("session", (session) => store.put(session)); socket.once("secureConnect", () => fin(null, socket)); socket.once("error", (err) => fin(err, null)); } catch (err) { fin(err, null); } }); } /** Test-only export of the store (the class itself is not public API). */ export { TicketStore as _TicketStoreForTests }; /** * Dial-vs-reused attribution (vault-first-op-premium task 1.1). Every * real TCP dial to the API origin increments this — `apiConnector` is the * ONLY place undici opens a fresh socket for that origin, so a count that * climbs faster than "once per process" proves a connection is dying and * being re-dialed rather than genuinely reused across daemon sessions. * Cheap (an integer increment) and always counted; the stderr line is * gated on `RUN402_VAULT_TRACE=1` like every other transport trace. */ let apiDialCount = 0; /** Test/diagnostic-only: the number of real dials to the API origin this process has made. */ export function _apiDialCount() { return apiDialCount; } /** Test-only: reset the dial counter alongside the dispatcher singleton. */ export function _resetApiDialCount() { apiDialCount = 0; } let agentSingleton = null; /** The owned dispatcher — created lazily so config/env resolution happens at first use, once per process. */ export function sdkDispatcher() { if (agentSingleton) return agentSingleton; let apiOrigin = null; let store = null; try { apiOrigin = new URL(getApiBase()).origin; store = new TicketStore(join(getConfigDir(), "tls-session-api.v1.bin")); } catch { /* no config — every origin gets the default Pool */ } agentSingleton = new Agent({ factory(origin, opts) { const o = typeof origin === "string" ? origin : origin.origin; if (apiOrigin && store && o === apiOrigin && o.startsWith("https:")) { // ONE connection: requests queue on the connecting socket (never a // second dial) and multiplex over h2 where the origin negotiates it. // Keep-alive sits just under the ALB's 60 s idle timeout — the // undici default (~4 s) silently re-dialed between a RESIDENT // daemon's sessions, observed as ~340 ms median first-op wire where // a genuinely warm socket serves at ~150 ms. return new Client(origin, { ...opts, allowH2: true, keepAliveTimeout: 55_000, keepAliveMaxTimeout: 55_000, connect: apiConnector(store) }); } return new Pool(origin, opts); }, }); return agentSingleton; } /** Test-only: drop the singleton so a suite can re-resolve config. */ export function _resetSdkDispatcher() { agentSingleton = null; } const ORIGINAL_GLOBAL_FETCH = globalThis.fetch; /** * A suite may install its fetch mock BEFORE this module first loads (a * dynamic-import graph behind a beforeEach), making the captured "original" * the mock itself — identity comparison alone would then route real network * traffic. Node's built-in fetch is named "fetch"; every in-repo mock is a * differently-named function. When the captured value does not look native, * identity proves nothing: always defer to the live global. */ const CAPTURED_LOOKS_NATIVE = typeof ORIGINAL_GLOBAL_FETCH === "function" && ORIGINAL_GLOBAL_FETCH.name === "fetch"; /** * npm undici's fetch does not recognize the GLOBAL `Request` class (Node's * bundled undici is a different instance), stringifying it to * "[object Request]" and failing URL parsing — and the x402 buyer path * constructs global Requests. Normalize a foreign Request-like input into * (url, init), buffering the body (SDK request bodies are small JSON; * nothing streams uploads through fetch — presigned PUTs carry bytes * explicitly). */ async function normalizedUndiciFetch(input, init) { let url; let mergedInit = init; if (typeof input === "object" && input !== null && !(input instanceof URL) && typeof input.url === "string") { const req = input; url = req.url; const method = (init?.method ?? req.method ?? "GET").toUpperCase(); const body = init?.body !== undefined ? init.body : method !== "GET" && method !== "HEAD" ? Buffer.from(await req.arrayBuffer()) : undefined; // No spread: Headers is not iterable under every entrypoint lib config. const headerPairs = []; req.headers.forEach((value, key) => headerPairs.push([key, value])); mergedInit = { method, headers: init?.headers ?? headerPairs, ...(body !== undefined ? { body } : {}), ...(init?.signal || req.signal ? { signal: init?.signal ?? req.signal } : {}), ...(init?.redirect || req.redirect ? { redirect: init?.redirect ?? req.redirect } : {}), }; } else { url = input; } return undiciFetch(url, { ...mergedInit, dispatcher: sdkDispatcher(), }); } /** * The SDK's transport fetch. Unpatched process → npm undici's fetch on the * owned dispatcher. A test that replaced `globalThis.fetch` sees every * request instead, exactly as before this change (design D4). */ export const sdkFetch = (input, init) => { if (!CAPTURED_LOOKS_NATIVE || globalThis.fetch !== ORIGINAL_GLOBAL_FETCH) return globalThis.fetch(input, init); return normalizedUndiciFetch(input, init); }; //# sourceMappingURL=http-dispatcher.js.map