diff --git a/.github/workflows/code-quality.yaml b/.github/workflows/code-quality.yaml index 6158aad3..4673ace0 100644 --- a/.github/workflows/code-quality.yaml +++ b/.github/workflows/code-quality.yaml @@ -31,7 +31,7 @@ jobs: run: pnpm dlx turbo check-types - name: Run CommandKit Command Handler Tests - run: pnpm test:commandkit + run: pnpm run test - name: Check Generated API Docs run: pnpm docgen:check diff --git a/apps/test-bot/.gitignore b/apps/test-bot/.gitignore index d4de26a9..9ef9eb28 100644 --- a/apps/test-bot/.gitignore +++ b/apps/test-bot/.gitignore @@ -3,4 +3,4 @@ compiled-commandkit.config.mjs *.db* .workflow-data/ -.swc/ \ No newline at end of file +.swc/ diff --git a/apps/test-bot/commandkit-env.d.ts b/apps/test-bot/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/apps/test-bot/commandkit-env.d.ts +++ b/apps/test-bot/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/apps/test-bot/tsconfig.json b/apps/test-bot/tsconfig.json index f31a0d75..5c085f9f 100644 --- a/apps/test-bot/tsconfig.json +++ b/apps/test-bot/tsconfig.json @@ -11,11 +11,9 @@ "skipLibCheck": true, "skipDefaultLibCheck": true, "allowJs": true, - "alwaysStrict": false, "checkJs": false, "strict": true, "strictNullChecks": true, - "baseUrl": ".", "paths": { "@/*": ["./src/*"] } diff --git a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx index 63cb2ca9..b520abf4 100644 --- a/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/app-command-handler.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandHandler - + Handles application commands for CommandKit, including loading, registration, and execution. Manages both slash commands and message commands with middleware support. @@ -34,7 +34,7 @@ class AppCommandHandler { registerCommandHandler() => void; prepareCommandRun(source: Interaction | Message, cmdName?: string) => Promise; resolveMessageCommandName(name: string) => string; - reloadCommands() => Promise; + reloadCommands(path?: string, changeType?: RouterFileChangeType) => Promise; addExternalMiddleware(data: Middleware[]) => Promise; addExternalCommands(data: Command[]) => Promise; registerExternalLoadedMiddleware(data: LoadedMiddleware[]) => Promise; @@ -108,7 +108,7 @@ Prepares a command for execution by resolving the command and its middleware. ### reloadCommands - Promise<void>`} /> +RouterFileChangeType) => Promise<void>`} /> Reloads all commands and middleware from scratch. ### addExternalMiddleware diff --git a/apps/website/docs/api-reference/commandkit/classes/app-events-handler.mdx b/apps/website/docs/api-reference/commandkit/classes/app-events-handler.mdx index 86f7e89f..e9b75a63 100644 --- a/apps/website/docs/api-reference/commandkit/classes/app-events-handler.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/app-events-handler.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppEventsHandler - + Handles Discord.js events and CommandKit custom events with support for namespacing and middleware. @@ -21,7 +21,7 @@ Handles Discord.js events and CommandKit custom events with support for namespac class AppEventsHandler { constructor(commandkit: CommandKit) getEvents() => AppEventsHandlerLoadedData[]; - reloadEvents() => ; + reloadEvents(path?: string, changeType?: EventsRouterFileChangeType) => ; loadEvents() => ; unregisterAll() => ; registerAllClientEvents() => ; @@ -43,7 +43,7 @@ Creates a new AppEventsHandler instance. Gets information about all loaded events. ### reloadEvents - `} /> +EventsRouterFileChangeType) => `} /> Reloads all events by unregistering existing ones and loading them again. ### loadEvents diff --git a/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx b/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx index de2afe53..4bc9b8a8 100644 --- a/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/command-kit.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKit - + The commandkit class that serves as the main entry point for the CommandKit framework. diff --git a/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx b/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx index bb769c0e..3e77df36 100644 --- a/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/commands-router.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandsRouter - + Handles discovery and parsing of command and middleware files in the filesystem. @@ -24,6 +24,7 @@ class CommandsRouter { isValidPath() => boolean; clear() => void; scan() => Promise; + scanIncremental(changedPath: string, _changeType: RouterFileChangeType = 'change') => Promise; getData() => { commands: Collection; middlewares: Collection; @@ -67,6 +68,12 @@ Clears all loaded commands, middleware, and compiled tree data. Promise<ParsedCommandData>`} /> Scans the filesystem for commands and middleware files. +### scanIncremental + +RouterFileChangeType = 'change') => Promise<ParsedCommandData>`} /> + +Incrementally updates only the top-level command subtree affected by a file change. +Falls back to a full scan when the changed path cannot be safely scoped. ### getData { commands: Collection<string, Command>; middlewares: Collection<string, Middleware>; treeNodes: Collection<string, CommandTreeNode>; compiledRoutes: Collection<string, CompiledCommandRoute>; diagnostics: CommandRouteDiagnostic[]; }`} /> diff --git a/apps/website/docs/api-reference/commandkit/classes/events-router.mdx b/apps/website/docs/api-reference/commandkit/classes/events-router.mdx index abe83788..ce85d6bb 100644 --- a/apps/website/docs/api-reference/commandkit/classes/events-router.mdx +++ b/apps/website/docs/api-reference/commandkit/classes/events-router.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## EventsRouter - + Router for discovering and managing event handler files in a directory structure. Events are represented by directories, and handlers are files within those directories. @@ -26,8 +26,10 @@ class EventsRouter { entrypoints: string[] isValidPath() => ; clear() => ; + populate(events: EventsTree) => ; reload() => ; scan() => Promise; + scanIncremental(changedPath: string, _changeType: EventsRouterFileChangeType = 'change') => Promise; toJSON() => EventsTree; } ``` @@ -64,6 +66,11 @@ Checks if the entrypoint path is valid `} /> Clear all parsed events +### populate + +EventsTree) => `} /> + +Populates router state from pre-resolved event metadata. ### reload `} /> @@ -74,6 +81,11 @@ Reload and re-scan the entrypoint directory for events Promise<EventsTree>`} /> Scan the entrypoint directory for events and their handlers +### scanIncremental + +EventsRouterFileChangeType = 'change') => Promise<EventsTree>`} /> + +Incrementally rescans only the event subtree impacted by a changed path. ### toJSON EventsTree`} /> diff --git a/apps/website/docs/api-reference/commandkit/functions/bootstrap-commandkit-cli.mdx b/apps/website/docs/api-reference/commandkit/functions/bootstrap-commandkit-cli.mdx index c2c89f66..cf595de4 100644 --- a/apps/website/docs/api-reference/commandkit/functions/bootstrap-commandkit-cli.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/bootstrap-commandkit-cli.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## bootstrapCommandkitCLI - + Creates a command line interface for CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/functions/create-router-tree-artifact.mdx b/apps/website/docs/api-reference/commandkit/functions/create-router-tree-artifact.mdx new file mode 100644 index 00000000..63a82029 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/functions/create-router-tree-artifact.mdx @@ -0,0 +1,33 @@ +--- +title: "CreateRouterTreeArtifact" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## createRouterTreeArtifact + + + + + +```ts title="Signature" +function createRouterTreeArtifact(options: { + outputRoot: string; + commandkitVersion: string; + commands: ParsedCommandData; + events: EventsTree; +}): RouterTreeArtifact +``` +Parameters + +### options + +ParsedCommandData; events: EventsTree; }`} /> + diff --git a/apps/website/docs/api-reference/commandkit/functions/get-types-file-path.mdx b/apps/website/docs/api-reference/commandkit/functions/get-types-file-path.mdx new file mode 100644 index 00000000..c8edbdad --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/functions/get-types-file-path.mdx @@ -0,0 +1,22 @@ +--- +title: "GetTypesFilePath" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getTypesFilePath + + + + + +```ts title="Signature" +function getTypesFilePath(): void +``` diff --git a/apps/website/docs/api-reference/commandkit/functions/hydrate-router-tree-artifact.mdx b/apps/website/docs/api-reference/commandkit/functions/hydrate-router-tree-artifact.mdx new file mode 100644 index 00000000..48d903ff --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/functions/hydrate-router-tree-artifact.mdx @@ -0,0 +1,35 @@ +--- +title: "HydrateRouterTreeArtifact" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## hydrateRouterTreeArtifact + + + + + +```ts title="Signature" +function hydrateRouterTreeArtifact(artifact: RouterTreeArtifact, outputRoot: string): { + commands: ParsedCommandData; + events: EventsTree; +} +``` +Parameters + +### artifact + +RouterTreeArtifact`} /> + +### outputRoot + + + diff --git a/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx b/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx index 3cab400c..d8ecfe01 100644 --- a/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/on-application-bootstrap.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## onApplicationBootstrap - + Registers a bootstrap hook that will be called when the CommandKit instance is created. This is useful for plugins that need to run some code after the CommandKit instance is fully initialized. diff --git a/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx b/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx index 2f0c92af..2074f610 100644 --- a/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/on-bootstrap.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## onBootstrap - + Registers a bootstrap hook that will be called when the CommandKit instance is created. This is useful for plugins that need to run some code after the CommandKit instance is fully initialized. diff --git a/apps/website/docs/api-reference/commandkit/functions/register-dev-hooks.mdx b/apps/website/docs/api-reference/commandkit/functions/register-dev-hooks.mdx index 191f08cf..69b883b2 100644 --- a/apps/website/docs/api-reference/commandkit/functions/register-dev-hooks.mdx +++ b/apps/website/docs/api-reference/commandkit/functions/register-dev-hooks.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## registerDevHooks - + Registers development hooks for CommandKit to handle HMR (Hot Module Replacement) events. diff --git a/apps/website/docs/api-reference/commandkit/functions/validate-router-tree-artifact.mdx b/apps/website/docs/api-reference/commandkit/functions/validate-router-tree-artifact.mdx new file mode 100644 index 00000000..4f3ebe7f --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/functions/validate-router-tree-artifact.mdx @@ -0,0 +1,32 @@ +--- +title: "ValidateRouterTreeArtifact" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## validateRouterTreeArtifact + + + + + +```ts title="Signature" +function validateRouterTreeArtifact(input: unknown, runtimeVersion: string): input is RouterTreeArtifact +``` +Parameters + +### input + + + +### runtimeVersion + + + diff --git a/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx index 75a310a0..7450918e 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/app-command-native.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommandNative - + Represents a native command structure used in CommandKit. This structure includes the command definition and various handlers for different interaction types. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/app-events-handler-loaded-data.mdx b/apps/website/docs/api-reference/commandkit/interfaces/app-events-handler-loaded-data.mdx index 888e1725..7ded58ca 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/app-events-handler-loaded-data.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/app-events-handler-loaded-data.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppEventsHandlerLoadedData - + Data structure representing loaded event information for external consumption. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-compiler-options.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-compiler-options.mdx index eecc428b..839e6985 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-compiler-options.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-compiler-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKitCompilerOptions - + diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-config.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-config.mdx index f4408e05..6956465b 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-config.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKitConfig - + @@ -59,6 +59,23 @@ interface CommandKitConfig { disablePermissionsMiddleware?: boolean; showUnknownPrefixCommandsWarning?: boolean; jsxDefaultOptionalComponents?: boolean; + experimental?: { + /** + * The runtime to use for the development server. This is an experimental feature and may be removed or changed without a major version bump. Use with caution. + * @default null + */ + devServerRuntime?: CommandKitJsRuntime | null; + /** + * Whether to emit and hydrate a pre-generated commands/events artifact in production builds. + * @default false + */ + pregenerateCommands?: boolean; + /** + * Whether to use incremental router reconciliation in development HMR. + * @default false + */ + incrementalRouter?: boolean; + }; } ``` @@ -141,6 +158,11 @@ Whether or not to show a warning when a prefix command is not found. This only a Whether to make interaction components optional by default when using JSX (opposite of Discord's default behavior). +### experimental + +version bump. Use with caution. * @default null */ devServerRuntime?: CommandKitJsRuntime | null; /** * Whether to emit and hydrate a pre-generated commands/events artifact in production builds. * @default false */ pregenerateCommands?: boolean; /** * Whether to use incremental router reconciliation in development HMR. * @default false */ incrementalRouter?: boolean; }`} /> + +Experimental features configuration. These features are not stable and may be removed or changed without a major version bump. Use with caution. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx index db9829f2..36873adb 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-configuration.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKitConfiguration - + Configurations for the CommandKit instance. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-hmrevent.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-hmrevent.mdx index 0ef4e85a..918c8872 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command-kit-hmrevent.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command-kit-hmrevent.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandKitHMREvent - + Represents an HMR event in CommandKit. @@ -21,6 +21,7 @@ Represents an HMR event in CommandKit. interface CommandKitHMREvent { event: HMREventType; path: string; + changeType?: HMREventChangeType; accept: () => void; preventDefault: () => void; } @@ -38,6 +39,11 @@ The type of HMR event. The path associated with the HMR event. +### changeType + +HMREventChangeType`} /> + +The original filesystem change type that triggered this HMR event. ### accept diff --git a/apps/website/docs/api-reference/commandkit/interfaces/command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/command.mdx index 05edd9dc..243b434d 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## Command - + Represents a command with its metadata and middleware associations. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx b/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx index 9230e0bb..b8037b59 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/commands-router-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandsRouterOptions - + Configuration options for the commands router. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx index 7b59396d..74452f7c 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/custom-app-command-props.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CustomAppCommandProps - + Custom properties that can be added to an AppCommand. This allows for additional metadata or configuration to be associated with a command. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/events-router-options.mdx b/apps/website/docs/api-reference/commandkit/interfaces/events-router-options.mdx index 8bad1e80..e5e7f4b9 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/events-router-options.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/events-router-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## EventsRouterOptions - + Configuration options for the EventsRouter diff --git a/apps/website/docs/api-reference/commandkit/interfaces/ipc-message-command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/ipc-message-command.mdx index 8581c315..394ee3d5 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/ipc-message-command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/ipc-message-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## IpcMessageCommand - + Represents HMR inter-process communication messages. @@ -21,6 +21,7 @@ Represents HMR inter-process communication messages. interface IpcMessageCommand { event: HMREventType; path: string; + changeType?: HMREventChangeType; id?: string; } ``` @@ -37,6 +38,11 @@ The type of HMR event being communicated. The path associated with the HMR event. +### changeType + +HMREventChangeType`} /> + +The original filesystem change type that triggered this HMR event. ### id diff --git a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx index 46845bff..036b0d5f 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/loaded-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## LoadedCommand - + Represents a loaded command with its metadata and configuration. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/loaded-event.mdx b/apps/website/docs/api-reference/commandkit/interfaces/loaded-event.mdx index 5e5d70ca..297ecc8b 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/loaded-event.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/loaded-event.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## LoadedEvent - + Represents a loaded event with all its listeners. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx b/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx index ab430475..eb31a12d 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/middleware.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## Middleware - + Represents a middleware with its metadata and scope. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx b/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx index f92c82db..95bdba44 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/parsed-command-data.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ParsedCommandData - + Data structure containing parsed commands, middleware, and tree data. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/parsed-event.mdx b/apps/website/docs/api-reference/commandkit/interfaces/parsed-event.mdx index f60e119c..f036710c 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/parsed-event.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/parsed-event.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ParsedEvent - + Represents a parsed event with its handlers diff --git a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx index b9f85a55..9b73cbc6 100644 --- a/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx +++ b/apps/website/docs/api-reference/commandkit/interfaces/prepared-app-command-execution.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## PreparedAppCommandExecution - + Represents a prepared command execution with all necessary data and middleware. diff --git a/apps/website/docs/api-reference/commandkit/interfaces/router-tree-artifact.mdx b/apps/website/docs/api-reference/commandkit/interfaces/router-tree-artifact.mdx new file mode 100644 index 00000000..3cbbeb7b --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/interfaces/router-tree-artifact.mdx @@ -0,0 +1,59 @@ +--- +title: "RouterTreeArtifact" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RouterTreeArtifact + + + + + +```ts title="Signature" +interface RouterTreeArtifact { + schemaVersion: number; + commandkitVersion: string; + generatedAt: string; + commands: ParsedCommandData; + events: EventsTree; +} +``` + +
+ +### schemaVersion + + + + +### commandkitVersion + + + + +### generatedAt + + + + +### commands + +ParsedCommandData`} /> + + +### events + +EventsTree`} /> + + + + +
diff --git a/apps/website/docs/api-reference/commandkit/types/app-command.mdx b/apps/website/docs/api-reference/commandkit/types/app-command.mdx index 03a3398c..7b7c169f 100644 --- a/apps/website/docs/api-reference/commandkit/types/app-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/app-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## AppCommand - + Represents a command in the CommandKit application, including its metadata and handlers. This type extends the native command structure with additional properties. diff --git a/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx b/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx index d55a1ca0..4d5874ad 100644 --- a/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx +++ b/apps/website/docs/api-reference/commandkit/types/bootstrap-function.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## BootstrapFunction - + Represents the function executed during the bootstrap phase of CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx index 8c21234a..58bef318 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-builder-like.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandBuilderLike - + Type representing command builder objects supported by CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/types/command-kit-js-runtime.mdx b/apps/website/docs/api-reference/commandkit/types/command-kit-js-runtime.mdx new file mode 100644 index 00000000..0816e01f --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/command-kit-js-runtime.mdx @@ -0,0 +1,22 @@ +--- +title: "CommandKitJsRuntime" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## CommandKitJsRuntime + + + +The JavaScript runtime to use for the development server. + +```ts title="Signature" +type CommandKitJsRuntime = 'node' | 'bun' | 'deno' | 'auto' +``` diff --git a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx index 27b6f445..39f47735 100644 --- a/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx +++ b/apps/website/docs/api-reference/commandkit/types/command-type-data.mdx @@ -13,10 +13,12 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## CommandTypeData - + Type representing command data identifier. ```ts title="Signature" -type CommandTypeData = string +type CommandTypeData = [CommandTypeDataRegistryKeys] extends [never] + ? string + : Extract ``` diff --git a/apps/website/docs/api-reference/commandkit/types/event-listener.mdx b/apps/website/docs/api-reference/commandkit/types/event-listener.mdx index 6185ac77..09b486b9 100644 --- a/apps/website/docs/api-reference/commandkit/types/event-listener.mdx +++ b/apps/website/docs/api-reference/commandkit/types/event-listener.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## EventListener - + Represents an event listener with its configuration. diff --git a/apps/website/docs/api-reference/commandkit/types/events-router-file-change-type.mdx b/apps/website/docs/api-reference/commandkit/types/events-router-file-change-type.mdx new file mode 100644 index 00000000..f6b7df83 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/events-router-file-change-type.mdx @@ -0,0 +1,25 @@ +--- +title: "EventsRouterFileChangeType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## EventsRouterFileChangeType + + + + + +```ts title="Signature" +type EventsRouterFileChangeType = | 'add' + | 'change' + | 'unlink' + | 'unlinkDir' +``` diff --git a/apps/website/docs/api-reference/commandkit/types/events-tree.mdx b/apps/website/docs/api-reference/commandkit/types/events-tree.mdx index d931b502..808871c9 100644 --- a/apps/website/docs/api-reference/commandkit/types/events-tree.mdx +++ b/apps/website/docs/api-reference/commandkit/types/events-tree.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## EventsTree - + Collection of event names to their parsed metadata diff --git a/apps/website/docs/api-reference/commandkit/types/hmrevent-change-type.mdx b/apps/website/docs/api-reference/commandkit/types/hmrevent-change-type.mdx new file mode 100644 index 00000000..7b9f986a --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/hmrevent-change-type.mdx @@ -0,0 +1,22 @@ +--- +title: "HMREventChangeType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## HMREventChangeType + + + +The type for file change events in HMR payloads. + +```ts title="Signature" +type HMREventChangeType = (typeof HMREventChangeType)[keyof typeof HMREventChangeType] +``` diff --git a/apps/website/docs/api-reference/commandkit/types/hmrevent-type.mdx b/apps/website/docs/api-reference/commandkit/types/hmrevent-type.mdx index de039772..77339743 100644 --- a/apps/website/docs/api-reference/commandkit/types/hmrevent-type.mdx +++ b/apps/website/docs/api-reference/commandkit/types/hmrevent-type.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## HMREventType - + The type for HMR events. diff --git a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx index 72607bfc..c2daf9fa 100644 --- a/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/resolvable-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolvableCommand - + Type for commands that can be resolved by the handler. diff --git a/apps/website/docs/api-reference/commandkit/types/router-file-change-type.mdx b/apps/website/docs/api-reference/commandkit/types/router-file-change-type.mdx new file mode 100644 index 00000000..934bd544 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/types/router-file-change-type.mdx @@ -0,0 +1,22 @@ +--- +title: "RouterFileChangeType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RouterFileChangeType + + + + + +```ts title="Signature" +type RouterFileChangeType = 'add' | 'change' | 'unlink' | 'unlinkDir' +``` diff --git a/apps/website/docs/api-reference/commandkit/types/run-command.mdx b/apps/website/docs/api-reference/commandkit/types/run-command.mdx index baeb2b87..35118aa0 100644 --- a/apps/website/docs/api-reference/commandkit/types/run-command.mdx +++ b/apps/website/docs/api-reference/commandkit/types/run-command.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RunCommand - + Function type for wrapping command execution with custom logic. diff --git a/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx b/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx index 5f79e498..95fd2e7a 100644 --- a/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx +++ b/apps/website/docs/api-reference/commandkit/variables/commandkit.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## commandkit - + The singleton instance of CommandKit. diff --git a/apps/website/docs/api-reference/commandkit/variables/commandkit_env_file.mdx b/apps/website/docs/api-reference/commandkit/variables/commandkit_env_file.mdx new file mode 100644 index 00000000..4a0ec16d --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/commandkit_env_file.mdx @@ -0,0 +1,19 @@ +--- +title: "COMMANDKIT_ENV_FILE" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## COMMANDKIT_ENV_FILE + + + + + diff --git a/apps/website/docs/api-reference/commandkit/variables/commandkit_types_directory.mdx b/apps/website/docs/api-reference/commandkit/variables/commandkit_types_directory.mdx new file mode 100644 index 00000000..6f2e8e5d --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/commandkit_types_directory.mdx @@ -0,0 +1,19 @@ +--- +title: "COMMANDKIT_TYPES_DIRECTORY" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## COMMANDKIT_TYPES_DIRECTORY + + + + + diff --git a/apps/website/docs/api-reference/commandkit/variables/commandkit_types_file.mdx b/apps/website/docs/api-reference/commandkit/variables/commandkit_types_file.mdx new file mode 100644 index 00000000..b3ee65a4 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/commandkit_types_file.mdx @@ -0,0 +1,19 @@ +--- +title: "COMMANDKIT_TYPES_FILE" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## COMMANDKIT_TYPES_FILE + + + + + diff --git a/apps/website/docs/api-reference/commandkit/variables/hmrevent-change-type.mdx b/apps/website/docs/api-reference/commandkit/variables/hmrevent-change-type.mdx new file mode 100644 index 00000000..18148c75 --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/hmrevent-change-type.mdx @@ -0,0 +1,19 @@ +--- +title: "HMREventChangeType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## HMREventChangeType + + + +File system change types delivered through HMR messages. + diff --git a/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_directory.mdx b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_directory.mdx new file mode 100644 index 00000000..b6f2de1b --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_directory.mdx @@ -0,0 +1,19 @@ +--- +title: "ROUTER_TREE_ARTIFACT_DIRECTORY" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ROUTER_TREE_ARTIFACT_DIRECTORY + + + + + diff --git a/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_file.mdx b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_file.mdx new file mode 100644 index 00000000..d0ed66df --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_file.mdx @@ -0,0 +1,19 @@ +--- +title: "ROUTER_TREE_ARTIFACT_FILE" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ROUTER_TREE_ARTIFACT_FILE + + + + + diff --git a/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_schema_version.mdx b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_schema_version.mdx new file mode 100644 index 00000000..9effda6e --- /dev/null +++ b/apps/website/docs/api-reference/commandkit/variables/router_tree_artifact_schema_version.mdx @@ -0,0 +1,19 @@ +--- +title: "ROUTER_TREE_ARTIFACT_SCHEMA_VERSION" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ROUTER_TREE_ARTIFACT_SCHEMA_VERSION + + + + + diff --git a/apps/website/docs/guide/10-experimental/01-experimental-features.mdx b/apps/website/docs/guide/10-experimental/01-experimental-features.mdx new file mode 100644 index 00000000..e13fb7cf --- /dev/null +++ b/apps/website/docs/guide/10-experimental/01-experimental-features.mdx @@ -0,0 +1,89 @@ +--- +title: Experimental Features +description: Opt-in experimental runtime and type generation features in + CommandKit +--- + +CommandKit includes a small set of opt-in experimental features for +improving startup performance and local developer ergonomics. + +:::warning Experimental features are disabled by default and may +change in future minor releases. ::: + +## Enable Experimental Flags + +Set these flags in `commandkit.config.ts`: + +```ts title="commandkit.config.ts" +import { defineConfig } from 'commandkit/config'; + +export default defineConfig({ + experimental: { + pregenerateCommands: true, + incrementalRouter: true, + }, +}); +``` + +## `experimental.pregenerateCommands` + +When enabled, `commandkit build` pre-generates a serialized command +and event router artifact and writes it into build output: + +- `dist/.commandkit/router-tree.json` + +At runtime in production, CommandKit attempts to hydrate routers from +this artifact instead of performing full filesystem traversal. + +If the artifact is missing, stale, or invalid (for example version +mismatch), CommandKit automatically falls back to dynamic runtime +resolution. + +### Why Use It + +- Keeps production cold starts predictable as command/event trees + grow. +- Avoids repeated full tree construction work on each startup. + +## `experimental.incrementalRouter` + +When enabled, development HMR updates only the subtree affected by a +file change instead of reconciling the full router tree. + +This applies to command and event routers during `commandkit dev`, +including file add/change/delete operations. + +### Why Use It + +- Reduces reload lag in larger bots. +- Preserves unchanged router state across updates. + +## Type Generation (`commandkit typegen`) + +CommandKit can generate command route types for editor autocomplete. + +Run: + +```sh +commandkit typegen +``` + +This writes: + +- `./.commandkit/types.ts` (generated command route registry) +- `./commandkit-env.d.ts` (type reference file) + +`commandkit-env.d.ts` references the generated file: + +```ts title="commandkit-env.d.ts" +/// +``` + +During development, CommandKit also refreshes generated command route +types after command reloads. + +### Where You See It + +APIs that accept resolvable command names (for example +`ctx.forwardCommand(...)`) get route-aware autocomplete when generated +types are present. diff --git a/examples/basic-js/.gitignore b/examples/basic-js/.gitignore index d2a82db7..969f0bc9 100644 --- a/examples/basic-js/.gitignore +++ b/examples/basic-js/.gitignore @@ -33,4 +33,4 @@ lerna-debug.log* # other **/*.DS_Store -.temp-example \ No newline at end of file +.temp-example diff --git a/examples/basic-js/commandkit-env.d.ts b/examples/basic-js/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/basic-js/commandkit-env.d.ts +++ b/examples/basic-js/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/basic-ts/.gitignore b/examples/basic-ts/.gitignore index d2a82db7..969f0bc9 100644 --- a/examples/basic-ts/.gitignore +++ b/examples/basic-ts/.gitignore @@ -33,4 +33,4 @@ lerna-debug.log* # other **/*.DS_Store -.temp-example \ No newline at end of file +.temp-example diff --git a/examples/basic-ts/commandkit-env.d.ts b/examples/basic-ts/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/basic-ts/commandkit-env.d.ts +++ b/examples/basic-ts/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/deno-ts/commandkit-env.d.ts b/examples/deno-ts/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/deno-ts/commandkit-env.d.ts +++ b/examples/deno-ts/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/with-ai/.gitignore b/examples/with-ai/.gitignore index ca96ee36..96794a24 100644 --- a/examples/with-ai/.gitignore +++ b/examples/with-ai/.gitignore @@ -37,4 +37,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .commandkit dist -*.db* \ No newline at end of file +*.db* diff --git a/examples/with-ai/commandkit-env.d.ts b/examples/with-ai/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/with-ai/commandkit-env.d.ts +++ b/examples/with-ai/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/with-leveling-system/.gitignore b/examples/with-leveling-system/.gitignore index 881fb089..a3069797 100644 --- a/examples/with-leveling-system/.gitignore +++ b/examples/with-leveling-system/.gitignore @@ -33,4 +33,4 @@ lerna-debug.log* # other **/*.DS_Store -src/database/prisma/* \ No newline at end of file +src/database/prisma/* diff --git a/examples/with-leveling-system/commandkit-env.d.ts b/examples/with-leveling-system/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/with-leveling-system/commandkit-env.d.ts +++ b/examples/with-leveling-system/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/with-workflow/.gitignore b/examples/with-workflow/.gitignore index dcad7dd5..1645d08f 100644 --- a/examples/with-workflow/.gitignore +++ b/examples/with-workflow/.gitignore @@ -35,4 +35,4 @@ lerna-debug.log* **/*.DS_Store .temp-example .workflow-data/ -.swc/ \ No newline at end of file +.swc/ diff --git a/examples/with-workflow/commandkit-env.d.ts b/examples/with-workflow/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/with-workflow/commandkit-env.d.ts +++ b/examples/with-workflow/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/examples/without-cli/commandkit-env.d.ts b/examples/without-cli/commandkit-env.d.ts index cc2ba6df..39279d8f 100644 --- a/examples/without-cli/commandkit-env.d.ts +++ b/examples/without-cli/commandkit-env.d.ts @@ -1 +1 @@ -/// +/// diff --git a/package.json b/package.json index 2f0f453e..7edc8ca2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "build": "turbo run --filter=\"./packages/*\" build", "docgen": "tsx ./scripts/docs/generate-typescript-docs.ts && pnpm format", "docgen:check": "tsx ./scripts/docs/generate-typescript-docs.ts && git diff --exit-code -- apps/website/docs/api-reference", - "test:commandkit": "pnpm --filter commandkit test -- --run spec/reload-commands.test.ts spec/context-command-identifier.test.ts spec/hierarchical-command-registration.test.ts spec/hierarchical-command-handler.test.ts spec/commands-router.test.ts spec/message-command-parser.test.ts spec/context-menu-registration.test.ts", + "test": "turbo run --filter=\"./packages/*\" test", "prettier:check": "prettier --experimental-cli --check . --ignore-path=.prettierignore", "format": "prettier --experimental-cli --write . --ignore-path=.prettierignore" }, diff --git a/packages/commandkit/.gitignore b/packages/commandkit/.gitignore index c94bca33..3e76dbd8 100644 --- a/packages/commandkit/.gitignore +++ b/packages/commandkit/.gitignore @@ -5,4 +5,5 @@ dist !.env.example .env.* .env -.commandkit \ No newline at end of file +.commandkit +.tmp \ No newline at end of file diff --git a/packages/commandkit/cache.cjs b/packages/commandkit/cache.cjs index 10412775..bd9a018e 100644 --- a/packages/commandkit/cache.cjs +++ b/packages/commandkit/cache.cjs @@ -1,4 +1,5 @@ const { + $ckitiucw, cacheTag, cacheLife, revalidateTag, @@ -11,6 +12,7 @@ const { } = require('@commandkit/cache'); module.exports = { + $ckitiucw, cacheTag, cacheLife, revalidateTag, diff --git a/packages/commandkit/cache.d.ts b/packages/commandkit/cache.d.ts index 780276f4..163df7c6 100644 --- a/packages/commandkit/cache.d.ts +++ b/packages/commandkit/cache.d.ts @@ -1,4 +1,5 @@ export { + $ckitiucw, cacheTag, cacheLife, revalidateTag, diff --git a/packages/commandkit/spec/context-command-identifier.test.ts b/packages/commandkit/src/app/commands/Context.command-identifier.test.ts similarity index 95% rename from packages/commandkit/spec/context-command-identifier.test.ts rename to packages/commandkit/src/app/commands/Context.command-identifier.test.ts index bb8f3514..9aa4767d 100644 --- a/packages/commandkit/spec/context-command-identifier.test.ts +++ b/packages/commandkit/src/app/commands/Context.command-identifier.test.ts @@ -2,10 +2,10 @@ import { afterEach, describe, expect, test } from 'vitest'; import { Client, Collection, Message } from 'discord.js'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { CommandKit } from '../src/commandkit'; -import { CommandExecutionMode, Context } from '../src/app/commands/Context'; -import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; -import { CommandsRouter } from '../src/app/router'; +import { CommandKit } from '../../commandkit'; +import { CommandExecutionMode, Context } from './Context'; +import { AppCommandHandler } from '../handlers/AppCommandHandler'; +import { CommandsRouter } from '../router'; const tmpRoots: string[] = []; const tempBaseDir = join(__dirname, '.tmp'); diff --git a/packages/commandkit/spec/message-command-parser.test.ts b/packages/commandkit/src/app/commands/MessageCommandParser.test.ts similarity index 97% rename from packages/commandkit/spec/message-command-parser.test.ts rename to packages/commandkit/src/app/commands/MessageCommandParser.test.ts index 52e5dc7d..cd3336df 100644 --- a/packages/commandkit/spec/message-command-parser.test.ts +++ b/packages/commandkit/src/app/commands/MessageCommandParser.test.ts @@ -1,6 +1,6 @@ import { Collection, ApplicationCommandOptionType, Message } from 'discord.js'; import { describe, expect, test, vi } from 'vitest'; -import { MessageCommandParser } from '../src/app/commands/MessageCommandParser'; +import { MessageCommandParser } from './MessageCommandParser'; function createMessage(content: string) { return { diff --git a/packages/commandkit/spec/hierarchical-command-handler.test.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.hierarchical.test.ts similarity index 93% rename from packages/commandkit/spec/hierarchical-command-handler.test.ts rename to packages/commandkit/src/app/handlers/AppCommandHandler.hierarchical.test.ts index b5869bc6..92b3fca7 100644 --- a/packages/commandkit/spec/hierarchical-command-handler.test.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.hierarchical.test.ts @@ -8,9 +8,9 @@ import { } from 'discord.js'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { CommandKit } from '../src/commandkit'; -import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; -import { CommandsRouter } from '../src/app/router'; +import { CommandKit } from '../../commandkit'; +import { AppCommandHandler } from './AppCommandHandler'; +import { CommandsRouter } from '../router'; const tmpRoots: string[] = []; const tempBaseDir = join(__dirname, '.tmp'); @@ -184,11 +184,20 @@ export async function message() {} handler.getCommandsArray().map((command) => command.command.name), ).toEqual(['ping']); - expect( - handler.getRuntimeCommandsArray().map((command) => { + const runtimeRouteKeys = handler + .getRuntimeCommandsArray() + .map((command) => { return (command.data.command as Record).__routeKey; - }), - ).toEqual(['ping', 'admin.moderation.ban', 'admin.moderation.kick']); + }); + + expect(runtimeRouteKeys).toHaveLength(3); + expect(runtimeRouteKeys).toEqual( + expect.arrayContaining([ + 'ping', + 'admin.moderation.ban', + 'admin.moderation.kick', + ]), + ); const preparedFlat = await handler.prepareCommandRun( createChatInputInteraction('ping'), diff --git a/packages/commandkit/spec/reload-commands.test.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.reload.test.ts similarity index 83% rename from packages/commandkit/spec/reload-commands.test.ts rename to packages/commandkit/src/app/handlers/AppCommandHandler.reload.test.ts index 2be3d1b8..98225be8 100644 --- a/packages/commandkit/spec/reload-commands.test.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.reload.test.ts @@ -9,12 +9,13 @@ import { writeFile, } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { CommandKit } from '../src/commandkit'; +import { HMREventChangeType } from '../../utils/constants'; +import { CommandKit } from '../../commandkit'; import { AppCommandHandler, PreparedAppCommandExecution, -} from '../src/app/handlers/AppCommandHandler'; -import { CommandsRouter } from '../src/app/router'; +} from './AppCommandHandler'; +import { CommandsRouter } from '../router'; const tmpRoots: string[] = []; const tempBaseDir = join(__dirname, '.tmp'); @@ -299,4 +300,56 @@ export async function message() {} await client.destroy(); } }); + + test('uses incremental command router reconciliation when enabled', async () => { + const { client, handler, entrypoint } = await createHandlerWithCommands([ + [ + '[admin]/command.mjs', + `export const command = { description: 'Admin' };`, + ], + [ + '[admin]/{moderation}/group.mjs', + `export const command = { description: 'Moderation' };`, + ], + [ + '[admin]/{moderation}/ban.subcommand.mjs', + ` +export const command = { description: 'Ban' }; +export async function chatInput() {} +`, + ], + [ + '[tools]/command.mjs', + ` +export const command = { description: 'Tools' }; +export async function chatInput() {} +`, + ], + ]); + + try { + handler.commandkit.config.experimental.incrementalRouter = true; + + await unlink( + join(entrypoint, '[admin]', '{moderation}', 'ban.subcommand.mjs'), + ); + + await handler.reloadCommands( + join(entrypoint, '[admin]', '{moderation}', 'ban.subcommand.mjs'), + HMREventChangeType.Unlink, + ); + + expect( + handler + .getRuntimeCommandsArray() + .map( + (command) => + (command.data.command as Record).__routeKey, + ) + .sort(), + ).toEqual(['tools']); + } finally { + await client.destroy(); + } + }); }); diff --git a/packages/commandkit/src/app/handlers/AppCommandHandler.ts b/packages/commandkit/src/app/handlers/AppCommandHandler.ts index 9aaeb19c..6f0908bf 100644 --- a/packages/commandkit/src/app/handlers/AppCommandHandler.ts +++ b/packages/commandkit/src/app/handlers/AppCommandHandler.ts @@ -40,6 +40,7 @@ import { CompiledCommandRoute, Middleware, } from '../router'; +import type { RouterFileChangeType } from '../router/CommandsRouter'; const KNOWN_NON_HANDLER_KEYS = [ 'command', @@ -115,10 +116,27 @@ export interface LoadedCommand { data: AppCommand; } +/** + * Global registry used to infer strongly-typed command identifiers. + * This namespace is augmented by generated `commandkit/types` declarations. + */ +declare global { + namespace CommandKitTypes { + interface Registry {} + } +} + +type CommandTypeDataRegistryKeys = Exclude< + keyof CommandKitTypes.Registry, + '__commandkit_wide__' +>; + /** * Type representing command data identifier. */ -export type CommandTypeData = string; +export type CommandTypeData = [CommandTypeDataRegistryKeys] extends [never] + ? string + : Extract; /** * Type for commands that can be resolved by the handler. @@ -863,8 +881,15 @@ export class AppCommandHandler { /** * Reloads all commands and middleware from scratch. */ - public async reloadCommands(): Promise { - await this.commandkit.commandsRouter?.scan(); + public async reloadCommands( + path?: string, + changeType?: RouterFileChangeType, + ): Promise { + if (this.commandkit.config.experimental.incrementalRouter && path) { + await this.commandkit.commandsRouter?.scanIncremental(path, changeType); + } else { + await this.commandkit.commandsRouter?.scan(); + } this.loadedCommands.clear(); this.loadedMiddlewares.clear(); @@ -981,9 +1006,7 @@ export class AppCommandHandler { const allNames = Array.from(new Set([...commandNames, ...aliases])); - await rewriteCommandDeclaration( - `type CommandTypeData = ${allNames.map((name) => JSON.stringify(name)).join(' | ')}`, - ); + await rewriteCommandDeclaration(allNames); } await this.commandkit.plugins.execute((ctx, plugin) => { diff --git a/packages/commandkit/src/app/handlers/AppEventsHandler.ts b/packages/commandkit/src/app/handlers/AppEventsHandler.ts index 469b3cc9..44203835 100644 --- a/packages/commandkit/src/app/handlers/AppEventsHandler.ts +++ b/packages/commandkit/src/app/handlers/AppEventsHandler.ts @@ -7,6 +7,7 @@ import { runInEventWorkerContext } from '../events/EventWorkerContext'; import { ParsedEvent } from '../router'; import { CommandKitEventDispatch } from '../../plugins'; import { CommandKitErrorCodes, isErrorType } from '../../utils/error-codes'; +import type { EventsRouterFileChangeType } from '../router/EventsRouter'; /** * Represents an event listener with its configuration. @@ -82,8 +83,27 @@ export class AppEventsHandler { /** * Reloads all events by unregistering existing ones and loading them again. */ - public async reloadEvents() { + public async reloadEvents( + path?: string, + changeType?: EventsRouterFileChangeType, + ) { this.unregisterAll(); + + if (this.commandkit.config.experimental.incrementalRouter && path) { + await this.commandkit.plugins.execute((ctx, plugin) => { + return plugin.onBeforeEventsLoad(ctx); + }); + + await this.commandkit.eventsRouter.scanIncremental(path, changeType); + await this.loadEventsFromTree(this.commandkit.eventsRouter.toJSON()); + + await this.commandkit.plugins.execute((ctx, plugin) => { + return plugin.onAfterEventsLoad(ctx); + }); + + return; + } + await this.loadEvents(); } @@ -99,6 +119,20 @@ export class AppEventsHandler { const events = await router.scan(); + await this.loadEventsFromTree(events); + + await this.commandkit.plugins.execute((ctx, plugin) => { + return plugin.onAfterEventsLoad(ctx); + }); + } + + /** + * @private + * @internal + */ + private async loadEventsFromTree(events: Record) { + this.loadedEvents.clear(); + for (const event of Object.values(events)) { const listeners: EventListener[] = []; @@ -140,10 +174,6 @@ export class AppEventsHandler { } this.registerAllClientEvents(); - - await this.commandkit.plugins.execute((ctx, plugin) => { - return plugin.onAfterEventsLoad(ctx); - }); } /** diff --git a/packages/commandkit/spec/context-menu-registration.test.ts b/packages/commandkit/src/app/register/CommandRegistrar.context-menu.test.ts similarity index 96% rename from packages/commandkit/spec/context-menu-registration.test.ts rename to packages/commandkit/src/app/register/CommandRegistrar.context-menu.test.ts index 48cafeb0..2bc9a044 100644 --- a/packages/commandkit/spec/context-menu-registration.test.ts +++ b/packages/commandkit/src/app/register/CommandRegistrar.context-menu.test.ts @@ -1,16 +1,16 @@ import { afterEach, describe, expect, test } from 'vitest'; import { ApplicationCommandType, Client } from 'discord.js'; import { join } from 'node:path'; -import { CommandKit } from '../src/commandkit'; +import { CommandKit } from '../../commandkit'; import { AppCommandHandler, type LoadedCommand, -} from '../src/app/handlers/AppCommandHandler'; -import { CommandRegistrar } from '../src/app/register/CommandRegistrar'; -import type { Command } from '../src/app/router/CommandsRouter'; -import type { CommandMetadata } from '../src/types'; +} from '../handlers/AppCommandHandler'; +import { CommandRegistrar } from './CommandRegistrar'; +import type { Command } from '../router/CommandsRouter'; +import type { CommandMetadata } from '../../types'; -const fixturesDir = join(__dirname, 'fixtures'); +const fixturesDir = join(__dirname, '__fixtures__'); const noop = async () => {}; const slowTestTimeout = 20_000; diff --git a/packages/commandkit/spec/hierarchical-command-registration.test.ts b/packages/commandkit/src/app/register/CommandRegistrar.hierarchical.test.ts similarity index 74% rename from packages/commandkit/spec/hierarchical-command-registration.test.ts rename to packages/commandkit/src/app/register/CommandRegistrar.hierarchical.test.ts index e662218c..8c83d7bf 100644 --- a/packages/commandkit/spec/hierarchical-command-registration.test.ts +++ b/packages/commandkit/src/app/register/CommandRegistrar.hierarchical.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, test } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; import { ApplicationCommandOptionType, ApplicationCommandType, @@ -6,9 +6,10 @@ import { } from 'discord.js'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { CommandKit } from '../src/commandkit'; -import { AppCommandHandler } from '../src/app/handlers/AppCommandHandler'; -import { CommandsRouter } from '../src/app/router'; +import { CommandKit } from '../../commandkit'; +import { Logger } from '../../logger/Logger'; +import { AppCommandHandler } from '../handlers/AppCommandHandler'; +import { CommandsRouter } from '../router'; const tmpRoots: string[] = []; const tempBaseDir = join(__dirname, '.tmp'); @@ -115,34 +116,56 @@ describe('Hierarchical command registration', () => { const admin = registrationCommands.find( (entry) => entry.name === 'admin', ); + const moderationGroup = admin?.options?.[0] as + | { + name: string; + options?: Array<{ + name: string; + description: string; + options: Array<{ + name: string; + type: ApplicationCommandOptionType; + }>; + type: ApplicationCommandOptionType; + }>; + type: ApplicationCommandOptionType; + } + | undefined; expect(ping?.type).toBe(ApplicationCommandType.ChatInput); expect(admin?.type).toBe(ApplicationCommandType.ChatInput); expect(admin?.description).toBe('Admin'); - expect(admin?.options).toEqual([ + expect(moderationGroup?.name).toBe('moderation'); + expect(moderationGroup?.type).toBe( + ApplicationCommandOptionType.SubcommandGroup, + ); + + const normalizedSubcommands = [...(moderationGroup?.options ?? [])] + .map((option) => ({ + ...option, + options: [...(option.options ?? [])].sort((a, b) => + a.name.localeCompare(b.name), + ), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + expect(normalizedSubcommands).toEqual([ { - description: 'Moderation', - name: 'moderation', + description: 'Ban', + name: 'ban', options: [ { - description: 'Ban', - name: 'ban', - options: [ - { - name: 'reason', - type: ApplicationCommandOptionType.String, - }, - ], - type: ApplicationCommandOptionType.Subcommand, - }, - { - description: 'Kick', - name: 'kick', - options: [], - type: ApplicationCommandOptionType.Subcommand, + name: 'reason', + type: ApplicationCommandOptionType.String, }, ], - type: ApplicationCommandOptionType.SubcommandGroup, + type: ApplicationCommandOptionType.Subcommand, + }, + { + description: 'Kick', + name: 'kick', + options: [], + type: ApplicationCommandOptionType.Subcommand, }, ]); @@ -198,6 +221,10 @@ describe('Hierarchical command registration', () => { ], ]); + const loggerErrorSpy = vi + .spyOn(Logger, 'error') + .mockImplementation((() => {}) as any); + try { const registrationCommands = handler.registrar.getCommandsData(); @@ -205,6 +232,7 @@ describe('Hierarchical command registration', () => { registrationCommands.find((entry) => entry.name === 'admin'), ).toBeUndefined(); } finally { + loggerErrorSpy.mockRestore(); await client.destroy(); } }); diff --git a/packages/commandkit/spec/fixtures/context-menu-command.mjs b/packages/commandkit/src/app/register/__fixtures__/context-menu-command.mjs similarity index 100% rename from packages/commandkit/spec/fixtures/context-menu-command.mjs rename to packages/commandkit/src/app/register/__fixtures__/context-menu-command.mjs diff --git a/packages/commandkit/spec/fixtures/context-menu-only-command.mjs b/packages/commandkit/src/app/register/__fixtures__/context-menu-only-command.mjs similarity index 100% rename from packages/commandkit/spec/fixtures/context-menu-only-command.mjs rename to packages/commandkit/src/app/register/__fixtures__/context-menu-only-command.mjs diff --git a/packages/commandkit/spec/commands-router.test.ts b/packages/commandkit/src/app/router/CommandsRouter.test.ts similarity index 98% rename from packages/commandkit/spec/commands-router.test.ts rename to packages/commandkit/src/app/router/CommandsRouter.test.ts index 5f13489f..f7e0fc3d 100644 --- a/packages/commandkit/spec/commands-router.test.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from 'vitest'; import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; -import { CommandsRouter } from '../src/app/router/CommandsRouter'; +import { CommandsRouter } from './CommandsRouter'; const tmpRoots: string[] = []; const tempBaseDir = join(__dirname, '.tmp'); diff --git a/packages/commandkit/src/app/router/CommandsRouter.ts b/packages/commandkit/src/app/router/CommandsRouter.ts index e49297e8..2972f49c 100644 --- a/packages/commandkit/src/app/router/CommandsRouter.ts +++ b/packages/commandkit/src/app/router/CommandsRouter.ts @@ -1,7 +1,14 @@ import { Collection } from 'discord.js'; import { Dirent, existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { basename, extname, join, normalize } from 'node:path'; +import { + basename, + extname, + isAbsolute, + join, + normalize, + relative, +} from 'node:path'; import { CommandRouteDiagnostic, CommandTreeNode, @@ -53,6 +60,8 @@ export interface CommandsRouterOptions { entrypoint: string; } +export type RouterFileChangeType = 'add' | 'change' | 'unlink' | 'unlinkDir'; + const ROOT_NODE_ID = '__commandkit_router_root__'; /** @@ -291,6 +300,62 @@ export class CommandsRouter { return this.toJSON(); } + /** + * Incrementally updates only the top-level command subtree affected by a file change. + * Falls back to a full scan when the changed path cannot be safely scoped. + */ + public async scanIncremental( + changedPath: string, + _changeType: RouterFileChangeType = 'change', + ): Promise { + const normalizedPath = normalize(changedPath); + + if (!this.isWithinPath(this.options.entrypoint, normalizedPath)) { + return this.toJSON(); + } + + const relativePath = this.replaceEntrypoint(normalizedPath) + .replace(/^[/\\]/, '') + .split(/[/\\]/) + .filter(Boolean); + + if (!relativePath.length) { + return this.scan(); + } + + const topLevelToken = relativePath[0]; + + if (!this.isCommandDirectory(topLevelToken)) { + return this.scan(); + } + + const commandName = topLevelToken.match(COMMAND_DIRECTORY_PATTERN)?.[1]; + + if (!commandName) { + return this.scan(); + } + + this.ensureRootNode(); + + const commandRootDirectory = join(this.options.entrypoint, topLevelToken); + this.pruneCommandSubtree(commandName, commandRootDirectory); + + if (existsSync(commandRootDirectory)) { + await this.traverseDirectory( + commandRootDirectory, + 'command', + null, + ROOT_NODE_ID, + commandName, + ); + } + + await this.applyMiddlewares(); + this.compileTree(); + + return this.toJSON(); + } + /** * Gets the raw command, middleware, and compiled tree collections. * @returns Object containing router collections @@ -363,6 +428,73 @@ export class CommandsRouter { }); } + /** + * @private + * @internal + */ + private ensureRootNode() { + if (!this.treeNodes.has(ROOT_NODE_ID)) { + this.initializeRootNode(); + } + } + + /** + * @private + * @internal + */ + private pruneCommandSubtree( + commandName: string, + commandRootDirectory: string, + ) { + const normalizedRootDirectory = normalize(commandRootDirectory); + + for (const [id, command] of this.commands.entries()) { + if ( + command.name === commandName || + this.isWithinPath(normalizedRootDirectory, command.path) || + this.isWithinPath(normalizedRootDirectory, command.parentPath) + ) { + this.commands.delete(id); + } + } + + for (const [id, middleware] of this.middlewares.entries()) { + if ( + this.isWithinPath(normalizedRootDirectory, middleware.path) || + this.isWithinPath(normalizedRootDirectory, middleware.parentPath) + ) { + this.middlewares.delete(id); + } + } + + const nodeIdsToDelete = new Set(); + + for (const [id, node] of this.treeNodes.entries()) { + if (id === ROOT_NODE_ID) continue; + + if ( + node.route[0] === commandName || + this.isWithinPath(normalizedRootDirectory, node.directoryPath) || + (node.definitionPath && + this.isWithinPath(normalizedRootDirectory, node.definitionPath)) + ) { + nodeIdsToDelete.add(id); + } + } + + if (nodeIdsToDelete.size) { + for (const id of nodeIdsToDelete) { + this.treeNodes.delete(id); + } + + for (const node of this.treeNodes.values()) { + node.childIds = node.childIds.filter((childId) => { + return !nodeIdsToDelete.has(childId); + }); + } + } + } + /** * @private * @internal @@ -878,4 +1010,20 @@ export class CommandsRouter { const normalized = normalize(path); return normalized.replace(this.options.entrypoint, ''); } + + /** + * @private + * @internal + */ + private isWithinPath(basePath: string, targetPath: string) { + const base = normalize(basePath); + const target = normalize(targetPath); + const rel = relative(base, target); + + if (!rel) { + return true; + } + + return !rel.startsWith('..') && !isAbsolute(rel); + } } diff --git a/packages/commandkit/src/app/router/EventsRouter.ts b/packages/commandkit/src/app/router/EventsRouter.ts index 9049e477..928503dc 100644 --- a/packages/commandkit/src/app/router/EventsRouter.ts +++ b/packages/commandkit/src/app/router/EventsRouter.ts @@ -1,7 +1,13 @@ import { Collection } from 'discord.js'; import { existsSync } from 'node:fs'; import { readdir } from 'node:fs/promises'; -import { join } from 'node:path'; +import { isAbsolute, join, normalize, relative } from 'node:path'; + +export type EventsRouterFileChangeType = + | 'add' + | 'change' + | 'unlink' + | 'unlinkDir'; /** * Configuration options for the EventsRouter @@ -94,6 +100,17 @@ export class EventsRouter { this.events.clear(); } + /** + * Populates router state from pre-resolved event metadata. + */ + public populate(events: EventsTree) { + this.clear(); + + for (const [key, event] of Object.entries(events)) { + this.events.set(key, event); + } + } + /** * Reload and re-scan the entrypoint directory for events * @returns Promise resolving to the updated events tree @@ -108,6 +125,8 @@ export class EventsRouter { * @returns Promise resolving to the events tree */ public async scan(): Promise { + this.clear(); + for (const entrypoint of this.entrypoints) { const dirs = await readdir(entrypoint, { withFileTypes: true }); @@ -122,6 +141,64 @@ export class EventsRouter { return Object.fromEntries(this.events); } + /** + * Incrementally rescans only the event subtree impacted by a changed path. + */ + public async scanIncremental( + changedPath: string, + _changeType: EventsRouterFileChangeType = 'change', + ): Promise { + const resolvedChangedPath = normalize(changedPath); + const entrypoint = this.entrypoints.find((root) => + this.isWithinPath(root, resolvedChangedPath), + ); + + if (!entrypoint) { + return this.toJSON(); + } + + const branch = this.resolveTopLevelBranch(entrypoint, resolvedChangedPath); + + if (!branch) { + return this.scan(); + } + + const normalizedBranch = normalize(branch); + + for (const [key, event] of this.events.entries()) { + const listeners = event.listeners.filter( + (listener) => !this.isWithinPath(normalizedBranch, listener), + ); + + if ( + !listeners.length && + this.isWithinPath(normalizedBranch, event.path) + ) { + this.events.delete(key); + continue; + } + + this.events.set(key, { + ...event, + listeners, + }); + } + + if (existsSync(normalizedBranch)) { + const branchName = branch + .slice(entrypoint.length) + .replace(/^[/\\]/, '') + .split(/[/\\]/) + .filter(Boolean)[0]; + + if (branchName) { + await this.scanEvent(branchName, normalizedBranch, null, [], true); + } + } + + return this.toJSON(); + } + /** * Convert the internal events Collection to a plain object * @returns Events tree as a plain object @@ -180,4 +257,36 @@ export class EventsRouter { this.events.set(event, { event, path, listeners, namespace }); } } + + /** + * @private + * @internal + */ + private isWithinPath(basePath: string, targetPath: string) { + const base = normalize(basePath); + const target = normalize(targetPath); + const rel = relative(base, target); + + if (!rel) { + return true; + } + + return !rel.startsWith('..') && !isAbsolute(rel); + } + + /** + * @private + * @internal + */ + private resolveTopLevelBranch(entrypoint: string, changedPath: string) { + const rel = relative(entrypoint, changedPath) + .split(/[/\\]/) + .filter(Boolean); + + if (!rel.length) { + return null; + } + + return join(entrypoint, rel[0]); + } } diff --git a/packages/commandkit/src/app/router/TreeArtifact.ts b/packages/commandkit/src/app/router/TreeArtifact.ts new file mode 100644 index 00000000..8519a62e --- /dev/null +++ b/packages/commandkit/src/app/router/TreeArtifact.ts @@ -0,0 +1,185 @@ +import { isAbsolute, join, normalize, relative } from 'node:path'; +import type { EventsTree } from './EventsRouter'; +import type { ParsedCommandData } from './CommandsRouter'; + +export const ROUTER_TREE_ARTIFACT_SCHEMA_VERSION = 1; +export const ROUTER_TREE_ARTIFACT_FILE = 'router-tree.json'; +export const ROUTER_TREE_ARTIFACT_DIRECTORY = '.commandkit'; + +export interface RouterTreeArtifact { + schemaVersion: number; + commandkitVersion: string; + generatedAt: string; + commands: ParsedCommandData; + events: EventsTree; +} + +export function createRouterTreeArtifact(options: { + outputRoot: string; + commandkitVersion: string; + commands: ParsedCommandData; + events: EventsTree; +}): RouterTreeArtifact { + const { outputRoot, commandkitVersion } = options; + + return { + schemaVersion: ROUTER_TREE_ARTIFACT_SCHEMA_VERSION, + commandkitVersion, + generatedAt: new Date().toISOString(), + commands: mapCommandDataPaths(options.commands, outputRoot, toRelativePath), + events: mapEventsTreePaths(options.events, outputRoot, toRelativePath), + }; +} + +export function validateRouterTreeArtifact( + input: unknown, + runtimeVersion: string, +): input is RouterTreeArtifact { + if (!input || typeof input !== 'object') return false; + + const artifact = input as Partial; + + if (artifact.schemaVersion !== ROUTER_TREE_ARTIFACT_SCHEMA_VERSION) { + return false; + } + + if ( + !artifact.commandkitVersion || + artifact.commandkitVersion !== runtimeVersion + ) { + return false; + } + + if (!artifact.commands || typeof artifact.commands !== 'object') { + return false; + } + + if (!artifact.events || typeof artifact.events !== 'object') { + return false; + } + + return true; +} + +export function hydrateRouterTreeArtifact( + artifact: RouterTreeArtifact, + outputRoot: string, +): { + commands: ParsedCommandData; + events: EventsTree; +} { + return { + commands: mapCommandDataPaths( + artifact.commands, + outputRoot, + toAbsolutePath, + ), + events: mapEventsTreePaths(artifact.events, outputRoot, toAbsolutePath), + }; +} + +function mapCommandDataPaths( + data: ParsedCommandData, + root: string, + mapper: (rootPath: string, path: string) => string, +): ParsedCommandData { + const commands = Object.fromEntries( + Object.entries(data.commands ?? {}).map(([id, command]) => [ + id, + { + ...command, + path: mapper(root, command.path), + parentPath: mapper(root, command.parentPath), + }, + ]), + ); + + const middlewares = Object.fromEntries( + Object.entries(data.middlewares ?? {}).map(([id, middleware]) => [ + id, + { + ...middleware, + path: mapper(root, middleware.path), + parentPath: mapper(root, middleware.parentPath), + }, + ]), + ); + + const treeNodes = Object.fromEntries( + Object.entries(data.treeNodes ?? {}).map(([id, node]) => [ + id, + { + ...node, + directoryPath: mapper(root, node.directoryPath), + definitionPath: + node.definitionPath === null + ? null + : mapper(root, node.definitionPath), + }, + ]), + ); + + const compiledRoutes = Object.fromEntries( + Object.entries(data.compiledRoutes ?? {}).map(([id, route]) => [ + id, + { + ...route, + definitionPath: mapper(root, route.definitionPath), + }, + ]), + ); + + const diagnostics = (data.diagnostics ?? []).map((diagnostic) => ({ + ...diagnostic, + path: mapper(root, diagnostic.path), + })); + + return { + commands, + middlewares, + treeNodes, + compiledRoutes, + diagnostics, + }; +} + +function mapEventsTreePaths( + events: EventsTree, + root: string, + mapper: (rootPath: string, path: string) => string, +): EventsTree { + return Object.fromEntries( + Object.entries(events ?? {}).map(([id, event]) => [ + id, + { + ...event, + path: mapper(root, event.path), + listeners: event.listeners.map((listener) => mapper(root, listener)), + }, + ]), + ); +} + +function toRelativePath(rootPath: string, path: string): string { + if (!path) return path; + + const normalizedPath = normalize(path); + + if (!isAbsolute(normalizedPath)) { + return normalizedPath; + } + + return normalize(relative(rootPath, normalizedPath)); +} + +function toAbsolutePath(rootPath: string, path: string): string { + if (!path) return path; + + const normalizedPath = normalize(path); + + if (isAbsolute(normalizedPath)) { + return normalizedPath; + } + + return normalize(join(rootPath, normalizedPath)); +} diff --git a/packages/commandkit/src/app/router/index.ts b/packages/commandkit/src/app/router/index.ts index 7f6e2c6a..6399c645 100644 --- a/packages/commandkit/src/app/router/index.ts +++ b/packages/commandkit/src/app/router/index.ts @@ -1,3 +1,4 @@ export * from './CommandTree'; export * from './CommandsRouter'; export * from './EventsRouter'; +export * from './TreeArtifact'; diff --git a/packages/commandkit/spec/cache.test.ts b/packages/commandkit/src/cache.test.ts similarity index 92% rename from packages/commandkit/spec/cache.test.ts rename to packages/commandkit/src/cache.test.ts index ca53b851..eaa4983b 100644 --- a/packages/commandkit/spec/cache.test.ts +++ b/packages/commandkit/src/cache.test.ts @@ -1,15 +1,16 @@ // @ts-nocheck import { afterAll, beforeAll, describe, expect, test } from 'vitest'; import { - cache, + $ckitiucw as cache, cacheTag, cacheLife, + MemoryCache, isCachedFunction, - invalidate, - revalidate, - CommandKit, - // @ts-ignore -} from 'commandkit'; + revalidateTag, + getCacheProvider, + setCacheProvider, +} from '@commandkit/cache'; +import { CommandKit } from 'commandkit'; import { setTimeout } from 'node:timers/promises'; import { Client } from 'discord.js'; @@ -17,6 +18,8 @@ describe('Cache', () => { let commandkit!: CommandKit, client!: Client; beforeAll(async () => { + setCacheProvider(new MemoryCache()); + client = new Client({ intents: [], }); @@ -28,7 +31,7 @@ describe('Cache', () => { afterAll(async () => { await client.destroy(); - await commandkit.getCacheProvider()?.clear(); + await getCacheProvider()?.clear(); commandkit = null!; client = null!; }); @@ -178,7 +181,7 @@ describe('Cache', () => { const result1 = await fn(); expect(await fn()).toBe(result1); - await invalidate('test-invalidate'); + await revalidateTag('test-invalidate'); expect(await fn()).not.toBe(result1); }); @@ -192,9 +195,8 @@ describe('Cache', () => { const result1 = await fn(1); expect(await fn(1)).toBe(result1); - const fresh = await revalidate('test-revalidate', 2); - expect(fresh).not.toBe(result1); - expect(await fn(2)).toBe(fresh); + await revalidateTag('test-revalidate'); + expect(await fn(2)).not.toBe(result1); }); test('Should cache with multiple arguments', async () => { @@ -280,17 +282,15 @@ describe('Cache', () => { expect(await fn('a')).not.toBe(result1); }); - test('Should support cache options via metadata parameter', async () => { - const fn = cache(async () => Math.random(), { - name: 'test-metadata', - ttl: '100ms', - }); + test('Should keep deterministic cache behavior for use-cache directives', async () => { + const fn = async () => { + 'use cache'; + + return Math.random(); + }; const result1 = await fn(); expect(await fn()).toBe(result1); - - await setTimeout(150); - expect(await fn()).not.toBe(result1); }); test('Should cache multiple arguments without explicit cache controls', async () => { diff --git a/packages/commandkit/src/cli/build.ts b/packages/commandkit/src/cli/build.ts index aaf0dd93..1b33551c 100644 --- a/packages/commandkit/src/cli/build.ts +++ b/packages/commandkit/src/cli/build.ts @@ -1,14 +1,21 @@ import { existsSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { rimraf } from 'rimraf'; import type { InlineConfig } from 'tsdown'; +import { CommandsRouter, EventsRouter } from '../app/router'; +import { + createRouterTreeArtifact, + ROUTER_TREE_ARTIFACT_DIRECTORY, + ROUTER_TREE_ARTIFACT_FILE, +} from '../app/router/TreeArtifact'; import { MaybeArray } from '../components'; import { loadConfigFile } from '../config/loader'; import { mergeDeep } from '../config/utils'; import { CompilerPlugin, CompilerPluginRuntime } from '../plugins'; import { COMMANDKIT_CWD } from '../utils/constants'; +import { version } from '../version'; import { copyLocaleFiles, loadTsdown } from './common'; import { devEnvFileArgs, prodEnvFileArgs } from './env'; import { performTypeCheck } from './type-checker'; @@ -169,6 +176,10 @@ export async function buildApplication({ ), config.distDir, ); + + if (config.experimental.pregenerateCommands) { + await emitPregeneratedRouterArtifact(join(COMMANDKIT_CWD, dest)); + } } catch (error) { console.error('Build failed:', error); if (error instanceof Error) { @@ -181,6 +192,45 @@ export async function buildApplication({ } } +async function emitPregeneratedRouterArtifact(outputRoot: string) { + const commandEntrypoint = join(outputRoot, 'app', 'commands'); + const eventsEntrypoint = join(outputRoot, 'app', 'events'); + + const commandsRouter = new CommandsRouter({ + entrypoint: commandEntrypoint, + }); + const eventsRouter = new EventsRouter({ + entrypoints: [eventsEntrypoint], + }); + + const commands = commandsRouter.isValidPath() + ? await commandsRouter.scan() + : { + commands: {}, + middlewares: {}, + treeNodes: {}, + compiledRoutes: {}, + diagnostics: [], + }; + + const events = eventsRouter.isValidPath() ? await eventsRouter.scan() : {}; + + const artifact = createRouterTreeArtifact({ + outputRoot, + commandkitVersion: version, + commands, + events, + }); + + const artifactDirectory = join(outputRoot, ROUTER_TREE_ARTIFACT_DIRECTORY); + await mkdir(artifactDirectory, { recursive: true }); + await writeFile( + join(artifactDirectory, ROUTER_TREE_ARTIFACT_FILE), + JSON.stringify(artifact, null, 2), + 'utf8', + ); +} + const envScript = (dev: boolean) => `// --- Environment Variables Loader --- const $env = [${(dev ? devEnvFileArgs : prodEnvFileArgs).map((p) => `"${p}"`).join(', ')}]; for (const file of $env) { diff --git a/packages/commandkit/src/cli/development.ts b/packages/commandkit/src/cli/development.ts index 5e998735..91be64b4 100644 --- a/packages/commandkit/src/cli/development.ts +++ b/packages/commandkit/src/cli/development.ts @@ -9,7 +9,11 @@ import colors from '../utils/colors'; import { ChildProcess } from 'node:child_process'; import { setTimeout as sleep } from 'node:timers/promises'; import { randomUUID } from 'node:crypto'; -import { COMMANDKIT_CWD, HMREventType } from '../utils/constants'; +import { + COMMANDKIT_CWD, + HMREventChangeType, + HMREventType, +} from '../utils/constants'; import { findEntrypoint } from './common'; /** @@ -56,8 +60,12 @@ export async function bootstrapDevelopmentServer(configPath?: string): Promise<{ watcher: ReturnType; isConfigUpdate: (path: string) => boolean; performHMR: (path?: string) => Promise; - hmrHandler: (path: string) => Promise; - sendHmrEvent: (event: HMREventType, path?: string) => Promise; + hmrHandler: (path: string, changeType?: HMREventChangeType) => Promise; + sendHmrEvent: ( + event: HMREventType, + path?: string, + changeType?: HMREventChangeType, + ) => Promise; getProcess: () => ChildProcess | null; buildAndStart: typeof buildAndStart; }> { @@ -103,13 +111,14 @@ export async function bootstrapDevelopmentServer(configPath?: string): Promise<{ const sendHmrEvent = async ( event: HMREventType, path?: string, + changeType?: HMREventChangeType, ): Promise => { if (!ps || !ps.send) return false; const messageId = randomUUID(); const messagePromise = waitForAcknowledgment(messageId); - ps.send({ event, path, id: messageId }); + ps.send({ event, path, changeType, id: messageId }); // Wait for acknowledgment or timeout after 3 seconds try { @@ -139,41 +148,47 @@ export async function bootstrapDevelopmentServer(configPath?: string): Promise<{ } }; - const performHMR = debounce(async (path?: string): Promise => { - if (!path || !ps) return false; - - let eventType: HMREventType | null = null; - let eventDescription = ''; - - if (isCommandSource(path)) { - eventType = HMREventType.ReloadCommands; - eventDescription = 'command(s)'; - } else if (isEventSource(path)) { - eventType = HMREventType.ReloadEvents; - eventDescription = 'event(s)'; - } else { - eventType = HMREventType.Unknown; - eventDescription = 'unknown source'; - } - - if (eventType) { - console.log( - `${colors.cyanBright(`Attempting to reload ${eventDescription} at`)} ${colors.yellowBright(path)}`, - ); - - await buildAndStart(cwd, true); - const hmrHandled = await sendHmrEvent(eventType, path); + const performHMR = debounce( + async ( + path?: string, + changeType?: HMREventChangeType, + ): Promise => { + if (!path || !ps) return false; + + let eventType: HMREventType | null = null; + let eventDescription = ''; + + if (isCommandSource(path)) { + eventType = HMREventType.ReloadCommands; + eventDescription = 'command(s)'; + } else if (isEventSource(path)) { + eventType = HMREventType.ReloadEvents; + eventDescription = 'event(s)'; + } else { + eventType = HMREventType.Unknown; + eventDescription = 'unknown source'; + } - if (hmrHandled) { + if (eventType) { console.log( - `${colors.greenBright(`Successfully hot reloaded ${eventDescription} at`)} ${colors.yellowBright(path)}`, + `${colors.cyanBright(`Attempting to reload ${eventDescription} at`)} ${colors.yellowBright(path)}`, ); - return true; + + await buildAndStart(cwd, true); + const hmrHandled = await sendHmrEvent(eventType, path, changeType); + + if (hmrHandled) { + console.log( + `${colors.greenBright(`Successfully hot reloaded ${eventDescription} at`)} ${colors.yellowBright(path)}`, + ); + return true; + } } - } - return false; - }, 700); + return false; + }, + 700, + ); const isConfigUpdate = (path: string) => { const isConfig = configPaths.some((configPath) => path === configPath); @@ -189,9 +204,12 @@ export async function bootstrapDevelopmentServer(configPath?: string): Promise<{ return isConfig; }; - const hmrHandler = async (path: string) => { + const hmrHandler = async ( + path: string, + changeType: HMREventChangeType = HMREventChangeType.Change, + ) => { if (isConfigUpdate(path)) return; - const hmr = await performHMR(path); + const hmr = await performHMR(path, changeType); if (hmr) return; console.log( @@ -224,10 +242,12 @@ export async function bootstrapDevelopmentServer(configPath?: string): Promise<{ } }); - watcher.on('change', hmrHandler); - watcher.on('add', hmrHandler); - watcher.on('unlink', hmrHandler); - watcher.on('unlinkDir', hmrHandler); + watcher.on('change', (path) => hmrHandler(path, HMREventChangeType.Change)); + watcher.on('add', (path) => hmrHandler(path, HMREventChangeType.Add)); + watcher.on('unlink', (path) => hmrHandler(path, HMREventChangeType.Unlink)); + watcher.on('unlinkDir', (path) => + hmrHandler(path, HMREventChangeType.UnlinkDir), + ); watcher.on('error', (e) => { console.error(e); }); diff --git a/packages/commandkit/src/cli/init.ts b/packages/commandkit/src/cli/init.ts index eb747e6b..4182ef85 100644 --- a/packages/commandkit/src/cli/init.ts +++ b/packages/commandkit/src/cli/init.ts @@ -1,13 +1,18 @@ import { existsSync } from 'node:fs'; -import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { generateTypesPackage } from '../utils/types-package'; +import { + COMMANDKIT_ENV_FILE, + generateTypesPackage, + getTypesFilePath, + rewriteCommandDeclaration, +} from '../utils/types-package'; import { loadConfigFile } from '../config/loader'; import { CompilerPlugin, CompilerPluginRuntime, isCompilerPlugin, } from '../plugins'; +import { CommandsRouter } from '../app/router'; import { panic } from './common'; import { COMMANDKIT_CWD } from '../utils/constants'; @@ -78,6 +83,49 @@ export async function bootstrapCommandkitCLI( return createProductionBuild(options.config); }); + program + .command('typegen') + .description('Generate command type declarations for this project.') + .option('-c, --config [path]', 'Path to your commandkit config file.') + .action(async () => { + setCLIEnv(); + const options = program.opts(); + const cwd = options.config || COMMANDKIT_CWD; + + await generateTypesPackage(true); + + const commandsEntrypoint = resolveCommandsEntrypoint(cwd); + + if (!commandsEntrypoint) { + await rewriteCommandDeclaration([]); + return; + } + + const router = new CommandsRouter({ + entrypoint: commandsEntrypoint, + }); + + if (!router.isValidPath()) { + await rewriteCommandDeclaration([]); + return; + } + + const result = await router.scan(); + const commandNames = Object.values(result.commands).map( + (cmd) => cmd.name, + ); + const routeNames = Object.keys(result.compiledRoutes ?? {}); + const names = Array.from( + new Set([...commandNames, ...routeNames]), + ).sort(); + + if (!existsSync(join(cwd, COMMANDKIT_ENV_FILE))) { + await generateTypesPackage(true); + } + + await rewriteCommandDeclaration(names); + }); + program .command('create') .description( @@ -143,12 +191,26 @@ export async function bootstrapCommandkitCLI( } }); - const types = join(COMMANDKIT_CWD, 'node_modules', 'commandkit-types'); + const types = getTypesFilePath(); if (!existsSync(types)) { - await mkdir(types, { recursive: true }).catch(() => {}); await generateTypesPackage(true).catch(() => {}); } await program.parseAsync(argv, options); } + +function resolveCommandsEntrypoint(cwd: string) { + const sourceLikeRoots = [ + join(cwd, 'src', 'app', 'commands'), + join(cwd, 'app', 'commands'), + ]; + + for (const candidate of sourceLikeRoots) { + if (existsSync(candidate)) { + return candidate; + } + } + + return null; +} diff --git a/packages/commandkit/spec/commandkit.test.ts b/packages/commandkit/src/commandkit.test.ts similarity index 100% rename from packages/commandkit/spec/commandkit.test.ts rename to packages/commandkit/src/commandkit.test.ts diff --git a/packages/commandkit/src/commandkit.ts b/packages/commandkit/src/commandkit.ts index 09d4a88d..62ff64ef 100644 --- a/packages/commandkit/src/commandkit.ts +++ b/packages/commandkit/src/commandkit.ts @@ -4,16 +4,31 @@ import colors from './utils/colors'; import { createElement, Fragment } from './components'; import { EventInterceptor } from './components/common/EventInterceptor'; import { Awaitable, Client, Events, Locale, Message } from 'discord.js'; -import { createProxy, findAppDirectory, SimpleProxy } from './utils/utilities'; +import { + createProxy, + findAppDirectory, + getCurrentDirectory, + SimpleProxy, +} from './utils/utilities'; import { join } from 'node:path'; import { AppCommandHandler } from './app/handlers/AppCommandHandler'; import { CommandsRouter, EventsRouter } from './app/router'; +import { + hydrateRouterTreeArtifact, + ROUTER_TREE_ARTIFACT_DIRECTORY, + ROUTER_TREE_ARTIFACT_FILE, + validateRouterTreeArtifact, +} from './app/router/TreeArtifact'; import { AppEventsHandler } from './app/handlers/AppEventsHandler'; import { CommandKitPluginRuntime } from './plugins/plugin-runtime/CommandKitPluginRuntime'; import { loadConfigFile } from './config/loader'; -import { COMMANDKIT_IS_DEV } from './utils/constants'; +import { + COMMANDKIT_BOOTSTRAP_MODE, + COMMANDKIT_IS_DEV, +} from './utils/constants'; import { registerDevHooks } from './utils/dev-hooks'; -import { writeFileSync } from 'node:fs'; +import { existsSync, writeFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { CommandKitEventsChannel } from './events/CommandKitEventsChannel'; import { isRuntimePlugin } from './plugins'; import { generateTypesPackage } from './utils/types-package'; @@ -23,6 +38,7 @@ import { FlagStore } from './flags/store'; import { AnalyticsEngine } from './analytics/analytics-engine'; import { ResolvedCommandKitConfig } from './config/utils'; import { getConfig } from './config/config'; +import { version } from './version'; /** * Configurations for the CommandKit instance. @@ -95,6 +111,7 @@ export function onApplicationBootstrap( */ export class CommandKit extends EventEmitter { #started = false; + #hydratedFromArtifact = false; #clientProxy: SimpleProxy | null = createProxy({} as Client); #client!: Client; /** @@ -403,12 +420,55 @@ export class CommandKit extends EventEmitter { return plugin.onEventsRouterInit(ctx); }); + this.#hydratedFromArtifact = await this.#hydrateRoutersFromArtifact(); + await this.#initEvents(); await this.#initCommands(); } + async #hydrateRoutersFromArtifact() { + if (COMMANDKIT_IS_DEV) return false; + if (COMMANDKIT_BOOTSTRAP_MODE !== 'production') return false; + if (!this.config.experimental.pregenerateCommands) return false; + + const outputRoot = getCurrentDirectory(); + const artifactPath = join( + outputRoot, + ROUTER_TREE_ARTIFACT_DIRECTORY, + ROUTER_TREE_ARTIFACT_FILE, + ); + + if (!existsSync(artifactPath)) { + return false; + } + + try { + const json = await readFile(artifactPath, 'utf8'); + const parsed = JSON.parse(json); + + if (!validateRouterTreeArtifact(parsed, version)) { + Logger.warn( + `Ignoring router tree artifact at ${artifactPath}: schema/version mismatch. Falling back to dynamic filesystem resolution.`, + ); + return false; + } + + const hydrated = hydrateRouterTreeArtifact(parsed, outputRoot); + + this.commandsRouter.populate(hydrated.commands); + this.eventsRouter.populate(hydrated.events); + + return true; + } catch (error) { + Logger.warn( + `Failed to load router tree artifact at ${artifactPath}. Falling back to dynamic filesystem resolution. ${error}`, + ); + return false; + } + } + async #initCommands() { - if (this.commandsRouter.isValidPath()) { + if (!this.#hydratedFromArtifact && this.commandsRouter.isValidPath()) { const result = await this.commandsRouter.scan(); if (COMMANDKIT_IS_DEV) { @@ -425,7 +485,7 @@ export class CommandKit extends EventEmitter { } async #initEvents() { - if (this.eventsRouter.isValidPath()) { + if (!this.#hydratedFromArtifact && this.eventsRouter.isValidPath()) { await this.eventsRouter.scan(); } diff --git a/packages/commandkit/src/config/config.ts b/packages/commandkit/src/config/config.ts index 59ded100..29ed03a8 100644 --- a/packages/commandkit/src/config/config.ts +++ b/packages/commandkit/src/config/config.ts @@ -83,6 +83,12 @@ export function defineConfig( experimental: { ...defaultConfig.experimental, ...config.experimental, + pregenerateCommands: + config.experimental?.pregenerateCommands ?? + defaultConfig.experimental.pregenerateCommands, + incrementalRouter: + config.experimental?.incrementalRouter ?? + defaultConfig.experimental.incrementalRouter, }, }; diff --git a/packages/commandkit/src/config/default.ts b/packages/commandkit/src/config/default.ts index 6b100859..b8fe82a8 100644 --- a/packages/commandkit/src/config/default.ts +++ b/packages/commandkit/src/config/default.ts @@ -37,5 +37,7 @@ export const defaultConfig: ResolvedCommandKitConfig = { jsxDefaultOptionalComponents: true, experimental: { devServerRuntime: null, + pregenerateCommands: false, + incrementalRouter: false, }, }; diff --git a/packages/commandkit/src/config/types.ts b/packages/commandkit/src/config/types.ts index 4c0fd15a..8ec52273 100644 --- a/packages/commandkit/src/config/types.ts +++ b/packages/commandkit/src/config/types.ts @@ -133,5 +133,15 @@ export interface CommandKitConfig { * @default null */ devServerRuntime?: CommandKitJsRuntime | null; + /** + * Whether to emit and hydrate a pre-generated commands/events artifact in production builds. + * @default false + */ + pregenerateCommands?: boolean; + /** + * Whether to use incremental router reconciliation in development HMR. + * @default false + */ + incrementalRouter?: boolean; }; } diff --git a/packages/commandkit/src/utils/constants.ts b/packages/commandkit/src/utils/constants.ts index cd613298..c91fa731 100644 --- a/packages/commandkit/src/utils/constants.ts +++ b/packages/commandkit/src/utils/constants.ts @@ -55,7 +55,23 @@ export const HMREventType = { Unknown: 'unknown', } as const; +/** + * File system change types delivered through HMR messages. + */ +export const HMREventChangeType = { + Add: 'add', + Change: 'change', + Unlink: 'unlink', + UnlinkDir: 'unlinkDir', +} as const; + /** * The type for HMR events. */ export type HMREventType = (typeof HMREventType)[keyof typeof HMREventType]; + +/** + * The type for file change events in HMR payloads. + */ +export type HMREventChangeType = + (typeof HMREventChangeType)[keyof typeof HMREventChangeType]; diff --git a/packages/commandkit/src/utils/dev-hooks.ts b/packages/commandkit/src/utils/dev-hooks.ts index 68fd41bb..9e3b96f2 100644 --- a/packages/commandkit/src/utils/dev-hooks.ts +++ b/packages/commandkit/src/utils/dev-hooks.ts @@ -1,6 +1,10 @@ import type { CommandKit } from '../commandkit'; import { Logger } from '../logger/Logger'; -import { COMMANDKIT_IS_DEV, HMREventType } from './constants'; +import { + COMMANDKIT_IS_DEV, + HMREventChangeType, + HMREventType, +} from './constants'; /** * Represents HMR inter-process communication messages. @@ -14,6 +18,10 @@ export interface IpcMessageCommand { * The path associated with the HMR event. */ path: string; + /** + * The original filesystem change type that triggered this HMR event. + */ + changeType?: HMREventChangeType; /** * An optional identifier for the HMR event, used for acknowledgment. */ @@ -32,6 +40,10 @@ export interface CommandKitHMREvent { * The path associated with the HMR event. */ path: string; + /** + * The original filesystem change type that triggered this HMR event. + */ + changeType?: HMREventChangeType; /** * Accepts the HMR event, indicating that it has been handled. * This prevents further processing of the event. @@ -54,9 +66,16 @@ export function registerDevHooks(commandkit: CommandKit) { process.on('message', async (message) => { if (typeof message !== 'object' || message === null) return; - const { event, path, id } = message as IpcMessageCommand; + const { + event, + path: maybePath, + id, + changeType, + } = message as IpcMessageCommand; if (!event) return; + const path = maybePath ?? ''; + if (process.env.COMMANDKIT_DEBUG_HMR === 'true') { Logger.info(`Received HMR event: ${event}${path ? ` for ${path}` : ''}`); } @@ -74,6 +93,7 @@ export function registerDevHooks(commandkit: CommandKit) { }, path, event, + changeType, }; try { @@ -88,11 +108,11 @@ export function registerDevHooks(commandkit: CommandKit) { switch (event) { case HMREventType.ReloadCommands: - await commandkit.commandHandler.reloadCommands(); + await commandkit.commandHandler.reloadCommands(path, changeType); handled = true; break; case HMREventType.ReloadEvents: - await commandkit.eventHandler.reloadEvents(); + await commandkit.eventHandler.reloadEvents(path, changeType); handled = true; break; case HMREventType.Unknown: diff --git a/packages/commandkit/src/utils/types-package.ts b/packages/commandkit/src/utils/types-package.ts index d8b6b7f8..264f34cf 100644 --- a/packages/commandkit/src/utils/types-package.ts +++ b/packages/commandkit/src/utils/types-package.ts @@ -1,48 +1,57 @@ import { mkdir, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { COMMANDKIT_CWD, COMMANDKIT_IS_DEV } from './constants'; import { existsSync } from 'node:fs'; +export const COMMANDKIT_TYPES_DIRECTORY = '.commandkit'; +export const COMMANDKIT_TYPES_FILE = 'types.ts'; +export const COMMANDKIT_ENV_FILE = 'commandkit-env.d.ts'; + +export function getTypesFilePath() { + return join( + COMMANDKIT_CWD, + COMMANDKIT_TYPES_DIRECTORY, + COMMANDKIT_TYPES_FILE, + ); +} + +function getEnvFilePath() { + return join(COMMANDKIT_CWD, COMMANDKIT_ENV_FILE); +} + +async function ensureCommandkitEnvFile() { + const envPath = getEnvFilePath(); + const content = `/// \n`; + await writeFile(envPath, content, 'utf8'); +} + +async function writeTypesContent(names: string[] = []) { + const typesPath = getTypesFilePath(); + + const entryLines = names + .sort((a, b) => a.localeCompare(b)) + .map((name) => ` ${JSON.stringify(name)}: true;`) + .join('\n'); + + const registryBody = entryLines || ' __commandkit_wide__: true;'; + + const declaration = `// Auto-generated by CommandKit\nexport {};\ndeclare global {\n namespace CommandKitTypes {\n interface Registry {\n${registryBody}\n }\n }\n}\n`; + + await mkdir(dirname(typesPath), { recursive: true }).catch(() => {}); + await writeFile(typesPath, declaration, 'utf8'); + + return typesPath; +} + /** * @private */ export async function generateTypesPackage(force = false) { - const location = join(COMMANDKIT_CWD, 'node_modules', 'commandkit-types'); + const location = getTypesFilePath(); if (!COMMANDKIT_IS_DEV && !force) return location; - const packageJSON = join(location, 'package.json'); - const index = join(location, 'index.js'); - const types = join(location, 'index.d.ts'); - const command = join(location, 'command.d.ts'); - - const packageJSONContent = { - name: 'commandkit-types', - version: '1.0.0', - description: 'CommandKit types package', - type: 'commonjs', - main: 'index.js', - types: 'index.d.ts', - }; - - const indexContent = `module.exports = {};`; - - // Restructuring the type declarations to properly extend rather than replace types - const typesContent = `// Main types index file - imports all type declarations -import './command'; -export {}; -`; - - // Properly set up command types to extend the CommandTypeData interface - const commandTypesContent = `// Auto-generated command types - export {}; -declare module 'commandkit' { - type CommandTypeData = string -}`; - - await mkdir(location, { recursive: true }).catch(() => {}); - await writeFile(packageJSON, JSON.stringify(packageJSONContent, null, 2)); - await writeFile(index, indexContent); - await writeFile(types, typesContent); - await writeFile(command, commandTypesContent); + + await writeTypesContent(); + await ensureCommandkitEnvFile(); return location; } @@ -50,19 +59,24 @@ declare module 'commandkit' { /** * @private */ -export async function rewriteCommandDeclaration(data: string) { - const commandTypesContent = `// Auto-generated command types - declare module 'commandkit' { - ${data} -} - export {}; -`; +export async function rewriteCommandDeclaration(data: string[] | string) { + const type = getTypesFilePath(); - const location = join(COMMANDKIT_CWD, 'node_modules', 'commandkit-types'); + if (!existsSync(dirname(type))) { + await mkdir(dirname(type), { recursive: true }).catch(() => {}); + } - if (!existsSync(location)) return; + const names = Array.isArray(data) + ? data + : data + .replace(/type\s+CommandTypeData\s*=\s*/g, '') + .split('|') + .map((part) => part.trim().replace(/^"|"$/g, '')) + .filter(Boolean) + .filter((part) => part !== 'string'); - const type = join(location, 'command.d.ts'); + const dedupedNames = Array.from(new Set(names)); - await writeFile(type, commandTypesContent, { encoding: 'utf-8' }); + await writeTypesContent(dedupedNames); + await ensureCommandkitEnvFile(); } diff --git a/packages/commandkit/tsconfig.json b/packages/commandkit/tsconfig.json index 33017fa9..85cb4952 100644 --- a/packages/commandkit/tsconfig.json +++ b/packages/commandkit/tsconfig.json @@ -5,6 +5,6 @@ "skipLibCheck": true, "skipDefaultLibCheck": true }, - "include": ["src/**/*.ts", "helpers/**/*.ts", "spec"], + "include": ["src/**/*.ts", "helpers/**/*.ts"], "exclude": ["node_modules"] } diff --git a/packages/commandkit/tsdown.config.mts b/packages/commandkit/tsdown.config.mts index e9f62b34..51f1fca4 100644 --- a/packages/commandkit/tsdown.config.mts +++ b/packages/commandkit/tsdown.config.mts @@ -5,7 +5,13 @@ const macro = new MacroTransformer(); export default defineConfig({ format: ['cjs'], - entry: ['src/**/*.ts'], + entry: [ + 'src/**/*.ts', + '!src/**/*.test.ts', + '!src/**/*.spec.ts', + '!src/**/*.test.tsx', + '!src/**/*.spec.tsx', + ], outDir: './dist', sourcemap: true, watch: false, diff --git a/packages/commandkit/vitest.config.ts b/packages/commandkit/vitest.config.ts index 12aededf..ac5120dc 100644 --- a/packages/commandkit/vitest.config.ts +++ b/packages/commandkit/vitest.config.ts @@ -4,7 +4,8 @@ import { join } from 'path'; export default defineConfig({ test: { - include: ['./spec/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + include: ['./src/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + exclude: ['dist/**', '.commandkit/**', 'node_modules/**'], watch: false, dangerouslyIgnoreUnhandledErrors: true, env: { @@ -19,7 +20,7 @@ export default defineConfig({ plugins: [ cacheDirectivePlugin({ directive: 'use cache', - importPath: 'commandkit', + importPath: '@commandkit/cache', importName: '$ckitiucw', asyncOnly: true, }), diff --git a/turbo.json b/turbo.json index 66c5e9aa..c276881a 100644 --- a/turbo.json +++ b/turbo.json @@ -16,6 +16,11 @@ "dependsOn": ["^build"], "cache": false, "persistent": true + }, + "test": { + "dependsOn": ["build"], + "outputs": [], + "cache": true } } }