@zudojs/rpc 1.2.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
@@ -74,7 +74,11 @@ const server = new RPCServer();
74
74
  server.register(sum);
75
75
 
76
76
  const response = await server.handle(
77
- 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
+ }),
78
82
  );
79
83
  // { id: "req-1", success: true, result: 3 }
80
84
  ```
@@ -98,7 +102,10 @@ import { RPCClient, type RPCTransport } from "@zudojs/rpc";
98
102
  const transport: RPCTransport = { send: (request) => server.handle(request) };
99
103
  const client = new RPCClient(transport, { timeout: 5_000 });
100
104
 
101
- 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
+ });
102
109
  // 3
103
110
  ```
104
111
 
@@ -158,12 +165,19 @@ caller's.
158
165
 
159
166
  ```typescript
160
167
  const server = new RPCServer(undefined, undefined, {
161
- limits: { maxPayloadBytes: 256 * 1024 },
168
+ limits: { maxPayloadBytes: 256 * 1024, maxRequestIdLength: 128 },
162
169
  dispatch: { defaultTimeout: 10_000 },
163
170
  onInternalError: (error, requestId) => logger.error({ requestId, error }),
164
171
  });
165
172
  ```
166
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
+
167
181
  ## License
168
182
 
169
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";
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
  */
@@ -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.
@@ -72,8 +72,14 @@ export class RPCServer {
72
72
  * `context.auth`; authorise on that, never on frame metadata.
73
73
  */
74
74
  async handle(request, trusted = {}) {
75
- const requestId = typeof request?.id === "string"
76
- ? request.id
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
77
83
  : "";
78
84
  try {
79
85
  assertValidRequest(request, this.options.limits);
@@ -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.2.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.1.0",
31
- "@zudojs/constants": "1.1.0",
32
- "@zudojs/types": "1.1.0",
33
- "@zudojs/schema": "1.1.0"
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",