- 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>
74 lines
3.0 KiB
Markdown
74 lines
3.0 KiB
Markdown
# `@wrnexus/rpc`
|
|
|
|
Define a service contract in a shared workspace package, then import that same contract from the caller and callee.
|
|
|
|
```ts
|
|
import {
|
|
defineService,
|
|
implement,
|
|
inProcessTransport,
|
|
procedure,
|
|
serviceClient,
|
|
} from "@wrnexus/rpc";
|
|
import { v } from "@wrnexus/validation";
|
|
|
|
const greeter = defineService({
|
|
name: "greeter",
|
|
procedures: {
|
|
greet: procedure
|
|
.input(v.object({ name: v.string() }))
|
|
.output<{ message: string }>()
|
|
.build(),
|
|
},
|
|
});
|
|
|
|
const service = implement(
|
|
greeter,
|
|
{ greet: async ({ name }) => ({ message: `Hello, ${name}` }) },
|
|
{ selfApp: "greeter" },
|
|
);
|
|
|
|
const client = serviceClient(greeter, {
|
|
app: "greeter",
|
|
transport: inProcessTransport({
|
|
"greeter/greet": (input, identity) => service.invoke("greet", input, identity),
|
|
}),
|
|
});
|
|
await client.greet({ name: "Ada" });
|
|
```
|
|
|
|
Service files default-export `implement(...)` from `app/services`. The development server mounts them under the private `/__wrnexus/rpc` prefix.
|
|
|
|
Pass `{ as: ctx }` to `serviceClient` to propagate the subject. The signed token contains only subject and tenant identifiers; permissions are always checked by the callee. Set `WRNEXUS_RPC_SECRET` in every app, use at least 32 characters, and never reuse the session secret.
|
|
|
|
Calls time out by default. Retrying is intentionally deferred; when introduced, only procedures marked `.idempotent()` may be retried.
|
|
|
|
## Deployment requirement: apps must be unreachable except through the gateway
|
|
|
|
`/__wrnexus/rpc/*` is authenticated by TWO signals together: a marker header
|
|
(`x-wrnexus-internal: 1`) AND the absence of any `X-Forwarded-*` header. The
|
|
WrNexus gateway satisfies this by construction — it strips any inbound
|
|
marker header from the public request, and it always adds `X-Forwarded-*`
|
|
when proxying to an app. A direct loopback call from a sibling app process
|
|
carries the marker and no forwarded headers, so it passes; anything that
|
|
came through the gateway carries forwarded headers, so it's rejected even if
|
|
it also carries the marker.
|
|
|
|
**This check only works if the app process is unreachable except through the
|
|
gateway.** If an app's port is exposed directly, or if a reverse proxy sits
|
|
in front of it WITHOUT setting `X-Forwarded-*` (a bare `proxy_pass` with no
|
|
`proxy_set_header X-Forwarded-For`/`X-Forwarded-Host`/`X-Forwarded-Proto`),
|
|
then an external caller can set the marker header itself, arrive with no
|
|
forwarded headers, and reach `/__wrnexus/rpc/*` as if it were an internal
|
|
call — bypassing the gateway's edge block entirely.
|
|
|
|
Requirements for any deployment:
|
|
|
|
- App processes must bind to a private/loopback interface and be reachable
|
|
ONLY through the gateway (or an equivalent trusted front door) — never
|
|
exposed directly to the internet or an untrusted network.
|
|
- Any reverse proxy placed in front of an app (nginx, a load balancer, etc.)
|
|
MUST set `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto` on
|
|
every request it forwards. Omitting these silently reopens the private RPC
|
|
namespace to anyone who can reach the proxy.
|