@cc-emu/yaka-nest
v3.7.0
Published
NestJS microservice transport over Yaka (RakNet)
Readme
@cc-emu/yaka-nest
NestJS microservice transport over Yaka.
Installation
npm install @cc-emu/yaka-nestDevelopment
npm install
npm run build
npm testRelease
Trigger the release workflow manually and choose patch/minor/major.
Usage
Packet classes extend YakaSerializable and are decorated with @YakaPacket(id).
A packet without an id is skipped (with a warning), which is handy for
feature-gated packets. An optional { version } binds the class to a protocol
version (see Protocol version).
@YakaPacket self-registers the class into a process-global registry at
import time, so a packet ends up in the codec only when its module is actually
loaded (i.e. when it is used). The server and the client both build their native
codec from this registry — there is no central protocol object to maintain.
// packets.ts
import { YakaPacket, YakaReply, YakaSerializable, type OutStream, type InStream } from '@cc-emu/yaka-nest';
@YakaPacket(1002)
export class PongPacket extends YakaSerializable {
reply = '';
write(out: OutStream): void {
out.writeString(this.reply);
}
read(input: InStream): void {
this.reply = input.readString() ?? '';
}
}
@YakaPacket(1003)
export class PingErrorPacket extends YakaSerializable {
code = 0;
write(out: OutStream): void {
out.writeInt32(this.code);
}
read(input: InStream): void {
this.code = input.readInt32();
}
}
// Reply classes (above) must be declared before @YakaReply references them.
@YakaPacket(1001)
@YakaReply(PongPacket, PingErrorPacket)
export class PingPacket extends YakaSerializable {
message = '';
write(out: OutStream): void {
out.writeString(this.message);
}
read(input: InStream): void {
this.message = input.readString() ?? '';
}
}
@YakaPacket(2001)
export class PlayerMoveEvent extends YakaSerializable {
x = 0;
y = 0;
write(out: OutStream): void {
out.writeFloat(this.x);
out.writeFloat(this.y);
}
read(input: InStream): void {
this.x = input.readFloat();
this.y = input.readFloat();
}
}Reply contract
@YakaReply(...) declares the reply contract on a query class. It is
read by the client to know which reply ids to await for request() / send().
The server ignores it — on the server, whether a reply is sent is decided purely
by the handler return value (return a YakaSerializable and it is sent; return
void and nothing is sent). Declare the reply classes before the query class,
since the decorator evaluates at class-definition time.
- One class maps to exactly one
(id, version). The same numeric id may be shared by several classes across different versions (the overlay pattern). - Two different classes claiming the same
(id, version)log a warning and the later one wins. - Naming convention:
*Command,*Query,*Reply,*Event— the role and direction are visible from the class name.
Send options
Per-class send settings (priority / reliability / orderingChannel) are declared
via the sendOptions option of YakaServerTransport and YakaClientProxy, as
an array of [PacketClass, SendOptions] tuples. Classes without an entry send
with the native defaults (MEDIUM_PRIORITY, RELIABLE_ORDERED, channel 0).
new YakaServerTransport({
port: 19132,
sendOptions: [[PongPacket, { priority: 0, reliability: 0 }]],
});Protocol version
All packets live in a single codec keyed by (id, version): a class without
{ version } forms the default layer, a versioned class sits alongside it — a
versioned packet and a default packet may share the same numeric id. At runtime
the entry is resolved per connection: exact (id, connectionVersion) match wins,
otherwise the default entry is used, otherwise deserialization fails.
Registration is always version-agnostic — neither the server nor the client
build their codec under a pinned version; version only participates in runtime
resolution.
protocolVersion in YakaServerTransport options overrides the version used to
resolve handlers at runtime for every connection
(options.protocolVersion ?? client.protocolVersion): dispatch tries the exact
{ id, version } match first and falls back to the versionless
@YakaQuery(Packet) handler, otherwise it logs a No handler for pattern
warning. It does not touch packet registration or the underlying codec:
new YakaServerTransport({
port: 19132,
protocolVersion: '0.28.01.98479', // handler dispatch override for every client
});Without the override each connection's version starts unset (default entries only) and is assigned from a handler once the client is identified:
@YakaQuery(AuthPacket)
handleAuth(data: AuthPacket, ctx: YakaContext): void {
ctx.getConnection().setProtocolVersion(data.clientVersion);
}Note: the first packet(s) a client sends must resolve against the default layer — a versioned packet arriving before the version is set falls back to the default entry with the same id, or fails to deserialize if there is none.
The client knows its version upfront and declares it via protocolVersion in
YakaClientProxy options — it is forwarded to the YakaConnection automatically.
This is how a packet contract evolves — keep the common packets default, and declare only the changed shape as a versioned class:
// common packets -> default layer, work for every version
@YakaPacket(0x88a3)
@YakaReply(AuthResultPacket)
class AuthPacket extends YakaSerializable { /* legacy shape */ }
// changed shape in 0.28 -> versioned entry (shares id 0x88a3 with the legacy one)
@YakaPacket(0x88a3, { version: '0.28.01.98479' })
@YakaReply(AuthResultPacketV28)
class AuthPacketV28 extends YakaSerializable { /* 0.28 shape */ }To override handler behavior for a specific revision within one process, decorate
the versioned class — the route pattern comes from the class's @YakaPacket
registration, so there is nothing to pass at the query level. Dispatch tries the
exact { id, version } match first and falls back to the versionless
@YakaQuery(Packet) default handler:
@YakaQuery(AuthPacket)
handleAuthDefault(data: AuthPacket, ctx: YakaContext): AuthResultPacket { /* shared path */ }
@YakaQuery(AuthPacketV28)
handleAuthV28(data: AuthPacketV28, ctx: YakaContext): AuthResultPacketV28 { /* v2-only path */ }Server
// main.ts
import { NestFactory } from '@nestjs/core';
import { YakaServerTransport } from '@cc-emu/yaka-nest';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.createMicroservice(AppModule, {
strategy: new YakaServerTransport({
port: 19132,
logLevel: 'debug',
}),
});
await app.listen();
}
bootstrap();A handler is invoked for every incoming packet. If it returns a YakaSerializable
(or an array of them), each element is sent back to the client as a separate
packet; if it returns void / null, nothing is sent (command semantics). There
is no reply-contract validation on the server — return whatever packet you like:
// app.controller.ts
import { Controller } from '@nestjs/common';
import { YakaClient, YakaContext, YakaQuery } from '@cc-emu/yaka-nest';
import type { YakaConnection } from '@cc-emu/yaka';
import { PingPacket, PongPacket, PingErrorPacket, PlayerMoveEvent } from './packets';
@Controller()
export class AppController {
// Returns a Serializable -> sent to the client.
// @YakaClient() injects the connection that sent the packet.
@YakaQuery(PingPacket)
handlePing(data: PingPacket, @YakaClient() client: YakaConnection): PongPacket | PingErrorPacket {
const response = new PongPacket();
response.reply = `pong: ${data.message}`;
return response;
}
// Returns void -> fire-and-forget command. The full context is available too.
@YakaQuery(PlayerMoveEvent)
handleMove(data: PlayerMoveEvent, context: YakaContext): void {
const connection = context.getConnection();
}
// An array -> each element sent as a separate packet.
@YakaQuery(PingPacket)
handlePingBurst(): PongPacket[] {
return [new PongPacket(), new PongPacket()];
}
}@YakaClient() is a parameter decorator that resolves to the YakaConnection of
the client that sent the inbound packet (a shortcut for context.getConnection()).
The full YakaContext is also available as the second handler argument.
Shared handlers
Several packet classes can share one handler — list them all on a single
@YakaQuery. Disambiguate the incoming packet in the body via instanceof,
data.constructor, or ctx.getPattern():
@YakaQuery(PingPacket, PlayerMoveEvent)
handleShared(data: PingPacket | PlayerMoveEvent, ctx: YakaContext): void {
if (data instanceof PingPacket) {
// ping path
}
// or branch on ctx.getPattern() — the matched packet id
}Unregistered classes are warned about and dropped; if every listed class is unregistered the whole decorator is skipped.
Logging
Set logLevel: 'debug' to have the underlying YakaServer emit per-packet debug
events (direction, packet id, length, totals, raw bytes). By default those events
are bridged into the transport's NestJS Logger, formatted as a hex dump:
new YakaServerTransport({
port: 19132,
logLevel: 'debug',
});Provide your own logger (any YakaLogger) to route debug/warn/error elsewhere:
import { createYakaLogger, YakaServerTransport } from '@cc-emu/yaka-nest';
new YakaServerTransport({
port: 19132,
logLevel: 'debug',
logger: createYakaLogger(app.get(Logger)), // bridge any NestJS LoggerService
});logLevel and logger are native YakaServer options and are forwarded as-is.
The client (YakaClientProxy) logs through its own NestJS Logger.
Server-side errors from YakaServer are routed by type: YakaDeserializeError
(malformed incoming packet) is logged as a warning, YakaSerializeError /
YakaConnectionError and other errors are logged as errors. Error classes are
re-exported from this package for instanceof checks.
Client
// app.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule } from '@nestjs/microservices';
import { YakaClientProxy } from '@cc-emu/yaka-nest';
@Module({
imports: [
ClientsModule.register([
{
name: 'YAKA_SERVICE',
customClass: YakaClientProxy,
options: {
host: 'localhost',
port: 19132,
protocolVersion: '0.28.01.98479',
sendOptions: [[PingPacket, { priority: 0, reliability: 0 }]],
},
},
]),
],
})
export class AppModule {}// game.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { YakaClientProxy } from '@cc-emu/yaka-nest';
import { PingPacket, PongPacket } from './packets';
@Injectable()
export class GameService {
constructor(@Inject('YAKA_SERVICE') private readonly proxy: YakaClientProxy) {}
async ping(message: string): Promise<PongPacket> {
// request() reads @YakaReply from PingPacket and awaits the first match.
return this.proxy.request<PongPacket>(new PingPacket(message)).toPromise();
}
movePlayer(data: { x: number; y: number }) {
this.proxy.emit(String(2001), data);
}
}request() resolves with the first incoming packet whose id is among the query's
declared @YakaReply. If the packet declares no @YakaReply, request()
throws and send() is treated as fire-and-forget.
Yaka-native helpers
You can also use the fluent builder for fire-and-forget messages:
import { YakaClientProxy } from '@cc-emu/yaka-nest';
export class GameService {
constructor(@Inject('YAKA_SERVICE') private readonly client: YakaClientProxy) {}
sendPing(message: string) {
this.client.to(PingPacket).emit({ message });
}
}