diff --git a/RELEASE.md b/RELEASE.md index 1a1825d..232352c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,16 +1,10 @@ -## OpenCore Framework v1.0.7 +## OpenCore Framework v1.0.8 ### Added -- Added `RpcPublicError` and `serializeRpcError()` for safe RPC error exposure. -- Added structured benchmark suites and reporting. +- Added framework event bridging from `CORE` to `RESOURCE` for `@OnFrameworkEvent` listeners. ### Changed -- Updated server RPC logging and error handling for clearer failures. -- Updated benchmark metrics to include duration tracking and line-delimited JSON output. +- Updated `@OnRuntimeEvent` and `@OnFrameworkEvent` documentation to clarify handler arguments and cross-context behavior. ### Fixed -- Fixed RPC error leakage by sanitizing unexpected exceptions before they are returned to the client. -- Fixed `PlayerPersistenceService` bootstrap so `PlayerPersistenceContract` implementations run on session load. - -### Notes -- This release tracks the current branch changes for RPC logging, benchmarks, and session persistence. +- Fixed `@OnFrameworkEvent` delivery so built-in framework lifecycle events can reach `RESOURCE` listeners with hydrated payloads. \ No newline at end of file diff --git a/src/runtime/server/bootstrap.ts b/src/runtime/server/bootstrap.ts index 6b273e1..25bc2cb 100644 --- a/src/runtime/server/bootstrap.ts +++ b/src/runtime/server/bootstrap.ts @@ -5,9 +5,11 @@ import { GLOBAL_CONTAINER, MetadataScanner } from '../../kernel/di/index' import { getLogLevel, LogLevelLabels, loggers } from '../../kernel/logger' import { createNodeServerAdapter } from './adapter/node-server-adapter' import { installServerAdapter } from './adapter/registry' +import { configureFrameworkEventBridge } from './bus/internal-event.bus' import { PrincipalProviderContract } from './contracts/index' import { BinaryServiceMetadata, getServerBinaryServiceRegistry } from './decorators/binaryService' import { getServerControllerRegistry } from './decorators/controller' +import { Players } from './ports/players.api-port' import { getFrameworkModeScope, type RuntimeContext, @@ -148,6 +150,14 @@ export async function initServer( registerServicesServer(ctx) loggers.bootstrap.debug('Core services registered') + configureFrameworkEventBridge({ + mode: ctx.mode, + engineEvents: GLOBAL_CONTAINER.resolve(IEngineEvents as any) as IEngineEvents, + players: GLOBAL_CONTAINER.isRegistered(Players as any) + ? (GLOBAL_CONTAINER.resolve(Players as any) as Players) + : undefined, + }) + // 2. Load Controllers (Framework & User controllers) // This is where user services get registered if they are decorated with @injectable() // and imported before init() or discovered here. diff --git a/src/runtime/server/bus/internal-event.bus.ts b/src/runtime/server/bus/internal-event.bus.ts index 9ba562b..2ddb697 100644 --- a/src/runtime/server/bus/internal-event.bus.ts +++ b/src/runtime/server/bus/internal-event.bus.ts @@ -1,10 +1,35 @@ import { loggers } from '../../../kernel/logger' -import { FrameworkEventsMap } from '../types/framework-events.types' +import { IEngineEvents } from '../../../adapters/contracts/IEngineEvents' +import { SYSTEM_EVENTS } from '../../shared/types/system-types' +import { type FrameworkMode } from '../runtime' +import { Players } from '../ports/players.api-port' +import { + type FrameworkEventEnvelope, + type FrameworkEventsMap, + type FrameworkTransportEventsMap, + type PlayerFullyConnectedPayload, + type PlayerFullyConnectedTransportPayload, + type PlayerSessionRecoveredPayload, + type PlayerSessionRecoveredTransportPayload, +} from '../types/framework-events.types' type InternalEventName = keyof FrameworkEventsMap type InternalEventHandler = (payload: FrameworkEventsMap[E]) => void +type InternalTransportEventName = keyof FrameworkTransportEventsMap + +interface FrameworkEventBridgeConfig { + mode: FrameworkMode + engineEvents?: IEngineEvents + players?: Players +} + const handlers: Partial[]>> = {} +const bridgeListenerRegistrations = new WeakSet() + +let bridgeConfig: FrameworkEventBridgeConfig = { + mode: 'STANDALONE', +} export function onFrameworkEvent( event: E, @@ -23,9 +48,43 @@ export function onFrameworkEvent( } } +export function configureFrameworkEventBridge(config: FrameworkEventBridgeConfig): void { + bridgeConfig = config + + if (config.mode !== 'RESOURCE' || !config.engineEvents) return + if (bridgeListenerRegistrations.has(config.engineEvents)) return + + config.engineEvents.on( + SYSTEM_EVENTS.framework.dispatch, + (envelope: FrameworkEventEnvelope) => { + if (bridgeConfig.mode !== 'RESOURCE') return + dispatchTransportFrameworkEvent(envelope.event, envelope.payload) + }, + ) + + bridgeListenerRegistrations.add(config.engineEvents) +} + export function emitFrameworkEvent( event: E, payload: FrameworkEventsMap[E], +) { + dispatchLocalFrameworkEvent(event, payload) + + if (bridgeConfig.mode !== 'CORE' || !bridgeConfig.engineEvents) return + + const transportPayload = serializeFrameworkEvent(event, payload) + if (!transportPayload) return + + bridgeConfig.engineEvents.emit(SYSTEM_EVENTS.framework.dispatch, { + event, + payload: transportPayload, + }) +} + +function dispatchLocalFrameworkEvent( + event: E, + payload: FrameworkEventsMap[E], ) { const list = handlers[event] as InternalEventHandler[] | undefined if (!list) return @@ -44,3 +103,79 @@ export function emitFrameworkEvent( } } } + +function dispatchTransportFrameworkEvent( + event: E, + payload: FrameworkTransportEventsMap[E], +): void { + const hydrated = hydrateFrameworkEvent(event, payload) + if (!hydrated) return + dispatchLocalFrameworkEvent(event, hydrated) +} + +function serializeFrameworkEvent( + event: E, + payload: FrameworkEventsMap[E], +): FrameworkTransportEventsMap[E] | null { + switch (event) { + case 'internal:playerSessionCreated': + case 'internal:playerSessionDestroyed': + return payload as FrameworkTransportEventsMap[E] + case 'internal:playerFullyConnected': { + const fullyConnectedPayload = payload as PlayerFullyConnectedPayload + return { + clientId: fullyConnectedPayload.player.clientID, + } as FrameworkTransportEventsMap[E] + } + case 'internal:playerSessionRecovered': { + const recoveredPayload = payload as PlayerSessionRecoveredPayload + return { + clientId: recoveredPayload.clientId, + license: recoveredPayload.license, + } as FrameworkTransportEventsMap[E] + } + default: + return null + } +} + +function hydrateFrameworkEvent( + event: E, + payload: FrameworkTransportEventsMap[E], +): FrameworkEventsMap[E] | null { + switch (event) { + case 'internal:playerSessionCreated': + case 'internal:playerSessionDestroyed': + return payload as FrameworkEventsMap[E] + case 'internal:playerFullyConnected': { + const fullyConnectedPayload = payload as PlayerFullyConnectedTransportPayload + const player = bridgeConfig.players?.getByClient(fullyConnectedPayload.clientId) + if (!player) { + loggers.eventBus.warn('Skipping framework event: player not found during hydration', { + event, + clientId: fullyConnectedPayload.clientId, + }) + return null + } + return { player } as FrameworkEventsMap[E] + } + case 'internal:playerSessionRecovered': { + const recoveredPayload = payload as PlayerSessionRecoveredTransportPayload + const player = bridgeConfig.players?.getByClient(recoveredPayload.clientId) + if (!player) { + loggers.eventBus.warn('Skipping framework event: player not found during hydration', { + event, + clientId: recoveredPayload.clientId, + }) + return null + } + return { + clientId: recoveredPayload.clientId, + license: recoveredPayload.license, + player, + } as FrameworkEventsMap[E] + } + default: + return null + } +} diff --git a/src/runtime/server/decorators/onFrameworkEvent.ts b/src/runtime/server/decorators/onFrameworkEvent.ts index 2b34e00..b47c4ef 100644 --- a/src/runtime/server/decorators/onFrameworkEvent.ts +++ b/src/runtime/server/decorators/onFrameworkEvent.ts @@ -8,7 +8,23 @@ import { FrameworkEventsMap } from '../types/framework-events.types' * This decorator only stores metadata. The framework binds listeners during bootstrap by scanning * controller methods. * - * The handler should accept the payload type corresponding to the event from {@link FrameworkEventsMap}. + * The handler receives the framework payload associated with the selected event from + * {@link FrameworkEventsMap}. Unlike {@link OnRuntimeEvent}, this payload is framework-defined + * and may include hydrated entities such as {@link Player}. + * + * Framework events are delivered: + * - locally in `STANDALONE` + * - locally inside `CORE` + * - from `CORE` to `RESOURCE` through the internal framework bridge + * + * For bridged events, the transport payload is serialized in `CORE` and then rehydrated in the + * receiving `RESOURCE`. That means handlers can keep using the same payload shape in every mode. + * + * Current built-in payloads: + * - `internal:playerSessionCreated`: `{ clientId, license }` + * - `internal:playerSessionDestroyed`: `{ clientId }` + * - `internal:playerFullyConnected`: `{ player }` + * - `internal:playerSessionRecovered`: `{ clientId, license, player }` * * @param event - Internal event name, strongly typed to {@link FrameworkEventsMap}. * @@ -18,7 +34,7 @@ import { FrameworkEventsMap } from '../types/framework-events.types' * export class SystemController { * @Server.OnFrameworkEvent('internal:playerFullyConnected') * onPlayerConnected(payload: PlayerFullyConnectedPayload) { - * console.log(`Player ${payload.player.session.clientId} connected`) + * console.log(`Player ${payload.player.clientID} connected`) * } * } * ``` diff --git a/src/runtime/server/decorators/onRuntimeEvent.ts b/src/runtime/server/decorators/onRuntimeEvent.ts index 7f21ca8..9b2990a 100644 --- a/src/runtime/server/decorators/onRuntimeEvent.ts +++ b/src/runtime/server/decorators/onRuntimeEvent.ts @@ -9,6 +9,19 @@ import { METADATA_KEYS } from '../system/metadata-server.keys' * This decorator only stores metadata. During bootstrap, the framework scans controller * methods and binds handlers to runtime events. * + * The decorated method receives the raw runtime arguments emitted by the adapter for + * the selected event. OpenCore does not wrap these arguments into an object payload and + * does not automatically resolve a {@link Player} entity for you. + * + * Common server event signatures in the current runtime map: + * - `playerJoining`: `(clientId: number, identifiers?: Record)` + * - `playerDropped`: `(clientId: number)` + * - `onServerResourceStop`: `(resourceName: string)` + * - `playerCommand`: runtime-specific raw arguments from the adapter + * + * If you need a framework-managed payload such as `{ player }`, use + * {@link OnFrameworkEvent} instead. + * * CitizenFX server event reference: * https://docs.fivem.net/docs/scripting-reference/events/server-events/ * @@ -19,8 +32,8 @@ import { METADATA_KEYS } from '../system/metadata-server.keys' * @Server.Controller() * export class SessionController { * @Server.OnRuntimeEvent('playerJoining') - * onPlayerJoining() { - * // ... + * onPlayerJoining(clientId: number, identifiers?: Record) { + * // Raw runtime arguments * } * } * ``` diff --git a/src/runtime/server/types/framework-events.types.ts b/src/runtime/server/types/framework-events.types.ts index 5e9679c..e8fd877 100644 --- a/src/runtime/server/types/framework-events.types.ts +++ b/src/runtime/server/types/framework-events.types.ts @@ -1,24 +1,74 @@ import { Player } from '../entities' +/** + * Emitted when a player session is created in the framework session lifecycle. + */ export interface PlayerSessionCreatedPayload { clientId: number license: string | undefined } +/** + * Emitted when a player session is destroyed in the framework session lifecycle. + */ export interface PlayerSessionDestroyedPayload { clientId: number } +/** + * Emitted when the framework considers the player fully connected and the runtime-specific + * {@link Player} entity can be consumed safely by application code. + */ export interface PlayerFullyConnectedPayload { player: Player } +/** + * Emitted when the framework recreates a player session after resource restart recovery. + */ export interface PlayerSessionRecoveredPayload { clientId: number player: Player license: string | undefined } +/** + * Serialized transport payload for `internal:playerFullyConnected` used across `CORE -> RESOURCE`. + */ +export interface PlayerFullyConnectedTransportPayload { + clientId: number +} + +/** + * Serialized transport payload for `internal:playerSessionRecovered` used across `CORE -> RESOURCE`. + */ +export interface PlayerSessionRecoveredTransportPayload { + clientId: number + license: string | undefined +} + +/** + * Internal transport contract used by the framework bridge when delivering framework events + * between different server runtimes. + */ +export type FrameworkTransportEventsMap = { + 'internal:playerSessionCreated': PlayerSessionCreatedPayload + 'internal:playerSessionDestroyed': PlayerSessionDestroyedPayload + 'internal:playerFullyConnected': PlayerFullyConnectedTransportPayload + 'internal:playerSessionRecovered': PlayerSessionRecoveredTransportPayload +} + +/** + * Envelope emitted through the internal framework bridge. + */ +export interface FrameworkEventEnvelope { + event: E + payload: FrameworkTransportEventsMap[E] +} + +/** + * Public payload map consumed by {@link OnFrameworkEvent} and `onFrameworkEvent(...)`. + */ export type FrameworkEventsMap = { 'internal:playerSessionCreated': PlayerSessionCreatedPayload 'internal:playerSessionDestroyed': PlayerSessionDestroyedPayload diff --git a/src/runtime/shared/types/system-types.ts b/src/runtime/shared/types/system-types.ts index 014af42..b619769 100644 --- a/src/runtime/shared/types/system-types.ts +++ b/src/runtime/shared/types/system-types.ts @@ -30,6 +30,9 @@ export const SYSTEM_EVENTS = { command: { execute: systemEvent('command', 'execute'), }, + framework: { + dispatch: systemEvent('framework', 'dispatch'), + }, spawner: { spawn: systemEvent('spawner', 'spawn'), teleport: systemEvent('spawner', 'teleport'), diff --git a/tests/unit/server/bus/internal-event.bus.test.ts b/tests/unit/server/bus/internal-event.bus.test.ts new file mode 100644 index 0000000..1f6362b --- /dev/null +++ b/tests/unit/server/bus/internal-event.bus.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + configureFrameworkEventBridge, + emitFrameworkEvent, + onFrameworkEvent, +} from '../../../../src/runtime/server/bus/internal-event.bus' +import { SYSTEM_EVENTS } from '../../../../src/runtime/shared/types/system-types' + +describe('internal-event bus bridge', () => { + beforeEach(() => { + configureFrameworkEventBridge({ mode: 'STANDALONE' }) + }) + + it('serializes framework events when CORE emits them', () => { + const emit = vi.fn() + + configureFrameworkEventBridge({ + mode: 'CORE', + engineEvents: { + on: vi.fn(), + onRuntime: vi.fn(), + emit, + getRuntimeEventMap: vi.fn(() => ({})), + } as any, + }) + + emitFrameworkEvent('internal:playerFullyConnected', { + player: { clientID: 42 } as any, + }) + + expect(emit).toHaveBeenCalledWith(SYSTEM_EVENTS.framework.dispatch, { + event: 'internal:playerFullyConnected', + payload: { clientId: 42 }, + }) + }) + + it('hydrates bridged framework events in RESOURCE mode', () => { + let transportHandler: ((envelope: unknown) => void) | undefined + const player = { clientID: 7, name: 'Tester' } + const listener = vi.fn() + const unsubscribe = onFrameworkEvent('internal:playerFullyConnected', listener) + + configureFrameworkEventBridge({ + mode: 'RESOURCE', + engineEvents: { + on: vi.fn((_event: string, handler: (envelope: unknown) => void) => { + transportHandler = handler + }), + onRuntime: vi.fn(), + emit: vi.fn(), + getRuntimeEventMap: vi.fn(() => ({})), + } as any, + players: { + getByClient: vi.fn((clientId: number) => (clientId === 7 ? (player as any) : undefined)), + } as any, + }) + + transportHandler?.({ + event: 'internal:playerFullyConnected', + payload: { clientId: 7 }, + }) + + expect(listener).toHaveBeenCalledWith({ player }) + unsubscribe() + }) +})