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
30 changes: 15 additions & 15 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
## OpenCore Framework v0.3.1
## OpenCore Framework v0.3.2

---

### Highlights

- **Hot-Reload Stability**: Fixed critical race condition that caused resources to hang during hot-reload
- **Command System Reliability**: Improved command registration and execution flow with better error handling (Now Array types means spreed operator in commands and netEvents parameters handler, string[] === rest of the arguments, and supporting spreed operator as string[])
- **Bidirectional Core Detection**: Enhanced core ready detection mechanism for late-starting resources
- **Command Parameter Intelligence**: The framework now correctly differentiates between TypeScript's spread operator (`...args: string[]`) and direct array parameters (`args: string[]`).
- **Improved Reflection**: Added source-code analysis to overcome TypeScript's reflection limitations (`design:paramtypes` ambiguity).

---

### Changes
### Fixes

- **Core Initialization**
- Added bidirectional ready detection with `core:request-ready` event for hot-reload scenarios
- Reordered core dependency detection to register event listener before requesting status
- Removed artificial delay in `ReadyController`, set `isReady=true` immediately
- **Spread Operator Handling**: Fixed an issue where using `...args` would sometimes result in arguments being joined by commas or passed incorrectly.
- **Array Parameter Support**: Resolved "join is not a function" errors when using `args: string[]` by ensuring the full array is passed as a single argument.
- **Argument Consistency**: Ensured that single-word and multi-word inputs are handled consistently across both `Command` and `OnNet` (NetEvents) systems.

- **Command System**
- Allow command re-registration from same resource during hot-reload
- Fixed tuple schema validation to properly handle rest array parameters
- Added comprehensive debug logging for command registration and execution flow
- Enhanced error handling in remote command service
---

### Internal Changes

- Added `getSpreadParameterIndices` to `function-helper.ts` for runtime parameter inspection.
- Updated `CommandMetadata` to track `hasSpreadParam`.
- Refined `validateAndExecuteCommand` to implement conditional argument flattening.

---

### Notes

This release focuses on improving the developer experience during hot-reload scenarios. Resources that are restarted or hot-reloaded will now properly detect the Core's ready state without hanging, and commands will continue to function correctly after resource restarts.
This release ensures that the framework respects the intended TypeScript parameter types, providing a more intuitive and reliable experience for building complex command handlers and event listeners.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@open-core/framework",
"version": "0.3.1",
"version": "0.3.2",
"description": "Secure, Event-Driven, OOP Engine for FiveM. Stop scripting, start engineering.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
8 changes: 7 additions & 1 deletion src/runtime/server/decorators/command.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod'
import { ClassConstructor } from '../../../kernel/di/class-constructor'
import { Player } from '../entities/player'
import { getParameterNames } from '../helpers/function-helper'
import { getParameterNames, getSpreadParameterIndices } from '../helpers/function-helper'
import { METADATA_KEYS } from '../system/metadata-server.keys'
import { SecurityMetadata } from '../types/core-exports'

Expand Down Expand Up @@ -38,6 +38,8 @@ export interface CommandMetadata extends CommandConfig {
isPublic?: boolean
/** Security metadata for remote validation */
security?: SecurityMetadata
/** True if the last parameter uses the spread operator (...args) */
hasSpreadParam?: boolean
}

type ServerCommandHandler = (() => any) | ((player: Player, ...args: any[]) => any)
Expand Down Expand Up @@ -148,13 +150,17 @@ export function Command(configOrName: string | CommandConfig, schema?: z.ZodType
}

const paramNames = getParameterNames(descriptor.value)
const spreadIndices = getSpreadParameterIndices(descriptor.value)
const hasSpreadParam = spreadIndices.length > 0 && spreadIndices[spreadIndices.length - 1]

const metadata: CommandMetadata = {
...config,
methodName: propertyKey,
target: target.constructor,
paramTypes,
paramNames,
expectsPlayer,
hasSpreadParam,
}

Reflect.defineMetadata(METADATA_KEYS.COMMAND, metadata, target, propertyKey)
Expand Down
17 changes: 16 additions & 1 deletion src/runtime/server/helpers/command-validation.helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,22 @@ export async function validateAndExecuteCommand(
})
})

return await handler(player, ...(validated as unknown[]))
const finalArgs = validated as unknown[]

// If the handler uses spread operator (...args), flatten the last array argument
// so the handler receives individual arguments instead of a single array.
if (
meta.hasSpreadParam &&
finalArgs.length > 0 &&
Array.isArray(finalArgs[finalArgs.length - 1])
) {
const positional = finalArgs.slice(0, finalArgs.length - 1)
const rest = finalArgs[finalArgs.length - 1] as unknown[]
return await handler(player, ...positional, ...rest)
}

// For regular array parameters (args: string[]), pass as-is
return await handler(player, ...finalArgs)
}

// fallback
Expand Down
19 changes: 19 additions & 0 deletions src/runtime/server/helpers/function-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,22 @@ export function getParameterNames(func: (...args: any[]) => any): string[] {

return args
}

