diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 2f7b1bea33c..3df81f77f7f 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -294,11 +294,12 @@ export function createContractBasedModuleSpecification, ) { return createExtensionSpecification({ identifier: spec.identifier, - schema: zod.any({}) as unknown as ZodSchemaType, + schema: spec.schema ?? (zod.any({}) as unknown as ZodSchemaType), appModuleFeatures: spec.appModuleFeatures, experience: spec.experience, buildConfig: spec.buildConfig ?? {mode: 'none'}, diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 0aea2ee661a..e4c859fbbbe 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -1,8 +1,20 @@ import {createContractBasedModuleSpecification} from '../specification.js' +import {ZodSchemaType, BaseConfigType} from '../schemas.js' +import {zod} from '@shopify/cli-kit/node/schema' + +const AdminSchema = zod.object({ + admin: zod + .object({ + static_root: zod.string().optional(), + }) + .optional(), +}) const adminSpecificationSpec = createContractBasedModuleSpecification({ identifier: 'admin', uidStrategy: 'single', + experience: 'configuration', + schema: AdminSchema as unknown as ZodSchemaType, transformRemoteToLocal: (remoteContent) => { return { admin: { @@ -24,7 +36,8 @@ const adminSpecificationSpec = createContractBasedModuleSpecification({ name: 'Hosted App Copy Files', type: 'include_assets', config: { - generatesAssetsManifest: true, + // Remove this until we fix the bug related to recreating the manifest during dev + generatesAssetsManifest: false, inclusions: [ { type: 'configKey', diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts index e54a27ba937..fafd5d3ed21 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher-handler.ts @@ -32,6 +32,7 @@ export async function handleWatcherEvents( const affectedExtensions = app.realExtensions.filter((ext) => ext.directory === event.extensionPath) const newEvent = handlers[event.type]({event, app: appEvent.app, extensions: affectedExtensions, options}) appEvent.extensionEvents.push(...newEvent.extensionEvents) + if (newEvent.appAssetsUpdated) appEvent.appAssetsUpdated = true } return appEvent @@ -61,6 +62,7 @@ const handlers: {[key in WatcherEvent['type']]: Handler} = { file_deleted: FileChangeHandler, file_updated: FileChangeHandler, app_config_deleted: AppConfigDeletedHandler, + app_asset_updated: AppAssetUpdatedHandler, // These two are processed manually to avoid multiple reloads extension_folder_created: EmptyHandler, extensions_config_updated: EmptyHandler, @@ -91,6 +93,17 @@ function FileChangeHandler({event, app, extensions}: HandlerInput): AppEvent { return {app, extensionEvents: events, startTime: event.startTime, path: event.path} } +/** + * When a file inside an app asset directory (e.g. static_root) is updated: + * Find the owning extension via extensionPath and return an Updated event so it gets rebuilt. + * The rebuild runs the include_assets step which copies the changed files into the bundle. + */ +function AppAssetUpdatedHandler({event, app}: HandlerInput): AppEvent { + const adminExtension = app.realExtensions.find((ext) => ext.specification.identifier === 'admin') + const events: ExtensionEvent[] = adminExtension ? [{type: EventType.Updated, extension: adminExtension}] : [] + return {app, extensionEvents: events, startTime: event.startTime, path: event.path, appAssetsUpdated: true} +} + /** * When an event doesn't require any action, return the same app and an empty event. */ diff --git a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts index 5a760de6332..2436bfc9448 100644 --- a/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/app-event-watcher.ts @@ -82,6 +82,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} diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts index ca479009e0a..aed4668a773 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.test.ts @@ -263,15 +263,15 @@ describe('file-watcher events', () => { // Then expect(watchSpy).toHaveBeenCalledWith([joinPath(dir, '/shopify.app.toml'), joinPath(dir, '/extensions')], { - ignored: [ + ignored: expect.arrayContaining([ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', - ], + expect.any(Function), + ]), ignoreInitial: true, persistent: true, }) diff --git a/packages/app/src/cli/services/dev/app-events/file-watcher.ts b/packages/app/src/cli/services/dev/app-events/file-watcher.ts index fae4b59f190..8d273bf3b6e 100644 --- a/packages/app/src/cli/services/dev/app-events/file-watcher.ts +++ b/packages/app/src/cli/services/dev/app-events/file-watcher.ts @@ -1,6 +1,7 @@ /* eslint-disable no-case-declarations */ import {AppLinkedInterface} from '../../../models/app/app.js' import {configurationFileNames} from '../../../constants.js' +import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {dirname, joinPath, normalizePath, relativePath} from '@shopify/cli-kit/node/path' import {FSWatcher} from 'chokidar' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -9,6 +10,7 @@ import {startHRTime, StartTime} from '@shopify/cli-kit/node/hrtime' import {fileExistsSync, matchGlob, mkdir, readFileSync} from '@shopify/cli-kit/node/fs' import {debounce} from '@shopify/cli-kit/common/function' import ignore from 'ignore' +import {getPathValue} from '@shopify/cli-kit/common/object' import {Writable} from 'stream' const DEFAULT_DEBOUNCE_TIME_IN_MS = 200 @@ -36,6 +38,7 @@ export interface WatcherEvent { | 'file_deleted' | 'extensions_config_updated' | 'app_config_deleted' + | 'app_asset_updated' path: string extensionPath: string startTime: StartTime @@ -58,6 +61,8 @@ export class FileWatcher { private readonly ignored: {[key: string]: ignore.Ignore | undefined} = {} // Map of file paths to the extensions that watch them private readonly extensionWatchedFiles = new Map>() + // Map of asset directory path to the extension directory that owns it + private appAssetToExtensionDir = new Map() constructor( app: AppLinkedInterface, @@ -104,7 +109,9 @@ export class FileWatcher { }), ) + this.appAssetToExtensionDir = this.resolveAppAssetWatchPaths(this.app.realExtensions) const watchPaths = [this.app.configPath, ...fullExtensionDirectories] + Array.from(this.appAssetToExtensionDir.keys()).forEach((key) => watchPaths.push(key)) // Get all watched files from extensions const allWatchedFiles = this.getAllWatchedFiles() @@ -114,15 +121,24 @@ export class FileWatcher { // Create new watcher const {default: chokidar} = await import('chokidar') + const appAssetDirs = [...this.appAssetToExtensionDir.keys()] this.watcher = chokidar.watch(watchPaths, { ignored: [ '**/node_modules/**', '**/.git/**', '**/*.test.*', - '**/dist/**', '**/*.swp', '**/generated/**', '**/.gitignore', + // Ignore files inside dist/ directories, unless the path falls under a watched + // app asset directory (e.g. static_root may point to a dist/ folder). + // Non-dist paths are never ignored here (return false). For dist paths, we only + // allow them through if they are inside one of the app asset directories. + (filePath: string) => { + const normalized = normalizePath(filePath) + if (!normalized.includes('/dist/') && !normalized.endsWith('/dist')) return false + return !appAssetDirs.some((assetDir) => normalized.startsWith(assetDir)) + }, ], persistent: true, ignoreInitial: true, @@ -177,6 +193,36 @@ export class FileWatcher { return Array.from(allFiles) } + /** + * Resolves app asset directories that should be watched. + * Returns a map of absolute asset directory path → owning extension directory. + */ + private resolveAppAssetWatchPaths(allExtensions: ExtensionInstance[]): Map { + const result = new Map() + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') + if (staticRootPath) { + const absolutePath = joinPath(adminExtension.directory, staticRootPath) + result.set(normalizePath(absolutePath), normalizePath(adminExtension.directory)) + } + } + return result + } + + /** + * Checks if a file path is inside any app asset directory. + * Returns the owning extension directory if found, undefined otherwise. + */ + private findAppAssetExtensionDir(filePath: string): string | undefined { + for (const [assetDir, extensionDir] of this.appAssetToExtensionDir) { + if (filePath.startsWith(assetDir)) { + return extensionDir + } + } + return undefined + } + /** * Emits the accumulated events and resets the current events list. * It also logs the number of events emitted and their paths for debugging purposes. @@ -227,7 +273,12 @@ export class FileWatcher { * Explicit watch paths have priority over custom gitignore files */ private shouldIgnoreEvent(event: WatcherEvent) { - if (event.type === 'extension_folder_deleted' || event.type === 'extension_folder_created') return false + if ( + event.type === 'extension_folder_deleted' || + event.type === 'extension_folder_created' || + event.type === 'app_asset_updated' + ) + return false const extension = this.app.realExtensions.find((ext) => ext.directory === event.extensionPath) const watchPaths = extension?.watchedFiles() @@ -258,6 +309,16 @@ export class FileWatcher { const affectedExtensions = this.extensionWatchedFiles.get(normalizedPath) const isUnknownExtension = affectedExtensions === undefined || affectedExtensions.size === 0 + // Check if the file is inside an app asset directory (e.g. static_root) + const appAssetExtensionDir = this.findAppAssetExtensionDir(normalizedPath) + if (appAssetExtensionDir) { + if (event === 'change' || event === 'add' || event === 'unlink') { + this.pushEvent({type: 'app_asset_updated', path, extensionPath: appAssetExtensionDir, startTime}) + } + this.debouncedEmit() + return + } + if (isUnknownExtension && !isExtensionToml && !isConfigAppPath) { // Ignore an event if it's not part of an existing extension // Except if it is a toml file (either app config or extension config) diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..d69838e49cb 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -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' @@ -33,7 +33,10 @@ describe('devUIExtensions()', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({mock: 'payload-store'})) + vi.spyOn(store, 'ExtensionsPayloadStore').mockImplementation(() => ({ + mock: 'payload-store', + updateAppAssets: vi.fn(), + })) vi.spyOn(server, 'setupHTTPServer').mockReturnValue({ mock: 'http-server', close: serverCloseSpy, @@ -65,11 +68,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 () => { @@ -91,12 +96,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 () => { @@ -128,14 +134,83 @@ 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 admin extension exists', () => { + const extensions = [ + {specification: {identifier: 'ui_extension'}, configuration: {}, directory: '/app'}, + ] as unknown as Parameters[0] + + expect(resolveAppAssets(extensions)).toStrictEqual({}) + }) + + test('returns empty object when admin extension has no static_root', () => { + const extensions = [ + {specification: {identifier: 'admin'}, configuration: {admin: {}}, directory: '/app/extensions/admin'}, + ] as unknown as Parameters[0] + + expect(resolveAppAssets(extensions)).toStrictEqual({}) + }) + + test('returns staticRoot mapped to resolved absolute path when static_root is set', () => { + const extensions = [ + { + specification: {identifier: 'admin'}, + configuration: {admin: {static_root: 'public'}}, + directory: '/app/extensions/admin', + }, + ] as unknown as Parameters[0] + + const result = resolveAppAssets(extensions) + + expect(result).toStrictEqual({ + staticRoot: '/app/extensions/admin/public', + }) + }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..98cb1bc8918 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -11,9 +11,15 @@ import {buildCartURLIfNeeded} from './extension/utilities.js' import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {AbortSignal} from '@shopify/cli-kit/node/abort' import {outputDebug} from '@shopify/cli-kit/node/output' +import {joinPath} from '@shopify/cli-kit/node/path' +import {getPathValue} from '@shopify/cli-kit/common/object' 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. @@ -112,6 +118,23 @@ 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 +} + +export function resolveAppAssets(allExtensions: ExtensionInstance[]): Record { + const appAssets: Record = {} + const adminExtension = allExtensions.find((ext) => ext.specification.identifier === 'admin') + if (adminExtension) { + const staticRootPath = getPathValue(adminExtension.configuration, 'admin.static_root') + if (staticRootPath) { + appAssets.staticRoot = joinPath(adminExtension.directory, staticRootPath) + } + } + return appAssets } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -133,17 +156,29 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise 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) => { 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' diff --git a/packages/app/src/cli/services/dev/extension/payload/models.ts b/packages/app/src/cli/services/dev/extension/payload/models.ts index 796f93108bd..9495c31aab6 100644 --- a/packages/app/src/cli/services/dev/extension/payload/models.ts +++ b/packages/app/src/cli/services/dev/extension/payload/models.ts @@ -8,6 +8,12 @@ interface ExtensionsPayloadInterface { url: string mobileUrl: string title: string + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } } appId?: string store: string diff --git a/packages/app/src/cli/services/dev/extension/payload/store.test.ts b/packages/app/src/cli/services/dev/extension/payload/store.test.ts index cd9a5229da8..4be0b5a8adf 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.test.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.test.ts @@ -365,4 +365,143 @@ describe('ExtensionsPayloadStore()', () => { expect(onUpdateSpy).not.toHaveBeenCalled() }) }) + + describe('updateAppAssetTimestamp()', () => { + test('updates lastUpdated for the given asset key and emits update', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('staticRoot') + + // Then + const asset = extensionsPayloadStore.getRawPayload().app.assets?.staticRoot + expect(asset?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(asset?.lastUpdated).toBeGreaterThan(1000) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('does nothing if the asset key does not exist', () => { + // Given + const mockPayload = { + app: {assets: {}}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssetTimestamp('nonExistent') + + // Then + expect(onUpdateSpy).not.toHaveBeenCalled() + }) + }) + + describe('updateAppAssets()', () => { + test('sets app.assets from the provided appAssets map', () => { + // Given + const mockPayload = { + app: {}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets({staticRoot: '/path/to/public'}, 'https://mock.url') + + // Then + const assets = extensionsPayloadStore.getRawPayload().app.assets + expect(assets?.staticRoot?.url).toBe('https://mock.url/extensions/assets/staticRoot/') + expect(assets?.staticRoot?.lastUpdated).toBeGreaterThan(0) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('removes app.assets when appAssets is undefined', () => { + // Given + const mockPayload = { + app: { + assets: { + staticRoot: {url: 'https://mock.url/extensions/assets/staticRoot/', lastUpdated: 1000}, + }, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const extensionsPayloadStore = new ExtensionsPayloadStore(mockPayload, mockOptions) + const onUpdateSpy = vi.fn() + extensionsPayloadStore.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + extensionsPayloadStore.updateAppAssets(undefined, 'https://mock.url') + + // Then + expect(extensionsPayloadStore.getRawPayload().app.assets).toBeUndefined() + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + }) +}) + +describe('getExtensionsPayloadStoreRawPayload() with appAssets', () => { + test('populates app.assets when appAssets option is provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + appAssets: {staticRoot: '/path/to/public'}, + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toStrictEqual({ + staticRoot: { + url: 'https://mock-url.com/extensions/assets/staticRoot/', + lastUpdated: expect.any(Number), + }, + }) + }) + + test('does not set app.assets when appAssets option is not provided', async () => { + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({ + mock: 'extension-payload', + } as unknown as UIExtensionPayload) + + const options = { + apiKey: 'mock-api-key', + appName: 'mock-app-name', + url: 'https://mock-url.com', + websocketURL: 'wss://mock-websocket-url.com', + extensions: [], + storeFqdn: 'mock-store-fqdn.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'mock-bundle-path') + + expect(rawPayload.app.assets).toBeUndefined() + }) }) diff --git a/packages/app/src/cli/services/dev/extension/payload/store.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index ae4919485c1..5b28e11787f 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -9,6 +9,7 @@ import {EventEmitter} from 'events' export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions { websocketURL: string + appAssets?: Record } export enum ExtensionsPayloadStoreEvent { @@ -19,7 +20,7 @@ export async function getExtensionsPayloadStoreRawPayload( options: Omit, bundlePath: string, ): Promise { - return { + const payload: ExtensionsEndpointPayload = { app: { title: options.appName, apiKey: options.apiKey, @@ -40,6 +41,19 @@ export async function getExtensionsPayloadStoreRawPayload( store: options.storeFqdn, extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), } + + if (options.appAssets) { + const assets: Record = {} + for (const assetKey of Object.keys(options.appAssets)) { + assets[assetKey] = { + url: new URL(`/extensions/assets/${assetKey}/`, options.url).toString(), + lastUpdated: Date.now(), + } + } + payload.app.assets = assets + } + + return payload } export class ExtensionsPayloadStore extends EventEmitter { @@ -170,6 +184,30 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + updateAppAssets(appAssets: Record | undefined, url: string) { + if (!appAssets || Object.keys(appAssets).length === 0) { + delete this.rawPayload.app.assets + } else { + const assets: Record = {} + for (const assetKey of Object.keys(appAssets)) { + assets[assetKey] = { + url: new URL(`/extensions/assets/${assetKey}/`, url).toString(), + lastUpdated: Date.now(), + } + } + this.rawPayload.app.assets = assets + } + this.emitUpdate([]) + } + + updateAppAssetTimestamp(assetKey: string) { + const asset = this.rawPayload.app.assets?.[assetKey] + if (asset) { + asset.lastUpdated = Date.now() + this.emitUpdate([]) + } + } + private emitUpdate(extensionIds: string[]) { this.emit(ExtensionsPayloadStoreEvent.Update, extensionIds) } diff --git a/packages/app/src/cli/services/dev/extension/server.ts b/packages/app/src/cli/services/dev/extension/server.ts index 456c8364c61..b7a5bd7adec 100644 --- a/packages/app/src/cli/services/dev/extension/server.ts +++ b/packages/app/src/cli/services/dev/extension/server.ts @@ -2,6 +2,7 @@ import { corsMiddleware, devConsoleAssetsMiddleware, devConsoleIndexMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, getExtensionPointMiddleware, @@ -19,6 +20,7 @@ interface SetupHTTPServerOptions { devOptions: ExtensionsPayloadStoreOptions payloadStore: ExtensionsPayloadStore getExtensions: () => ExtensionInstance[] + getAppAssets?: () => Record | undefined } export function setupHTTPServer(options: SetupHTTPServerOptions) { @@ -28,6 +30,9 @@ export function setupHTTPServer(options: SetupHTTPServerOptions) { httpApp.use(getLogMiddleware(options)) httpApp.use(corsMiddleware) httpApp.use(noCacheMiddleware) + if (options.getAppAssets) { + httpRouter.use('/extensions/assets/:assetKey/**:filePath', getAppAssetsMiddleware(options.getAppAssets)) + } httpRouter.use('/extensions/dev-console', devConsoleIndexMiddleware) httpRouter.use('/extensions/dev-console/assets/**:assetPath', devConsoleAssetsMiddleware) httpRouter.use('/extensions/:extensionId', getExtensionPayloadMiddleware(options)) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts index 69924d80cc9..651c4fc86c5 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.test.ts @@ -1,5 +1,6 @@ import { corsMiddleware, + getAppAssetsMiddleware, getExtensionAssetMiddleware, getExtensionPayloadMiddleware, fileServerMiddleware, @@ -573,3 +574,41 @@ describe('getExtensionPointMiddleware()', () => { expect(h3.sendRedirect).toHaveBeenCalledWith(event, 'http://www.mock.com/redirect/url', 307) }) }) + +describe('getAppAssetsMiddleware()', () => { + test('serves a file from the matching asset directory', async () => { + await inTemporaryDirectory(async (tmpDir: string) => { + const assetDir = joinPath(tmpDir, 'public') + await mkdir(assetDir) + await writeFile(joinPath(assetDir, 'icon.png'), 'png-content') + + const middleware = getAppAssetsMiddleware(() => ({staticRoot: assetDir})) + + const event = getMockEvent({ + params: {assetKey: 'staticRoot', filePath: 'icon.png'}, + }) + + const result = await middleware(event) + + expect(event.node.res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png') + expect(result).toBe('png-content') + }) + }) + + test('returns 404 for an unknown asset key', async () => { + vi.spyOn(utilities, 'sendError').mockImplementation(() => {}) + + const middleware = getAppAssetsMiddleware(() => ({staticRoot: '/some/path'})) + + const event = getMockEvent({ + params: {assetKey: 'unknown', filePath: 'icon.png'}, + }) + + await middleware(event) + + expect(utilities.sendError).toHaveBeenCalledWith(event, { + statusCode: 404, + statusMessage: 'No app assets configured for key: unknown', + }) + }) +}) diff --git a/packages/app/src/cli/services/dev/extension/server/middlewares.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index ed17a0f474d..7dd0993cac1 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -134,6 +134,23 @@ export const devConsoleAssetsMiddleware = defineEventHandler(async (event) => { }) }) +export function getAppAssetsMiddleware(getAppAssets: () => Record | undefined) { + return defineEventHandler(async (event) => { + const {assetKey = '', filePath = ''} = getRouterParams(event) + + const appAssets = getAppAssets() + const directory = appAssets?.[assetKey] + + if (!directory) { + return sendError(event, {statusCode: 404, statusMessage: `No app assets configured for key: ${assetKey}`}) + } + + return fileServerMiddleware(event, { + filePath: joinPath(directory, filePath), + }) + }) +} + export function getLogMiddleware({devOptions}: GetExtensionsMiddlewareOptions) { return defineEventHandler((event) => { outputDebug(`UI extensions server received a ${event.method} request to URL ${event.path}`, devOptions.stdout) diff --git a/packages/app/src/cli/services/dev/processes/previewable-extension.ts b/packages/app/src/cli/services/dev/processes/previewable-extension.ts index 387d97ebeed..c5fc8317c45 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -1,5 +1,5 @@ import {BaseProcess, DevProcessFunction} from './types.js' -import {devUIExtensions} from '../extension.js' +import {devUIExtensions, resolveAppAssets} from '../extension.js' import {ExtensionInstance} from '../../../models/extensions/extension-instance.js' import {buildCartURLIfNeeded} from '../extension/utilities.js' import {AppEventWatcher} from '../app-events/app-event-watcher.js' @@ -24,6 +24,7 @@ interface PreviewableExtensionOptions { grantedScopes: string[] previewableExtensions: ExtensionInstance[] appWatcher: AppEventWatcher + appAssets?: Record } export interface PreviewableExtensionProcess extends BaseProcess { @@ -47,6 +48,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -68,6 +70,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction 0 ? appAssets : undefined, ...options, }, } diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index e2ef8e8eb61..43e86fcb2ad 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -182,4 +182,10 @@ export interface App { } supportEmail?: string supportLocales?: string[] + assets?: { + [key: string]: { + url: string + lastUpdated: number + } + } }