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
}
}
}