From 053eb50ecec62b41d784c447ce1c1e926ccbe8b2 Mon Sep 17 00:00:00 2001 From: Flussen Date: Mon, 30 Mar 2026 19:05:07 +0200 Subject: [PATCH 1/4] feat: implement RpcPublicError and add error logging to RPC handlers --- src/adapters/contracts/transport/index.ts | 1 + src/adapters/contracts/transport/rpc-error.ts | 46 ++++++++++++ .../system/processors/onRpc.processor.ts | 22 ++++-- tests/integration/server/rpc-flow.test.ts | 71 +++++++++++++++++++ tests/unit/transport/rpc-error.test.ts | 43 +++++++++++ 5 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 src/adapters/contracts/transport/rpc-error.ts create mode 100644 tests/unit/transport/rpc-error.test.ts diff --git a/src/adapters/contracts/transport/index.ts b/src/adapters/contracts/transport/index.ts index 12edf45..26ed344 100644 --- a/src/adapters/contracts/transport/index.ts +++ b/src/adapters/contracts/transport/index.ts @@ -1,4 +1,5 @@ export * from './context' export * from './events.api' export * from './rpc.api' +export * from './rpc-error' export * from './messaging.transport' diff --git a/src/adapters/contracts/transport/rpc-error.ts b/src/adapters/contracts/transport/rpc-error.ts new file mode 100644 index 0000000..9ce22f9 --- /dev/null +++ b/src/adapters/contracts/transport/rpc-error.ts @@ -0,0 +1,46 @@ +export const PUBLIC_RPC_ERROR_MESSAGE = 'An internal server error occurred' + +export type RpcErrorInfo = { + message: string + name?: string +} + +type ExposedRpcError = { + message: string + name?: string + expose: true +} + +export class RpcPublicError extends Error { + readonly expose = true + + constructor(message: string, name?: string) { + super(message) + if (name) { + this.name = name + } + Object.setPrototypeOf(this, new.target.prototype) + } +} + +export function isExposedRpcError(error: unknown): error is ExposedRpcError { + return ( + typeof error === 'object' && + error !== null && + 'message' in error && + typeof error.message === 'string' && + 'expose' in error && + error.expose === true + ) +} + +export function serializeRpcError(error: unknown): RpcErrorInfo { + if (isExposedRpcError(error)) { + return { + message: error.message, + name: 'name' in error && typeof error.name === 'string' ? error.name : undefined, + } + } + + return { message: PUBLIC_RPC_ERROR_MESSAGE } +} diff --git a/src/runtime/server/system/processors/onRpc.processor.ts b/src/runtime/server/system/processors/onRpc.processor.ts index 27715fe..9c3ca9a 100644 --- a/src/runtime/server/system/processors/onRpc.processor.ts +++ b/src/runtime/server/system/processors/onRpc.processor.ts @@ -129,11 +129,25 @@ export class OnRpcProcessor implements DecoratorProcessor { throw error } - if (hasNoDeclaredParams) { - return handler() - } + try { + if (hasNoDeclaredParams) { + return await handler() + } - return handler(player, ...validatedArgs) + return await handler(player, ...validatedArgs) + } catch (error) { + loggers.netEvent.error( + `Handler error in RPC`, + { + event: metadata.eventName, + handler: handlerName, + playerId: player.clientID, + accountId: player.accountID, + }, + error as Error, + ) + throw error + } }) loggers.netEvent.debug(`Registered RPC: ${metadata.eventName} -> ${handlerName}`) diff --git a/tests/integration/server/rpc-flow.test.ts b/tests/integration/server/rpc-flow.test.ts index 0e3e0eb..70813ac 100644 --- a/tests/integration/server/rpc-flow.test.ts +++ b/tests/integration/server/rpc-flow.test.ts @@ -1,6 +1,8 @@ import 'reflect-metadata' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { z } from 'zod' +import { loggers } from '../../../src/kernel/logger' +import { RpcPublicError } from '../../../src/adapters/contracts/transport/rpc-error' import { NodeRpc } from '../../../src/adapters/node/transport/node.rpc' import type { RpcHandlerOptions } from '../../../src/runtime/server/decorators/onRPC' import { Public } from '../../../src/runtime/server/decorators/public' @@ -59,6 +61,7 @@ describe('OnRpcProcessor – Server RPC Flow', () => { } as any processor = new OnRpcProcessor(mockPlayerService, rpc) + vi.spyOn(loggers.netEvent, 'error').mockImplementation(() => undefined) }) afterEach(() => { @@ -270,6 +273,74 @@ describe('OnRpcProcessor – Server RPC Flow', () => { }) }) + describe('Handler errors', () => { + it('should log and rethrow unexpected handler errors', async () => { + class TestController { + async handle(_player: Player) { + throw new Error('db connection string: secret') + } + } + + const instance = new TestController() + const metadata = buildMeta('rpc:error:unexpected', z.tuple([]), [Player]) + + const mockPlayer = createMockPlayer({ clientID: 7, accountID: 'acc-7' }) + vi.mocked(mockPlayerService.getByClient).mockReturnValue(mockPlayer) + + processor.process(instance, 'handle', metadata) + + const handler = (rpc as any).handlers.get('rpc:error:unexpected') + + await expect(handler({ requestId: 'req-1', clientId: 7 })).rejects.toThrow( + 'db connection string: secret', + ) + + expect(loggers.netEvent.error).toHaveBeenCalledWith( + 'Handler error in RPC', + expect.objectContaining({ + event: 'rpc:error:unexpected', + handler: 'TestController.handle', + playerId: 7, + accountId: 'acc-7', + }), + expect.any(Error), + ) + }) + + it('should log and rethrow public handler errors', async () => { + class TestController { + async handle(_player: Player) { + throw new RpcPublicError('Character not found', 'CharacterLookupError') + } + } + + const instance = new TestController() + const metadata = buildMeta('rpc:error:public', z.tuple([]), [Player]) + + const mockPlayer = createMockPlayer({ clientID: 8, accountID: 'acc-8' }) + vi.mocked(mockPlayerService.getByClient).mockReturnValue(mockPlayer) + + processor.process(instance, 'handle', metadata) + + const handler = (rpc as any).handlers.get('rpc:error:public') + + await expect(handler({ requestId: 'req-2', clientId: 8 })).rejects.toThrow( + 'Character not found', + ) + + expect(loggers.netEvent.error).toHaveBeenCalledWith( + 'Handler error in RPC', + expect.objectContaining({ + event: 'rpc:error:public', + handler: 'TestController.handle', + playerId: 8, + accountId: 'acc-8', + }), + expect.any(Error), + ) + }) + }) + // ═══════════════════════════════════════════════════════════════════════════ // Player resolution // ═══════════════════════════════════════════════════════════════════════════ diff --git a/tests/unit/transport/rpc-error.test.ts b/tests/unit/transport/rpc-error.test.ts new file mode 100644 index 0000000..d5e6f0e --- /dev/null +++ b/tests/unit/transport/rpc-error.test.ts @@ -0,0 +1,43 @@ +import 'reflect-metadata' +import { describe, expect, it } from 'vitest' +import { + PUBLIC_RPC_ERROR_MESSAGE, + RpcPublicError, + serializeRpcError, +} from '../../../src/adapters/contracts/transport/rpc-error' + +describe('serializeRpcError', () => { + it('should sanitize unexpected Error instances', () => { + expect(serializeRpcError(new Error('database credentials leaked'))).toEqual({ + message: PUBLIC_RPC_ERROR_MESSAGE, + }) + }) + + it('should preserve message and name for RpcPublicError', () => { + const error = new RpcPublicError('Character not found', 'CharacterLookupError') + + expect(serializeRpcError(error)).toEqual({ + message: 'Character not found', + name: 'CharacterLookupError', + }) + }) + + it('should preserve message for exposed structural errors', () => { + expect( + serializeRpcError({ + message: 'Inventory full', + name: 'InventoryError', + expose: true, + }), + ).toEqual({ + message: 'Inventory full', + name: 'InventoryError', + }) + }) + + it('should sanitize non-Error values', () => { + expect(serializeRpcError('raw failure')).toEqual({ + message: PUBLIC_RPC_ERROR_MESSAGE, + }) + }) +}) From d20eb3f4f55c2edd9e27738c22ec33549846ce8c Mon Sep 17 00:00:00 2001 From: Flussen Date: Mon, 30 Mar 2026 19:06:29 +0200 Subject: [PATCH 2/4] chore: bump version to 1.0.6 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ac18ae4..88d4a34 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@open-core/framework", - "version": "1.0.5", + "version": "1.0.6", "description": "Secure, event-driven TypeScript Framework & Runtime engine for CitizenFX (Cfx).", "main": "dist/index.js", "types": "dist/index.d.ts", From ec2812488e5e84d35f77cc6fdb0a0f0d50ad3e28 Mon Sep 17 00:00:00 2001 From: Flussen Date: Mon, 30 Mar 2026 19:08:48 +0200 Subject: [PATCH 3/4] docs: update project branding to stable v1 and remove ecosystem section from README --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 8fb2ac2..23b94e5 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ [![website](https://img.shields.io/badge/web-opencorejs.dev-black?style=flat-square)](https://opencorejs.dev) -# OpenCore Framework - Open Stable beta +# OpenCore Framework - Stable v1 -OpenCore is a TypeScript multiplayer runtime framework targeting CitizenFX runtimes (Cfx) via adapters. +OpenCore is a TypeScript multiplayer runtime framework targeting CitizenFX runtimes (Cfx/RageMP) via adapters. It is not a gamemode or RP framework. It provides: @@ -277,12 +277,6 @@ pnpm lint:fix pnpm format ``` -## Ecosystem - -OpenCore is designed to be extended via separate packages/resources. - -- `@open-core/identity`: identity and permission system - ## License MPL-2.0. See `LICENSE`. From aee1034e59db60dc8d0e108a5c73656cb85d22d6 Mon Sep 17 00:00:00 2001 From: Flussen Date: Tue, 31 Mar 2026 20:00:50 +0200 Subject: [PATCH 4/4] feat: implement secure RPC error serialization and enhanced server-side logging for v1.0.6 --- RELEASE.md | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 2e3090b..e6d61a1 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,22 +1,20 @@ -## OpenCore Framework v1.0.5 +## OpenCore Framework v1.0.6 ### Added -- Added new client adapter ports for camera, ped, vehicle, progress, spawn, local player, runtime bridge, and WebView integration, with matching node runtime implementations. -- Added support for WebView chat mode, richer client UI/runtime abstractions, and cleaner adapter-facing contracts/exports. -- Added server-side improvements for command handling, including command validation, default function parameter support, and standardized system event names. -- Added more coverage around parallel compute, vehicle modification, vehicle sync state, player state sync, adapters, and command execution flows. -- Added Husky pre-commit and pre-push hooks for local quality checks. +- Added `RpcPublicError` and `serializeRpcError()` for safe RPC error exposure. +- Added `PUBLIC_RPC_ERROR_MESSAGE` as the default public message for unexpected RPC failures. +- Added transport exports for RPC error helpers through `src/adapters/contracts/transport`. +- Added unit and integration coverage for RPC error serialization and server RPC flow logging. ### Changed -- Refactored client services to rely on explicit adapter ports instead of direct runtime assumptions, especially for camera, ped, progress, spawn, and vehicle flows. -- Refactored logging so logger writes use string log levels and runtime log domain labels are derived dynamically from the active resource. -- Refactored worker execution to use inline worker scripts with performance tracking in the parallel compute pipeline. -- Updated package/tooling setup to TypeScript 6 and refreshed package exports, scripts, and dependency configuration. +- Updated server RPC processing to log handler failures with event, handler, player, and account context. +- Updated RPC handling to preserve explicit public errors while masking unexpected internal errors. +- Refined the RPC path so invalid payloads and session issues are logged with clearer warnings. ### Fixed -- Fixed command schema handling so exported/remote commands support default parameters more reliably. -- Fixed transport/event contract alignment across node events and RPC layers. -- Fixed several test, lint, and export consistency issues while expanding automated coverage. +- Fixed RPC error leakage by sanitizing unexpected exceptions before they are returned to the client. +- Fixed RPC logger behavior so exposed errors can pass through with their original message and name. +- Fixed contract alignment across transport, server RPC processing, and test coverage. ### Notes -- This release covers the full `master...v1` delta and keeps the release notes compact by grouping related adapter/runtime refactors instead of listing each port separately. +- This release tracks the `fix/rpc-logger` merge request (#51) and keeps the release note focused on the RPC error-handling changes.