@zudojs/rpc 1.1.0 → 1.3.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
@@ -2,6 +2,12 @@
2
2
 
3
3
  Type-safe RPC — define procedures, apply middleware, dispatch calls, and serve them over your own transport.
4
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 -->
10
+
5
11
  ## When to use
6
12
 
7
13
  Import this when you need:
@@ -68,7 +74,11 @@ const server = new RPCServer();
68
74
  server.register(sum);
69
75
 
70
76
  const response = await server.handle(
71
- createRPCRequest({ id: "req-1", procedure: "math.sum", payload: { a: 1, b: 2 } }),
77
+ createRPCRequest({
78
+ id: "req-1",
79
+ procedure: "math.sum",
80
+ payload: { a: 1, b: 2 },
81
+ }),
72
82
  );
73
83
  // { id: "req-1", success: true, result: 3 }
74
84
  ```
@@ -92,7 +102,10 @@ import { RPCClient, type RPCTransport } from "@zudojs/rpc";
92
102
  const transport: RPCTransport = { send: (request) => server.handle(request) };
93
103
  const client = new RPCClient(transport, { timeout: 5_000 });
94
104
 
95
- const total = await client.call<{ a: number; b: number }, number>("math.sum", { a: 1, b: 2 });
105
+ const total = await client.call<{ a: number; b: number }, number>("math.sum", {
106
+ a: 1,
107
+ b: 2,
108
+ });
96
109
  // 3
97
110
  ```
98
111
 
