From 8f59f38200ccbd0ed603af300bd33c53d66adb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isaac=20Rold=C3=A1n?= Date: Thu, 9 Apr 2026 12:10:35 +0200 Subject: [PATCH 1/2] notify extension dev server of app assets updates --- packages/app/src/cli/models/app/app.ts | 10 ++++++++ .../models/extensions/extension-instance.ts | 2 +- .../cli/models/extensions/specification.ts | 13 +++++++++++ .../models/extensions/specifications/admin.ts | 5 ++++ .../build/steps/include-assets-step.test.ts | 13 ++++++----- .../steps/include-assets/generate-manifest.ts | 10 ++------ .../services/dev/app-events/file-watcher.ts | 2 +- .../app/src/cli/services/dev/extension.ts | 22 +++++++++++++++++- .../services/dev/extension/payload/models.ts | 6 +++++ .../services/dev/extension/payload/store.ts | 23 ++++++++++++++++++- .../src/cli/services/dev/extension/server.ts | 5 ++++ .../dev/extension/server/middlewares.ts | 14 +++++++++++ .../dev/processes/dev-session/dev-session.ts | 4 ++++ .../dev/processes/previewable-extension.ts | 3 +++ .../dev/processes/setup-dev-processes.ts | 3 ++- .../ui-extensions-server-kit/src/types.ts | 6 +++++ 16 files changed, 122 insertions(+), 19 deletions(-) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index 5c6a5eab81c..c557afbc62b 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -229,6 +229,7 @@ export interface AppInterface< realExtensions: ExtensionInstance[] nonConfigExtensions: ExtensionInstance[] draftableExtensions: ExtensionInstance[] + appAssetsConfigs: Record | undefined errors: AppErrors hiddenConfig: AppHiddenConfig includeConfigOnDeploy: boolean | undefined @@ -334,6 +335,15 @@ export class App< ) } + get appAssetsConfigs(): Record | undefined { + if (!this.realExtensions.some((ext) => ext.specification.appAssetsConfig)) return undefined + return this.realExtensions.reduce>((acc, ext) => { + const config = ext.specification.appAssetsConfig?.(ext.configuration) + if (config) acc[config.assetsKey] = joinPath(this.directory, config.assetsDir) + return acc + }, {}) + } + setDevApplicationURLs(devApplicationURLs: ApplicationURLs) { this.patchAppConfiguration(devApplicationURLs) this.realExtensions.forEach((ext) => ext.patchWithAppDevURLs(devApplicationURLs)) diff --git a/packages/app/src/cli/models/extensions/extension-instance.ts b/packages/app/src/cli/models/extensions/extension-instance.ts index 6da9a76ebf7..1a495d3b022 100644 --- a/packages/app/src/cli/models/extensions/extension-instance.ts +++ b/packages/app/src/cli/models/extensions/extension-instance.ts @@ -285,7 +285,7 @@ export class ExtensionInstance) => DevSessionWatchConfig | undefined + + /** + * App assets configuration for this extension. + * Return undefined if this extension doesn't serve app assets. + */ + appAssetsConfig?: (config: TConfiguration) => AppAssetsConfig | undefined +} + +export interface AppAssetsConfig { + /** The config key that points to the assets directory (e.g. 'admin.static_root') */ + assetsKey: string + /** The assets directory relative to the extension directory */ + assetsDir: string } export interface DevSessionWatchConfig { diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index e8b132e233b..e166eb5d35e 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -62,6 +62,11 @@ const adminSpecificationSpec = createExtensionSpecification({ }, ], appModuleFeatures: () => [], + appAssetsConfig: (config) => { + const dir = config.admin?.static_root + if (!dir) return undefined + return {assetsKey: 'staticRoot', assetsDir: dir} + }, }) export default adminSpecificationSpec diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index 6429801a725..6daf807c33a 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -1044,7 +1044,7 @@ describe('executeIncludeAssetsStep', () => { ) }) - test('throws when manifest.json already exists in the output directory', async () => { + test('overwrites manifest.json when it already exists in the output directory', async () => { // Given — a prior inclusion already copied a manifest.json to the output dir const contextWithConfig = { ...mockContext, @@ -1056,8 +1056,7 @@ describe('executeIncludeAssetsStep', () => { } as unknown as ExtensionInstance, } - // Source files exist; output manifest.json already exists (simulating conflict); - // candidate output paths for tools.json are free so copyConfigKeyEntry succeeds. + // Source files exist; output manifest.json already exists vi.mocked(fs.fileExists).mockImplementation(async (path) => { const pathStr = String(path) return pathStr === '/test/output/manifest.json' || pathStr.startsWith('/test/extension/') @@ -1081,9 +1080,11 @@ describe('executeIncludeAssetsStep', () => { }, } - // When / Then — throws rather than silently overwriting - await expect(executeIncludeAssetsStep(step, contextWithConfig)).rejects.toThrow( - `Can't write manifest.json: a file already exists at '/test/output/manifest.json'`, + // When / Then — overwrites existing manifest.json + await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow() + expect(fs.writeFile).toHaveBeenCalledWith( + '/test/output/manifest.json', + expect.any(String), ) }) diff --git a/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts b/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts index c39cc8b9529..20cdc405ead 100644 --- a/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts +++ b/packages/app/src/cli/services/build/steps/include-assets/generate-manifest.ts @@ -1,6 +1,6 @@ import {getNestedValue, tokenizePath} from './copy-config-key-entry.js' import {joinPath} from '@shopify/cli-kit/node/path' -import {fileExists, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {mkdir, writeFile} from '@shopify/cli-kit/node/fs' import {outputDebug} from '@shopify/cli-kit/node/output' import type {BuildContext} from '../../client-steps.js' @@ -20,7 +20,7 @@ interface ConfigKeyManifestEntry { * 3. Build root-level entries. * 4. Build grouped entries (anchor/groupBy logic) with path strings resolved * via `resolveManifestPaths` using the copy-tracked `pathMap`. - * 5. Write `outputDir/manifest.json`; throw if the file already exists. + * 5. Write `outputDir/manifest.json`, overwriting any existing file. * * @param pathMap - Map from raw config path values to their output-relative * paths, as recorded during the copy phase by `copyConfigKeyEntry`. @@ -113,12 +113,6 @@ export async function generateManifestFile( } const manifestPath = joinPath(outputDir, 'manifest.json') - if (await fileExists(manifestPath)) { - throw new Error( - `Can't write manifest.json: a file already exists at '${manifestPath}'. ` + - `Remove or rename the conflicting inclusion to avoid overwriting the generated manifest.`, - ) - } await mkdir(outputDir) await writeFile(manifestPath, JSON.stringify(manifest, null, 2)) outputDebug(`Generated manifest.json in ${outputDir}\n`, options.stdout) 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 ca55158bae2..6b022e82cf2 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 @@ -150,7 +150,7 @@ export class FileWatcher { private getAllWatchedFiles(): string[] { this.extensionWatchedFiles.clear() - const extensionResults = this.app.nonConfigExtensions.map((extension) => ({ + const extensionResults = this.app.realExtensions.map((extension) => ({ extension, watchedFiles: extension.watchedFiles(), })) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index 301f1313c4d..b1e1929f732 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -12,6 +12,7 @@ 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 {DotEnvFile} from '@shopify/cli-kit/node/dot-env' +import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {Writable} from 'stream' export interface ExtensionDevOptions { @@ -112,6 +113,11 @@ 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?: Record } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -133,7 +139,13 @@ 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}) @@ -144,6 +156,14 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) } + // Handle App Assets updates. + const appAssetsConfigs = extensionEvents.map((event) => + event.extension.specification.appAssetsConfig?.(event.extension.configuration), + ) + getArrayRejectingUndefined(appAssetsConfigs).forEach((config) => { + payloadStore.updateAppAssetTimestamp(config.assetsKey) + }) + 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.ts b/packages/app/src/cli/services/dev/extension/payload/store.ts index ae4919485c1..ef2676178ea 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,18 @@ 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 +183,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.ts b/packages/app/src/cli/services/dev/extension/server/middlewares.ts index ed17a0f474d..98b034d63fe 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,20 @@ 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/dev-session/dev-session.ts b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts index 1e9602ce520..f5914193acd 100644 --- a/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts +++ b/packages/app/src/cli/services/dev/processes/dev-session/dev-session.ts @@ -351,6 +351,10 @@ export class DevSession { .filter((event) => event.type !== 'deleted') .map((event) => event.extension.uid) + // PENDING: Clean up. This is a temporary workaround because `admin` is not compatible with inheritedUids in Core. + // It needs to be included in the manifest always. + updatedUids.push('admin') + const nonUpdatedUids = appEvent.app.allExtensions .filter((ext) => !updatedUids.includes(ext.uid)) .map((ext) => ext.uid) 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..935fbc948d4 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -24,6 +24,7 @@ interface PreviewableExtensionOptions { grantedScopes: string[] previewableExtensions: ExtensionInstance[] appWatcher: AppEventWatcher + appAssetsConfigs: Record | undefined } export interface PreviewableExtensionProcess extends BaseProcess { @@ -47,6 +48,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -68,6 +70,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction Date: Fri, 10 Apr 2026 13:26:49 +0200 Subject: [PATCH 2/2] Fix CI: update test expectations, lint formatting, unexport AppAssetsConfig Co-authored-by: Claude Code --- packages/app/src/cli/models/app/app.ts | 10 - .../cli/models/extensions/specification.ts | 13 -- .../models/extensions/specifications/admin.ts | 10 +- .../build/steps/include-assets-step.test.ts | 5 +- .../src/cli/services/dev/extension.test.ts | 14 +- .../app/src/cli/services/dev/extension.ts | 21 +- .../services/dev/extension/payload/models.ts | 1 + .../dev/extension/payload/store.test.ts | 185 +++++++++++++++++- .../services/dev/extension/payload/store.ts | 77 ++++++-- .../dev/extension/server/middlewares.ts | 9 +- .../dev/processes/dev-session/dev-session.ts | 8 +- .../dev/processes/previewable-extension.ts | 13 +- .../dev/processes/setup-dev-processes.test.ts | 2 +- .../dev/processes/setup-dev-processes.ts | 1 - .../ui-extensions-server-kit/src/types.ts | 1 + 15 files changed, 287 insertions(+), 83 deletions(-) diff --git a/packages/app/src/cli/models/app/app.ts b/packages/app/src/cli/models/app/app.ts index c557afbc62b..5c6a5eab81c 100644 --- a/packages/app/src/cli/models/app/app.ts +++ b/packages/app/src/cli/models/app/app.ts @@ -229,7 +229,6 @@ export interface AppInterface< realExtensions: ExtensionInstance[] nonConfigExtensions: ExtensionInstance[] draftableExtensions: ExtensionInstance[] - appAssetsConfigs: Record | undefined errors: AppErrors hiddenConfig: AppHiddenConfig includeConfigOnDeploy: boolean | undefined @@ -335,15 +334,6 @@ export class App< ) } - get appAssetsConfigs(): Record | undefined { - if (!this.realExtensions.some((ext) => ext.specification.appAssetsConfig)) return undefined - return this.realExtensions.reduce>((acc, ext) => { - const config = ext.specification.appAssetsConfig?.(ext.configuration) - if (config) acc[config.assetsKey] = joinPath(this.directory, config.assetsDir) - return acc - }, {}) - } - setDevApplicationURLs(devApplicationURLs: ApplicationURLs) { this.patchAppConfiguration(devApplicationURLs) this.realExtensions.forEach((ext) => ext.patchWithAppDevURLs(devApplicationURLs)) diff --git a/packages/app/src/cli/models/extensions/specification.ts b/packages/app/src/cli/models/extensions/specification.ts index d5a89f377bf..94907693f10 100644 --- a/packages/app/src/cli/models/extensions/specification.ts +++ b/packages/app/src/cli/models/extensions/specification.ts @@ -143,19 +143,6 @@ export interface ExtensionSpecification) => DevSessionWatchConfig | undefined - - /** - * App assets configuration for this extension. - * Return undefined if this extension doesn't serve app assets. - */ - appAssetsConfig?: (config: TConfiguration) => AppAssetsConfig | undefined -} - -export interface AppAssetsConfig { - /** The config key that points to the assets directory (e.g. 'admin.static_root') */ - assetsKey: string - /** The assets directory relative to the extension directory */ - assetsDir: string } export interface DevSessionWatchConfig { diff --git a/packages/app/src/cli/models/extensions/specifications/admin.ts b/packages/app/src/cli/models/extensions/specifications/admin.ts index e166eb5d35e..f5002999372 100644 --- a/packages/app/src/cli/models/extensions/specifications/admin.ts +++ b/packages/app/src/cli/models/extensions/specifications/admin.ts @@ -7,11 +7,12 @@ const AdminSchema = zod.object({ admin: zod .object({ static_root: zod.string().optional(), + allowed_domains: zod.array(zod.string()).optional(), }) .optional(), }) -type AdminConfigType = zod.infer & BaseConfigType +export type AdminConfigType = zod.infer & BaseConfigType const adminSpecificationSpec = createExtensionSpecification({ identifier: 'admin', @@ -33,6 +34,8 @@ const adminSpecificationSpec = createExtensionSpecification({ admin: { // eslint-disable-next-line @typescript-eslint/no-explicit-any static_root: (remoteContent as any).admin.static_root, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + allowed_domains: (remoteContent as any).admin.allowed_domains, }, } }, @@ -62,11 +65,6 @@ const adminSpecificationSpec = createExtensionSpecification({ }, ], appModuleFeatures: () => [], - appAssetsConfig: (config) => { - const dir = config.admin?.static_root - if (!dir) return undefined - return {assetsKey: 'staticRoot', assetsDir: dir} - }, }) export default adminSpecificationSpec diff --git a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts index 6daf807c33a..ac4e89dfaba 100644 --- a/packages/app/src/cli/services/build/steps/include-assets-step.test.ts +++ b/packages/app/src/cli/services/build/steps/include-assets-step.test.ts @@ -1082,10 +1082,7 @@ describe('executeIncludeAssetsStep', () => { // When / Then — overwrites existing manifest.json await expect(executeIncludeAssetsStep(step, contextWithConfig)).resolves.not.toThrow() - expect(fs.writeFile).toHaveBeenCalledWith( - '/test/output/manifest.json', - expect.any(String), - ) + expect(fs.writeFile).toHaveBeenCalledWith('/test/output/manifest.json', expect.any(String)) }) test('writes an empty manifest when anchor resolves to a non-array value', async () => { diff --git a/packages/app/src/cli/services/dev/extension.test.ts b/packages/app/src/cli/services/dev/extension.test.ts index a6b696159bf..f63ce543464 100644 --- a/packages/app/src/cli/services/dev/extension.test.ts +++ b/packages/app/src/cli/services/dev/extension.test.ts @@ -33,7 +33,14 @@ 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', + updateAdminConfigFromExtensionEvents: vi.fn(), + getAppAssets: vi.fn(), + }) as unknown as store.ExtensionsPayloadStore, + ) vi.spyOn(server, 'setupHTTPServer').mockReturnValue({ mock: 'http-server', close: serverCloseSpy, @@ -67,8 +74,9 @@ describe('devUIExtensions()', () => { // THEN expect(server.setupHTTPServer).toHaveBeenCalledWith({ devOptions: {...options, websocketURL: 'wss://mock.url/extensions'}, - payloadStore: {mock: 'payload-store'}, + payloadStore: expect.objectContaining({mock: 'payload-store'}), getExtensions: expect.any(Function), + getAppAssets: expect.any(Function), }) }) @@ -94,7 +102,7 @@ describe('devUIExtensions()', () => { expect(websocket.setupWebsocketConnection).toHaveBeenCalledWith({ ...options, httpServer: expect.objectContaining({mock: 'http-server'}), - payloadStore: {mock: 'payload-store'}, + payloadStore: expect.objectContaining({mock: 'payload-store'}), websocketURL: 'wss://mock.url/extensions', }) }) diff --git a/packages/app/src/cli/services/dev/extension.ts b/packages/app/src/cli/services/dev/extension.ts index b1e1929f732..49aae24d55c 100644 --- a/packages/app/src/cli/services/dev/extension.ts +++ b/packages/app/src/cli/services/dev/extension.ts @@ -12,7 +12,6 @@ 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 {DotEnvFile} from '@shopify/cli-kit/node/dot-env' -import {getArrayRejectingUndefined} from '@shopify/cli-kit/common/array' import {Writable} from 'stream' export interface ExtensionDevOptions { @@ -36,7 +35,8 @@ export interface ExtensionDevOptions { buildDirectory?: string /** - * The extension to be built. + * All real extensions in the app, including non-previewable ones (e.g., admin config). + * Previewable extensions are filtered internally for the UI payload. */ extensions: ExtensionInstance[] @@ -113,11 +113,6 @@ 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?: Record } export async function devUIExtensions(options: ExtensionDevOptions): Promise { @@ -132,14 +127,14 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) const getExtensions = () => { return extensions } outputDebug(`Setting up the UI extensions HTTP server...`, payloadOptions.stdout) - const getAppAssets = () => payloadOptions.appAssets + const getAppAssets = () => payloadStore.getAppAssets() const httpServer = setupHTTPServer({ devOptions: payloadOptions, payloadStore, @@ -156,13 +151,7 @@ export async function devUIExtensions(options: ExtensionDevOptions): Promise ext.isPreviewable) } - // Handle App Assets updates. - const appAssetsConfigs = extensionEvents.map((event) => - event.extension.specification.appAssetsConfig?.(event.extension.configuration), - ) - getArrayRejectingUndefined(appAssetsConfigs).forEach((config) => { - payloadStore.updateAppAssetTimestamp(config.assetsKey) - }) + payloadStore.updateAdminConfigFromExtensionEvents(extensionEvents) for (const event of extensionEvents) { if (!event.extension.isPreviewable) continue 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 9495c31aab6..487f29e58c6 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,7 @@ interface ExtensionsPayloadInterface { url: string mobileUrl: string title: string + allowed_domains?: string[] assets?: { [key: string]: { url: 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..f48e70a04d5 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 @@ -7,8 +7,18 @@ import { import {UIExtensionPayload, ExtensionsEndpointPayload} from './models.js' import * as payload from '../payload.js' import {ExtensionInstance} from '../../../../models/extensions/extension-instance.js' +import {ExtensionEvent} from '../../app-events/app-event-watcher.js' import {beforeEach, describe, expect, test, vi} from 'vitest' +function createAdminExtension(config: {static_root?: string; allowed_domains?: string[]} = {}) { + return { + type: 'admin', + isPreviewable: false, + configuration: {admin: config}, + specification: {}, + } as unknown as ExtensionInstance +} + describe('getExtensionsPayloadStoreRawPayload()', () => { test('returns the raw payload', async () => { // Given @@ -21,7 +31,11 @@ describe('getExtensionsPayloadStoreRawPayload()', () => { appName: 'mock-app-name', url: 'https://mock-url.com', websocketURL: 'wss://mock-websocket-url.com', - extensions: [{}, {}, {}], + extensions: [ + {specification: {}, isPreviewable: true}, + {specification: {}, isPreviewable: true}, + {specification: {}, isPreviewable: true}, + ], storeFqdn: 'mock-store-fqdn.myshopify.com', manifestVersion: '3', } as unknown as ExtensionsPayloadStoreOptions @@ -52,10 +66,86 @@ describe('getExtensionsPayloadStoreRawPayload()', () => { extensions: [{mock: 'extension-payload'}, {mock: 'extension-payload'}, {mock: 'extension-payload'}], }) }) + + test('includes allowed_domains and assets when admin extension is present', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + const adminExt = createAdminExtension({static_root: 'public', allowed_domains: ['https://cdn.example.com']}) + const previewableExt = {specification: {}, isPreviewable: true} as unknown as ExtensionInstance + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [previewableExt, adminExt], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toStrictEqual(['https://cdn.example.com']) + expect(rawPayload.app.assets).toStrictEqual({ + staticRoot: { + url: 'https://tunnel.example.com/extensions/assets/staticRoot/', + lastUpdated: expect.any(Number), + }, + }) + // Admin extension should not appear in the UI extensions payload + expect(rawPayload.extensions).toHaveLength(1) + }) + + test('does not include assets or allowed_domains when no admin extension exists', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [{specification: {}, isPreviewable: true}], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toBeUndefined() + expect(rawPayload.app.assets).toBeUndefined() + }) + + test('includes allowed_domains but not assets when admin has no static_root', async () => { + // Given + vi.spyOn(payload, 'getUIExtensionPayload').mockResolvedValue({mock: 'ext'} as unknown as UIExtensionPayload) + const adminExt = createAdminExtension({allowed_domains: ['https://cdn.example.com']}) + + const options = { + apiKey: 'api-key', + appName: 'my-app', + url: 'https://tunnel.example.com', + websocketURL: 'wss://tunnel.example.com', + extensions: [adminExt], + storeFqdn: 'store.myshopify.com', + manifestVersion: '3', + } as unknown as ExtensionsPayloadStoreOptions + + // When + const rawPayload = await getExtensionsPayloadStoreRawPayload(options, 'bundle-path') + + // Then + expect(rawPayload.app.allowed_domains).toStrictEqual(['https://cdn.example.com']) + expect(rawPayload.app.assets).toBeUndefined() + }) }) describe('ExtensionsPayloadStore()', () => { - const mockOptions = {} as unknown as ExtensionsPayloadStoreOptions + const mockOptions = {extensions: []} as unknown as ExtensionsPayloadStoreOptions test('getRawPayload() returns the raw payload', async () => { // Given @@ -365,4 +455,95 @@ describe('ExtensionsPayloadStore()', () => { expect(onUpdateSpy).not.toHaveBeenCalled() }) }) + + describe('getAppAssets()', () => { + test('returns asset directories when admin extension has static_root', () => { + const adminExt = createAdminExtension({static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, options) + + expect(store.getAppAssets()).toStrictEqual({staticRoot: '/app/public'}) + }) + + test('returns undefined when no admin extension exists', () => { + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, mockOptions) + + expect(store.getAppAssets()).toBeUndefined() + }) + + test('returns undefined when admin extension has no static_root', () => { + const adminExt = createAdminExtension({allowed_domains: ['https://example.com']}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const store = new ExtensionsPayloadStore({extensions: []} as unknown as ExtensionsEndpointPayload, options) + + expect(store.getAppAssets()).toBeUndefined() + }) + }) + + describe('updateAdminConfigFromExtensionEvents()', () => { + test('updates allowed_domains and bumps asset timestamps on admin change', () => { + // Given + const adminExt = createAdminExtension({allowed_domains: ['https://new.example.com'], static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const initialPayload = { + app: { + allowed_domains: ['https://old.example.com'], + assets: {staticRoot: {url: 'https://tunnel/extensions/assets/staticRoot/', lastUpdated: 1000}}, + }, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const store = new ExtensionsPayloadStore(initialPayload, options) + const onUpdateSpy = vi.fn() + store.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + // When + store.updateAdminConfigFromExtensionEvents([{extension: adminExt} as unknown as ExtensionEvent]) + + // Then + const result = store.getRawPayload() + expect(result.app.allowed_domains).toStrictEqual(['https://new.example.com']) + expect(result.app.assets!.staticRoot!.lastUpdated).toBeGreaterThan(1000) + expect(onUpdateSpy).toHaveBeenCalledWith([]) + }) + + test('clears allowed_domains when admin config removes them', () => { + // Given + const adminExt = createAdminExtension({static_root: 'public'}) + const options = {extensions: [adminExt], appDirectory: '/app'} as unknown as ExtensionsPayloadStoreOptions + const initialPayload = { + app: {allowed_domains: ['https://old.example.com'], assets: {}}, + extensions: [], + } as unknown as ExtensionsEndpointPayload + + const store = new ExtensionsPayloadStore(initialPayload, options) + + // When + store.updateAdminConfigFromExtensionEvents([{extension: adminExt} as unknown as ExtensionEvent]) + + // Then + expect(store.getRawPayload().app.allowed_domains).toBeUndefined() + }) + + test('does nothing when no admin extension event is present', () => { + // Given + const store = new ExtensionsPayloadStore( + {app: {allowed_domains: ['https://example.com']}, extensions: []} as unknown as ExtensionsEndpointPayload, + mockOptions, + ) + const onUpdateSpy = vi.fn() + store.on(ExtensionsPayloadStoreEvent.Update, onUpdateSpy) + + const nonAdminEvent = { + extension: {type: 'ui_extension'}, + } as unknown as ExtensionEvent + + // When + store.updateAdminConfigFromExtensionEvents([nonAdminEvent]) + + // Then + expect(store.getRawPayload().app.allowed_domains).toStrictEqual(['https://example.com']) + expect(onUpdateSpy).not.toHaveBeenCalled() + }) + }) }) 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 ef2676178ea..1b9345f5d2a 100644 --- a/packages/app/src/cli/services/dev/extension/payload/store.ts +++ b/packages/app/src/cli/services/dev/extension/payload/store.ts @@ -3,13 +3,30 @@ import {ExtensionDevOptions} from '../../extension.js' import {getUIExtensionPayload, isNewExtensionPointsSchema} from '../payload.js' import {buildAppURLForMobile, buildAppURLForWeb} from '../../../../utilities/app/app-url.js' import {ExtensionInstance} from '../../../../models/extensions/extension-instance.js' +import {AdminConfigType} from '../../../../models/extensions/specifications/admin.js' +import {ExtensionEvent} from '../../app-events/app-event-watcher.js' +import {joinPath} from '@shopify/cli-kit/node/path' import {deepMergeObjects} from '@shopify/cli-kit/common/object' import {outputDebug, outputContent} from '@shopify/cli-kit/node/output' import {EventEmitter} from 'events' export interface ExtensionsPayloadStoreOptions extends ExtensionDevOptions { websocketURL: string - appAssets?: Record +} + +interface AdminConfig { + allowedDomains?: string[] + staticRoot?: string +} + +function getAdminConfig(extensions: ExtensionInstance[]): AdminConfig | undefined { + const adminExtension = extensions.find((ext) => ext.type === 'admin') + if (!adminExtension) return undefined + const admin = (adminExtension.configuration as AdminConfigType).admin + return { + allowedDomains: admin?.allowed_domains, + staticRoot: admin?.static_root, + } } export enum ExtensionsPayloadStoreEvent { @@ -39,30 +56,46 @@ export async function getExtensionsPayloadStoreRawPayload( url: new URL('/extensions/dev-console', options.url).toString(), }, store: options.storeFqdn, - extensions: await Promise.all(options.extensions.map((ext) => getUIExtensionPayload(ext, bundlePath, options))), + extensions: await Promise.all( + options.extensions + .filter((ext) => ext.isPreviewable) + .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(), + // Admin extension contributes app-level config to the payload + const adminConfig = getAdminConfig(options.extensions) + if (adminConfig) { + payload.app.allowed_domains = adminConfig.allowedDomains + if (adminConfig.staticRoot) { + const assetKey = 'staticRoot' + payload.app.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 { private readonly options: ExtensionsPayloadStoreOptions private rawPayload: ExtensionsEndpointPayload + private appAssetDirectories: Record | undefined constructor(rawPayload: ExtensionsEndpointPayload, options: ExtensionsPayloadStoreOptions) { super() this.rawPayload = rawPayload this.options = options + + this.refreshAppAssetDirectories() + } + + getAppAssets(): Record | undefined { + return this.appAssetDirectories } getConnectedPayload() { @@ -183,12 +216,28 @@ 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([]) + updateAdminConfigFromExtensionEvents(extensionEvents: ExtensionEvent[]) { + const adminEvent = extensionEvents.find((event) => event.extension.type === 'admin') + if (!adminEvent) return + + const adminConfig = getAdminConfig([adminEvent.extension]) + this.rawPayload.app.allowed_domains = adminConfig?.allowedDomains + + this.refreshAppAssetDirectories() + if (this.rawPayload.app.assets) { + for (const key of Object.keys(this.rawPayload.app.assets)) { + this.rawPayload.app.assets[key]!.lastUpdated = Date.now() + } } + + this.emitUpdate([]) + } + + private refreshAppAssetDirectories() { + const adminConfig = getAdminConfig(this.options.extensions) + this.appAssetDirectories = adminConfig?.staticRoot + ? {staticRoot: joinPath(this.options.appDirectory, adminConfig.staticRoot)} + : undefined } private emitUpdate(extensionIds: string[]) { 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 98b034d63fe..c7ab7e8967f 100644 --- a/packages/app/src/cli/services/dev/extension/server/middlewares.ts +++ b/packages/app/src/cli/services/dev/extension/server/middlewares.ts @@ -5,7 +5,7 @@ import {getHTML} from '../templates.js' import {getWebSocketUrl} from '../../extension.js' import {fileExists, isDirectory, readFile, findPathUp} from '@shopify/cli-kit/node/fs' import {sendRedirect, defineEventHandler, getRequestHeader, getRouterParams, setResponseHeader} from 'h3' -import {joinPath, dirname, extname, moduleDirectory} from '@shopify/cli-kit/node/path' +import {joinPath, resolvePath, dirname, extname, moduleDirectory} from '@shopify/cli-kit/node/path' import {outputDebug} from '@shopify/cli-kit/node/output' import type {H3Event} from 'h3' @@ -142,8 +142,13 @@ export function getAppAssetsMiddleware(getAppAssets: () => Record event.type !== 'deleted') .map((event) => event.extension.uid) - // PENDING: Clean up. This is a temporary workaround because `admin` is not compatible with inheritedUids in Core. - // It needs to be included in the manifest always. - updatedUids.push('admin') + // WORKAROUND. This is a temporary fix because `admin` is not compatible with inheritedUids in Core. + // It needs to be included in the manifest always if present in the app. + if (appEvent.app.allExtensions.some((ext) => ext.type === 'admin') && !updatedUids.includes('admin')) { + updatedUids.push('admin') + } const nonUpdatedUids = appEvent.app.allExtensions .filter((ext) => !updatedUids.includes(ext.uid)) 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 935fbc948d4..c6e064ba1d4 100644 --- a/packages/app/src/cli/services/dev/processes/previewable-extension.ts +++ b/packages/app/src/cli/services/dev/processes/previewable-extension.ts @@ -22,9 +22,8 @@ interface PreviewableExtensionOptions { appDirectory: string appId?: string grantedScopes: string[] - previewableExtensions: ExtensionInstance[] + allExtensions: ExtensionInstance[] appWatcher: AppEventWatcher - appAssetsConfigs: Record | undefined } export interface PreviewableExtensionProcess extends BaseProcess { @@ -45,10 +44,9 @@ export const launchPreviewableExtensionProcess: DevProcessFunction { await devUIExtensions({ @@ -56,7 +54,7 @@ export const launchPreviewableExtensionProcess: DevProcessFunction & { +}: Omit & { allExtensions: ExtensionInstance[] checkoutCartUrl?: string }): Promise { @@ -95,7 +92,7 @@ export async function setupPreviewableExtensionsProcess({ pathPrefix: '/extensions', port: -1, storeFqdn, - previewableExtensions, + allExtensions, cartUrl, ...options, }, diff --git a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts index add3d387079..aeb6702a4ea 100644 --- a/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts +++ b/packages/app/src/cli/services/dev/processes/setup-dev-processes.test.ts @@ -220,7 +220,7 @@ describe('setup-dev-processes', () => { prefix: 'extensions', options: { apiKey: 'api-key', - previewableExtensions: [previewable], + allExtensions: expect.arrayContaining([previewable]), storeFqdn, proxyUrl: 'https://example.com/proxy', port: expect.any(Number), 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 dd458f4fe48..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 @@ -162,7 +162,6 @@ export async function setupDevProcesses({ appId: remoteApp.id, appDirectory: reloadedApp.directory, appWatcher, - appAssetsConfigs: reloadedApp.appAssetsConfigs, }), developerPlatformClient.supportsDevSessions ? await setupDevSessionProcess({ diff --git a/packages/ui-extensions-server-kit/src/types.ts b/packages/ui-extensions-server-kit/src/types.ts index 43e86fcb2ad..46a09de168f 100644 --- a/packages/ui-extensions-server-kit/src/types.ts +++ b/packages/ui-extensions-server-kit/src/types.ts @@ -182,6 +182,7 @@ export interface App { } supportEmail?: string supportLocales?: string[] + allowed_domains?: string[] assets?: { [key: string]: { url: string