Skip to content

Commit 0748fb6

Browse files
committed
Extract custom watch paths to specifications
1 parent dbcb9d6 commit 0748fb6

7 files changed

Lines changed: 115 additions & 57 deletions

File tree

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

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {BaseConfigType, MAX_EXTENSION_HANDLE_LENGTH, MAX_UID_LENGTH} from './schemas.js'
22
import {FunctionConfigType} from './specifications/function.js'
3-
import {ExtensionFeature, ExtensionSpecification} from './specification.js'
3+
import {DevSessionWatchConfig, ExtensionFeature, ExtensionSpecification} from './specification.js'
44
import {SingleWebhookSubscriptionType} from './specifications/app_config_webhook_schemas/webhooks_schema.js'
55
import {ExtensionBuildOptions, bundleFunctionExtension} from '../../services/build/extension.js'
66
import {bundleThemeExtension} from '../../services/extensions/bundle.js'
@@ -277,20 +277,14 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
277277
return [this.entrySourceFilePath]
278278
}
279279

280-
// Custom paths to be watched in a dev session
281-
// Return undefiend if there aren't custom configured paths (everything is watched)
282-
// If there are, include some default paths.
283-
get devSessionCustomWatchPaths() {
284-
const config = this.configuration as unknown as FunctionConfigType
285-
if (!config.build || !config.build.watch) return undefined
286-
287-
const watchPaths = [config.build.watch].flat().map((path) => joinPath(this.directory, path))
288-
289-
watchPaths.push(joinPath(this.directory, 'locales', '**.json'))
290-
watchPaths.push(joinPath(this.directory, '**', '!(.)*.graphql'))
291-
watchPaths.push(joinPath(this.directory, '**.toml'))
292-
293-
return watchPaths
280+
// Custom watch configuration for dev sessions
281+
// Return undefined to watch everything (default for 'extension' experience)
282+
// Return a config with empty paths to watch nothing (default for 'configuration' experience)
283+
get devSessionWatchConfig(): DevSessionWatchConfig | undefined {
284+
if (this.specification.devSessionWatchConfig) {
285+
return this.specification.devSessionWatchConfig(this)
286+
}
287+
return this.specification.experience === 'configuration' ? {paths: []} : undefined
294288
}
295289

296290
async watchConfigurationPaths() {
@@ -436,20 +430,28 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
436430
watchedFiles(): string[] {
437431
const watchedFiles: string[] = []
438432

439-
// Add extension directory files based on devSessionCustomWatchPaths or all files
440-
const patterns = this.devSessionCustomWatchPaths ?? ['**/*']
433+
const defaultIgnore = ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/*.swp', '**/generated/**']
434+
const watchConfig = this.devSessionWatchConfig
435+
436+
if (this.specification.experience === 'configuration' && watchConfig === undefined) {
437+
// For configuration experience, if no custom watch config is defined, we only watch the configuration file by default
438+
return []
439+
}
440+
441+
const patterns = watchConfig?.paths ?? ['**/*']
442+
const ignore = watchConfig?.ignore ?? defaultIgnore
441443
const files = patterns.flatMap((pattern) =>
442444
globSync(pattern, {
443445
cwd: this.directory,
444446
absolute: true,
445447
followSymbolicLinks: false,
446-
ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/*.swp', '**/generated/**'],
448+
ignore,
447449
}),
448450
)
449451
watchedFiles.push(...files.flat())
450452

451-
// Add imported files from outside the extension directory unless custom watch paths are defined
452-
if (!this.devSessionCustomWatchPaths) {
453+
// Add imported files from outside the extension directory unless custom watch config is defined
454+
if (!watchConfig) {
453455
const importedFiles = this.scanImports()
454456
watchedFiles.push(...importedFiles)
455457
}

packages/app/src/cli/models/extensions/specification.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,20 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
136136
* Copy static assets from the extension directory to the output path
137137
*/
138138
copyStaticAssets?: (configuration: TConfiguration, directory: string, outputPath: string) => Promise<void>
139+
140+
/**
141+
* Custom watch configuration for dev sessions.
142+
* Return a DevSessionWatchConfig with paths to watch and optionally paths to ignore,
143+
* or undefined to watch all files in the extension directory.
144+
*/
145+
devSessionWatchConfig?: (extension: ExtensionInstance<TConfiguration>) => DevSessionWatchConfig | undefined
146+
}
147+
148+
export interface DevSessionWatchConfig {
149+
/** Absolute paths or globs to watch */
150+
paths: string[]
151+
/** Additional glob patterns to ignore (on top of the default ignore list) */
152+
ignore?: string[]
139153
}
140154

141155
/**
@@ -294,6 +308,7 @@ export function createContractBasedModuleSpecification<TConfiguration extends Ba
294308
| 'clientSteps'
295309
| 'experience'
296310
| 'transformRemoteToLocal'
311+
| 'devSessionWatchConfig'
297312
>,
298313
) {
299314
return createExtensionSpecification({

packages/app/src/cli/models/extensions/specifications/admin.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,32 @@
1-
import {createContractBasedModuleSpecification} from '../specification.js'
1+
import {createExtensionSpecification} from '../specification.js'
2+
import {BaseSchemaWithoutHandle} from '../schemas.js'
3+
import {zod} from '@shopify/cli-kit/node/schema'
4+
import {joinPath} from '@shopify/cli-kit/node/path'
25

3-
const adminSpecificationSpec = createContractBasedModuleSpecification({
6+
const AdminSchema = BaseSchemaWithoutHandle.extend({
7+
admin: zod
8+
.object({
9+
static_root: zod.string().optional(),
10+
})
11+
.optional(),
12+
})
13+
14+
const adminSpecificationSpec = createExtensionSpecification({
415
identifier: 'admin',
516
uidStrategy: 'single',
17+
experience: 'configuration',
18+
schema: AdminSchema,
19+
deployConfig: async (config, _) => {
20+
return {admin: config.admin}
21+
},
22+
devSessionWatchConfig: (extension) => {
23+
const staticRoot = extension.configuration.admin?.static_root
24+
if (!staticRoot) return {paths: []}
25+
26+
const roots = [staticRoot].flat()
27+
const paths = roots.map((root: string) => joinPath(extension.directory, root, '**/*'))
28+
return {paths, ignore: []}
29+
},
630
transformRemoteToLocal: (remoteContent) => {
731
return {
832
admin: {

packages/app/src/cli/models/extensions/specifications/function.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,18 @@ const functionSpec = createExtensionSpecification({
9090
appModuleFeatures: (_) => ['function'],
9191
buildConfig: {mode: 'function'},
9292
getOutputRelativePath: (_extension: ExtensionInstance<FunctionConfigType>) => joinPath('dist', 'index.wasm'),
93+
devSessionWatchConfig: (extension: ExtensionInstance<FunctionConfigType>) => {
94+
const config = extension.configuration
95+
if (!config.build || !config.build.watch) return undefined
96+
97+
const paths = [config.build.watch].flat().map((path) => joinPath(extension.directory, path))
98+
99+
paths.push(joinPath(extension.directory, 'locales', '**.json'))
100+
paths.push(joinPath(extension.directory, '**', '!(.)*.graphql'))
101+
paths.push(joinPath(extension.directory, '**.toml'))
102+
103+
return {paths}
104+
},
93105
clientSteps: [
94106
{
95107
lifecycle: 'deploy',

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@ export async function handleWatcherEvents(
2929
const appEvent: AppEvent = {app, extensionEvents: [], path: events[0].path, startTime: events[0].startTime}
3030

3131
for (const event of otherEvents) {
32-
const affectedExtensions = app.realExtensions.filter((ext) => ext.directory === event.extensionPath)
32+
const affectedExtensions = event.extensionHandle
33+
? app.realExtensions.filter((ext) => ext.handle === event.extensionHandle)
34+
: app.realExtensions.filter((ext) => ext.directory === event.extensionPath)
3335
const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options})
3436
appEvent.extensionEvents.push(...newEvent.extensionEvents)
3537
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ interface TestCaseSingleEvent {
7676
fileSystemEvent: string
7777
path: string
7878
expectedEvent?: Omit<WatcherEvent, 'startTime'> & {startTime?: WatcherEvent['startTime']}
79+
expectedEventCount?: number
7980
}
8081

8182
/**
@@ -104,6 +105,7 @@ const singleEventTestCases: TestCaseSingleEvent[] = [
104105
path: '/extensions/ui_extension_1/index.js',
105106
extensionPath: '/extensions/ui_extension_1',
106107
},
108+
expectedEventCount: 2,
107109
},
108110
{
109111
name: 'change in toml',
@@ -114,6 +116,7 @@ const singleEventTestCases: TestCaseSingleEvent[] = [
114116
path: '/extensions/ui_extension_1/shopify.ui.extension.toml',
115117
extensionPath: '/extensions/ui_extension_1',
116118
},
119+
expectedEventCount: 2,
117120
},
118121
{
119122
name: 'change in app config',
@@ -134,6 +137,7 @@ const singleEventTestCases: TestCaseSingleEvent[] = [
134137
path: '/extensions/ui_extension_1/new-file.js',
135138
extensionPath: '/extensions/ui_extension_1',
136139
},
140+
expectedEventCount: 2,
137141
},
138142
{
139143
name: 'delete a file',
@@ -280,7 +284,7 @@ describe('file-watcher events', () => {
280284

281285
test.each(singleEventTestCases)(
282286
'The event $name returns the expected WatcherEvent',
283-
async ({fileSystemEvent, path, expectedEvent}) => {
287+
async ({fileSystemEvent, path, expectedEvent, expectedEventCount}) => {
284288
// Given
285289
let eventHandler: any
286290

@@ -369,7 +373,8 @@ describe('file-watcher events', () => {
369373
throw new Error('Expected onChange to be called with events, but all calls had empty arrays')
370374
}
371375

372-
expect(actualEvents).toHaveLength(1)
376+
const eventCount = expectedEventCount ?? 1
377+
expect(actualEvents).toHaveLength(eventCount)
373378
const actualEvent = actualEvents[0]
374379

375380
expect(actualEvent.type).toBe(expectedEvent.type)

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

Lines changed: 30 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,12 @@ const FILE_DELETE_TIMEOUT_IN_MS = 500
1919
/**
2020
* Event emitted by the file watcher
2121
*
22-
* Includes the type of the event, the path of the file that triggered the event and the extension path that contains the file.
23-
* path and extensionPath could be the same if the event is at the extension level (create, delete extension)
22+
* Includes the type of the event, the path of the file that triggered the event and the extension handle that owns the file.
23+
* For folder-level events (create, delete), extensionHandle is undefined since the extension may not exist yet.
2424
*
2525
* @typeParam type - The type of the event
2626
* @typeParam path - The path of the file that triggered the event
27-
* @typeParam extensionPath - The path of the extension that contains the file
27+
* @typeParam extensionHandle - The unique handle of the extension that owns the file
2828
* @typeParam startTime - The time when the event was triggered
2929
*/
3030
export interface WatcherEvent {
@@ -37,6 +37,9 @@ export interface WatcherEvent {
3737
| 'extensions_config_updated'
3838
| 'app_config_deleted'
3939
path: string
40+
/** The unique handle of the extension that owns this file. Undefined for folder-level events. */
41+
extensionHandle?: string
42+
/** The directory path of the extension. Used for folder-level events (create/delete) where no extension handle exists yet. */
4043
extensionPath: string
4144
startTime: StartTime
4245
}
@@ -56,7 +59,7 @@ export class FileWatcher {
5659
private watcher?: FSWatcher
5760
private readonly debouncedEmit: () => void
5861
private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {}
59-
// Map of file paths to the extensions that watch them
62+
// Map of file paths to the extension handles that watch them
6063
private readonly extensionWatchedFiles = new Map<string, Set<string>>()
6164

6265
constructor(
@@ -115,15 +118,6 @@ export class FileWatcher {
115118
// Create new watcher
116119
const {default: chokidar} = await import('chokidar')
117120
this.watcher = chokidar.watch(watchPaths, {
118-
ignored: [
119-
'**/node_modules/**',
120-
'**/.git/**',
121-
'**/*.test.*',
122-
'**/dist/**',
123-
'**/*.swp',
124-
'**/generated/**',
125-
'**/.gitignore',
126-
],
127121
persistent: true,
128122
ignoreInitial: true,
129123
})
@@ -155,22 +149,21 @@ export class FileWatcher {
155149
private getAllWatchedFiles(): string[] {
156150
this.extensionWatchedFiles.clear()
157151

158-
const extensionResults = this.app.nonConfigExtensions.map((extension) => ({
152+
const extensionResults = this.app.realExtensions.map((extension) => ({
159153
extension,
160154
watchedFiles: extension.watchedFiles(),
161155
}))
162156

163157
const allFiles = new Set<string>()
164158
for (const {extension, watchedFiles} of extensionResults) {
165-
const extensionDir = normalizePath(extension.directory)
166159
for (const file of watchedFiles) {
167160
const normalizedPath = normalizePath(file)
168161
allFiles.add(normalizedPath)
169162

170-
// Track which extensions watch this file
171-
const extensionsSet = this.extensionWatchedFiles.get(normalizedPath) ?? new Set()
172-
extensionsSet.add(extensionDir)
173-
this.extensionWatchedFiles.set(normalizedPath, extensionsSet)
163+
// Track which extension handles watch this file
164+
const handlesSet = this.extensionWatchedFiles.get(normalizedPath) ?? new Set()
165+
handlesSet.add(extension.handle)
166+
this.extensionWatchedFiles.set(normalizedPath, handlesSet)
174167
}
175168
}
176169

@@ -204,13 +197,13 @@ export class FileWatcher {
204197
}
205198

206199
// If the event is already in the list, don't push it again
207-
// Check path, type, AND extensionPath to properly handle shared files
200+
// Check path, type, AND extensionHandle to properly handle shared files
208201
if (
209202
this.currentEvents.some(
210203
(extEvent) =>
211204
extEvent.path === event.path &&
212205
extEvent.type === event.type &&
213-
extEvent.extensionPath === event.extensionPath,
206+
extEvent.extensionHandle === event.extensionHandle,
214207
)
215208
)
216209
return
@@ -229,15 +222,17 @@ export class FileWatcher {
229222
private shouldIgnoreEvent(event: WatcherEvent) {
230223
if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false
231224

232-
const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath)
225+
const extension = event.extensionHandle
226+
? this.app.realExtensions.find((ext) => ext.handle === event.extensionHandle)
227+
: undefined
233228
const watchPaths = extension?.watchedFiles()
234-
const ignoreInstance = this.ignored[event.extensionPath]
229+
const ignoreInstance = extension ? this.ignored[extension.directory] : undefined
235230

236231
if (watchPaths) {
237232
const isAValidWatchedPath = watchPaths.some((pattern) => matchGlob(event.path, pattern))
238233
return !isAValidWatchedPath
239234
} else if (ignoreInstance) {
240-
const relative = relativePath(event.extensionPath, event.path)
235+
const relative = relativePath(extension!.directory, event.path)
241236
return ignoreInstance.ignores(relative)
242237
}
243238

@@ -255,8 +250,8 @@ export class FileWatcher {
255250
if (isConfigAppPath) {
256251
this.handleEventForExtension(event, path, this.app.directory, startTime, false)
257252
} else {
258-
const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath)
259-
const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0
253+
const affectedHandles = this.extensionWatchedFiles.get(normalizedPath)
254+
const isUnknownExtension = affectedHandles === undefined || affectedHandles.size === 0
260255

261256
if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) {
262257
// Ignore an event if it's not part of an existing extension
@@ -265,8 +260,10 @@ export class FileWatcher {
265260
return
266261
}
267262

268-
for (const extensionPath of affectedExtensions ?? []) {
269-
this.handleEventForExtension(event, path, extensionPath, startTime, false)
263+
for (const handle of affectedHandles ?? []) {
264+
const extension = this.app.realExtensions.find((ext) => ext.handle === handle)
265+
const extensionPath = extension ? normalizePath(extension.directory) : this.app.directory
266+
this.handleEventForExtension(event, path, extensionPath, startTime, false, handle)
270267
}
271268
if (isUnknownExtension) {
272269
this.handleEventForExtension(event, path, this.app.directory, startTime, true)
@@ -281,6 +278,7 @@ export class FileWatcher {
281278
extensionPath: string,
282279
startTime: StartTime,
283280
isUnknownExtension: boolean,
281+
extensionHandle?: string,
284282
) {
285283
const isExtensionToml = path.endsWith('.extension.toml')
286284
const isConfigAppPath = path === this.app.configPath
@@ -293,17 +291,17 @@ export class FileWatcher {
293291
break
294292
}
295293
if (isExtensionToml || isConfigAppPath) {
296-
this.pushEvent({type: 'extensions_config_updated', path, extensionPath, startTime})
294+
this.pushEvent({type: 'extensions_config_updated', path, extensionPath, extensionHandle, startTime})
297295
} else {
298-
this.pushEvent({type: 'file_updated', path, extensionPath, startTime})
296+
this.pushEvent({type: 'file_updated', path, extensionPath, extensionHandle, startTime})
299297
}
300298
break
301299
case 'add':
302300
// If it's a normal non-toml file, just report a file_created event.
303301
// If a toml file was added, a new extension(s) is being created.
304302
// We need to wait for the lock file to disappear before triggering the event.
305303
if (!isExtensionToml) {
306-
this.pushEvent({type: 'file_created', path, extensionPath, startTime})
304+
this.pushEvent({type: 'file_created', path, extensionPath, extensionHandle, startTime})
307305
break
308306
}
309307
let totalWaitedTime = 0
@@ -339,7 +337,7 @@ export class FileWatcher {
339337
setTimeout(() => {
340338
// If the extensionPath is not longer in the list, the extension was deleted while the timeout was running.
341339
if (!this.extensionPaths.includes(extensionPath)) return
342-
this.pushEvent({type: 'file_deleted', path, extensionPath, startTime})
340+
this.pushEvent({type: 'file_deleted', path, extensionPath, extensionHandle, startTime})
343341
// Force an emit because we are inside a timeout callback
344342
this.debouncedEmit()
345343
}, FILE_DELETE_TIMEOUT_IN_MS)

0 commit comments

Comments
 (0)