@@ -100,25 +113,38 @@ A failed call rejects with a typed error rebuilt from the wire code
100
113
  (`RPCTimeoutError`, `RPCCancelledError`, `RPCUnavailableError`, or an
101
114
  `RPCError` carrying the server's `code` and `details`).
102
115
 
103
- ### Middleware
116
+ ### Middleware and trusted identity
117
+
118
+ Everything in a frame, `metadata` included, is written by the caller: any
119
+ client can send `metadata: { userId: "admin" }`. Never authorise on it.
120
+ Identity your transport has verified (a checked bearer token, an mTLS peer,
121
+ a server-side session) goes in the second argument of `handle`, and reaches
122
+ middleware and handlers as the frozen `context.auth`:
104
123
 
105
124
  ```typescript
106
125
  import { RPCMiddlewareStack, RPCAuthenticationError } from "@zudojs/rpc";
107
126
 
108
127
  const stack = new RPCMiddlewareStack([
109
128
  async (context, next) => {
110
- if (context.metadata.userId === undefined) {
129
+ if (typeof context.auth?.userId !== "string") {
111
130
  throw new RPCAuthenticationError("Sign in first.");
112
131
  }
132
+ context.set("actor", context.auth.userId);
113
133
  return next();
114
134
  },
115
135
  ]);
116
136
 
117
137
  const server = new RPCServer(undefined, stack);
138
+
139
+ // In the transport, after verifying the caller's credentials yourself:
140
+ await server.handle(frame, { auth: { userId: verifiedUserId } });
118
141
  ```
119
142
 
120
143
  Each middleware may call `next()` once. Input validation runs before the
121
- stack, so middleware sees a payload the procedure's schema has accepted.
144
+ stack. `context.input` holds the payload as the procedure's schema parsed
145
+ it (unknown keys stripped, defaults applied, values coerced), so authorise
146
+ on `context.input`, not on `context.request.payload`, which stays the raw
147
+ frame value.
122
148
 
123
149
  ## Errors
124
150
 
@@ -139,12 +165,19 @@ caller's.
139
165
 
140
166
  ```typescript
141
167
  const server = new RPCServer(undefined, undefined, {
142
- limits: { maxPayloadBytes: 256 * 1024 },
168
+ limits: { maxPayloadBytes: 256 * 1024, maxRequestIdLength: 128 },
143
169
  dispatch: { defaultTimeout: 10_000 },
144
170
  onInternalError: (error, requestId) => logger.error({ requestId, error }),
145
171
  });
146
172
  ```
147
173
 
174
+ `maxPayloadBytes` bounds `payload` and `metadata` together, because both are
175
+ caller-controlled and both reach the handler. `maxRequestIdLength` bounds
176
+ `request.id`, which every response echoes back; an id over the limit is
177
+ refused before a response is built, so it is never reflected. Both default to
178
+ `MAX_RPC_PAYLOAD_SIZE` (1 MiB) and `MAX_RPC_REQUEST_ID_LENGTH` (128), and
179
+ either can be set to `0` when the transport already enforces the limit.
180
+
148
181
  ## License
149
182
 
150
183
  MIT
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  export type { RPCProcedureName, RPCMetadata, RPCMetadataOptions, RPCRequest, RPCRequestOptions, RPCErrorPayload, RPCResponse, } from "./rpc/types/index.js";
23
23
  export { createRPCMetadata, createRPCRequest, createRPCResponse, createRPCErrorResponse, } from "./rpc/types/index.js";
24
- export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpc/constants/index.js";
24
+ export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpc/constants/index.js";
25
25
  export type { RPCRequestLimits, RPCSchema } from "./rpc/validation/index.js";
26
26
  export { assertValidProcedureName, assertValidRequest, measurePayloadBytes, toValidationIssues, parseInput, parseOutput, } from "./rpc/validation/index.js";
27
27
  export type { RPCErrorOptions } from "./rpc/errors/index.js";
@@ -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";
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  export { createRPCMetadata, createRPCRequest, createRPCResponse, createRPCErrorResponse, } from "./rpc/types/index.js";
23
23
  // Constants
24
- export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpc/constants/index.js";
24
+ export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpc/constants/index.js";
25
25
  export { assertValidProcedureName, assertValidRequest, measurePayloadBytes, toValidationIssues, parseInput, parseOutput, } from "./rpc/validation/index.js";
26
26
  export { RPCError, RPCProcedureNotFoundError, RPCInvalidRequestError, RPCValidationError, RPCAuthenticationError, RPCForbiddenError, RPCTimeoutError, RPCCancelledError, RPCInternalError, RPCTransportError, RPCSerializationError, RPCDeserializationError, RPCUnavailableError, RPCRateLimitedError, RPCDeadlineExceededError, RPCDuplicateProcedureError, createRPCError, isRPCError, } from "./rpc/errors/index.js";
27
27
  export { createRPCProcedure } from "./rpc/procedure/index.js";
@@ -1,2 +1,2 @@
1
- export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpcConstants.core.js";
1
+ export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpcConstants.core.js";
2
2
  //# sourceMappingURL=index.d.ts.map
@@ -1,2 +1,2 @@
1
- export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpcConstants.core.js";
1
+ export { DEFAULT_RPC_TIMEOUT, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, MAX_PENDING_REQUESTS, MAX_MIDDLEWARE, MAX_PROCEDURES, MAX_PROCEDURE_NAME_LENGTH, MAX_TIMER_DELAY, PROCEDURE_NAME_PATTERN, INTERNAL_ERROR_MESSAGE, } from "./rpcConstants.core.js";
2
2
  //# sourceMappingURL=index.js.map
@@ -11,6 +11,15 @@ export declare const DEFAULT_RPC_TIMEOUT = 30000;
11
11
  * Maximum payload size for RPC messages (1MB).
12
12
  */
13
13
  export declare const MAX_RPC_PAYLOAD_SIZE: number;
14
+ /**
15
+ * Maximum length of a request id accepted from a peer.
16
+ *
17
+ * The id is echoed verbatim into every success and error response, so an
18
+ * unbounded id is a reflection amplifier: the server writes back whatever
19
+ * the caller sent, on both paths. 128 characters holds a UUID, a ULID or a
20
+ * W3C trace id with room to spare.
21
+ */
22
+ export declare const MAX_RPC_REQUEST_ID_LENGTH = 128;
14
23
  /**
15
24
  * Maximum number of pending requests allowed in the client.
16
25
  */
@@ -11,6 +11,15 @@ export const DEFAULT_RPC_TIMEOUT = 30_000;
11
11
  * Maximum payload size for RPC messages (1MB).
12
12
  */
13
13
  export const MAX_RPC_PAYLOAD_SIZE = 1024 * 1024;
14
+ /**
15
+ * Maximum length of a request id accepted from a peer.
16
+ *
17
+ * The id is echoed verbatim into every success and error response, so an
18
+ * unbounded id is a reflection amplifier: the server writes back whatever
19
+ * the caller sent, on both paths. 128 characters holds a UUID, a ULID or a
20
+ * W3C trace id with room to spare.
21
+ */
22
+ export const MAX_RPC_REQUEST_ID_LENGTH = 128;
14
23
  /**
15
24
  * Maximum number of pending requests allowed in the client.
16
25
  */
@@ -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,13 +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
17
  // A frame decoded from JSON may omit `metadata`; middleware reads
9
18
  // `context.metadata.userId` and the like without guarding.
10
19
  metadata: request.metadata ?? {},
20
+ auth,
21
+ get input() {
22
+ return inputs.get(context);
23
+ },
11
24
  signal,
12
25
  state,
13
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(input: 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,15 +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(input) {
34
+ async dispatch(input, trusted = {}) {
31
35
  // Tolerate a frame without `metadata`: the field is optional when a
32
36
  // request is built by hand or decoded from JSON, and everything below
33
37
  // — deadline reading, the context's `metadata` — reads it as an object.
34
38
  const request = input.metadata === undefined ? { ...input, metadata: {} } : input;
35
39
  const procedure = this.registry.require(request.procedure);
36
40
  const controller = new AbortController();
37
- const context = createRPCContext(request, controller.signal);
41
+ const context = createRPCContext(request, controller.signal, trusted);
38
42
  const timeoutMs = this.resolveTimeout(request, procedure);
39
43
  // A deadline already in the past is rejected before any work runs.
40
44
  if (this.options.honourDeadline ?? true) {
@@ -49,6 +53,7 @@ export class RPCDispatcher {
49
53
  const input = procedure.options?.input
50
54
  ? parseInput(procedure.options.input, request.payload, request.procedure)
51
55
  : request.payload;
56
+ bindRPCContextInput(context, input);
52
57
  const result = await this.middleware.execute(context, async () => {
53
58
  return procedure.handler(input, context);
54
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
  */
@@ -3,7 +3,7 @@ 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
5
  import { isRPCError, RPCAuthenticationError, RPCCancelledError, RPCDeadlineExceededError, RPCDeserializationError, RPCForbiddenError, RPCInternalError, RPCInvalidRequestError, RPCProcedureNotFoundError, RPCRateLimitedError, RPCSerializationError, RPCTimeoutError, RPCUnavailableError, RPCValidationError, } from "../errors/rpc.errors.js";
6
- import { INTERNAL_ERROR_MESSAGE } from "../constants/rpcConstants.core.js";
6
+ import { INTERNAL_ERROR_MESSAGE, MAX_RPC_REQUEST_ID_LENGTH, } from "../constants/rpcConstants.core.js";
7
7
  import { assertValidRequest } from "../validation/rpcValidation.core.js";
8
8
  /**
9
9
  * Wire codes for the error types the server maps.
@@ -65,10 +65,21 @@ export class RPCServer {
65
65
  * `onInternalError` and answered with a fixed message: internal
66
66
  * exception text can name hosts, paths, credentials or queries, and
67
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.
68
73
  */
69
- async handle(request) {
70
- const requestId = typeof request?.id === "string"
71
- ? request.id
74
+ async handle(request, trusted = {}) {
75
+ // An id the validator would refuse is never reflected: the error
76
+ // response below echoes this value, so accepting an over-long id here
77
+ // would amplify it straight back to the peer that sent it.
78
+ const rawId = request?.id;
79
+ const maxIdLength = this.options.limits?.maxRequestIdLength ?? MAX_RPC_REQUEST_ID_LENGTH;
80
+ const requestId = typeof rawId === "string" &&
81
+ (maxIdLength <= 0 || rawId.length <= maxIdLength)
82
+ ? rawId
72
83
  : "";
73
84
  try {
74
85
  assertValidRequest(request, this.options.limits);
@@ -77,7 +88,7 @@ export class RPCServer {
77
88
  // reads it as an object, so a frame without it used to fail with a
78
89
  // TypeError reported as an internal error.
79
90
  const frame = request.metadata === undefined ? { ...request, metadata: {} } : request;
80
- return await this.dispatcher.dispatch(frame);
91
+ return await this.dispatcher.dispatch(frame, trusted);
81
92
  }
82
93
  catch (error) {
83
94
  const mapped = this.mapError(error);
@@ -13,11 +13,21 @@ import type { RPCRequest } from "../types/rpcRequest.type.js";
13
13
  */
14
14
  export interface RPCRequestLimits {
15
15
  /**
16
- * Maximum encoded payload size in bytes. Defaults to
16
+ * Maximum combined encoded size, in bytes, of the caller-controlled
17
+ * parts of the frame — `payload` and `metadata`. Defaults to
17
18
  * {@link MAX_RPC_PAYLOAD_SIZE}. Set to `0` to skip the check when the
18
19
  * transport already enforces a frame limit.
20
+ *
21
+ * `metadata` counts because it is caller-controlled and is handed to
22
+ * middleware and handlers as `context.metadata`; measuring `payload`
23
+ * alone left an unbounded second channel into the same handler.
19
24
  */
20
25
  readonly maxPayloadBytes?: number;
26
+ /**
27
+ * Maximum length of `request.id`. Defaults to
28
+ * {@link MAX_RPC_REQUEST_ID_LENGTH}. Set to `0` to skip the check.
29
+ */
30
+ readonly maxRequestIdLength?: number;
21
31
  /**
22
32
  * Whether procedure names must match {@link PROCEDURE_NAME_PATTERN}.
23
33
  * Defaults to `true`.
@@ -41,8 +51,12 @@ export declare function measurePayloadBytes(payload: unknown): number | undefine
41
51
  /**
42
52
  * Validates the shape and size of an incoming request.
43
53
  *
44
- * @throws {RPCInvalidRequestError} when the frame is malformed or the
45
- * payload exceeds the configured limit.
54
+ * Every caller-controlled part of the frame is bounded: the id by
55
+ * length, the procedure name by length and pattern, and `payload` plus
56
+ * `metadata` by their combined encoded size.
57
+ *
58
+ * @throws {RPCInvalidRequestError} when the frame is malformed, the id is
59
+ * over-long, or payload and metadata together exceed the configured limit.
46
60
  */
47
61
  export declare function assertValidRequest(request: unknown, limits?: RPCRequestLimits): asserts request is RPCRequest;
48
62
  /**
@@ -7,7 +7,7 @@
7
7
  * the first checks the server runs, not the last.
8
8
  */
9
9
  import { RPCInternalError, RPCInvalidRequestError, RPCValidationError, } from "../errors/rpc.errors.js";
10
- import { MAX_PROCEDURE_NAME_LENGTH, MAX_RPC_PAYLOAD_SIZE, PROCEDURE_NAME_PATTERN, } from "../constants/rpcConstants.core.js";
10
+ import { MAX_PROCEDURE_NAME_LENGTH, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, PROCEDURE_NAME_PATTERN, } from "../constants/rpcConstants.core.js";
11
11
  /**
12
12
  * Validates a procedure name.
13
13
  *
@@ -49,8 +49,12 @@ export function measurePayloadBytes(payload) {
49
49
  /**
50
50
  * Validates the shape and size of an incoming request.
51
51
  *
52
- * @throws {RPCInvalidRequestError} when the frame is malformed or the
53
- * payload exceeds the configured limit.
52
+ * Every caller-controlled part of the frame is bounded: the id by
53
+ * length, the procedure name by length and pattern, and `payload` plus
54
+ * `metadata` by their combined encoded size.
55
+ *
56
+ * @throws {RPCInvalidRequestError} when the frame is malformed, the id is
57
+ * over-long, or payload and metadata together exceed the configured limit.
54
58
  */
55
59
  export function assertValidRequest(request, limits = {}) {
56
60
  if (typeof request !== "object" || request === null) {
@@ -60,6 +64,13 @@ export function assertValidRequest(request, limits = {}) {
60
64
  if (typeof candidate.id !== "string" || candidate.id.length === 0) {
61
65
  throw new RPCInvalidRequestError("Request id must be a non-empty string.");
62
66
  }
67
+ // Checked before anything else touches the frame: the id is reflected
68
+ // into every response the server builds, so an oversized one must be
69
+ // refused before a response exists to carry it.
70
+ const maxIdLength = limits.maxRequestIdLength ?? MAX_RPC_REQUEST_ID_LENGTH;
71
+ if (maxIdLength > 0 && candidate.id.length > maxIdLength) {
72
+ throw new RPCInvalidRequestError(`Request id exceeds ${maxIdLength} characters.`);
73
+ }
63
74
  if (limits.enforceProcedureNamePattern ?? true) {
64
75
  assertValidProcedureName(candidate.procedure);
65
76
  }
@@ -73,10 +84,12 @@ export function assertValidRequest(request, limits = {}) {
73
84
  }
74
85
  const maxBytes = limits.maxPayloadBytes ?? MAX_RPC_PAYLOAD_SIZE;
75
86
  if (maxBytes > 0) {
76
- const size = measurePayloadBytes(candidate.payload);
77
- if (size === undefined) {
87
+ const payloadSize = measurePayloadBytes(candidate.payload);
88
+ const metadataSize = measurePayloadBytes(candidate.metadata);
89
+ if (payloadSize === undefined || metadataSize === undefined) {
78
90
  throw new RPCInvalidRequestError("Request payload could not be encoded.", candidate.procedure);
79
91
  }
92
+ const size = payloadSize + metadataSize;
80
93
  if (size > maxBytes) {
81
94
  throw new RPCInvalidRequestError(`Request payload of ${size} bytes exceeds the ${maxBytes} byte limit.`, candidate.procedure);
82
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/rpc",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Remote procedure call infrastructure for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,10 +27,10 @@
27
27
  "node": ">=24.0.0"
28
28
  },
29
29
  "dependencies": {
30
- "@zudojs/errors": "1.0.1",
31
- "@zudojs/constants": "1.0.1",
32
- "@zudojs/types": "1.0.0",
33
- "@zudojs/schema": "1.0.1"
30
+ "@zudojs/errors": "1.2.0",
31
+ "@zudojs/constants": "1.1.1",
32
+ "@zudojs/types": "1.1.1",
33
+ "@zudojs/schema": "1.1.1"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "7.0.2",