Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/app/src/cli/models/extensions/specification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ export interface DevSessionWatchConfig {
paths: string[]
/** Additional glob patterns to ignore (on top of the default ignore list) */
ignore?: string[]
/** If set, files under these paths are served as app assets under this key (e.g. 'static_root') */
assetKey?: string
}

/**
Expand Down Expand Up @@ -446,3 +448,9 @@ export function configWithoutFirstClassFields(config: JsonMapType): JsonMapType
const {type, handle, uid, path, extensions, ...configWithoutFirstClassFields} = config
return configWithoutFirstClassFields
}

// Extracts the base directory from a glob pattern by stripping the glob suffix.
// e.g. "/app/public/" + glob -> "/app/public"
export function globPatternBaseDir(pattern: string): string {
return pattern.replace(/\/\*.*$/, '')
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const adminSpecificationSpec = createExtensionSpecification({
if (!staticRoot) return {paths: []}

const path = joinPath(extension.directory, staticRoot, '**/*')
return {paths: [path], ignore: []}
return {paths: [path], ignore: [], assetKey: 'staticRoot'}
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added so that we can use this config in the resolveAppAssets()

We don't have to do this if we're okay with directly using App object to find the appAsset directory

},
transformRemoteToLocal: (remoteContent) => {
return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import {appDiff} from './app-diffing.js'
import {AppLinkedInterface} from '../../../models/app/app.js'
import {ExtensionInstance} from '../../../models/extensions/extension-instance.js'
import {reloadApp} from '../../../models/app/loader.js'
import {globPatternBaseDir} from '../../../models/extensions/specification.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {endHRTimeInMs, startHRTime} from '@shopify/cli-kit/node/hrtime'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {normalizePath} from '@shopify/cli-kit/node/path'

/**
* Transforms an array of WatcherEvents from the file system into a processed AppEvent.
Expand All @@ -32,7 +34,17 @@ export async function handleWatcherEvents(
const affectedExtensions = event.extensionHandle
? app.realExtensions.filter((ext) => ext.handle === event.extensionHandle)
: app.realExtensions.filter((ext) => ext.directory === event.extensionPath)
const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options})

// Check if this is an app asset change (e.g. file inside admin static_root).
// If so, mark assetsUpdated and skip the normal rebuild for those extensions.
const assetExtensions = affectedExtensions.filter((ext) => isAppAssetChange(ext, event.path))
const nonAssetExtensions = affectedExtensions.filter((ext) => !isAppAssetChange(ext, event.path))

if (assetExtensions.length > 0) {
appEvent.appAssetsUpdated = true
}

const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: nonAssetExtensions, options})
appEvent.extensionEvents.push(...newEvent.extensionEvents)
}

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

/**
* Checks whether a file change is inside an app asset directory (e.g. admin static_root).
* App asset changes should only update asset timestamps, not trigger a full extension rebuild.
*/
function isAppAssetChange(extension: ExtensionInstance, filePath: string): boolean {
if (!extension.isAppConfigExtension) return false
const watchConfig = extension.devSessionWatchConfig
if (!watchConfig || watchConfig.paths.length === 0 || !watchConfig.assetKey) return false

const normalizedFile = normalizePath(filePath)
return watchConfig.paths.some((pattern) => {
const baseDir = normalizePath(globPatternBaseDir(pattern))
return normalizedFile.startsWith(baseDir)
})
}

/*
* Reload the app and returns it
* Prints the time to reload the app to stdout
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export interface AppEvent {
path: string
startTime: [number, number]
appWasReloaded?: boolean
appAssetsUpdated?: boolean
}

type ExtensionBuildResult = {status: 'ok'; uid: string} | {status: 'error'; error: string; file?: string; uid: string}
Expand Down
104 changes: 90 additions & 14 deletions packages/app/src/cli/services/dev/extension.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as store from './extension/payload/store.js'
import * as server from './extension/server.js'
import * as websocket from './extension/websocket.js'
import {devUIExtensions, ExtensionDevOptions} from './extension.js'
import {devUIExtensions, ExtensionDevOptions, resolveAppAssets} from './extension.js'
import {ExtensionsEndpointPayload} from './extension/payload/models.js'
import {WebsocketConnection} from './extension/websocket/models.js'
import {AppEventWatcher} from './app-events/app-event-watcher.js'
Expand Down Expand Up @@ -65,11 +65,13 @@ describe('devUIExtensions()', () => {
await devUIExtensions(options)

// THEN
expect(server.setupHTTPServer).toHaveBeenCalledWith({
devOptions: {...options, websocketURL: 'wss://mock.url/extensions'},
payloadStore: {mock: 'payload-store'},
getExtensions: expect.any(Function),
})
expect(server.setupHTTPServer).toHaveBeenCalledWith(
expect.objectContaining({
devOptions: expect.objectContaining({websocketURL: 'wss://mock.url/extensions'}),
payloadStore: expect.objectContaining({mock: 'payload-store'}),
getExtensions: expect.any(Function),
}),
)
})

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

// THEN
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({
...options,
httpServer: expect.objectContaining({mock: 'http-server'}),
payloadStore: {mock: 'payload-store'},
websocketURL: 'wss://mock.url/extensions',
})
expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith(
expect.objectContaining({
httpServer: expect.objectContaining({mock: 'http-server'}),
payloadStore: expect.objectContaining({mock: 'payload-store'}),
websocketURL: 'wss://mock.url/extensions',
}),
)
})

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

const newUIExtension = {type: 'ui_extension', devUUID: 'BAR', isPreviewable: true}
const newUIExtension = {
type: 'ui_extension',
devUUID: 'BAR',
isPreviewable: true,
specification: {identifier: 'ui_extension'},
}
const newApp = {
...app,
allExtensions: [newUIExtension, {type: 'function_extension', devUUID: 'FUNCTION', isPreviewable: false}],
allExtensions: [
newUIExtension,
{
type: 'function_extension',
devUUID: 'FUNCTION',
isPreviewable: false,
specification: {identifier: 'function'},
},
],
}
options.appWatcher.emit('all', {app: newApp, appWasReloaded: true, extensionEvents: []})

// THEN
expect(getExtensions()).toStrictEqual([newUIExtension])
})

test('passes getAppAssets callback to the HTTP server when appAssets provided', async () => {
// GIVEN
spyOnEverything()
const optionsWithAssets = {
...options,
appAssets: {staticRoot: '/absolute/path/to/public'},
} as unknown as ExtensionDevOptions

// WHEN
await devUIExtensions(optionsWithAssets)

// THEN
expect(server.setupHTTPServer).toHaveBeenCalledWith(
expect.objectContaining({
getAppAssets: expect.any(Function),
}),
)

const {getAppAssets} = vi.mocked(server.setupHTTPServer).mock.calls[0]![0]
expect(getAppAssets!()).toStrictEqual({staticRoot: '/absolute/path/to/public'})
})
})

describe('resolveAppAssets()', () => {
test('returns empty object when no config extensions have watch paths with assetKey', () => {
const extensions = [
{isAppConfigExtension: false, devSessionWatchConfig: undefined},
{isAppConfigExtension: true, devSessionWatchConfig: {paths: []}},
{isAppConfigExtension: true, devSessionWatchConfig: {paths: ['/app/some/**/*']}},
] as unknown as Parameters<typeof resolveAppAssets>[0]

