Skip to content

Commit e80096c

Browse files
committed
Add appAssets to the websocket app payload
1 parent d33c06d commit e80096c

File tree

15 files changed

+368
-21
lines changed

15 files changed

+368
-21
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ export interface DevSessionWatchConfig {
150150
paths: string[]
151151
/** Additional glob patterns to ignore (on top of the default ignore list) */
152152
ignore?: string[]
153+
/** If set, files under these paths are served as app assets under this key (e.g. 'static_root') */
154+
assetKey?: string
153155
}
154156

155157
/**
@@ -446,3 +448,9 @@ export function configWithoutFirstClassFields(config: JsonMapType): JsonMapType
446448
const {type, handle, uid, path, extensions, ...configWithoutFirstClassFields} = config
447449
return configWithoutFirstClassFields
448450
}
451+
452+
// Extracts the base directory from a glob pattern by stripping the glob suffix.
453+
// e.g. "/app/public/" + glob -> "/app/public"
454+
export function globPatternBaseDir(pattern: string): string {
455+
return pattern.replace(/\/\*.*$/, '')
456+
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const adminSpecificationSpec = createExtensionSpecification({
2424
if (!staticRoot) return {paths: []}
2525

2626
const path = joinPath(extension.directory, staticRoot, '**/*')
27-
return {paths: [path], ignore: []}
27+
return {paths: [path], ignore: [], assetKey: 'staticRoot'}
2828
},
2929
transformRemoteToLocal: (remoteContent) => {
3030
return {

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ import {appDiff} from './app-diffing.js'
44
import {AppLinkedInterface} from '../../../models/app/app.js'
55
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
66
import {reloadApp} from '../../../models/app/loader.js'
7+
import {globPatternBaseDir} from '../../../models/extensions/specification.js'
78
import {AbortError} from '@shopify/cli-kit/node/error'
89
import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime'
910
import {outputDebug} from '@shopify/cli-kit/node/output'
11+
import {normalizePath} from '@shopify/cli-kit/node/path'
1012

1113
/**
1214
* Transforms an array of WatcherEvents from the file system into a processed AppEvent.
@@ -32,7 +34,17 @@ export async function handleWatcherEvents(
3234
const affectedExtensions = event.extensionHandle
3335
? app.realExtensions.filter((ext) => ext.handle === event.extensionHandle)
3436
: app.realExtensions.filter((ext) => ext.directory === event.extensionPath)
35-
const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options})
37+
38+
// Check if this is an app asset change (e.g. file inside admin static_root).
39+
// If so, mark assetsUpdated and skip the normal rebuild for those extensions.
40+
const assetExtensions = affectedExtensions.filter((ext) => isAppAssetChange(ext, event.path))
41+
const nonAssetExtensions = affectedExtensions.filter((ext) => !isAppAssetChange(ext, event.path))
42+
43+
if (assetExtensions.length > 0) {
44+
appEvent.appAssetsUpdated = true
45+
}
46+
47+
const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: nonAssetExtensions, options})
3648
appEvent.extensionEvents.push(...newEvent.extensionEvents)
3749
}
3850

@@ -125,6 +137,22 @@ async function ReloadAppHandler({event, app}: HandlerInput): Promise<AppEvent> {
125137
return {app: newApp, extensionEvents, startTime: event.startTime, path: event.path, appWasReloaded: true}
126138
}
127139

140+
/**
141+
* Checks whether a file change is inside an app asset directory (e.g. admin static_root).
142+
* App asset changes should only update asset timestamps, not trigger a full extension rebuild.
143+
*/
144+
function isAppAssetChange(extension: ExtensionInstance, filePath: string): boolean {
145+
if (!extension.isAppConfigExtension) return false
146+
const watchConfig = extension.devSessionWatchConfig
147+
if (!watchConfig || watchConfig.paths.length === 0 || !watchConfig.assetKey) return false
148+
149+
const normalizedFile = normalizePath(filePath)
150+
return watchConfig.paths.some((pattern) => {
151+
const baseDir = normalizePath(globPatternBaseDir(pattern))
152+
return normalizedFile.startsWith(baseDir)
153+
})
154+
}
155+
128156
/*
129157
* Reload the app and returns it
130158
* Prints the time to reload the app to stdout

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export interface AppEvent {
8383
path: string
8484
startTime: [number, number]
8585
appWasReloaded?: boolean
86+
appAssetsUpdated?: boolean
8687
}
8788

8889
type ExtensionBuildResult = {status: 'ok'; uid: string} | {status: 'error'; error: string; file?: string; uid: string}

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

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as store from './extension/payload/store.js'
22
import * as server from './extension/server.js'
33
import * as websocket from './extension/websocket.js'
4-
import {devUIExtensions, ExtensionDevOptions} from './extension.js'
4+
import {devUIExtensions, ExtensionDevOptions, resolveAppAssets} from './extension.js'
55
import {ExtensionsEndpointPayload} from './extension/payload/models.js'
66
import {WebsocketConnection} from './extension/websocket/models.js'
77
import {AppEventWatcher} from './app-events/app-event-watcher.js'
@@ -65,11 +65,13 @@ describe('devUIExtensions()', () => {
6565
await devUIExtensions(options)
6666

6767
// THEN
68-
expect(server.setupHTTPServer).toHaveBeenCalledWith({
69-
devOptions: {...options, websocketURL: 'wss://mock.url/extensions'},
70-
payloadStore: {mock: 'payload-store'},
71-
getExtensions: expect.any(Function),
72-
})
68+
expect(server.setupHTTPServer).toHaveBeenCalledWith(
69+
expect.objectContaining({
70+
devOptions: expect.objectContaining({websocketURL: 'wss://mock.url/extensions'}),
71+
payloadStore: expect.objectContaining({mock: 'payload-store'}),
72+
getExtensions: expect.any(Function),
73+
}),
74+
)
7375
})
7476

7577
test('initializes the HTTP server with a getExtensions function that returns the extensions from the provided options', async () => {
@@ -91,12 +93,13 @@ describe('devUIExtensions()', () => {
9193
await devUIExtensions(options)
9294

9395
// THEN
94-
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({
95-
...options,
96-
httpServer: expect.objectContaining({mock: 'http-server'}),
97-
payloadStore: {mock: 'payload-store'},
98-
websocketURL: 'wss://mock.url/extensions',
99-
})
96+
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith(
97+
expect.objectContaining({
98+
httpServer: expect.objectContaining({mock: 'http-server'}),
99+
payloadStore: expect.objectContaining({mock: 'payload-store'}),
100+
websocketURL: 'wss://mock.url/extensions',
101+
}),
102+
)
100103
})
101104

102105
test('closes the http server, websocket and bundler when the process aborts', async () => {
@@ -128,14 +131,87 @@ describe('devUIExtensions()', () => {
128131
const {getExtensions} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0]
129132
expect(getExtensions()).toStrictEqual(options.extensions)
130133

131-
const newUIExtension = {type: 'ui_extension', devUUID: 'BAR', isPreviewable: true}
134+
const newUIExtension = {
135+
type: 'ui_extension',
136+
devUUID: 'BAR',
137+
isPreviewable: true,
138+
specification: {identifier: 'ui_extension'},
139+
}
132140
const newApp = {
133141
...app,
134-
allExtensions: [newUIExtension, {type: 'function_extension', devUUID: 'FUNCTION', isPreviewable: false}],
142+
allExtensions: [
143+
newUIExtension,
144+
{
145+
type: 'function_extension',
146+
devUUID: 'FUNCTION',
147+
isPreviewable: false,
148+
specification: {identifier: 'function'},
149+
},
150+
],
135151
}
136152
options.appWatcher.emit('all', {app: newApp, appWasReloaded: true, extensionEvents: []})
137153

138154
// THEN
139155
expect(getExtensions()).toStrictEqual([newUIExtension])
140156
})
157+
158+
test('passes getAppAssets callback to the HTTP server when appAssets provided', async () => {
159+
// GIVEN
160+
spyOnEverything()
161+
const optionsWithAssets = {
162+
...options,
163+
appAssets: {staticRoot: '/absolute/path/to/public'},
164+
} as unknown as ExtensionDevOptions
165+
166+
// WHEN
167+
await devUIExtensions(optionsWithAssets)
168+
169+
// THEN
170+
expect(server.setupHTTPServer).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
getAppAssets: expect.any(Function),
173+
}),
174+
)
175+
176+
const {getAppAssets} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0]
177+
expect(getAppAssets!()).toStrictEqual({staticRoot: '/absolute/path/to/public'})
178+
})
179+
})
180+
181+
describe('resolveAppAssets()', () => {
182+
test('returns empty object when no config extensions have watch paths with assetKey', () => {
183+
const extensions = [
184+
{isAppConfigExtension: false, devSessionWatchConfig: undefined},
185+
{isAppConfigExtension: true, devSessionWatchConfig: {paths: []}},
186+
{isAppConfigExtension: true, devSessionWatchConfig: {paths: ['/app/some/**/*']}},
187+
] as unknown as Parameters<typeof resolveAppAssets>[0]
188+
189+
expect(resolveAppAssets(extensions)).toStrictEqual({})
190+
})
191+
192+
test('returns asset entry keyed by assetKey for config extensions with watch paths', () => {
193+
const extensions = [
194+
{
195+
isAppConfigExtension: true,
196+
handle: 'admin',
197+
devSessionWatchConfig: {paths: ['/app/public/**/*'], assetKey: 'staticRoot'},
198+
},
199+
] as unknown as Parameters<typeof resolveAppAssets>[0]
200+
201+
expect(resolveAppAssets(extensions)).toStrictEqual({
202+
staticRoot: '/app/public',
203+
})
204+
})
205+
206+
test('ignores non-config extensions even if they have watch paths with assetKey', () => {
207+
const extensions = [
208+
{
209+
isAppConfigExtension: false,
210+
handle: 'ui_ext',
211+
devSessionWatchConfig: {paths: ['/app/extensions/ui/**/*'], assetKey: 'assets'},
212+
},
213+
] as unknown as Parameters<typeof resolveAppAssets>[0]
214+
215+
expect(resolveAppAssets(extensions)).toStrictEqual({})
216+
})
141217
})

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

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ import {
99
import {AppEvent, AppEventWatcher, EventType} from './app-events/app-event-watcher.js'
1010
import {buildCartURLIfNeeded} from './extension/utilities.js'
1111
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
12+
import {globPatternBaseDir} from '../../models/extensions/specification.js'
1213
import {AbortSignal} from '@shopify/cli-kit/node/abort'
1314
import {outputDebug} from '@shopify/cli-kit/node/output'
15+
import {normalizePath} from '@shopify/cli-kit/node/path'
1416
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
1517
import {Writable} from 'stream'
1618

19+
interface AppAssets {
20+
[key: string]: string
21+
}
22+
1723
export interface ExtensionDevOptions {
1824
/**
1925
* Standard output stream to send the output through.
@@ -112,6 +118,28 @@ export interface ExtensionDevOptions {
112118
* The app watcher that emits events when the app is updated
113119
*/
114120
appWatcher: AppEventWatcher
121+
122+
/**
123+
* Map of asset key to absolute directory path for app-level assets (e.g., admin static_root)
124+
*/
125+
appAssets?: AppAssets
126+
}
127+
128+
/**
129+
* Derives app-level asset directories from config extensions that define devSessionWatchConfig
130+
* with an assetKey. Returns a map of asset key (e.g. 'static_root') to absolute directory path.
131+
*/
132+
export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record<string, string> {
133+
const appAssets: Record<string, string> = {}
134+
for (const ext of allExtensions) {
135+
if (!ext.isAppConfigExtension) continue
136+
const watchConfig = ext.devSessionWatchConfig
137+
if (!watchConfig || watchConfig.paths.length === 0 || !watchConfig.assetKey) continue
138+
139+
const baseDir = normalizePath(globPatternBaseDir(watchConfig.paths[0]!))
140+
appAssets[watchConfig.assetKey] = baseDir
141+
}
142+
return appAssets
115143
}
116144

117145
export async function devUIExtensions(options: ExtensionDevOptions): Promise<void> {
@@ -133,17 +161,29 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise<voi
133161
}
134162

135163
outputDebug(`Setting up the UI extensions HTTP server...`, payloadOptions.stdout)
136-
const httpServer = setupHTTPServer({devOptions: payloadOptions, payloadStore, getExtensions})
164+
const getAppAssets = () => payloadOptions.appAssets
165+
const httpServer = setupHTTPServer({
166+
devOptions: payloadOptions,
167+
payloadStore,
168+
getExtensions,
169+
getAppAssets,
170+
})
137171

138172
outputDebug(`Setting up the UI extensions Websocket server...`, payloadOptions.stdout)
139173
const websocketConnection = setupWebsocketConnection({...payloadOptions, httpServer, payloadStore})
140174
outputDebug(`Setting up the UI extensions bundler and file watching...`, payloadOptions.stdout)
141175

142-
const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => {
176+
const eventHandler = async ({appWasReloaded, app, extensionEvents, appAssetsUpdated}: AppEvent) => {
143177
if (appWasReloaded) {
144178
extensions = app.allExtensions.filter((ext) => ext.isPreviewable)
145179
}
146180

181+
if (appAssetsUpdated && payloadOptions.appAssets) {
182+
for (const assetKey of Object.keys(payloadOptions.appAssets)) {
183+
payloadStore.updateAppAssetTimestamp(assetKey)
184+
}
185+
}
186+
147187
for (const event of extensionEvents) {
148188
if (!event.extension.isPreviewable) continue
149189
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

0 commit comments

Comments
 (0)