Skip to main content

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:

  1. Decorator declarations - TypeScript type definitions (src/typings/builtins.d.ts)
  2. SWC plugin transformation - Compile-time code generation (@true_life/swc-plugin-modules)
  3. Runtime RPC system - Network communication (src/lib/rpc.ts)
Source Code Location

The SWC plugin source code is located in a separate repository: true_life_natives/swc-plugin/modules/

Available Decorators

DecoratorOn ClientOn ServerUse Case
@ServerRPC call to serverRegisters handlerClient requests server data/actions
@ClientRegisters handlerRPC call to clientServer pushes to client
@ServerOnlyThrows errorExecutes locallyServer-only guards
@ClientOnlyExecutes locallyThrows errorClient-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

  1. Keep methods focused - One action per RPC method
  2. Validate on server - Never trust client data
  3. Use fire-and-forget - For non-critical updates (noResponse: true)
  4. Handle errors gracefully - Wrap in try/catch on caller side
  5. Document parameters - Use JSDoc for complex methods
  6. 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:

MethodPurpose
__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:

  1. Parameter count - RPC methods must have exactly 1 parameter
  2. Parameter type - Must be EventContext<T>
  3. 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]>) { }