From e80096cafb909db6d3441ee53f1d614c4485bb16 Mon Sep 17 00:00:00 2001 From: Melissa Luu Date: Wed, 8 Apr 2026 18:42:20 -0400 Subject: [PATCH] Add appAssets to the websocket app payload --- .../cli/models/extensions/specification.ts | 8 ++ .../models/extensions/specifications/admin.ts | 2 +- .../app-events/app-event-watcher-handler.ts | 30 ++++- .../dev/app-events/app-event-watcher.ts | 1 + .../src/cli/services/dev/extension.test.ts | 104 +++++++++++++++--- .../app/src/cli/services/dev/extension.ts | 44 +++++++- .../services/dev/extension/payload/models.ts | 6 + .../dev/extension/payload/store.test.ts | 93 ++++++++++++++++ .../services/dev/extension/payload/store.ts | 24 +++- .../src/cli/services/dev/extension/server.ts | 5 + .../dev/extension/server/middlewares.test.ts | 39 +++++++ .../dev/extension/server/middlewares.ts | 17 +++ .../dev/processes/previewable-extension.ts | 8 +- .../dev/processes/setup-dev-processes.ts | 2 +- .../ui-extensions-server-kit/src/types.ts | 6 + 15 files changed, 368 insertions(+), 21 deletions(-) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index 01b3df8e75b..d0ea764eac0 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -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 } /** @@ -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(/\/\*.*$/, '') +} diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index 2ef549c11a6..9d459adf632 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -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'} }, transformRemoteToLocal: (remoteContent) => { return { 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 5b5e8f8a33a..01beb99b19d 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 @@ -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. @@ -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) } @@ -125,6 +137,22 @@ async function ReloadAppHandler({event, app}: HandlerInput): Promise { 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 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 686cfb17bdb..17f63ac0441 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 @@ -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} diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..a848521abf9 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' @@ -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 () => { @@ -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 () => { @@ -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[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[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[0] + + expect(resolveAppAssets(extensions)).toStrictEqual({}) + }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..a275e365a99 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -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. @@ -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 { + const appAssets: Record = {} + 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 } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -133,17 +161,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..2f43f2c8e5d 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,97 @@ 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('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..930740e986d 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,14 @@ export class ExtensionsPayloadStore extends EventEmitter { this.emitUpdate([extension.devUUID]) } + 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/app/src/cli/services/dev/processes/setup-dev-processes.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts index 9a2a3723740..a7ece06e3dc 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.ts @@ -149,7 +149,7 @@ export async function setupDevProcesses({ }) : undefined, await setupPreviewableExtensionsProcess({ - allExtensions: reloadedApp.allExtensions, + allExtensions: reloadedApp.realExtensions, storeFqdn, storeId, apiKey, 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 + } + } }