@zudojs/rpc 1.2.0 → 1.4.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.
Files changed (55) hide show
  1. package/README.md +137 -20
  2. package/dist/index.d.ts +12 -8
  3. package/dist/index.js +14 -5
  4. package/dist/rpc/client/index.d.ts +5 -0
  5. package/dist/rpc/client/index.js +5 -0
  6. package/dist/rpc/client/rpcClient.core.d.ts +6 -7
  7. package/dist/rpc/client/rpcClient.core.js +32 -46
  8. package/dist/rpc/client/rpcWireError.helper.d.ts +13 -0
  9. package/dist/rpc/client/rpcWireError.helper.js +71 -0
  10. package/dist/rpc/constants/index.d.ts +6 -1
  11. package/dist/rpc/constants/index.js +6 -1
  12. package/dist/rpc/constants/rpcConstants.core.d.ts +20 -0
  13. package/dist/rpc/constants/rpcConstants.core.js +20 -0
  14. package/dist/rpc/context/rpcContext.type.d.ts +7 -0
  15. package/dist/rpc/dispatcher/rpcDispatcher.core.js +14 -1
  16. package/dist/rpc/reliability/cancellation/rpcAbort.helper.d.ts +17 -0
  17. package/dist/rpc/reliability/cancellation/rpcAbort.helper.js +40 -0
  18. package/dist/rpc/reliability/retry/rpcRetry.helper.js +3 -1
  19. package/dist/rpc/reliability/timeout/rpcTimeout.helper.d.ts +5 -3
  20. package/dist/rpc/reliability/timeout/rpcTimeout.helper.js +5 -4
  21. package/dist/rpc/server/index.d.ts +6 -0
  22. package/dist/rpc/server/index.js +5 -0
  23. package/dist/rpc/server/rpcBaseErrorMapping.helper.d.ts +16 -0
  24. package/dist/rpc/server/rpcBaseErrorMapping.helper.js +57 -0
  25. package/dist/rpc/server/rpcErrorMapping.helper.d.ts +35 -0
  26. package/dist/rpc/server/rpcErrorMapping.helper.js +101 -0
  27. package/dist/rpc/server/rpcServer.core.d.ts +0 -7
  28. package/dist/rpc/server/rpcServer.core.js +15 -93
  29. package/dist/rpc/transport/codec/index.d.ts +10 -0
  30. package/dist/rpc/transport/codec/index.js +8 -0
  31. package/dist/rpc/transport/codec/rpcBody.helper.d.ts +36 -0
  32. package/dist/rpc/transport/codec/rpcBody.helper.js +64 -0
  33. package/dist/rpc/transport/codec/rpcCodec.helper.d.ts +41 -0
  34. package/dist/rpc/transport/codec/rpcCodec.helper.js +40 -0
  35. package/dist/rpc/transport/http/index.d.ts +12 -0
  36. package/dist/rpc/transport/http/index.js +10 -0
  37. package/dist/rpc/transport/http/rpcFetchHandler.core.d.ts +36 -0
  38. package/dist/rpc/transport/http/rpcFetchHandler.core.js +97 -0
  39. package/dist/rpc/transport/http/rpcHttpExchange.helper.d.ts +27 -0
  40. package/dist/rpc/transport/http/rpcHttpExchange.helper.js +78 -0
  41. package/dist/rpc/transport/http/rpcHttpStatus.helper.d.ts +16 -0
  42. package/dist/rpc/transport/http/rpcHttpStatus.helper.js +37 -0
  43. package/dist/rpc/transport/http/rpcHttpTransport.core.d.ts +36 -0
  44. package/dist/rpc/transport/http/rpcHttpTransport.core.js +45 -0
  45. package/dist/rpc/transport/index.d.ts +12 -0
  46. package/dist/rpc/transport/index.js +9 -1
  47. package/dist/rpc/transport/memory/index.d.ts +7 -0
  48. package/dist/rpc/transport/memory/index.js +6 -0
  49. package/dist/rpc/transport/memory/rpcMemoryTransport.core.d.ts +42 -0
  50. package/dist/rpc/transport/memory/rpcMemoryTransport.core.js +73 -0
  51. package/dist/rpc/validation/rpcUnsafeKey.helper.d.ts +18 -0
  52. package/dist/rpc/validation/rpcUnsafeKey.helper.js +60 -0
  53. package/dist/rpc/validation/rpcValidation.core.d.ts +26 -3
  54. package/dist/rpc/validation/rpcValidation.core.js +27 -5
  55. package/package.json +9 -7
