From d5f1bdc643c623e7563d36460e02ffdb0599e0ee Mon Sep 17 00:00:00 2001 From: twlite <46562212+twlite@users.noreply.github.com> Date: Sun, 12 Jan 2025 13:18:37 +0545 Subject: [PATCH] feat: improved cache and docs --- apps/test-bot/src/commands/misc/help.ts | 31 ++- apps/website/docs/guide/11-caching.mdx | 124 +++++++++++ .../docs/guide/12-post-command-hooks.mdx | 30 +++ .../guide/13-command-restriction-helpers.mdx | 40 ++++ packages/commandkit/bin/build.mjs | 3 +- packages/commandkit/bin/development.mjs | 2 + .../bin/esbuild-plugins/use-cache.mjs | 176 +++++++++++++++ packages/commandkit/package.json | 8 +- packages/commandkit/src/CommandKit.ts | 15 ++ .../commandkit/src/cache/CacheProvider.ts | 11 +- packages/commandkit/src/cache/MemoryCache.ts | 26 ++- packages/commandkit/src/cache/index.ts | 205 ++++++++++++------ .../commandkit/src/context/async-context.ts | 38 ++-- .../commandkit/src/context/environment.ts | 36 --- .../command-handler/CommandHandler.ts | 2 +- packages/commandkit/src/utils/constants.ts | 1 + packages/commandkit/src/utils/error-codes.ts | 1 - pnpm-lock.yaml | 102 ++++++--- 18 files changed, 674 insertions(+), 177 deletions(-) create mode 100644 apps/website/docs/guide/11-caching.mdx create mode 100644 apps/website/docs/guide/12-post-command-hooks.mdx create mode 100644 apps/website/docs/guide/13-command-restriction-helpers.mdx create mode 100644 packages/commandkit/bin/esbuild-plugins/use-cache.mjs create mode 100644 packages/commandkit/src/utils/constants.ts diff --git a/apps/test-bot/src/commands/misc/help.ts b/apps/test-bot/src/commands/misc/help.ts index da9691f..a18f165 100644 --- a/apps/test-bot/src/commands/misc/help.ts +++ b/apps/test-bot/src/commands/misc/help.ts @@ -1,4 +1,8 @@ -import { unstable_cache, SlashCommandProps, CommandData } from 'commandkit'; +import { + SlashCommandProps, + CommandData, + unstable_cacheTag as cacheTag, +} from 'commandkit'; import { setTimeout } from 'node:timers/promises'; export const data: CommandData = { @@ -6,36 +10,29 @@ export const data: CommandData = { description: 'This is a help command.', }; -function $botVersion() { - 'use macro'; - // this function is inlined in production build - const process = require('node:process'); - return require(`${process.cwd()}/package.json`).version; -} - async function someExpensiveDatabaseCall() { - await setTimeout(3000); + 'use cache'; + + await setTimeout(5000); + return Date.now(); } -export async function run({ interaction }: SlashCommandProps) { - await unstable_cache({ name: interaction.commandName, ttl: 60_000 }); +cacheTag(15000, someExpensiveDatabaseCall); +export async function run({ interaction }: SlashCommandProps) { await interaction.deferReply(); + const dataRetrievalStart = Date.now(); const time = await someExpensiveDatabaseCall(); - - const version = $botVersion(); + const dataRetrievalEnd = Date.now() - dataRetrievalStart; return interaction.editReply({ embeds: [ { title: 'Help', - description: `This is a help command. The current time is \`${time}\``, + description: `This is a help command. The current time is \`${time}\`. Fetched in ${dataRetrievalEnd}ms.`, color: 0x7289da, - footer: { - text: `Bot Version: ${version}`, - }, timestamp: new Date().toISOString(), }, ], diff --git a/apps/website/docs/guide/11-caching.mdx b/apps/website/docs/guide/11-caching.mdx new file mode 100644 index 0000000..d867b3c --- /dev/null +++ b/apps/website/docs/guide/11-caching.mdx @@ -0,0 +1,124 @@ +--- +title: Caching +description: A guide on how to implement caching in your bot using CommandKit. +--- + +# Caching + +:::warning +This feature is currently available in development version of CommandKit only. +::: + +Caching is a technique used to store data in a temporary storage to reduce the time it takes to fetch the data from the original source. This can be useful in Discord bots to reduce the number of database queries or external API calls. + +CommandKit provides an easy way to implement caching in your bot without having to worry about the underlying implementation. This guide will show you how to use the caching feature in CommandKit. + +## Setting up the cache + +By default, commandkit enables in-memory caching. This means that the cache will be stored in the bot's memory and will be lost when the bot restarts. +You can provide a custom cache store by specifying the `cacheProvider` option when instantiating CommandKit. + +```js +const { CommandKit } = require('commandkit'); + +new CommandKit({ + client, + commandsPath, + eventsPath, + cacheProvider: new MyCustomCacheProvider(), +}); +``` + +The `MyCustomCacheProvider` class should extend `CacheProvider` from CommandKit and implement the required methods. You may use this to store the cache in redis, a database or a file system. + +## Using the cache + +### Using commandkit CLI + +If you are using the commandkit cli to run your bot, you can simply add `"use cache"` directive on a function that you want to cache the result of. + +```js +async function fetchData() { + 'use cache'; + + // Fetch data from an external source + const data = await fetch('https://my-example-api.com/data'); + + return data.json(); +} + +export async function run({ interaction }) { + await interaction.deferReply(); + + // Fetch data + const data = await fetchData(); + + // Send the data to the user + await interaction.editReply(data); +} +``` + +### Using the cache manually + +To use the cache manually, you can import the `unstable_cache()` function from CommandKit and use it to cache the result of a function. + +```js +import { unstable_cache as cache } from 'commandkit'; + +const fetchData = cache(async () => { + // Fetch data from an external source + const data = await fetch('https://my-example-api.com/data'); + + return data.json(); +}); + +export async function run({ interaction }) { + await interaction.deferReply(); + + // Fetch data + const data = await fetchData(); + + // Send the data to the user + await interaction.editReply(data); +} +``` + +By default, the cached data will be stored forever until `unstable_revalidate()` or `unstable_invalidate()` is called on the cache object. You can also specify a custom TTL (time to live) for the cache by passing a second argument to the `cache` function. + +```js +const fetchData = cache( + async () => { + // Fetch data from an external source + const data = await fetch('https://my-example-api.com/data'); + + return data.json(); + }, + { + name: 'fetchData', // name of the cache + ttl: 60_000, // cache for 1 minute + }, +); +``` + +You may want to specify the cache parameters when using `"use cache"` directive. When using this approach, you can use `unstable_cacheTag()` to tag the cache with custom parameters. + +```js +import { unstable_cacheTag as cacheTag } from 'commandkit'; + +async function fetchData() { + 'use cache'; + + // Fetch data from an external source + const data = await fetch('https://my-example-api.com/data'); + + return data.json(); +} + +cacheTag( + { + name: 'fetchData', // name of the cache + ttl: 60_000, // cache for 1 minute + }, + fetchData, +); +``` diff --git a/apps/website/docs/guide/12-post-command-hooks.mdx b/apps/website/docs/guide/12-post-command-hooks.mdx new file mode 100644 index 0000000..b61c048 --- /dev/null +++ b/apps/website/docs/guide/12-post-command-hooks.mdx @@ -0,0 +1,30 @@ +--- +title: Post-command hooks +description: Post-command hooks allow you to run a function after a command has been executed. +--- + +:::warning +This feature is currently available in development version of CommandKit only. +::: + +# Post-command hooks + +Post-command hooks allow you to run a function after a command has been executed. This can be useful for logging, analytics, or any other post-processing tasks. + +## Setting up post-command hooks + +To set up a post-command hook, you need to define a function that will be called after a command has been executed. This feature is dynamic and you must use this inside your command. + +```ts +import { after } from 'commandkit'; + +export async function run({ interaction }) { + after(() => { + // handle post-processing logic here + }); + + // handle your command +} +``` + +The `after()` function is guaranteed to be called after the command has been executed, regardless of whether the command was successful or not. The registered functions are called sequentially in the order they were defined. diff --git a/apps/website/docs/guide/13-command-restriction-helpers.mdx b/apps/website/docs/guide/13-command-restriction-helpers.mdx new file mode 100644 index 0000000..3f9cfec --- /dev/null +++ b/apps/website/docs/guide/13-command-restriction-helpers.mdx @@ -0,0 +1,40 @@ +--- +title: Command restriction helpers +description: Command restriction helpers allow you to restrict commands based on various conditions. +--- + +# Command restriction helpers + +:::warning +This feature is currently available in development version of CommandKit only. +::: + +Command restriction helpers allow you to restrict commands based on various conditions. At the moment, only `guildOnly` and `dmOnly` restrictions are available. + +## `guildOnly` + +The `guildOnly` restriction allows you to restrict a command to be used only in guilds (servers) and not in direct messages. This is useful when your command is available both in guilds and direct messages, but you want to restrict it to guilds only for some reason. + +```ts +import { guildOnly } from 'commandkit'; + +export async function run({ interaction }) { + guildOnly(); + + // Your command logic here +} +``` + +## `dmOnly` + +The `dmOnly` restriction allows you to restrict a command to be used only in direct messages and not in guilds (servers). This is useful when your command is available both in guilds and direct messages, but you want to restrict it to direct messages only for some reason. + +```ts +import { dmOnly } from 'commandkit'; + +export async function run({ interaction }) { + dmOnly(); + + // Your command logic here +} +``` diff --git a/packages/commandkit/bin/build.mjs b/packages/commandkit/bin/build.mjs index 732a1fa..d265d3d 100644 --- a/packages/commandkit/bin/build.mjs +++ b/packages/commandkit/bin/build.mjs @@ -12,6 +12,7 @@ import { } from './common.mjs'; import ora from 'ora'; import { esbuildPluginUseMacro } from 'use-macro'; +import { cacheDirectivePlugin } from './esbuild-plugins/use-cache.mjs'; export async function bootstrapProductionBuild(config) { const { @@ -47,7 +48,7 @@ export async function bootstrapProductionBuild(config) { watch: false, cjsInterop: true, entry: [src, '!dist', '!.commandkit', `!${outDir}`], - esbuildPlugins: [esbuildPluginUseMacro()], + esbuildPlugins: [cacheDirectivePlugin()], }); await injectShims(outDir, main, antiCrash, polyfillRequire); diff --git a/packages/commandkit/bin/development.mjs b/packages/commandkit/bin/development.mjs index 91f4682..192ce09 100644 --- a/packages/commandkit/bin/development.mjs +++ b/packages/commandkit/bin/development.mjs @@ -13,6 +13,7 @@ import { parseEnv } from './parse-env.mjs'; import child_process from 'node:child_process'; import ora from 'ora'; import { injectShims } from './build.mjs'; +import { cacheDirectivePlugin } from './esbuild-plugins/use-cache.mjs'; const RESTARTING_MSG_PATTERN = /^Restarting '|".+'|"\n?$/; const FAILED_RUNNING_PATTERN = /^Failed running '.+'|"\n?$/; @@ -73,6 +74,7 @@ export async function bootstrapDevelopmentServer(opts) { async onSuccess() { return await injectShims('.commandkit', main, false, requirePolyfill); }, + esbuildPlugins: [cacheDirectivePlugin()], }); status.succeed( diff --git a/packages/commandkit/bin/esbuild-plugins/use-cache.mjs b/packages/commandkit/bin/esbuild-plugins/use-cache.mjs new file mode 100644 index 0000000..b897d07 --- /dev/null +++ b/packages/commandkit/bin/esbuild-plugins/use-cache.mjs @@ -0,0 +1,176 @@ +import * as parser from '@babel/parser'; +import _traverse from '@babel/traverse'; +import _generate from '@babel/generator'; +import * as t from '@babel/types'; + +const traverse = _traverse.default || _traverse; +const generate = _generate.default || _generate; + +const IMPORT_PATH = 'commandkit'; +const DIRECTIVE = 'use cache'; +const CACHE_IDENTIFIER = 'unstable_cache'; + +const generateRandomString = (length = 6) => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + return Array.from( + { length }, + () => chars[Math.floor(Math.random() * chars.length)], + ).join(''); +}; + +export const cacheDirectivePlugin = () => { + return { + name: 'cache-directive', + setup(build) { + const fileFilter = /\.(c|m)?(t|j)sx?$/; + + build.onLoad({ filter: fileFilter }, async (args) => { + const { readFile } = await import('fs/promises'); + const source = await readFile(args.path, 'utf8'); + + const ast = parser.parse(source, { + sourceType: 'module', + plugins: ['typescript', 'jsx'], + }); + + let state = { + needsImport: false, + hasExistingImport: false, + cacheIdentifierName: CACHE_IDENTIFIER, + modifications: [], + }; + + // First pass: check for naming collisions and collect modifications + traverse(ast, { + Program: { + enter(path) { + const binding = path.scope.getBinding(CACHE_IDENTIFIER); + if (binding) { + state.cacheIdentifierName = `cache_${generateRandomString()}`; + } + }, + }, + + ImportDeclaration(path) { + if ( + path.node.source.value === IMPORT_PATH && + path.node.specifiers.some( + (spec) => + t.isImportSpecifier(spec) && + spec.imported.name === CACHE_IDENTIFIER, + ) + ) { + state.hasExistingImport = true; + if (state.cacheIdentifierName !== CACHE_IDENTIFIER) { + state.modifications.push(() => { + path.node.specifiers.forEach((spec) => { + if ( + t.isImportSpecifier(spec) && + spec.imported.name === CACHE_IDENTIFIER + ) { + spec.local.name = state.cacheIdentifierName; + } + }); + }); + } + } + }, + + 'FunctionDeclaration|FunctionExpression|ArrowFunctionExpression'( + path, + ) { + const body = t.isBlockStatement(path.node.body) + ? path.node.body + : null; + const hasUseCache = body?.directives?.some( + (d) => d.value.value === DIRECTIVE, + ); + + if (!hasUseCache && !t.isBlockStatement(path.node.body)) { + const parentFunction = path.findParent( + (p) => + (p.isFunction() || p.isProgram()) && 'directives' in p.node, + ); + if ( + !parentFunction?.node.directives?.some( + (d) => d.value.value === DIRECTIVE, + ) + ) { + return; + } + } + + if (hasUseCache || !t.isBlockStatement(path.node.body)) { + // Check if the function is async + if (!path.node.async) { + throw new Error( + `"${DIRECTIVE}" directive may only be used in async functions at ${args.path}`, + ); + } + + state.needsImport = true; + const isDeclaration = t.isFunctionDeclaration(path.node); + const name = isDeclaration ? path.node.id?.name : undefined; + + // Create a new body without the 'use cache' directive + const newBody = t.isBlockStatement(path.node.body) + ? t.blockStatement( + path.node.body.body, + path.node.body.directives.filter( + (d) => d.value.value !== DIRECTIVE, + ), + ) + : path.node.body; + + const wrapped = t.callExpression( + t.identifier(state.cacheIdentifierName), + [t.arrowFunctionExpression(path.node.params, newBody, true)], + ); + + state.modifications.push(() => { + if (name) { + path.replaceWith( + t.variableDeclaration('const', [ + t.variableDeclarator(t.identifier(name), wrapped), + ]), + ); + } else if (!t.isVariableDeclarator(path.parent)) { + path.replaceWith(wrapped); + } else { + path.parent.init = wrapped; + } + }); + } + }, + }); + + // Apply all collected modifications + if (state.modifications.length > 0) { + // Add import if needed + if (state.needsImport && !state.hasExistingImport) { + ast.program.body.unshift( + t.importDeclaration( + [ + t.importSpecifier( + t.identifier(state.cacheIdentifierName), + t.identifier(CACHE_IDENTIFIER), + ), + ], + t.stringLiteral(IMPORT_PATH), + ), + ); + } + + // Apply collected modifications + state.modifications.forEach((modify) => modify()); + } + + const { code } = generate(ast); + return { + contents: code, + loader: args.path.split('.').pop(), + }; + }); + }, + }; +}; diff --git a/packages/commandkit/package.json b/packages/commandkit/package.json index 9d67d04..617389b 100644 --- a/packages/commandkit/package.json +++ b/packages/commandkit/package.json @@ -42,13 +42,16 @@ "event handler" ], "dependencies": { + "@babel/generator": "^7.26.5", + "@babel/parser": "^7.26.5", + "@babel/traverse": "^7.26.5", + "@babel/types": "^7.26.5", "commander": "^12.1.0", "dotenv": "^16.4.7", "ora": "^8.0.1", "rfdc": "^1.3.1", "rimraf": "^5.0.5", - "tsup": "^8.3.5", - "use-macro": "^1.0.1" + "tsup": "^8.3.5" }, "devDependencies": { "@types/node": "^22.10.2", @@ -57,6 +60,7 @@ "tsconfig": "workspace:*", "tsx": "^4.7.0", "typescript": "^5.7.2", + "use-macro": "^1.0.1", "vitest": "^1.2.1" }, "peerDependencies": { diff --git a/packages/commandkit/src/CommandKit.ts b/packages/commandkit/src/CommandKit.ts index 0796781..b2ea86a 100644 --- a/packages/commandkit/src/CommandKit.ts +++ b/packages/commandkit/src/CommandKit.ts @@ -13,6 +13,8 @@ import { MemoryCache } from './cache/MemoryCache'; export class CommandKit extends EventEmitter { #data: CommandKitData; + static instance: CommandKit | undefined = undefined; + /** * Create a new command and event handler with CommandKit. * @@ -20,6 +22,15 @@ export class CommandKit extends EventEmitter { * @see {@link https://commandkit.dev/guide/commandkit-setup} */ constructor(options: CommandKitOptions) { + if (CommandKit.instance) { + process.emitWarning( + 'CommandKit instance already exists. Having multiple instance in same project is discouraged and it may lead to unexpected behavior.', + { + code: 'MultiInstanceWarning', + }, + ); + } + if (!options.client) { throw new Error( colors.red('"client" is required when instantiating CommandKit.'), @@ -50,6 +61,10 @@ export class CommandKit extends EventEmitter { // Increment client listeners count, as commandkit registers internal event listeners. this.incrementClientListenersCount(); }); + + if (!CommandKit.instance) { + CommandKit.instance = this; + } } /** diff --git a/packages/commandkit/src/cache/CacheProvider.ts b/packages/commandkit/src/cache/CacheProvider.ts index 1c78241..e904edc 100644 --- a/packages/commandkit/src/cache/CacheProvider.ts +++ b/packages/commandkit/src/cache/CacheProvider.ts @@ -1,7 +1,14 @@ +export interface CacheEntry { + key: string; + value: any; + ttl?: number; +} + export abstract class CacheProvider { - abstract get(key: string): Promise; - abstract set(key: string, value: T, ttl?: number): Promise; + abstract get(key: string): Promise; + abstract set(key: string, value: any, ttl?: number): Promise; abstract exists(key: string): Promise; abstract delete(key: string): Promise; abstract clear(): Promise; + abstract expire(key: string, ttl: number): Promise; } diff --git a/packages/commandkit/src/cache/MemoryCache.ts b/packages/commandkit/src/cache/MemoryCache.ts index 477f6f2..7aac3b6 100644 --- a/packages/commandkit/src/cache/MemoryCache.ts +++ b/packages/commandkit/src/cache/MemoryCache.ts @@ -1,13 +1,7 @@ -import { CacheProvider } from './CacheProvider'; - -export interface MemoryCacheEntry { - key: string; - value: any; - ttl?: number; -} +import { CacheEntry, CacheProvider } from './CacheProvider'; export class MemoryCache extends CacheProvider { - #cache = new Map(); + #cache = new Map(); public async get(key: string): Promise { const entry = this.#cache.get(key); @@ -43,4 +37,20 @@ export class MemoryCache extends CacheProvider { public async clear(): Promise { this.#cache.clear(); } + + public async expire(key: string, ttl: number): Promise { + const entry = this.#cache.get(key); + + if (!entry) return; + + const _ttl = Date.now() + ttl; + + // delete if _ttl is in the past + if (_ttl < Date.now()) { + this.#cache.delete(key); + return; + } + + entry.ttl = _ttl; + } } diff --git a/packages/commandkit/src/cache/index.ts b/packages/commandkit/src/cache/index.ts index bb300e3..a421712 100644 --- a/packages/commandkit/src/cache/index.ts +++ b/packages/commandkit/src/cache/index.ts @@ -1,8 +1,7 @@ -import { InteractionResponse, Message } from 'discord.js'; -import { useEnvironment } from '../context/async-context'; -import { after } from '../context/environment'; -import { CommandKitErrorCodes } from '../utils/error-codes'; +import { GenericFunction, getCommandKit } from '../context/async-context'; +import { COMMANDKIT_CACHE_TAG } from '../utils/constants'; import { warnUnstable } from '../utils/warn-unstable'; +import { randomUUID } from 'node:crypto'; export * from './CacheProvider'; export * from './MemoryCache'; @@ -18,90 +17,168 @@ export interface CacheOptions { ttl?: number; } +/** + * Assigns cache tag parameters to the function that uses the "use cache" directive. + * @param options The cache options. + * @param fn The function to assign the cache tag. + */ +export function unstable_cacheTag( + options: string | number | CacheOptions, + fn: GenericFunction, +): void { + warnUnstable('cacheTag()'); + + const isCacheable = Reflect.get(fn, COMMANDKIT_CACHE_TAG); + + if (!isCacheable) { + throw new Error( + 'cacheTag() can only be used with cache() functions or functions that use the "use cache" directive.', + ); + } + + const opt = + typeof options === 'string' + ? { name: options } + : typeof options === 'number' + ? { name: randomUUID(), ttl: options } + : options; + + Reflect.set(fn, '__cache_params', opt); +} + /** * Cache a value. * @param options The cache options. */ -export async function unstable_cache(options: string): Promise; -export async function unstable_cache(options: CacheOptions): Promise; -export async function unstable_cache( - options: string | CacheOptions, -): Promise { - warnUnstable('unstable_cache()'); - const env = useEnvironment(); - const commandkit = env.commandkit; +export function unstable_cache Promise>( + fn: F, + options?: string | CacheOptions | undefined, +): F { + warnUnstable('cache()'); + const commandkit = getCommandKit(true); const cache = commandkit.getCacheProvider(); if (!cache) { throw new Error('cache() cannot be used without a cache provider.'); } + options ??= randomUUID(); + const params = typeof options === 'string' ? { name: options } : options; - // check cache - const data = await cache.get(params.name); + const _fn = (async (...args: Parameters): Promise => { + const context = (Reflect.get(_fn, '__cache_params') || + params) as CacheOptions; + + // check cache + const data = await cache.get(context.name); + + if (data) return data as R; + + // cache miss + const result = await fn(...args); + + await cache.set(context.name, result, context.ttl); + + return result; + }) as F; + + Reflect.set(_fn, '__cache_params', params); + Reflect.set(_fn, COMMANDKIT_CACHE_TAG, true); + + return _fn; +} + +/** + * Revalidate a cache by its key. + * @param cache The cache key or the function that was cached. + * @param ttl The new time-to-live of the cache key in milliseconds. If not provided, the ttl will not be set (past ttl will be ignored). + */ +export async function unstable_revalidate( + cache: string | GenericFunction, + ttl?: number, +): Promise { + warnUnstable('revalidate()'); + const commandkit = getCommandKit(true); + const cacheProvider = commandkit.getCacheProvider(); + + if (!cacheProvider) { + throw new Error('revalidate() cannot be used without a cache provider.'); + } + + const key = + typeof cache === 'string' + ? cache + : Reflect.get(cache, '__cache_params')?.name; + + if (!key) { + throw new Error('Invalid cache key.'); + } + + const data = await cacheProvider.get(key); if (data) { - const error = new Error('Cache hit exception'); - Reflect.set(error, 'data', data); - Reflect.set(error, 'code', CommandKitErrorCodes.CacheHit); + const _ttl = ttl ?? undefined; + await cacheProvider.set(key, data, _ttl); + } +} + +/** + * Invalidate a cache by its key. + * @param cache The cache key or the function that was cached. + */ +export async function unstable_invalidate( + cache: string | GenericFunction, +): Promise { + warnUnstable('invalidate()'); + const commandkit = getCommandKit(true); + const cacheProvider = commandkit.getCacheProvider(); + + if (!cacheProvider) { + throw new Error('invalidate() cannot be used without a cache provider.'); + } - throw error; + const key = + typeof cache === 'string' + ? cache + : Reflect.get(cache, '__cache_params')?.name; + + if (!key) { + throw new Error('Invalid cache key.'); } - env.captureResult(); - - after(async (env) => { - try { - const result = await env.getResult(); - - if (result == null) return; - - if (result instanceof InteractionResponse) { - const message = await result.fetch(); - await cache.set( - params.name, - { - content: message.content, - embeds: message.embeds, - components: message.components, - }, - params.ttl, - ); - } else if (result instanceof Message) { - await cache.set( - params.name, - { - content: result.content, - embeds: result.embeds, - components: result.components, - }, - params.ttl, - ); - } else { - const json = 'toJSON' in result ? result.toJSON() : result; - - await cache.set(params.name, json, params.ttl); - } - } catch (e) { - commandkit.emit('cacheError', e); - } - }); + await cacheProvider.delete(key); } /** * Manually expire a cache by its key. - * @param name The name of the cache key. + * @param cache The cache key. + * @param ttl The new time-to-live of the cache key in milliseconds. If not provided, the cache key will be deleted. */ -export async function unstable_expire(name: string): Promise { - warnUnstable('unstable_expire()'); - const env = useEnvironment(); - const commandkit = env.commandkit; - const cache = commandkit.getCacheProvider(); +export async function unstable_expire( + cache: string | GenericFunction, + ttl?: number, +): Promise { + warnUnstable('expire()'); + const commandkit = getCommandKit(true); + const cacheProvider = commandkit.getCacheProvider(); - if (!cache) { + if (!cacheProvider) { throw new Error('expire() cannot be used without a cache provider.'); } - await cache.delete(name); + const name = + typeof cache === 'string' + ? cache + : Reflect.get(cache, '__cache_params')?.name; + + if (!name) { + throw new Error('Invalid cache key.'); + } + + if (typeof ttl === 'number') { + await cacheProvider.expire(name, ttl); + } else { + await cacheProvider.delete(name); + } } diff --git a/packages/commandkit/src/context/async-context.ts b/packages/commandkit/src/context/async-context.ts index 2406a5c..e728909 100644 --- a/packages/commandkit/src/context/async-context.ts +++ b/packages/commandkit/src/context/async-context.ts @@ -2,7 +2,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'; import { CommandKitEnvironment } from './environment'; import { CommandKitErrorCodes, isCommandKitError } from '../utils/error-codes'; import { Interaction } from 'discord.js'; -import { warnUnstable } from '../utils/warn-unstable'; +import { CommandKit } from '../CommandKit'; const context = new AsyncLocalStorage(); @@ -25,10 +25,6 @@ export function makeContextAwareFunction< // execute the target function const result = await fn(...args); - if (env.isCapturingResult() && result != null) { - env.setResult(result); - } - return result; } catch (e) { // set the error in the environment data @@ -38,20 +34,6 @@ export function makeContextAwareFunction< if (!interaction) return; switch (code) { - case CommandKitErrorCodes.CacheHit: { - const data = Reflect.get(e, 'data'); - env.variables.set('cacheHit', true); - - if (interaction.isCommand()) { - if (interaction.deferred) { - await interaction.editReply(data); - } else if (interaction.isRepliable()) { - await interaction.reply(data); - } - } - - return; - } case CommandKitErrorCodes.GuildOnlyException: { if (interaction.isRepliable()) { await interaction.reply({ @@ -92,6 +74,24 @@ export function makeContextAwareFunction< return _fn as R; } +/** + * Retrieves commandkit + * @private + * @internal + */ +export function getCommandKit(): CommandKit | undefined; +export function getCommandKit(strict: true): CommandKit; +export function getCommandKit(strict: false): CommandKit | undefined; +export function getCommandKit(strict = false): CommandKit | undefined { + const kit = context.getStore()?.commandkit ?? CommandKit.instance; + + if (!kit && strict) { + throw new Error('CommandKit instance not found.'); + } + + return kit; +} + /** * Get the current commandkit context. * @internal diff --git a/packages/commandkit/src/context/environment.ts b/packages/commandkit/src/context/environment.ts index 5f520df..f52ff14 100644 --- a/packages/commandkit/src/context/environment.ts +++ b/packages/commandkit/src/context/environment.ts @@ -10,8 +10,6 @@ export interface CommandKitEnvironmentInternalData { marker: string; markStart: number; markEnd: number; - captureResult: boolean; - result: any; } export class CommandKitEnvironment { @@ -23,8 +21,6 @@ export class CommandKitEnvironment { marker: '', markStart: 0, markEnd: 0, - captureResult: false, - result: null, }; /** @@ -33,38 +29,6 @@ export class CommandKitEnvironment { */ public constructor(public readonly commandkit: CommandKit) {} - /** - * Capture the result of the command execution - * @internal - */ - public captureResult(): void { - this.#data.captureResult = true; - } - - /** - * Whether the result of the command execution is being captured. - * @internal - */ - public isCapturingResult(): boolean { - return this.#data.captureResult; - } - - /** - * Set the result of the command execution - * @internal - */ - public setResult(result: any): void { - this.#data.result = result; - } - - /** - * Get the result of the command execution - * @internal - */ - public getResult(): any { - return this.#data.result; - } - /** * Get the execution error. * @internal diff --git a/packages/commandkit/src/handlers/command-handler/CommandHandler.ts b/packages/commandkit/src/handlers/command-handler/CommandHandler.ts index 593fd3b..ae3686f 100644 --- a/packages/commandkit/src/handlers/command-handler/CommandHandler.ts +++ b/packages/commandkit/src/handlers/command-handler/CommandHandler.ts @@ -282,7 +282,7 @@ export class CommandHandler { after((env) => { const error = env.getExecutionError(); const marker = env.getMarker(); - const time = env.getExecutionTime().toFixed(2); + const time = `${env.getExecutionTime().toFixed(2)}ms`; const cached = !!env.variables.get('cacheHit'); const cachedMarker = cached ? ' (cached)' : ''; diff --git a/packages/commandkit/src/utils/constants.ts b/packages/commandkit/src/utils/constants.ts new file mode 100644 index 0000000..b3792a7 --- /dev/null +++ b/packages/commandkit/src/utils/constants.ts @@ -0,0 +1 @@ +export const COMMANDKIT_CACHE_TAG = Symbol('kCommandKitCacheTag'); diff --git a/packages/commandkit/src/utils/error-codes.ts b/packages/commandkit/src/utils/error-codes.ts index 9ce3695..4c0adfb 100644 --- a/packages/commandkit/src/utils/error-codes.ts +++ b/packages/commandkit/src/utils/error-codes.ts @@ -1,7 +1,6 @@ export const CommandKitErrorCodes = { GuildOnlyException: Symbol('kGuildOnlyException'), DMOnlyException: Symbol('kDMOnlyException'), - CacheHit: Symbol('kCacheHit'), } as const; export function isCommandKitError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9370666..d1e7c80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,18 @@ importers: packages/commandkit: dependencies: + '@babel/generator': + specifier: ^7.26.5 + version: 7.26.5 + '@babel/parser': + specifier: ^7.26.5 + version: 7.26.5 + '@babel/traverse': + specifier: ^7.26.5 + version: 7.26.5 + '@babel/types': + specifier: ^7.26.5 + version: 7.26.5 commander: specifier: ^12.1.0 version: 12.1.0 @@ -252,9 +264,6 @@ importers: tsup: specifier: ^8.3.5 version: 8.3.5(jiti@1.21.6)(postcss@8.4.49)(tsx@4.7.0)(typescript@5.7.2)(yaml@2.6.1) - use-macro: - specifier: ^1.0.1 - version: 1.0.1 devDependencies: '@types/node': specifier: ^22.10.2 @@ -274,6 +283,9 @@ importers: typescript: specifier: ^5.7.2 version: 5.7.2 + use-macro: + specifier: ^1.0.1 + version: 1.0.1 vitest: specifier: ^1.2.1 version: 1.2.1(@types/node@22.10.2)(jiti@1.21.6)(terser@5.37.0)(tsx@4.7.0)(yaml@2.6.1) @@ -460,6 +472,10 @@ packages: resolution: {integrity: sha512-6FF/urZvD0sTeO7k6/B15pMLC4CHUv1426lzr3N01aHJTl046uCAh9LXW/fzeXXjPNCJ6iABW5XaWOsIZB93aQ==} engines: {node: '>=6.9.0'} + '@babel/generator@7.26.5': + resolution: {integrity: sha512-2caSP6fN9I7HOe6nqhtft7V4g7/V/gfDsC3Ag4W7kEzzvRGKqiv0pu0HogPiZ3KaVSoNDhUws6IJjDjpfmYIXw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.25.9': resolution: {integrity: sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==} engines: {node: '>=6.9.0'} @@ -548,6 +564,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.26.5': + resolution: {integrity: sha512-SRJ4jYmXRqV1/Xc+TIVG84WjHBXKlxO9sHQnA2Pf12QQEAp1LOh6kDzNHXcUnbH1QI0FDoPPVOt+vyUDucxpaw==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9': resolution: {integrity: sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==} engines: {node: '>=6.9.0'} @@ -1000,10 +1021,18 @@ packages: resolution: {integrity: sha512-fH+b7Y4p3yqvApJALCPJcwb0/XaOSgtK4pzV6WVjPR5GLFQBRI7pfoX2V2iM48NXvX07NUxxm1Vw98YjqTcU5w==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.26.5': + resolution: {integrity: sha512-rkOSPOw+AXbgtwUga3U4u8RpoK9FEFWBNAlTpcnkLFjL5CT+oyHNuUUC/xx6XefEJ16r38r8Bc/lfp6rYuHeJQ==} + engines: {node: '>=6.9.0'} + '@babel/types@7.26.3': resolution: {integrity: sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==} engines: {node: '>=6.9.0'} + '@babel/types@7.26.5': + resolution: {integrity: sha512-L6mZmwFDK6Cjh1nRCLXpa6no13ZIioJDz7mdkzHv399pThrTa/k0nUlNaenOeh2kWu/iaOQYElEpKPUswUa9Vg==} + engines: {node: '>=6.9.0'} + '@clack/core@0.3.3': resolution: {integrity: sha512-5ZGyb75BUBjlll6eOa1m/IZBxwk91dooBWhPSL67sWcLS0zt9SnswRL0l26TVdBhb0wnWORRxUn//uH6n4z7+A==} @@ -2129,9 +2158,6 @@ packages: '@jridgewell/sourcemap-codec@1.4.15': resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - '@jridgewell/trace-mapping@0.3.19': - resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==} - '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} @@ -8440,6 +8466,14 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 + '@babel/generator@7.26.5': + dependencies: + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.25.9': dependencies: '@babel/types': 7.26.3 @@ -8560,6 +8594,10 @@ snapshots: dependencies: '@babel/types': 7.26.3 + '@babel/parser@7.26.5': + dependencies: + '@babel/types': 7.26.5 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.25.9(@babel/core@7.26.0)': dependencies: '@babel/core': 7.26.0 @@ -9123,8 +9161,8 @@ snapshots: '@babel/template@7.25.9': dependencies: '@babel/code-frame': 7.26.2 - '@babel/parser': 7.26.3 - '@babel/types': 7.26.3 + '@babel/parser': 7.26.5 + '@babel/types': 7.26.5 '@babel/traverse@7.26.4': dependencies: @@ -9138,11 +9176,28 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.26.5': + dependencies: + '@babel/code-frame': 7.26.2 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.5 + '@babel/template': 7.25.9 + '@babel/types': 7.26.5 + debug: 4.4.0 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + '@babel/types@7.26.3': dependencies: '@babel/helper-string-parser': 7.25.9 '@babel/helper-validator-identifier': 7.25.9 + '@babel/types@7.26.5': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + '@clack/core@0.3.3': dependencies: picocolors: 1.0.0 @@ -10471,7 +10526,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.4 + debug: 4.4.0 espree: 9.6.1 globals: 13.24.0 ignore: 5.2.4 @@ -10529,7 +10584,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.4 + debug: 4.4.0 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -10639,7 +10694,7 @@ snapshots: dependencies: '@jridgewell/set-array': 1.1.2 '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping': 0.3.19 + '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -10660,11 +10715,6 @@ snapshots: '@jridgewell/sourcemap-codec@1.4.15': {} - '@jridgewell/trace-mapping@0.3.19': - dependencies: - '@jridgewell/resolve-uri': 3.1.1 - '@jridgewell/sourcemap-codec': 1.4.15 - '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.1 @@ -11620,7 +11670,7 @@ snapshots: '@typescript-eslint/types': 8.18.0 '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.7.2) '@typescript-eslint/visitor-keys': 8.18.0 - debug: 4.3.4 + debug: 4.4.0 eslint: 8.57.1 typescript: 5.7.2 transitivePeerDependencies: @@ -11635,7 +11685,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 8.18.0(typescript@5.7.2) '@typescript-eslint/utils': 8.18.0(eslint@8.57.1)(typescript@5.7.2) - debug: 4.3.4 + debug: 4.4.0 eslint: 8.57.1 ts-api-utils: 1.4.3(typescript@5.7.2) typescript: 5.7.2 @@ -11648,7 +11698,7 @@ snapshots: dependencies: '@typescript-eslint/types': 8.18.0 '@typescript-eslint/visitor-keys': 8.18.0 - debug: 4.3.4 + debug: 4.4.0 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 @@ -17150,7 +17200,7 @@ snapshots: sucrase@3.34.0: dependencies: - '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/gen-mapping': 0.3.8 commander: 4.1.1 glob: 7.1.6 lines-and-columns: 1.2.4 @@ -17606,10 +17656,10 @@ snapshots: use-macro@1.0.1: dependencies: - '@babel/generator': 7.26.3 - '@babel/parser': 7.26.3 - '@babel/traverse': 7.26.4 - '@babel/types': 7.26.3 + '@babel/generator': 7.26.5 + '@babel/parser': 7.26.5 + '@babel/traverse': 7.26.5 + '@babel/types': 7.26.5 devalue: 5.1.1 transitivePeerDependencies: - supports-color @@ -17655,9 +17705,9 @@ snapshots: vite-node@1.2.1(@types/node@22.10.2)(jiti@1.21.6)(terser@5.37.0)(tsx@4.7.0)(yaml@2.6.1): dependencies: cac: 6.7.14 - debug: 4.3.4 + debug: 4.4.0 pathe: 1.1.2 - picocolors: 1.0.0 + picocolors: 1.1.1 vite: 6.0.7(@types/node@22.10.2)(jiti@1.21.6)(terser@5.37.0)(tsx@4.7.0)(yaml@2.6.1) transitivePeerDependencies: - '@types/node'