109 lines
3.8 KiB
TypeScript
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 inter-app RPC: the
|
|
* gateway 404s private `/__wrnexus/rpc/<service>/<procedure>` routes. (The
|
|
* exact prefix remains the CSRF-protected browser server-function endpoint.)
|
|
* That block 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");
|
|
}
|
|
},
|
|
};
|
|
}
|