Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 4 additions & 10 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions src/runtime/server/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down
137 changes: 136 additions & 1 deletion src/runtime/server/bus/internal-event.bus.ts
Original file line number Diff line number Diff line change
@@ -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<E extends InternalEventName> = (payload: FrameworkEventsMap[E]) => void

type InternalTransportEventName = keyof FrameworkTransportEventsMap

interface FrameworkEventBridgeConfig {
mode: FrameworkMode
engineEvents?: IEngineEvents
players?: Players
}

const handlers: Partial<Record<InternalEventName, InternalEventHandler<any>[]>> = {}
const bridgeListenerRegistrations = new WeakSet<IEngineEvents>()

let bridgeConfig: FrameworkEventBridgeConfig = {
mode: 'STANDALONE',
}

export function onFrameworkEvent<E extends InternalEventName>(
event: E,
Expand All @@ -23,9 +48,43 @@ export function onFrameworkEvent<E extends InternalEventName>(
}
}

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<InternalTransportEventName>) => {
if (bridgeConfig.mode !== 'RESOURCE') return
dispatchTransportFrameworkEvent(envelope.event, envelope.payload)
},
)

bridgeListenerRegistrations.add(config.engineEvents)
}

export function emitFrameworkEvent<E extends InternalEventName>(
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<E extends InternalEventName>(
event: E,
payload: FrameworkEventsMap[E],
) {
const list = handlers[event] as InternalEventHandler<E>[] | undefined
if (!list) return
Expand All @@ -44,3 +103,79 @@ export function emitFrameworkEvent<E extends InternalEventName>(
}
}
}

function dispatchTransportFrameworkEvent<E extends InternalTransportEventName>(
event: E,
payload: FrameworkTransportEventsMap[E],
): void {
const hydrated = hydrateFrameworkEvent(event, payload)
if (!hydrated) return
dispatchLocalFrameworkEvent(event, hydrated)
}

function serializeFrameworkEvent<E extends InternalEventName>(
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<E extends InternalTransportEventName>(
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
}
}
20 changes: 18 additions & 2 deletions src/runtime/server/decorators/onFrameworkEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}.
*
Expand All @@ -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`)
* }
* }
* ```
Expand Down
17 changes: 15 additions & 2 deletions src/runtime/server/decorators/onRuntimeEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)`
* - `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/
*
Expand All @@ -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<string, string>) {
* // Raw runtime arguments
* }
* }
* ```
Expand Down
50 changes: 50 additions & 0 deletions src/runtime/server/types/framework-events.types.ts
Original file line number Diff line number Diff line change
@@ -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<E extends keyof FrameworkTransportEventsMap> {
event: E
payload: FrameworkTransportEventsMap[E]
}

/**
* Public payload map consumed by {@link OnFrameworkEvent} and `onFrameworkEvent(...)`.
*/
export type FrameworkEventsMap = {
'internal:playerSessionCreated': PlayerSessionCreatedPayload
'internal:playerSessionDestroyed': PlayerSessionDestroyedPayload
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/shared/types/system-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
Loading
Loading