diff --git a/packages/cli/e2e/api.ts b/packages/cli/e2e/api.ts index 9b1e6fd5119..6cfea12e059 100644 --- a/packages/cli/e2e/api.ts +++ b/packages/cli/e2e/api.ts @@ -10,3 +10,4 @@ export const fetchAllIntegrations = async (client: Client): Promise>['interfaces'][0] export type ApiPlugin = Awaited>['plugins'][0] +export const fetchAllPlugins = async (client: Client): Promise => await client.list.plugins({}).collect() diff --git a/packages/cli/e2e/defaults.ts b/packages/cli/e2e/defaults.ts index e03e6fe637b..8a673563f66 100644 --- a/packages/cli/e2e/defaults.ts +++ b/packages/cli/e2e/defaults.ts @@ -2,6 +2,7 @@ const noBuild = false const dryRun = false const secrets = [] satisfies string[] const sourceMap = false +const watch = true const verbose = false const confirm = true const json = false @@ -18,6 +19,7 @@ export default { dryRun, secrets, sourceMap, + watch, verbose, confirm, json, diff --git a/packages/cli/e2e/index.ts b/packages/cli/e2e/index.ts index d9398226c6d..1e7820c33d6 100644 --- a/packages/cli/e2e/index.ts +++ b/packages/cli/e2e/index.ts @@ -16,7 +16,12 @@ import { reinstallLocalIntegration, } from './tests/install-package' import { requiredIntegrationSecrets } from './tests/integration-secrets' -import { enforceWorkspaceHandle, prependWorkspaceHandle } from './tests/manage-workspace-handle' +import { + enforceWorkspaceHandleIntegration, + enforceWorkspaceHandlePlugin, + prependWorkspaceHandleIntegration, + prependWorkspaceHandlePlugin, +} from './tests/manage-workspace-handle' import { removePackage } from './tests/remove-package' import { Test } from './typings' import { sleep, TmpDirectory } from './utils' @@ -28,8 +33,10 @@ const tests: Test[] = [ devBot, requiredBotSecrets, requiredIntegrationSecrets, - prependWorkspaceHandle, - enforceWorkspaceHandle, + prependWorkspaceHandleIntegration, + enforceWorkspaceHandleIntegration, + prependWorkspaceHandlePlugin, + enforceWorkspaceHandlePlugin, addIntegration, addPlugin, addLocalIntegrationKeepsRelativePath, diff --git a/packages/cli/e2e/tests/manage-workspace-handle.ts b/packages/cli/e2e/tests/manage-workspace-handle.ts index 37ffc846454..7651076dacb 100644 --- a/packages/cli/e2e/tests/manage-workspace-handle.ts +++ b/packages/cli/e2e/tests/manage-workspace-handle.ts @@ -2,7 +2,7 @@ import { Client } from '@botpress/client' import * as fs from 'fs' import pathlib from 'path' import impl from '../../src' -import { ApiIntegration, fetchAllIntegrations } from '../api' +import { ApiIntegration, fetchAllIntegrations, ApiPlugin, fetchAllPlugins } from '../api' import defaults from '../defaults' import * as retry from '../retry' import { Test } from '../typings' @@ -13,7 +13,12 @@ const fetchIntegration = async (client: Client, integrationName: string): Promis return integrations.find(({ name }) => name === integrationName) } -export const prependWorkspaceHandle: Test = { +const fetchPlugin = async (client: Client, pluginName: string): Promise => { + const plugins = await fetchAllPlugins(client) + return plugins.find(({ name }) => name === pluginName) +} + +export const prependWorkspaceHandleIntegration: Test = { name: 'cli should automatically preprend the workspace handle to the integration name when deploying', handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => { const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') @@ -78,7 +83,71 @@ export const prependWorkspaceHandle: Test = { }, } -export const enforceWorkspaceHandle: Test = { +export const prependWorkspaceHandlePlugin: Test = { + name: 'cli should automatically prepend the workspace handle to the plugin name when deploying', + handler: async ({ tmpDir, dependencies, workspaceHandle, logger, ...creds }) => { + const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') + const baseDir = pathlib.join(tmpDir, 'plugins') + + const pluginSuffix = utils.getUUID() + const pluginName = `myplugin${pluginSuffix}` + const pluginNameWithHandle = `${workspaceHandle}/${pluginName}` + const pluginDir = pathlib.join(baseDir, pluginName) + + const argv = { + ...defaults, + botpressHome: botpressHomeDir, + confirm: true, + ...creds, + } + + const client = new Client({ + apiUrl: creds.apiUrl, + token: creds.token, + workspaceId: creds.workspaceId, + retry: retry.config, + }) + + await impl + .init({ ...argv, workDir: baseDir, name: pluginNameWithHandle, type: 'plugin', template: 'empty' }) + .then(utils.handleExitCode) + + // Remove handle from package.json pluginName field: + const pkgJsonPath = pathlib.join(pluginDir, 'package.json') + const pkgJson = await fs.promises.readFile(pkgJsonPath, 'utf-8').then(JSON.parse) + pkgJson.pluginName = pluginName + await fs.promises.writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2)) + + await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies }) + await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode) + await impl.build({ ...argv, workDir: pluginDir }).then(utils.handleExitCode) + await impl.login({ ...argv }).then(utils.handleExitCode) + + await impl + .deploy({ ...argv, createNewBot: undefined, botId: undefined, workDir: pluginDir }) + .then(utils.handleExitCode) + + logger.debug(`Fetching plugin "${pluginName}"`) + let plugin = await fetchPlugin(client, pluginName) + if (plugin) { + throw new Error(`Plugin ${pluginName} should not have been created without handle prefix`) + } + + const expectedPluginName = `${workspaceHandle}/${pluginName}` + logger.debug(`Fetching plugin "${expectedPluginName}"`) + plugin = await fetchPlugin(client, expectedPluginName) + if (!plugin) { + throw new Error(`Plugin ${expectedPluginName} should have been created`) + } + + logger.debug(`Deleting plugin "${expectedPluginName}"`) + await impl.plugins.delete({ ...argv, pluginRef: plugin.id }).then(({ exitCode }) => { + exitCode !== 0 && logger.warn(`Failed to delete plugin "${expectedPluginName}"`) + }) + }, +} + +export const enforceWorkspaceHandleIntegration: Test = { name: 'cli should fail when attempting to deploy an integration with incorrect workspace handle', handler: async ({ tmpDir, dependencies, ...creds }) => { const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') @@ -117,3 +186,43 @@ export const enforceWorkspaceHandle: Test = { } }, } + +export const enforceWorkspaceHandlePlugin: Test = { + name: 'cli should fail when attempting to deploy a plugin with incorrect workspace handle', + handler: async ({ tmpDir, dependencies, ...creds }) => { + const botpressHomeDir = pathlib.join(tmpDir, '.botpresshome') + const baseDir = pathlib.join(tmpDir, 'plugins') + + const randomSuffix = utils.getUUID().slice(0, 8) + + const name = 'myplugin' + const handle = `myhandle${randomSuffix}` + const pluginName = `${handle}/${name}` + const pluginDir = pathlib.join(baseDir, name) + + const argv = { + ...defaults, + botpressHome: botpressHomeDir, + confirm: true, + ...creds, + } + + await impl + .init({ ...argv, workDir: baseDir, name: pluginName, type: 'plugin', template: 'empty' }) + .then(utils.handleExitCode) + await utils.fixBotpressDependencies({ workDir: pluginDir, target: dependencies }) + await utils.npmInstall({ workDir: pluginDir }).then(utils.handleExitCode) + await impl.login({ ...argv }).then(utils.handleExitCode) + + const { exitCode } = await impl.deploy({ + ...argv, + createNewBot: undefined, + botId: undefined, + workDir: pluginDir, + }) + + if (exitCode === 0) { + throw new Error(`Plugin ${pluginName} should not have been deployed`) + } + }, +} diff --git a/packages/cli/package.json b/packages/cli/package.json index 69f3c9f005b..206a7380090 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@botpress/cli", - "version": "6.8.5", + "version": "6.8.6", "description": "Botpress CLI", "scripts": { "build": "pnpm run build:types && pnpm run bundle && pnpm run template:gen", diff --git a/packages/cli/src/command-implementations/bundle-command.ts b/packages/cli/src/command-implementations/bundle-command.ts index bf538dd96d1..f74d0aa34f9 100644 --- a/packages/cli/src/command-implementations/bundle-command.ts +++ b/packages/cli/src/command-implementations/bundle-command.ts @@ -42,18 +42,22 @@ export class BundleCommand extends ProjectCommand { props: Partial = {} ) { const abs = this.projectPaths.abs - const context = buildContext ?? new utils.esbuild.BuildCodeContext() - await context.rebuild( - { - outfile, - absWorkingDir: abs.workDir, - code: this._code, - }, - { - ...this._buildOptions, - ...props, - } - ) + const buildProps = { + outfile, + absWorkingDir: abs.workDir, + code: this._code, + } + const buildOptions = { + ...this._buildOptions, + ...props, + } + + if (buildContext) { + await buildContext.rebuild(buildProps, buildOptions) + return + } + + await utils.esbuild.buildCode(buildProps, buildOptions) } private get _code() { diff --git a/packages/cli/src/command-implementations/deploy-command.ts b/packages/cli/src/command-implementations/deploy-command.ts index f8b73bd5136..8e227ec5a86 100644 --- a/packages/cli/src/command-implementations/deploy-command.ts +++ b/packages/cli/src/command-implementations/deploy-command.ts @@ -59,9 +59,9 @@ export class DeployCommand extends ProjectCommand { } private async _deployIntegration(api: apiUtils.ApiClient, integrationDef: sdk.IntegrationDefinition) { - const res = await this.manageWorkspaceHandle(api, integrationDef) + const res = await this.manageWorkspaceHandle(api, { type: 'integration', definition: integrationDef }) if (!res) return - const { integration: updatedIntegrationDef, workspaceId } = res + const { definition: updatedIntegrationDef, workspaceId } = res integrationDef = updatedIntegrationDef if (workspaceId) { api = api.switchWorkspace(workspaceId) @@ -270,6 +270,14 @@ export class DeployCommand extends ProjectCommand { } private async _deployPlugin(api: apiUtils.ApiClient, pluginDef: sdk.PluginDefinition) { + const res = await this.manageWorkspaceHandle(api, { type: 'plugin', definition: pluginDef }) + if (!res) return + const { definition: updatedPluginDef, workspaceId } = res + pluginDef = updatedPluginDef + if (workspaceId) { + api = api.switchWorkspace(workspaceId) + } + const codeCJS = await fs.promises.readFile(this.projectPaths.abs.outFileCJS, 'utf-8') const codeESM = await fs.promises.readFile(this.projectPaths.abs.outFileESM, 'utf-8') diff --git a/packages/cli/src/command-implementations/dev-command.ts b/packages/cli/src/command-implementations/dev-command.ts index 840db7f01cb..f8ecf219063 100644 --- a/packages/cli/src/command-implementations/dev-command.ts +++ b/packages/cli/src/command-implementations/dev-command.ts @@ -36,6 +36,7 @@ export class DevCommand extends ProjectCommand { public async run(): Promise { this.logger.warn('This command is experimental and subject to breaking changes without notice.') + const watchEnabled = this.argv.watch !== false let api = await this.ensureLoginAndCreateClient(this.argv) const { projectType, resolveProjectDefinition } = this.readProjectDefinitionFromFS() @@ -46,12 +47,12 @@ export class DevCommand extends ProjectCommand { this._initialDef = projectDef if (projectDef.type === 'integration') { - const handleResult = await this.manageWorkspaceHandle(api, projectDef.definition) + const handleResult = await this.manageWorkspaceHandle(api, projectDef) if (!handleResult) return if (handleResult.workspaceId) { api = api.switchWorkspace(handleResult.workspaceId) } - this._deployedIntegrationName = handleResult.integration.name + this._deployedIntegrationName = handleResult.definition.name } let env: Record = { @@ -156,7 +157,7 @@ export class DevCommand extends ProjectCommand { await supervisor.start() - await this._runBuild() + await this._runBuild(watchEnabled) worker = await this._spawnWorker(env, port) try { @@ -169,43 +170,49 @@ export class DevCommand extends ProjectCommand { } try { - const watcher = await utils.filewatcher.FileWatcher.watch( - this.argv.workDir, - async (events) => { - if (!worker) { - this.logger.debug('Worker not ready yet, ignoring file change event') - return - } - - const typescriptEvents = events - .filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir)) - .filter((e) => pathlib.extname(e.path) === '.ts') - - const packageJsonEvents = events - .filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir)) - .filter((e) => pathlib.basename(e.path) === 'package.json') - - const distEvents = events.filter((e) => e.path.startsWith(this.projectPaths.abs.distDir)) - - if (typescriptEvents.length > 0 || packageJsonEvents.length > 0) { - this.logger.log('Changes detected, rebuilding') - await this._restart(api, worker, httpTunnelUrl) - } else if (distEvents.length > 0) { - this.logger.log('Changes detected in output directory, reloading worker') - await worker.reload() + let watcher: Awaited> | undefined + if (!watchEnabled) { + await this._disposeBuildResources({ stopEsbuild: true }) + await Promise.race([worker.wait(), supervisor.wait()]) + } else { + watcher = await utils.filewatcher.FileWatcher.watch( + this.argv.workDir, + async (events) => { + if (!worker) { + this.logger.debug('Worker not ready yet, ignoring file change event') + return + } + + const typescriptEvents = events + .filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir)) + .filter((e) => pathlib.extname(e.path) === '.ts') + + const packageJsonEvents = events + .filter((e) => !e.path.startsWith(this.projectPaths.abs.outDir)) + .filter((e) => pathlib.basename(e.path) === 'package.json') + + const distEvents = events.filter((e) => e.path.startsWith(this.projectPaths.abs.distDir)) + + if (typescriptEvents.length > 0 || packageJsonEvents.length > 0) { + this.logger.log('Changes detected, rebuilding') + await this._restart(api, worker, httpTunnelUrl) + } else if (distEvents.length > 0) { + this.logger.log('Changes detected in output directory, reloading worker') + await worker.reload() + } + }, + { + debounceMs: FILEWATCHER_DEBOUNCE_MS, } - }, - { - debounceMs: FILEWATCHER_DEBOUNCE_MS, - } - ) + ) - await Promise.race([worker.wait(), watcher.wait(), supervisor.wait()]) + await Promise.race([worker.wait(), watcher.wait(), supervisor.wait()]) + } if (worker.running) { await worker.kill() } - await watcher.close() + await watcher?.close() supervisor.close() } catch (thrown) { throw errors.BotpressCLIError.wrap(thrown, 'An error occurred while running the dev server') @@ -213,6 +220,7 @@ export class DevCommand extends ProjectCommand { if (worker.running) { await worker.kill() } + await this._disposeBuildResources() } } @@ -315,10 +323,24 @@ export class DevCommand extends ProjectCommand { return worker } - private _runBuild() { + private _runBuild(watchEnabled = true) { return new BuildCommand(this.api, this.prompt, this.logger, this.argv) .setProjectContext(this.projectContext) - .run(this._buildContext) + .run(watchEnabled ? this._buildContext : undefined) + } + + private async _disposeBuildResources({ stopEsbuild = false } = {}) { + // Best-effort teardown: this runs from the `finally` of `run()`, so it must never throw — + // a failure here would mask the original error being propagated by the dev server. + try { + await Promise.all([this._buildContext.dispose(), this.projectContext.dispose()]) + if (stopEsbuild) { + await utils.esbuild.stop() + } + } catch (thrown: unknown) { + const err = errors.BotpressCLIError.map(thrown) + this.logger.debug(`Failed to dispose build resources: ${err.message}`) + } } private async _deployDevIntegration( diff --git a/packages/cli/src/command-implementations/project-command.ts b/packages/cli/src/command-implementations/project-command.ts index 7d176f6aa07..596d2e499d0 100644 --- a/packages/cli/src/command-implementations/project-command.ts +++ b/packages/cli/src/command-implementations/project-command.ts @@ -42,25 +42,28 @@ export type ProjectDefinition = LintIgnoredConfig & type ProjectDefinitionResolver = () => Promise +type ResolvedProjectDefinition = + | { type: 'bot'; definition: sdk.BotDefinition } + | { type: 'integration'; definition: sdk.IntegrationDefinition } + | { type: 'interface'; definition: sdk.InterfaceDefinition } + | { type: 'plugin'; definition: sdk.PluginDefinition } + export type ProjectDefinitionLazy = | { projectType: 'integration' - resolveProjectDefinition: ProjectDefinitionResolver<{ - type: 'integration' - definition: sdk.IntegrationDefinition - }> + resolveProjectDefinition: ProjectDefinitionResolver> } | { projectType: 'bot' - resolveProjectDefinition: ProjectDefinitionResolver<{ type: 'bot'; definition: sdk.BotDefinition }> + resolveProjectDefinition: ProjectDefinitionResolver> } | { projectType: 'interface' - resolveProjectDefinition: ProjectDefinitionResolver<{ type: 'interface'; definition: sdk.InterfaceDefinition }> + resolveProjectDefinition: ProjectDefinitionResolver> } | { projectType: 'plugin' - resolveProjectDefinition: ProjectDefinitionResolver<{ type: 'plugin'; definition: sdk.PluginDefinition }> + resolveProjectDefinition: ProjectDefinitionResolver> } type UpdatedBot = client.Bot @@ -95,6 +98,16 @@ export class ProjectDefinitionContext { public rebuildEntrypoint(...args: Parameters) { return this._buildContext.rebuild(...args) } + + public async dispose() { + // Drop the resolved-definition cache up front so a failed esbuild teardown can't leave stale state behind. + this._codeCache.clear() + try { + await this._buildContext.dispose() + } catch (thrown: unknown) { + throw errors.BotpressCLIError.map(thrown) + } + } } type ResolvedDependency = { id: string } @@ -871,18 +884,21 @@ export abstract class ProjectCommand extends protected async manageWorkspaceHandle( api: apiUtils.ApiClient, - integration: sdk.IntegrationDefinition - ): Promise< - | { - integration: sdk.IntegrationDefinition - workspaceId?: string - } - | undefined - > { - const { name: localName, workspaceHandle: localHandle } = this._parseIntegrationName(integration.name) + project: Extract + ): Promise<{ definition: sdk.IntegrationDefinition; workspaceId?: string } | undefined> + protected async manageWorkspaceHandle( + api: apiUtils.ApiClient, + project: Extract + ): Promise<{ definition: sdk.PluginDefinition; workspaceId?: string } | undefined> + protected async manageWorkspaceHandle( + api: apiUtils.ApiClient, + project: Extract + ): Promise<{ definition: sdk.IntegrationDefinition | sdk.PluginDefinition; workspaceId?: string } | undefined> { + const { type, definition } = project + const { name: localName, workspaceHandle: localHandle } = this._parseHandledName(definition.name) if (!localHandle && api.isBotpressWorkspace) { this.logger.debug('Botpress workspace detected; workspace handle omitted') - return { integration } // botpress has the right to omit workspace handle + return { definition } // botpress has the right to omit workspace handle } const { handle: remoteHandle, name: workspaceName } = await api.getWorkspace().catch((thrown) => { @@ -897,27 +913,27 @@ export abstract class ProjectCommand extends }) if (!remoteWorkspace) { throw new errors.BotpressCLIError( - `The integration handle "${localHandle}" is not associated with any of your workspaces.` + `The ${type} handle "${localHandle}" is not associated with any of your workspaces.` ) } this.logger.warn( - `Your are logged in to workspace "${workspaceName}" but integration handle "${localHandle}" belongs to "${remoteWorkspace.name}".` + `Your are logged in to workspace "${workspaceName}" but ${type} handle "${localHandle}" belongs to "${remoteWorkspace.name}".` ) const confirmUseAlternateWorkspace = await this.prompt.confirm( - 'Do you want to deploy integration on this workspace instead?' + `Do you want to deploy ${type} on this workspace instead?` ) if (!confirmUseAlternateWorkspace) { throw new errors.BotpressCLIError( - `Cannot deploy integration with handle "${localHandle}" on workspace "${workspaceName}"` + `Cannot deploy ${type} with handle "${localHandle}" on workspace "${workspaceName}"` ) } workspaceId = remoteWorkspace.id } - return { integration, workspaceId } + return { definition, workspaceId } } - const workspaceHandleIsMandatoryMsg = 'Cannot deploy integration without workspace handle' + const workspaceHandleIsMandatoryMsg = `Cannot deploy ${type} without workspace handle` if (!localHandle && remoteHandle) { const confirmAddHandle = await this.prompt.confirm( @@ -928,7 +944,7 @@ export abstract class ProjectCommand extends return } const newName = `${remoteHandle}/${localName}` - return { integration: new sdk.IntegrationDefinition({ ...integration, name: newName }) } + return { definition: this._cloneDefinition(project, { name: newName }) } } if (localHandle && !remoteHandle) { @@ -952,7 +968,7 @@ export abstract class ProjectCommand extends }) this.logger.success(`Handle "${localHandle}" is now yours!`) - return { integration } + return { definition } } this.logger.warn("It seems you don't have a workspace handle yet.") @@ -977,15 +993,33 @@ export abstract class ProjectCommand extends this.logger.success(`Handle "${claimedHandle}" is yours!`) const newName = `${claimedHandle}/${localName}` - return { integration: new sdk.IntegrationDefinition({ ...integration, name: newName }) } + return { definition: this._cloneDefinition(project, { name: newName }) } + } + + private _cloneDefinition = ( + def: T, + props: Partial + ): T['definition'] => { + if (def.type === 'integration') { + return new sdk.IntegrationDefinition({ ...def.definition.props, ...props }) as T['definition'] + } + if (def.type === 'plugin') { + return new sdk.PluginDefinition({ ...def.definition.props, ...props }) as T['definition'] + } + if (def.type === 'interface') { + return new sdk.InterfaceDefinition({ ...def.definition.props, ...props }) as T['definition'] + } + if (def.type === 'bot') { + return new sdk.BotDefinition({ ...def.definition.props, ...props }) as T['definition'] + } + def satisfies never + throw new errors.BotpressCLIError('Unsupported definition type') } - protected _parseIntegrationName = (integrationName: string): { name: string; workspaceHandle?: string } => { - const parts = integrationName.split('/') + protected _parseHandledName = (artifactName: string): { name: string; workspaceHandle?: string } => { + const parts = artifactName.split('/') if (parts.length > 2) { - throw new errors.BotpressCLIError( - `Invalid integration name "${integrationName}": a single forward slash is allowed` - ) + throw new errors.BotpressCLIError(`Invalid name "${artifactName}": a single forward slash is allowed`) } if (parts.length === 2) { const [workspaceHandle, name] = parts as [string, string] diff --git a/packages/cli/src/config.test.ts b/packages/cli/src/config.test.ts new file mode 100644 index 00000000000..29ee2d28d1f --- /dev/null +++ b/packages/cli/src/config.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import yargs, { cleanupConfig } from '@bpinternal/yargs-extra' +import { schemas } from './config' + +const watchSchema = { watch: schemas.dev.watch } + +const parseWatch = (args: string[]) => { + const argv = yargs(args).option('watch', schemas.dev.watch).parseSync() + return cleanupConfig(watchSchema, argv) +} + +describe('config schemas', () => { + it('enables dev file watching by default', () => { + expect(parseWatch([])).toEqual({ watch: true }) + }) + + it('parses --no-watch as disabled dev file watching', () => { + expect(parseWatch(['--no-watch'])).toEqual({ watch: false }) + }) +}) diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index d0f9c8bcf2a..154fc214b92 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -102,6 +102,12 @@ const dev = { default: false, } satisfies CommandOption +const watch = { + type: 'boolean', + description: 'Watch project files and hot reload on changes', + default: true, +} satisfies CommandOption + // base schemas const globalSchema = { @@ -220,6 +226,7 @@ const devSchema = { ...secretsSchema, sourceMap, minify, + watch, port, tunnelUrl: { type: 'string', diff --git a/packages/cli/src/utils/esbuild-utils.test.ts b/packages/cli/src/utils/esbuild-utils.test.ts new file mode 100644 index 00000000000..17e96aebb4e --- /dev/null +++ b/packages/cli/src/utils/esbuild-utils.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { BuildContext } from './esbuild-utils' + +class TestBuildContext extends BuildContext<{ value: string }> { + public contexts: Array<{ + dispose: ReturnType + rebuild: ReturnType + }> = [] + + protected async _createContext() { + const context = { + dispose: vi.fn().mockResolvedValue(undefined), + rebuild: vi.fn().mockResolvedValue({ errors: [], warnings: [] }), + } + this.contexts.push(context) + return context as never + } +} + +describe('BuildContext', () => { + it('recreates the underlying esbuild context after disposal', async () => { + const context = new TestBuildContext() + + await context.rebuild({ value: 'first' }) + await context.rebuild({ value: 'first' }) + + expect(context.contexts).toHaveLength(1) + + await context.dispose() + + expect(context.contexts[0]?.dispose).toHaveBeenCalledTimes(1) + + await context.rebuild({ value: 'first' }) + + expect(context.contexts).toHaveLength(2) + }) +}) diff --git a/packages/cli/src/utils/esbuild-utils.ts b/packages/cli/src/utils/esbuild-utils.ts index dee6b9e6a5f..2126b3e5bb4 100644 --- a/packages/cli/src/utils/esbuild-utils.ts +++ b/packages/cli/src/utils/esbuild-utils.ts @@ -46,6 +46,24 @@ export abstract class BuildContext { } return await this._context?.rebuild() } + + public async dispose() { + if (!this._context) { + return + } + + // Clear our handle in `finally` so a failed teardown can't leave a stale context behind + // that a later dispose would try to tear down a second time. + try { + await this._context.dispose() + } catch (thrown: unknown) { + throw thrown instanceof Error ? thrown : new Error(String(thrown)) + } finally { + this._context = undefined + this._previousProps = undefined + this._previousOpts = {} + } + } } export class BuildCodeContext extends BuildContext {