RPC
RPC stands for Remote Procedure Call and allows to call functions (procedures) on a remote server as if it were a local function. In contrast to HTTP client-server communication, the assignment is not done via the HTTP method and a URL, but the function name. The data to be sent is passed as normal function arguments and the result of the function call on the server is returned to the client.
The advantage of RPC is that the client-server abstraction is more lightweight, since no headers, URLs, query strings or the like are used. The disadvantage is that functions on a server via RPC cannot be called easily by a browser and it often requires a special client.
A key feature of RPC is that the data between the client and server is automatically serialized and deserialized. For this reason, type-safe RPC clients are usually possible. Some RPC frameworks therefore force users to provide the types (parameter types and return types) in a specific format. This can be in the form of a custom DSL as in gRPC (Protocol Buffers) and GraphQL with a code generator, or in the form of a JavaScript schema builder. Additional validation of the data can also be provided by the RPC framework, but is not supported by all.
In Deepkit RPC, the types from the functions are extracted from the TypeScript code itself (see Runtime Types), so there is no need to use a code generator or define them manually. Deepkit supports automatic serialization and deserialization of parameters and results. Once additional constraints are defined from Validation, they are also automatically validated. This makes communication via RPC extremely type-safe and effective. The support for streaming via rxjs
in Deepkit RPC also makes this RPC framework a suitable tool for real-time communication.
To illustrate the concept behind RPC the following code:
//server.ts
class Controller {
hello(title: string): string {
return 'Hello ' + title
}
}
A method like hello
is implemented normally within a class on the server and can then be called from a remote client.
//client.ts
const client = new RpcClient('localhost');
const controller = client.controller<Controller>();
const result = await controller.hello('World'); // => 'Hello World';
Since RPC is fundamentally based on asynchronous communication, communication is mostly over HTTP, but can also happen over TCP or WebSockets. This means that all function calls in TypeScript itself are converted to a promise
. With a corresponding await
the result can be received asynchronously.
Isomorphic TypeScript
As soon as a project uses TypeScript in the client (mostly frontend) and server (backend), it is called Isomorphic TypeScript. A type-safe RPC framework based on TypeScript’s types is then particularly profitable for such a project, since types can be shared between client and server.
To take advantage of this, types that are used on both sides should be swapped out into a separate file or package. Importing on the respective side will then merge them again.
//shared.ts
export class User {
id: number;
username: string;
}
interface UserControllerApi {
getUser(id: number): Promise<User>;
}
//server.ts
import { User } from './shared';
class UserController implements UserControllerApi {
async getUser(id: number): Promise<User> {
return await datbase.query(User).filter({id}).findOne();
}
}
//client.ts
import { UserControllerApi } from './shared';
const controller = client.controller<UserControllerApi>();
const user = await controller.getUser(2); // => User
The interface UserControllerApi
acts here as a contract between client and server. The server must implement this correctly and the client can consume it.
Backward compatibility can be implemented in the same way as for a normal local API: either new parameters are marked as optional or a new method is added.
While it is also possible to directly import UserController
via import type { UserController } from './server.ts
, this has other disadvantages like no support for nominal types (which means that class instances cannot be checked with instanceof
).
Installation
Since Deepkit RPC is based on Runtime Types, it is necessary to have @deepkit/type
already installed correctly. See Runtime Type Installation.
If this is done successfully, @deepkit/rpc
can be installed or the Deepkit framework which already uses the library under the hood.
npm install @deepkit/rpc
Note that controller classes in @deepkit/rpc
are based on TypeScript decorators and this feature must be enabled accordingly with experimentalDecorators
.
The @deepkit/rpc
package must be installed on the server and client if both have their own package.json
.
To communicate with the server via TCP, the @deepkit/rpc-tcp
package must be installed in the client and server.
For a WebSocket communication it needs the package on the server as well. The client in the browser, on the other hand, uses WebSocket
from the official standard.
npm install @deepkit/rpc-tcp
As soon as the client is to be used via WebSocket in an environment where WebSocket
is not available (for example NodeJS), the package ws
is required in the client.
npm install ws
Use
Below is a fully functional example based on WebSockets and the low-level API of @deepkit/rpc
. Once the Deepkit framework is used, controllers are provided via app modules and no RpcKernel is instantiated manually.
file: server.ts
import { rpc, RpcKernel } from '@deepkit/rpc';
import { RpcWebSocketServer } from '@deepkit/rpc-tcp';
@rpc.controller('myController');
export class Controller {
@rpc.action()
hello(title: string): string {
return 'Hello ' + title;
}
}
const kernel = new RpcKernel();
kernel.registerController(Controller);
const server = new RpcWebSocketServer(kernel, 'localhost:8081');
server.start();
file: client.ts
import { RpcWebSocketClient } from '@deepkit/rpc';
import type { Controller } from './server';
async function main() {
const client = new RpcWebSocketClient('localhost:8081');
const controller = client.controller<Controller>('myController');
const result = await controller.hello('World');
console.log('result', result);
client.disconnect();
}
main().catch(console.error);
Server Controller
The "Procedure" in Remote Procedure Call is also called Action. Such an action is defined as a method in a class and marked with the @rpc.action
decorator. The class itself is marked as controller by the @rpc.controller
decorator and assigned a unique name. This name is then referenced in the client to address the correct controller. Any number of controllers can be defined and registered.
import { rpc } from '@deepkit/rpc';
@rpc.controller('myController');
class Controller {
@rpc.action()
hello(title: string): string {
return 'Hello ' + title;
}
@rpc.action()
test(): boolean {
return true;
}
}
Only methods that are also marked as @rpc.action()
can be accessed by a client.
Types must be explicitly specified and cannot be inferred. This is important because the serializer needs to know exactly what the types are in order to convert them to binary data (BSON) or JSON.
Client Controller
The normal flow in RPC is that the client can perform functions on the server. However, it is also possible in Deepkit RPC for the server to perform functions on the client. To allow this, the client can also register a controller.
TODO
Dependency Injection
The controller classes are managed by the dependency injection container of @deepkit/injector
. When the Deepkit framework is used, these controllers automatically have access to the module’s providers that deploy the controller.
Controllers are instantiated in the Deepkit framework in the dependency injection scope rpc
so that all controllers automatically have access to various providers from this scope. These additional providers are HttpRequest
(optional), RpcInjectorContext
, SessionState
, RpcKernelConnection
, and ConnectionWriter
.
import { RpcKernel, rpc } from '@deepkit/rpc';
import { App } from '@deepkit/app';
import { Database, User } from './database';
@rpc.controller('my')
class Controller {
constructor(private database: Database) {}
@rpc.action()
async getUser(id: number): Promise<User> {
return await this.database.query(User).filter({id}).findOne();
}
}
new App({
providers: [{provide: Database, useValue: new Database}]
controllers: [Controller],
}).run();
However, as soon as a RpcKernel
is instantiated manually, a DI container can also be passed there. The RPC controller is then instantiated via this DI container.
import { RpcKernel, rpc } from '@deepkit/rpc';
import { InjectorContext } from '@deepkit/injector';
import { Database, User } from './database';
@rpc.controller('my')
class Controller {
constructor(private database: Database) {}
@rpc.action()
async getUser(id: number): Promise<User> {
return await this.database.query(User).filter({id}).findOne();
}
}
const injector = InjectorContext.forProviders([
Controller,
{provide: Database, useValue: new Database},
]);
const kernel = new RpcKernel(injector);
kernel.registerController(Controller);
See Dependency Injection to learn more.
Nominal Types
When data is received on the client from the function call, it was previously serialized on the server and then deserialized on the client. If classes are now used in the return type of the function, they are reconstructed in the client, but lose their nominal identity and all methods. To counteract this behavior, classes can be registered as nominal types via a unique ID. This should be done for all classes used in an RPC API.
To register a class it is necessary to use the decorator @entity.name('id')
.
import { entity } from '@deepkit/type';
@entity.name('user')
class User {
id!: number;
firstName!: string;
lastName!: string;
get fullName() {
return this.firstName + ' ' + this.lastName;
}
}
As soon as this class is now used as the result of a function, its identity is preserved.
const controller = client.controller<Controller>('controller');
const user = await controller.getUser(2);
user instanceof User; //true when @entity.name is used, and false if not
Error Forwarding
RPC functions can throw errors. These errors are forwarded to the client by default and thrown again there. If custom error classes are used, their nominal type should be enabled. See RPC Nominal Types.
@entity.name('@error:myError')
class MyError extends Error {}
//server
class Controller {
@rpc.action()
saveUser(user: User): void {
throw new MyError('Can not save user');
}
}
//client
//[MyError] makes sure the class MyError is known in runtime
const controller = client.controller<Controller>('controller', [MyError]);
try {
await controller.getUser(2);
} catch (e) {
if (e instanceof MyError) {
//ops, could not save user
} else {
//all other errors
}
}
Security
By default, all RPC functions can be called from any client. Also the feature Peer-To-Peer communication is activated by default. To be able to set exactly which client is allowed to do what, the class RpcKernelSecurity
can be overridden.
import { RpcKernelSecurity, Session, RpcControllerAccess } from '@deepkit/type';
//contains default implementations
class MyKernelSecurity extends RpcKernelSecurity {
async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
return true;
}
async isAllowedToRegisterAsPeer(session: Session, peerId: string): Promise<boolean> {
return true;
}
async isAllowedToSendToPeer(session: Session, peerId: string): Promise<boolean> {
return true;
}
async authenticate(token: any): Promise<Session> {
throw new Error('Authentication not implemented');
}
transformError(err: Error) {
return err;
}
}
To use this either the RpcKernel
is passed an instance of it:
const kernel = new RpcKernel(undefined, new MyKernelSecurity);
Or in the case of a Deepkit Framework application the class RpcKernelSecurity
is overwritten with a provider.
import { App } from '@deepkit/type';
import { RpcKernelSecurity } from '@deepkit/rpc';
import { FrameworkModule } from '@deepkit/framework';
new App({
controllers: [MyRpcController],
providers: [
{provide: RpcKernelSecurity, useClass: MyRpcKernelSecurity}
],
imports: [new FrameworkModule]
}).run();
Authentication / Session
The session
object is by default an anonymous session, which means that the client has not authenticated itself. As soon as it wants to authenticate, the authenticate
method is called. The token that the authenticate
method receives comes from the client and can have any value.
Once the client sets a token, authentication is performed as soon as the first RPC function or manually client.connect()
is called.
const client = new RpcWebSocketClient('localhost:8081');
client.token.set('123456789');
const controller = client.controller<Controller>('myController');
Here RpcKernelSecurity.authenticate
receives the token 123456789
and can return another session accordingly. This returned session is then passed to all other methods like the hasControllerAccess
.
import { Session, RpcKernelSecurity } from '@deepkit/rpc';
class UserSession extends Session {
}
class MyKernelSecurity extends RpcKernelSecurity {
async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
if (controllerAccess.controllerClassType instanceof MySecureController) {
//MySecureController requires UserSession
return session instanceof UserSession;
}
return true;
}
async authenticate(token: any): Promise<Session> {
if (token === '123456789') {
return new UserSession('username', token);
}
throw new Error('Authentication failed');
}
}
Controller Access
The hasControllerAccess
method can be used to determine whether a client is allowed to execute a specific RPC function. This method is executed on every RPC function call. If it returns false
, access is denied and an error is thrown on the client.
In RpcControllerAccess
there are several valuable information about the RPC function:
interface RpcControllerAccess {
controllerName: string;
controllerClassType: ClassType;
actionName: string;
actionGroups: string[];
actionData: { [name: string]: any };
}
Groups and additional data can be changed via the decorator @rpc.action()
:
class Controller {
@rpc.action().group('secret').data('role', 'admin')
saveUser(user: User): void {
}
}
class MyKernelSecurity extends RpcKernelSecurity {
async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
if (controllerAccess.actionGroups.includes('secret')) {
//todo: check
return false;
}
return true;
}
}
Transform Error
Since thrown errors are automatically forwarded to the client with all its information like the error message and also the stacktrace, this could unwantedly publish sensitive information. To change this, in the method transformError
the thrown error can be modified.
class MyKernelSecurity extends RpcKernelSecurity {
transformError(error: Error) {
//wrap in new error
return new Error('Something went wrong: ' + error.message);
}
}
Note that once the error is converted to a generic error
, the complete stack trace and the identity of the error are lost. Accordingly, no instanceof
checks can be used on the error in the client.
If Deepkit RPC is used between two microservices, and thus the client and server are under complete control of the developer, then transforming the error is rarely necessary. If, on the other hand, the client is running in a browser with an unknown, then care should be taken in transformError
as to what information is to be revealed. If in doubt, each error should be transformed with a generic Error
to ensure that no internal details are leaked. Logging the error would then be a good idea at this point.
Dependency Injection
If the Deepkit RPC library is used directly, the RpcKernelSecurity
class itself is instantiated. If this class needs a database or a logger, this must be passed itself.
When the Deepkit framework is used, the class is instantiated by the Dependency Injection container and thus automatically has access to all other providers in the application.
See also Dependency Injection.
Transport Protocol
Deepkit RPC supports several transport protocols. WebSockets is the protocol that has the best compatibility (since browsers support it) while supporting all features like streaming. TCP is usually faster and is great for communication between servers (microservices) or non-browser clients.
Deepkit’s RPC HTTP protocol is a variant that is particularly easy to debug in the browser, as each function call is an HTTP request, but has its limitations such as no support for RxJS streaming.