@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 +39 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/rpc/constants/index.d.ts +1 -1
- package/dist/rpc/constants/index.js +1 -1
- package/dist/rpc/constants/rpcConstants.core.d.ts +9 -0
- package/dist/rpc/constants/rpcConstants.core.js +9 -0
- package/dist/rpc/context/index.d.ts +5 -1
- package/dist/rpc/context/index.js +4 -0
- package/dist/rpc/context/rpcContext.type.d.ts +40 -1
- package/dist/rpc/context/rpcContext.type.js +14 -1
- package/dist/rpc/dispatcher/rpcDispatcher.core.d.ts +6 -1
- package/dist/rpc/dispatcher/rpcDispatcher.core.js +8 -3
- package/dist/rpc/server/rpcServer.core.d.ts +7 -1
- package/dist/rpc/server/rpcServer.core.js +16 -5
- package/dist/rpc/validation/rpcValidation.core.d.ts +17 -3
- package/dist/rpc/validation/rpcValidation.core.js +18 -5
- package/package.json +5 -5
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({
|
|
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", {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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,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
|
-
|
|
71
|
-
|
|
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
|
|
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
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
53
|
-
*
|
|
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
|
|
77
|
-
|
|
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.
|
|
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
|
|
31
|
-
"@zudojs/constants": "1.
|
|
32
|
-
"@zudojs/types": "1.
|
|
33
|
-
"@zudojs/schema": "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",
|