@zudojs/rpc 1.0.0 → 1.1.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 +104 -12
- package/dist/rpc/client/rpcClient.core.js +20 -3
- package/dist/rpc/context/rpcContext.type.js +3 -1
- package/dist/rpc/dispatcher/rpcDispatcher.core.d.ts +1 -1
- package/dist/rpc/dispatcher/rpcDispatcher.core.js +5 -1
- package/dist/rpc/server/rpcServer.core.js +45 -8
- package/dist/rpc/validation/rpcValidation.core.d.ts +7 -2
- package/dist/rpc/validation/rpcValidation.core.js +12 -4
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
|
|
5
5
|
## When to use
|
|
6
6
|
|
|
@@ -8,9 +8,14 @@ Import this when you need:
|
|
|
8
8
|
|
|
9
9
|
- a typed RPC layer between services (gateway ↔ microservice, frontend ↔ backend)
|
|
10
10
|
- procedure-level middleware (auth, tracing, rate limit)
|
|
11
|
-
- a
|
|
11
|
+
- a server that validates frames and maps every failure to a wire code
|
|
12
12
|
- structured RPC errors
|
|
13
13
|
|
|
14
|
+
The package is transport-agnostic: `RPCServer.handle` takes a request frame
|
|
15
|
+
and returns a response frame, and `RPCClient` sends through any object
|
|
16
|
+
implementing `RPCTransport`. It ships no HTTP or WebSocket transport of its
|
|
17
|
+
own.
|
|
18
|
+
|
|
14
19
|
For request/response inside one process, prefer `@zudojs/api`.
|
|
15
20
|
|
|
16
21
|
## Installation
|
|
@@ -24,8 +29,11 @@ npm install @zudojs/rpc
|
|
|
24
29
|
```typescript
|
|
25
30
|
import {
|
|
26
31
|
createRPCProcedure,
|
|
27
|
-
|
|
32
|
+
createRPCRequest,
|
|
28
33
|
RPCServer,
|
|
34
|
+
RPCClient,
|
|
35
|
+
RPCDispatcher,
|
|
36
|
+
RPCProcedureRegistry,
|
|
29
37
|
RPCMiddlewareStack,
|
|
30
38
|
createRPCContext,
|
|
31
39
|
type RPCContext,
|
|
@@ -33,24 +41,108 @@ import {
|
|
|
33
41
|
type RPCProcedure,
|
|
34
42
|
type RPCRequest,
|
|
35
43
|
type RPCResponse,
|
|
44
|
+
type RPCTransport,
|
|
36
45
|
type RPCErrorOptions,
|
|
37
46
|
} from "@zudojs/rpc";
|
|
38
47
|
```
|
|
39
48
|
|
|
40
49
|
## Usage
|
|
41
50
|
|
|
51
|
+
A procedure is `createRPCProcedure(name, handler, options?)`. Names are
|
|
52
|
+
dot-separated identifiers (`"math.sum"`); the handler is positional,
|
|
53
|
+
`(input, context)`; `options.input` / `options.output` are `@zudojs/schema`
|
|
54
|
+
schemas (anything with `safeParse`) the payload and result are checked
|
|
55
|
+
against.
|
|
56
|
+
|
|
42
57
|
```typescript
|
|
43
|
-
import { createRPCProcedure,
|
|
58
|
+
import { createRPCProcedure, createRPCRequest, RPCServer } from "@zudojs/rpc";
|
|
59
|
+
import { schema } from "@zudojs/schema";
|
|
44
60
|
|
|
45
|
-
const sum = createRPCProcedure(
|
|
46
|
-
|
|
47
|
-
input: { a:
|
|
48
|
-
|
|
49
|
-
|
|
61
|
+
const sum = createRPCProcedure(
|
|
62
|
+
"math.sum",
|
|
63
|
+
async (input: { a: number; b: number }) => input.a + input.b,
|
|
64
|
+
{ input: schema.object({ a: schema.number(), b: schema.number() }) },
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const server = new RPCServer();
|
|
68
|
+
server.register(sum);
|
|
69
|
+
|
|
70
|
+
const response = await server.handle(
|
|
71
|
+
createRPCRequest({ id: "req-1", procedure: "math.sum", payload: { a: 1, b: 2 } }),
|
|
72
|
+
);
|
|
73
|
+
// { id: "req-1", success: true, result: 3 }
|
|
74
|
+
```
|
|
50
75
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
76
|
+
`handle` never throws for a bad request: a malformed frame, an unknown
|
|
77
|
+
procedure, a payload the input schema rejects, a timeout or a handler error
|
|
78
|
+
each come back as `{ success: false, error: { code, message, details? } }`.
|
|
79
|
+
The frame's `metadata` is optional.
|
|
80
|
+
|
|
81
|
+
### Calling through a transport
|
|
82
|
+
|
|
83
|
+
`RPCClient` needs an `RPCTransport` — an object whose `send(request, options)`
|
|
84
|
+
delivers the frame and resolves with the response. `options.signal` aborts the
|
|
85
|
+
call and `options.timeout` is the deadline in milliseconds, so a transport can
|
|
86
|
+
set its own socket timeout. The in-process transport below is the smallest
|
|
87
|
+
possible one:
|
|
88
|
+
|
|
89
|
+
```typescript
|
|
90
|
+
import { RPCClient, type RPCTransport } from "@zudojs/rpc";
|
|
91
|
+
|
|
92
|
+
const transport: RPCTransport = { send: (request) => server.handle(request) };
|
|
93
|
+
const client = new RPCClient(transport, { timeout: 5_000 });
|
|
94
|
+
|
|
95
|
+
const total = await client.call<{ a: number; b: number }, number>("math.sum", { a: 1, b: 2 });
|
|
96
|
+
// 3
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
A failed call rejects with a typed error rebuilt from the wire code
|
|
100
|
+
(`RPCTimeoutError`, `RPCCancelledError`, `RPCUnavailableError`, or an
|
|
101
|
+
`RPCError` carrying the server's `code` and `details`).
|
|
102
|
+
|
|
103
|
+
### Middleware
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { RPCMiddlewareStack, RPCAuthenticationError } from "@zudojs/rpc";
|
|
107
|
+
|
|
108
|
+
const stack = new RPCMiddlewareStack([
|
|
109
|
+
async (context, next) => {
|
|
110
|
+
if (context.metadata.userId === undefined) {
|
|
111
|
+
throw new RPCAuthenticationError("Sign in first.");
|
|
112
|
+
}
|
|
113
|
+
return next();
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
const server = new RPCServer(undefined, stack);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Each middleware may call `next()` once. Input validation runs before the
|
|
121
|
+
stack, so middleware sees a payload the procedure's schema has accepted.
|
|
122
|
+
|
|
123
|
+
## Errors
|
|
124
|
+
|
|
125
|
+
Every error class from `@zudojs/errors`' RPC family is re-exported. The
|
|
126
|
+
server maps them to wire codes (`RPC_PROCEDURE_NOT_FOUND`,
|
|
127
|
+
`RPC_VALIDATION_ERROR`, `RPC_UNAUTHENTICATED`, `RPC_FORBIDDEN`,
|
|
128
|
+
`RPC_RATE_LIMITED`, `RPC_TIMEOUT`, …). A custom `RPCError` subclass keeps its
|
|
129
|
+
own `code`.
|
|
130
|
+
|
|
131
|
+
What reaches the caller follows the error's `expose` flag. Anything thrown
|
|
132
|
+
with `expose: false` — an `RPCInternalError`, an `RPCSerializationError`, a
|
|
133
|
+
plain `new RPCError(...)` (whose default is `expose: false`), or any
|
|
134
|
+
non-RPC error — is answered with the fixed `INTERNAL_ERROR_MESSAGE`; the
|
|
135
|
+
original error is handed to `onInternalError(error, requestId)` so it can be
|
|
136
|
+
logged against the request id. A handler result that fails the procedure's
|
|
137
|
+
`output` schema is treated the same way: it is the server's fault, not the
|
|
138
|
+
caller's.
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
const server = new RPCServer(undefined, undefined, {
|
|
142
|
+
limits: { maxPayloadBytes: 256 * 1024 },
|
|
143
|
+
dispatch: { defaultTimeout: 10_000 },
|
|
144
|
+
onInternalError: (error, requestId) => logger.error({ requestId, error }),
|
|
145
|
+
});
|
|
54
146
|
```
|
|
55
147
|
|
|
56
148
|
## License
|
|
@@ -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":
|
|
@@ -5,7 +5,9 @@ export function createRPCContext(request, signal) {
|
|
|
5
5
|
const state = new Map();
|
|
6
6
|
const context = {
|
|
7
7
|
request,
|
|
8
|
-
metadata
|
|
8
|
+
// A frame decoded from JSON may omit `metadata`; middleware reads
|
|
9
|
+
// `context.metadata.userId` and the like without guarding.
|
|
10
|
+
metadata: request.metadata ?? {},
|
|
9
11
|
signal,
|
|
10
12
|
state,
|
|
11
13
|
get(key) {
|
|
@@ -27,7 +27,11 @@ export class RPCDispatcher {
|
|
|
27
27
|
/**
|
|
28
28
|
* Dispatches an RPC request.
|
|
29
29
|
*/
|
|
30
|
-
async dispatch(
|
|
30
|
+
async dispatch(input) {
|
|
31
|
+
// Tolerate a frame without `metadata`: the field is optional when a
|
|
32
|
+
// request is built by hand or decoded from JSON, and everything below
|
|
33
|
+
// — deadline reading, the context's `metadata` — reads it as an object.
|
|
34
|
+
const request = input.metadata === undefined ? { ...input, metadata: {} } : input;
|
|
31
35
|
const procedure = this.registry.require(request.procedure);
|
|
32
36
|
const controller = new AbortController();
|
|
33
37
|
const context = createRPCContext(request, controller.signal);
|
|
@@ -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
|
/**
|
|
@@ -69,7 +72,12 @@ export class RPCServer {
|
|
|
69
72
|
: "";
|
|
70
73
|
try {
|
|
71
74
|
assertValidRequest(request, this.options.limits);
|
|
72
|
-
|
|
75
|
+
// `metadata` is optional on the wire (`createRPCRequest` fills it in,
|
|
76
|
+
// a hand-built or JSON-decoded frame need not). Every consumer below
|
|
77
|
+
// reads it as an object, so a frame without it used to fail with a
|
|
78
|
+
// TypeError reported as an internal error.
|
|
79
|
+
const frame = request.metadata === undefined ? { ...request, metadata: {} } : request;
|
|
80
|
+
return await this.dispatcher.dispatch(frame);
|
|
73
81
|
}
|
|
74
82
|
catch (error) {
|
|
75
83
|
const mapped = this.mapError(error);
|
|
@@ -80,7 +88,21 @@ export class RPCServer {
|
|
|
80
88
|
message: INTERNAL_ERROR_MESSAGE,
|
|
81
89
|
});
|
|
82
90
|
}
|
|
83
|
-
|
|
91
|
+
if (mapped.internal) {
|
|
92
|
+
// The error is typed, so the caller keeps its code, but it was
|
|
93
|
+
// constructed with `expose: false`: its message is server detail
|
|
94
|
+
// and goes to the log, not the wire.
|
|
95
|
+
this.options.onInternalError?.(error, requestId);
|
|
96
|
+
return createRPCErrorResponse(requestId, {
|
|
97
|
+
code: mapped.code,
|
|
98
|
+
message: INTERNAL_ERROR_MESSAGE,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return createRPCErrorResponse(requestId, {
|
|
102
|
+
code: mapped.code,
|
|
103
|
+
message: mapped.message,
|
|
104
|
+
...(mapped.details !== undefined ? { details: mapped.details } : {}),
|
|
105
|
+
});
|
|
84
106
|
}
|
|
85
107
|
}
|
|
86
108
|
/**
|
|
@@ -96,13 +118,22 @@ export class RPCServer {
|
|
|
96
118
|
* answers with a generic internal error.
|
|
97
119
|
*/
|
|
98
120
|
mapError(error) {
|
|
99
|
-
|
|
121
|
+
// An RPCInternalError *is* the generic internal failure: it carries
|
|
122
|
+
// `expose: false` and a message written for the log. Mapping it like
|
|
123
|
+
// any other RPCError put that message on the wire.
|
|
124
|
+
if (error instanceof RPCInternalError) {
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
for (const [type, code, internal] of ERROR_CODES) {
|
|
100
128
|
if (error instanceof type) {
|
|
101
129
|
const payload = {
|
|
102
130
|
code,
|
|
103
131
|
message: error.message,
|
|
132
|
+
...(internal ? { internal: true } : {}),
|
|
104
133
|
};
|
|
105
|
-
if (error instanceof RPCValidationError &&
|
|
134
|
+
if (error instanceof RPCValidationError &&
|
|
135
|
+
error.issues !== undefined &&
|
|
136
|
+
error.issues.length > 0) {
|
|
106
137
|
payload.details = error.issues;
|
|
107
138
|
}
|
|
108
139
|
if (error instanceof RPCRateLimitedError &&
|
|
@@ -113,9 +144,15 @@ export class RPCServer {
|
|
|
113
144
|
}
|
|
114
145
|
}
|
|
115
146
|
// A custom RPCError subclass is still a deliberate, caller-facing
|
|
116
|
-
// error; honour its own code rather than hiding it.
|
|
147
|
+
// error; honour its own code rather than hiding it. Its message only
|
|
148
|
+
// travels when the error was built to be exposed — `RPCError`
|
|
149
|
+
// defaults to `expose: false`.
|
|
117
150
|
if (isRPCError(error)) {
|
|
118
|
-
return {
|
|
151
|
+
return {
|
|
152
|
+
code: error.code,
|
|
153
|
+
message: error.message,
|
|
154
|
+
internal: error.expose === false,
|
|
155
|
+
};
|
|
119
156
|
}
|
|
120
157
|
return undefined;
|
|
121
158
|
}
|
|
@@ -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.1.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.0.
|
|
27
|
-
"@zudojs/constants": "1.0.
|
|
30
|
+
"@zudojs/errors": "1.0.1",
|
|
31
|
+
"@zudojs/constants": "1.0.1",
|
|
28
32
|
"@zudojs/types": "1.0.0",
|
|
29
|
-
"@zudojs/schema": "1.0.
|
|
33
|
+
"@zudojs/schema": "1.0.1"
|
|
30
34
|
},
|
|
31
35
|
"devDependencies": {
|
|
32
36
|
"typescript": "7.0.2",
|