diff --git a/RELEASE.md b/RELEASE.md index cdc38ed..1b1bed0 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -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. \ No newline at end of file diff --git a/package.json b/package.json index 5056dfa..4723b92 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/runtime/server/decorators/command.ts b/src/runtime/server/decorators/command.ts index 7e95fee..6796558 100644 --- a/src/runtime/server/decorators/command.ts +++ b/src/runtime/server/decorators/command.ts @@ -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' @@ -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) @@ -148,6 +150,9 @@ 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, @@ -155,6 +160,7 @@ export function Command(configOrName: string | CommandConfig, schema?: z.ZodType paramTypes, paramNames, expectsPlayer, + hasSpreadParam, } Reflect.defineMetadata(METADATA_KEYS.COMMAND, metadata, target, propertyKey) diff --git a/src/runtime/server/helpers/command-validation.helper.ts b/src/runtime/server/helpers/command-validation.helper.ts index 35d8940..516c29a 100644 --- a/src/runtime/server/helpers/command-validation.helper.ts +++ b/src/runtime/server/helpers/command-validation.helper.ts @@ -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 diff --git a/src/runtime/server/helpers/function-helper.ts b/src/runtime/server/helpers/function-helper.ts index 64783de..a5bfb6e 100644 --- a/src/runtime/server/helpers/function-helper.ts +++ b/src/runtime/server/helpers/function-helper.ts @@ -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('...')) +} diff --git a/src/runtime/server/helpers/process-tuple-schema.ts b/src/runtime/server/helpers/process-tuple-schema.ts index 2ec13b5..ecf239f 100644 --- a/src/runtime/server/helpers/process-tuple-schema.ts +++ b/src/runtime/server/helpers/process-tuple-schema.ts @@ -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 diff --git a/tests/unit/server/services/command-bug-reproduction.test.ts b/tests/unit/server/services/command-bug-reproduction.test.ts new file mode 100644 index 0000000..f9a8ea7 --- /dev/null +++ b/tests/unit/server/services/command-bug-reproduction.test.ts @@ -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') + }) +})