RPC Decorators
RPC (Remote Procedure Call) decorators enable type-safe communication between client, server, and web 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 | On Web | Use Case |
|---|---|---|---|---|
@Server | RPC call to server | Registers handler | Empty body | Client requests server data/actions |
@Client | Registers handler | RPC call to client | Empty body | Server pushes to client |
@Web | RPC call to web UI | RPC routed via client to web | Registers handler | Client/server updates web UI |
@ServerOnly | Throws error | Executes locally | Throws error | Server-only guards |
@ClientOnly | Executes locally | Throws error | Throws error | Client-only guards |
@WebOnly | Throws error | Throws error | Executes locally | Web-only guards |
@ServerOnly, @ClientOnly, and @WebOnly decorators throw errors when called in incompatible runtimes. Always guard calls with runtime constants from @core/runtime:
import { IS_SERVER } from "@core/runtime";
// ✅ Correct: Guard with runtime constant
if (IS_SERVER) {
myService.initServerSide();
}
// ❌ Wrong: Calling without guard throws error
myService.initServerSide(); // Throws: "@ServerOnly method 'MyService:initServerSide' cannot be called in client runtime."The raw __RUNTIME__ global is also available, but prefer the IS_* constants (IS_CLIENT, IS_SERVER, IS_WEB) for cleaner code and better tree-shaking.
The thrown error includes the decorator name, method name, and current runtime to aid debugging.
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 - update web UI via observable
this.notificationsState.set({
notifications: [...this.notificationsState.value.notifications, { 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,
});@Web Decorator
Methods called from client or server, executed on web UI:
export class NeedsService {
public hudState = webOwned<HudState>({
id: "needs:hud",
initialValue: { hunger: 100, thirst: 100, health: 100, armor: 0 },
});
@Web
async updateHud(ctx: EventContext<[state: HudState]>): Promise<void> {
const [state] = ctx.args;
// Runs on web UI
this.hudState.set(state);
}
}Calling @Web from Client
// In client code - fire-and-forget update to web UI
void needsService.updateHud({
source: 0,
args: [{ hunger: 80, thirst: 90, health: 100, armor: 50 }],
noResponse: true,
});Calling @Web from Server
When calling a @Web method from the server, the call is automatically routed through the target player's client to their web UI. The ctx.source must specify the target player ID:
// In server code - update a specific player's web UI
await needsService.updateHud({
source: playerId, // Required: target player ID
args: [{ hunger: 60, thirst: 70, health: 100, armor: 0 }],
});
// Fire-and-forget mode (no response expected)
void needsService.updateHud({
source: playerId,
args: [{ hunger: 60, thirst: 70, health: 100, armor: 0 }],
noResponse: true,
});When calling @Web from server, the call flows: Server → Client (relay) → Web UI → Client → Server (response). This adds latency compared to client→web calls.
For simple state updates, prefer using webOwned observables directly instead of @Web methods. The observable system handles synchronization automatically.
@ServerOnly Decorator
Guards that ensure code only runs on server (throws error elsewhere):
export class DatabaseService {
@ServerOnly
initDatabase(): void {
// Only runs on server
// Throws error if called on client or web
connectToMongo();
}
}
// In module.ts - always use runtime guard:
import { IS_SERVER } from "@core/runtime";
if (IS_SERVER) {
databaseService.initDatabase(); // ✅ Safe
}@ClientOnly Decorator
Guards that ensure code only runs on client (throws error elsewhere):
export class GameService {
@ClientOnly
setupControls(): void {
// Only runs on client
// Throws error if called on server or web
RegisterCommand("test", () => {}, false);
}
}
// In module.ts - always use runtime guard:
import { IS_CLIENT } from "@core/runtime";
if (IS_CLIENT) {
gameService.setupControls(); // ✅ Safe
}@WebOnly Decorator
Guards that ensure code only runs on web (throws error elsewhere):
export class UIService {
@WebOnly
initUI(): void {
// Only runs on web
// Throws error if called on client or server
document.body.classList.add("dark");
}
}
// In module.ts - always use runtime guard:
import { IS_WEB } from "@core/runtime";
if (IS_WEB) {
uiService.initUI(); // ✅ Safe
}Complete Service Example
// src/modules/inventory/inventory.service.ts
import { inject } from "@core/di";
import { webOwned } from "@core/observable";
import { CharacterService } from "@modules/core/characters/character.service";
import * as repository from "@modules/inventory/repository";
export class InventoryService {
private characterService = inject(CharacterService);
// Web-owned observable for UI state
public inventoryState = webOwned<InventoryState>({
id: "inventory:state",
initialValue: { items: [], isLoading: true },
});
/**
* 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);
}
/**
* Update web UI with inventory - called from client
*/
@Web
async updateInventoryUI(ctx: EventContext<[items: InventoryItem[]]>): Promise<void> {
const [items] = ctx.args;
this.inventoryState.set({ items, isLoading: false });
}
/**
* Server initialization
*/
@ServerOnly
initServerSide(): void {
// Server-only setup
}
/**
* Client initialization
*/
@ClientOnly
initClientSide(): void {
// Client-only setup - maybe load inventory and update UI
}
/**
* Web initialization
*/
@WebOnly
initWebSide(): void {
// Web-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");
}
}SWC Plugin Transformation
The @true_life/swc-plugin-modules transforms decorated methods at compile-time based on the build target.
Transformation Rules
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
Generated Methods
The SWC plugin generates two methods on classes with RPC 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 build files:
// rollup.client.js / rollup.server.js
swc({
jsc: {
experimental: {
plugins: [
["@true_life/swc-plugin-modules", { environment: "client" }], // or "server"
],
},
},
});
// vite.config.ts (for web)
swc.vite({
jsc: {
experimental: {
plugins: [["@true_life/swc-plugin-modules", { environment: "web" }]],
},
},
});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
- Prefer webOwned over @Web - For simple state updates, observables are simpler
- Document parameters - Use JSDoc for complex methods
- Type everything - Full type coverage for args and returns