import { appOrigin } from "@wrnexus/helpers"; import { RPC_ERROR_CODES, failure, isRetryableStatus } from "./errors.ts"; import { RPC_IDENTITY_HEADER } from "./identity.ts"; import type { CallOptions, RpcTarget, Transport } from "./transport.ts"; import type { ServiceResult } from "./types.ts"; export const RPC_PATH_PREFIX = "/__wrnexus/rpc"; export const RPC_INTERNAL_HEADER = "x-wrnexus-internal"; export function rpcPath(service: string, procedure: string): string { return `${RPC_PATH_PREFIX}/${service}/${procedure}`; } function parseOriginMap(value: string | undefined): Record { if (!value) return {}; try { const parsed: unknown = JSON.parse(value); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; return parsed as Record; } catch { return {}; } } /** * Resolve the origin an RPC call to `app` should target. * * Prefer `WRNEXUS_INTERNAL_ORIGINS` (loopback origins the gateway hands each * child before spawning it) over `appOrigin`, which resolves the app's * PUBLIC origin. The public origin is the wrong target for RPC: the gateway * unconditionally 404s the reserved `/__wrnexus/rpc` prefix on anything that * arrives at a public origin — that block is the whole point, it is what * keeps inter-app calls off the public internet. Falling back to `appOrigin` * when no internal-origin map is present keeps single-app and test setups * (which only set `WRNEXUS_WORKSPACE_ORIGINS`) working. */ export function resolveAppOrigin(app: string): string { const internalOrigin = parseOriginMap(process.env.WRNEXUS_INTERNAL_ORIGINS)[app]; if (internalOrigin) { try { return new URL(internalOrigin).origin; } catch { // Malformed internal-origin entry — fall through to the public origin. } } return appOrigin(app); } export interface HttpTransportOptions { resolveOrigin?: (app: string) => string; fetch?: typeof fetch; } function isServiceResult(value: unknown): value is ServiceResult { if (!value || typeof value !== "object" || !("ok" in value)) return false; const result = value as Record; return ( result.ok === true || (result.ok === false && typeof result.code === "string" && typeof result.message === "string" && typeof result.retryable === "boolean") ); } export function httpTransport(options: HttpTransportOptions = {}): Transport { const resolveOrigin = options.resolveOrigin ?? resolveAppOrigin; const doFetch = options.fetch ?? fetch; return { async call(target: RpcTarget, payload: unknown, callOptions: CallOptions) { let response: Response; try { const headers: Record = { "content-type": "application/json", [RPC_INTERNAL_HEADER]: "1", }; if (callOptions.identity) headers[RPC_IDENTITY_HEADER] = callOptions.identity; response = await doFetch( `${resolveOrigin(target.app)}${rpcPath(target.service, target.procedure)}`, { method: "POST", headers, body: JSON.stringify(payload ?? {}), signal: callOptions.signal, }, ); } catch { return failure(RPC_ERROR_CODES.transport, "Service unreachable"); } if (!response.ok) { return { ok: false, code: RPC_ERROR_CODES.transport, message: `Service returned ${response.status}`, retryable: isRetryableStatus(response.status), }; } try { const result: unknown = await response.json(); return isServiceResult(result) ? result : failure(RPC_ERROR_CODES.malformed, "Malformed service response"); } catch { return failure(RPC_ERROR_CODES.malformed, "Malformed service response"); } }, }; }