@zudojs/rpc 1.1.0 → 1.2.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 +22 -3
- package/dist/index.d.ts +1 -1
- 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 +7 -2
- 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:
|
|
@@ -100,25 +106,38 @@ A failed call rejects with a typed error rebuilt from the wire code
|
|
|
100
106
|
(`RPCTimeoutError`, `RPCCancelledError`, `RPCUnavailableError`, or an
|
|
101
107
|
`RPCError` carrying the server's `code` and `details`).
|
|
102
108
|
|
|
103
|
-
### Middleware
|
|
109
|
+
### Middleware and trusted identity
|
|
110
|
+
|
|
111
|
+
Everything in a frame, `metadata` included, is written by the caller: any
|
|
112
|
+
client can send `metadata: { userId: "admin" }`. Never authorise on it.
|
|
113
|
+
Identity your transport has verified (a checked bearer token, an mTLS peer,
|
|
114
|
+
a server-side session) goes in the second argument of `handle`, and reaches
|
|
115
|
+
middleware and handlers as the frozen `context.auth`:
|
|
104
116
|
|
|
105
117
|
```typescript
|
|
106
118
|
import { RPCMiddlewareStack, RPCAuthenticationError } from "@zudojs/rpc";
|
|
107
119
|
|
|
108
120
|
const stack = new RPCMiddlewareStack([
|
|
109
121
|
async (context, next) => {
|
|
110
|
-
if (context.
|
|
122
|
+
if (typeof context.auth?.userId !== "string") {
|
|
111
123
|
throw new RPCAuthenticationError("Sign in first.");
|
|
112
124
|
}
|
|
125
|
+
context.set("actor", context.auth.userId);
|
|
113
126
|
return next();
|
|
114
127
|
},
|
|
115
128
|
]);
|
|
116
129
|
|
|
117
130
|
const server = new RPCServer(undefined, stack);
|
|
131
|
+
|
|
132
|
+
// In the transport, after verifying the caller's credentials yourself:
|
|
133
|
+
await server.handle(frame, { auth: { userId: verifiedUserId } });
|
|
118
134
|
```
|
|
119
135
|
|
|
120
136
|
Each middleware may call `next()` once. Input validation runs before the
|
|
121
|
-
stack
|
|
137
|
+
stack. `context.input` holds the payload as the procedure's schema parsed
|
|
138
|
+
it (unknown keys stripped, defaults applied, values coerced), so authorise
|
|
139
|
+
on `context.input`, not on `context.request.payload`, which stays the raw
|
|
140
|
+
frame value.
|
|
122
141
|
|
|
123
142
|
## Errors
|
|
124
143
|
|
package/dist/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -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
|
*/
|
|
@@ -65,8 +65,13 @@ 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) {
|
|
74
|
+
async handle(request, trusted = {}) {
|
|
70
75
|
const requestId = typeof request?.id === "string"
|
|
71
76
|
? request.id
|
|
72
77
|
: "";
|
|
@@ -77,7 +82,7 @@ export class RPCServer {
|
|
|
77
82
|
// reads it as an object, so a frame without it used to fail with a
|
|
78
83
|
// TypeError reported as an internal error.
|
|
79
84
|
const frame = request.metadata === undefined ? { ...request, metadata: {} } : request;
|
|
80
|
-
return await this.dispatcher.dispatch(frame);
|
|
85
|
+
return await this.dispatcher.dispatch(frame, trusted);
|
|
81
86
|
}
|
|
82
87
|
catch (error) {
|
|
83
88
|
const mapped = this.mapError(error);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/rpc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.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.0
|
|
32
|
-
"@zudojs/types": "1.
|
|
33
|
-
"@zudojs/schema": "1.0
|
|
30
|
+
"@zudojs/errors": "1.1.0",
|
|
31
|
+
"@zudojs/constants": "1.1.0",
|
|
32
|
+
"@zudojs/types": "1.1.0",
|
|
33
|
+
"@zudojs/schema": "1.1.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"typescript": "7.0.2",
|