@@ -0,0 +1,73 @@
1
+ import { createRPCJsonSerializer } from "../codec/rpcCodec.helper.js";
2
+ import { createRPCErrorResponse } from "../../types/rpcResponse.type.js";
3
+ import { RPCSerializationError, RPCUnavailableError, } from "../../errors/rpc.errors.js";
4
+ import { INTERNAL_ERROR_MESSAGE } from "../../constants/rpcConstants.core.js";
5
+ import { raceAbort } from "../../reliability/cancellation/rpcAbort.helper.js";
6
+ /**
7
+ * Creates a transport that delivers frames to a server in the same
8
+ * process — for tests, and for modular monoliths that may later split a
9
+ * module into its own service without touching callers.
10
+ *
11
+ * Honours `options.signal`: an aborted call stops waiting at once and the
12
+ * server's dispatch is cancelled with it. After `close()` every send fails
13
+ * with `RPCUnavailableError`.
14
+ */
15
+ export function createRPCMemoryTransport(server, options = {}) {
16
+ const serializer = options.serializer === false
17
+ ? undefined
18
+ : (options.serializer ?? createRPCJsonSerializer());
19
+ let closed = false;
20
+ const resolveAuth = async (request) => typeof options.auth === "function" ? options.auth(request) : options.auth;
21
+ return {
22
+ async send(request, sendOptions = {}) {
23
+ if (closed) {
24
+ throw new RPCUnavailableError("RPC memory transport has been closed.", request.procedure);
25
+ }
26
+ const frame = copy(serializer, request, request.procedure);
27
+ const exchange = async () => {
28
+ const auth = await resolveAuth(request);
29
+ const signal = sendOptions.signal;
30
+ const response = await server.handle(frame, {
31
+ ...(auth === undefined ? {} : { auth }),
32
+ ...(signal === undefined ? {} : { signal }),
33
+ });
34
+ return copyResponse(serializer, response);
35
+ };
36
+ return raceAbort(exchange(), sendOptions.signal, request.procedure);
37
+ },
38
+ async close() {
39
+ closed = true;
40
+ },
41
+ };
42
+ }
43
+ function copy(serializer, value, procedure) {
44
+ if (serializer === undefined) {
45
+ return value;
46
+ }
47
+ try {
48
+ return serializer.deserialize(serializer.serialize(value));
49
+ }
50
+ catch {
51
+ throw new RPCSerializationError("RPC request could not be serialized.", procedure);
52
+ }
53
+ }
54
+ /**
55
+ * A response the server produced but that cannot cross the boundary (a
56
+ * `BigInt` result under plain JSON, a cycle) is answered the way a
57
+ * network transport would: a generic internal error frame.
58
+ */
59
+ function copyResponse(serializer, response) {
60
+ if (serializer === undefined) {
61
+ return response;
62
+ }
63
+ try {
64
+ return serializer.deserialize(serializer.serialize(response));
65
+ }
66
+ catch {
67
+ return createRPCErrorResponse(response.id, {
68
+ code: "RPC_SERIALIZATION_ERROR",
69
+ message: INTERNAL_ERROR_MESSAGE,
70
+ });
71
+ }
72
+ }
73
+ //# sourceMappingURL=rpcMemoryTransport.core.js.map
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Detection of prototype-polluting keys in decoded, untrusted data.
3
+ */
4
+ /**
5
+ * Returns the first `__proto__`, `constructor` or `prototype` key found
6
+ * anywhere in `value`, or `undefined` when there is none.
7
+ *
8
+ * `JSON.parse` keeps such a key as an ordinary own property, so it reaches
9
+ * handlers intact; the moment one is copied with `Object.assign`, a
10
+ * `for…in` merge or a bracket assignment, it replaces the target's
11
+ * prototype. Transports refuse a frame that carries one rather than
12
+ * silently dropping it.
13
+ *
14
+ * Walks plain objects and arrays only, iteratively (no recursion limit)
15
+ * and cycle-safe, so it is safe on any decoded or in-process value.
16
+ */
17
+ export declare function findUnsafeKey(value: unknown): string | undefined;
18
+ //# sourceMappingURL=rpcUnsafeKey.helper.d.ts.map
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Detection of prototype-polluting keys in decoded, untrusted data.
3
+ */
4
+ import { SCHEMA_FORBIDDEN_KEYS } from "@zudojs/constants";
5
+ function isTraversable(value) {
6
+ if (typeof value !== "object" || value === null) {
7
+ return false;
8
+ }
9
+ if (Array.isArray(value)) {
10
+ return true;
11
+ }
12
+ const prototype = Object.getPrototypeOf(value);
13
+ return prototype === Object.prototype || prototype === null;
14
+ }
15
+ /**
16
+ * Returns the first `__proto__`, `constructor` or `prototype` key found
17
+ * anywhere in `value`, or `undefined` when there is none.
18
+ *
19
+ * `JSON.parse` keeps such a key as an ordinary own property, so it reaches
20
+ * handlers intact; the moment one is copied with `Object.assign`, a
21
+ * `for…in` merge or a bracket assignment, it replaces the target's
22
+ * prototype. Transports refuse a frame that carries one rather than
23
+ * silently dropping it.
24
+ *
25
+ * Walks plain objects and arrays only, iteratively (no recursion limit)
26
+ * and cycle-safe, so it is safe on any decoded or in-process value.
27
+ */
28
+ export function findUnsafeKey(value) {
29
+ if (!isTraversable(value)) {
30
+ return undefined;
31
+ }
32
+ const seen = new WeakSet();
33
+ const pending = [value];
34
+ while (pending.length > 0) {
35
+ const node = pending.pop();
36
+ if (seen.has(node)) {
37
+ continue;
38
+ }
39
+ seen.add(node);
40
+ if (Array.isArray(node)) {
41
+ for (const child of node) {
42
+ if (isTraversable(child)) {
43
+ pending.push(child);
44
+ }
45
+ }
46
+ continue;
47
+ }
48
+ for (const key of Object.keys(node)) {
49
+ if (SCHEMA_FORBIDDEN_KEYS.has(key)) {
50
+ return key;
51
+ }
52
+ const child = node[key];
53
+ if (isTraversable(child)) {
54
+ pending.push(child);
55
+ }
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ //# sourceMappingURL=rpcUnsafeKey.helper.js.map
@@ -13,16 +13,33 @@ 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`.
24
34
  */
25
35
  readonly enforceProcedureNamePattern?: boolean;
36
+ /**
37
+ * Accept `__proto__`, `constructor` and `prototype` as keys inside
38
+ * `payload` and `metadata`. Defaults to `false`: a frame carrying one is
39
+ * refused, because a handler that merges its input into another object
40
+ * would have that object's prototype replaced.
41
+ */
42
+ readonly allowUnsafeKeys?: boolean;
26
43
  }
27
44
  /**
28
45
  * Validates a procedure name.
@@ -41,8 +58,14 @@ export declare function measurePayloadBytes(payload: unknown): number | undefine
41
58
  /**
42
59
  * Validates the shape and size of an incoming request.
43
60
  *
44
- * @throws {RPCInvalidRequestError} when the frame is malformed or the
45
- * payload exceeds the configured limit.
61
+ * Every caller-controlled part of the frame is bounded: the id by
62
+ * length, the procedure name by length and pattern, and `payload` plus
63
+ * `metadata` by their combined encoded size. Neither may carry a
64
+ * prototype-polluting key unless `limits.allowUnsafeKeys` is set.
65
+ *
66
+ * @throws {RPCInvalidRequestError} when the frame is malformed, the id is
67
+ * over-long, payload and metadata together exceed the configured limit, or
68
+ * either carries a `__proto__`, `constructor` or `prototype` key.
46
69
  */
47
70
  export declare function assertValidRequest(request: unknown, limits?: RPCRequestLimits): asserts request is RPCRequest;
48
71
  /**
@@ -6,8 +6,9 @@
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 { findUnsafeKey } from "@zudojs/security";
9
10
  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";
11
+ import { MAX_PROCEDURE_NAME_LENGTH, MAX_RPC_PAYLOAD_SIZE, MAX_RPC_REQUEST_ID_LENGTH, PROCEDURE_NAME_PATTERN, } from "../constants/rpcConstants.core.js";
11
12
  /**
12
13
  * Validates a procedure name.
13
14
  *
@@ -49,8 +50,14 @@ export function measurePayloadBytes(payload) {
49
50
  /**
50
51
  * Validates the shape and size of an incoming request.
51
52
  *
52
- * @throws {RPCInvalidRequestError} when the frame is malformed or the
53
- * payload exceeds the configured limit.
53
+ * Every caller-controlled part of the frame is bounded: the id by
54
+ * length, the procedure name by length and pattern, and `payload` plus
55
+ * `metadata` by their combined encoded size. Neither may carry a
56
+ * prototype-polluting key unless `limits.allowUnsafeKeys` is set.
57
+ *
58
+ * @throws {RPCInvalidRequestError} when the frame is malformed, the id is
59
+ * over-long, payload and metadata together exceed the configured limit, or
60
+ * either carries a `__proto__`, `constructor` or `prototype` key.
54
61
  */
55
62
  export function assertValidRequest(request, limits = {}) {
56
63
  if (typeof request !== "object" || request === null) {
@@ -60,6 +67,13 @@ export function assertValidRequest(request, limits = {}) {
60
67
  if (typeof candidate.id !== "string" || candidate.id.length === 0) {
61
68
  throw new RPCInvalidRequestError("Request id must be a non-empty string.");
62
69
  }
70
+ // Checked before anything else touches the frame: the id is reflected
71
+ // into every response the server builds, so an oversized one must be
72
+ // refused before a response exists to carry it.
73
+ const maxIdLength = limits.maxRequestIdLength ?? MAX_RPC_REQUEST_ID_LENGTH;
74
+ if (maxIdLength > 0 && candidate.id.length > maxIdLength) {
75
+ throw new RPCInvalidRequestError(`Request id exceeds ${maxIdLength} characters.`);
76
+ }
63
77
  if (limits.enforceProcedureNamePattern ?? true) {
64
78
  assertValidProcedureName(candidate.procedure);
65
79
  }
@@ -73,14 +87,22 @@ export function assertValidRequest(request, limits = {}) {
73
87
  }
74
88
  const maxBytes = limits.maxPayloadBytes ?? MAX_RPC_PAYLOAD_SIZE;
75
89
  if (maxBytes > 0) {
76
- const size = measurePayloadBytes(candidate.payload);
77
- if (size === undefined) {
90
+ const payloadSize = measurePayloadBytes(candidate.payload);
91
+ const metadataSize = measurePayloadBytes(candidate.metadata);
92
+ if (payloadSize === undefined || metadataSize === undefined) {
78
93
  throw new RPCInvalidRequestError("Request payload could not be encoded.", candidate.procedure);
79
94
  }
95
+ const size = payloadSize + metadataSize;
80
96
  if (size > maxBytes) {
81
97
  throw new RPCInvalidRequestError(`Request payload of ${size} bytes exceeds the ${maxBytes} byte limit.`, candidate.procedure);
82
98
  }
83
99
  }
100
+ if (limits.allowUnsafeKeys !== true) {
101
+ const unsafe = findUnsafeKey(candidate.payload) ?? findUnsafeKey(candidate.metadata);
102
+ if (unsafe !== undefined) {
103
+ throw new RPCInvalidRequestError(`Request contains the forbidden key "${unsafe}".`, candidate.procedure);
104
+ }
105
+ }
84
106
  }
85
107
  /**
86
108
  * Converts schema issues into metadata safe to return to a caller.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/rpc",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Remote procedure call infrastructure for Zudojs applications.",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,14 +27,16 @@
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/constants": "1.1.2",
31
+ "@zudojs/errors": "1.3.0",
32
+ "@zudojs/schema": "1.2.0",
33
+ "@zudojs/security": "1.3.0",
34
+ "@zudojs/serialization": "1.2.0",
35
+ "@zudojs/types": "1.2.0"
34
36
  },
35
37
  "devDependencies": {
36
38
  "typescript": "7.0.2",
37
- "vitest": "^4.1.11"
39
+ "vitest": "^5.0.1"
38
40
  },
39
41
  "publishConfig": {
40
42
  "access": "public"
@@ -45,7 +47,7 @@
45
47
  "remote",
46
48
  "procedure"
47
49
  ],
48
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
50
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-rpc",
49
51
  "bugs": {
50
52
  "url": "https://github.com/oyinlola-tech/zudo/issues"
51
53
  },