fix(rpc): better error handling

This commit is contained in:
2026-09-28 02:05:40 +01:00
parent 79faa3b23c
commit 571d113386
8 changed files with 282 additions and 148 deletions
+24 -33
View File
@@ -1,6 +1,7 @@
import type * as s from '@entityseven/fivem-rpc-shared-types'
import { Emitter } from '../utils/emitter'
import { RPCError } from '../utils/errors'
import { generateUUID, parse, stringify, stringifyWeb } from '../utils/funcs'
import {
NATIVE_CLIENT_EVENTS,
@@ -35,8 +36,11 @@ export class RPCInstanceClient extends Wrapper {
on(
`__cfx_nui:${RPCEvents.LISTENER_WEB}`,
async (data: RPCState, callback: (res: unknown) => void) => {
const res = await this._handleWeb(data)
callback(res)
try {
callback(await this._handleWeb(data))
} catch (e) {
callback(this.errorResponse(data, e))
}
},
)
}
@@ -47,7 +51,7 @@ export class RPCInstanceClient extends Wrapper {
try {
parse(payloadRaw)
} catch {
throw new Error(RPCErrors.INVALID_DATA)
throw new RPCError(RPCErrors.INVALID_DATA, RPCErrors.INVALID_DATA)
}
const payload = parse(payloadRaw)
@@ -59,23 +63,7 @@ export class RPCInstanceClient extends Wrapper {
if (payload.type === 'event') {
if (payload.calledTo === 'client') {
this.verifyEvent(this._emitterServer, payload)
const responseData = await this._emitterServer.emit(
payload.event,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
const response: RPCState = {
event: payload.event,
uuid: payload.uuid,
calledFrom: 'client',
calledTo: 'server',
error: null,
data: [responseData],
player: payload.player,
type: 'response',
}
const response = await this.dispatch(this._emitterServer, payload)
emitNet(RPCEvents.LISTENER_CLIENT, stringify(response))
}
@@ -87,8 +75,14 @@ export class RPCInstanceClient extends Wrapper {
}
}
if (payload.type === 'response') {
if (payload.calledTo === 'client' || payload.calledTo === 'webview') {
this.resolvePending(payload)
if (payload.calledTo === 'client') {
this.settle(payload)
}
if (payload.calledTo === 'webview') {
// relayed webview -> server call: the webview gets the whole response
if (!this._pending.resolve(payload.uuid, payload)) {
this.logIgnored(payload)
}
}
}
}
@@ -102,22 +96,19 @@ export class RPCInstanceClient extends Wrapper {
if (payload.type === 'event') {
if (payload.calledTo === 'client') {
return await this._emitterWeb.emit(
payload.event,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
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.uuid)
return this._pending.wait<RPCState>(payload)
}
}
if (payload.type === 'response') {
if (payload.calledTo === 'client') {
this.resolvePending(payload)
this.settle(payload)
return { status: 'ok' }
}
@@ -182,7 +173,7 @@ export class RPCInstanceClient extends Wrapper {
emitNet(RPCEvents.LISTENER_CLIENT, stringify(payload))
return this._pending.wait<Awaited<Response>>(payload.uuid)
return this._pending.wait<Awaited<Response>>(payload)
}
// ===== WEBVIEW =====
@@ -239,7 +230,7 @@ export class RPCInstanceClient extends Wrapper {
data: payload,
})
return this._pending.wait<Awaited<Response>>(payload.uuid)
return this._pending.wait<Awaited<Response>>(payload)
}
// ===== SELF =====
@@ -297,7 +288,7 @@ export class RPCInstanceClient extends Wrapper {
)
}
this.verifyEvent(this._emitterLocal, payload)
this.assertListener(this._emitterLocal, payload.event)
return await this._emitterLocal.emit<Awaited<Response>>(
payload.event,
@@ -328,7 +319,7 @@ export class RPCInstanceClient extends Wrapper {
CallbackArguments extends Parameters<RPCNativeClientEvents[EventName]>,
>(eventName: EventName, cb: (...args: CallbackArguments) => void): this {
if (!NATIVE_CLIENT_EVENTS.includes(eventName)) {
throw new Error(RPCErrors.UNKNOWN_NATIVE)
throw new RPCError(RPCErrors.UNKNOWN_NATIVE, RPCErrors.UNKNOWN_NATIVE)
}
if (this.debug) {
@@ -347,7 +338,7 @@ export class RPCInstanceClient extends Wrapper {
>,
>(eventName: EventName, cb: (...args: CallbackArguments) => void): this {
if (!NATIVE_CLIENT_NETWORK_EVENTS.includes(eventName)) {
throw new Error(RPCErrors.UNKNOWN_NATIVE)
throw new RPCError(RPCErrors.UNKNOWN_NATIVE, RPCErrors.UNKNOWN_NATIVE)
}
if (this.debug) {
+29 -42
View File
@@ -1,6 +1,7 @@
import type * as s from '@entityseven/fivem-rpc-shared-types'
import { Emitter } from '../utils/emitter'
import { RPCError } from '../utils/errors'
import { generateUUID, parse, stringify } from '../utils/funcs'
import { NATIVE_SERVER_EVENTS } from '../utils/native'
import {
@@ -35,7 +36,7 @@ export class RPCInstanceServer extends Wrapper {
try {
parse(payloadRaw)
} catch {
throw new Error(RPCErrors.INVALID_DATA)
throw new RPCError(RPCErrors.INVALID_DATA, RPCErrors.INVALID_DATA)
}
const payload = parse(payloadRaw)
@@ -47,34 +48,27 @@ export class RPCInstanceServer extends Wrapper {
if (payload.calledFrom === 'client') {
if (payload.type === 'event') {
this.verifyEvent(this._emitterClient, payload)
if (payload.player === null || payload.player === -1) {
payload.error = RPCErrors.NO_PLAYER
this.triggerError(payload)
// nobody to reply to, the caller times out
this.console.error(
new RPCError(
RPCErrors.NO_PLAYER,
`${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`,
),
)
return
}
const responseData = await this._emitterClient.emit(
payload.event,
const response = await this.dispatch(
this._emitterClient,
payload,
payload.player,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
const response: RPCState = {
event: payload.event,
uuid: payload.uuid,
calledFrom: 'server',
calledTo: 'client',
error: null,
data: [responseData],
player: payload.player,
type: 'response',
}
emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response))
}
if (payload.type === 'response') {
this.resolvePending(payload)
this.settle(payload)
}
}
}
@@ -83,7 +77,7 @@ export class RPCInstanceServer extends Wrapper {
try {
parse(payloadRaw)
} catch {
throw new Error(RPCErrors.INVALID_DATA)
throw new RPCError(RPCErrors.INVALID_DATA, RPCErrors.INVALID_DATA)
}
const payload = parse(payloadRaw)
@@ -95,34 +89,27 @@ export class RPCInstanceServer extends Wrapper {
if (payload.calledFrom === 'webview') {
if (payload.type === 'event') {
this.verifyEvent(this._emitterWeb, payload)
if (payload.player === null || payload.player === -1) {
payload.error = RPCErrors.NO_PLAYER
this.triggerError(payload)
// nobody to reply to, the caller times out
this.console.error(
new RPCError(
RPCErrors.NO_PLAYER,
`${RPCErrors.NO_PLAYER}: "${payload.event}" from ${payload.calledFrom}`,
),
)
return
}
const responseData = await this._emitterWeb.emit(
payload.event,
const response = await this.dispatch(
this._emitterWeb,
payload,
payload.player,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
const response: RPCState = {
event: payload.event,
uuid: payload.uuid,
calledFrom: 'server',
calledTo: 'webview',
error: null,
data: [responseData],
player: payload.player,
type: 'response',
}
emitNet(RPCEvents.LISTENER_SERVER, response.player, stringify(response))
}
if (payload.type === 'response') {
this.resolvePending(payload)
this.settle(payload)
}
}
}
@@ -183,7 +170,7 @@ export class RPCInstanceServer extends Wrapper {
emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload))
return this._pending.wait<Awaited<Response>>(payload.uuid)
return this._pending.wait<Awaited<Response>>(payload)
}
public async emitClientEveryone<
@@ -260,7 +247,7 @@ export class RPCInstanceServer extends Wrapper {
emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload))
return this._pending.wait<Awaited<Response>>(payload.uuid)
return this._pending.wait<Awaited<Response>>(payload)
}
// ===== SELF =====
@@ -318,7 +305,7 @@ export class RPCInstanceServer extends Wrapper {
)
}
this.verifyEvent(this._emitterLocal, payload)
this.assertListener(this._emitterLocal, payload.event)
return await this._emitterLocal.emit<Awaited<Response>>(
payload.event,
@@ -350,7 +337,7 @@ export class RPCInstanceServer extends Wrapper {
CallbackArguments extends Parameters<RPCNativeServerEvents[EventName]>,
>(eventName: EventName, cb: (...args: CallbackArguments) => void): this {
if (!NATIVE_SERVER_EVENTS.includes(eventName)) {
throw new Error(RPCErrors.UNKNOWN_NATIVE)
throw new RPCError(RPCErrors.UNKNOWN_NATIVE, RPCErrors.UNKNOWN_NATIVE)
}
if (this.debug) {
+7 -39
View File
@@ -43,25 +43,9 @@ export class RPCInstanceWebview extends Wrapper {
}
if (payload.calledFrom === 'client' && payload.type === 'event') {
this.verifyEvent(this._emitterClient, payload)
const response = await this.dispatch(this._emitterClient, payload)
const responseData = await this._emitterClient.emit(
payload.event,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
const response: RPCState = {
event: payload.event,
uuid: payload.uuid,
calledFrom: 'webview',
calledTo: 'client',
error: null,
data: [responseData],
player: payload.player,
type: 'response',
}
await this._createHttpClientRequest(response).then()
await this._createHttpClientRequest(response)
}
}
@@ -73,23 +57,7 @@ export class RPCInstanceWebview extends Wrapper {
}
if (payload.calledFrom === 'server' && payload.type === 'event') {
this.verifyEvent(this._emitterServer, payload)
const responseData = await this._emitterServer.emit(
payload.event,
...(payload.data && payload.data.length > 0 ? payload.data : []),
)
const response: RPCState = {
event: payload.event,
uuid: payload.uuid,
calledFrom: 'webview',
calledTo: 'server',
error: null,
data: [responseData],
player: payload.player,
type: 'response',
}
const response = await this.dispatch(this._emitterServer, payload)
await this._createHttpClientRequest(response)
}
@@ -254,7 +222,7 @@ export class RPCInstanceWebview extends Wrapper {
)
}
this.verifyEvent(this._emitterLocal, payload)
this.assertListener(this._emitterLocal, payload.event)
return await this._emitterLocal.emit<Awaited<Response>>(
payload.event,
@@ -266,9 +234,9 @@ export class RPCInstanceWebview extends Wrapper {
/** Sends an event to the client and waits for its response (with timeout) */
private _request<R>(payload: RPCState): Promise<R> {
const response = this._pending.wait<R>(payload.uuid)
this._createHttpClientRequest(payload).then(
data => this._pending.resolve(payload.uuid, data),
const response = this._pending.wait<R>(payload)
this._createHttpClientRequest<RPCState>(payload).then(
res => this.settle(res),
(error: Error) => this._pending.reject(payload.uuid, error),
)
return response
+94 -28
View File
@@ -1,12 +1,15 @@
import { Emitter } from '../utils/emitter'
import { parse } from '../utils/funcs'
import {
handlerErrorMessage,
notRegisteredMessage,
RPCError,
} from '../utils/errors'
import { Pending } from '../utils/pending'
import {
type RPCConfig,
type RPCEnvironment,
RPCErrors,
type RPCState,
type RPCStateRaw,
} from '../utils/types'
export class Wrapper {
@@ -24,41 +27,104 @@ export class Wrapper {
this.console = console
}
/** Settles the call waiting for this response; ignores late or unexpected ones */
protected resolvePending(payload: RPCState): void {
const found = this._pending.resolve(payload.uuid, payload.data?.[0])
if (!found && this.debug) {
this.console.log(
`[RPC]:ignored response ${payload.event} ${payload.uuid} (no pending call, possibly timed out)`,
/**
* 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`.
*
* @param prefix - arguments passed before the request data (server: player)
*/
protected async dispatch(
emitter: Emitter,
request: RPCState,
...prefix: unknown[]
): Promise<RPCState> {
if (!emitter.has(request.event)) {
return this.errorResponse(
request,
new RPCError(
RPCErrors.EVENT_NOT_REGISTERED,
notRegisteredMessage(request.event, this.env, request.calledFrom),
),
)
}
try {
const data = await emitter.emit(
request.event,
...prefix,
...(request.data ?? []),
)
return this.response(request, [data], null)
} catch (e) {
// keep the stack visible where the listener lives
this.console.error(e)
return this.errorResponse(
request,
new RPCError(
RPCErrors.HANDLER_ERROR,
handlerErrorMessage(request.event, this.env, e),
),
)
}
}
protected verifyEvent(state: Emitter, data: RPCStateRaw | RPCState) {
const rpcData = typeof data === 'string' ? parse(data) : data
/** Builds the response to `request` carrying `error` */
protected errorResponse(request: RPCState, error: unknown): RPCState {
const rpcError =
error instanceof RPCError
? error
: new RPCError(
RPCErrors.HANDLER_ERROR,
handlerErrorMessage(request.event, this.env, error),
)
return this.response(request, null, {
code: rpcError.code,
message: rpcError.message,
})
}
if (!state.has(rpcData.event)) {
rpcData.error = RPCErrors.EVENT_NOT_REGISTERED
this.triggerError(rpcData)
/** Settles the call waiting for `response`; ignores late or unexpected ones */
protected settle(response: RPCState): void {
const found = response.error
? this._pending.reject(response.uuid, RPCError.fromResponse(response))
: this._pending.resolve(response.uuid, response.data?.[0])
if (!found) this.logIgnored(response)
}
/** Throws if no local listener is registered for `event` */
protected assertListener(emitter: Emitter, event: string): void {
if (!emitter.has(event)) {
throw new RPCError(
RPCErrors.EVENT_NOT_REGISTERED,
notRegisteredMessage(event, this.env, this.env),
{ event, uuid: '', from: this.env, to: this.env },
)
}
}
protected triggerError(rpcData: RPCState, error?: string): Error {
const errorMessage = [
`${rpcData.error}`,
`Event: ${rpcData.event}`,
`Uuid: ${rpcData.uuid}`,
`From: ${rpcData.calledFrom}`,
`To: ${rpcData.calledTo}`,
`Player: ${rpcData.player}`,
`Type: ${rpcData.type}`,
`Data: ${rpcData.data}`,
]
if (error) {
errorMessage.push(`Info: ${error}`)
protected logIgnored(response: RPCState): void {
if (this.debug) {
this.console.log(
`[RPC]:ignored response ${response.event} ${response.uuid} (no pending call, possibly timed out)`,
)
}
}
throw new Error(errorMessage.join('\n | '))
private response(
request: RPCState,
data: RPCState['data'],
error: RPCState['error'],
): RPCState {
return {
event: request.event,
uuid: request.uuid,
calledFrom: this.env,
calledTo: request.calledFrom,
error,
data,
player: request.player,
type: 'response',
}
}
}