@zudojs/rpc 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +123 -12
- package/dist/index.d.ts +1 -1
- package/dist/rpc/client/rpcClient.core.js +20 -3
- package/dist/rpc/context/index.d.ts +5 -1
- package/dist/rpc/context/index.js +4 -0
- package/dist/rpc/context/rpcContext.type.d.ts +40 -1
- package/dist/rpc/context/rpcContext.type.js +17 -2
- package/dist/rpc/dispatcher/rpcDispatcher.core.d.ts +6 -1
- package/dist/rpc/dispatcher/rpcDispatcher.core.js +12 -3
- package/dist/rpc/server/rpcServer.core.d.ts +7 -1
- package/dist/rpc/server/rpcServer.core.js +51 -9
- package/dist/rpc/validation/rpcValidation.core.d.ts +7 -2
- package/dist/rpc/validation/rpcValidation.core.js +12 -4
- package/package.json +9 -5
package/README.md
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# @zudojs/rpc
|
|
2
2
|
|
|
3
|
-
Type-safe RPC — define procedures, apply middleware, dispatch calls, and serve over
|
|
3
|
+
Type-safe RPC — define procedures, apply middleware, dispatch calls, and serve them over your own transport.
|
|
4
|
+
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-rpc](https://zudojs.oyinlola.site/docs/packages-rpc) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-rpc.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
4
10
|
|
|
5
11
|
## When to use
|
|
6
12
|
|
|
@@ -8,9 +14,14 @@ Import this when you need:
|
|
|
8
14
|
|
|
9
15
|
- a typed RPC layer between services (gateway ↔ microservice, frontend ↔ backend)
|
|
10
16
|
- procedure-level middleware (auth, tracing, rate limit)
|
|
11
|
-
- a
|
|
17
|
+
- a server that validates frames and maps every failure to a wire code
|
|
12
18
|
- structured RPC errors
|
|
13
19
|
|
|
20
|
+
The package is transport-agnostic: `RPCServer.handle` takes a request frame
|
|
21
|
+
and returns a response frame, and `RPCClient` sends through any object
|
|
22
|
+
implementing `RPCTransport`. It ships no HTTP or WebSocket transport of its
|
|
23
|
+
own.
|
|
24
|
+
|
|
14
25
|
For request/response inside one process, prefer `@zudojs/api`.
|
|
15
26
|
|
|
16
27
|
## Installation
|
|
@@ -24,8 +35,11 @@ npm install @zudojs/rpc
|
|
|
24
35
|
```typescript
|
|
25
36
|
import {
|
|
26
37
|
createRPCProcedure,
|
|
27
|
-
|
|
38
|
+
createRPCRequest,
|
|
28
39
|
RPCServer,
|
|
40
|
+
RPCClient,
|
|
41
|
+
RPCDispatcher,
|
|
42
|
+
RPCProcedureRegistry,
|
|
29
43
|
RPCMiddlewareStack,
|
|
30
44
|
createRPCContext,
|
|
31
45
|
type RPCContext,
|
|
@@ -33,24 +47,121 @@ import {
|
|
|
33
47
|
type RPCProcedure,
|
|
34
48
|
type RPCRequest,
|
|
35
49
|
type RPCResponse,
|
|
50
|
+
type RPCTransport,
|
|
36
51
|
type RPCErrorOptions,
|
|
37
52
|
} from "@zudojs/rpc";
|
|
38
53
|
```
|
|
39
54
|
|
|
40
55
|
## Usage
|
|
41
56
|
|
|
57
|
+
A procedure is `createRPCProcedure(name, handler, options?)`. Names are
|
|
58
|
+
dot-separated identifiers (`"math.sum"`); the handler is positional,
|
|
59
|
+
`(input, context)`; `options.input` / `options.output` are `@zudojs/schema`
|
|
60
|
+
schemas (anything with `safeParse`) the payload and result are checked
|
|
61
|
+
against.
|
|
62
|
+
|
|
42
63
|
```typescript
|
|
43
|
-
import { createRPCProcedure,
|
|
64
|
+
import { createRPCProcedure, createRPCRequest, RPCServer } from "@zudojs/rpc";
|
|
65
|
+
import { schema } from "@zudojs/schema";
|
|
44
66
|
|
|
45
|
-
const sum = createRPCProcedure(
|
|
46
|
-
|
|
47
|
-
input: { a:
|
|
48
|
-
|
|
49
|
-
|
|
67
|
+
const sum = createRPCProcedure(
|
|
68
|
+
"math.sum",
|
|
69
|
+
async (input: { a: number; b: number }) => input.a + input.b,
|
|
70
|
+
{ input: schema.object({ a: schema.number(), b: schema.number() }) },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const server = new RPCServer();
|
|
74
|
+
server.register(sum);
|
|
75
|
+
|
|
76
|
+
const response = await server.handle(
|
|
77
|
+
createRPCRequest({ id: "req-1", procedure: "math.sum", payload: { a: 1, b: 2 } }),
|
|
78
|
+
);
|
|
79
|
+
// { id: "req-1", success: true, result: 3 }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`handle` never throws for a bad request: a malformed frame, an unknown
|
|
83
|
+
procedure, a payload the input schema rejects, a timeout or a handler error
|
|
84
|
+
each come back as `{ success: false, error: { code, message, details? } }`.
|
|
85
|
+
The frame's `metadata` is optional.
|
|
86
|
+
|
|
87
|
+
### Calling through a transport
|
|
88
|
+
|
|
89
|
+
`RPCClient` needs an `RPCTransport` — an object whose `send(request, options)`
|
|
90
|
+
delivers the frame and resolves with the response. `options.signal` aborts the
|
|
91
|
+
call and `options.timeout` is the deadline in milliseconds, so a transport can
|
|
92
|
+
set its own socket timeout. The in-process transport below is the smallest
|
|
93
|
+
possible one:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
import { RPCClient, type RPCTransport } from "@zudojs/rpc";
|
|
97
|
+
|
|
98
|
+
const transport: RPCTransport = { send: (request) => server.handle(request) };
|
|
99
|
+
const client = new RPCClient(transport, { timeout: 5_000 });
|
|
50
100
|
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
101
|
+
const total = await client.call<{ a: number; b: number }, number>("math.sum", { a: 1, b: 2 });
|
|
102
|
+
// 3
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
A failed call rejects with a typed error rebuilt from the wire code
|
|
106
|
+
(`RPCTimeoutError`, `RPCCancelledError`, `RPCUnavailableError`, or an
|
|
107
|
+
`RPCError` carrying the server's `code` and `details`).
|
|
108
|
+
|
|
109
|
+
### Middleware and trusted identity
|
|
110
|
+
|
|
111
|
+
Everything in a frame, `metadata` included, is written by the caller: any
|
|
112
|
+
client can send `metadata: { userId: "admin" }`. Never authorise on it.
|
|
113
|
+
Identity your transport has verified (a checked bearer token, an mTLS peer,
|
|
114
|
+
a server-side session) goes in the second argument of `handle`, and reaches
|
|
115
|
+
middleware and handlers as the frozen `context.auth`:
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { RPCMiddlewareStack, RPCAuthenticationError } from "@zudojs/rpc";
|
|
119
|
+
|
|
120
|
+
const stack = new RPCMiddlewareStack([
|
|
121
|
+
async (context, next) => {
|
|
122
|
+
if (typeof context.auth?.userId !== "string") {
|
|
123
|
+
throw new RPCAuthenticationError("Sign in first.");
|
|
124
|
+
}
|
|
125
|
+
context.set("actor", context.auth.userId);
|
|
126
|
+
return next();
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
const server = new RPCServer(undefined, stack);
|
|
131
|
+
|
|
132
|
+
// In the transport, after verifying the caller's credentials yourself:
|
|
133
|
+
await server.handle(frame, { auth: { userId: verifiedUserId } });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Each middleware may call `next()` once. Input validation runs before the
|
|
137
|
+
stack. `context.input` holds the payload as the procedure's schema parsed
|
|
138
|
+
it (unknown keys stripped, defaults applied, values coerced), so authorise
|
|
139
|
+
on `context.input`, not on `context.request.payload`, which stays the raw
|
|
140
|
+
frame value.
|
|
141
|
+
|
|
142
|
+
## Errors
|
|
143
|
+
|
|
144
|
+
Every error class from `@zudojs/errors`' RPC family is re-exported. The
|
|
145
|
+
server maps them to wire codes (`RPC_PROCEDURE_NOT_FOUND`,
|
|
146
|
+
`RPC_VALIDATION_ERROR`, `RPC_UNAUTHENTICATED`, `RPC_FORBIDDEN`,
|
|
147
|
+
`RPC_RATE_LIMITED`, `RPC_TIMEOUT`, …). A custom `RPCError` subclass keeps its
|
|
148
|
+
own `code`.
|
|
149
|
+
|
|
150
|
+
What reaches the caller follows the error's `expose` flag. Anything thrown
|
|
151
|
+
with `expose: false` — an `RPCInternalError`, an `RPCSerializationError`, a
|
|
152
|
+
plain `new RPCError(...)` (whose default is `expose: false`), or any
|
|
153
|
+
non-RPC error — is answered with the fixed `INTERNAL_ERROR_MESSAGE`; the
|
|
154
|
+
original error is handed to `onInternalError(error, requestId)` so it can be
|
|
155
|
+
logged against the request id. A handler result that fails the procedure's
|
|
156
|
+
`output` schema is treated the same way: it is the server's fault, not the
|
|
157
|
+
caller's.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
const server = new RPCServer(undefined, undefined, {
|
|
161
|
+
limits: { maxPayloadBytes: 256 * 1024 },
|
|
162
|
+
dispatch: { defaultTimeout: 10_000 },
|
|
163
|
+
onInternalError: (error, requestId) => logger.error({ requestId, error }),
|
|
164
|
+
});
|
|
54
165
|
```
|
|
55
166
|
|
|
56
167
|
## License
|
package/dist/index.d.ts
CHANGED
|
@@ -29,7 +29,7 @@ export { RPCError, RPCProcedureNotFoundError, RPCInvalidRequestError, RPCValidat
|
|
|
29
29
|
export type { RPCHandler, RPCProcedure, RPCProcedureOptions, } from "./rpc/procedure/index.js";
|
|
30
30
|
export { createRPCProcedure } from "./rpc/procedure/index.js";
|
|
31
31
|
export { RPCProcedureRegistry, RPCProcedureRouter, } from "./rpc/procedure/index.js";
|
|
32
|
-
export type { RPCContext } from "./rpc/context/index.js";
|
|
32
|
+
export type { RPCAuthContext, RPCContext, RPCContextOptions, } from "./rpc/context/index.js";
|
|
33
33
|
export { createRPCContext } from "./rpc/context/index.js";
|
|
34
34
|
export type { RPCMiddleware } from "./rpc/middleware/index.js";
|
|
35
35
|
export { RPCMiddlewareStack } from "./rpc/middleware/index.js";
|
|
@@ -64,8 +64,14 @@ export class RPCClient {
|
|
|
64
64
|
payload: input,
|
|
65
65
|
metadata: options.metadata,
|
|
66
66
|
});
|
|
67
|
+
// The transport is told the deadline as well as given the signal:
|
|
68
|
+
// `RPCTransportRequestOptions.timeout` exists so a transport can set
|
|
69
|
+
// its own socket or request timeout, and it was never populated.
|
|
67
70
|
const races = [
|
|
68
|
-
this.transport.send(request, {
|
|
71
|
+
this.transport.send(request, {
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
...(timeoutMs > 0 ? { timeout: timeoutMs } : {}),
|
|
74
|
+
}),
|
|
69
75
|
this.abortPromise(controller.signal, procedure),
|
|
70
76
|
];
|
|
71
77
|
if (timeout) {
|
|
@@ -170,8 +176,19 @@ export class RPCClient {
|
|
|
170
176
|
const message = response.error?.message ?? "RPC call failed.";
|
|
171
177
|
const code = response.error?.code;
|
|
172
178
|
switch (code) {
|
|
173
|
-
case "RPC_TIMEOUT":
|
|
174
|
-
|
|
179
|
+
case "RPC_TIMEOUT": {
|
|
180
|
+
// The constructor derives its message from a duration the wire
|
|
181
|
+
// does not carry; keep the type and restore the server's message
|
|
182
|
+
// rather than reporting "timed out after 0ms".
|
|
183
|
+
const timeout = new RPCTimeoutError(0, procedure);
|
|
184
|
+
Object.defineProperty(timeout, "message", {
|
|
185
|
+
value: message,
|
|
186
|
+
enumerable: false,
|
|
187
|
+
configurable: true,
|
|
188
|
+
writable: true,
|
|
189
|
+
});
|
|
190
|
+
return timeout;
|
|
191
|
+
}
|
|
175
192
|
case "RPC_CANCELLED":
|
|
176
193
|
return new RPCCancelledError(message, procedure);
|
|
177
194
|
case "RPC_UNAVAILABLE":
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* RPC execution context: per-call state, the caller's (untrusted) frame
|
|
3
|
+
* metadata, the transport's trusted `auth`, and the validated `input`.
|
|
4
|
+
*/
|
|
5
|
+
export type { RPCAuthContext, RPCContext, RPCContextOptions, } from "./rpcContext.type.js";
|
|
2
6
|
export { createRPCContext } from "./rpcContext.type.js";
|
|
3
7
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import type { RPCMetadata } from "../types/rpcMetadata.type.js";
|
|
2
2
|
import type { RPCRequest } from "../types/rpcRequest.type.js";
|
|
3
|
+
/**
|
|
4
|
+
* Identity and other facts established by the transport (a verified
|
|
5
|
+
* bearer token, an mTLS peer, a session looked up server-side).
|
|
6
|
+
*
|
|
7
|
+
* Unlike frame `metadata`, which the caller writes, this is supplied by
|
|
8
|
+
* the server's own code through `RPCServer.handle(request, { auth })`, so
|
|
9
|
+
* it is the only context field safe to authorise on.
|
|
10
|
+
*/
|
|
11
|
+
export type RPCAuthContext = Readonly<Record<string, unknown>>;
|
|
12
|
+
/**
|
|
13
|
+
* Extra, server-supplied values for {@link createRPCContext}.
|
|
14
|
+
*/
|
|
15
|
+
export interface RPCContextOptions {
|
|
16
|
+
/** Trusted, transport-derived identity. See {@link RPCAuthContext}. */
|
|
17
|
+
readonly auth?: RPCAuthContext;
|
|
18
|
+
}
|
|
3
19
|
/**
|
|
4
20
|
* Context passed through the RPC execution pipeline.
|
|
5
21
|
*/
|
|
6
22
|
export interface RPCContext {
|
|
7
23
|
readonly request: RPCRequest;
|
|
24
|
+
/**
|
|
25
|
+
* Frame metadata exactly as the caller sent it. Untrusted: any client
|
|
26
|
+
* can set `userId`, `tenantId` or any other key. Authorise on
|
|
27
|
+
* {@link RPCContext.auth} instead.
|
|
28
|
+
*/
|
|
8
29
|
readonly metadata: RPCMetadata;
|
|
30
|
+
/**
|
|
31
|
+
* Trusted identity supplied by the transport through
|
|
32
|
+
* `RPCServer.handle(request, { auth })`; `undefined` when none was given.
|
|
33
|
+
*/
|
|
34
|
+
readonly auth: RPCAuthContext | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The payload after the procedure's input schema has parsed it
|
|
37
|
+
* (stripped, defaulted, coerced), or the raw payload when the procedure
|
|
38
|
+
* declares no input schema. `undefined` until validation has run, i.e.
|
|
39
|
+
* in an interceptor before it calls `next()`. `request.payload` always
|
|
40
|
+
* stays the raw, unvalidated value.
|
|
41
|
+
*/
|
|
42
|
+
readonly input: unknown;
|
|
9
43
|
readonly signal: AbortSignal;
|
|
10
44
|
readonly state: Map<string, unknown>;
|
|
11
45
|
get<T>(key: string): T | undefined;
|
|
12
46
|
set<T>(key: string, value: T): void;
|
|
13
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Records the validated input on a context. Called by the dispatcher
|
|
50
|
+
* once, after input validation and before the middleware stack runs.
|
|
51
|
+
*/
|
|
52
|
+
export declare function bindRPCContextInput(context: RPCContext, input: unknown): void;
|
|
14
53
|
/**
|
|
15
54
|
* Creates a new RPC context.
|
|
16
55
|
*/
|
|
17
|
-
export declare function createRPCContext(request: RPCRequest, signal: AbortSignal): RPCContext;
|
|
56
|
+
export declare function createRPCContext(request: RPCRequest, signal: AbortSignal, options?: RPCContextOptions): RPCContext;
|
|
18
57
|
//# sourceMappingURL=rpcContext.type.d.ts.map
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
const inputs = new WeakMap();
|
|
2
|
+
/**
|
|
3
|
+
* Records the validated input on a context. Called by the dispatcher
|
|
4
|
+
* once, after input validation and before the middleware stack runs.
|
|
5
|
+
*/
|
|
6
|
+
export function bindRPCContextInput(context, input) {
|
|
7
|
+
inputs.set(context, input);
|
|
8
|
+
}
|
|
1
9
|
/**
|
|
2
10
|
* Creates a new RPC context.
|
|
3
11
|
*/
|
|
4
|
-
export function createRPCContext(request, signal) {
|
|
12
|
+
export function createRPCContext(request, signal, options = {}) {
|
|
5
13
|
const state = new Map();
|
|
14
|
+
const auth = options.auth === undefined ? undefined : Object.freeze({ ...options.auth });
|
|
6
15
|
const context = {
|
|
7
16
|
request,
|
|
8
|
-
metadata
|
|
17
|
+
// A frame decoded from JSON may omit `metadata`; middleware reads
|
|
18
|
+
// `context.metadata.userId` and the like without guarding.
|
|
19
|
+
metadata: request.metadata ?? {},
|
|
20
|
+
auth,
|
|
21
|
+
get input() {
|
|
22
|
+
return inputs.get(context);
|
|
23
|
+
},
|
|
9
24
|
signal,
|
|
10
25
|
state,
|
|
11
26
|
get(key) {
|
|
@@ -3,6 +3,7 @@ import type { RPCResponse } from "../types/rpcResponse.type.js";
|
|
|
3
3
|
import type { RPCProcedure } from "../procedure/rpcProcedure.type.js";
|
|
4
4
|
import type { RPCMiddlewareStack } from "../middleware/rpcMiddleware.core.js";
|
|
5
5
|
import type { RPCInterceptor } from "../interceptor/rpcInterceptor.type.js";
|
|
6
|
+
import type { RPCContextOptions } from "../context/rpcContext.type.js";
|
|
6
7
|
/**
|
|
7
8
|
* Options controlling dispatch.
|
|
8
9
|
*/
|
|
@@ -46,8 +47,12 @@ export declare class RPCDispatcher {
|
|
|
46
47
|
}, middleware: RPCMiddlewareStack, options?: RPCDispatcherOptions);
|
|
47
48
|
/**
|
|
48
49
|
* Dispatches an RPC request.
|
|
50
|
+
*
|
|
51
|
+
* `trusted` carries server-supplied context (`auth`) that the caller
|
|
52
|
+
* cannot forge; it is exposed to middleware and handlers as
|
|
53
|
+
* `context.auth`.
|
|
49
54
|
*/
|
|
50
|
-
dispatch(
|
|
55
|
+
dispatch(input: RPCRequest, trusted?: RPCContextOptions): Promise<RPCResponse>;
|
|
51
56
|
/**
|
|
52
57
|
* Runs the interceptor chain around the dispatch.
|
|
53
58
|
*
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createRPCContext } from "../context/rpcContext.type.js";
|
|
1
|
+
import { bindRPCContextInput, createRPCContext, } from "../context/rpcContext.type.js";
|
|
2
2
|
import { createRPCResponse } from "../types/rpcResponse.type.js";
|
|
3
3
|
import { RPCCancelledError } from "../errors/rpc.errors.js";
|
|
4
4
|
import { DEFAULT_RPC_TIMEOUT } from "../constants/rpcConstants.core.js";
|
|
@@ -26,11 +26,19 @@ export class RPCDispatcher {
|
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Dispatches an RPC request.
|
|
29
|
+
*
|
|
30
|
+
* `trusted` carries server-supplied context (`auth`) that the caller
|
|
31
|
+
* cannot forge; it is exposed to middleware and handlers as
|
|
32
|
+
* `context.auth`.
|
|
29
33
|
*/
|
|
30
|
-
async dispatch(
|
|
34
|
+
async dispatch(input, trusted = {}) {
|
|
35
|
+
// Tolerate a frame without `metadata`: the field is optional when a
|
|
36
|
+
// request is built by hand or decoded from JSON, and everything below
|
|
37
|
+
// — deadline reading, the context's `metadata` — reads it as an object.
|
|
38
|
+
const request = input.metadata === undefined ? { ...input, metadata: {} } : input;
|
|
31
39
|
const procedure = this.registry.require(request.procedure);
|
|
32
40
|
const controller = new AbortController();
|
|
33
|
-
const context = createRPCContext(request, controller.signal);
|
|
41
|
+
const context = createRPCContext(request, controller.signal, trusted);
|
|
34
42
|
const timeoutMs = this.resolveTimeout(request, procedure);
|
|
35
43
|
// A deadline already in the past is rejected before any work runs.
|
|
36
44
|
if (this.options.honourDeadline ?? true) {
|
|
@@ -45,6 +53,7 @@ export class RPCDispatcher {
|
|
|
45
53
|
const input = procedure.options?.input
|
|
46
54
|
? parseInput(procedure.options.input, request.payload, request.procedure)
|
|
47
55
|
: request.payload;
|
|
56
|
+
bindRPCContextInput(context, input);
|
|
48
57
|
const result = await this.middleware.execute(context, async () => {
|
|
49
58
|
return procedure.handler(input, context);
|
|
50
59
|
});
|
|
@@ -3,6 +3,7 @@ import type { RPCResponse } from "../types/rpcResponse.type.js";
|
|
|
3
3
|
import type { RPCProcedure } from "../procedure/rpcProcedure.type.js";
|
|
4
4
|
import { RPCProcedureRegistry } from "../procedure/rpcProcedureRegistry.core.js";
|
|
5
5
|
import { RPCMiddlewareStack } from "../middleware/rpcMiddleware.core.js";
|
|
6
|
+
import type { RPCContextOptions } from "../context/rpcContext.type.js";
|
|
6
7
|
import type { RPCDispatcherOptions } from "../dispatcher/rpcDispatcher.core.js";
|
|
7
8
|
import type { RPCRequestLimits } from "../validation/rpcValidation.core.js";
|
|
8
9
|
/**
|
|
@@ -49,8 +50,13 @@ export declare class RPCServer {
|
|
|
49
50
|
* `onInternalError` and answered with a fixed message: internal
|
|
50
51
|
* exception text can name hosts, paths, credentials or queries, and
|
|
51
52
|
* the caller is an untrusted peer.
|
|
53
|
+
*
|
|
54
|
+
* Everything in `request` comes from that peer, including
|
|
55
|
+
* `request.metadata.userId`. Identity the transport has verified goes
|
|
56
|
+
* in `trusted.auth` and reaches middleware and handlers as
|
|
57
|
+
* `context.auth`; authorise on that, never on frame metadata.
|
|
52
58
|
*/
|
|
53
|
-
handle(request: RPCRequest): Promise<RPCResponse>;
|
|
59
|
+
handle(request: RPCRequest, trusted?: RPCContextOptions): Promise<RPCResponse>;
|
|
54
60
|
/**
|
|
55
61
|
* Returns the procedure registry.
|
|
56
62
|
*/
|
|
@@ -2,7 +2,7 @@ import { RPCProcedureRegistry } from "../procedure/rpcProcedureRegistry.core.js"
|
|
|
2
2
|
import { RPCMiddlewareStack } from "../middleware/rpcMiddleware.core.js";
|
|
3
3
|
import { RPCDispatcher } from "../dispatcher/rpcDispatcher.core.js";
|
|
4
4
|
import { createRPCErrorResponse } from "../types/rpcResponse.type.js";
|
|
5
|
-
import { isRPCError, RPCAuthenticationError, RPCCancelledError, RPCDeadlineExceededError, RPCDeserializationError, RPCForbiddenError, RPCInvalidRequestError, RPCProcedureNotFoundError, RPCRateLimitedError, RPCSerializationError, RPCTimeoutError, RPCUnavailableError, RPCValidationError, } from "../errors/rpc.errors.js";
|
|
5
|
+
import { isRPCError, RPCAuthenticationError, RPCCancelledError, RPCDeadlineExceededError, RPCDeserializationError, RPCForbiddenError, RPCInternalError, RPCInvalidRequestError, RPCProcedureNotFoundError, RPCRateLimitedError, RPCSerializationError, RPCTimeoutError, RPCUnavailableError, RPCValidationError, } from "../errors/rpc.errors.js";
|
|
6
6
|
import { INTERNAL_ERROR_MESSAGE } from "../constants/rpcConstants.core.js";
|
|
7
7
|
import { assertValidRequest } from "../validation/rpcValidation.core.js";
|
|
8
8
|
/**
|
|
@@ -23,7 +23,10 @@ const ERROR_CODES = [
|
|
|
23
23
|
[RPCTimeoutError, "RPC_TIMEOUT"],
|
|
24
24
|
[RPCCancelledError, "RPC_CANCELLED"],
|
|
25
25
|
[RPCUnavailableError, "RPC_UNAVAILABLE"],
|
|
26
|
-
|
|
26
|
+
// A serialization failure is a server fault (500, `expose: false`) whose
|
|
27
|
+
// message names what could not be encoded; the code travels, the
|
|
28
|
+
// message goes to `onInternalError`.
|
|
29
|
+
[RPCSerializationError, "RPC_SERIALIZATION_ERROR", true],
|
|
27
30
|
[RPCDeserializationError, "RPC_DESERIALIZATION_ERROR"],
|
|
28
31
|
];
|
|
29
32
|
/**
|
|
@@ -62,14 +65,24 @@ export class RPCServer {
|
|
|
62
65
|
* `onInternalError` and answered with a fixed message: internal
|
|
63
66
|
* exception text can name hosts, paths, credentials or queries, and
|
|
64
67
|
* the caller is an untrusted peer.
|
|
68
|
+
*
|
|
69
|
+
* Everything in `request` comes from that peer, including
|
|
70
|
+
* `request.metadata.userId`. Identity the transport has verified goes
|
|
71
|
+
* in `trusted.auth` and reaches middleware and handlers as
|
|
72
|
+
* `context.auth`; authorise on that, never on frame metadata.
|
|
65
73
|
*/
|
|
66
|
-
async handle(request) {
|
|
74
|
+
async handle(request, trusted = {}) {
|
|
67
75
|
const requestId = typeof request?.id === "string"
|
|
68
76
|
? request.id
|
|
69
77
|
: "";
|
|
70
78
|
try {
|
|
71
79
|
assertValidRequest(request, this.options.limits);
|
|
72
|
-
|
|
80
|
+
// `metadata` is optional on the wire (`createRPCRequest` fills it in,
|
|
81
|
+
// a hand-built or JSON-decoded frame need not). Every consumer below
|
|
82
|
+
// reads it as an object, so a frame without it used to fail with a
|
|
83
|
+
// TypeError reported as an internal error.
|
|
84
|
+
const frame = request.metadata === undefined ? { ...request, metadata: {} } : request;
|
|
85
|
+
return await this.dispatcher.dispatch(frame, trusted);
|
|
73
86
|
}
|
|
74
87
|
catch (error) {
|
|
75
88
|
const mapped = this.mapError(error);
|
|
@@ -80,7 +93,21 @@ export class RPCServer {
|
|
|
80
93
|
message: INTERNAL_ERROR_MESSAGE,
|
|
81
94
|
});
|
|
82
95
|
}
|
|
83
|
-
|
|
96
|
+
if (mapped.internal) {
|
|
97
|
+
// The error is typed, so the caller keeps its code, but it was
|
|
98
|
+
// constructed with `expose: false`: its message is server detail
|
|
99
|
+
// and goes to the log, not the wire.
|
|
100
|
+
this.options.onInternalError?.(error, requestId);
|
|
101
|
+
return createRPCErrorResponse(requestId, {
|
|
102
|
+
code: mapped.code,
|
|
103
|
+
message: INTERNAL_ERROR_MESSAGE,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
return createRPCErrorResponse(requestId, {
|
|
107
|
+
code: mapped.code,
|
|
108
|
+
message: mapped.message,
|
|
109
|
+
...(mapped.details !== undefined ? { details: mapped.details } : {}),
|
|
110
|
+
});
|
|
84
111
|
}
|
|
85
112
|
}
|
|
86
113
|
/**
|
|
@@ -96,13 +123,22 @@ export class RPCServer {
|
|
|
96
123
|
* answers with a generic internal error.
|
|
97
124
|
*/
|
|
98
125
|
mapError(error) {
|
|
99
|
-
|
|
126
|
+
// An RPCInternalError *is* the generic internal failure: it carries
|
|
127
|
+
// `expose: false` and a message written for the log. Mapping it like
|
|
128
|
+
// any other RPCError put that message on the wire.
|
|
129
|
+
if (error instanceof RPCInternalError) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
for (const [type, code, internal] of ERROR_CODES) {
|
|
100
133
|
if (error instanceof type) {
|
|
101
134
|
const payload = {
|
|
102
135
|
code,
|
|
103
136
|
message: error.message,
|
|
137
|
+
...(internal ? { internal: true } : {}),
|
|
104
138
|
};
|
|
105
|
-
if (error instanceof RPCValidationError &&
|
|
139
|
+
if (error instanceof RPCValidationError &&
|
|
140
|
+
error.issues !== undefined &&
|
|
141
|
+
error.issues.length > 0) {
|
|
106
142
|
payload.details = error.issues;
|
|
107
143
|
}
|
|
108
144
|
if (error instanceof RPCRateLimitedError &&
|
|
@@ -113,9 +149,15 @@ export class RPCServer {
|
|
|
113
149
|
}
|
|
114
150
|
}
|
|
115
151
|
// A custom RPCError subclass is still a deliberate, caller-facing
|
|
116
|
-
// error; honour its own code rather than hiding it.
|
|
152
|
+
// error; honour its own code rather than hiding it. Its message only
|
|
153
|
+
// travels when the error was built to be exposed — `RPCError`
|
|
154
|
+
// defaults to `expose: false`.
|
|
117
155
|
if (isRPCError(error)) {
|
|
118
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
code: error.code,
|
|
158
|
+
message: error.message,
|
|
159
|
+
internal: error.expose === false,
|
|
160
|
+
};
|
|
119
161
|
}
|
|
120
162
|
return undefined;
|
|
121
163
|
}
|
|
@@ -74,8 +74,13 @@ export declare function parseInput<T>(schema: RPCSchema<T>, value: unknown, proc
|
|
|
74
74
|
* Parses a handler's result against its output schema.
|
|
75
75
|
*
|
|
76
76
|
* An invalid output is a server defect rather than a caller mistake, so
|
|
77
|
-
*
|
|
78
|
-
* error
|
|
77
|
+
* it is thrown as an `RPCInternalError` (`expose: false`): the server
|
|
78
|
+
* answers with the generic internal error and hands the detail — the
|
|
79
|
+
* failing paths and codes — to `onInternalError`. Throwing a validation
|
|
80
|
+
* error here told the caller *its* request was invalid, with an empty
|
|
81
|
+
* issue list, for a fault that is entirely the handler's.
|
|
82
|
+
*
|
|
83
|
+
* @throws {RPCInternalError}
|
|
79
84
|
*/
|
|
80
85
|
export declare function parseOutput<T>(schema: RPCSchema<T>, value: unknown, procedureName: string): T;
|
|
81
86
|
//# sourceMappingURL=rpcValidation.core.d.ts.map
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Requests arrive from an untrusted peer over a transport, so these are
|
|
7
7
|
* the first checks the server runs, not the last.
|
|
8
8
|
*/
|
|
9
|
-
import { RPCInvalidRequestError, RPCValidationError, } from "../errors/rpc.errors.js";
|
|
9
|
+
import { RPCInternalError, RPCInvalidRequestError, RPCValidationError, } from "../errors/rpc.errors.js";
|
|
10
10
|
import { MAX_PROCEDURE_NAME_LENGTH, MAX_RPC_PAYLOAD_SIZE, PROCEDURE_NAME_PATTERN, } from "../constants/rpcConstants.core.js";
|
|
11
11
|
/**
|
|
12
12
|
* Validates a procedure name.
|
|
@@ -112,14 +112,22 @@ export function parseInput(schema, value, procedureName) {
|
|
|
112
112
|
* Parses a handler's result against its output schema.
|
|
113
113
|
*
|
|
114
114
|
* An invalid output is a server defect rather than a caller mistake, so
|
|
115
|
-
*
|
|
116
|
-
* error
|
|
115
|
+
* it is thrown as an `RPCInternalError` (`expose: false`): the server
|
|
116
|
+
* answers with the generic internal error and hands the detail — the
|
|
117
|
+
* failing paths and codes — to `onInternalError`. Throwing a validation
|
|
118
|
+
* error here told the caller *its* request was invalid, with an empty
|
|
119
|
+
* issue list, for a fault that is entirely the handler's.
|
|
120
|
+
*
|
|
121
|
+
* @throws {RPCInternalError}
|
|
117
122
|
*/
|
|
118
123
|
export function parseOutput(schema, value, procedureName) {
|
|
119
124
|
const result = schema.safeParse(value);
|
|
120
125
|
if (result.success) {
|
|
121
126
|
return result.data;
|
|
122
127
|
}
|
|
123
|
-
|
|
128
|
+
const where = toValidationIssues(result.issues)
|
|
129
|
+
.map((issue) => `${issue.path || "(root)"}: ${issue.code}`)
|
|
130
|
+
.join(", ");
|
|
131
|
+
throw new RPCInternalError(`Procedure "${procedureName}" produced a response that does not match its output schema at ${where}.`, procedureName);
|
|
124
132
|
}
|
|
125
133
|
//# sourceMappingURL=rpcValidation.core.js.map
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/rpc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Remote procedure call infrastructure for Zudojs applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Oluwayemi Oyinlola",
|
|
8
|
+
"url": "https://github.com/oyinlola-tech"
|
|
9
|
+
},
|
|
6
10
|
"type": "module",
|
|
7
11
|
"main": "./dist/index.js",
|
|
8
12
|
"module": "./dist/index.js",
|
|
@@ -23,10 +27,10 @@
|
|
|
23
27
|
"node": ">=24.0.0"
|
|
24
28
|
},
|
|
25
29
|
"dependencies": {
|
|
26
|
-
"@zudojs/errors": "1.
|
|
27
|
-
"@zudojs/constants": "1.
|
|
28
|
-
"@zudojs/types": "1.
|
|
29
|
-
"@zudojs/schema": "1.
|
|
30
|
+
"@zudojs/errors": "1.1.0",
|
|
31
|
+
"@zudojs/constants": "1.1.0",
|
|
32
|
+
"@zudojs/types": "1.1.0",
|
|
33
|
+
"@zudojs/schema": "1.1.0"
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"typescript": "7.0.2",
|