diff --git a/rpc/src/core/client.ts b/rpc/src/core/client.ts index dd40f06..5dfc835 100644 --- a/rpc/src/core/client.ts +++ b/rpc/src/core/client.ts @@ -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(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>(payload.uuid) + return this._pending.wait>(payload) } // ===== WEBVIEW ===== @@ -239,7 +230,7 @@ export class RPCInstanceClient extends Wrapper { data: payload, }) - return this._pending.wait>(payload.uuid) + return this._pending.wait>(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>( payload.event, @@ -328,7 +319,7 @@ export class RPCInstanceClient extends Wrapper { CallbackArguments extends Parameters, >(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) { diff --git a/rpc/src/core/server.ts b/rpc/src/core/server.ts index 0340a60..8a55d2d 100644 --- a/rpc/src/core/server.ts +++ b/rpc/src/core/server.ts @@ -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>(payload.uuid) + return this._pending.wait>(payload) } public async emitClientEveryone< @@ -260,7 +247,7 @@ export class RPCInstanceServer extends Wrapper { emitNet(RPCEvents.LISTENER_SERVER, player, stringify(payload)) - return this._pending.wait>(payload.uuid) + return this._pending.wait>(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>( payload.event, @@ -350,7 +337,7 @@ export class RPCInstanceServer extends Wrapper { CallbackArguments extends Parameters, >(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) { diff --git a/rpc/src/core/webview.ts b/rpc/src/core/webview.ts index d269ef4..4de4616 100644 --- a/rpc/src/core/webview.ts +++ b/rpc/src/core/webview.ts @@ -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>( 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(payload: RPCState): Promise { - const response = this._pending.wait(payload.uuid) - this._createHttpClientRequest(payload).then( - data => this._pending.resolve(payload.uuid, data), + const response = this._pending.wait(payload) + this._createHttpClientRequest(payload).then( + res => this.settle(res), (error: Error) => this._pending.reject(payload.uuid, error), ) return response diff --git a/rpc/src/core/wrapper.ts b/rpc/src/core/wrapper.ts index a36d4f6..c964e6e 100644 --- a/rpc/src/core/wrapper.ts +++ b/rpc/src/core/wrapper.ts @@ -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 { + 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', + } } } diff --git a/rpc/src/index.ts b/rpc/src/index.ts index a710e54..e67b16f 100644 --- a/rpc/src/index.ts +++ b/rpc/src/index.ts @@ -2,6 +2,7 @@ import { RPCInstanceClient } from './core/client' import { RPCInstanceServer } from './core/server' import { RPCInstanceWebview } from './core/webview' import { Wrapper } from './core/wrapper' +import { RPCError } from './utils/errors' import { type RPCConfig, type RPCEnvironment, @@ -54,7 +55,10 @@ class RPCFactory extends Wrapper { this.operator = new RPCInstanceWebview(opts as RPCConfig<'webview'>) break default: - throw new Error(RPCErrors.UNKNOWN_ENVIRONMENT) + throw new RPCError( + RPCErrors.UNKNOWN_ENVIRONMENT, + RPCErrors.UNKNOWN_ENVIRONMENT, + ) } } @@ -65,6 +69,7 @@ class RPCFactory extends Wrapper { export { RPCFactory } export * from './utils/types' +export { RPCError, type RPCErrorDetails } from './utils/errors' export * from './utils/native' export type * from './core/server' export type * from './core/client' diff --git a/rpc/src/utils/errors.ts b/rpc/src/utils/errors.ts new file mode 100644 index 0000000..193905c --- /dev/null +++ b/rpc/src/utils/errors.ts @@ -0,0 +1,90 @@ +import { type RPCEnvironment, RPCErrors, type RPCState } from './types' + +/** Where a failed call was going, when the error comes from a call */ +export type RPCErrorDetails = { + event: string + uuid: string + /** Environment that made the call */ + from: RPCEnvironment + /** Environment that was called */ + to: RPCEnvironment +} + +/** + * Error thrown or rejected by this library. Check `code` against `RPCErrors`. + * + * @example + * try { + * await rpc.emitServer('buyItem', 'water') + * } catch (e) { + * if (e instanceof RPCError && e.code === RPCErrors.TIMEOUT) { + * // server did not answer in time + * } + * } + */ +export class RPCError extends Error { + public readonly code: RPCErrors + public readonly details: RPCErrorDetails | undefined + + constructor(code: RPCErrors, message: string, details?: RPCErrorDetails) { + super(message) + this.name = 'RPCError' + this.code = code + this.details = details + } + + /** **Internal** Rebuilds the error a receiver sent back in a response */ + static fromResponse(response: RPCState): RPCError { + return new RPCError( + response.error?.code ?? RPCErrors.HANDLER_ERROR, + response.error?.message ?? 'Unknown error', + { + event: response.event, + uuid: response.uuid, + from: response.calledTo, + to: response.calledFrom, + }, + ) + } +} + +/** **Internal** Name of the method that listens on `receiver` for calls from `sender` */ +export function listenerName( + receiver: RPCEnvironment, + sender: RPCEnvironment, +): string { + if (receiver === sender) return 'onSelf' + return `on${sender[0]?.toUpperCase()}${sender.slice(1)}` +} + +/** **Internal** */ +export function notRegisteredMessage( + event: string, + receiver: RPCEnvironment, + sender: RPCEnvironment, +): string { + return `No listener for "${event}" on ${receiver}. Register it with rpc.${listenerName(receiver, sender)}("${event}", ...) in ${receiver} code.` +} + +/** **Internal** */ +export function handlerErrorMessage( + event: string, + receiver: RPCEnvironment, + cause: unknown, +): string { + const reason = + cause instanceof Error || (cause as Error | undefined)?.message + ? (cause as Error).message + : String(cause) + return `Listener for "${event}" on ${receiver} threw: ${reason}` +} + +/** **Internal** */ +export function timeoutMessage( + event: string, + receiver: RPCEnvironment, + sender: RPCEnvironment, + timeout: number, +): string { + return `No response for "${event}" from ${receiver} within ${timeout} ms. Check that ${receiver} registered rpc.${listenerName(receiver, sender)}("${event}", ...) and that it returns, or raise RPCConfig.timeout (0 disables it).` +} diff --git a/rpc/src/utils/pending.ts b/rpc/src/utils/pending.ts index d1af6f5..07dd1ad 100644 --- a/rpc/src/utils/pending.ts +++ b/rpc/src/utils/pending.ts @@ -1,4 +1,5 @@ -import { RPCErrors } from './types' +import { RPCError, timeoutMessage } from './errors' +import { RPCErrors, type RPCState } from './types' type Call = { resolve: (data: unknown) => void @@ -13,17 +14,18 @@ export class Pending { /** @param timeout - ms before a call rejects, `0` or less disables it */ constructor(private readonly _timeout: number) {} - public wait(uuid: string): Promise { + /** Waits for the response to `request`, rejects with `RPCErrors.TIMEOUT` */ + public wait(request: RPCState): Promise { return new Promise((resolve, reject) => { const timer = this._timeout > 0 ? setTimeout( - () => this.reject(uuid, new Error(RPCErrors.TIMEOUT)), + () => this.reject(request.uuid, this._timeoutError(request)), this._timeout, ) : undefined - this._calls.set(uuid, { + this._calls.set(request.uuid, { resolve: resolve as (data: unknown) => void, reject, timer, @@ -53,4 +55,22 @@ export class Pending { } return call } + + private _timeoutError(request: RPCState): RPCError { + return new RPCError( + RPCErrors.TIMEOUT, + timeoutMessage( + request.event, + request.calledTo, + request.calledFrom, + this._timeout, + ), + { + event: request.event, + uuid: request.uuid, + from: request.calledFrom, + to: request.calledTo, + }, + ) + } } diff --git a/rpc/src/utils/types.ts b/rpc/src/utils/types.ts index 23a23b0..3e793df 100644 --- a/rpc/src/utils/types.ts +++ b/rpc/src/utils/types.ts @@ -46,12 +46,18 @@ export type RPCState = { uuid: string calledFrom: RPCEnvironment calledTo: RPCEnvironment - error: string | null + error: RPCErrorPayload | null data: unknown[] | null player: number | null type: RPCEventType } +/** **Internal** Error sent back in a response, rebuilt as `RPCError` by the caller */ +export type RPCErrorPayload = { + code: RPCErrors + message: string +} + /** * **Internal** * @@ -93,6 +99,7 @@ export enum RPCErrors { 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', + HANDLER_ERROR = 'Listener threw an error', } /**