RPC Decorators
RPC (Remote Procedure Call) decorators enable type-safe communication between client and server runtimes. The decorators are transformed at compile-time by a custom SWC plugin written in Rust.
How It Works
The RPC system has three layers:
- Decorator declarations - TypeScript type definitions (
src/typings/builtins.d.ts) - SWC plugin transformation - Compile-time code generation (
@true_life/swc-plugin-modules) - Runtime RPC system - Network communication (
src/lib/rpc.ts)
The SWC plugin source code is located in a separate repository: true_life_natives/swc-plugin/modules/
Available Decorators
| Decorator | On Client | On Server | Use Case |
|---|---|---|---|
@Server | RPC call to server | Registers handler | Client requests server data/actions |
@Client | Registers handler | RPC call to client | Server pushes to client |
@ServerOnly | Throws error | Executes locally | Server-only guards |
@ClientOnly | Executes locally | Throws error | Client-only guards |
EventContext Structure
All RPC methods receive an EventContext parameter:
type EventContext<T extends unknown[] = []> = {
args: T; // Parameters as tuple array
source: number; // Player server ID
noResponse?: boolean; // Fire-and-forget mode
};
@Server Decorator
Methods called from client, executed on server:
export class PlayerService {
@Server
async getPlayerData(ctx: EventContext<[playerId: string]>): Promise<PlayerData> {
const [playerId] = ctx.args;
const callerPlayerId = ctx.source; // Who called this
return await repository.findPlayer(playerId);
}
}
Calling from Client
// In client code
const data = await playerService.getPlayerData({
source: 0, // Ignored on client, set by system
args: ["player-123"],
});
@Client Decorator
Methods called from server, executed on client:
export class NotificationService {
@Client
async showNotification(ctx: EventContext<[message: string, type: string]>): Promise<void> {
const [message, type] = ctx.args;
// Runs on the specific client
sendHudEvent("notification:show", { message, type });
}
}
Calling from Server
// In server code - send to specific player
await notificationService.showNotification({
source: playerId, // Target player
args: ["Welcome!", "success"],
});
// Fire-and-forget (no response expected)
await notificationService.showNotification({
source: playerId,
args: ["Update received", "info"],
noResponse: true,
});
@ServerOnly Decorator
Guards that ensure code only runs on server:
export class DatabaseService {
@ServerOnly
initDatabase(): void {
// This will throw if called from client
connectToMongo();
}
}
@ClientOnly Decorator
Guards that ensure code only runs on client:
export class GameService {
@ClientOnly
setupControls(): void {
// This will throw if called from server
RegisterCommand("test", () => {}, false);
}
}
Complete Service Example
// src/modules/inventory/inventory.service.ts
import { inject } from "@core/di";
import { CharacterService } from "@modules/characters/character.service";
import * as repository from "@modules/inventory/repository";
export class InventoryService {
private characterService = inject(CharacterService);
/**
* Get player's inventory - called from client
*/
@Server
async getInventory(ctx: EventContext<[]>): Promise<InventoryItem[]> {
const character = this.characterService.getActiveCharacter(ctx.source);
if (!character) throw new Error("No active character");
return await repository.getInventory(character.id);
}
/**
* Add item to inventory - called from client
*/
@Server
async addItem(ctx: EventContext<[itemId: string, quantity: number]>): Promise<boolean> {
const [itemId, quantity] = ctx.args;
const character = this.characterService.getActiveCharacter(ctx.source);
if (!character) return false;
return await repository.addItem(character.id, itemId, quantity);
}
/**
* Notify client of inventory change - called from server
*/
@Client
async notifyInventoryUpdate(ctx: EventContext<[items: InventoryItem[]]>): Promise<void> {
const [items] = ctx.args;
sendHudEvent("inventory:update", { items });
}
/**
* Server initialization
*/
@ServerOnly
initServerSide(): void {
// Server-only setup
}
/**
* Client initialization
*/
@ClientOnly
initClientSide(): void {
// Client-only setup
}
}
Parameter Conventions
Use Tuple Parameters
// ✅ Good: Tuple parameters
@Server
async transfer(ctx: EventContext<[from: string, to: string, amount: number]>): Promise<void> {
const [from, to, amount] = ctx.args;
}
// ❌ Bad: Object parameters (avoid)
@Server
async transfer(ctx: EventContext<{ from: string; to: string; amount: number }>): Promise<void> {
const { from, to, amount } = ctx.args; // Don't do this
}
Optional Parameters
@Server
async search(ctx: EventContext<[query: string, limit?: number]>): Promise<Results> {
const [query, limit = 10] = ctx.args;
return await repository.search(query, limit);
}
Error Handling
Errors thrown in RPC methods are serialized and rethrown on the caller:
@Server
async withdraw(ctx: EventContext<[amount: number]>): Promise<void> {
const [amount] = ctx.args;
if (amount <= 0) {
throw new Error("Amount must be positive");
}
// This error will be received by the client
if (balance < amount) {
throw new Error("Insufficient funds");
}
}
Best Practices
- Keep methods focused - One action per RPC method
- Validate on server - Never trust client data
- Use fire-and-forget - For non-critical updates (
noResponse: true) - Handle errors gracefully - Wrap in try/catch on caller side
- Document parameters - Use JSDoc for complex methods
- Type everything - Full type coverage for args and returns
SWC Plugin Transformation
The @true_life/swc-plugin-modules transforms decorated methods at compile-time based on the build target.
Transformation Rules
Example: @Server Transformation
Original TypeScript:
class SpawnService {
@Server
async playerLoaded(ctx: EventContext<[character: Character]>): Promise<void> {
const [character] = ctx.args;
await this.spawnPlayer(ctx.source, character);
}
}
Client Build Output:
class SpawnService {
async playerLoaded(ctx) {
// Method body replaced with RPC call
return global.rpc.callServer(
`SpawnService:playerLoaded@${this.__moduleContext.moduleId}`,
ctx
);
}
}
Server Build Output:
class SpawnService {
async playerLoaded(ctx) {
// Original body preserved
const [character] = ctx.args;
await this.spawnPlayer(ctx.source, character);
}
// Generated by SWC plugin
__registerEvents(moduleContext) {
this.__moduleContext = moduleContext;
global.rpc.register(
`SpawnService:playerLoaded@${this.__moduleContext.moduleId}`,
this.playerLoaded.bind(this)
);
}
__unregisterEvents() {
global.rpc.unregister(
`SpawnService:playerLoaded@${this.__moduleContext.moduleId}`
);
this.__moduleContext = undefined;
}
}
Event Naming Convention
RPC events use the format: ClassName:methodName@moduleId
- ClassName - The service class name
- methodName - The decorated method name
- moduleId - The module name (provides namespacing)
Example: SpawnService:playerLoaded@spawning
Runtime Flow
Generated Methods
The SWC plugin generates two methods on classes with @Server or @Client decorators:
| Method | Purpose |
|---|---|
__registerEvents(moduleContext) | Called on module start, registers all RPC handlers |
__unregisterEvents() | Called on module stop, unregisters all RPC handlers |
These methods are called automatically by the module system during lifecycle hooks.
Plugin Configuration
The plugin is configured in rollup.client.js and rollup.server.js:
// rollup.client.js
swc({
jsc: {
experimental: {
plugins: [
["@true_life/swc-plugin-modules", { environment: "client" }],
// ... other plugins
]
}
}
})
Validation
The SWC plugin validates at compile time:
- Parameter count - RPC methods must have exactly 1 parameter
- Parameter type - Must be
EventContext<T> - Parameter pattern - Must be a simple identifier (not destructuring)
// ✅ Valid
@Server
async getData(ctx: EventContext<[id: string]>) { }
// ❌ Invalid - no parameter
@Server
async getData() { }
// ❌ Invalid - wrong type
@Server
async getData(ctx: { id: string }) { }
// ❌ Invalid - destructured parameter
@Server
async getData({ args }: EventContext<[id: string]>) { }