Skip to content

Commit b655072

Browse files
committed
notify extension dev server of app assets updates
1 parent d1271b9 commit b655072

File tree

16 files changed

+122
-19
lines changed

16 files changed

+122
-19
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ export interface AppInterface<
229229
realExtensions: ExtensionInstance[]
230230
nonConfigExtensions: ExtensionInstance[]
231231
draftableExtensions: ExtensionInstance[]
232+
appAssetsConfigs: Record<string, string> | undefined
232233
errors: AppErrors
233234
hiddenConfig: AppHiddenConfig
234235
includeConfigOnDeploy: boolean | undefined
@@ -334,6 +335,15 @@ export class App<
334335
)
335336
}
336337

338+
get appAssetsConfigs(): Record<string, string> | undefined {
339+
if (!this.realExtensions.some((ext) => ext.specification.appAssetsConfig)) return undefined
340+
return this.realExtensions.reduce<Record<string, string>>((acc, ext) => {
341+
const config = ext.specification.appAssetsConfig?.(ext.configuration)
342+
if (config) acc[config.assetsKey] = config.assetsDir
343+
return acc
344+
}, {})
345+
}
346+
337347
setDevApplicationURLs(devApplicationURLs: ApplicationURLs) {
338348
this.patchAppConfiguration(devApplicationURLs)
339349
this.realExtensions.forEach((ext) => ext.patchWithAppDevURLs(devApplicationURLs))

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ export class ExtensionInstance<TConfiguration extends BaseConfigType = BaseConfi
285285
return this.specification.devSessionWatchConfig(this)
286286
}
287287

288-
return this.specification.experience === 'configuration' ? {paths: []} : undefined
288+
return this.isAppConfigExtension ? {paths: []} : undefined
289289
}
290290

291291
async watchConfigurationPaths() {

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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,19 @@ export interface ExtensionSpecification<TConfiguration extends BaseConfigType =
143143
* or undefined to watch all files in the extension directory.
144144
*/
145145
devSessionWatchConfig?: (extension: ExtensionInstance<TConfiguration>) => DevSessionWatchConfig | undefined
146+
147+
/**
148+
* App assets configuration for this extension.
149+
* Return undefined if this extension doesn't serve app assets.
150+
*/
151+
appAssetsConfig?: (config: TConfiguration) => AppAssetsConfig | undefined
152+
}
153+
154+
export interface AppAssetsConfig {
155+
/** The config key that points to the assets directory (e.g. 'admin.static_root') */
156+
assetsKey: string
157+
/** The assets directory relative to the extension directory */
158+
assetsDir: string
146159
}
147160

148161
export interface DevSessionWatchConfig {

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ const adminSpecificationSpec = createExtensionSpecification<AdminConfigType>({
6262
},
6363
],
6464
appModuleFeatures: () => [],
65+
appAssetsConfig: (config) => {
66+
const dir = config.admin?.static_root
67+
if (!dir) return undefined
68+
return {assetsKey: 'staticRoot', assetsDir: dir}
69+
},
6570
})
6671

6772
export default adminSpecificationSpec

packages/app/src/cli/services/build/steps/include-assets-step.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1044,7 +1044,7 @@ describe('executeIncludeAssetsStep', () => {
10441044
)
10451045
})
10461046

