Files
WRNexusJS/packages/rpc/src/http.ts
T
ClintchizandClaude Opus 5 3eec9fd8c6 fix(rpc): close the four final-review blockers on inter-app RPC
- Resolve RPC call origins via a new WRNEXUS_INTERNAL_ORIGINS map (loopback
  origins the gateway hands each child before spawning it), falling back to
  the public appOrigin only when it is absent. Calls previously always went
  to the public gateway origin, which the gateway unconditionally 404s on
  the RPC prefix by design — every real cross-app call failed.
- Stop loadServices() from running ahead of routing and stop memoizing a
  rejected load: one bad file under app/services/ no longer permanently
  breaks every route in the app. A failed load logs loudly, is retried on
  the next RPC request, and the RPC path gets a structured RPC_UNKNOWN
  instead of an unhandled throw.
- Reject a service whose contract.name does not match the filename it is
  mounted under, naming both, instead of silently mounting under the
  filename while the typed client calls by contract name.
- Let ServiceError accept an explicit retryable and have the client pass the
  wire value through, instead of recomputing (and silently flipping) it from
  the error code alone.
- Document the gateway/X-Forwarded-* deployment requirement in the RPC
  README.

Each of the three code blockers has a new/extended test that was verified to
fail when its fix was reverted (rpc/test/integration.test.ts,
dev-server/test/rpc-services-loading.test.ts).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 20:36:31 +05:30

109 lines
3.8 KiB
TypeScript

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<string, string> {
if (!value) return {};
try {
const parsed: unknown = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, string>;
} 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<string, unknown>;
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<string, string> = {
"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");
}
},
};
}