@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.
- package/README.md +137 -20
- package/dist/index.d.ts +12 -8
- package/dist/index.js +14 -5
- package/dist/rpc/client/index.d.ts +5 -0
- package/dist/rpc/client/index.js +5 -0
- package/dist/rpc/client/rpcClient.core.d.ts +6 -7
- package/dist/rpc/client/rpcClient.core.js +32 -46
- package/dist/rpc/client/rpcWireError.helper.d.ts +13 -0
- package/dist/rpc/client/rpcWireError.helper.js +71 -0
- package/dist/rpc/constants/index.d.ts +6 -1
- package/dist/rpc/constants/index.js +6 -1
- package/dist/rpc/constants/rpcConstants.core.d.ts +20 -0
- package/dist/rpc/constants/rpcConstants.core.js +20 -0
- package/dist/rpc/context/rpcContext.type.d.ts +7 -0
- package/dist/rpc/dispatcher/rpcDispatcher.core.js +14 -1
- package/dist/rpc/reliability/cancellation/rpcAbort.helper.d.ts +17 -0
- package/dist/rpc/reliability/cancellation/rpcAbort.helper.js +40 -0
- package/dist/rpc/reliability/retry/rpcRetry.helper.js +3 -1
- package/dist/rpc/reliability/timeout/rpcTimeout.helper.d.ts +5 -3
- package/dist/rpc/reliability/timeout/rpcTimeout.helper.js +5 -4
- package/dist/rpc/server/index.d.ts +6 -0
- package/dist/rpc/server/index.js +5 -0
- package/dist/rpc/server/rpcBaseErrorMapping.helper.d.ts +16 -0
- package/dist/rpc/server/rpcBaseErrorMapping.helper.js +57 -0
- package/dist/rpc/server/rpcErrorMapping.helper.d.ts +35 -0
- package/dist/rpc/server/rpcErrorMapping.helper.js +101 -0
- package/dist/rpc/server/rpcServer.core.d.ts +0 -7
- package/dist/rpc/server/rpcServer.core.js +15 -93
- package/dist/rpc/transport/codec/index.d.ts +10 -0
- package/dist/rpc/transport/codec/index.js +8 -0
- package/dist/rpc/transport/codec/rpcBody.helper.d.ts +36 -0
- package/dist/rpc/transport/codec/rpcBody.helper.js +64 -0
- package/dist/rpc/transport/codec/rpcCodec.helper.d.ts +41 -0
- package/dist/rpc/transport/codec/rpcCodec.helper.js +40 -0
- package/dist/rpc/transport/http/index.d.ts +12 -0
- package/dist/rpc/transport/http/index.js +10 -0
- package/dist/rpc/transport/http/rpcFetchHandler.core.d.ts +36 -0
- package/dist/rpc/transport/http/rpcFetchHandler.core.js +97 -0
- package/dist/rpc/transport/http/rpcHttpExchange.helper.d.ts +27 -0
- package/dist/rpc/transport/http/rpcHttpExchange.helper.js +78 -0
- package/dist/rpc/transport/http/rpcHttpStatus.helper.d.ts +16 -0
- package/dist/rpc/transport/http/rpcHttpStatus.helper.js +37 -0
- package/dist/rpc/transport/http/rpcHttpTransport.core.d.ts +36 -0
- package/dist/rpc/transport/http/rpcHttpTransport.core.js +45 -0
- package/dist/rpc/transport/index.d.ts +12 -0
- package/dist/rpc/transport/index.js +9 -1
- package/dist/rpc/transport/memory/index.d.ts +7 -0
- package/dist/rpc/transport/memory/index.js +6 -0
- package/dist/rpc/transport/memory/rpcMemoryTransport.core.d.ts +42 -0
- package/dist/rpc/transport/memory/rpcMemoryTransport.core.js +73 -0
- package/dist/rpc/validation/rpcUnsafeKey.helper.d.ts +18 -0
- package/dist/rpc/validation/rpcUnsafeKey.helper.js +60 -0
- package/dist/rpc/validation/rpcValidation.core.d.ts +26 -3
- package/dist/rpc/validation/rpcValidation.core.js +27 -5
- 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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
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
|
|
77
|
-
|
|
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.
|
|
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/
|
|
31
|
-
"@zudojs/
|
|
32
|
-
"@zudojs/
|
|
33
|
-
"@zudojs/
|
|
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": "^
|
|
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://
|
|
50
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-rpc",
|
|
49
51
|
"bugs": {
|
|
50
52
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
51
53
|
},
|