@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 CHANGED
@@ -1,6 +1,12 @@
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
+
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 dispatcher that picks the right transport
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
- RPCDispatcher,
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, RPCDispatcher } from "@zudojs/rpc";
64
+ import { createRPCProcedure, createRPCRequest, RPCServer } from "@zudojs/rpc";
65
+ import { schema } from "@zudojs/schema";
44
66
 
45
- const sum = createRPCProcedure({
46
- name: "sum",
47
- input: { a: "number", b: "number" },
48
- handler: ({ input }) => input.a + input.b,
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 dispatcher = new RPCDispatcher();
52
- dispatcher.register(sum);
53
- const result = await dispatcher.call("sum", { a: 1, b: 2 });
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, { 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":
@@ -1,3 +1,7 @@
1
- export type { RPCContext } from "./rpcContext.type.js";
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,2 +1,6 @@
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
+ */
1
5
  export { createRPCContext } from "./rpcContext.type.js";
2
6
  //# sourceMappingURL=index.js.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: request.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(request: RPCRequest): Promise<RPCResponse>;
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(request) {
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
- [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
  /**
@@ -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
- return await this.dispatcher.dispatch(request);
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
- return createRPCErrorResponse(requestId, mapped);
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
- for (const [type, code] of ERROR_CODES) {
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 && error.issues !== undefined) {
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 { code: error.code, message: error.message };
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
- * 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.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.0.0",
27
- "@zudojs/constants": "1.0.0",
28
- "@zudojs/types": "1.0.0",
29
- "@zudojs/schema": "1.0.0"
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",