Skip to content

Commit 330e89c

Browse files
authored
Merge pull request #7252 from Shopify/04-13-refactor_remove_esbuildcontextmanager_from_app-event-watcher
refactor: remove ESBuildContextManager from app-event-watcher
2 parents f264d10 + 178908c commit 330e89c

File tree

9 files changed

+66
-465
lines changed

9 files changed

+66
-465
lines changed

packages/app/src/cli/models/extensions/extension-instance.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,6 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
324324
// eslint-disable-next-line no-await-in-loop
325325
const result = await executeStep(step, context)
326326
context.stepResults.set(step.id, result)
327-
328-
if (!result.success && !step.continueOnError) {
329-
throw new Error(`Build step "${step.name}" failed: ${result.error?.message}`)
330-
}
331327
}
332328
}
333329

packages/app/src/cli/services/build/client-steps.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export async function executeStep(step: LifecycleStep, context: BuildContext): P
109109
}
110110
}
111111

112-
throw new Error(`Build step "${step.name}" failed: ${stepError.message}`)
112+
stepError.message = `Build step "${step.name}" failed: ${stepError.message}`
113+
throw stepError
113114
}
114115
}

packages/app/src/cli/services/build/extension.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
6868

6969
const {main, assets} = extension.getBundleExtensionStdinContent()
7070

71+
const startTime = performance.now()
7172
try {
7273
await bundleExtension({
7374
minify: true,
@@ -102,17 +103,25 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex
102103
}),
103104
)
104105
}
105-
} catch (extensionBundlingError) {
106+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
107+
} catch (extensionBundlingError: any) {
106108
// this fails if the app's own source code is broken; wrap such that this isn't flagged as a CLI bug
107-
throw new AbortError(
109+
// Preserve esbuild errors array so the dev watcher can format actionable error messages
110+
const errorMessage = (extensionBundlingError as Error).message ?? 'Unknown error occurred'
111+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112+
const newError: any = new AbortError(
108113
`Failed to bundle extension ${extension.localIdentifier}. Please check the extension source code for errors.`,
114+
errorMessage,
109115
)
116+
newError.errors = extensionBundlingError.errors
117+
throw newError
110118
}
111119

112120
await extension.buildValidation()
113121

122+
const duration = Math.round(performance.now() - startTime)
114123
const sizeInfo = await formatBundleSize(extension.outputPath)
115-
options.stdout.write(`${extension.localIdentifier} successfully built${sizeInfo}`)
124+
options.stdout.write(`${extension.localIdentifier} successfully built in ${duration}ms${sizeInfo}`)
116125
}
117126

118127
type BuildFunctionExtensionOptions = ExtensionBuildOptions

packages/app/src/cli/services/dev/app-events/app-event-watcher.test.ts