/**
* Detects which parameter indices use the spread operator (...args).
* Returns an array of booleans where true means the parameter at that index is a spread parameter.
*/
export function getSpreadParameterIndices(func: (...args: any[]) => any): boolean[] {
const stripped = func
.toString()
.replace(/\/\/.*$/gm, '')
.replace(/\/\*[\s\S]*?\*\//gm, '')

const argsString = stripped.slice(stripped.indexOf('(') + 1, stripped.indexOf(')'))
const args = argsString
.split(',')
.map((arg) => arg.trim())
.filter(Boolean)

return args.map((arg) => arg.startsWith('...'))
}
43 changes: 27 additions & 16 deletions src/runtime/server/helpers/process-tuple-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,27 +25,38 @@ export function processTupleSchema(schema: z.ZodTuple, args: any[]): any[] {
return args
}

// Only process if we have MORE args than schema expects
// This means we need to group extra args into the last position
if (args.length <= items.length) {
return args
}

const lastItem = items[items.length - 1]
const positionalCount = items.length - 1

// If last parameter is an array type, collect extra args into it
if (lastItem instanceof z.ZodArray) {
const positional = args.slice(0, positionalCount)
const restArray = args.slice(positionalCount)
return [...positional, restArray]
// Case: More args than items (Greedy grouping)
if (args.length > items.length) {
// If last parameter is a string, join extra args with space
if (lastItem instanceof z.ZodString) {
const positional = args.slice(0, positionalCount)
const restString = args.slice(positionalCount).join(' ')
return [...positional, restString]
}

// If last parameter is an array, we keep them as individual elements
// for the handler's spread operator (...args) or just as the array itself
// if ZodTuple is being used to parse.
// However, to avoid nesting [arg1, [arg2, arg3]], we return them flat
// if the handler expects a spread, OR we return the array if it's a single param.
if (lastItem instanceof z.ZodArray) {
// For ZodTuple.parse() to work with a ZodArray at the end,
// it actually expects the array as a single element in that position.
const positional = args.slice(0, positionalCount)
const restArray = args.slice(positionalCount)
return [...positional, restArray]
}
}

// If last parameter is a string, join extra args with space
if (lastItem instanceof z.ZodString) {
const positional = args.slice(0, positionalCount)
const restString = args.slice(positionalCount).join(' ')
return [...positional, restString]
// Case: Exact match but last is array
if (args.length === items.length) {
if (lastItem instanceof z.ZodArray && !Array.isArray(args[positionalCount])) {
const positional = args.slice(0, positionalCount)
return [...positional, [args[positionalCount]]]
}
}

return args
Expand Down
105 changes: 105 additions & 0 deletions tests/unit/server/services/command-bug-reproduction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import 'reflect-metadata'
import { describe, expect, it, vi } from 'vitest'
import type { CommandMetadata } from '../../../../src/runtime/server/decorators/command'
import { Player } from '../../../../src/runtime/server/entities'
import { CommandService } from '../../../../src/runtime/server/services/core/command.service'
import { createTestPlayer } from '../../../helpers'

describe('Command Arguments - Spread Operator vs Array Parameter', () => {
it('should flatten arguments when using spread operator (...args)', async () => {
const service = new CommandService()
const handler = vi.fn().mockImplementation((_player: Player, ...actions: string[]) => {
return actions.join(' ')
})

// Simulates: meCommand(player: Server.Player, ...actions: string[])
const meta: CommandMetadata = {
command: 'me',
methodName: 'handleMe',
target: class Dummy {} as any,
paramTypes: [Player, Array],
paramNames: ['player', '...actions'],
expectsPlayer: true,
description: 'Me command',
usage: 'Usage: /me [action]',
schema: undefined,
isPublic: true,
hasSpreadParam: true, // Key difference: spread operator detected
}

service.register(meta, handler)

const fakePlayer = createTestPlayer({ clientID: 1 })

// Test with multiple arguments: /me hello world
const result = await service.execute(fakePlayer, 'me', ['hello', 'world'])

// Handler receives individual arguments: handler(player, 'hello', 'world')
expect(result).toBe('hello world')
expect(handler).toHaveBeenCalledWith(fakePlayer, 'hello', 'world')
})

it('should pass array as single argument when using array parameter (args: string[])', async () => {
const service = new CommandService()
const handler = vi.fn().mockImplementation((_player: Player, actions: string[]) => {
return actions.join(' ')
})

// Simulates: doCommand(player: Server.Player, descriptions: string[])
const meta: CommandMetadata = {
command: 'do',
methodName: 'handleDo',
target: class Dummy {} as any,
paramTypes: [Player, Array],
paramNames: ['player', 'descriptions'],
expectsPlayer: true,
description: 'Do command',
usage: 'Usage: /do [description]',
schema: undefined,
isPublic: true,
hasSpreadParam: false, // Key difference: regular array parameter
}

service.register(meta, handler)

const fakePlayer = createTestPlayer({ clientID: 1 })

// Test with multiple arguments: /do hello world
const result = await service.execute(fakePlayer, 'do', ['hello', 'world'])

// Handler receives array as single argument: handler(player, ['hello', 'world'])
expect(result).toBe('hello world')
expect(handler).toHaveBeenCalledWith(fakePlayer, ['hello', 'world'])
})

it('should work with single argument using spread operator', async () => {
const service = new CommandService()
const handler = vi.fn().mockImplementation((_player: Player, ...actions: string[]) => {
return actions.join(' ')
})

const meta: CommandMetadata = {
command: 'me',
methodName: 'handleMe',
target: class Dummy {} as any,
paramTypes: [Player, Array],
paramNames: ['player', '...actions'],
expectsPlayer: true,
description: 'Me command',
usage: 'Usage: /me [action]',
schema: undefined,
isPublic: true,
hasSpreadParam: true,
}

service.register(meta, handler)

const fakePlayer = createTestPlayer({ clientID: 1 })

// Test with single argument: /me hello
const result = await service.execute(fakePlayer, 'me', ['hello'])

expect(result).toBe('hello')
expect(handler).toHaveBeenCalledWith(fakePlayer, 'hello')
})
})