1047-
test('throws when manifest.json already exists in the output directory', async () => {
1047+
test('overwrites manifest.json when it already exists in the output directory', async () => {
10481048
// Given — a prior inclusion already copied a manifest.json to the output dir
10491049
const contextWithConfig = {
10501050
...mockContext,
@@ -1056,8 +1056,7 @@ describe('executeIncludeAssetsStep', () => {
10561056
} as unknown as ExtensionInstance,
10571057
}
10581058

1059-
// Source files exist; output manifest.json already exists (simulating conflict);
1060-
// candidate output paths for tools.json are free so copyConfigKeyEntry succeeds.
1059+
// Source files exist; output manifest.json already exists
10611060
vi.mocked(fs.fileExists).mockImplementation(async (path) => {
10621061
const pathStr = String(path)
10631062
return pathStr === '/test/output/manifest.json' || pathStr.startsWith('/test/extension/')
@@ -1081,9 +1080,11 @@ describe('executeIncludeAssetsStep', () => {
10811080
},
10821081
}
10831082

1084-
// When / Then — throws rather than silently overwriting
1085-
await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow(
1086-
`Can't write manifest.json: a file already exists at '/test/output/manifest.json'`,
1083+
// When / Then — overwrites existing manifest.json
1084+
await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow()
1085+
expect(fs.writeFile).toHaveBeenCalledWith(
1086+
'/test/output/manifest.json',
1087+
expect.any(String),
10871088
)
10881089
})
10891090

packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {getNestedValue, tokenizePath} from './copy-config-key-entry.js'
22
import {joinPath} from '@shopify/cli-kit/node/path'
3-
import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs'
3+
import {mkdir, writeFile} from '@shopify/cli-kit/node/fs'
44
import {outputDebug} from '@shopify/cli-kit/node/output'
55
import type {BuildContext} from '../../client-steps.js'
66

@@ -20,7 +20,7 @@ interface ConfigKeyManifestEntry {
2020
* 3. Build root-level entries.
2121
* 4. Build grouped entries (anchor/groupBy logic) with path strings resolved
2222
* via `resolveManifestPaths` using the copy-tracked `pathMap`.
23-
* 5. Write `outputDir/manifest.json`; throw if the file already exists.
23+
* 5. Write `outputDir/manifest.json`, overwriting any existing file.
2424
*
2525
* @param pathMap - Map from raw config path values to their output-relative
2626
* paths, as recorded during the copy phase by `copyConfigKeyEntry`.
@@ -113,12 +113,6 @@ export async function generateManifestFile(
113113
}
114114

115115
const manifestPath = joinPath(outputDir, 'manifest.json')
116-
if (await fileExists(manifestPath)) {
117-
throw new Error(
118-
`Can't write manifest.json: a file already exists at '${manifestPath}'. ` +
119-
`Remove or rename the conflicting inclusion to avoid overwriting the generated manifest.`,
120-
)
121-
}
122116
await mkdir(outputDir)
123117
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
124118
outputDebug(`Generated manifest.json in ${outputDir}\n`, options.stdout)

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export class FileWatcher {
150150
private getAllWatchedFiles(): string[] {
151151
this.extensionWatchedFiles.clear()
152152

153-
const extensionResults = this.app.nonConfigExtensions.map((extension) => ({
153+
const extensionResults = this.app.realExtensions.map((extension) => ({
154154
extension,
155155
watchedFiles: extension.watchedFiles(),
156156
}))

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
1212
import {AbortSignal} from '@shopify/cli-kit/node/abort'
1313
import {outputDebug} from '@shopify/cli-kit/node/output'
1414
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
15+
import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array'
1516
import {Writable} from 'stream'
1617

1718
export interface ExtensionDevOptions {
@@ -112,6 +113,11 @@ export interface ExtensionDevOptions {
112113
* The app watcher that emits events when the app is updated
113114
*/
114115
appWatcher: AppEventWatcher
116+
117+
/**
118+
* Map of asset key to absolute directory path for app-level assets (e.g., admin static_root)
119+
*/
120+
appAssets?: Record<string, string>
115121
}
116122

117123
export async function devUIExtensions(options: ExtensionDevOptions): Promise<void> {
@@ -133,7 +139,13 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
133139
}
134140

135141
outputDebug(`Setting up the UI extensions HTTP server...`, payloadOptions.stdout)
136-
const httpServer = setupHTTPServer({devOptions: payloadOptions, payloadStore, getExtensions})
142+
const getAppAssets = () => payloadOptions.appAssets
143+
const httpServer = setupHTTPServer({
144+
devOptions: payloadOptions,
145+
payloadStore,
146+
getExtensions,
147+
getAppAssets,
148+
})
137149

138150
outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout)
139151
const websocketConnection = setupWebsocketConnection({...payloadOptions, httpServer, payloadStore})
@@ -144,6 +156,14 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
144156
extensions = app.allExtensions.filter((ext) => ext.isPreviewable)
145157
}
146158

159+
// Handle App Assets updates.
160+
const appAssetsConfigs = extensionEvents.map((event) =>
161+
event.extension.specification.appAssetsConfig?.(event.extension.configuration),
162+
)
163+
getArrayRejectingUndefined(appAssetsConfigs).forEach((config) => {
164+
payloadStore.updateAppAssetTimestamp(config.assetsKey)
165+
})
166+
147167
for (const event of extensionEvents) {
148168
if (!event.extension.isPreviewable) continue
149169
const status = event.buildResult?.status === 'ok' ? 'success' : 'error'

packages/app/src/cli/services/dev/extension/payload/models.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface {
88
url: string
99
mobileUrl: string
1010
title: string
11+
assets?: {
12+
[key: string]: {
13+
url: string
14+
lastUpdated: number
15+
}
16+
}
1117
}
1218
appId?: string
1319
store: string

packages/app/src/cli/services/dev/extension/payload/store.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {EventEmitter} from 'events'
99

1010
export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions {
1111
websocketURL: string
12+
appAssets?: Record<string, string>
1213
}
1314

1415
export enum ExtensionsPayloadStoreEvent {
@@ -19,7 +20,7 @@ export async function getExtensionsPayloadStoreRawPayload(
1920
options: Omit<ExtensionsPayloadStoreOptions, 'appWatcher'>,
2021
bundlePath: string,
2122
): Promise<ExtensionsEndpointPayload> {
22-
return {
23+
const payload: ExtensionsEndpointPayload = {
2324
app: {
2425
title: options.appName,
2526
apiKey: options.apiKey,
@@ -40,6 +41,18 @@ export async function getExtensionsPayloadStoreRawPayload(
4041
store: options.storeFqdn,
4142
extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))),
4243
}
44+
45+
if (options.appAssets) {
46+
const assets: Record<string, {url: string; lastUpdated: number}> = {}
47+
for (const assetKey of Object.keys(options.appAssets)) {
48+
assets[assetKey] = {
49+
url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(),
50+
lastUpdated: Date.now(),
51+
}
52+
}
53+
payload.app.assets = assets
54+
}
55+
return payload
4356
}
4457

4558
export class ExtensionsPayloadStore extends EventEmitter {
@@ -170,6 +183,14 @@ export class ExtensionsPayloadStore extends EventEmitter {
170183
this.emitUpdate([extension.devUUID])
171184
}
172185

186+
updateAppAssetTimestamp(assetKey: string) {
187+
const asset = this.rawPayload.app.assets?.[assetKey]
188+
if (asset) {
189+
asset.lastUpdated = Date.now()
190+
this.emitUpdate([])
191+
}
192+
}
193+
173194
private emitUpdate(extensionIds: string[]) {
174195
this.emit(ExtensionsPayloadStoreEvent.Update, extensionIds)
175196
}

0 commit comments

Comments
 (0)