Lines changed: 34 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import {AppEvent, AppEventWatcher, EventType, ExtensionEvent} from './app-event-watcher.js'
1+
import {AppEventWatcher, EventType, ExtensionEvent} from './app-event-watcher.js'
22
import {OutputContextOptions, WatcherEvent, FileWatcher} from './file-watcher.js'
3-
import {ESBuildContextManager} from './app-watcher-esbuild.js'
43
import {
54
testAppAccessConfigExtension,
65
testAppConfigExtensions,
@@ -22,7 +21,6 @@ import {joinPath} from '@shopify/cli-kit/node/path'
2221
import {Writable} from 'stream'
2322

2423
vi.mock('../../../models/app/loader.js')
25-
vi.mock('./app-watcher-esbuild.js')
2624

2725
// Extensions 1 and 1B simulate extensions defined in the same directory (same toml)
2826
const extension1 = await testUIExtension({
@@ -336,6 +334,24 @@ describe('app-event-watcher', () => {
336334
stdout = {write: vi.fn()}
337335
stderr = {write: vi.fn()}
338336
abortController = new AbortController()
337+
338+
// Mock buildForBundle on all test extensions so the watcher doesn't attempt real builds
339+
const allExtensions = [
340+
extension1,
341+
extension1B,
342+
extension2,
343+
extension1Updated,
344+
extension1BUpdated,
345+
flowExtension,
346+
posExtension,
347+
posExtensionUpdated,
348+
appAccessExtension,
349+
webhookExtension,
350+
]
351+
for (const ext of allExtensions) {
352+
vi.spyOn(ext, 'buildForBundle').mockResolvedValue()
353+
vi.spyOn(ext, 'rescanImports').mockResolvedValue(false)
354+
}
339355
})
340356

341357
afterEach(() => {
@@ -360,9 +376,8 @@ describe('app-event-watcher', () => {
360376
configuration: testAppConfiguration,
361377
})
362378

363-
const mockManager = new MockESBuildContextManager()
364379
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
365-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
380+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
366381
const emitSpy = vi.spyOn(watcher, 'emit')
367382
await watcher.start({stdout, stderr, signal: abortController.signal})
368383

@@ -433,9 +448,8 @@ describe('app-event-watcher', () => {
433448
})
434449
const generateTypesSpy = vi.spyOn(app, 'generateExtensionTypes')
435450

436-
const mockManager = new MockESBuildContextManager()
437451
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
438-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
452+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
439453
const emitSpy = vi.spyOn(watcher, 'emit')
440454

441455
// When
@@ -468,9 +482,8 @@ describe('app-event-watcher', () => {
468482
configuration: testAppConfiguration,
469483
})
470484

471-
const mockManager = new MockESBuildContextManager()
472485
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
473-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
486+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
474487
const emitSpy = vi.spyOn(watcher, 'emit')
475488

476489
// When
@@ -503,9 +516,8 @@ describe('app-event-watcher', () => {
503516
configuration: testAppConfiguration,
504517
})
505518

506-
const mockManager = new MockESBuildContextManager()
507519
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
508-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
520+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
509521
const emitSpy = vi.spyOn(watcher, 'emit')
510522

511523
// When
@@ -535,9 +547,8 @@ describe('app-event-watcher', () => {
535547
})
536548
const generateTypesSpy = vi.spyOn(app, 'generateExtensionTypes')
537549

538-
const mockManager = new MockESBuildContextManager()
539550
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
540-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
551+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
541552
const emitSpy = vi.spyOn(watcher, 'emit')
542553

543554
// When
@@ -571,19 +582,18 @@ describe('app-event-watcher', () => {
571582
],
572583
}
573584

574-
const mockManager = new MockESBuildContextManager()
575-
mockManager.rebuildContext = vi.fn().mockRejectedValueOnce(esbuildError)
576-
577585
const buildOutputPath = joinPath(tmpDir, '.shopify', 'bundle')
578586
const app = testAppLinked({
579587
allExtensions: [extension1],
580588
configPath: 'shopify.app.custom.toml',
581589
configuration: testAppConfiguration,
582590
})
591+
// First call succeeds (initial build on start), second call fails (file watcher triggered build)
592+
vi.spyOn(extension1, 'buildForBundle').mockResolvedValueOnce().mockRejectedValueOnce(esbuildError)
583593
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
584594

585595
// When
586-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
596+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
587597
const emitSpy = vi.spyOn(watcher, 'emit')
588598
const stderr = {write: vi.fn()} as unknown as Writable
589599
const stdout = {write: vi.fn()} as unknown as Writable
@@ -627,9 +637,9 @@ describe('app-event-watcher', () => {
627637
})
628638

629639
// When
630-
const mockManager = new MockESBuildContextManager()
640+
631641
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
632-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
642+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
633643
const emitSpy = vi.spyOn(watcher, 'emit')
634644
const stderr = {write: vi.fn()} as unknown as Writable
635645
const stdout = {write: vi.fn()} as unknown as Writable
@@ -661,13 +671,14 @@ describe('app-event-watcher', () => {
661671
configPath: 'shopify.app.custom.toml',
662672
configuration: testAppConfiguration,
663673
})
674+
675+
// Make rescanImports throw to simulate an uncaught error in the watcher pipeline
676+
vi.spyOn(extension1, 'rescanImports').mockRejectedValueOnce(uncaughtError)
677+
664678
const mockFileWatcher = new MockFileWatcher(app, outputOptions, [fileWatchEvent])
665679

666680
// When
667-
const mockManager = new MockESBuildContextManager()
668-
mockManager.updateContexts = vi.fn().mockRejectedValueOnce(uncaughtError)
669-
670-
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockManager, mockFileWatcher)
681+
const watcher = new AppEventWatcher(app, 'url', buildOutputPath, mockFileWatcher)
671682
const emitSpy = vi.spyOn(watcher, 'emit')
672683
const stderr = {write: vi.fn()} as unknown as Writable
673684
const stdout = {write: vi.fn()} as unknown as Writable
@@ -684,26 +695,6 @@ describe('app-event-watcher', () => {
684695
})
685696
})
686697
})
687-
// Mock class for ESBuildContextManager
688-
// It handles the ESBuild contexts for the extensions that are being watched
689-
class MockESBuildContextManager extends ESBuildContextManager {
690-
contexts = {
691-
// The keys are the extension handles, the values are the ESBuild contexts mocked
692-
uid1: [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}],
693-
uid1B: [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}],
694-
uid2: [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}],
695-
'test-ui-extension': [{rebuild: vi.fn(), watch: vi.fn(), serve: vi.fn(), cancel: vi.fn(), dispose: vi.fn()}],
696-
}
697-
698-
constructor() {
699-
super({dotEnvVariables: {}, url: 'url', outputPath: 'outputPath'})
700-
}
701-
702-
async createContexts(extensions: ExtensionInstance[]) {}
703-
async updateContexts(appEvent: AppEvent) {}
704-
async deleteContexts(extensions: ExtensionInstance[]) {}
705-
}
706-
707698
// Mock class for FileWatcher
708699
// Used to trigger mocked file system events immediately after the watcher is started.
709700
class MockFileWatcher extends FileWatcher {

packages/app/src/cli/services/dev/app-events/app-event-watcher.ts

Lines changed: 4 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
/* eslint-disable tsdoc/syntax */
22
import {FileWatcher, OutputContextOptions} from './file-watcher.js'
3-
import {ESBuildContextManager} from './app-watcher-esbuild.js'
43
import {handleWatcherEvents} from './app-event-watcher-handler.js'
54
import {AppLinkedInterface} from '../../../models/app/app.js'
65
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
76
import {ExtensionBuildOptions} from '../../build/extension.js'
8-
import {formatBundleSize} from '../../build/bundle-size.js'
97
import {outputDebug} from '@shopify/cli-kit/node/output'
108
import {AbortSignal} from '@shopify/cli-kit/node/abort'
119
import {joinPath} from '@shopify/cli-kit/node/path'
@@ -95,33 +93,18 @@ export class AppEventWatcher extends EventEmitter {
9593
private app: AppLinkedInterface
9694
private options: OutputContextOptions
9795
private readonly appURL?: string
98-
private readonly esbuildManager: ESBuildContextManager
9996
private started = false
10097
private ready = false
10198
private initialEvents: ExtensionEvent[] = []
10299
private fileWatcher?: FileWatcher
103100

104-
constructor(
105-
app: AppLinkedInterface,
106-
appURL?: string,
107-
buildOutputPath?: string,
108-
esbuildManager?: ESBuildContextManager,
109-
fileWatcher?: FileWatcher,
110-
) {
101+
constructor(app: AppLinkedInterface, appURL?: string, buildOutputPath?: string, fileWatcher?: FileWatcher) {
111102
super()
112103
this.app = app
113104
this.appURL = appURL
114105
this.buildOutputPath = buildOutputPath ?? joinPath(app.directory, '.shopify', 'dev-bundle')
115106
// Default options, to be overwritten by the start method
116107
this.options = {stdout: process.stdout, stderr: process.stderr, signal: new AbortSignal()}
117-
this.esbuildManager =
118-
esbuildManager ??
119-
new ESBuildContextManager({
120-
outputPath: this.buildOutputPath,
121-
dotEnvVariables: this.app.dotenv?.variables ?? {},
122-
url: this.appURL ?? '',
123-
...this.options,
124-
})
125108
this.fileWatcher = fileWatcher
126109
}
127110

@@ -130,15 +113,11 @@ export class AppEventWatcher extends EventEmitter {
130113
this.started = true
131114

132115
this.options = options ?? this.options
133-
this.esbuildManager.setAbortSignal(this.options.signal)
134116

135117
// If there is a previous build folder, delete it
136118
if (await fileExists(this.buildOutputPath)) await rmdir(this.buildOutputPath, {force: true})
137119
await mkdir(this.buildOutputPath)
138120

139-
// Start the esbuild bundler for extensions that require it
140-
await this.esbuildManager.createContexts(this.app.realExtensions.filter((ext) => ext.isESBuildExtension))
141-
142121
// Initial build of all extensions
143122
if (buildExtensionsFirst) {
144123
this.initialEvents = this.app.realExtensions.map((ext) => ({type: EventType.Updated, extension: ext}))
@@ -154,8 +133,6 @@ export class AppEventWatcher extends EventEmitter {
154133

155134
this.app = appEvent.app
156135
if (appEvent.appWasReloaded) this.fileWatcher?.updateApp(this.app)
157-
await this.esbuildManager.updateContexts(appEvent)
158-
159136
await this.rescanImports(appEvent)
160137

161138
// Find affected created/updated extensions and build them
@@ -237,9 +214,7 @@ export class AppEventWatcher extends EventEmitter {
237214
}
238215

239216
/**
240-
* Builds all given extensions.
241-
* ESBuild extensions will be built using their own ESBuild context, other extensions will be built using the default
242-
* buildForBundle method.
217+
* Builds all given extensions using the default buildForBundle method.
243218
*/
244219
private async buildExtensions(extensionEvents: ExtensionEvent[]) {
245220
// Group events by extension to handle multiple events for the same extension
@@ -258,13 +233,7 @@ export class AppEventWatcher extends EventEmitter {
258233
const promises = groupedExtensionEvents.map(async ({extension: ext, events}) => {
259234
return useConcurrentOutputContext({outputPrefix: ext.handle, stripAnsi: false}, async () => {
260235
try {
261-
if (this.esbuildManager.contexts?.[ext.uid]?.length) {
262-
await this.esbuildManager.rebuildContext(ext)
263-
const sizeInfo = await formatBundleSize(ext.outputPath)
264-
this.options.stdout.write(`Build successful${sizeInfo}`)
265-
} else {
266-
await this.buildExtension(ext)
267-
}
236+
await this.buildExtension(ext)
268237
// Update all events for this extension with success result
269238
const buildResult = {status: 'ok' as const, uid: ext.uid}
270239
events.forEach((event) => {
@@ -309,7 +278,7 @@ export class AppEventWatcher extends EventEmitter {
309278
}
310279

311280
/**
312-
* Build a single non-esbuild extension using the default buildForBundle method.
281+
* Build a single extension using the default buildForBundle method.
313282
* @param extension - The extension to build
314283
*/
315284
private async buildExtension(extension: ExtensionInstance): Promise<void> {

0 commit comments

Comments
 (0)