diff --git a/rpc/src/core/base.ts b/rpc/src/core/base.ts index 3330db8..5024a05 100644 --- a/rpc/src/core/base.ts +++ b/rpc/src/core/base.ts @@ -4,13 +4,14 @@ import { notRegisteredMessage, RPCError, } from '../utils/errors' -import { generateUUID } from '../utils/funcs' +import { generateUUID, isRPCState, parse } from '../utils/funcs' import { Pending } from '../utils/pending' import { type RPCConfig, type RPCEnvironment, RPCErrors, type RPCState, + type RPCStateRaw, } from '../utils/types' export class RPCInstanceBase { @@ -36,7 +37,7 @@ export class RPCInstanceBase { emitter.on(event, cb) return this } - + /** Unregisters `cb` for `event` on `emitter`; `method` is only for logs */ protected unlisten(emitter: Emitter, method: string, event: string): this { this.log(`${method} ${event}`) @@ -76,15 +77,46 @@ export class RPCInstanceBase { return this._emitterLocal.emit(event, ...args) } - /** Settles the call waiting for `response`; ignores late or unexpected ones */ - protected settle(response: RPCState): void { + /** + * Settles the call waiting for `response`; ignores late or unexpected ones. + * + * @param peer - who sent the response (server: `source`) + */ + protected settle(response: RPCState, peer?: number): void { const found = response.error - ? this._pending.reject(response.uuid, RPCError.fromResponse(response)) - : this._pending.resolve(response.uuid, response.data?.[0]) + ? this._pending.reject( + response.uuid, + RPCError.fromResponse(response), + peer, + ) + : this._pending.resolve(response.uuid, response.data?.[0], peer) if (!found) this.logIgnored(response) } + /** + * Parses and validates an incoming payload. Anything else sent on the RPC + * channels (broken JSON, other shapes) is dropped: `null`. + */ + protected accept(input: RPCStateRaw | unknown): RPCState | null { + const payload = + typeof input === 'string' + ? parse(input as RPCStateRaw) + : isRPCState(input) + ? input + : null + + if (!payload) { + this.log(`dropped invalid payload ${String(input).slice(0, 200)}`) + return null + } + + this.log( + `accepted ${payload.type} ${payload.event} from ${payload.calledFrom}`, + ) + return payload + } + /** * Runs the listener for `request` and builds the response to send back. * Never throws: a missing listener or a thrown error ends up in `error`. diff --git a/rpc/src/core/client.ts b/rpc/src/core/client.ts index 98bdd39..d718973 100644 --- a/rpc/src/core/client.ts +++ b/rpc/src/core/client.ts @@ -86,7 +86,6 @@ export class RPCInstanceClient extends RPCInstanceBase { return this.dispatch(this._emitterWeb, payload) } if (payload.calledTo === 'server') { - payload.player = GetPlayerServerId(PlayerId()) emitNet(RPCEvents.LISTENER_WEB, stringify(payload)) return this._pending.wait(payload) @@ -100,7 +99,6 @@ export class RPCInstanceClient extends RPCInstanceBase { return { status: 'ok' } } if (payload.calledTo === 'server') { - payload.player = GetPlayerServerId(PlayerId()) emitNet(RPCEvents.LISTENER_WEB, stringify(payload)) return { status: 'ok' } @@ -135,12 +133,7 @@ export class RPCInstanceClient extends RPCInstanceBase { Arguments extends Parameters, Response extends ReturnType, >(eventName: EventName, ...args: Arguments): Promise> { - const payload = this.request( - eventName, - 'server', - args, - GetPlayerServerId(PlayerId()), - ) + const payload = this.request(eventName, 'server', args, null) emitNet(RPCEvents.LISTENER_CLIENT, stringify(payload)) @@ -173,7 +166,7 @@ export class RPCInstanceClient extends RPCInstanceBase { Arguments extends Parameters, Response extends ReturnType, >(eventName: EventName, ...args: Arguments): Promise> { - const payload = this.request(eventName, 'webview', args, PlayerId()) + const payload = this.request(eventName, 'webview', args, null) this._sendWebMessage({ origin: RPCEvents.LISTENER_CLIENT, diff --git a/rpc/src/core/server.ts b/rpc/src/core/server.ts index b7ea9f3..977f2bf 100644 --- a/rpc/src/core/server.ts +++ b/rpc/src/core/server.ts @@ -25,71 +25,44 @@ export class RPCInstanceServer extends RPCInstanceBase { console.log('[RPC] Initialized Server') - onNet(RPCEvents.LISTENER_CLIENT, this._handleClient.bind(this)) - onNet(RPCEvents.LISTENER_WEB, this._handleWeb.bind(this)) + // `source` must be read synchronously, before any await + onNet(RPCEvents.LISTENER_CLIENT, (raw: RPCStateRaw) => + this._handle(raw, source, 'client', this._emitterClient), + ) + onNet(RPCEvents.LISTENER_WEB, (raw: RPCStateRaw) => + this._handle(raw, source, 'webview', this._emitterWeb), + ) } // ===== HANDLERS ===== - private async _handleClient(payloadRaw: RPCStateRaw) { + /** + * Handles a payload from a client or its webview. + * + * @param player - FiveM `source` of the net event: the only trusted player id, + * whatever the payload claims + */ + private async _handle( + payloadRaw: RPCStateRaw, + player: number, + from: 'client' | 'webview', + emitter: Emitter, + ) { const payload = this.accept(payloadRaw) - if (!payload) return + if (!payload || payload.calledFrom !== from) return - if (payload.calledFrom === 'client') { - if (payload.type === 'event') { - if (payload.player === null || payload.player === -1) { - // nobody to reply to, the caller times out - console.error( - new RPCError( - RPCErrors.NO_PLAYER, - `${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`, - ), - ) - return - } - - const response = await this.dispatch( - this._emitterClient, - payload, - payload.player, - ) - - emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response)) - } - if (payload.type === 'response') { - this.settle(payload) - } + // not sent by a player, e.g. a server-side trigger of the RPC channel + if (!(player > 0)) { + this.log(`dropped ${payload.event}: no player source`) + return } - } + payload.player = player - private async _handleWeb(payloadRaw: RPCStateRaw) { - const payload = this.accept(payloadRaw) - if (!payload) return - - if (payload.calledFrom === 'webview') { - if (payload.type === 'event') { - if (payload.player === null || payload.player === -1) { - // nobody to reply to, the caller times out - console.error( - new RPCError( - RPCErrors.NO_PLAYER, - `${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`, - ), - ) - return - } - - const response = await this.dispatch( - this._emitterWeb, - payload, - payload.player, - ) - - emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response)) - } - if (payload.type === 'response') { - this.settle(payload) - } + if (payload.type === 'event') { + const response = await this.dispatch(emitter, payload, player) + emitNet(RPCEvents.LISTENER_SERVER, player, stringify(response)) + } else { + this.settle(payload, player) } } @@ -128,7 +101,7 @@ export class RPCInstanceServer extends RPCInstanceBase { emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload)) - return this._pending.wait>(payload) + return this._pending.wait>(payload, player) } public async emitClientEveryone< @@ -175,7 +148,7 @@ export class RPCInstanceServer extends RPCInstanceBase { emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload)) - return this._pending.wait>(payload) + return this._pending.wait>(payload, player) } // ===== SELF ===== diff --git a/rpc/src/fivem.d.ts b/rpc/src/fivem.d.ts index 3d9a00a..716b21b 100644 --- a/rpc/src/fivem.d.ts +++ b/rpc/src/fivem.d.ts @@ -21,11 +21,14 @@ declare function RegisterCommand( restricted: boolean, ): void +// ===== SERVER ===== + +/** Player that sent the current net event. Only valid synchronously in the handler */ +declare var source: number + // ===== CLIENT ===== declare function RegisterNuiCallbackType(callbackType: string): void -declare function GetPlayerServerId(player: number): number -declare function PlayerId(): number declare function SetNuiFocus(hasFocus: boolean, hasCursor: boolean): void declare function SendNuiMessage(jsonString: string): boolean diff --git a/rpc/src/utils/pending.ts b/rpc/src/utils/pending.ts index 07dd1ad..c2f419b 100644 --- a/rpc/src/utils/pending.ts +++ b/rpc/src/utils/pending.ts @@ -5,6 +5,8 @@ type Call = { resolve: (data: unknown) => void reject: (error: Error) => void timer: ReturnType | undefined + /** Only a response from this peer settles the call (server: target player) */ + peer: number | undefined } /** Calls waiting for a response, keyed by payload uuid. */ @@ -14,13 +16,18 @@ export class Pending { /** @param timeout - ms before a call rejects, `0` or less disables it */ constructor(private readonly _timeout: number) {} - /** Waits for the response to `request`, rejects with `RPCErrors.TIMEOUT` */ - public wait(request: RPCState): Promise { + /** + * Waits for the response to `request`, rejects with `RPCErrors.TIMEOUT`. + * + * @param peer - if set, only a response from this peer settles the call + */ + public wait(request: RPCState, peer?: number): Promise { return new Promise((resolve, reject) => { const timer = this._timeout > 0 ? setTimeout( - () => this.reject(request.uuid, this._timeoutError(request)), + () => + this.reject(request.uuid, this._timeoutError(request), peer), this._timeout, ) : undefined @@ -29,30 +36,31 @@ export class Pending { resolve: resolve as (data: unknown) => void, reject, timer, + peer, }) }) } - /** @returns `false` if no call waits for this uuid (late or unexpected response) */ - public resolve(uuid: string, data: unknown): boolean { - const call = this._take(uuid) + /** @returns `false` if no call from `peer` waits for this uuid (late or unexpected response) */ + public resolve(uuid: string, data: unknown, peer?: number): boolean { + const call = this._take(uuid, peer) call?.resolve(data) return call !== undefined } - /** @returns `false` if no call waits for this uuid */ - public reject(uuid: string, error: Error): boolean { - const call = this._take(uuid) + /** @returns `false` if no call from `peer` waits for this uuid */ + public reject(uuid: string, error: Error, peer?: number): boolean { + const call = this._take(uuid, peer) call?.reject(error) return call !== undefined } - private _take(uuid: string): Call | undefined { + private _take(uuid: string, peer: number | undefined): Call | undefined { const call = this._calls.get(uuid) - if (call) { - this._calls.delete(uuid) - clearTimeout(call.timer) - } + if (!call || (call.peer !== undefined && call.peer !== peer)) return + + this._calls.delete(uuid) + clearTimeout(call.timer) return call } diff --git a/rpc/src/utils/types.ts b/rpc/src/utils/types.ts index d0fc19c..f022201 100644 --- a/rpc/src/utils/types.ts +++ b/rpc/src/utils/types.ts @@ -48,6 +48,7 @@ export type RPCState = { calledTo: RPCEnvironment error: RPCErrorPayload | null data: unknown[] | null + /** Server id of the player involved. The server fills it from `source`, never trusting the sender */ player: number | null type: RPCEventType } @@ -94,7 +95,6 @@ export enum RPCEvents { */ export enum RPCErrors { EVENT_NOT_REGISTERED = 'Event not registered', - NO_PLAYER = 'No player (failed to resolve from local index)', UNKNOWN_NATIVE = 'Unknown native event (if you are sure this exists - use native handler)', UNKNOWN_ENVIRONMENT = 'Unknown environment (must be either "server", "client" or "webview")', TIMEOUT = 'Timed out waiting for response',