@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @zudojs/rpc
2
2
 
3
- Type-safe RPC — define procedures, apply middleware, dispatch calls, and serve over HTTP or your own transport.
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 dispatcher that picks the right transport
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
- RPCDispatcher,
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, RPCDispatcher } from "@zudojs/rpc";
58
+ import { createRPCProcedure, createRPCRequest, RPCServer } from "@zudojs/rpc";
59
+ import { schema } from "@zudojs/schema";
44
60
 
45
- const sum = createRPCProcedure({
46
- name: "sum",
47
- input: { a: "number", b: "number" },
48
- handler: ({ input }) => input.a + input.b,
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
- const dispatcher = new RPCDispatcher();
52
- dispatcher.register(sum);
53
- const result = await dispatcher.call("sum", { a: 1, b: 2 });
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, { signal: controller.signal }),
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
- return new RPCTimeoutError(0, procedure);
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: request.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) {
@@ -47,7 +47,7 @@ export declare class RPCDispatcher {
47
47
  /**
48
48
  * Dispatches an RPC request.
49
49
  */
50
- dispatch(request: RPCRequest): Promise<RPCResponse>;
50
+ dispatch(input: RPCRequest): Promise<RPCResponse>;
51
51
  /**
52
52
  * Runs the interceptor chain around the dispatch.
53
53
  *
@@ -27,7 +27,11 @@ export class RPCDispatcher {
27
27
  /**
28
28
  * Dispatches an RPC request.
29
29
  */
30
- async dispatch(request) {
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
- [RPCSerializationError, "RPC_SERIALIZATION_ERROR"],
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
- return await this.dispatcher.dispatch(request);
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
- return createRPCErrorResponse(requestId, mapped);
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
- for (const [type, code] of ERROR_CODES) {
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 && error.issues !== undefined) {
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 { code: error.code, message: error.message };
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
- * the issues describe internal shape and stay server-side: the thrown
78
- * error carries no issue detail.
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
- * the issues describe internal shape and stay server-side: the thrown
116
- * error carries no issue detail.
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
- throw new RPCValidationError(`Procedure "${procedureName}" produced a response that does not match its output schema.`, undefined, procedureName);
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.0.0",
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.0",
27
- "@zudojs/constants": "1.0.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.0"
33
+ "@zudojs/schema": "1.0.1"
30
34
  },
31
35
  "devDependencies": {
32
36
  "typescript": "7.0.2",