expect(resolveAppAssets(extensions)).toStrictEqual({})
})

test('returns asset entry keyed by assetKey for config extensions with watch paths', () => {
const extensions = [
{
isAppConfigExtension: true,
handle: 'admin',
devSessionWatchConfig: {paths: ['/app/public/**/*'], assetKey: 'staticRoot'},
},
] as unknown as Parameters<typeof resolveAppAssets>[0]

expect(resolveAppAssets(extensions)).toStrictEqual({
staticRoot: '/app/public',
})
})

test('ignores non-config extensions even if they have watch paths with assetKey', () => {
const extensions = [
{
isAppConfigExtension: false,
handle: 'ui_ext',
devSessionWatchConfig: {paths: ['/app/extensions/ui/**/*'], assetKey: 'assets'},
},
] as unknown as Parameters<typeof resolveAppAssets>[0]

expect(resolveAppAssets(extensions)).toStrictEqual({})
})
})
44 changes: 42 additions & 2 deletions packages/app/src/cli/services/dev/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@ import {
import {AppEvent, AppEventWatcher, EventType} from './app-events/app-event-watcher.js'
import {buildCartURLIfNeeded} from './extension/utilities.js'
import {ExtensionInstance} from '../../models/extensions/extension-instance.js'
import {globPatternBaseDir} from '../../models/extensions/specification.js'
import {AbortSignal} from '@shopify/cli-kit/node/abort'
import {outputDebug} from '@shopify/cli-kit/node/output'
import {normalizePath} from '@shopify/cli-kit/node/path'
import {DotEnvFile} from '@shopify/cli-kit/node/dot-env'
import {Writable} from 'stream'

interface AppAssets {
[key: string]: string
}

export interface ExtensionDevOptions {
/**
* Standard output stream to send the output through.
Expand Down Expand Up @@ -112,6 +118,28 @@ export interface ExtensionDevOptions {
* The app watcher that emits events when the app is updated
*/
appWatcher: AppEventWatcher

/**
* Map of asset key to absolute directory path for app-level assets (e.g., admin static_root)
*/
appAssets?: AppAssets
}

/**
* Derives app-level asset directories from config extensions that define devSessionWatchConfig
* with an assetKey. Returns a map of asset key (e.g. 'static_root') to absolute directory path.
*/
export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record<string, string> {
Copy link
Copy Markdown
Author

@melissaluu melissaluu Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can switch this back to using the App object to find the appAssets directory path (i.e. app.configuration.assets.static_root) but this implementation removes hardcoding and specifying the path directly and we grab it from the devSessionWatchConfig instead

const appAssets: Record<string, string> = {}
for (const ext of allExtensions) {
if (!ext.isAppConfigExtension) continue
const watchConfig = ext.devSessionWatchConfig
if (!watchConfig || watchConfig.paths.length === 0 || !watchConfig.assetKey) continue

const baseDir = normalizePath(globPatternBaseDir(watchConfig.paths[0]!))
appAssets[watchConfig.assetKey] = baseDir
}
return appAssets
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solution is very hacky. We are adding the assetKey as a way of generization, but in the way we are polluting the devSessionWatchConfig with a property that doesn't have anything to do with watching, i'll think on a better way to extract this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This solution is very hacky. We are adding the assetKey as a way of generization, but in the way we are polluting the devSessionWatchConfig with a property that doesn't have anything to do with watching, i'll think on a better way to extract this.

}

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

outputDebug(`Setting up the UI extensions HTTP server...`, payloadOptions.stdout)
const httpServer = setupHTTPServer({devOptions: payloadOptions, payloadStore, getExtensions})
const getAppAssets = () => payloadOptions.appAssets
const httpServer = setupHTTPServer({
devOptions: payloadOptions,
payloadStore,
getExtensions,
getAppAssets,
})

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

const eventHandler = async ({appWasReloaded, app, extensionEvents}: AppEvent) => {
const eventHandler = async ({appWasReloaded, app, extensionEvents, appAssetsUpdated}: AppEvent) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you also have the app here from the AppEvent, this one is better because is the updated app (in case anything changed)

if (appWasReloaded) {
extensions = app.allExtensions.filter((ext) => ext.isPreviewable)
}

if (appAssetsUpdated && payloadOptions.appAssets) {
for (const assetKey of Object.keys(payloadOptions.appAssets)) {
payloadStore.updateAppAssetTimestamp(assetKey)
}
}

for (const event of extensionEvents) {
if (!event.extension.isPreviewable) continue
const status = event.buildResult?.status === 'ok' ? 'success' : 'error'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface {
url: string
mobileUrl: string
title: string
assets?: {
[key: string]: {
url: string
lastUpdated: number
}
}
}
appId?: string
store: string
Expand Down
Loading
Loading