Skip to main content

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:

  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 ServerOn WebUse Case
@ServerRPC call to serverRegisters handlerEmpty bodyClient requests server data/actions
@ClientRegisters handlerRPC call to clientEmpty bodyServer pushes to client
@WebRPC call to web UIRPC routed via client to webRegisters handlerClient/server updates web UI
@ServerOnlyThrows errorExecutes locallyThrows errorServer-only guards
@ClientOnlyExecutes locallyThrows errorThrows errorClient-only guards
@WebOnlyThrows errorThrows errorExecutes locallyWeb-only guards
Runtime Guards Required

@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,
});
Server→Web Routing

When calling @Web from server, the call flows: Server → Client (relay) → Web UI → Client → Server (response). This adds latency compared to client→web calls.

Prefer webOwned

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:

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 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

  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. Prefer webOwned over @Web - For simple state updates, observables are simpler
  6. Document parameters - Use JSDoc for complex methods
  7. Type everything - Full type coverage for args and returns